Keep folds through multi-place edits; title citations; table-line clamp !142

merged merged by cmc on 2026-10-07 22:38 UTC · krz/orgstar:review-folds into main

11 files changed, +208 −124

Layout: unified · split

Sources/OrgCore/Capture/Capture.swift +5 −2
@@ -764,8 +764,10 @@ public enum Capture {
764764 table = origin..<buffer.point
765765 }
766766 let rows = buffer.substring(table!.lowerBound, table!.upperBound).components(separatedBy: "\n")
767 /// The start of the table's line `index`, within the table, as Org narrows to it.
767768 func lineStart(_ index: Int) -> Int {
768 buffer.saveExcursion {
769 guard index > 0 else { return table!.lowerBound }
770 return buffer.saveExcursion {
769771 buffer.point = table!.lowerBound
770772 return buffer.forwardLine(index) == 0 ? min(buffer.point, table!.upperBound) : table!.upperBound
771773 }
@@ -777,7 +779,8 @@ public enum Capture {
777779 guard let delta = Int(m.2.replacingOccurrences(of: "+", with: "")), (-999_999...999_999).contains(delta) else {
778780 throw Failure.message("Invalid table line specification \"\(pos)\"")
779781 }
780 buffer.point = lineStart(hlines[m.1.count - 1] + delta + (delta < 0 ? 0 : -1) + 1)
782 // `forward-line` stops at the table's start, then one more line down.
783 buffer.point = lineStart(max(0, hlines[m.1.count - 1] + delta + (delta < 0 ? 0 : -1)) + 1)
781784 } else if options.prepend {
782785 if let rule = rows.firstIndex(where: { $0.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil }) {
783786 let data = rows.indices.first { $0 > rule && rows[$0].range(of: "^[ \\t]*\\|[^-]", options: .regularExpression) != nil }
Sources/OrgCore/Export/Citations.swift +9 −1
@@ -399,7 +399,15 @@ struct CiteProcessor {
399399 }
400400 }
401401 let tree = OrgParser.parse(text)
402 let citations = tree.root.descendants().filter { $0.kind == .citation }.compactMap { Self.parse($0.text) }
402 // Keywords (`#+TITLE`) aren't part of the document Org processes citations in.
403 var nodes: [SyntaxNode] = []
404 func collect(_ node: SyntaxNode) {
405 for child in node.children where child.kind != .keyword {
406 if child.kind == .citation { nodes.append(child) } else { collect(child) }
407 }
408 }
409 collect(tree.root)
410 let citations = nodes.compactMap { Self.parse($0.text) }
403411 var keys: [String] = []
404412 for citation in citations { for r in citation.references where !keys.contains(r.key) { keys.append(r.key) } }
405413 for citation in citations {
Sources/OrgCore/Export/HTMLExport.swift +7 −1
@@ -47,8 +47,11 @@ public enum HTMLExport {
4747 let meta = metadata(text, settings: settings)
4848 let title = meta.title ?? fallbackTitle
4949 let renderer = HTMLRenderer(settings: settings, options: options)
50 renderer.citations = CiteProcessor(text: text, directory: directory)
50 // The title isn't part of the document Org processes citations in: they drop out.
51 renderer.inTitle = true
5152 let titleHTML = renderer.inlineString(title)
53 renderer.inTitle = false
54 renderer.citations = CiteProcessor(text: text, directory: directory)
5255 var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : ""
5356 let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty }
5457 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
@@ -107,6 +110,8 @@ final class HTMLRenderer {
107110 private var ids: Set<String> = []
108111 /// Citations and the bibliography, as `oc-basic` exports them.
109112 var citations: CiteProcessor?
113 /// Rendering the title, where Org exports citations as nothing.
114 var inTitle = false
110115
111116 enum FootnoteContent {
112117 case definition(String)
@@ -702,6 +707,7 @@ final class HTMLRenderer {
702707 guard node.tokens.first?.text == "@@html:" else { return "" }
703708 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
704709 case .citation:
710 if inTitle { return "" }
705711 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
706712 return render(pieces)
707713 case .inlineBabelCall: return Self.escape(node.text)
Sources/OrgCore/Export/MarkdownExport.swift +6 −1
@@ -6,9 +6,11 @@ public enum MarkdownExport {
66 /// `path` is the file's, for `#+bibliography:`.
77 public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil) -> String {
88 let renderer = MarkdownRenderer(settings: settings)
9 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() })
109 var out = ""
10 renderer.inTitle = true
1111 if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
12 renderer.inTitle = false
13 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() })
1214 out += renderer.document(renderer.citations == nil ? text : CiteProcessor.removingNocite(text))
1315 return out.trimmingCharacters(in: .newlines) + "\n"
1416 }
@@ -22,6 +24,8 @@ final class MarkdownRenderer {
2224 private var anonymous = 0
2325 /// Citations and the bibliography, as `oc-basic` exports them.
2426 var citations: CiteProcessor?
27 /// Rendering the title, where Org exports citations as nothing.
28 var inTitle = false
2529
2630 init(settings: OrgSettings) {
2731 self.settings = settings
@@ -276,6 +280,7 @@ final class MarkdownRenderer {
276280 guard node.tokens.first?.text == "@@html:" else { return "" }
277281 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
278282 case .citation:
283 if inTitle { return "" }
279284 guard let pieces = citations?.next(node.text) else { return node.text }
280285 return render(pieces)
281286 case .inlineBabelCall: return node.text
Sources/OrgCore/LineDiff.swift added +138
@@ -0,0 +1,138 @@
1import Foundation
2
3// Line diffs: Myers over line ids, for merges, diffs and commands' edits.
4
5/// Edits, in `old` coordinates and ascending order, that turn `old` into `new` line by line.
6public func lineEdits(from old: String, to new: String) -> [TextEdit] {
7 let oldLines = textLines(old)
8 let newLines = textLines(new)
9 var ids = LineIDs()
10 let pairs = matchingLines(ids.encode(oldLines), ids.encode(newLines))
11 var offsets = [0]
12 for line in oldLines { offsets.append(offsets.last! + line.utf16.count) }
13 var edits: [TextEdit] = []
14 var i = 0, j = 0
15 for (pi, pj) in pairs + [(oldLines.count, newLines.count)] {
16 if i < pi || j < pj {
17 edits.append(TextEdit(range: offsets[i]..<offsets[pi], replacement: newLines[j..<pj].joined()))
18 }
19 i = pi + 1
20 j = pj + 1
21 }
22 return edits
23}
24
25/// Lines with their endings.
26public func textLines(_ text: String) -> [String] {
27 var lines: [String] = []
28 var current = ""
29 for scalar in text.unicodeScalars {
30 current.unicodeScalars.append(scalar)
31 if scalar == "\n" {
32 lines.append(current)
33 current = ""
34 }
35 }
36 if !current.isEmpty { lines.append(current) }
37 return lines
38}
39
40public struct LineIDs {
41 private var ids: [String: Int] = [:]
42
43 public init() {}
44
45 public mutating func encode(_ lines: [String]) -> [Int] {
46 lines.map { line in
47 if let id = ids[line] { return id }
48 let id = ids.count
49 ids[line] = id
50 return id
51 }
52 }
53}
54
55/// Matched index pairs of a longest common subsequence, ascending. Common prefix and suffix
56/// are matched directly; Myers' algorithm handles the middle. When the middle differs by more
57/// than the memory budget allows, it is treated as having no matches.
58public func matchingLines(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
59 var prefix = 0
60 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
61 var suffix = 0
62 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] {
63 suffix += 1
64 }
65 var pairs = (0..<prefix).map { ($0, $0) }
66 let middleA = Array(a[prefix..<(a.count - suffix)])
67 let middleB = Array(b[prefix..<(b.count - suffix)])
68 pairs += myers(middleA, middleB).map { ($0.0 + prefix, $0.1 + prefix) }
69 pairs += (0..<suffix).map { (a.count - suffix + $0, b.count - suffix + $0) }
70 return pairs
71}
72
73private func myers(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
74 let n = a.count, m = b.count
75 guard n > 0, m > 0 else { return [] }
76 let maxD = n + m
77 let offset = maxD + 1
78 // Each step keeps a copy of the frontier for backtracking; cap that at ~20M entries.
79 let budget = max(1, 20_000_000 / (2 * maxD + 3))
80 var v = [Int](repeating: 0, count: 2 * maxD + 3)
81 var trace: [[Int]] = []
82 var found = false
83 search: for d in 0...min(maxD, budget) {
84 trace.append(v)
85 for k in stride(from: -d, through: d, by: 2) {
86 var x = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1
87 var y = x - k
88 while x < n, y < m, a[x] == b[y] {
89 x += 1
90 y += 1
91 }
92 v[offset + k] = x
93 if x >= n, y >= m {
94 found = true
95 break search
96 }
97 }
98 }
99 guard found else { return [] }
100
101 var pairs: [(Int, Int)] = []
102 var x = n, y = m
103 for d in stride(from: trace.count - 1, through: 0, by: -1) {
104 let v = trace[d]
105 let k = x - y
106 let previousK = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? k + 1 : k - 1
107 let previousX = v[offset + previousK]
108 let previousY = previousX - previousK
109 while x > previousX, y > previousY {
110 pairs.append((x - 1, y - 1))
111 x -= 1
112 y -= 1
113 }
114 if d > 0 {
115 x = previousX
116 y = previousY
117 }
118 }
119 return pairs.reversed()
120}
121
122
123/// An edit spanning several lines as the lines it changes, so what lies between (a folded
124/// heading) is left alone: a command changing the text in two places gives two edits. For
125/// editors applying edits; commands still return one.
126public func splitByLines(_ edit: TextEdit, in text: String) -> [TextEdit] {
127 let ns = text as NSString
128 let start = ns.lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
129 var end = edit.range.upperBound
130 if end < ns.length, end == 0 || ns.character(at: end - 1) != 10 {
131 end = NSMaxRange(ns.lineRange(for: NSRange(location: end, length: 0)))
132 }
133 let old = ns.substring(with: NSRange(start..<end))
134 guard old.utf16.lazy.filter({ $0 == 10 }).count >= 2 else { return [edit] }
135 let new = ns.substring(with: NSRange(start..<edit.range.lowerBound)) + edit.replacement + ns.substring(with: NSRange(edit.range.upperBound..<end))
136 let edits = lineEdits(from: old, to: new).map { TextEdit(range: ($0.range.lowerBound + start)..<($0.range.upperBound + start), replacement: $0.replacement) }
137 return edits.isEmpty ? [edit] : edits
138}
Sources/OrgDocument/Merge.swift −115
@@ -59,121 +59,6 @@ public func threeWayMerge(base: String, ours: String, theirs: String) -> MergeRe
5959 return conflicts.isEmpty ? .merged(merged.joined()) : .conflict(conflicts)
6060}
6161
62/// Edits, in `old` coordinates and ascending order, that turn `old` into `new` line by line.
63public func lineEdits(from old: String, to new: String) -> [TextEdit] {
64 let oldLines = textLines(old)
65 let newLines = textLines(new)
66 var ids = LineIDs()
67 let pairs = matchingLines(ids.encode(oldLines), ids.encode(newLines))
68 var offsets = [0]
69 for line in oldLines { offsets.append(offsets.last! + line.utf16.count) }
70 var edits: [TextEdit] = []
71 var i = 0, j = 0
72 for (pi, pj) in pairs + [(oldLines.count, newLines.count)] {
73 if i < pi || j < pj {
74 edits.append(TextEdit(range: offsets[i]..<offsets[pi], replacement: newLines[j..<pj].joined()))
75 }
76 i = pi + 1
77 j = pj + 1
78 }
79 return edits
80}
81
82/// Lines with their endings.
83func textLines(_ text: String) -> [String] {
84 var lines: [String] = []
85 var current = ""
86 for scalar in text.unicodeScalars {
87 current.unicodeScalars.append(scalar)
88 if scalar == "\n" {
89 lines.append(current)
90 current = ""
91 }
92 }
93 if !current.isEmpty { lines.append(current) }
94 return lines
95}
96
97struct LineIDs {
98 private var ids: [String: Int] = [:]
99
100 mutating func encode(_ lines: [String]) -> [Int] {
101 lines.map { line in
102 if let id = ids[line] { return id }
103 let id = ids.count
104 ids[line] = id
105 return id
106 }
107 }
108}
109
110/// Matched index pairs of a longest common subsequence, ascending. Common prefix and suffix
111/// are matched directly; Myers' algorithm handles the middle. When the middle differs by more
112/// than the memory budget allows, it is treated as having no matches.
113func matchingLines(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
114 var prefix = 0
115 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
116 var suffix = 0
117 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] {
118 suffix += 1
119 }
120 var pairs = (0..<prefix).map { ($0, $0) }
121 let middleA = Array(a[prefix..<(a.count - suffix)])
122 let middleB = Array(b[prefix..<(b.count - suffix)])
123 pairs += myers(middleA, middleB).map { ($0.0 + prefix, $0.1 + prefix) }
124 pairs += (0..<suffix).map { (a.count - suffix + $0, b.count - suffix + $0) }
125 return pairs
126}
127
128private func myers(_ a: [Int], _ b: [Int]) -> [(Int, Int)] {
129 let n = a.count, m = b.count
130 guard n > 0, m > 0 else { return [] }
131 let maxD = n + m
132 let offset = maxD + 1
133 // Each step keeps a copy of the frontier for backtracking; cap that at ~20M entries.
134 let budget = max(1, 20_000_000 / (2 * maxD + 3))
135 var v = [Int](repeating: 0, count: 2 * maxD + 3)
136 var trace: [[Int]] = []
137 var found = false
138 search: for d in 0...min(maxD, budget) {
139 trace.append(v)
140 for k in stride(from: -d, through: d, by: 2) {
141 var x = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1
142 var y = x - k
143 while x < n, y < m, a[x] == b[y] {
144 x += 1
145 y += 1
146 }
147 v[offset + k] = x
148 if x >= n, y >= m {
149 found = true
150 break search
151 }
152 }
153 }
154 guard found else { return [] }
155
156 var pairs: [(Int, Int)] = []
157 var x = n, y = m
158 for d in stride(from: trace.count - 1, through: 0, by: -1) {
159 let v = trace[d]
160 let k = x - y
161 let previousK = (k == -d || (k != d && v[offset + k - 1] < v[offset + k + 1])) ? k + 1 : k - 1
162 let previousX = v[offset + previousK]
163 let previousY = previousX - previousK
164 while x > previousX, y > previousY {
165 pairs.append((x - 1, y - 1))
166 x -= 1
167 y -= 1
168 }
169 if d > 0 {
170 x = previousX
171 y = previousY
172 }
173 }
174 return pairs.reversed()
175}
176
17762/// One line of a diff, with its ending.
17863public struct DiffLine: Sendable, Equatable {
17964 public enum Kind: Sendable, Equatable { case same, removed, added }
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
@@ -964,7 +964,9 @@ public final class OrgEditor: NSObject {
964964 guard !edits.isEmpty, let storage = textView.textStorage else { return }
965965 textView.breakUndoCoalescing()
966966 textView.undoManager?.beginUndoGrouping()
967 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
967 // By lines, so a fold between two changes stays.
968 let text = storage.string
969 for edit in edits.flatMap({ splitByLines($0, in: text) }).sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
968970 let range = NSRange(edit.range)
969971 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
970972 storage.replaceCharacters(in: range, with: edit.replacement)
Sources/OrgstarMobile/MobileEditor.swift +3 −3
@@ -319,7 +319,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
319319 // keyboard's substitutions (smart dashes) when another view has the keyboard.
320320 let storage = textView.textStorage
321321 let old = storage.string as NSString
322 let ascending = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
322 // By lines, so a fold between two changes stays.
323 let ascending = edits.flatMap { splitByLines($0, in: storage.string) }.sorted { $0.range.lowerBound < $1.range.lowerBound }
323324 var inverse: [TextEdit] = []
324325 var delta = 0
325326 for edit in ascending {
@@ -329,11 +330,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
329330 delta += length - edit.range.count
330331 }
331332 let caret = textView.selectedRange
332 storage.beginEditing()
333 // One at a time, last first: each is processed alone, so folds between them stay.
333334 for edit in ascending.reversed() {
334335 storage.replaceCharacters(in: NSRange(edit.range), with: edit.replacement)
335336 }
336 storage.endEditing()
337337 textView.undoManager?.registerUndo(withTarget: self) { editor in
338338 editor.applyEdits(inverse)
339339 editor.textView.selectedRange = caret
Tests/OrgCoreTests/CaptureTests.swift +2
@@ -153,6 +153,8 @@ struct CaptureTests {
153153 CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "| first %? |\n| second |",
154154 options: .init(tableLinePos: "I+1")),
155155 CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .file(""), template: "| %? |"),
156 CaptureTemplate(key: "x", name: "", kind: .tableLine, target: .headline(file: "", headline: "Inbox"), template: "| far %? |",
157 options: .init(tableLinePos: "I-9")),
156158 ]
157159
158160 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
Tests/OrgCoreTests/CitationTests.swift +11
@@ -98,6 +98,17 @@ struct CitationTests {
9898 #expect(!page.contains("[cite"))
9999 }
100100
101 @Test func aTitleCitationLeavesTheBodysAlone() throws {
102 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-cite-\(UUID().uuidString)")
103 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
104 defer { try? FileManager.default.removeItem(at: folder) }
105 try Self.bib.write(to: folder.appendingPathComponent("refs.bib"), atomically: true, encoding: .utf8)
106 let page = HTMLExport.page("#+bibliography: refs.bib\n#+TITLE: On [cite:@doe2020]\nSee [cite:@doe2020].\n", fallbackTitle: "t",
107 path: folder.appendingPathComponent("doc.org").path)
108 #expect(page.contains("<h1 class=\"title\">On</h1>"))
109 #expect(page.contains("<p>See (Doe, Jane and Roe, Richard, 2020).</p>"))
110 }
111
101112 @Test(.enabled(if: oracle))
102113 func authorYearMatchesOcBasic() throws {
103114 try compare("#+bibliography: refs.bib\n#+bibliography: more.json\n" + Self.body)
Tests/OrgCoreTests/LineDiffTests.swift added +24
@@ -0,0 +1,24 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct LineDiffTests {
6 @Test func splitsAnEditSpanningSeveralLinesIntoTheLinesItChanges() {
7 let text = "* Top\nText here.\n* Folded\n** Child\nbody\n* End\n"
8 let new = "* Top\nText here.[fn:1]\n* Folded\n** Child\nbody\n* End\n\n* Footnotes\n\n[fn:1] \n"
9 let one = TextEdit.between(text, new)
10 #expect(one.count == 1)
11 let split = splitByLines(one[0], in: text)
12 #expect(split.count == 2)
13 #expect(split.allSatisfy { !(text as NSString).substring(with: NSRange($0.range)).contains("* Folded") })
14 var result = text
15 for edit in split.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { result = edit.apply(to: result) }
16 #expect(result == new)
17 }
18
19 @Test func leavesAnEditWithinALineAlone() {
20 let text = "* a\nbc\n"
21 let edit = TextEdit(range: 4..<5, replacement: "x")
22 #expect(splitByLines(edit, in: text) == [edit])
23 }
24}