Files
dingtalk-workspace-cli/internal/helpers/doc/jsonml_schema_validate.go

501 lines
14 KiB
Go

//go:build goexperiment.jsonv2
package doc
import (
"fmt"
"sort"
"strings"
)
// ValidateWithSchema 使用 schema 对 AST 进行语义校验 (Layer 2)。
// 仅遍历 IsError == false 的节点。
func ValidateWithSchema(doc *JsonMLDocument, schema *SchemaV2) DiagnosticList {
var diags DiagnosticList
for _, node := range doc.Nodes {
validateSchemaNode(node, nil, schema, &diags)
}
return diags
}
func validateSchemaNode(node *JsonMLNode, parentSchema *TagSchema, schema *SchemaV2, diags *DiagnosticList) {
if node == nil || node.IsError || node.IsText {
return
}
tag := node.Tag
// Check if child is allowed by parent
if parentSchema != nil && !parentSchema.IsAllowedChild(tag) {
*diags = append(*diags, Diagnostic{
Range: node.TagRange,
Severity: SeverityWarning, // 保持现有行为:warning
Code: CodeJSONMLSchemaChildNotAllowed,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("tag %q not in parent's allowed_children", tag),
Context: &DiagnosticContext{
AllowedChildren: parentSchema.AllowedChildren,
},
})
}
tagSchema := schema.TagSchemaFor(tag)
if tagSchema == nil {
candidates := didYouMean(tag, schema.AllTagNames(), 3)
msg := fmt.Sprintf("unknown tag %q", tag)
if len(candidates) > 0 {
msg += fmt.Sprintf(". Did you mean: %s?", strings.Join(candidates, ", "))
}
*diags = append(*diags, Diagnostic{
Range: node.TagRange,
Severity: SeverityWarning, // 保持现有行为:warning
Code: CodeJSONMLSchemaUnknownTag,
Source: SourceJSONMLSchema,
Message: msg,
Context: &DiagnosticContext{
AllValidTags: schema.AllTagNames(),
},
})
return // 无 schema 可参照,不继续校验
}
// Validate attrs
if node.Attrs != nil {
for key, val := range node.Attrs {
spec, known := tagSchema.Attrs[key]
if !known {
// P1: Check if key is a known alias
if canonicalName, isAlias := tagSchema.Aliases[key]; isAlias {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityWarning,
Code: CodeJSONMLSchemaAttrAlias,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("unknown attribute %q on tag %q; %q is a known alias for %q, use %q instead", key, tag, key, canonicalName, canonicalName),
Suggestion: canonicalName,
Context: &DiagnosticContext{
TagName: tag,
AllValidAttrs: tagSchema.AttrKeys(),
},
})
// Still validate the VALUE against the canonical spec
if canonicalSpec, ok := tagSchema.Attrs[canonicalName]; ok {
checkTypeSchema(val, &canonicalSpec, canonicalName, tag, node, diags)
}
continue
}
// Otherwise: regular unknown attr with did-you-mean
candidates := didYouMean(key, tagSchema.AllAttrCandidates(), 3)
msg := fmt.Sprintf("unknown attr %q on tag %q", key, tag)
if len(candidates) > 0 {
msg += fmt.Sprintf(". Did you mean: %s?", strings.Join(candidates, ", "))
}
*diags = append(*diags, Diagnostic{
Range: node.Range, // 使用节点范围(精确 attr range 待后续)
Severity: SeverityWarning,
Code: CodeJSONMLSchemaUnknownAttr,
Source: SourceJSONMLSchema,
Message: msg,
Context: &DiagnosticContext{
TagName: tag,
AllValidAttrs: tagSchema.AttrKeys(),
},
})
continue
}
// P1: Check deprecated
if spec.Deprecated {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityWarning,
Code: CodeJSONMLSchemaDeprecatedAttr,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("attribute %q on tag %q is deprecated", key, tag),
})
}
checkTypeSchema(val, &spec, key, tag, node, diags)
}
}
// P1: Check required attrs
for attrKey, spec := range tagSchema.Attrs {
if spec.Required {
if node.Attrs == nil || node.Attrs[attrKey] == nil {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaRequiredMissing,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("tag %q requires attribute %q", tag, attrKey),
Suggestion: fmt.Sprintf("%q: %s", attrKey, typePlaceholder(&spec)),
Context: &DiagnosticContext{
TagName: tag,
Description: tagSchema.Description,
},
})
}
}
}
// Validate children
for _, child := range node.Children {
if child.IsText {
if !tagSchema.IsAllowedChild("#text") {
*diags = append(*diags, Diagnostic{
Range: child.Range,
Severity: SeverityWarning,
Code: CodeJSONMLSchemaChildNotAllowed,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("bare text not allowed in %q", tag),
Context: &DiagnosticContext{
AllowedChildren: tagSchema.AllowedChildren,
},
})
}
continue
}
validateSchemaNode(child, tagSchema, schema, diags)
}
// P1: CustomValidator hooks
if validators, ok := customValidators[tag]; ok {
for _, v := range validators {
v(node, node.Attrs, tagSchema, func(d Diagnostic) { *diags = append(*diags, d) })
}
}
}
// checkTypeSchema validates a value against a TypeSpec, emitting Diagnostics.
func checkTypeSchema(value any, spec *TypeSpec, attrKey, tagName string, node *JsonMLNode, diags *DiagnosticList) {
attrPath := fmt.Sprintf("%s.attrs.%s", tagName, attrKey)
switch spec.Type {
case "any":
return
case "string":
if _, ok := value.(string); !ok {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: expected string, got %T", attrPath, value),
})
}
case "number":
num, ok := toFloat64(value)
if !ok {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: expected number, got %T", attrPath, value),
})
return
}
if spec.Min != nil && num < *spec.Min {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaNumberRange,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: value %v < min %v", attrPath, num, *spec.Min),
})
}
if spec.Max != nil && num > *spec.Max {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaNumberRange,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: value %v > max %v", attrPath, num, *spec.Max),
})
}
case "boolean":
if _, ok := value.(bool); !ok {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: expected boolean, got %T", attrPath, value),
})
}
case "array":
if _, ok := value.([]any); !ok {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: expected array, got %T", attrPath, value),
})
}
case "object":
obj, ok := value.(map[string]any)
if !ok {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: expected object, got %T", attrPath, value),
})
return
}
if spec.Fields != nil {
for key, val := range obj {
fieldSpec, known := spec.Fields[key]
if !known {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityWarning,
Code: CodeJSONMLSchemaUnknownAttr,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s.%s: unknown field %q", attrPath, key, key),
})
continue
}
checkTypeSchema(val, &fieldSpec, attrKey+"."+key, tagName, node, diags)
}
}
case "enum":
str, ok := value.(string)
if !ok {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: enum expects string, got %T", attrPath, value),
})
return
}
found := false
for _, v := range spec.Values {
if v == str {
found = true
break
}
}
if !found {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError, // P1-j: enum 约束为硬性限制
Code: CodeJSONMLSchemaEnumInvalid,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: value %q not in enum [%s]", attrPath, str, strings.Join(spec.Values, ", ")),
Suggestion: fmt.Sprintf("valid values: [%s]", strings.Join(spec.Values, ", ")),
})
}
case "union":
if matchesUnion(value, spec.Types) {
return
}
if len(spec.WarnTypes) > 0 && matchesUnion(value, spec.WarnTypes) {
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityWarning,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: value (%T) matches warn_types [%s], expected [%s]",
attrPath, value, strings.Join(spec.WarnTypes, ", "), strings.Join(spec.Types, ", ")),
})
return
}
*diags = append(*diags, Diagnostic{
Range: node.Range,
Severity: SeverityError,
Code: CodeJSONMLSchemaTypeMismatch,
Source: SourceJSONMLSchema,
Message: fmt.Sprintf("%s: value (%T) doesn't match any of [%s]",
attrPath, value, strings.Join(spec.Types, ", ")),
})
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Schema 辅助方法
// ──────────────────────────────────────────────────────────────────────────────
// AllTagNames 返回 schema 中所有已知 tag 名称(排序)。
func (s *SchemaV2) AllTagNames() []string {
names := make([]string, 0, len(s.Tags))
for name := range s.Tags {
names = append(names, name)
}
sort.Strings(names)
return names
}
// AttrKeys 返回 TagSchema 中所有已知 attr key(排序)。
func (ts *TagSchema) AttrKeys() []string {
keys := make([]string, 0, len(ts.Attrs))
for k := range ts.Attrs {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// AllAttrCandidates 返回 canonical attr names + alias names(排序)。
// 用于 did-you-mean 候选集,当 attr 既不在 Attrs 也不在 Aliases 时,将两者合并提供近似建议。
func (ts *TagSchema) AllAttrCandidates() []string {
keys := ts.AttrKeys()
for alias := range ts.Aliases {
keys = append(keys, alias)
}
sort.Strings(keys)
return keys
}
// ──────────────────────────────────────────────────────────────────────────────
// did-you-mean (Levenshtein 编辑距离)
// ──────────────────────────────────────────────────────────────────────────────
// didYouMean 返回编辑距离最近的 top-N 候选。
// 阈值: 编辑距离 <= max(2, len(input)/3) 才纳入。
func didYouMean(input string, candidates []string, topN int) []string {
if len(candidates) == 0 || input == "" {
return nil
}
threshold := len(input) / 3
if threshold < 2 {
threshold = 2
}
type scored struct {
name string
dist int
}
var matches []scored
for _, c := range candidates {
d := levenshtein(input, c)
if d <= threshold {
matches = append(matches, scored{c, d})
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].dist < matches[j].dist
})
if len(matches) > topN {
matches = matches[:topN]
}
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m.name
}
return result
}
// levenshtein 计算两个字符串之间的编辑距离。
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
// 使用单行 DP
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// typePlaceholder generates a placeholder value string for a TypeSpec.
// Used to auto-derive Suggestion content.
func typePlaceholder(spec *TypeSpec) string {
switch spec.Type {
case "string":
return `"<string>"`
case "number":
if spec.Min != nil {
return fmt.Sprintf("%v", *spec.Min)
}
return "0"
case "boolean":
return "true"
case "array":
return "[]"
case "object":
if len(spec.Fields) > 0 {
parts := make([]string, 0, len(spec.Fields))
for k, f := range spec.Fields {
parts = append(parts, fmt.Sprintf("%q: %s", k, typePlaceholder(&f)))
}
sort.Strings(parts)
return "{" + strings.Join(parts, ", ") + "}"
}
return "{}"
case "enum":
if len(spec.Values) > 0 {
return fmt.Sprintf("%q", spec.Values[0])
}
return `"<enum>"`
case "union":
if len(spec.Types) > 0 {
return unionFirstPlaceholder(spec.Types[0])
}
return "null"
case "any":
return "null"
default:
return "null"
}
}
// unionFirstPlaceholder returns placeholder for the first type in a union.
func unionFirstPlaceholder(typeName string) string {
switch typeName {
case "string":
return `"<string>"`
case "number":
return "0"
case "boolean":
return "true"
case "array":
return "[]"
case "object":
return "{}"
case "null":
return "null"
default:
return "null"
}
}