diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b9363..a88dcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Schema compile diagnostics are now located: `SchemaError.inconsistent` carries `[ValidationError]` with coding paths that resolve to `TreeNode.sourceRange` on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; `SchemaError.inconsistentFindings` exposes the full list. Schematron reuses the shared `validationCodingPath()` helper. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`. The derivation/cycle-pass checkers, which report a cycle by component name over a name-keyed graph, now record the declaring node alongside that graph and underline it: a type derived from itself underlines its `complexType`/`simpleType`, an element that is a member of its own substitution group underlines its `element`, a model group or attribute group that references itself underlines its `group`/`attributeGroup` declaration, and an all-group referenced inside a `sequence`/`choice`/`all` underlines the offending `group` reference. +- Schema compile diagnostics are now located: `SchemaError.inconsistent` carries `[ValidationError]` with coding paths that resolve to `TreeNode.sourceRange` on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; `SchemaError.inconsistentFindings` exposes the full list. Schematron reuses the shared `validationCodingPath()` helper. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`. The derivation/cycle-pass checkers, which report a cycle by component name over a name-keyed graph, now record the declaring node alongside that graph and underline it: a type derived from itself underlines its `complexType`/`simpleType`, an element that is a member of its own substitution group underlines its `element`, a model group or attribute group that references itself underlines its `group`/`attributeGroup` declaration, and an all-group referenced inside a `sequence`/`choice`/`all` underlines the offending `group` reference. The content-derivation-pass checkers (the `cos-ct-extends`/`src-ct` base-kind family) follow: a complexContent extension adding element content over a simpleContent base, a simpleContent restriction whose base is a built-in or user simple type, an unfaithful inline type in a simpleContent restriction, a complexContent base that is a simple type, a simpleContent extension whose base is `xs:anyType`, and a mismatched `mixed` setting on a complexContent extension each underline their `extension`/`restriction` derivation node, and an element carrying a `default`/`fixed` value over element-only or empty content underlines the `element`. - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index 500d613..e7e6bf3 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -358,15 +358,13 @@ extension PureXML.Validation.SchemaCompile { static var complexExtensionBaseValid: PureXML.Validation.Validation { compileRule("A content-model derivation is consistent with its base type") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.simpleContentExtensionBaseErrors(document.schema, document.context, namedTypes) - + PureXML.Schema.XSDParser.extensionMixedAgreementErrors(document.schema, document.context, namedTypes) - + PureXML.Schema.XSDParser.simpleContentRestrictionBaseErrors(document.schema, document.context, namedTypes) - + PureXML.Schema.XSDParser.simpleContentRestrictionTypeErrors(document.schema, document.context, namedTypes) - + PureXML.Schema.XSDParser.complexContentBaseKindErrors(document.schema, document.context, namedTypes) - + PureXML.Schema.XSDParser.simpleContentExtensionBaseKindErrors(document.schema, document.context, namedTypes) - + PureXML.Schema.XSDParser.elementValueConstraintContentErrors(document.schema, document.context, namedTypes), - ) + return PureXML.Schema.XSDParser.simpleContentExtensionBaseFindings(document.schema, document.context, namedTypes) + + PureXML.Schema.XSDParser.extensionMixedAgreementFindings(document.schema, document.context, namedTypes) + + PureXML.Schema.XSDParser.simpleContentRestrictionBaseFindings(document.schema, document.context, namedTypes) + + PureXML.Schema.XSDParser.simpleContentRestrictionTypeFindings(document.schema, document.context, namedTypes) + + PureXML.Schema.XSDParser.complexContentBaseKindFindings(document.schema, document.context, namedTypes) + + PureXML.Schema.XSDParser.simpleContentExtensionBaseKindFindings(document.schema, document.context, namedTypes) + + PureXML.Schema.XSDParser.elementValueConstraintContentFindings(document.schema, document.context, namedTypes) } } } diff --git a/Sources/Schema/SchemaContentDerivation.swift b/Sources/Schema/SchemaContentDerivation.swift new file mode 100644 index 0000000..e05b7ac --- /dev/null +++ b/Sources/Schema/SchemaContentDerivation.swift @@ -0,0 +1,294 @@ +private typealias ContentDerivNode = PureXML.Schema.XSDNode + +extension PureXML.Schema.XSDParser { + /// XSD 1.0 `cos-ct-extends.1.4.2.2`: a `complexContent` extension that adds + /// element content (a model group) produces an element-only/mixed content type, + /// so its base must have complex or empty content. A `simpleContent` base has a + /// simple content type and cannot gain element content this way. An extension + /// that adds only attributes keeps the base's simple content and is valid (it + /// is the `complexContent`-around-`simpleContent` idiom), so the model group is + /// required for the error. Resolved in this schema's own target namespace. + static func simpleContentExtensionBaseFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + let bindings = context.namespaceBindings + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for content in descendants(schema, named: "complexContent") { + guard let ext = ContentDerivNode.firstChild(content, named: "extension"), + extensionAddsElementContent(ext), + let base = ContentDerivNode.attribute(ext, "base"), + ContentDerivNode.referenceNamespace(base, bindings) == context.targetNamespace, + case let .complex(complex)? = types[ContentDerivNode.stripPrefix(base)], + case .simpleContent = complex.content + else { continue } + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a complexContent extension that adds element content may not extend '\(ContentDerivNode.stripPrefix(base))', which has simple content", + node: ext, + )) + } + return findings + } + + /// XSD 1.0 `src-ct.2` / Derivation Valid (Restriction): the base of a + /// `simpleContent` RESTRICTION must be a complex type (whose content is simple). + /// A built-in (`xs:integer`, `xs:anySimpleType`) or a user `simpleType` base is + /// invalid: a complex type with simple content is built by EXTENDING a simple + /// type, never by restricting one through `simpleContent`. Self-contained schema + /// only; a foreign or unresolved base is left alone. + static func simpleContentRestrictionBaseFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + let bindings = context.namespaceBindings + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for content in descendants(schema, named: "simpleContent") { + guard let restriction = ContentDerivNode.firstChild(content, named: "restriction"), + let base = ContentDerivNode.attribute(restriction, "base") + else { continue } + let namespace = ContentDerivNode.referenceNamespace(base, bindings) + let baseName = ContentDerivNode.stripPrefix(base) + if namespace == xsdNamespace { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a simpleContent restriction's base must be a complex type, not the built-in simple type '\(baseName)'", + node: restriction, + )) + } else if namespace == context.targetNamespace, case .simple? = types[baseName] { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a simpleContent restriction's base must be a complex type, not the simple type '\(baseName)'", + node: restriction, + )) + } + } + return findings + } + + /// XSD 1.0 Derivation Valid (Restriction, Simple): when a `simpleContent` + /// restriction supplies an inline `simpleType`, that inline type must itself be + /// a valid restriction of the base complex type's simple content type. A list + /// of `xs:int`, for example, is not a restriction of `xs:decimal` because it + /// changes the variety from atomic to list (W3C msData particlesZ018). + /// + /// Only local, resolved complex bases with simple content are checked. Built-in, + /// foreign, unresolved, and composed bases are left to the surrounding base-kind + /// and cross-document rules so this cannot reject a schema whose base type is + /// unknown to this compile pass. + static func simpleContentRestrictionTypeFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for content in descendants(schema, named: "simpleContent") { + guard let restriction = ContentDerivNode.firstChild(content, named: "restriction"), + let inline = ContentDerivNode.firstChild(restriction, named: "simpleType"), + let base = ContentDerivNode.attribute(restriction, "base") + else { continue } + let bindings = namespaceBindingsInScope(of: restriction, defaultBindings: context.namespaceBindings) + let namespace = ContentDerivNode.referenceNamespace(base, bindings) + let baseName = ContentDerivNode.stripPrefix(base) + guard namespace == context.targetNamespace, + case let .complex(complex)? = types[baseName], + case let .simpleContent(baseSimpleType) = complex.content + else { continue } + let derivedSimpleType = scopedSimpleType(inline, context) + if !isSimpleTypeRestrictionOK(derived: derivedSimpleType, base: baseSimpleType) { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the inline simpleType in a simpleContent restriction is not a valid restriction of base type '\(baseName)'", + node: restriction, + )) + } + } + return findings + } + + /// XSD 1.0 `src-ct.1`: the base of a `complexContent` derivation (restriction or + /// extension) must be a COMPLEX type. The ur-type `xs:anyType` is complex and is + /// the legitimate base of most complex types; every other XSD built-in, and a + /// user `simpleType`, is a simple type and may not be a complexContent base. A + /// LOCALLY-declared type takes precedence over the built-in reading: a schema may + /// target the XSD namespace and define its own components there (the + /// schema-for-schemas extends its own `xs:openAttrs`), so resolve `types` first + /// and only fall back to the built-in table when there is no local declaration. + /// Self-contained schema only; a foreign or unresolved base is left alone. + static func complexContentBaseKindFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + let bindings = context.namespaceBindings + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for content in descendants(schema, named: "complexContent") { + guard let derivation = ContentDerivNode.firstChild(content, named: "extension") + ?? ContentDerivNode.firstChild(content, named: "restriction"), + let base = ContentDerivNode.attribute(derivation, "base") + else { continue } + let namespace = ContentDerivNode.referenceNamespace(base, bindings) + let baseName = ContentDerivNode.stripPrefix(base) + if namespace == context.targetNamespace, let local = types[baseName] { + if case .simple = local { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a complexContent base must be a complex type, not the simple type '\(baseName)'", + node: derivation, + )) + } + } else if namespace == xsdNamespace, types[baseName] == nil, baseName != "anyType" { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a complexContent base must be a complex type, not the built-in simple type '\(baseName)'", + node: derivation, + )) + } + } + return findings + } + + /// XSD 1.0 `src-ct.2` (extension): the base of a `simpleContent` EXTENSION must be + /// a simple type or a complex type whose content type is simple. A complex type + /// with element-only, mixed, or empty content (including the ur-type + /// `xs:anyType`) may not be a simpleContent extension base, since the result must + /// have simple content. A LOCALLY-declared type takes precedence over the + /// built-in reading (a schema may target the XSD namespace), so resolve `types` + /// first. A simple-type base (built-in or user) is valid; only `xs:anyType` as a + /// built-in, or a local complex type without simple content, is an error. + /// Self-contained schema only; a foreign or unresolved base is left alone. + static func simpleContentExtensionBaseKindFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + let bindings = context.namespaceBindings + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for content in descendants(schema, named: "simpleContent") { + guard let ext = ContentDerivNode.firstChild(content, named: "extension"), + let base = ContentDerivNode.attribute(ext, "base") + else { continue } + let namespace = ContentDerivNode.referenceNamespace(base, bindings) + let baseName = ContentDerivNode.stripPrefix(base) + if namespace == context.targetNamespace, case let .complex(complex)? = types[baseName] { + switch complex.content { + case .simpleContent: break + case .mixed, .elementOnly, .empty: + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a simpleContent extension's base complex type '\(baseName)' must have simple content", + node: ext, + )) + } + } else if namespace == xsdNamespace, types[baseName] == nil, baseName == "anyType" { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a simpleContent extension's base must be a simple type or a complex type with simple content, not '\(baseName)'", + node: ext, + )) + } + } + return findings + } + + /// XSD 1.0 `cos-valid-default` / `e-props-correct`: an element with a `default` + /// or `fixed` value constraint must have a content type that admits character + /// data, i.e. a simple type or mixed content. An element whose type is (or whose + /// inline complex type has) element-only or empty content may not carry a value + /// constraint. Conservative by construction: it flags only a content type that is + /// CERTAINLY element-only or empty. A simple type, mixed content, the ur-type + /// `xs:anyType` (mixed), an untyped element (also `anyType`), a complexContent + /// derivation (mixedness may be inherited), and a foreign or unresolved type are + /// all left alone, so this cannot raise a false positive. Self-contained schema. + static func elementValueConstraintContentFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + let bindings = context.namespaceBindings + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for element in descendants(schema, named: "element") where element.name?.namespaceURI == xsdNamespace { + guard ContentDerivNode.attribute(element, "fixed") != nil || ContentDerivNode.attribute(element, "default") != nil, + valueConstraintForbiddenByContent(element, bindings, context, types) + else { continue } + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "an element with a 'default' or 'fixed' value must have simple or mixed content, not element-only or empty content", + node: element, + )) + } + return findings + } + + /// Whether `element`'s content type is certainly element-only or empty, so a + /// value constraint on it is invalid. Returns false (no error) for anything that + /// admits character data or that cannot be resolved with certainty. + private static func valueConstraintForbiddenByContent( + _ element: XSDTree, + _ bindings: [String: String], + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> Bool { + if let inline = ContentDerivNode.firstChild(element, named: "complexType") { + if ContentDerivNode.firstChild(inline, named: "simpleContent") != nil { return false } + if ["true", "1"].contains(ContentDerivNode.attribute(inline, "mixed")) { return false } + if ContentDerivNode.firstChild(inline, named: "complexContent") != nil { return false } + return ["all", "sequence", "choice", "group"].contains { ContentDerivNode.firstChild(inline, named: $0) != nil } + } + guard let typeName = ContentDerivNode.attribute(element, "type") else { return false } + let namespace = ContentDerivNode.referenceNamespace(typeName, bindings) + let local = ContentDerivNode.stripPrefix(typeName) + if namespace == xsdNamespace { return false } + guard namespace == context.targetNamespace, case let .complex(complex)? = types[local] else { return false } + switch complex.content { + case .elementOnly, .empty: return true + case .simpleContent, .mixed: return false + } + } + + /// XSD 1.0 `cos-ct-extends.1.4.3.2.2.1`: an explicit `mixed` on a + /// `complexContent` extension must match the base. If `mixed` is omitted, an + /// extension with no element particle inherits the base content unchanged + /// (`ctZ012b`), while an extension that adds element content uses the schema + /// default `false` (`ctZ010d`). Checked for a self-contained schema, and only + /// when the base content is definitely element-only or mixed (an empty or + /// simple-content base is governed by other rules and is left alone). + static func extensionMixedAgreementFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } + let bindings = context.namespaceBindings + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + for content in descendants(schema, named: "complexContent") { + guard let ext = ContentDerivNode.firstChild(content, named: "extension"), + let base = ContentDerivNode.attribute(ext, "base"), + ContentDerivNode.referenceNamespace(base, bindings) == context.targetNamespace, + case let .complex(complex)? = types[ContentDerivNode.stripPrefix(base)] + else { continue } + let baseIsMixed: Bool + switch complex.content { + case .mixed: baseIsMixed = true + case .elementOnly: baseIsMixed = false + case .empty, .simpleContent: continue + } + guard let derivedMixed = declaredMixed(content, addsElementContent: extensionAddsElementContent(ext)) else { continue } + if baseIsMixed != derivedMixed { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a complexContent extension of '\(ContentDerivNode.stripPrefix(base))' must have the same mixed setting as its base", + node: ext, + )) + } + } + return findings + } + + /// The declared `mixed` value of a `complexContent` derivation. An omitted + /// value matters only when the extension adds element content; otherwise the + /// base content is inherited unchanged and there is no mixedness conflict. + private static func declaredMixed(_ complexContent: XSDTree, addsElementContent: Bool) -> Bool? { + if let own = ContentDerivNode.attribute(complexContent, "mixed") { return own == "true" || own == "1" } + if let parent = complexContent.parent, let outer = ContentDerivNode.attribute(parent, "mixed") { return outer == "true" || outer == "1" } + return addsElementContent ? false : nil + } +} diff --git a/Sources/Schema/SchemaExtensionAllGroup.swift b/Sources/Schema/SchemaExtensionAllGroup.swift index ad23bb1..1a5fd28 100644 --- a/Sources/Schema/SchemaExtensionAllGroup.swift +++ b/Sources/Schema/SchemaExtensionAllGroup.swift @@ -89,244 +89,12 @@ extension PureXML.Schema.XSDParser { } } - /// XSD 1.0 `cos-ct-extends.1.4.2.2`: a `complexContent` extension that adds - /// element content (a model group) produces an element-only/mixed content type, - /// so its base must have complex or empty content. A `simpleContent` base has a - /// simple content type and cannot gain element content this way. An extension - /// that adds only attributes keeps the base's simple content and is valid (it - /// is the `complexContent`-around-`simpleContent` idiom), so the model group is - /// required for the error. Resolved in this schema's own target namespace. - static func simpleContentExtensionBaseErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - let bindings = context.namespaceBindings - var errors: [String] = [] - for content in descendants(schema, named: "complexContent") { - guard let ext = ExtAllNode.firstChild(content, named: "extension"), - extensionAddsElementContent(ext), - let base = ExtAllNode.attribute(ext, "base"), - ExtAllNode.referenceNamespace(base, bindings) == context.targetNamespace, - case let .complex(complex)? = types[ExtAllNode.stripPrefix(base)], - case .simpleContent = complex.content - else { continue } - errors.append("a complexContent extension that adds element content may not extend '\(ExtAllNode.stripPrefix(base))', which has simple content") - } - return errors - } - - /// XSD 1.0 `src-ct.2` / Derivation Valid (Restriction): the base of a - /// `simpleContent` RESTRICTION must be a complex type (whose content is simple). - /// A built-in (`xs:integer`, `xs:anySimpleType`) or a user `simpleType` base is - /// invalid: a complex type with simple content is built by EXTENDING a simple - /// type, never by restricting one through `simpleContent`. Self-contained schema - /// only; a foreign or unresolved base is left alone. - static func simpleContentRestrictionBaseErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - let bindings = context.namespaceBindings - var errors: [String] = [] - for content in descendants(schema, named: "simpleContent") { - guard let restriction = ExtAllNode.firstChild(content, named: "restriction"), - let base = ExtAllNode.attribute(restriction, "base") - else { continue } - let namespace = ExtAllNode.referenceNamespace(base, bindings) - let baseName = ExtAllNode.stripPrefix(base) - if namespace == xsdNamespace { - errors.append("a simpleContent restriction's base must be a complex type, not the built-in simple type '\(baseName)'") - } else if namespace == context.targetNamespace, case .simple? = types[baseName] { - errors.append("a simpleContent restriction's base must be a complex type, not the simple type '\(baseName)'") - } - } - return errors - } - - /// XSD 1.0 Derivation Valid (Restriction, Simple): when a `simpleContent` - /// restriction supplies an inline `simpleType`, that inline type must itself be - /// a valid restriction of the base complex type's simple content type. A list - /// of `xs:int`, for example, is not a restriction of `xs:decimal` because it - /// changes the variety from atomic to list (W3C msData particlesZ018). - /// - /// Only local, resolved complex bases with simple content are checked. Built-in, - /// foreign, unresolved, and composed bases are left to the surrounding base-kind - /// and cross-document rules so this cannot reject a schema whose base type is - /// unknown to this compile pass. - static func simpleContentRestrictionTypeErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - var errors: [String] = [] - for content in descendants(schema, named: "simpleContent") { - guard let restriction = ExtAllNode.firstChild(content, named: "restriction"), - let inline = ExtAllNode.firstChild(restriction, named: "simpleType"), - let base = ExtAllNode.attribute(restriction, "base") - else { continue } - let bindings = namespaceBindingsInScope(of: restriction, defaultBindings: context.namespaceBindings) - let namespace = ExtAllNode.referenceNamespace(base, bindings) - let baseName = ExtAllNode.stripPrefix(base) - guard namespace == context.targetNamespace, - case let .complex(complex)? = types[baseName], - case let .simpleContent(baseSimpleType) = complex.content - else { continue } - let derivedSimpleType = scopedSimpleType(inline, context) - if !isSimpleTypeRestrictionOK(derived: derivedSimpleType, base: baseSimpleType) { - errors.append("the inline simpleType in a simpleContent restriction is not a valid restriction of base type '\(baseName)'") - } - } - return errors - } - - /// XSD 1.0 `src-ct.1`: the base of a `complexContent` derivation (restriction or - /// extension) must be a COMPLEX type. The ur-type `xs:anyType` is complex and is - /// the legitimate base of most complex types; every other XSD built-in, and a - /// user `simpleType`, is a simple type and may not be a complexContent base. A - /// LOCALLY-declared type takes precedence over the built-in reading: a schema may - /// target the XSD namespace and define its own components there (the - /// schema-for-schemas extends its own `xs:openAttrs`), so resolve `types` first - /// and only fall back to the built-in table when there is no local declaration. - /// Self-contained schema only; a foreign or unresolved base is left alone. - static func complexContentBaseKindErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - let bindings = context.namespaceBindings - var errors: [String] = [] - for content in descendants(schema, named: "complexContent") { - guard let derivation = ExtAllNode.firstChild(content, named: "extension") - ?? ExtAllNode.firstChild(content, named: "restriction"), - let base = ExtAllNode.attribute(derivation, "base") - else { continue } - let namespace = ExtAllNode.referenceNamespace(base, bindings) - let baseName = ExtAllNode.stripPrefix(base) - if namespace == context.targetNamespace, let local = types[baseName] { - if case .simple = local { - errors.append("a complexContent base must be a complex type, not the simple type '\(baseName)'") - } - } else if namespace == xsdNamespace, types[baseName] == nil, baseName != "anyType" { - errors.append("a complexContent base must be a complex type, not the built-in simple type '\(baseName)'") - } - } - return errors - } - - /// XSD 1.0 `src-ct.2` (extension): the base of a `simpleContent` EXTENSION must be - /// a simple type or a complex type whose content type is simple. A complex type - /// with element-only, mixed, or empty content (including the ur-type - /// `xs:anyType`) may not be a simpleContent extension base, since the result must - /// have simple content. A LOCALLY-declared type takes precedence over the - /// built-in reading (a schema may target the XSD namespace), so resolve `types` - /// first. A simple-type base (built-in or user) is valid; only `xs:anyType` as a - /// built-in, or a local complex type without simple content, is an error. - /// Self-contained schema only; a foreign or unresolved base is left alone. - static func simpleContentExtensionBaseKindErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - let bindings = context.namespaceBindings - var errors: [String] = [] - for content in descendants(schema, named: "simpleContent") { - guard let ext = ExtAllNode.firstChild(content, named: "extension"), - let base = ExtAllNode.attribute(ext, "base") - else { continue } - let namespace = ExtAllNode.referenceNamespace(base, bindings) - let baseName = ExtAllNode.stripPrefix(base) - if namespace == context.targetNamespace, case let .complex(complex)? = types[baseName] { - switch complex.content { - case .simpleContent: break - case .mixed, .elementOnly, .empty: - errors.append("a simpleContent extension's base complex type '\(baseName)' must have simple content") - } - } else if namespace == xsdNamespace, types[baseName] == nil, baseName == "anyType" { - errors.append("a simpleContent extension's base must be a simple type or a complex type with simple content, not '\(baseName)'") - } - } - return errors - } - - /// XSD 1.0 `cos-valid-default` / `e-props-correct`: an element with a `default` - /// or `fixed` value constraint must have a content type that admits character - /// data, i.e. a simple type or mixed content. An element whose type is (or whose - /// inline complex type has) element-only or empty content may not carry a value - /// constraint. Conservative by construction: it flags only a content type that is - /// CERTAINLY element-only or empty. A simple type, mixed content, the ur-type - /// `xs:anyType` (mixed), an untyped element (also `anyType`), a complexContent - /// derivation (mixedness may be inherited), and a foreign or unresolved type are - /// all left alone, so this cannot raise a false positive. Self-contained schema. - static func elementValueConstraintContentErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - let bindings = context.namespaceBindings - var errors: [String] = [] - for element in descendants(schema, named: "element") where element.name?.namespaceURI == xsdNamespace { - guard ExtAllNode.attribute(element, "fixed") != nil || ExtAllNode.attribute(element, "default") != nil, - valueConstraintForbiddenByContent(element, bindings, context, types) - else { continue } - errors.append("an element with a 'default' or 'fixed' value must have simple or mixed content, not element-only or empty content") - } - return errors - } - - /// Whether `element`'s content type is certainly element-only or empty, so a - /// value constraint on it is invalid. Returns false (no error) for anything that - /// admits character data or that cannot be resolved with certainty. - private static func valueConstraintForbiddenByContent( - _ element: XSDTree, - _ bindings: [String: String], - _ context: PureXML.Schema.XSDContext, - _ types: [String: PureXML.Schema.ElementType], - ) -> Bool { - if let inline = ExtAllNode.firstChild(element, named: "complexType") { - if ExtAllNode.firstChild(inline, named: "simpleContent") != nil { return false } - if ["true", "1"].contains(ExtAllNode.attribute(inline, "mixed")) { return false } - if ExtAllNode.firstChild(inline, named: "complexContent") != nil { return false } - return ["all", "sequence", "choice", "group"].contains { ExtAllNode.firstChild(inline, named: $0) != nil } - } - guard let typeName = ExtAllNode.attribute(element, "type") else { return false } - let namespace = ExtAllNode.referenceNamespace(typeName, bindings) - let local = ExtAllNode.stripPrefix(typeName) - if namespace == xsdNamespace { return false } - guard namespace == context.targetNamespace, case let .complex(complex)? = types[local] else { return false } - switch complex.content { - case .elementOnly, .empty: return true - case .simpleContent, .mixed: return false - } - } - - /// XSD 1.0 `cos-ct-extends.1.4.3.2.2.1`: an explicit `mixed` on a - /// `complexContent` extension must match the base. If `mixed` is omitted, an - /// extension with no element particle inherits the base content unchanged - /// (`ctZ012b`), while an extension that adds element content uses the schema - /// default `false` (`ctZ010d`). Checked for a self-contained schema, and only - /// when the base content is definitely element-only or mixed (an empty or - /// simple-content base is governed by other rules and is left alone). - static func extensionMixedAgreementErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { - guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } - let bindings = context.namespaceBindings - var errors: [String] = [] - for content in descendants(schema, named: "complexContent") { - guard let ext = ExtAllNode.firstChild(content, named: "extension"), - let base = ExtAllNode.attribute(ext, "base"), - ExtAllNode.referenceNamespace(base, bindings) == context.targetNamespace, - case let .complex(complex)? = types[ExtAllNode.stripPrefix(base)] - else { continue } - let baseIsMixed: Bool - switch complex.content { - case .mixed: baseIsMixed = true - case .elementOnly: baseIsMixed = false - case .empty, .simpleContent: continue - } - guard let derivedMixed = declaredMixed(content, addsElementContent: extensionAddsElementContent(ext)) else { continue } - if baseIsMixed != derivedMixed { - errors.append("a complexContent extension of '\(ExtAllNode.stripPrefix(base))' must have the same mixed setting as its base") - } - } - return errors - } - - /// The declared `mixed` value of a `complexContent` derivation. An omitted - /// value matters only when the extension adds element content; otherwise the - /// base content is inherited unchanged and there is no mixedness conflict. - private static func declaredMixed(_ complexContent: XSDTree, addsElementContent: Bool) -> Bool? { - if let own = ExtAllNode.attribute(complexContent, "mixed") { return own == "true" || own == "1" } - if let parent = complexContent.parent, let outer = ExtAllNode.attribute(parent, "mixed") { return outer == "true" || outer == "1" } - return addsElementContent ? false : nil - } - /// Whether a `complexContent` extension adds element content: an `element` or /// a wildcard `any` somewhere in its model group. An empty group (``) /// or an attribute-only extension adds no element content, so it keeps the - /// base's simple content and is not flagged. - private static func extensionAddsElementContent(_ extension: XSDTree) -> Bool { + /// base's simple content and is not flagged. Shared with the content-derivation + /// checkers in `SchemaContentDerivation.swift`, so it is module-internal. + static func extensionAddsElementContent(_ extension: XSDTree) -> Bool { let hasElement = descendants(`extension`, named: "element").contains { $0.name?.namespaceURI == xsdNamespace } let hasWildcard = descendants(`extension`, named: "any").contains { $0.name?.namespaceURI == xsdNamespace } return hasElement || hasWildcard diff --git a/Tests/SchemaContentDerivationLocatedTests.swift b/Tests/SchemaContentDerivationLocatedTests.swift new file mode 100644 index 0000000..5f6b775 --- /dev/null +++ b/Tests/SchemaContentDerivationLocatedTests.swift @@ -0,0 +1,174 @@ +import Testing +@testable import PureXML + +/// Content-derivation-pass schema compile findings (the `complexExtensionBaseValid` +/// bundle: simpleContent/complexContent base-kind rules, a simpleContent +/// restriction's inline-type faithfulness, the mixed-agreement rule, and an +/// element value constraint over element-only content) carry validation coding +/// paths that resolve to source spans, the IDE underline flow for #169. Each +/// derivation error underlines its `extension`/`restriction` node (or the element +/// declaration). Split from the other located-diagnostics suites to keep each +/// suite within the type-body length budget. +@Suite("Schema content-derivation-pass located compile diagnostics") +struct SchemaContentDerivationLocatedTests { + private func inconsistentFindings(in xsd: String) throws -> [PureXML.Validation.ValidationError] { + do { + _ = try PureXML.Schema.Document(xsd) + Issue.record("expected schema compilation to fail") + return [] + } catch let error as PureXML.Schema.SchemaError { + guard case let .inconsistent(findings) = error else { + Issue.record("expected .inconsistent, got \(error)") + return [] + } + return findings + } + } + + @Test("A complexContent extension adding element content over a simpleContent base is located on the extension") + func test_simpleContentExtensionBaseLocated() throws { + let xsd = """ + + + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("may not extend 'base', which has simple content") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:complexContent", "xs:extension"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 7) + } + + @Test("A simpleContent restriction with a built-in base is located on the restriction") + func test_simpleContentRestrictionBaseLocated() throws { + let xsd = """ + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("a simpleContent restriction's base must be a complex type, not the built-in simple type 'string'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:simpleContent", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 4) + } + + @Test("An unfaithful inline type in a simpleContent restriction is located on the restriction") + func test_simpleContentRestrictionTypeLocated() throws { + let xsd = """ + + + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("the inline simpleType in a simpleContent restriction is not a valid restriction of base type 'base'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:simpleContent", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 7) + } + + @Test("A complexContent base that is a built-in simple type is located on the derivation") + func test_complexContentBaseKindLocated() throws { + let xsd = """ + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("a complexContent base must be a complex type, not the built-in simple type 'string'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:complexContent", "xs:extension"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 4) + } + + @Test("A simpleContent extension whose base is xs:anyType is located on the extension") + func test_simpleContentExtensionBaseKindLocated() throws { + let xsd = """ + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings + .first { $0.reason.contains("a simpleContent extension's base must be a simple type or a complex type with simple content, not 'anyType'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:simpleContent", "xs:extension"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 4) + } + + @Test("An element value constraint over element-only content is located on the element") + func test_elementValueConstraintContentLocated() throws { + let xsd = """ + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("an element with a 'default' or 'fixed' value must have simple or mixed content") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 2) + } + + @Test("A complexContent extension with a mismatched mixed setting is located on the extension") + func test_extensionMixedAgreementLocated() throws { + let xsd = """ + + + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("must have the same mixed setting as its base") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:complexContent", "xs:extension"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 7) + } +} diff --git a/Tests/SchemaRestrictionMixedTests.swift b/Tests/SchemaRestrictionMixedTests.swift index ce39cbb..0ba2f81 100644 --- a/Tests/SchemaRestrictionMixedTests.swift +++ b/Tests/SchemaRestrictionMixedTests.swift @@ -8,7 +8,7 @@ import Testing /// depend on: a bidirectional "derived mixed must equal base mixed" rule would /// wrongly reject the legal narrowing direction (it regressed four valid schemas /// when tried). The targets ctZ010d (extension) and ctZ010e (restriction) are -/// already rejected by `extensionMixedAgreementErrors` and `ParticleRestriction` +/// already rejected by `extensionMixedAgreementFindings` and `ParticleRestriction` /// respectively; this guards the restriction half from regressing. @Suite("XSD restriction mixed-content asymmetry (#184)") struct SchemaRestrictionMixedTests {