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:
parent
1e89790e87
commit
d667b4cf1e
@ -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,
|
||||
|
||||
@ -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
58
Sources/QuickFix.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
56
Tests/QuickFixTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user