krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 07733fc0c0

07733fc0c0a00d045371c7fffcb2467788555cfc

parent: 9d407be245

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 02:34 UTC

Reparse structural edits within one section's body

Layout: unified · split

Sources/OrgCore/Parser/Incremental.swift +149 −4
@@ -22,6 +22,8 @@ public struct TextEdit: Sendable, Equatable {
22enum ReparseStrategy: Equatable { 22enum ReparseStrategy: Equatable {
23 /// One element reparsed in place; everything else reused. 23 /// One element reparsed in place; everything else reused.
24 case element 24 case element
25 /// A run of elements in one section's body reparsed; everything else reused.
26 case region
25 /// A run of top-level sections reparsed; the sections before and after reused. 27 /// A run of top-level sections reparsed; the sections before and after reused.
26 case sections 28 case sections
27 case full 29 case full
@@ -41,6 +43,7 @@ extension OrgParser {
41 let context = EditContext(oldText: oldText, newText: newText, edit: edit) 43 let context = EditContext(oldText: oldText, newText: newText, edit: edit)
42 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) } 44 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) }
43 if let tree = context.reparseElement(old) { return (tree, .element) } 45 if let tree = context.reparseElement(old) { return (tree, .element) }
46 if let tree = context.reparseRegion(old) { return (tree, .region) }
44 if let tree = context.reparseSections(old) { return (tree, .sections) } 47 if let tree = context.reparseSections(old) { return (tree, .sections) }
45 return (parse(newText, defaults: defaults), .full) 48 return (parse(newText, defaults: defaults), .full)
46 } 49 }
@@ -113,10 +116,7 @@ private struct EditContext {
113 func leaf(in root: SyntaxNode) -> SyntaxNode? { 116 func leaf(in root: SyntaxNode) -> SyntaxNode? {
114 let range = edit.range 117 let range = edit.range
115 var current = root 118 var current = root
116 while let child = current.children.first(where: { 119 while let child = current.child(containing: range.lowerBound), range.upperBound <= child.range.upperBound {
117 $0.range.lowerBound <= range.lowerBound && range.lowerBound < $0.range.upperBound
118 && range.upperBound <= $0.range.upperBound
119 }) {
120 if Self.leafKinds.contains(child.kind) { return child } 120 if Self.leafKinds.contains(child.kind) { return child }
121 current = child 121 current = child
122 } 122 }
@@ -140,6 +140,151 @@ private struct EditContext {
140 return replacement 140 return replacement
141 } 141 }
142 142
143 // MARK: - Body region
144
145 /// Reparses a window of the innermost section's own body (the elements between its heading
146 /// lines and its first child section) and splices it into the old section.
147 ///
148 /// The window starts one element before the element holding the line before the edit. It ends at the first
149 /// later element boundary that the reparse reproduces at the same shifted offset. The
150 /// window is parsed one element past that boundary, so every decision about where an element
151 /// ends was made with the following line in view; from a reproduced boundary on, elements
152 /// parse the same as before. Edits that touch heading lines, the first line after a heading
153 /// (where planning lines and property drawers are only recognized), or that add an end
154 /// delimiter (which could pair with a begin line before the window) are left to the
155 /// section strategy.
156 func reparseRegion(_ old: OrgTree) -> OrgTree? {
157 let start = lineStart(oldText, edit.range.lowerBound)
158 let oldLines = classes(slice(oldText, start, lineEnd(oldText, edit.range.upperBound)))
159 let newLines = classes(slice(newText, start, lineEnd(newText, edit.range.lowerBound + edit.replacement.utf16.count)))
160 for line in oldLines + newLines {
161 if case .heading = line.cls { return nil }
162 }
163 for line in newLines {
164 switch line.cls {
165 case .blockEnd, .dynamicEnd, .drawerEnd: return nil
166 default: break
167 }
168 }
169
170 // The innermost section or zeroth section holding the edited lines.
171 let damaged = start..<max(lineEnd(oldText, edit.range.upperBound), start + 1)
172 var container = old.root
173 while let child = container.child(containing: damaged.lowerBound),
174 child.kind == .section || child.kind == .zerothSection,
175 damaged.upperBound <= child.range.upperBound {
176 container = child
177 }
178 guard container.kind == .section || container.kind == .zerothSection else { return nil }
179
180 // Body children with their offsets.
181 var body: [(element: GreenElement, offset: Int)] = []
182 var bodyStart = container.range.lowerBound
183 var bodyEnd = container.range.upperBound
184 var offset = container.range.lowerBound
185 var prefixCount = 0
186 for (index, element) in container.green.children.enumerated() {
187 defer { offset += element.length }
188 if case .node(let node) = element {
189 if [.heading, .planning, .propertyDrawer].contains(node.kind), body.isEmpty {
190 prefixCount = index + 1
191 bodyStart = offset + node.length
192 continue
193 }
194 if node.kind == .section {
195 bodyEnd = offset
196 break
197 }
198 }
199 body.append((element, offset))
200 }
201 // The first body line of a heading section can become a planning line or drawer.
202 let firstEditable = container.kind == .section ? lineEnd(oldText, bodyStart) : bodyStart
203 guard start >= firstEditable, edit.range.upperBound <= bodyEnd, !body.isEmpty else { return nil }
204
205 // Window start: the body element holding the line before the edit, and one more before
206 // it, which could absorb the window's first line if that line changes meaning (a drawer
207 // that loses its end turns into paragraph text).
208 let anchor = max(bodyStart, start - 1)
209 guard var first = body.lastIndex(where: { $0.offset <= anchor }) else { return nil }
210 while first > 0, case .token = body[first].element { first -= 1 }
211 if first > 0 {
212 first -= 1
213 while first > 0, case .token = body[first].element { first -= 1 }
214 }
215 let windowStart = body[first].offset
216
217 var next = body.firstIndex { $0.offset > edit.range.upperBound } ?? body.count
218 var attempts = 0
219 while attempts < 8 {
220 attempts += 1
221 // Parse through one node past the candidate boundary.
222 var verifyEnd = next
223 while verifyEnd < body.count, case .token = body[verifyEnd].element { verifyEnd += 1 }
224 let boundary = next < body.count ? body[next].offset : bodyEnd
225 let oldWindowEnd = verifyEnd < body.count ? body[verifyEnd].offset + body[verifyEnd].element.length : bodyEnd
226 let rows = reusableRows(body[first..<min(verifyEnd + 1, body.count)].map(\.element))
227 guard let window = parseBody(slice(newText, windowStart, oldWindowEnd + delta), settings: old.settings, rows: rows) else { return nil }
228 if window.unmatchedBegin, oldWindowEnd < bodyEnd {
229 next = body.count
230 continue
231 }
232 // The reparsed elements up to the boundary, if the reparse has one there.
233 let target = boundary + delta - windowStart
234 var reparsed: [GreenElement] = []
235 var at = 0
236 for element in window.elements where at < target {
237 reparsed.append(element)
238 at += element.length
239 }
240 guard at == target else {
241 next += 1
242 if next > body.count { return nil }
243 continue
244 }
245 let before = container.green.children[..<(prefixCount + first)]
246 let after = next < body.count ? Array(body[next...].map(\.element)) : []
247 let tail = container.green.children[(prefixCount + body.count)...]
248 let green = GreenNode(kind: container.kind, children: Array(before) + reparsed + after + Array(tail))
249 // A full parse has no zeroth section for empty text.
250 if green.children.isEmpty { return nil }
251 return OrgTree(green: replacing(container, with: green), settings: old.settings)
252 }
253 return nil
254 }
255
256 /// Rows of the tables among `elements`, keyed by their text.
257 func reusableRows(_ elements: [GreenElement]) -> [Substring: GreenNode] {
258 var rows: [Substring: GreenNode] = [:]
259 for case .node(let table) in elements where table.kind == .table {
260 for case .node(let row) in table.children where row.kind == .tableRow {
261 rows[Substring(row.text)] = row
262 }
263 }
264 return rows
265 }
266
267 /// Body elements parsed from `text`, or nil if the text holds a heading.
268 func parseBody(_ text: Substring, settings: OrgSettings, rows: [Substring: GreenNode] = [:]) -> (elements: [GreenElement], unmatchedBegin: Bool)? {
269 var parser = Parser(text: String(text), settings: settings)
270 parser.reusableRows = rows
271 let count = parser.lines.count
272 parser.builder.start(.zerothSection)
273 parser.parseContent(limit: count)
274 guard parser.i == count else { return nil }
275 parser.builder.finish()
276 var unmatched = false
277 for (index, line) in parser.info.enumerated() {
278 switch line.cls {
279 case .blockBegin, .dynamicBegin, .drawerBegin:
280 if parser.blockEnds[index] == nil { unmatched = true }
281 default:
282 break
283 }
284 }
285 return (parser.builder.build().children, unmatched)
286 }
287
143 // MARK: - Sections 288 // MARK: - Sections
144 289
145 /// Reparses from the top-level section before the edit up to the first later top-level 290 /// Reparses from the top-level section before the edit up to the first later top-level
Sources/OrgCore/Parser/Parser.swift +11
@@ -14,6 +14,9 @@ struct Parser {
14 let settings: OrgSettings 14 let settings: OrgSettings
15 var builder = GreenBuilder() 15 var builder = GreenBuilder()
16 var i = 0 16 var i = 0
17 /// Table rows from a previous version, by their line text. A row parses from its own line
18 /// alone, so a row with the same text can be reused instead of scanned again.
19 var reusableRows: [Substring: GreenNode] = [:]
17 20
18 init(text: String, defaults: OrgSettings) { 21 init(text: String, defaults: OrgSettings) {
19 let lines = splitRawLines(text) 22 let lines = splitRawLines(text)
@@ -230,6 +233,14 @@ struct Parser {
230 /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells; 233 /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells;
231 /// every pair of pipes gets a cell, even an empty one, so columns line up. 234 /// every pair of pipes gets a cell, even an empty one, so columns line up.
232 mutating func tableRow() { 235 mutating func tableRow() {
236 if !reusableRows.isEmpty {
237 let content = lines[i].content
238 if let row = reusableRows[content.base[content.startIndex..<lines[i].ending.endIndex]] {
239 builder.node(row)
240 i += 1
241 return
242 }
243 }
233 builder.start(.tableRow) 244 builder.start(.tableRow)
234 let rest = whitespace(lines[i].content) 245 let rest = whitespace(lines[i].content)
235 if rest.hasPrefix("|-") { 246 if rest.hasPrefix("|-") {
Sources/OrgCore/Syntax/GreenTree.swift +5
@@ -70,6 +70,11 @@ struct GreenBuilder {
70 stack[stack.count - 1].children.append(.token(GreenToken(kind: kind, text: String(text)))) 70 stack[stack.count - 1].children.append(.token(GreenToken(kind: kind, text: String(text))))
71 } 71 }
72 72
73 /// Appends an already-built node, for reuse across versions.
74 mutating func node(_ green: GreenNode) {
75 stack[stack.count - 1].children.append(.node(green))
76 }
77
73 mutating func finish() { 78 mutating func finish() {
74 let (kind, children) = stack.removeLast() 79 let (kind, children) = stack.removeLast()
75 let node = GreenNode(kind: kind, children: children) 80 let node = GreenNode(kind: kind, children: children)
Sources/OrgCore/Syntax/SyntaxNode.swift +40
@@ -38,6 +38,46 @@ public final class SyntaxNode: Sendable {
38 return result 38 return result
39 } 39 }
40 40
41 /// Child nodes overlapping `range`. Only those are created, so a section with thousands of
42 /// children costs little when the range is small.
43 public func children(overlapping range: Range<Int>) -> [SyntaxNode] {
44 var result: [SyntaxNode] = []
45 var at = offset
46 for child in green.children {
47 let childRange = at..<(at + child.length)
48 if childRange.lowerBound >= range.upperBound, !range.isEmpty { break }
49 if case .node(let node) = child, childRange.overlaps(range) {
50 result.append(SyntaxNode(green: node, offset: at, parent: self))
51 }
52 at = childRange.upperBound
53 }
54 return result
55 }
56
57 /// The child node whose range contains `position`.
58 public func child(containing position: Int) -> SyntaxNode? {
59 var at = offset
60 for child in green.children {
61 let end = at + child.length
62 if position < end {
63 if case .node(let node) = child, position >= at { return SyntaxNode(green: node, offset: at, parent: self) }
64 return nil
65 }
66 at = end
67 }
68 return nil
69 }
70
71 /// The first child node of `kind`.
72 public func firstChild(_ kind: SyntaxKind) -> SyntaxNode? {
73 var at = offset
74 for child in green.children {
75 if case .node(let node) = child, node.kind == kind { return SyntaxNode(green: node, offset: at, parent: self) }
76 at += child.length
77 }
78 return nil
79 }
80
41 /// This node and every node below it, in document order. 81 /// This node and every node below it, in document order.
42 public func descendants() -> [SyntaxNode] { 82 public func descendants() -> [SyntaxNode] {
43 [self] + children.flatMap { $0.descendants() } 83 [self] + children.flatMap { $0.descendants() }
Tests/OrgCoreTests/IncrementalTests.swift +17 −2
@@ -49,8 +49,23 @@ struct IncrementalTests {
49 #expect(checkReparse("* a\nx\ny\n* b\n", 6..<6, "* c\n") == .sections) 49 #expect(checkReparse("* a\nx\ny\n* b\n", 6..<6, "* c\n") == .sections)
50 } 50 }
51 51
52 @Test func joiningParagraphsReparsesSections() { 52 @Test func joiningParagraphsReparsesARegion() {
53 #expect(checkReparse("a\n\nb\n", 1..<2, "") == .sections) 53 #expect(checkReparse("a\n\nb\n", 1..<2, "") == .region)
54 #expect(checkReparse("* h\nfirst\nsecond\n\nthird\n** child\n", 15..<15, "\n\n") == .region)
55 }
56
57 @Test func regionGrowsUntilABoundaryHolds() {
58 // Splitting a paragraph next to a table and a list.
59 #expect(checkReparse("* h\nx\ntext\nmore\n| a |\n- i\n", 9..<9, "\n") == .region)
60 // A new begin line without its end runs to the end of the body.
61 #expect(checkReparse("* h\nx\none\ntwo\n#+end_src\nthree\n", 6..<6, "\n#+begin_src") == .region)
62 }
63
64 @Test func regionDefersToSectionsNearHeadings() {
65 // The first body line could become a planning line.
66 #expect(checkReparse("* h\nbody\n", 4..<4, "\n") != .region)
67 // An added end delimiter could close a block that starts before the window.
68 #expect(checkReparse("x\n#+begin_src\na\n\nb\n", 16..<16, "#+end_src\n") != .region)
54 } 69 }
55 70
56 @Test func runGrowsUntilAHeadingEndsIt() { 71 @Test func runGrowsUntilAHeadingEndsIt() {