242 lines
5.9 KiB
Go
242 lines
5.9 KiB
Go
//go:build !goexperiment.jsonv2
|
|
|
|
package doc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ValidateJsonMLBodyV2 validates a JSONML body using schema-v2.
|
|
// Only type mismatches are errors; everything else is a warning.
|
|
func ValidateJsonMLBodyV2(body []any) *JsonMLValidationResult {
|
|
r := &JsonMLValidationResult{}
|
|
if len(body) == 0 {
|
|
return r
|
|
}
|
|
|
|
// Root-wrapped, single block node, or future/unknown single node. If the
|
|
// first element is a string, the JSONML shape is a node; unknown tags should
|
|
// be warnings, not a cascade of "node must be array" errors.
|
|
if _, ok := body[0].(string); ok {
|
|
validateNodeV2(body, "$", nil, r)
|
|
return r
|
|
}
|
|
|
|
// Array of blocks
|
|
for i, node := range body {
|
|
nodePath := fmt.Sprintf("$[%d]", i)
|
|
if arr, ok := node.([]any); ok && len(arr) > 0 {
|
|
if t, ok := arr[0].(string); ok {
|
|
nodePath = fmt.Sprintf("$[%d:%s]", i, t)
|
|
}
|
|
}
|
|
validateNodeV2(node, nodePath, nil, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ValidateJsonMLNodeV2 validates a single JSONML node using schema-v2.
|
|
func ValidateJsonMLNodeV2(node any) *JsonMLValidationResult {
|
|
r := &JsonMLValidationResult{}
|
|
validateNodeV2(node, "$", nil, r)
|
|
return r
|
|
}
|
|
|
|
// ValidateJsonMLSource is a fallback for non-jsonv2 builds.
|
|
// It unmarshals the source and delegates to ValidateJsonMLBodyV2.
|
|
// Positions in diagnostics will not be source-accurate in this build.
|
|
func ValidateJsonMLSource(src []byte) *JsonMLValidationResult {
|
|
if len(src) == 0 {
|
|
return &JsonMLValidationResult{}
|
|
}
|
|
var body any
|
|
if err := json.Unmarshal(src, &body); err != nil {
|
|
r := &JsonMLValidationResult{}
|
|
r.Errors = append(r.Errors, "JSON parse error: "+err.Error())
|
|
return r
|
|
}
|
|
switch v := body.(type) {
|
|
case []any:
|
|
return ValidateJsonMLBodyV2(v)
|
|
case map[string]any:
|
|
if jml, ok := v["jsonml"]; ok {
|
|
if arr, ok := jml.([]any); ok {
|
|
return ValidateJsonMLBodyV2(arr)
|
|
}
|
|
}
|
|
}
|
|
r := &JsonMLValidationResult{}
|
|
r.Errors = append(r.Errors, "input is not a valid JSONML array or {\"jsonml\": [...]} wrapper")
|
|
return r
|
|
}
|
|
|
|
func validateNodeV2(node any, path string, parentSchema *TagSchema, r *JsonMLValidationResult) {
|
|
arr, ok := node.([]any)
|
|
if !ok {
|
|
r.addError(path, fmt.Sprintf("node must be array, got %T", node), "")
|
|
return
|
|
}
|
|
if len(arr) < 1 {
|
|
r.addError(path, "node array must not be empty", "")
|
|
return
|
|
}
|
|
|
|
tag, ok := arr[0].(string)
|
|
if !ok {
|
|
r.addError(path, fmt.Sprintf("tag must be string, got %T", arr[0]), "")
|
|
return
|
|
}
|
|
|
|
// Check if child is allowed by parent
|
|
if parentSchema != nil && !parentSchema.IsAllowedChild(tag) {
|
|
r.addWarn(path,
|
|
fmt.Sprintf("tag %q not in parent's allowed_children", tag), "")
|
|
}
|
|
|
|
tagSchema := schemaV2.TagSchemaFor(tag)
|
|
if tagSchema == nil {
|
|
r.addWarn(path, fmt.Sprintf("unknown tag %q", tag), "")
|
|
return
|
|
}
|
|
|
|
// Extract attrs
|
|
childStart := 1
|
|
var attrs map[string]any
|
|
if len(arr) > 1 {
|
|
if m, ok := arr[1].(map[string]any); ok {
|
|
attrs = m
|
|
childStart = 2
|
|
}
|
|
}
|
|
|
|
// Validate attrs
|
|
for key, val := range attrs {
|
|
spec, known := tagSchema.Attrs[key]
|
|
if !known {
|
|
r.addWarn(path+".attrs."+key,
|
|
fmt.Sprintf("unknown attr %q", key), "")
|
|
continue
|
|
}
|
|
checkTypeV2(val, &spec, path+".attrs."+key, r)
|
|
}
|
|
|
|
// Validate children
|
|
for i := childStart; i < len(arr); i++ {
|
|
child := arr[i]
|
|
childPath := fmt.Sprintf("%s[%d]", path, i)
|
|
switch c := child.(type) {
|
|
case string:
|
|
if !tagSchema.IsAllowedChild("#text") {
|
|
r.addWarn(childPath,
|
|
fmt.Sprintf("bare text not allowed in %q", tag), "")
|
|
}
|
|
_ = c
|
|
case []any:
|
|
if len(c) > 0 {
|
|
if childTag, ok := c[0].(string); ok {
|
|
childPath = fmt.Sprintf("%s[%d:%s]", path, i, childTag)
|
|
}
|
|
}
|
|
validateNodeV2(child, childPath, tagSchema, r)
|
|
default:
|
|
// null, number etc — skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkTypeV2 validates a value against a TypeSpec.
|
|
// Type and enum mismatches are blocking schema errors.
|
|
func checkTypeV2(value any, spec *TypeSpec, path string, r *JsonMLValidationResult) {
|
|
switch spec.Type {
|
|
case "any":
|
|
return
|
|
|
|
case "string":
|
|
if _, ok := value.(string); !ok {
|
|
r.addError(path,
|
|
fmt.Sprintf("expected string, got %T", value), "")
|
|
}
|
|
|
|
case "number":
|
|
num, ok := toFloat64(value)
|
|
if !ok {
|
|
r.addError(path,
|
|
fmt.Sprintf("expected number, got %T", value), "")
|
|
return
|
|
}
|
|
if spec.Min != nil && num < *spec.Min {
|
|
r.addError(path,
|
|
fmt.Sprintf("value %v < min %v", num, *spec.Min), "")
|
|
}
|
|
if spec.Max != nil && num > *spec.Max {
|
|
r.addError(path,
|
|
fmt.Sprintf("value %v > max %v", num, *spec.Max), "")
|
|
}
|
|
|
|
case "boolean":
|
|
if _, ok := value.(bool); !ok {
|
|
r.addError(path,
|
|
fmt.Sprintf("expected boolean, got %T", value), "")
|
|
}
|
|
|
|
case "array":
|
|
if _, ok := value.([]any); !ok {
|
|
r.addError(path,
|
|
fmt.Sprintf("expected array, got %T", value), "")
|
|
}
|
|
|
|
case "object":
|
|
obj, ok := value.(map[string]any)
|
|
if !ok {
|
|
r.addError(path,
|
|
fmt.Sprintf("expected object, got %T", value), "")
|
|
return
|
|
}
|
|
// Deep validation if fields defined
|
|
if spec.Fields != nil {
|
|
for key, val := range obj {
|
|
fieldSpec, known := spec.Fields[key]
|
|
if !known {
|
|
r.addWarn(path+"."+key,
|
|
fmt.Sprintf("unknown field %q", key), "")
|
|
continue
|
|
}
|
|
checkTypeV2(val, &fieldSpec, path+"."+key, r)
|
|
}
|
|
}
|
|
|
|
case "enum":
|
|
str, ok := value.(string)
|
|
if !ok {
|
|
r.addError(path,
|
|
fmt.Sprintf("enum expects string, got %T", value), "")
|
|
return
|
|
}
|
|
found := false
|
|
for _, v := range spec.Values {
|
|
if v == str {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
r.addError(path,
|
|
fmt.Sprintf("value %q not in enum [%s]", str, strings.Join(spec.Values, ", ")), "")
|
|
}
|
|
|
|
case "union":
|
|
if matchesUnion(value, spec.Types) {
|
|
return
|
|
}
|
|
if len(spec.WarnTypes) > 0 && matchesUnion(value, spec.WarnTypes) {
|
|
r.addWarn(path,
|
|
fmt.Sprintf("value (%T) matches warn_types [%s], expected [%s]", value, strings.Join(spec.WarnTypes, ", "), strings.Join(spec.Types, ", ")), "")
|
|
return
|
|
}
|
|
r.addError(path,
|
|
fmt.Sprintf("value (%T) doesn't match any of [%s]", value, strings.Join(spec.Types, ", ")), "")
|
|
}
|
|
}
|