PureXML/Sources/API.swift
Mihaela Mihaljevic 229d7df3d0 feat(validation,schema): composable validation framework and cross-document composition
Ship the OpenAPIKit-style validation layer (builtin rules, warnings channel,
Validator tiers, coverage gates) and wire XSD compile checks through it. Advance
#161 with chameleon include validation, multi-container reference resolution,
and include targetNamespace composition rules so merged schema sets compile
correctly when a schemaLoader is provided.
2026-06-16 09:57:43 +02:00

231 lines
11 KiB
Swift

public extension PureXML {
/// Parses an XML document into a ``Model/Node`` tree.
static func parse(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) throws -> Model.Node {
try Parsing.Parser().parse(xml, limits: limits, resolver: resolver)
}
/// Parses an XML document from an incremental character source into a
/// ``Model/Node`` tree. The closure returns the next character or nil at end
/// of input, so the document can arrive in chunks and is never held whole.
static func parse(
pulling pull: @escaping () -> Character?,
limits: Parsing.Limits = .default,
) throws -> Model.Node {
try Parsing.Parser().parse(pulling: pull, limits: limits)
}
/// Parses an XML document from raw bytes, detecting the encoding (UTF-8 or
/// UTF-16, with or without a byte-order mark) before parsing.
static func parse(bytes: [UInt8], limits: Parsing.Limits = .default) throws -> Model.Node {
try Parsing.Parser().parse(bytes: bytes, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an XML string. Pull events
/// one at a time with `next()` to process documents without building a tree.
static func events(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) -> Parsing.EventReader {
Parsing.EventReader(xml, limits: limits, resolver: resolver)
}
/// Parses a document, delivering SAX-style callbacks (the libxml2 SAX2 model)
/// instead of building a tree.
static func parse(_ xml: String, sax handler: Parsing.SAXHandler, limits: Parsing.Limits = .default) throws {
try Parsing.Parser().parse(xml, sax: handler, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over raw bytes, detecting the
/// encoding before streaming events.
static func events(bytes: [UInt8], limits: Parsing.Limits = .default) throws -> Parsing.EventReader {
try Parsing.EventReader(Parsing.ByteDecoder.decode(bytes), limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an incremental character
/// source, for processing arbitrarily large or chunked input.
static func events(
pulling pull: @escaping () -> Character?,
limits: Parsing.Limits = .default,
) -> Parsing.EventReader {
Parsing.EventReader(pulling: pull, limits: limits)
}
/// Parses an XML document from an incremental byte source, decoding the
/// encoding on the fly so the bytes are never fully buffered.
static func parse(
pullingBytes pull: @escaping () -> UInt8?,
limits: Parsing.Limits = .default,
) throws -> Model.Node {
try Parsing.Parser().parse(pullingBytes: pull, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an incremental byte
/// source, decoding the encoding on the fly.
static func events(
pullingBytes pull: @escaping () -> UInt8?,
limits: Parsing.Limits = .default,
) -> Parsing.EventReader {
var decoder = Parsing.StreamingDecoder(pullingBytes: pull)
return Parsing.EventReader(pulling: { decoder.next() }, limits: limits)
}
/// Parses an XML document and validates its tree against the DTD content
/// models declared in its internal subset, returning the validation issues.
/// DTD processing must be enabled (`Limits(allowDoctype: true)`); without a
/// DTD the result is empty.
static func validateAgainstInternalDTD(
_ xml: String,
limits: Parsing.Limits = .init(allowDoctype: true),
strict: Bool = false,
resolver: Parsing.EntityResolver = .refusing,
) throws -> [Validation.ValidationError] {
let parsed = try Parsing.Parser().parseWithDocumentType(xml, limits: limits, resolver: resolver)
let schema = Validation.DTDSchema(parsed.documentType, standalone: parsed.declaration?.standalone == true)
return Validation.DTD.validator(strict: strict).findings(for: parsed.node, in: schema)
}
/// The XML declaration (`version`, `encoding`, `standalone`) at the start of
/// `xml`, or nil when the document has none or its declaration is malformed.
static func xmlDeclaration(
_ xml: String,
limits: Parsing.Limits = .default,
) -> Parsing.XMLDeclaration? {
(try? Parsing.Parser().parseWithDocumentType(xml, limits: limits))?.declaration
}
/// Parses an XML document and returns its tree with the DTD attribute defaults
/// from the internal subset applied: every element gains any attribute its
/// `<!ATTLIST>` declares with a default or `#FIXED` value and that the element
/// omits, so an editor sees the same effective attributes a validating
/// processor would. DTD processing must be enabled.
static func parseApplyingInternalDTDDefaults(
_ xml: String,
limits: Parsing.Limits = .init(allowDoctype: true),
resolver: Parsing.EntityResolver = .refusing,
) throws -> Model.Node {
let parsed = try Parsing.Parser().parseWithDocumentType(xml, limits: limits, resolver: resolver)
return Validation.DTDSchema(parsed.documentType).applyingDefaults(to: parsed.node)
}
/// Compiles and evaluates an XPath query over a node, returning the selected
/// node-set. Compile once with ``XPath/Query`` to reuse a query.
static func xpath(_ path: String, over node: Model.Node) throws -> [XPath.Selection] {
try XPath.Query(path).evaluate(over: node)
}
/// Serializes a ``Model/Node`` tree into XML with the selected options.
static func serialize(
_ node: Model.Node,
options: Emitting.Options = .default,
) -> String {
Emitting.Serializer(options: options).serialize(node)
}
/// Serializes a ``Model/Node`` tree into bytes in `encoding` (the libxml2
/// save-with-encoding model): the declaration carries the encoding name, a
/// UTF-16/32 stream is preceded by a byte-order mark, and any unrepresentable
/// scalar becomes a numeric character reference.
static func serialize(
_ node: Model.Node,
encoding: Parsing.InputEncoding,
options: Emitting.Options = .default,
) -> [UInt8] {
Emitting.Serializer(options: options).serialize(node, encoding: encoding)
}
/// Returns a pull cursor (the libxml2 `xmlTextReader` model) over an XML
/// string. Call `read()` to advance node by node, reading `nodeKind`, `name`,
/// `value`, `depth`, and `attributes` at each step.
static func reader(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) -> Parsing.TextReader {
Parsing.TextReader(xml, limits: limits, resolver: resolver)
}
/// Parses an XML document into a mutable, parent-aware ``Model/TreeNode`` for
/// in-place editing (insert, remove, replace, copy, and upward navigation).
/// Serialize the result back with `serialize(tree.node)`.
///
/// Ownership: a node holds its children strongly and its parent weakly, so
/// keep a reference to this returned tree (or a node's `ownerDocument`) while
/// using any node inside it; once the tree is released, a separately held
/// child's ancestry is gone. `TreeNode` is a mutable reference type with no
/// internal synchronization: confine a tree to one thread or actor, and
/// convert to the immutable, `Sendable` ``Model/Node`` (via `tree.node`) to
/// cross concurrency boundaries.
static func parseTree(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) throws -> Model.TreeNode {
try Model.TreeNode(parse(xml, limits: limits, resolver: resolver))
}
/// Reads a possibly-invalid document without throwing, returning the maximal
/// best-effort tree and one located ``Parsing/Diagnostic`` per problem.
/// Well-formed input gives the same node as ``parse(_:limits:resolver:)`` with
/// no diagnostics; malformed input is recovered deterministically rather than
/// rejected. The result never crashes, whatever the bytes.
static func read(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) -> Parsing.ReadResult {
Parsing.Parser().read(xml, limits: limits, resolver: resolver)
}
/// Reads a possibly-invalid document into a mutable, parent-aware
/// ``Model/TreeNode`` whose nodes carry source spans, plus located
/// ``Parsing/Diagnostic``s. The editor entry point: never throws, recovers in
/// place, and lets a located validation finding be mapped to a source range
/// via ``Model/TreeNode/node(at:)`` and the node's `sourceRange`.
///
/// Ownership and threading: the same contract as ``parseTree(_:limits:resolver:)``,
/// hold the returned tree while using any node inside it (parents are weak),
/// confine it to one thread or actor, and cross concurrency boundaries with
/// the immutable `tree.node` instead.
static func readTree(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) -> (tree: Model.TreeNode, diagnostics: [Parsing.Diagnostic]) {
Parsing.Parser().readTree(xml, limits: limits, resolver: resolver)
}
/// Validates a parsed XML node with the default structural rules, throwing a
/// ``PureXML/Validation/ValidationErrorCollection`` that locates every failure
/// by coding path. Pass a custom ``PureXML/Validation/Validator`` to add or
/// remove rules, or call ``Validation/Validator/errors(for:)`` to collect the
/// errors without throwing.
static func validate(
_ node: Model.Node,
using validator: Validation.Validator<Void> = .init(),
) throws {
try validator.validate(node)
}
/// The HTML5 content-model validation errors for a parsed node; empty when the
/// tree satisfies the intrinsic rules (void elements, required parents, unique
/// `id` values). Built on ``Validation/HTML``.
static func validateHTML(_ node: Model.Node) -> [Validation.ValidationError] {
HTML.validationErrors(in: node)
}
/// Lints a possibly-invalid document against an XSD schema, merging parse
/// diagnostics and schema validation findings into source-ranged
/// ``LintDiagnostic``s. See ``lint(_:validate:)``.
static func lint(
_ xml: String,
schema: Schema.Document,
) -> [LintDiagnostic] {
lint(xml, validate: schema.validate)
}
}