krz/orgstar

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

Commit 520c19a5a0

520c19a5a032aba7670f66906b721f4333a160b3

parent: 60d94beed4

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 04:38 UTC

Port plain list commands from org

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +2
@@ -97,6 +97,8 @@ public enum Commands {
97 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), 97 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
98 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), 98 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(),
99 SetTags(), 99 SetTags(),
100 InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(),
101 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
100 ] 102 ]
101 103
102 public static func command(_ id: String) -> (any OrgCommand)? { 104 public static func command(_ id: String) -> (any OrgCommand)? {
Sources/OrgCore/Commands/EmacsBuffer.swift +83 −15
@@ -20,14 +20,37 @@ final class EmacsBuffer {
20 var point: Int 20 var point: Int
21 private var markers: [Marker] = [] 21 private var markers: [Marker] = []
22 let settings: OrgSettings 22 let settings: OrgSettings
23 /// Groups of the last successful match, as Emacs's match data.
24 var lastMatch: [Range<Int>?] = []
23 25
24 init(_ text: String, point: Int, settings: OrgSettings = .default) { 26 init(_ text: String, point: Int, settings: OrgSettings = .default) {
25 self.text = NSMutableString(string: text) 27 self.text = NSMutableString(string: text)
26 self.point = point 28 self.point = point
27 self.settings = settings 29 self.settings = settings
30 unchangedPrefix = self.text.length
31 unchangedSuffix = self.text.length
28 } 32 }
29 33
30 var string: String { text as String } 34 /// Lengths of the start and end of the text no edit has touched, so the result's diff can
35 /// look only between them.
36 private(set) var unchangedPrefix: Int
37 private(set) var unchangedSuffix: Int
38
39 private func touched(_ range: Range<Int>) {
40 unchangedPrefix = min(unchangedPrefix, range.lowerBound)
41 unchangedSuffix = min(unchangedSuffix, length - range.upperBound)
42 }
43
44 /// The text as a `String` backed by an immutable `NSString`, copied once per change rather
45 /// than per regexp search; regexps read it without converting.
46 var string: String {
47 if let cachedString { return cachedString as String }
48 let copy = text.copy() as! NSString
49 cachedString = copy
50 return copy as String
51 }
52
53 private var cachedString: NSString?
31 var length: Int { text.length } 54 var length: Int { text.length }
32 55
33 func character(at offset: Int) -> unichar? { 56 func character(at offset: Int) -> unichar? {
@@ -65,7 +88,9 @@ final class EmacsBuffer {
65 func insert(_ string: String) { 88 func insert(_ string: String) {
66 let count = (string as NSString).length 89 let count = (string as NSString).length
67 guard count > 0 else { return } 90 guard count > 0 else { return }
91 touched(point..<point)
68 text.insert(string, at: point) 92 text.insert(string, at: point)
93 cachedString = nil
69 for marker in markers where marker.position > point { marker.position += count } 94 for marker in markers where marker.position > point { marker.position += count }
70 point += count 95 point += count
71 } 96 }
@@ -74,7 +99,9 @@ final class EmacsBuffer {
74 func insertBeforeMarkers(_ string: String) { 99 func insertBeforeMarkers(_ string: String) {
75 let count = (string as NSString).length 100 let count = (string as NSString).length
76 guard count > 0 else { return } 101 guard count > 0 else { return }
102 touched(point..<point)
77 text.insert(string, at: point) 103 text.insert(string, at: point)
104 cachedString = nil
78 for marker in markers where marker.position >= point { marker.position += count } 105 for marker in markers where marker.position >= point { marker.position += count }
79 point += count 106 point += count
80 } 107 }
@@ -82,7 +109,9 @@ final class EmacsBuffer {
82 func deleteRegion(_ start: Int, _ end: Int) { 109 func deleteRegion(_ start: Int, _ end: Int) {
83 let (lower, upper) = (min(start, end), max(start, end)) 110 let (lower, upper) = (min(start, end), max(start, end))
84 guard upper > lower else { return } 111 guard upper > lower else { return }
112 touched(lower..<upper)
85 text.deleteCharacters(in: NSRange(lower..<upper)) 113 text.deleteCharacters(in: NSRange(lower..<upper))
114 cachedString = nil
86 func map(_ position: Int) -> Int { 115 func map(_ position: Int) -> Int {
87 position <= lower ? position : position >= upper ? position - (upper - lower) : lower 116 position <= lower ? position : position >= upper ? position - (upper - lower) : lower
88 } 117 }
@@ -98,7 +127,9 @@ final class EmacsBuffer {
98 /// or after its end shift with the text. 127 /// or after its end shift with the text.
99 func replace(_ range: Range<Int>, with replacement: String) { 128 func replace(_ range: Range<Int>, with replacement: String) {
100 let delta = (replacement as NSString).length - range.count 129 let delta = (replacement as NSString).length - range.count
130 touched(range)
101 text.replaceCharacters(in: NSRange(range), with: replacement) 131 text.replaceCharacters(in: NSRange(range), with: replacement)
132 cachedString = nil
102 func map(_ position: Int) -> Int { 133 func map(_ position: Int) -> Int {
103 if position >= range.upperBound, position > range.lowerBound { return position + delta } 134 if position >= range.upperBound, position > range.lowerBound { return position + delta }
104 return position > range.lowerBound ? range.lowerBound : position 135 return position > range.lowerBound ? range.lowerBound : position
@@ -164,11 +195,19 @@ final class EmacsBuffer {
164 195
165 var currentColumn: Int { column(of: substring(lineStart(point), point)) } 196 var currentColumn: Int { column(of: substring(lineStart(point), point)) }
166 197
167 /// `move-to-column`: as far as `target` on this line, or the line's end. 198 /// `move-to-column`: as far as `target` on this line, or the line's end. With `force`, a
168 func moveToColumn(_ target: Int) { 199 /// tab spanning `target` becomes spaces (`indent-tabs-mode` nil) so point lands on it.
200 func moveToColumn(_ target: Int, force: Bool = false) {
169 let start = lineStart(point) 201 let start = lineStart(point)
170 let line = LineBuffer(substring(start, lineEnd(point)), caret: nil) 202 let line = LineBuffer(substring(start, lineEnd(point)), caret: nil)
171 point = start + line.offset(ofColumn: target) 203 point = start + line.offset(ofColumn: target)
204 guard force, currentColumn > target, character(at: point - 1) == 9 else { return }
205 let tabEnd = currentColumn
206 point -= 1
207 let tabStart = currentColumn
208 deleteRegion(point, point + 1)
209 insert(String(repeating: " ", count: tabEnd - tabStart))
210 point -= tabEnd - target
172 } 211 }
173 212
174 // MARK: - Outline 213 // MARK: - Outline
@@ -305,15 +344,34 @@ final class EmacsBuffer {
305 344
306 // MARK: - Regular expressions 345 // MARK: - Regular expressions
307 346
347 static let lookingOptions: NSRegularExpression.MatchingOptions = [.anchored, .withoutAnchoringBounds, .withTransparentBounds]
348 static let searchOptions: NSRegularExpression.MatchingOptions = [.withoutAnchoringBounds, .withTransparentBounds]
349 nonisolated(unsafe) private static var cache: [String: NSRegularExpression] = [:]
350 private static let cacheLock = NSLock()
351
352 /// A compiled Emacs-style pattern: `^` and `$` match at line boundaries, and a leading `^`
353 /// also matches at the end of a buffer that ends with a newline, as in Emacs.
354 static func regex(_ pattern: String, caseInsensitive: Bool) -> NSRegularExpression {
355 let key = (caseInsensitive ? "i" : "c") + pattern
356 return cacheLock.withLock {
357 if let cached = cache[key] { return cached }
358 let source = pattern.hasPrefix("^") ? "(?:(?<=\\n)|(?<![\\s\\S]))" + pattern.dropFirst() : pattern
359 let compiled = try! NSRegularExpression(pattern: source, options: caseInsensitive ? [.anchorsMatchLines, .caseInsensitive] : [.anchorsMatchLines])
360 cache[key] = compiled
361 return compiled
362 }
363 }
364
308 /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines), 365 /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines),
309 /// or nil. Unmatched groups are nil. 366 /// or nil. Unmatched groups are nil.
310 func lookingAt(_ pattern: String) -> [Range<Int>?]? { 367 func lookingAt(_ pattern: String) -> [Range<Int>?]? {
311 let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) 368 let regex = Self.regex(pattern, caseInsensitive: false)
312 guard let match = regex.firstMatch(in: string, options: [.anchored], range: NSRange(point..<length)) else { return nil } 369 guard let match = regex.firstMatch(in: string, options: Self.lookingOptions, range: NSRange(point..<length)) else { return nil }
313 return (0..<match.numberOfRanges).map { i in 370 lastMatch = (0..<match.numberOfRanges).map { i in
314 let range = match.range(at: i) 371 let range = match.range(at: i)
315 return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range) 372 return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
316 } 373 }
374 return lastMatch
317 } 375 }
318 376
319 /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags. 377 /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags.
@@ -356,7 +414,9 @@ final class EmacsBuffer {
356 } 414 }
357 let newPoint = map(point) 415 let newPoint = map(point)
358 let newMarkers = markers.map { map($0.position) } 416 let newMarkers = markers.map { map($0.position) }
417 touched(start..<end)
359 text.replaceCharacters(in: NSRange(start..<end), with: plain.string) 418 text.replaceCharacters(in: NSRange(start..<end), with: plain.string)
419 cachedString = nil
360 point = newPoint 420 point = newPoint
361 for (marker, position) in zip(markers, newMarkers) { marker.position = position } 421 for (marker, position) in zip(markers, newMarkers) { marker.position = position }
362 } 422 }
@@ -373,20 +433,28 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void)
373 } catch { 433 } catch {
374 return .failed("\(error)") 434 return .failed("\(error)")
375 } 435 }
376 let old = context.text as NSString 436 // Only the span between the untouched start and end can differ.
377 let new = buffer.text 437 let oldText = context.text.utf16
438 let oldCount = oldText.count
439 let newCount = buffer.length
440 let lower = min(buffer.unchangedPrefix, oldCount, newCount)
441 let window = max(0, min(buffer.unchangedSuffix, oldCount - lower, newCount - lower))
442 let old = Array(oldText[oldText.index(oldText.startIndex, offsetBy: lower)..<oldText.index(oldText.startIndex, offsetBy: oldCount - window)])
443 var newBuffer = [unichar](repeating: 0, count: newCount - window - lower)
444 buffer.text.getCharacters(&newBuffer, range: NSRange(lower..<(newCount - window)))
445 let new = newBuffer
378 var prefix = 0 446 var prefix = 0
379 let shorter = min(old.length, new.length) 447 let shorter = min(old.count, new.count)
380 while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 } 448 while prefix < shorter, old[prefix] == new[prefix] { prefix += 1 }
381 var suffix = 0 449 var suffix = 0
382 while suffix < shorter - prefix, old.character(at: old.length - 1 - suffix) == new.character(at: new.length - 1 - suffix) { suffix += 1 } 450 while suffix < shorter - prefix, old[old.count - 1 - suffix] == new[new.count - 1 - suffix] { suffix += 1 }
383 // Keep surrogate pairs whole. 451 // Keep surrogate pairs whole.
384 while prefix > 0, prefix < old.length, UTF16.isTrailSurrogate(old.character(at: prefix)) { prefix -= 1 } 452 while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 }
385 while suffix > 0, UTF16.isTrailSurrogate(old.character(at: old.length - suffix)) { suffix -= 1 } 453 while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 }
386 let caret = buffer.point 454 let caret = buffer.point
387 guard old.length != new.length || prefix != old.length else { 455 guard old.count != new.count || prefix != old.count else {
388 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) 456 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret]))
389 } 457 }
390 let edit = TextEdit(range: prefix..<(old.length - suffix), replacement: new.substring(with: NSRange(prefix..<(new.length - suffix)))) 458 let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self))
391 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret])) 459 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret]))
392} 460}
Sources/OrgCore/Commands/ListCommands.swift added +369
@@ -0,0 +1,369 @@
1import Foundation
2
3// Plain list commands from org-list.el (Org 9.8.7) on `EmacsBuffer`, matched to Emacs by the
4// oracle tests. The ORDERED property, radio lists and timer items are not handled.
5
6extension EmacsBuffer {
7 /// `org-insert-item`. False when point isn't in an item.
8 func insertListItem(checkbox: Bool) -> Bool {
9 guard let start = inItem() else { return false }
10 let pos = point
11 var structure = saveExcursion { point = start; return listStruct() }
12 let prevs = structure.prevs
13 let description = structure.listType(start, prevs) == .descriptive ? " :: " : nil
14 structure = insertItem(at: pos, structure, prevs, checkbox: checkbox, afterBullet: description)
15 writeStruct(&structure, structure.parents)
16 if checkbox { updateCheckboxCount() }
17 forwardLine(0)
18 let match = looking(Self.fullItemPattern)!
19 if let tag = match[4], substring(match[1]!.lowerBound, match[1]!.upperBound).contains(where: { $0 == "." || $0 == ")" }) {
20 point = tag.lowerBound
21 } else {
22 point = match[0]!.upperBound
23 }
24 if description != nil { point -= 1 }
25 return true
26 }
27
28 /// `org-list-indent-item-generic` without a region.
29 func indentItem(_ direction: Int, subtree: Bool) throws {
30 guard atItem else { throw UserError(message: "Not at an item") }
31 var structure = listStruct()
32 try saveExcursion {
33 let top = structure.top
34 let parents = structure.parents
35 let prevs = structure.prevs
36 let line = lineStart(point)
37 let special = top == line
38 if special, !subtree { throw UserError(message: "At first item: use S-M-<left/right> to move the whole list") }
39 let begin = line
40 let end = special ? structure.bottom : !subtree ? line + 1 : structure.end(line)
41 if special {
42 let offset = direction < 0 ? -1 : 1
43 let topInd = structure.ind(begin)
44 let old = structure
45 guard topInd + offset >= 0 else { throw UserError(message: "Cannot outdent beyond margin") }
46 if topInd + offset == 0, structure.bullet(begin).contains("*") {
47 structure.set(begin) { $0.bullet = bulletString("-") }
48 }
49 for i in structure.indices { structure[i].ind += offset }
50 fixBullets(&structure, prevs)
51 applyStruct(structure, old)
52 } else if direction < 0,
53 (!subtree && structure.hasChild(begin) != nil)
54 || structure.hasChild(structure.last { $0.pos < end }!.pos) != nil {
55 throw UserError(message: "Cannot outdent an item without its children")
56 } else {
57 let old = structure
58 let newParents = direction < 0
59 ? try structOutdent(begin, end, structure, parents)
60 : try structIndent(begin, end, structure, parents, prevs)
61 writeStruct(&structure, newParents, old: old)
62 updateCheckboxCount()
63 }
64 }
65 }
66
67 /// `org-move-item-down` and `org-move-item-up`.
68 func moveItem(down: Bool) throws {
69 guard atItem else { throw UserError(message: "Not at an item") }
70 let column = currentColumn
71 let item = lineStart(point)
72 var structure = listStruct()
73 let prevs = structure.prevs
74 if down {
75 guard let next = structure.nextItem(item, prevs) else { throw UserError(message: "Cannot move this item further down") }
76 structure = swapItems(item, next, structure)
77 point = structure.nextItem(item, structure.prevs)!
78 } else {
79 guard let previous = structure.previousItem(item, prevs) else { throw UserError(message: "Cannot move this item further up") }
80 structure = swapItems(previous, item, structure)
81 }
82 writeStruct(&structure, structure.parents)
83 moveToColumn(column)
84 }
85
86 /// `org-toggle-checkbox` on the item at point.
87 func toggleCheckbox() throws {
88 guard atItem else { throw UserError(message: "Not at an item or heading, and no active region") }
89 saveExcursion {
90 let lower = lineStart(point)
91 let upper = marker(lineEnd(point))
92 defer { release(upper) }
93 point = lower
94 let current = looking(Self.itemPattern).flatMap { match -> String? in
95 let box = saveExcursion { () -> [Range<Int>?]? in
96 point = match[0]!.upperBound
97 if let counter = looking("(?:\\[@(?:start:)?[0-9]+\\][ \\t]*)") { point = counter[0]!.upperBound }
98 return looking("(\\[[- X]\\])[ \\t]+")
99 }
100 return box.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
101 }
102 let reference = current == "[X]" ? "[ ]" : "[X]"
103 point = lower
104 while point < upper.position, searchForward(Self.itemBeginningPattern, bound: upper.position, move: true) != nil {
105 var structure = listStruct()
106 let old = structure
107 let parents = structure.parents
108 let bottom = marker(structure.bottom)
109 for i in structure.indices where structure[i].pos >= lower && structure[i].pos <= upper.position {
110 if structure[i].checkbox != nil { structure[i].checkbox = reference }
111 }
112 fixCheckboxes(&structure, parents)
113 point = bottom.position
114 release(bottom)
115 applyStruct(structure, old)
116 }
117 }
118 updateCheckboxCount()
119 }
120
121 // MARK: - Statistics
122
123 /// `org-update-checkbox-count` for the current section.
124 func updateCheckboxCount() {
125 saveExcursion {
126 let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
127 if beforeFirstHeading { point = 0 } else { _ = try? backToHeading() }
128 let sectionStart = point
129 let section = OrgParser.parse(substring(sectionStart, end))
130 let cookies = section.root.descendants().filter { $0.kind == .statisticsCookie }.map { node in
131 (range: (node.range.lowerBound + sectionStart)..<(node.range.upperBound + sectionStart), node: node)
132 }
133 let cookieData = entryProperty("COOKIE_DATA", sectionStart: sectionStart, end: end) ?? ""
134 let recursive = cookieData.range(of: "\\brecursive\\b", options: .regularExpression) != nil
135 var found: [(begin: Int, end: Int, percent: Bool, checked: Int, total: Int)] = []
136 var cache: [Int: (Int, Int)] = [:]
137 let cookiePattern = "((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))"
138 while let match = searchForward(cookiePattern, bound: end) {
139 let last = match[0]!.upperBound - 1
140 guard let cookie = cookies.first(where: { $0.range.contains(last) }), cookieData.range(of: "\\btodo\\b", options: .regularExpression) == nil else { continue }
141 let container = containerRange(of: cookie.node, sectionStart: sectionStart)
142 let begin = container?.contents.lowerBound ?? saveExcursion { () -> Int in
143 point = match[0]!.lowerBound
144 outlinePreviousHeading()
145 return point
146 }
147 let count: (Int, Int)
148 if let cached = cache[begin] {
149 count = cached
150 } else {
151 let limit = container?.contents.upperBound ?? end
152 var structures: [ListStruct] = []
153 saveExcursion {
154 point = begin
155 let boxPattern = "^[ \\t]*([-+*]|([0-9]+|[A-Za-z])[.)])[ \\t]+(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(\\[[- X]\\])"
156 while searchForward(boxPattern, bound: limit) != nil {
157 guard atItem else { continue }
158 let structure = listStruct()
159 structures.append(structure)
160 point = min(max(structure.bottom, point), limit)
161 }
162 }
163 count = countBoxes(item: container?.item, structures, recursive: recursive)
164 cache[begin] = count
165 }
166 found.insert((match[1]!.lowerBound, match[1]!.upperBound, match[2] != nil, count.0, count.1), at: 0)
167 }
168 for cookie in found {
169 point = cookie.begin
170 let text = cookie.percent ? "[\(Int((100.0 * Double(cookie.checked) / Double(max(1, cookie.total))).rounded(.down)))%]" : "[\(cookie.checked)/\(cookie.total)]"
171 insert(text)
172 deleteRegion(point, point + (cookie.end - cookie.begin))
173 fixTagsOnTheFly()
174 }
175 }
176 }
177
178 /// Checked and total boxes, as `count-boxes` in `org-update-checkbox-count`.
179 private func countBoxes(item: Int?, _ structures: [ListStruct], recursive: Bool) -> (Int, Int) {
180 var on = 0, all = 0
181 for structure in structures {
182 let items: [Int]
183 if recursive, let item {
184 items = structure.subtree(item)
185 } else if recursive {
186 items = structure.map(\.pos)
187 } else if let item {
188 items = structure.children(item, structure.parents)
189 } else {
190 items = structure.allItems(structure.top, structure.prevs)
191 }
192 let boxes = items.compactMap { structure.checkbox($0) }
193 all += boxes.count
194 on += boxes.filter { $0 == "[X]" }.count
195 }
196 return (on, all)
197 }
198
199 /// The innermost container org counts a statistics cookie over: its contents range, and its
200 /// start when it is an item.
201 private func containerRange(of cookie: SyntaxNode, sectionStart: Int) -> (contents: Range<Int>, item: Int?)? {
202 var node = cookie.parent
203 while let current = node {
204 let start = current.range.lowerBound + sectionStart
205 let end = current.range.upperBound + sectionStart
206 switch current.kind {
207 case .item:
208 let item = start
209 let structure = saveExcursion { point = item; return listStruct() }
210 let itemEnd = structure.item(item)?.end ?? end
211 let contentsStart = saveExcursion { () -> Int? in
212 point = item
213 let match = looking(Self.fullItemPattern)!
214 let bullet = substring(match[1]!.lowerBound, match[1]!.upperBound)
215 point = match[4] != nil && bullet.contains(where: { $0 == "." || $0 == ")" }) ? match[4]!.lowerBound : match[0]!.upperBound
216 while point < itemEnd, let c = character(at: point), Self.whitespace.contains(c) { point += 1 }
217 if point == itemEnd { return nil }
218 return bolp ? lineStart(point) : point
219 }
220 guard let contentsStart else { return (item..<item, item) }
221 let contentsEnd = saveExcursion { () -> Int in
222 point = itemEnd
223 skipBackward(Self.whitespace)
224 return min(lineEnd(point) + 1, length)
225 }
226 return (contentsStart..<contentsEnd, item)
227 case .drawer, .dynamicBlock:
228 return (bodyRange(start, end), nil)
229 case .block:
230 let firstLine = substring(start, lineEnd(start)).lowercased()
231 let name = firstLine.range(of: "#\\+begin_(\\S+)", options: .regularExpression).map { String(firstLine[$0].dropFirst(8)) } ?? ""
232 if !["src", "example", "export", "comment"].contains(name) { return (bodyRange(start, end), nil) }
233 default:
234 break
235 }
236 node = current.parent
237 }
238 return nil
239 }
240
241 /// Lines between a container's first and last line.
242 private func bodyRange(_ start: Int, _ end: Int) -> Range<Int> {
243 let contentsStart = lineEnd(start) + 1
244 var last = end
245 if character(at: last - 1) == Self.newline { last -= 1 }
246 let closing = saveExcursion { () -> Int in
247 point = last
248 skipBackward(Self.whitespace)
249 return lineStart(point)
250 }
251 return contentsStart..<max(contentsStart, closing)
252 }
253
254 /// A property of the entry from its property drawer, without inheritance.
255 private func entryProperty(_ name: String, sectionStart: Int, end: Int) -> String? {
256 let text = substring(sectionStart, end)
257 let pattern = "^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]+(.*?)[ \\t]*$"
258 guard let range = text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) else { return nil }
259 return String(text[range]).replacingOccurrences(of: "^[ \\t]*:[^:]+:[ \\t]+", with: "", options: .regularExpression)
260 }
261
262 /// `org-fix-tags-on-the-fly`.
263 private func fixTagsOnTheFly() {
264 let line = saveExcursion { () -> [Range<Int>?]? in
265 beginningOfLine()
266 return looking(SetTags.tagLine)
267 }
268 guard let tags = line?[1], point < tags.lowerBound else { return }
269 alignTags(onLineAt: point)
270 }
271}
272
273private func listCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
274 commitBuffer(context, body)
275}
276
277/// M-RET in a list: `org-insert-item`.
278public struct InsertItem: OrgCommand {
279 public init() {}
280 public var id: String { "org.item.insert" }
281 public var title: String { "Insert Item" }
282 public func applies(in context: EditContext) -> Bool { KeyContext.holds("item", in: context) }
283 public func run(in context: EditContext) -> CommandStep {
284 listCommand(context) { buffer in
285 guard buffer.insertListItem(checkbox: false) else { throw EmacsBuffer.UserError(message: "Not in an item") }
286 }
287 }
288}
289
290/// M-S-RET in a list: `org-insert-item` with a checkbox.
291public struct InsertCheckboxItem: OrgCommand {
292 public init() {}
293 public var id: String { "org.item.insert-checkbox" }
294 public var title: String { "Insert Checkbox Item" }
295 public func applies(in context: EditContext) -> Bool { KeyContext.holds("item", in: context) }
296 public func run(in context: EditContext) -> CommandStep {
297 listCommand(context) { buffer in
298 guard buffer.insertListItem(checkbox: true) else { throw EmacsBuffer.UserError(message: "Not in an item") }
299 }
300 }
301}
302
303/// An item command that needs point on an item's first line.
304private func atItemLine(_ context: EditContext) -> Bool {
305 EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).atItem
306}
307
308/// M-right on an item: `org-indent-item`.
309public struct IndentItem: OrgCommand {
310 public init() {}
311 public var id: String { "org.item.indent" }
312 public var title: String { "Indent Item" }
313 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
314 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(1, subtree: false) } }
315}
316
317/// M-left on an item: `org-outdent-item`.
318public struct OutdentItem: OrgCommand {
319 public init() {}
320 public var id: String { "org.item.outdent" }
321 public var title: String { "Outdent Item" }
322 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
323 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(-1, subtree: false) } }
324}
325
326/// M-S-right on an item: `org-indent-item-tree`.
327public struct IndentItemTree: OrgCommand {
328 public init() {}
329 public var id: String { "org.item.indent-tree" }
330 public var title: String { "Indent Item and Children" }
331 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
332 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(1, subtree: true) } }
333}
334
335/// M-S-left on an item: `org-outdent-item-tree`.
336public struct OutdentItemTree: OrgCommand {
337 public init() {}
338 public var id: String { "org.item.outdent-tree" }
339 public var title: String { "Outdent Item and Children" }
340 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
341 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(-1, subtree: true) } }
342}
343
344/// M-up on an item: `org-move-item-up`.
345public struct MoveItemUp: OrgCommand {
346 public init() {}
347 public var id: String { "org.item.up" }
348 public var title: String { "Move Item Up" }
349 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
350 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.moveItem(down: false) } }
351}
352
353/// M-down on an item: `org-move-item-down`.
354public struct MoveItemDown: OrgCommand {
355 public init() {}
356 public var id: String { "org.item.down" }
357 public var title: String { "Move Item Down" }
358 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
359 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.moveItem(down: true) } }
360}
361
362/// C-c C-c on an item: `org-toggle-checkbox`.
363public struct ToggleCheckbox: OrgCommand {
364 public init() {}
365 public var id: String { "org.checkbox.toggle" }
366 public var title: String { "Toggle Checkbox" }
367 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
368 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.toggleCheckbox() } }
369}
Sources/OrgCore/Commands/OrgList.swift added +976
@@ -0,0 +1,976 @@
1import Foundation
2
3// Org's plain list machinery (org-list.el, Org 9.8.7) on `EmacsBuffer`: the list structure,
4// its repairs (bullets, indentation, checkboxes) and how changes are written back. Defaults as
5// in org: `org-list-allow-alphabetical` nil, `org-plain-list-ordered-item-terminator` t,
6// `org-list-indent-offset` 0, `org-list-demote-modify-bullet` nil,
7// `org-list-two-spaces-after-bullet-regexp` nil, `org-blank-before-new-entry` items `auto`,
8// `org-checkbox-hierarchical-statistics` t, `indent-tabs-mode` nil.
9
10/// One item of `org-list-struct`.
11struct ListItem: Equatable {
12 var pos: Int
13 var ind: Int
14 /// With its trailing whitespace.
15 var bullet: String
16 var counter: String?
17 var checkbox: String?
18 var tag: String?
19 var end: Int
20}
21
22/// Items in buffer order.
23typealias ListStruct = [ListItem]
24
25extension ListStruct {
26 func item(_ pos: Int) -> ListItem? { first { $0.pos == pos } }
27 func index(of pos: Int) -> Int? { firstIndex { $0.pos == pos } }
28 func ind(_ pos: Int) -> Int { item(pos)!.ind }
29 func bullet(_ pos: Int) -> String { item(pos)!.bullet }
30 func checkbox(_ pos: Int) -> String? { item(pos)?.checkbox }
31 func end(_ pos: Int) -> Int { item(pos)!.end }
32 mutating func set(_ pos: Int, _ change: (inout ListItem) -> Void) {
33 if let i = index(of: pos) { change(&self[i]) }
34 }
35
36 var top: Int { self[0].pos }
37 var bottom: Int { map(\.end).max()! }
38
39 /// `org-list-prevs-alist`: item to previous item in its sub-list.
40 var prevs: [Int: Int] {
41 var byEnd: [Int: Int] = [:]
42 for item in self where byEnd[item.end] == nil { byEnd[item.end] = item.pos }
43 var result: [Int: Int] = [:]
44 for item in self { if let previous = byEnd[item.pos] { result[item.pos] = previous } }
45 return result
46 }
47
48 /// `org-list-parents-alist`, in buffer order.
49 var parents: [(item: Int, parent: Int?)] {
50 var indToOri: [(ind: Int, origin: Int?)] = [(self[0].ind, nil)]
51 var previousPositions = [top]
52 var result: [(Int, Int?)] = [(top, nil)]
53 for item in dropFirst() {
54 let previousInd = indToOri[0].ind
55 previousPositions.insert(item.pos, at: 0)
56 if previousInd > item.ind {
57 if let i = indToOri.firstIndex(where: { $0.ind == item.ind }) {
58 indToOri.removeFirst(i)
59 } else if let i = indToOri.firstIndex(where: { $0.ind < item.ind }) {
60 indToOri.removeFirst(i)
61 } else {
62 indToOri = [(item.ind, nil)]
63 }
64 result.append((item.pos, indToOri[0].origin))
65 } else if previousInd < item.ind {
66 let origin = previousPositions[1]
67 indToOri.insert((item.ind, origin), at: 0)
68 result.append((item.pos, origin))
69 } else {
70 result.append((item.pos, indToOri[0].origin))
71 }
72 }
73 return result
74 }
75
76 func nextItem(_ pos: Int, _ prevs: [Int: Int]) -> Int? { first { prevs[$0.pos] == pos }?.pos }
77 func previousItem(_ pos: Int, _ prevs: [Int: Int]) -> Int? { prevs[pos] }
78
79 func hasChild(_ pos: Int) -> Int? {
80 guard let i = index(of: pos), i + 1 < count, self[i + 1].ind > self[i].ind else { return nil }
81 return self[i + 1].pos
82 }
83
84 func subtree(_ pos: Int) -> [Int] {
85 guard let i = index(of: pos) else { return [] }
86 let end = self[i].end
87 return self[(i + 1)...].prefix { $0.pos < end }.map(\.pos)
88 }
89
90 func children(_ pos: Int, _ parents: [(item: Int, parent: Int?)]) -> [Int] {
91 parents.filter { $0.parent == pos }.map(\.item)
92 }
93
94 func parent(_ pos: Int, _ parents: [(item: Int, parent: Int?)]) -> Int? {
95 parents.first { $0.item == pos }?.parent ?? nil
96 }
97
98 func listBegin(_ pos: Int, _ prevs: [Int: Int]) -> Int {
99 var first = pos
100 while let previous = prevs[first] { first = previous }
101 return first
102 }
103
104 func lastItem(_ pos: Int, _ prevs: [Int: Int]) -> Int {
105 var last = pos
106 while let next = nextItem(last, prevs) { last = next }
107 return last
108 }
109
110 func allItems(_ pos: Int, _ prevs: [Int: Int]) -> [Int] {
111 var before: [Int] = []
112 var item = pos
113 while let previous = prevs[item] {
114 before.insert(previous, at: 0)
115 item = previous
116 }
117 var after: [Int] = []
118 item = pos
119 while let next = nextItem(item, prevs) {
120 after.append(next)
121 item = next
122 }
123 return before + [pos] + after
124 }
125
126 enum ListType { case ordered, descriptive, unordered }
127
128 func listType(_ pos: Int, _ prevs: [Int: Int]) -> ListType {
129 let first = item(listBegin(pos, prevs))!
130 if first.bullet.contains(where: { $0.isLetter || $0.isNumber }) { return .ordered }
131 return first.tag != nil ? .descriptive : .unordered
132 }
133}
134
135/// `org-list-struct-assoc-end`: gives each item the first ending at its indentation or less.
136private func assocEnd(_ structure: inout ListStruct, _ endList: [(ind: Int, pos: Int)]) {
137 var endings = endList[...]
138 for i in structure.indices {
139 let item = structure[i]
140 while let first = endings.first, first.pos <= item.pos { endings = endings.dropFirst() }
141 structure[i].end = endings.first { $0.ind <= item.ind }?.pos ?? structure[i].end
142 }
143}
144
145/// `org-list-bullet-string`: one space after the bullet's text.
146func bulletString(_ bullet: String) -> String {
147 guard let range = bullet.range(of: "\\S+([ \\t]*)", options: .regularExpression) else { return bullet }
148 let text = bullet[range]
149 let core = text.prefix { !$0.isWhitespace }
150 return bullet[..<range.lowerBound] + core + " " + bullet[range.upperBound...]
151}
152
153extension EmacsBuffer {
154 // MARK: - Lines and searching
155
156 /// `org-current-text-indentation` of the line at point.
157 var currentTextIndentation: Int {
158 var at = lineStart(point)
159 var column = 0
160 while let c = character(at: at), c == 32 || c == 9 {
161 column = c == 9 ? (column / 8 + 1) * 8 : column + 1
162 at += 1
163 }
164 return column
165 }
166
167 private func regex(_ pattern: String, caseInsensitive: Bool) -> NSRegularExpression {
168 EmacsBuffer.regex(pattern, caseInsensitive: caseInsensitive)
169 }
170
171 /// `looking-at`; with `setsMatch` false, `looking-at-p` or inside `save-match-data`.
172 func looking(_ pattern: String, caseInsensitive: Bool = true, setsMatch: Bool = true) -> [Range<Int>?]? {
173 let match = regex(pattern, caseInsensitive: caseInsensitive).firstMatch(in: string, options: Self.lookingOptions, range: NSRange(point..<length))
174 let groups = match.map(ranges)
175 if let groups, setsMatch { lastMatch = groups }
176 return groups
177 }
178
179 private func ranges(_ match: NSTextCheckingResult) -> [Range<Int>?] {
180 (0..<match.numberOfRanges).map { i in
181 let range = match.range(at: i)
182 return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
183 }
184 }
185
186 /// `re-search-forward` with a bound and no error: point to the match end; nil leaves point.
187 @discardableResult
188 func searchForward(_ pattern: String, bound: Int? = nil, caseInsensitive: Bool = true, move: Bool = false) -> [Range<Int>?]? {
189 let limit = min(bound ?? length, length)
190 guard point <= limit else { return nil }
191 guard let match = regex(pattern, caseInsensitive: caseInsensitive).firstMatch(in: string, options: Self.searchOptions, range: NSRange(point..<limit)) else {
192 if move { point = limit }
193 return nil
194 }
195 let groups = ranges(match)
196 point = groups[0]!.upperBound
197 lastMatch = groups
198 return groups
199 }
200
201 /// `re-search-backward` with a bound and no error: the match closest before point that
202 /// ends by point; point to its start.
203 @discardableResult
204 func searchBackward(_ pattern: String, bound: Int? = nil, caseInsensitive: Bool = true) -> [Range<Int>?]? {
205 let lower = max(bound ?? 0, 0)
206 guard lower <= point else { return nil }
207 let expression = regex(pattern, caseInsensitive: caseInsensitive)
208 var start = point
209 while start >= lower {
210 if let match = expression.firstMatch(in: string, options: Self.lookingOptions, range: NSRange(start..<point)) {
211 let groups = ranges(match)
212 point = start
213 lastMatch = groups
214 return groups
215 }
216 start -= 1
217 }
218 return nil
219 }
220
221 /// `indent-to` with spaces.
222 func indentTo(_ column: Int) {
223 let current = currentColumn
224 if column > current { insert(String(repeating: " ", count: column - current)) }
225 }
226
227 /// `indent-line-to` with spaces and a tab width of 8.
228 func indentLineTo(_ column: Int) {
229 beginningOfLine()
230 skipForward(Self.blanks)
231 let current = currentColumn
232 if current < column {
233 if column - (current / 8) * 8 >= 8 {
234 let end = point
235 skipBackward([32])
236 deleteRegion(point, end)
237 }
238 indentTo(column)
239 } else if current > column {
240 moveToColumn(column, force: true)
241 let start = point
242 beginningOfLine()
243 skipForward(Self.blanks)
244 deleteRegion(start, point)
245 }
246 }
247
248 // MARK: - Context
249
250 static let itemPattern = "([ \\t]*([-+]|(([0-9]+)[.)]))|[ \\t]+\\*)([ \\t]+|$)"
251 static let itemBeginningPattern = "^" + itemPattern
252 static let fullItemPattern = "^[ \\t]*((?:[-+*]|(?:[0-9]+|[A-Za-z])[.)])(?:[ \\t]+|$))(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(?:(\\[[ X-]\\])(?:[ \\t]+|$))?(?:(.*)[ \\t]+::(?:[ \\t]+|$))?"
253 static let listEndPattern = "^[ \\t]*\\n[ \\t]*\\n"
254 static let drawerPattern = "^[ \\t]*:([-_\\w]+):[ \\t]*$"
255 static let forbiddenBlocks = ["example", "verse", "src", "export"]
256
257 enum ListContextKind: Equatable { case none, drawer, block(String), invalid }
258
259 /// `org-list-context`.
260 func listContext() -> (up: Int, down: Int, kind: ListContextKind) {
261 let savedMatch = lastMatch
262 defer { lastMatch = savedMatch }
263 return saveExcursion {
264 beginningOfLine()
265 let pos = point
266 var up = saveExcursion { (try? backToHeading()) ?? 0 }
267 var down = saveExcursion { outlineNextHeading() ? point : length }
268 var kind = ListContextKind.none
269 let drawerEnd = "^[ \\t]*:END:"
270 if looking(Self.drawerPattern) == nil, looking(drawerEnd) == nil {
271 let found = saveExcursion { () -> (Int, Int)? in
272 guard searchBackward(Self.drawerPattern, bound: up) != nil else { return nil }
273 let begin = lineEnd(point) + 1
274 let end = searchForward(drawerEnd, bound: down).map { $0[0]!.lowerBound - 1 } ?? down
275 return end >= pos ? (begin, end) : nil
276 }
277 if let (begin, end) = found {
278 (up, down, kind) = (begin, end, .drawer)
279 }
280 }
281 let blockPattern = "^[ \\t]*#\\+(begin|end)_"
282 if looking(blockPattern) == nil {
283 let found = saveExcursion { () -> (Int, Int, String)? in
284 guard searchBackward(blockPattern, bound: up) != nil else { return nil }
285 let begin = lineEnd(point) + 1
286 guard let open = looking("^[ \\t]*#\\+begin_(\\S+)") else { return nil }
287 let type = substring(open[1]!.lowerBound, open[1]!.upperBound).lowercased()
288 point = begin
289 let close = searchForward(blockPattern, bound: down)
290 let end = close.map { _ in lineStart(point) - 1 } ?? down
291 guard end >= pos else { return nil }
292 let word = close.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) } ?? type
293 return word.lowercased() == "end" ? (begin, end, type) : nil
294 }
295 if let (begin, end, type) = found {
296 (up, down, kind) = (begin, end, Self.forbiddenBlocks.contains(type) ? .invalid : .block(type))
297 }
298 }
299 return (up, down, kind)
300 }
301 }
302
303 /// `org-in-item-p`: the start of the item holding point, or nil.
304 func inItem() -> Int? {
305 saveExcursion {
306 beginningOfLine()
307 let context = listContext()
308 var reference = looking("^[ \\t]*$") != nil ? 10000 : currentTextIndentation
309 if context.kind == .invalid { return nil }
310 if looking(Self.itemPattern) != nil { return point }
311 // Inside `org-list-end-re`: start above it.
312 let windowStart = saveExcursion { forwardLine(-2); return point }
313 let windowEnd = saveExcursion { forwardLine(2); return lineEnd(point) }
314 let expression = regex(Self.listEndPattern, caseInsensitive: true)
315 for match in expression.matches(in: string, options: Self.searchOptions, range: NSRange(windowStart..<windowEnd)) {
316 let range = match.range.location..<NSMaxRange(match.range)
317 if point >= range.lowerBound, point < range.upperBound {
318 point = range.lowerBound
319 forwardLine(-1)
320 break
321 }
322 }
323 while true {
324 let ind = currentTextIndentation
325 if looking(Self.itemPattern) != nil, ind < reference { return point }
326 if point <= context.up { return nil }
327 if looking(Self.listEndPattern) != nil { return nil }
328 if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil {
329 continue
330 }
331 if looking("^[ \\t]*:END:") != nil, searchBackward(Self.drawerPattern, bound: context.up) != nil {
332 beginningOfLine()
333 continue
334 }
335 if looking("^[ \\t]*$") != nil {
336 forwardLine(-1)
337 continue
338 }
339 if ind == 0 { return nil }
340 if ind < reference { reference = ind }
341 forwardLine(-1)
342 }
343 }
344 }
345
346 /// `org-at-item-p`: an item line where the element parser sees one, so not in blocks
347 /// whose contents aren't elements.
348 var atItem: Bool {
349 saveExcursion {
350 beginningOfLine()
351 switch listContext().kind {
352 case .invalid, .block("comment"), .block("verse"): return false
353 default: return looking(Self.itemPattern) != nil
354 }
355 }
356 }
357
358 // MARK: - Structure
359
360 private func assocAtPoint(_ ind: Int) -> ListItem {
361 let match = looking(Self.fullItemPattern)!
362 func group(_ i: Int) -> String? { match[i].map { substring($0.lowerBound, $0.upperBound) } }
363 let bullet = group(1) ?? ""
364 let tag = bullet.range(of: "[-+*]", options: .regularExpression) != nil ? group(4) : nil
365 return ListItem(pos: point, ind: ind, bullet: bullet, counter: group(2), checkbox: group(3), tag: tag, end: 0)
366 }
367
368 /// `org-list-struct` for the list holding point, which is at an item.
369 func listStruct() -> ListStruct {
370 saveExcursion {
371 beginningOfLine()
372 let context = listContext()
373 var textMinInd = 10000
374 var beginCell = (pos: point, ind: currentTextIndentation)
375 var items: [ListItem] = []
376 var ends: [(ind: Int, pos: Int)] = []
377 func endBeforeBlank() -> Int {
378 skipBackward(Self.whitespace)
379 return min(lineEnd(point) + 1, context.down)
380 }
381 func trimToBegin() {
382 if let i = items.firstIndex(where: { $0.pos == beginCell.pos }) { items = Array(items[i...]) } else { items = [] }
383 }
384 saveExcursion {
385 while true {
386 let ind = currentTextIndentation
387 if point <= context.up {
388 if looking(Self.itemPattern) == nil {
389 trimToBegin()
390 } else {
391 beginCell = (point, ind)
392 items.insert(assocAtPoint(ind), at: 0)
393 }
394 return
395 }
396 if looking(Self.listEndPattern) != nil {
397 trimToBegin()
398 return
399 }
400 if looking(Self.itemPattern) != nil {
401 items.insert(assocAtPoint(ind), at: 0)
402 ends.insert((ind, point), at: 0)
403 if ind < textMinInd { beginCell = (point, ind) }
404 forwardLine(-1)
405 } else if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil {
406 } else if looking("^[ \\t]*:END:") != nil, searchBackward(Self.drawerPattern, bound: context.up) != nil {
407 beginningOfLine()
408 } else if looking("^[ \\t]*$") != nil {
409 forwardLine(-1)
410 } else if ind == 0 {
411 trimToBegin()
412 return
413 } else {
414 if ind < textMinInd { textMinInd = ind }
415 ends.insert((ind, point), at: 0)
416 forwardLine(-1)
417 }
418 }
419 }
420 var items2: [ListItem] = []
421 var ends2: [(ind: Int, pos: Int)] = []
422 while true {
423 let ind = currentTextIndentation
424 if point >= context.down {
425 ends2.append((0, endBeforeBlank()))
426 break
427 }
428 if looking(Self.listEndPattern) != nil {
429 ends2.append((0, point))
430 break
431 }
432 if looking(Self.itemPattern) != nil {
433 items2.append(assocAtPoint(ind))
434 ends2.append((ind, point))
435 forwardLine(1)
436 } else if looking("^[ \\t]*$") != nil {
437 forwardLine(1)
438 } else if ind <= beginCell.ind {
439 ends2.append((0, endBeforeBlank()))
440 break
441 } else {
442 if let last = items2.last, ind <= last.ind { ends2.append((ind, point)) }
443 if looking("^[ \\t]*#\\+begin_") != nil, searchForward("^[ \\t]*#\\+end_", bound: context.down) != nil {
444 } else if looking(Self.drawerPattern) != nil, searchForward("^[ \\t]*:END:", bound: context.down) != nil {
445 }
446 forwardLine(1)
447 }
448 }
449 var structure = items + Array(items2.dropFirst())
450 assocEnd(&structure, ends + Array(ends2.dropFirst()))
451 return structure
452 }
453 }
454
455 func itemEndBeforeBlank(_ pos: Int, _ structure: ListStruct) -> Int {
456 saveExcursion {
457 point = structure.end(pos)
458 skipBackward(Self.whitespace)
459 return lineEnd(point)
460 }
461 }
462
463 // MARK: - Editing structures
464
465 /// `org-list-swap-items`.
466 func swapItems(_ a: Int, _ b: Int, _ structure: ListStruct) -> ListStruct {
467 saveExcursion {
468 let endANoBlank = itemEndBeforeBlank(a, structure)
469 let endBNoBlank = itemEndBeforeBlank(b, structure)
470 let endA = structure.end(a), endB = structure.end(b)
471 let sizeA = endANoBlank - a, sizeB = endBNoBlank - b
472 let bodyA = substring(a, endANoBlank), bodyB = substring(b, endBNoBlank)
473 let between = substring(endANoBlank, b)
474 let subA = [a] + structure.subtree(a), subB = [b] + structure.subtree(b)
475 point = a
476 deleteRegion(a, endBNoBlank)
477 insert(bodyB + between + bodyA)
478 var result = structure
479 for i in result.indices {
480 let pos = result[i].pos, end = result[i].end
481 if pos < a {
482 } else if subA.contains(pos) {
483 result[i].pos = pos + (endBNoBlank - endANoBlank)
484 result[i].end = end + (endBNoBlank - endANoBlank)
485 if end == endA { result[i].end = endB }
486 } else if subB.contains(pos) {
487 result[i].pos = pos + a - b
488 result[i].end = end + (a - b)
489 if end == endB { result[i].end = a + sizeB + (endA - endANoBlank) }
490 } else if pos < b {
491 result[i].pos = pos + (sizeB - sizeA)
492 result[i].end = end + (sizeB - sizeA)
493 }
494 }
495 return result.sorted { $0.pos < $1.pos }
496 }
497 }
498
499 /// `org-list-separating-blank-lines-number`, with point at the item.
500 func separatingBlankLines(_ pos: Int, _ structure: ListStruct, _ prevs: [Int: Int]) -> Int {
501 saveExcursion {
502 let item = point
503 func countBlanks() -> Int {
504 saveExcursion {
505 point = lineStart(point)
506 let start = point
507 skipBackward(Self.whitespace)
508 forwardLine(1)
509 return countLines(point, start)
510 }
511 }
512 if let next = structure.nextItem(item, prevs) {
513 point = next
514 return countBlanks()
515 }
516 if structure.previousItem(item, prevs) != nil { return countBlanks() }
517 if pos > itemEndBeforeBlank(item, structure) {
518 let blanks = saveExcursion { point = pos; return countBlanks() }
519 if blanks > 0 { return blanks }
520 }
521 point = structure.top
522 if searchForward("^[ \\t]*$", bound: itemEndBeforeBlank(item, structure)) != nil { return 1 }
523 return 0
524 }
525 }
526
527 /// `count-lines` between two positions.
528 func countLines(_ start: Int, _ end: Int) -> Int {
529 let (lower, upper) = (min(start, end), max(start, end))
530 guard upper > lower else { return 0 }
531 let newlines = substring(lower, upper).utf16.filter { $0 == 10 }.count
532 return character(at: upper - 1) == Self.newline ? newlines : newlines + 1
533 }
534
535 /// `org-list-insert-item`. Leaves point at the new item.
536 func insertItem(at position: Int, _ structure: ListStruct, _ prevs: [Int: Int], checkbox: Bool, afterBullet: String?, splitLine: Bool = true) -> ListStruct {
537 var pos = position
538 var found: Int?
539 var stopped = false
540 for entry in structure {
541 if entry.pos > pos {
542 stopped = true
543 break
544 }
545 if entry.end < pos { continue }
546 found = entry.pos
547 }
548 guard let item = stopped ? found : (found ?? structure.last?.pos) else { return structure }
549 let itemEnd = structure.end(item)
550 let itemEndNoBlank = itemEndBeforeBlank(item, structure)
551 point = item
552 let match = looking(Self.fullItemPattern)!
553 let bodyStart: Int
554 if match[4] == nil {
555 bodyStart = match[0]!.upperBound
556 } else if substring(match[1]!.lowerBound, match[1]!.upperBound).contains(where: { $0 == "." || $0 == ")" }) {
557 bodyStart = match[4]!.lowerBound
558 } else {
559 bodyStart = saveExcursion { point = match[4]!.upperBound; skipForward(Self.blanks); return point }
560 }
561 let before = pos <= bodyStart
562 let blanks = separatingBlankLines(pos, structure, prevs)
563 let ind = structure.ind(item)
564 let bullet = bulletString(structure.bullet(item))
565 let box = checkbox ? "[ ]" : nil
566 var textCut: String?
567 if !before, splitLine {
568 point = pos
569 if itemEnd < pos { deleteRegion(itemEnd - 1, lineEnd(point)) }
570 skipBackward(Self.whitespace)
571 pos = saveExcursion { skipForward(Self.blanks); return point }
572 textCut = substring(point, itemEndNoBlank)
573 deleteRegion(point, itemEndNoBlank)
574 }
575 var body = bullet + (box.map { $0 + " " } ?? "") + (afterBullet ?? "")
576 if let textCut {
577 // `string-match' sets the match data to the string's blanks.
578 let blanks = textCut.prefix { $0 == " " || $0 == "\t" }.utf16.count
579 if blanks > 0 { lastMatch = [0..<blanks] }
580 body += textCut.replacingOccurrences(of: "^[ \\t]+", with: "", options: .regularExpression)
581 }
582 let separator = String(repeating: "\n", count: blanks + 1)
583 let itemSize = ind + (body as NSString).length + (separator as NSString).length
584 let sizeOffset = itemSize - ((textCut ?? "") as NSString).length
585 point = item
586 indentTo(ind)
587 insert(body + separator)
588 var result = structure
589 for i in result.indices {
590 let p = result[i].pos, end = result[i].end
591 if p < item {
592 if end > item { result[i].end = end + sizeOffset }
593 } else if p == item, !before, splitLine {
594 result[i].pos = p + itemSize
595 result[i].end = end + sizeOffset
596 } else if splitLine, !before, p >= pos, p <= itemEndNoBlank {
597 let offset = pos - item - ind - (bullet as NSString).length - ((afterBullet ?? "") as NSString).length
598 result[i].pos = p - offset
599 result[i].end = end - offset
600 } else {
601 result[i].pos = p + sizeOffset
602 result[i].end = end + sizeOffset
603 }
604 }
605 result.append(ListItem(pos: item, ind: ind, bullet: bullet, counter: nil, checkbox: box, tag: nil, end: item + itemSize))
606 result.sort { $0.pos < $1.pos }
607 if before {
608 point = item
609 } else {
610 result = swapItems(item, item + itemSize, result)
611 point = result.nextItem(item, result.prevs)!
612 }
613 return result
614 }
615
616 /// `org-list-delete-item`.
617 func deleteItem(_ item: Int, _ structure: ListStruct) -> ListStruct {
618 let end = structure.end(item)
619 let begin = structure.bottom == end ? saveExcursion { () -> Int in
620 point = item
621 skipBackward(Self.whitespace)
622 return min(lineEnd(point) + 1, length)
623 } : item
624 deleteRegion(begin, end)
625 let size = end - begin
626 return structure.compactMap { entry in
627 var entry = entry
628 if entry.pos < item {
629 if entry.end == item { entry.end = begin } else if entry.end > item { entry.end -= size }
630 return entry
631 }
632 if entry.pos < end { return nil }
633 entry.pos -= size
634 entry.end -= size
635 return entry
636 }
637 }
638
639 enum SendDestination { case begin, end }
640
641 /// `org-list-send-item` to the start or end of the item's sub-list.
642 func sendItem(_ item: Int, to destination: SendDestination, _ structure: ListStruct) -> ListStruct {
643 let prevs = structure.prevs
644 let itemEnd = structure.end(item)
645 let bulletEnd = saveExcursion { () -> Int in
646 point = item
647 return looking("[ \\t]*" + NSRegularExpression.escapedPattern(for: structure.bullet(item)))![0]!.upperBound
648 }
649 let body = substring(bulletEnd, itemEnd).trimmingCharacters(in: .whitespacesAndNewlines)
650 let insertion: Int
651 switch destination {
652 case .begin:
653 insertion = structure.listBegin(item, prevs)
654 case .end:
655 insertion = saveExcursion { () -> Int in
656 point = structure.lastItem(item, prevs)
657 return lineEnd(point)
658 }
659 }
660 guard item != insertion else { return structure }
661 let itemMarker = marker(item)
662 defer { release(itemMarker) }
663 var result = insertItem(at: insertion, structure, prevs, checkbox: false, afterBullet: body, splitLine: false)
664 let moved = [itemMarker.position] + result.subtree(itemMarker.position)
665 let newEnd = result.end(point)
666 let oldEnd = result.end(itemMarker.position)
667 let newItem = point
668 let shift = point - itemMarker.position
669 result.removeAll { $0.pos == newItem }
670 let copies = moved.map { pos -> ListItem in
671 var cell = result.item(pos)!
672 cell.end = cell.end == oldEnd ? newEnd : cell.end + shift
673 cell.pos = pos + shift
674 return cell
675 }
676 result = (result + copies).sorted { $0.pos < $1.pos }
677 return deleteItem(itemMarker.position, result)
678 }
679
680 // MARK: - Indentation changes
681
682 /// `org-list-struct-outdent`.
683 func structOutdent(_ start: Int, _ end: Int, _ structure: ListStruct, _ parents: [(item: Int, parent: Int?)]) throws -> [(item: Int, parent: Int?)] {
684 var acc: [Int: Int] = [:]
685 return try parents.map { cell in
686 let (item, parent) = cell
687 if item < start { return cell }
688 if item >= end {
689 if let parent, let convert = acc[parent] { return (item, convert) }
690 return cell
691 }
692 guard let parent else { throw UserError(message: "Cannot outdent top-level items") }
693 if parent >= start {
694 acc[parent] = item
695 return cell
696 }
697 acc[parent] = item
698 return (item, structure.parent(parent, parents))
699 }
700 }
701
702 /// `org-list-struct-indent`.
703 func structIndent(_ start: Int, _ end: Int, _ structure: ListStruct, _ parents: [(item: Int, parent: Int?)], _ prevs: [Int: Int]) throws -> [(item: Int, parent: Int?)] {
704 var acc: [(item: Int, parent: Int?)] = []
705 func remember(_ cell: (item: Int, parent: Int?)) -> (item: Int, parent: Int?) {
706 acc.insert(cell, at: 0)
707 return cell
708 }
709 return try parents.map { cell in
710 let (item, parent) = cell
711 if item < start { return cell }
712 if item >= end {
713 if let convert = acc.first(where: { $0.item == parent }) { return (item, convert.parent) }
714 return cell
715 }
716 let previous = structure.previousItem(item, prevs)
717 guard let previous else {
718 if parent == nil || parent! < start { throw UserError(message: "Cannot indent the first item of a list") }
719 return remember(cell)
720 }
721 if previous < start { return remember((item, previous)) }
722 return remember((item, acc.first { $0.item == previous }?.parent ?? nil))
723 }
724 }
725
726 // MARK: - Repairs
727
728 /// Records a successful `string-match' on a string in the match data.
729 private func stringMatched(_ string: String, _ range: Range<String.Index>) {
730 let start = string.utf16.distance(from: string.startIndex, to: range.lowerBound)
731 lastMatch = [start..<(start + string[range].utf16.count)]
732 }
733
734 /// `org-list-inc-bullet-maybe`.
735 func incrementBullet(_ bullet: String) -> String {
736 if let range = bullet.range(of: "[0-9]+", options: .regularExpression) {
737 stringMatched(bullet, range)
738 return bullet.replacingCharacters(in: range, with: String(Int(bullet[range])! + 1))
739 }
740 if let range = bullet.range(of: "[A-Za-z]", options: .regularExpression) {
741 stringMatched(bullet, range)
742 let scalar = bullet[range].unicodeScalars.first!.value + 1
743 return bullet.replacingCharacters(in: range, with: String(Character(Unicode.Scalar(scalar)!)))
744 }
745 return bullet
746 }
747
748 /// `org-list-struct-fix-bul` (alphabetical bullets off).
749 func fixBullets(_ structure: inout ListStruct, _ prevs: [Int: Int]) {
750 for pos in structure.map(\.pos) {
751 let previous = prevs[pos]
752 let counter = structure.item(pos)!.counter
753 let bullet = structure.bullet(pos)
754 let new: String
755 if let previous, let counter, counter.range(of: "[a-zA-Z]", options: .regularExpression) != nil,
756 structure.bullet(previous).range(of: "[a-zA-Z]", options: .regularExpression) != nil {
757 let previousBullet = structure.bullet(previous)
758 let real = previousBullet.range(of: "[a-z]", options: .regularExpression) != nil ? counter.lowercased() : counter.uppercased()
759 let range = previousBullet.range(of: "[a-zA-Z]", options: .regularExpression)!
760 new = previousBullet.replacingCharacters(in: range, with: real)
761 } else if let previous, let counter, let counterRange = counter.range(of: "[0-9]+", options: .regularExpression),
762 let range = structure.bullet(previous).range(of: "[0-9]+", options: .regularExpression) {
763 new = structure.bullet(previous).replacingCharacters(in: range, with: counter[counterRange])
764 } else if let previous {
765 new = incrementBullet(structure.bullet(previous))
766 } else if let counter, let counterRange = counter.range(of: "[0-9]+", options: .regularExpression),
767 let range = bullet.range(of: "[0-9]+", options: .regularExpression) {
768 new = bullet.replacingCharacters(in: range, with: counter[counterRange])
769 } else if let range = bullet.range(of: "([0-9]+|[A-Za-z])", options: .regularExpression) {
770 stringMatched(bullet, range)
771 new = bullet.replacingCharacters(in: range, with: "1")
772 } else {
773 new = bullet
774 }
775 structure.set(pos) { $0.bullet = bulletString(new) }
776 }
777 }
778
779 /// `org-list-struct-fix-ind`.
780 func fixIndentation(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], bulletSize: Int? = nil) {
781 let topInd = structure.ind(structure.top)
782 for pos in structure.dropFirst().map(\.pos) {
783 if let parent = structure.parent(pos, parents) {
784 let ind = (bulletSize ?? (structure.bullet(parent) as NSString).length) + structure.ind(parent)
785 structure.set(pos) { $0.ind = ind }
786 } else {
787 structure.set(pos) { $0.ind = topInd }
788 }
789 }
790 }
791
792 /// `org-list-struct-fix-box` without ORDERED.
793 func fixCheckboxes(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)]) {
794 var parentList: [Int] = []
795 for pos in structure.map(\.pos) {
796 guard let parent = structure.parent(pos, parents), structure.checkbox(parent) != nil, !parentList.contains(parent) else { continue }
797 parentList.insert(parent, at: 0)
798 }
799 // `sort` in Emacs is stable; ties keep their order.
800 let ordered = parentList.enumerated().sorted { lhs, rhs in
801 let (a, b) = (structure.ind(lhs.element), structure.ind(rhs.element))
802 return a != b ? a > b : lhs.offset < rhs.offset
803 }.map(\.element)
804 for item in ordered {
805 let boxes = structure.children(item, parents).map { structure.checkbox($0) }
806 let new: String?
807 if boxes.contains("[ ]"), boxes.contains("[X]") {
808 new = "[-]"
809 } else if boxes.contains("[-]") {
810 new = "[-]"
811 } else if boxes.contains("[X]") {
812 new = "[X]"
813 } else if boxes.contains("[ ]") {
814 new = "[ ]"
815 } else {
816 new = structure.checkbox(item)
817 }
818 structure.set(item) { $0.checkbox = new }
819 }
820 }
821
822 /// `org-list-struct-fix-item-end`.
823 func fixItemEnds(_ structure: inout ListStruct) {
824 var endList: [(ind: Int, pos: Int)] = []
825 var accEnd: [(end: Int, pos: Int)] = []
826 for item in structure {
827 if structure.item(item.end) == nil {
828 let up = accEnd.first { item.end < $0.end }?.pos
829 endList.insert((up.map { structure.ind($0) + 2 } ?? 0, item.end), at: 0)
830 }
831 endList.insert((item.ind, item.pos), at: 0)
832 accEnd.insert((item.end, item.pos), at: 0)
833 }
834 let sorted = endList.enumerated().sorted { $0.element.pos != $1.element.pos ? $0.element.pos < $1.element.pos : $0.offset < $1.offset }.map(\.element)
835 assocEnd(&structure, sorted)
836 }
837
838 /// The previous match data, with groups it lacks as unmatched.
839 private var staleMatch: [Range<Int>?] {
840 lastMatch + Array(repeating: nil, count: max(0, 6 - lastMatch.count))
841 }
842
843 /// `org-list-struct-apply-struct`.
844 func applyStruct(_ structure: ListStruct, _ old: ListStruct) {
845 let origin = marker(point)
846 defer {
847 point = origin.position
848 release(origin)
849 }
850 func shiftBody(end: Int, begin: Int, delta: Int, ind: Int?) {
851 point = end
852 skipBackward(Self.whitespace)
853 beginningOfLine()
854 while point > begin || (point == begin && looking(Self.itemPattern) == nil) {
855 if looking("^[ \\t]*\\S", setsMatch: false) != nil {
856 indentLineTo(max(currentTextIndentation + delta, ind.map { $0 + 1 } ?? -1))
857 }
858 if point == 0 { break }
859 forwardLine(-1)
860 }
861 }
862 func modify(_ item: Int) {
863 point = item
864 let newInd = structure.ind(item)
865 let oldInd = currentTextIndentation
866 let newBullet = bulletString(structure.bullet(item))
867 let oldBullet = old.bullet(item)
868 let newBox = structure.checkbox(item)
869 // A failed `looking-at' leaves the previous match data, which org then uses; this
870 // happens when a split item takes a sub-list with it.
871 var match = looking(Self.fullItemPattern) ?? staleMatch
872 if oldBullet != newBullet {
873 var keepSpace = ""
874 saveExcursion {
875 let bulletRange = match[1]!
876 if bulletRange.lowerBound <= origin.position, origin.position <= bulletRange.upperBound {
877 let spaces = saveExcursion { () -> String? in
878 point = origin.position
879 return looking("[ \\t]+", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) }
880 }
881 keepSpace = spaces ?? ""
882 }
883 replace(bulletRange, with: "")
884 point = bulletRange.lowerBound
885 insertBeforeMarkers(newBullet)
886 insert(keepSpace)
887 }
888 }
889 point = item
890 match = looking(Self.fullItemPattern) ?? staleMatch
891 let oldBox = match[3].map { substring($0.lowerBound, $0.upperBound) }
892 if oldBox == newBox {
893 } else if let boxRange = match[3], let newBox {
894 replace(boxRange, with: newBox)
895 } else if oldBox != nil {
896 let box = looking(".*?([ \\t]*\\[[ X-]\\])")!
897 replace(box[1]!, with: "")
898 } else if let newBox {
899 let counterEnd = match[2]?.upperBound
900 point = counterEnd.map { $0 + 1 } ?? match[1]!.upperBound
901 insert(newBox + (counterEnd == nil ? " " : ""))
902 }
903 if newInd != oldInd {
904 point = lineStart(point)
905 let start = point
906 skipForward(Self.blanks)
907 deleteRegion(start, point)
908 indentTo(newInd)
909 }
910 }
911 var endList: [(end: Int, up: Int?)] = []
912 var accEnd: [(end: Int, pos: Int)] = []
913 var shifts: [Int: (delta: Int, ind: Int)] = [:]
914 for entry in old {
915 let pos = entry.pos
916 let indNew = structure.ind(pos), indOld = entry.ind
917 let delta = (indNew + (structure.bullet(pos) as NSString).length) - (indOld + (entry.bullet as NSString).length)
918 shifts[pos] = (delta, indNew)
919 if old.item(entry.end) == nil {
920 endList.insert((entry.end, accEnd.first { entry.end < $0.end }?.pos), at: 0)
921 }
922 accEnd.insert((entry.end, pos), at: 0)
923 }
924 var uniqueEnds: [Int] = []
925 for end in endList.map(\.end) where !uniqueEnds.contains(end) { uniqueEnds.append(end) }
926 var allEnds = (old.map(\.pos) + uniqueEnds).sorted()
927 accEnd.reverse()
928 var slices: [(down: Int, up: Int, delta: Int, ind: Int?)] = []
929 while allEnds.count > 1 {
930 let up = allEnds.removeFirst()
931 let down = allEnds[0]
932 let isItem = structure.item(up) != nil
933 let delta: Int
934 if isItem {
935 delta = shifts[up]!.delta
936 } else {
937 let child = accEnd.first { $0.end == up }!.pos
938 let ind = structure.ind(child)
939 var minInd = Int.max
940 saveExcursion {
941 point = up
942 while point < down {
943 if looking("[ \\t]*$", setsMatch: false) == nil {
944 minInd = min(currentTextIndentation, minInd)
945 if let open = looking("#\\+BEGIN(:|_\\S+)") {
946 let name = NSRegularExpression.escapedPattern(for: substring(open[1]!.lowerBound, open[1]!.upperBound))
947 searchForward("^[ \\t]*#\\+END\(name)[ \\t]*$", bound: down)
948 } else if looking(Self.drawerPattern) != nil {
949 searchForward("^[ \\t]*:END:[ \\t]*$", bound: down)
950 }
951 }
952 if forwardLine(1) != 0 { break }
953 }
954 }
955 delta = ind - minInd
956 }
957 slices.insert((down, up, delta, isItem ? shifts[up]!.ind : nil), at: 0)
958 }
959 for slice in slices {
960 if slice.delta != 0 { shiftBody(end: slice.down, begin: slice.up, delta: slice.delta, ind: slice.ind) }
961 if let cell = structure.item(slice.up), cell != old.item(slice.up) { modify(slice.up) }
962 }
963 }
964
965 /// `org-list-write-struct`.
966 func writeStruct(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], old: ListStruct? = nil) {
967 let old = old ?? structure
968 fixIndentation(&structure, parents, bulletSize: 2)
969 fixItemEnds(&structure)
970 let prevs = structure.prevs
971 fixBullets(&structure, prevs)
972 fixIndentation(&structure, parents)
973 fixCheckboxes(&structure, parents)
974 applyStruct(structure, old)
975 }
976}
Sources/OrgCore/Keymap/Presets.swift +74
@@ -206,6 +206,45 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
206 [[bind]] 206 [[bind]]
207 keys = "C-c C-q" 207 keys = "C-c C-q"
208 command = "org.tags.set" 208 command = "org.tags.set"
209 [[bind]]
210 keys = "M-RET"
211 command = "org.item.insert"
212 when = "item"
213 [[bind]]
214 keys = "M-S-RET"
215 command = "org.item.insert-checkbox"
216 when = "item"
217 [[bind]]
218 keys = "M-<left>"
219 command = "org.item.outdent"
220 when = "item"
221 [[bind]]
222 keys = "M-<right>"
223 command = "org.item.indent"
224 when = "item"
225 [[bind]]
226 keys = "M-S-<left>"
227 command = "org.item.outdent-tree"
228 when = "item"
229 [[bind]]
230 keys = "M-S-<right>"
231 command = "org.item.indent-tree"
232 when = "item"
233 [[bind]]
234 keys = "M-<up>"
235 command = "org.item.up"
236 when = "item"
237 [[bind]]
238 keys = "M-<down>"
239 command = "org.item.down"
240 when = "item"
241 [[bind]]
242 keys = "C-c C-c"
243 command = "org.checkbox.toggle"
244 when = "item"
245 [[bind]]
246 keys = "C-c C-x C-b"
247 command = "org.checkbox.toggle"
209 248
210 """ 249 """
211 250
@@ -257,6 +296,41 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
257 [[bind]] 296 [[bind]]
258 keys = "C-s-q" 297 keys = "C-s-q"
259 command = "org.tags.set" 298 command = "org.tags.set"
299 [[bind]]
300 keys = "s-RET"
301 command = "org.item.insert"
302 when = "item"
303 [[bind]]
304 keys = "s-S-RET"
305 command = "org.item.insert-checkbox"
306 when = "item"
307 [[bind]]
308 keys = "C-s-<left>"
309 command = "org.item.outdent"
310 when = "item"
311 [[bind]]
312 keys = "C-s-<right>"
313 command = "org.item.indent"
314 when = "item"
315 [[bind]]
316 keys = "C-M-s-<left>"
317 command = "org.item.outdent-tree"
318 when = "item"
319 [[bind]]
320 keys = "C-M-s-<right>"
321 command = "org.item.indent-tree"
322 when = "item"
323 [[bind]]
324 keys = "C-M-s-<up>"
325 command = "org.item.up"
326 when = "item"
327 [[bind]]
328 keys = "C-M-s-<down>"
329 command = "org.item.down"
330 when = "item"
331 [[bind]]
332 keys = "C-s-c"
333 command = "org.checkbox.toggle"
260 334
261 """ 335 """
262} 336}
Tests/OrgCoreTests/EmacsOracle.swift +3 −2
@@ -34,8 +34,9 @@ enum EmacsOracle {
34 org-tags-column -77 34 org-tags-column -77
35 org-priority-highest ?A 35 org-priority-highest ?A
36 org-priority-lowest ?C 36 org-priority-lowest ?C
37 org-priority-default ?B 37 org-priority-default ?B)
38 indent-tabs-mode nil) 38 ;; Buffer-local when set, so set the default for the case buffers.
39 (setq-default indent-tabs-mode nil)
39 (let* ((input (with-temp-buffer 40 (let* ((input (with-temp-buffer
40 (let ((coding-system-for-read 'utf-8-unix)) 41 (let ((coding-system-for-read 'utf-8-unix))
41 (insert-file-contents (getenv "ORACLE_INPUT"))) 42 (insert-file-contents (getenv "ORACLE_INPUT")))
Tests/OrgCoreTests/HeadingCommandTests.swift +19 −3
@@ -115,13 +115,29 @@ func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(c
115 if !same { 115 if !same {
116 mismatches += 1 116 mismatches += 1
117 if mismatches <= 15 { 117 if mismatches <= 15 {
118 let (a, b) = excerpts(mine.text, emacs.text)
118 Issue.record(""" 119 Issue.record("""
119 \(mine.label) 120 \(mine.label.prefix(100))
120 ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)") 121 ours: \(mine.failed ? "failed" : "\(a.debugDescription) @\(mine.caret)")
121 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)") 122 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(b.debugDescription) @\(emacsCaret)")
122 """) 123 """)
123 } 124 }
124 } 125 }
125 } 126 }
126 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs") 127 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
127} 128}
129
130/// The parts of two texts around their first difference.
131func excerpts(_ a: String, _ b: String) -> (String, String) {
132 let x = Array(a.utf16), y = Array(b.utf16)
133 var prefix = 0
134 while prefix < min(x.count, y.count), x[prefix] == y[prefix] { prefix += 1 }
135 var suffix = 0
136 while suffix < min(x.count, y.count) - prefix, x[x.count - 1 - suffix] == y[y.count - 1 - suffix] { suffix += 1 }
137 let start = max(0, prefix - 40)
138 func cut(_ s: [UInt16]) -> String {
139 let end = min(s.count, s.count - suffix + 40)
140 return "…" + String(decoding: s[start..<max(start, end)], as: UTF16.self) + "…"
141 }
142 return (cut(x), cut(y))
143}
Tests/OrgCoreTests/ListCommandTests.swift added +76
@@ -0,0 +1,76 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ListCommandTests {
6 @Test func basics() {
7 #expect(runCommand(InsertItem(), "- a\n", caret: 3).text == "- a\n- \n")
8 #expect(runCommand(InsertItem(), "1. a\n2. b\n", caret: 4).text == "1. a\n2. \n3. b\n")
9 #expect(runCommand(IndentItem(), "- a\n- b\n", caret: 4).text == "- a\n - b\n")
10 #expect(runCommand(OutdentItem(), "- a\n - b\n", caret: 6).text == "- a\n- b\n")
11 #expect(runCommand(MoveItemDown(), "- a\n- b\n", caret: 0).text == "- b\n- a\n")
12 #expect(runCommand(ToggleCheckbox(), "* h [0/1]\n- [ ] a\n", caret: 12).text == "* h [1/1]\n- [X] a\n")
13 #expect(runCommand(IndentItem(), "text\n", caret: 0).failure != nil)
14 }
15}
16
17struct ListOracleTests {
18 static let variants = [
19 "- a\n- b\n- c\n",
20 "1. one\n2. two\n3. three\n",
21 "- a\n - b\n - c\n- d\n",
22 "- [ ] a\n- [X] b\n",
23 "* T [1/3]\n- [X] a\n- [ ] b\n- [ ] c\n",
24 "* T [50%]\n- [ ] a\n - [X] b\n - [ ] c\n",
25 "- a\n\n- b\n",
26 "- term :: def\n- other :: x\n",
27 "text\n- a\n more\n- b\nafter\n",
28 "1) x\n2) y\n",
29 "- a 😀\n - b\n",
30 "#+begin_src\n- a\n#+end_src\n",
31 "- a\n- b\n - c\n- d\n",
32 ]
33
34 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
35 (InsertItem(), "(or (org-insert-item) (error \"Not in an item\"))", false),
36 (InsertCheckboxItem(), "(or (org-insert-item 'checkbox) (error \"Not in an item\"))", false),
37 (IndentItem(), "(org-indent-item)", false),
38 (OutdentItem(), "(org-outdent-item)", false),
39 (IndentItemTree(), "(org-indent-item-tree)", false),
40 (OutdentItemTree(), "(org-outdent-item-tree)", false),
41 (MoveItemUp(), "(org-move-item-up)", false),
42 (MoveItemDown(), "(org-move-item-down)", false),
43 (ToggleCheckbox(), "(if (org-at-heading-p) (error \"heading\") (org-toggle-checkbox))", false),
44 ]
45
46 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
47 func listCommandsMatchEmacs() throws {
48 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
49 }
50
51 /// Real lists: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every list command in files up to
52 /// 20 KB with the caret at the start, bullet end and end of up to six item lines per file.
53 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
54 func listCommandsMatchEmacsOnACorpus() throws {
55 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
56 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
57 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
58 let item = try NSRegularExpression(pattern: "^[ \\t]*(?:[-+]|[0-9]+[.)]) ")
59 var samples: [(String, [Int])] = []
60 for file in files where samples.count < 60 {
61 guard let text = try? String(contentsOf: file, encoding: .utf8), text.utf16.count <= 20_000, !text.contains("\r") else { continue }
62 let ns = text as NSString
63 var carets: [Int] = []
64 var location = 0
65 while location < ns.length, carets.count < 18 {
66 let line = ns.lineRange(for: NSRange(location: location, length: 0))
67 if let match = item.firstMatch(in: text, options: .anchored, range: line) {
68 carets += [line.location, NSMaxRange(match.range), NSMaxRange(line) - (ns.substring(with: line).hasSuffix("\n") ? 1 : 0)]
69 }
70 location = NSMaxRange(line)
71 }
72 if !carets.isEmpty { samples.append((text, carets)) }
73 }
74 try compareWithEmacs(samples, Self.commands)
75 }
76}