diff --git a/Sources/Model/TreeNode.swift b/Sources/Model/TreeNode.swift
index c964410..6ce599d 100644
--- a/Sources/Model/TreeNode.swift
+++ b/Sources/Model/TreeNode.swift
@@ -42,6 +42,11 @@ public extension PureXML.Model {
/// reader (``PureXML/read(asTree:)``); nil for synthesized or edited nodes.
/// Lets a located finding be mapped back to characters in the document.
public internal(set) var sourceRange: PureXML.Parsing.SourceRange?
+ /// For an element, the span between its start tag and end tag (where
+ /// children live), so a quick-fix can place an inserted attribute (just
+ /// before `contentRange.start`) or child (at `contentRange.end`). Nil for
+ /// self-closing or non-element nodes.
+ public internal(set) var contentRange: PureXML.Parsing.SourceRange?
init(
kind: PureXML.Model.TreeNodeKind,
diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift
index 6c0d3e5..7785511 100644
--- a/Sources/Parsing/Parser.swift
+++ b/Sources/Parsing/Parser.swift
@@ -6,6 +6,14 @@ private struct ElementFrame {
var children: [PureXML.Model.Node] = []
}
+/// An open element during the ranged tree build: the node, the position of its
+/// `<`, and where its content begins (just past the start tag). File-scope.
+private struct OpenTreeElement {
+ let node: PureXML.Model.TreeNode
+ let open: PureXML.Parsing.Mark
+ let contentStart: PureXML.Parsing.Mark
+}
+
public extension PureXML.Parsing {
/// Builds a ``PureXML/Model/Node`` tree by draining the streaming
/// ``EventReader`` with an explicit element stack. It is iterative, not
@@ -192,7 +200,7 @@ public extension PureXML.Parsing {
func readTreeRecovering(_ source: EventReader) -> (tree: PureXML.Model.TreeNode, diagnostics: [Diagnostic]) {
var reader = source
let document = PureXML.Model.TreeNode.document()
- var stack: [(node: PureXML.Model.TreeNode, start: PureXML.Parsing.Mark)] = []
+ var stack: [OpenTreeElement] = []
func attach(_ node: PureXML.Model.TreeNode) {
(stack.last?.node ?? document).append(node)
@@ -203,11 +211,15 @@ public extension PureXML.Parsing {
guard let event = try? reader.next() else { break }
switch event {
case let .startElement(name, attributes):
- stack.append((PureXML.Model.TreeNode.element(name, attributes: attributes), start))
+ // After the start tag is scanned, reader.mark is where content begins.
+ let element = PureXML.Model.TreeNode.element(name, attributes: attributes)
+ stack.append(OpenTreeElement(node: element, open: start, contentStart: reader.mark))
case .endElement:
- guard let (element, openMark) = stack.popLast() else { continue }
- element.sourceRange = PureXML.Parsing.SourceRange(start: openMark, end: reader.mark)
- attach(element)
+ guard let open = stack.popLast() else { continue }
+ open.node.sourceRange = PureXML.Parsing.SourceRange(start: open.open, end: reader.mark)
+ // `start` is the position just before the end tag was consumed.
+ open.node.contentRange = PureXML.Parsing.SourceRange(start: open.contentStart, end: start)
+ attach(open.node)
case let .characters(text):
attach(ranged(.text(text), from: start, to: reader.mark))
case let .cdata(text):
@@ -218,9 +230,10 @@ public extension PureXML.Parsing {
attach(ranged(.processingInstruction(target: target, data: data), from: start, to: reader.mark))
}
}
- while let (element, openMark) = stack.popLast() {
- element.sourceRange = PureXML.Parsing.SourceRange(start: openMark, end: reader.mark)
- attach(element)
+ while let open = stack.popLast() {
+ open.node.sourceRange = PureXML.Parsing.SourceRange(start: open.open, end: reader.mark)
+ open.node.contentRange = PureXML.Parsing.SourceRange(start: open.contentStart, end: reader.mark)
+ attach(open.node)
}
return (document, reader.diagnostics)
}
diff --git a/Sources/QuickFix.swift b/Sources/QuickFix.swift
new file mode 100644
index 0000000..0290115
--- /dev/null
+++ b/Sources/QuickFix.swift
@@ -0,0 +1,58 @@
+public extension PureXML {
+ /// A single text edit an editor can apply: replace `range` with `replacement`.
+ /// An insertion is a zero-width range at the insertion point.
+ struct TextEdit: Equatable, Sendable {
+ public var range: Parsing.SourceRange
+ public var replacement: String
+
+ public init(range: Parsing.SourceRange, replacement: String) {
+ self.range = range
+ self.replacement = replacement
+ }
+ }
+
+ /// An offered fix: a human-readable title and the edits that apply it.
+ struct QuickFix: Equatable, Sendable {
+ public var title: String
+ public var edits: [TextEdit]
+
+ public init(title: String, edits: [TextEdit]) {
+ self.title = title
+ self.edits = edits
+ }
+ }
+
+ /// Derives quick-fixes from the *structured* schema completions of an element
+ /// (never from parsing a diagnostic message): a required-but-absent attribute
+ /// becomes an insertion just before the start tag's `>`, and a still-expected
+ /// required child becomes an insertion just before the end tag.
+ enum QuickFixEngine {
+ static func fixes(from completions: Schema.Completions, element: Model.TreeNode) -> [QuickFix] {
+ guard let content = element.contentRange else { return [] }
+ var fixes: [QuickFix] = []
+
+ // The `>` of the start tag sits one character before the content start.
+ let tagEnd = Parsing.Mark(
+ line: content.start.line,
+ column: Swift.max(1, content.start.column - 1),
+ offset: Swift.max(0, content.start.offset - 1),
+ )
+ let attributePoint = Parsing.SourceRange(start: tagEnd, end: tagEnd)
+ for attribute in completions.attributes where attribute.required && !attribute.present {
+ fixes.append(QuickFix(
+ title: "Add required attribute '\(attribute.name)'",
+ edits: [TextEdit(range: attributePoint, replacement: " \(attribute.name)=\"\"")],
+ ))
+ }
+
+ if !completions.complete, let next = completions.elements.first {
+ let childPoint = Parsing.SourceRange(start: content.end, end: content.end)
+ fixes.append(QuickFix(
+ title: "Insert <\(next)>",
+ edits: [TextEdit(range: childPoint, replacement: "<\(next)>\(next)>")],
+ ))
+ }
+ return fixes
+ }
+ }
+}
diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift
index ad0dd50..931aae2 100644
--- a/Sources/Schema/SchemaDocument.swift
+++ b/Sources/Schema/SchemaDocument.swift
@@ -72,5 +72,15 @@ public extension PureXML.Schema {
}
return CompletionEngine.completions(for: element, type: type, types: types)
}
+
+ /// The quick-fixes the schema offers at the element a coding `path`
+ /// addresses in `tree`: add a required-but-absent attribute, or insert a
+ /// still-expected required child. Derived from the structured
+ /// ``completions(at:in:)``, with precise placement from the node's content
+ /// span, so the edits are exact, not guessed from a message.
+ public func quickFixes(at path: [PureXML.Validation.PathKey], in tree: PureXML.Model.TreeNode) -> [PureXML.QuickFix] {
+ guard let completions = completions(at: path, in: tree), let element = tree.node(at: path) else { return [] }
+ return PureXML.QuickFixEngine.fixes(from: completions, element: element)
+ }
}
}
diff --git a/Tests/QuickFixTests.swift b/Tests/QuickFixTests.swift
new file mode 100644
index 0000000..fb5cfb9
--- /dev/null
+++ b/Tests/QuickFixTests.swift
@@ -0,0 +1,56 @@
+@testable import PureXML
+import Testing
+
+/// Quick-fixes derived from the structured schema completions, with precise
+/// placement from content spans. The strongest check: applying a fix yields XML
+/// that the same schema then accepts.
+@Suite("Quick fixes")
+struct QuickFixTests {
+ private let xsd = """
+
+
+
+
+
+
+
+
+ """
+
+ private func apply(_ edit: PureXML.TextEdit, to xml: String) -> String {
+ var characters = Array(xml)
+ let start = edit.range.start.offset
+ let end = edit.range.end.offset
+ characters.replaceSubrange(start ..< end, with: Array(edit.replacement))
+ return String(characters)
+ }
+
+ @Test("A missing required attribute becomes an insertion before the start tag")
+ func test_addAttribute() throws {
+ let schema = try PureXML.Schema.Document(xsd)
+ let (tree, _) = PureXML.readTree("1")
+ let fixes = schema.quickFixes(at: [.element("order")], in: tree)
+ let fix = try #require(fixes.first { $0.title.contains("attribute 'id'") })
+ let fixed = apply(fix.edits[0], to: "1")
+ #expect(fixed == "1")
+ // The required-attribute error is gone after applying.
+ #expect(try schema.validate(fixed).contains { $0.reason.contains("required attribute 'id'") } == false)
+ }
+
+ @Test("A still-expected required child becomes an insertion before the end tag")
+ func test_insertChild() throws {
+ let schema = try PureXML.Schema.Document(xsd)
+ let (tree, _) = PureXML.readTree("")
+ let fixes = schema.quickFixes(at: [.element("order")], in: tree)
+ let fix = try #require(fixes.first { $0.title == "Insert " })
+ let fixed = apply(fix.edits[0], to: "")
+ #expect(fixed == "")
+ }
+
+ @Test("A complete, valid element offers no fixes")
+ func test_noFixes() throws {
+ let schema = try PureXML.Schema.Document(xsd)
+ let (tree, _) = PureXML.readTree("1")
+ #expect(schema.quickFixes(at: [.element("order")], in: tree).isEmpty)
+ }
+}