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)>")], + )) + } + 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) + } +}