From 3a55eeeaa6f54770e926d536dbba0c7f936a364e Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 04:39:03 +0200 Subject: [PATCH] feat(html): conformance validator as composable Validation rules --- CHANGELOG.md | 1 + README.md | 2 +- Sources/Validation/HTMLValidations.swift | 109 +++++++++++++++++++++++ Tests/HTMLValidationTests.swift | 79 ++++++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 Sources/Validation/HTMLValidations.swift create mode 100644 Tests/HTMLValidationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5158cfa..f345098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- HTML conformance validator in the composable `Validation` idiom. `PureXML.HTML.validationErrors(in:)` (backed by `PureXML.Validation.HTML`) judges a parsed tree against the intrinsic HTML5 content-model invariants as named, removable, isolation-testable `Validation` rules, the same idiom as the DTD/XSD/structural validators: `voidElementsAreEmpty` (void elements carry no content), `requiredParent` (`li`/`td`/`option`/... appear inside an allowed parent, with the allowed parents named in the finding), and `uniqueIdentifiers` (`id` values are unique). Failures are located `ValidationError`s; this is the validation layer HTML previously lacked, so every parsing/schema subsystem now exposes its conformance checks as composable rules. - XInclude range inclusion (#87), completing the XInclude audit. An `xi:include` whose `xpointer` selects a range (`xpointer(range(...))`, `range-to`, or `string-range`) now includes that range's content: when no node-selecting scheme matches, the processor falls back to the XPointer range model and substitutes each range's spanned nodes (or the matched text for a `string-range`). - XPointer `range()` model (#88), completing the XPath/XPointer audit. `PureXML.XPointer.evaluateRanges(_:over:)` (and `Pointer.evaluateRanges(over:)`) evaluate the range forms inside an `xpointer()`/`xpath1()` scheme: `range(expr)` (the covering range of each location), `start/range-to(end)` (one range spanning from the first location of `start` to the first of `end`, the sibling run when they share a parent), and `string-range(location, "search"[, offset[, length]])` (a character range per occurrence, with the XPointer offset/length adjustment). Each `Range` materializes to the whole `nodes` it spans and the `text` it covers, and `xmlns()` bindings apply to the range parts that follow, so this also unblocks XInclude range inclusion (#87). - C14N node-subset canonicalization and Canonical XML 2.0 parameters (#85), completing the canonicalization audit. `Canonicalizer.canonicalize(_ subtree: TreeNode)` canonicalizes a fragment in place: the apex element renders the namespace context in scope from its omitted ancestors and inherits their `xml:base`/`xml:lang`/`xml:space` when it does not set them, so a signed fragment is independent of where it sat. `canonicalize(_:including:)` canonicalizes a node-set selected by a `TreeNode` predicate (XPath/position-based selection): an excluded element's tags are omitted but its selected descendants are kept and re-declare their in-scope namespaces. `Options.prefixRewrite = .sequential` rewrites every prefix to a canonical `n0`/`n1`/... in document order (eliminating the default namespace and placing declarations at first use), and `Options.qnameAwareLabels` extends that rewrite into QName-valued attributes and elements, so documents differing only in prefix spelling canonicalize to identical bytes. diff --git a/README.md b/README.md index a4e4a9d..5ebc4d4 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **703 tests in 111 suites** (`swift test`). +currently runs **710 tests in 112 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Validation/HTMLValidations.swift b/Sources/Validation/HTMLValidations.swift new file mode 100644 index 0000000..a575953 --- /dev/null +++ b/Sources/Validation/HTMLValidations.swift @@ -0,0 +1,109 @@ +public extension PureXML.Validation { + /// HTML conformance as composable ``Validation`` values, in the same idiom as + /// the DTD, XSD, and structural validators: each rule is a named, removable + /// ``Validation`` with a positive description, the ``validator()`` composes + /// them, and failures are located ``ValidationError``s. The rules are intrinsic + /// to HTML (no external schema), so the document type is `Void`. + /// + /// These check the HTML5 content-model invariants that a parsed tree can be + /// judged against without a grammar: void elements stay empty, elements that + /// require a particular parent appear inside one, and `id` values are unique. + enum HTML { + /// A void element (`br`, `img`, `input`, ...) carries no child content. + static var voidElementsAreEmpty: Validation { + .init(description: "Void HTML elements have no content") { context in + let name = elementName(context.subject) + guard PureXML.HTML.Elements.void.contains(name), !context.subject.children.isEmpty else { return [] } + return [ValidationError(reason: "void element <\(name)> must not have content", at: context.codingPath)] + } + } + + /// An element that requires a particular parent (`li`, `td`, `option`, ...) + /// appears directly inside one of its allowed parents. + static var requiredParent: Validation { + .init(description: "HTML elements appear inside their required parent") { context in + let name = elementName(context.subject) + guard let allowed = requiredParents[name] else { return [] } + let parent = parentName(in: context.codingPath) + guard parent.map({ !allowed.contains($0) }) ?? true else { return [] } + let hint = allowed.sorted().map { "<\($0)>" }.joined(separator: ", ") + return [ValidationError(reason: "element <\(name)> must appear inside \(hint)", at: context.codingPath)] + } + } + + /// Every `id` attribute value is unique in the document. Runs once over the + /// whole tree, so it is gated to the document root. + static var uniqueIdentifiers: Validation { + .init( + description: "HTML id attributes are unique", + check: { context in + var counts: [String: Int] = [:] + collectIdentifiers(context.subject, into: &counts) + return counts.filter { $0.value > 1 }.keys.sorted().map { + ValidationError(reason: "duplicate id '\($0)' (used \(counts[$0] ?? 0) times)", at: context.codingPath) + } + }, + when: { $0.codingPath.isEmpty }, + ) + } + + /// A validator combining the HTML conformance rules. + static func validator() -> Validator { + Validator.blank + .validating(voidElementsAreEmpty, requiredParent) + .validating(uniqueIdentifiers) + } + + /// The allowed direct parents for each element that requires one (the + /// HTML5 content-model parent constraints). + private static let requiredParents: [String: Set] = [ + "li": ["ul", "ol", "menu"], + "dt": ["dl"], "dd": ["dl"], + "tr": ["table", "thead", "tbody", "tfoot"], + "td": ["tr"], "th": ["tr"], + "thead": ["table"], "tbody": ["table"], "tfoot": ["table"], + "caption": ["table"], "colgroup": ["table"], "col": ["colgroup"], + "option": ["select", "optgroup", "datalist"], "optgroup": ["select"], + "figcaption": ["figure"], "summary": ["details"], + ] + + private static func elementName(_ element: PureXML.Model.Element) -> String { + element.name.description.lowercased() + } + + /// The name of the element's direct parent from the coding path, or nil at + /// the document root. The path ends with the element's own name, so the + /// parent is the entry before it. + private static func parentName(in path: [PathKey]) -> String? { + guard path.count >= 2 else { return nil } + return path[path.count - 2].stringValue.lowercased() + } + + private static func collectIdentifiers(_ node: PureXML.Model.Node, into counts: inout [String: Int]) { + switch node { + case let .document(children): + for child in children { + collectIdentifiers(child, into: &counts) + } + case let .element(element): + if let id = element.attributes.first(where: { $0.name.description.lowercased() == "id" })?.value, !id.isEmpty { + counts[id, default: 0] += 1 + } + for child in element.children { + collectIdentifiers(child, into: &counts) + } + case .text, .cdata, .comment, .processingInstruction: + break + } + } + } +} + +public extension PureXML.HTML { + /// The HTML conformance errors for a parsed node, in document order; empty + /// when the tree satisfies the intrinsic HTML5 content-model rules. Built on + /// the composable ``PureXML/Validation/HTML`` rules. + static func validationErrors(in node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] { + PureXML.Validation.HTML.validator().errors(for: node, in: ()) + } +} diff --git a/Tests/HTMLValidationTests.swift b/Tests/HTMLValidationTests.swift new file mode 100644 index 0000000..0471185 --- /dev/null +++ b/Tests/HTMLValidationTests.swift @@ -0,0 +1,79 @@ +@testable import PureXML +import Testing + +@Suite("HTML conformance validator (composable Validation rules)") +struct HTMLValidationTests { + private typealias Element = PureXML.Model.Element + private typealias Node = PureXML.Model.Node + + private func errors(_ node: Node) -> [PureXML.Validation.ValidationError] { + PureXML.HTML.validationErrors(in: node) + } + + @Test("A void element carrying content is flagged") + func test_voidWithContent() { + let node = Node.element(Element("br", children: [.text("x")])) + let found = errors(node) + #expect(found.count == 1) + #expect(found.first?.reason == "void element
must not have content") + } + + @Test("li inside ul is valid; li with no list parent is flagged") + func test_requiredParent() { + let valid = Node.document([.element(Element("ul", children: [.element(Element("li", children: [.text("a")]))]))]) + #expect(errors(valid).isEmpty) + + let invalid = Node.document([.element(Element("li", children: [.text("a")]))]) + let found = errors(invalid) + #expect(found.count == 1) + #expect(found.first?.reason == "element
  • must appear inside ,
      ,
        ") + } + + @Test("td must appear inside tr") + func test_tableCellParent() { + let invalid = Node.document([.element(Element("table", children: [.element(Element("td", children: [.text("x")]))]))]) + let found = errors(invalid) + #expect(found.contains { $0.reason == "element must appear inside " }) + } + + @Test("Duplicate id values are reported once each") + func test_uniqueIdentifiers() { + let div = Element("div", children: [ + .element(Element("p", attributes: [.init("id", "x")])), + .element(Element("span", attributes: [.init("id", "x")])), + ]) + let found = errors(.document([.element(div)])) + #expect(found.count == 1) + #expect(found.first?.reason == "duplicate id 'x' (used 2 times)") + } + + @Test("A well-formed document passes") + func test_valid() { + let list = Element("ul", children: [ + .element(Element("li", attributes: [.init("id", "a")], children: [.text("one")])), + .element(Element("li", attributes: [.init("id", "b")], children: [.text("two")])), + ]) + #expect(errors(.document([.element(list)])).isEmpty) + } + + // MARK: Each rule is composable and isolation-testable on its own. + + @Test("HTML.requiredParent runs in isolation") + func test_requiredParentIsolated() { + let node = Node.document([.element(Element("option", children: [.text("a")]))]) + let found = PureXML.Validation.Validator.blank + .validating(PureXML.Validation.HTML.requiredParent) + .errors(for: node, in: ()) + #expect(found.count == 1) + #expect(found.first?.reason.contains("must appear inside") == true) + } + + @Test("HTML.uniqueIdentifiers runs in isolation") + func test_uniqueIdentifiersIsolated() { + let node = Node.document([.element(Element("a", attributes: [.init("id", "z")])), .element(Element("b", attributes: [.init("id", "z")]))]) + let found = PureXML.Validation.Validator.blank + .validating(PureXML.Validation.HTML.uniqueIdentifiers) + .errors(for: node, in: ()) + #expect(found.count == 1) + } +}