feat(editor): structured quick-fixes from completions

Slice 4 of #94 (completes the epic). Quick-fixes are derived from the structured
schema completions, never by parsing a diagnostic message:

- TreeNode gains contentRange (the span between an element's start and end tags),
  captured by the ranged reader, so an inserted attribute or child is placed
  exactly.
- QuickFix and TextEdit model an editor edit (a replacement over a source range;
  an insertion is a zero-width range).
- QuickFixEngine turns a Completions into fixes: a required-but-absent attribute
  becomes an insertion before the start tag's >, and a still-expected required
  child becomes an insertion before the end tag. Schema.Document.quickFixes(at:in:)
  is the entry over a ranged tree.

Tests apply a fix and re-validate: adding the required id, or inserting the
required qty, yields XML the schema then accepts; a complete element offers none.

#94 (editor integration) is complete: ranged recovering read, unified lint,
schema completions, and quick-fixes.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 13:35:33 +02:00
parent 1e89790e87
commit d667b4cf1e
5 changed files with 150 additions and 8 deletions

View File

@ -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,

View File

@ -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)
}

58
Sources/QuickFix.swift Normal file
View File

@ -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
}
}
}

View File

@ -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)
}
}
}

56
Tests/QuickFixTests.swift Normal file
View File

@ -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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="order">
<xs:complexType>
<xs:sequence><xs:element name="qty" type="xs:integer"/></xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:schema>
"""
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("<order><qty>1</qty></order>")
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: "<order><qty>1</qty></order>")
#expect(fixed == "<order id=\"\"><qty>1</qty></order>")
// 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("<order id='1'></order>")
let fixes = schema.quickFixes(at: [.element("order")], in: tree)
let fix = try #require(fixes.first { $0.title == "Insert <qty>" })
let fixed = apply(fix.edits[0], to: "<order id='1'></order>")
#expect(fixed == "<order id='1'><qty></qty></order>")
}
@Test("A complete, valid element offers no fixes")
func test_noFixes() throws {
let schema = try PureXML.Schema.Document(xsd)
let (tree, _) = PureXML.readTree("<order id='1'><qty>1</qty></order>")
#expect(schema.quickFixes(at: [.element("order")], in: tree).isEmpty)
}
}