Lists and checkboxes !14
10 files changed, +1661 −20
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +2
| @@ -97,6 +97,8 @@ public enum Commands { | ||
| 97 | 97 | MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), |
| 98 | 98 | NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), |
| 99 | 99 | SetTags(), |
| 100 | InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), | |
| 101 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), | |
| 100 | 102 | ] |
| 101 | 103 | |
| 102 | 104 | public static func command(_ id: String) -> (any OrgCommand)? { |
Sources/OrgCore/Commands/EmacsBuffer.swift +83 −15
| @@ -20,14 +20,37 @@ final class EmacsBuffer { | ||
| 20 | 20 | var point: Int |
| 21 | 21 | private var markers: [Marker] = [] |
| 22 | 22 | let settings: OrgSettings |
| 23 | /// Groups of the last successful match, as Emacs's match data. | |
| 24 | var lastMatch: [Range<Int>?] = [] | |
| 23 | 25 | |
| 24 | 26 | init(_ text: String, point: Int, settings: OrgSettings = .default) { |
| 25 | 27 | self.text = NSMutableString(string: text) |
| 26 | 28 | self.point = point |
| 27 | 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 | 54 | var length: Int { text.length } |
| 32 | 55 | |
| 33 | 56 | func character(at offset: Int) -> unichar? { |
| @@ -65,7 +88,9 @@ final class EmacsBuffer { | ||
| 65 | 88 | func insert(_ string: String) { |
| 66 | 89 | let count = (string as NSString).length |
| 67 | 90 | guard count > 0 else { return } |
| 91 | touched(point..<point) | |
| 68 | 92 | text.insert(string, at: point) |
| 93 | cachedString = nil | |
| 69 | 94 | for marker in markers where marker.position > point { marker.position += count } |
| 70 | 95 | point += count |
| 71 | 96 | } |
| @@ -74,7 +99,9 @@ final class EmacsBuffer { | ||
| 74 | 99 | func insertBeforeMarkers(_ string: String) { |
| 75 | 100 | let count = (string as NSString).length |
| 76 | 101 | guard count > 0 else { return } |
| 102 | touched(point..<point) | |
| 77 | 103 | text.insert(string, at: point) |
| 104 | cachedString = nil | |
| 78 | 105 | for marker in markers where marker.position >= point { marker.position += count } |
| 79 | 106 | point += count |
| 80 | 107 | } |
| @@ -82,7 +109,9 @@ final class EmacsBuffer { | ||
| 82 | 109 | func deleteRegion(_ start: Int, _ end: Int) { |
| 83 | 110 | let (lower, upper) = (min(start, end), max(start, end)) |
| 84 | 111 | guard upper > lower else { return } |
| 112 | touched(lower..<upper) | |
| 85 | 113 | text.deleteCharacters(in: NSRange(lower..<upper)) |
| 114 | cachedString = nil | |
| 86 | 115 | func map(_ position: Int) -> Int { |
| 87 | 116 | position <= lower ? position : position >= upper ? position - (upper - lower) : lower |
| 88 | 117 | } |
| @@ -98,7 +127,9 @@ final class EmacsBuffer { | ||
| 98 | 127 | /// or after its end shift with the text. |
| 99 | 128 | func replace(_ range: Range<Int>, with replacement: String) { |
| 100 | 129 | let delta = (replacement as NSString).length - range.count |
| 130 | touched(range) | |
| 101 | 131 | text.replaceCharacters(in: NSRange(range), with: replacement) |
| 132 | cachedString = nil | |
| 102 | 133 | func map(_ position: Int) -> Int { |
| 103 | 134 | if position >= range.upperBound, position > range.lowerBound { return position + delta } |
| 104 | 135 | return position > range.lowerBound ? range.lowerBound : position |
| @@ -164,11 +195,19 @@ final class EmacsBuffer { | ||
| 164 | 195 | |
| 165 | 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. | |
| 168 | func moveToColumn(_ target: Int) { | |
| 198 | /// `move-to-column`: as far as `target` on this line, or the line's end. With `force`, a | |
| 199 | /// tab spanning `target` becomes spaces (`indent-tabs-mode` nil) so point lands on it. | |
| 200 | func moveToColumn(_ target: Int, force: Bool = false) { | |
| 169 | 201 | let start = lineStart(point) |
| 170 | 202 | let line = LineBuffer(substring(start, lineEnd(point)), caret: nil) |
| 171 | 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 | 213 | // MARK: - Outline |
| @@ -305,15 +344,34 @@ final class EmacsBuffer { | ||
| 305 | 344 | |
| 306 | 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 | 365 | /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines), |
| 309 | 366 | /// or nil. Unmatched groups are nil. |
| 310 | 367 | func lookingAt(_ pattern: String) -> [Range<Int>?]? { |
| 311 | let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) | |
| 312 | guard let match = regex.firstMatch(in: string, options: [.anchored], range: NSRange(point..<length)) else { return nil } | |
| 313 | return (0..<match.numberOfRanges).map { i in | |
| 368 | let regex = Self.regex(pattern, caseInsensitive: false) | |
| 369 | guard let match = regex.firstMatch(in: string, options: Self.lookingOptions, range: NSRange(point..<length)) else { return nil } | |
| 370 | lastMatch = (0..<match.numberOfRanges).map { i in | |
| 314 | 371 | let range = match.range(at: i) |
| 315 | 372 | return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range) |
| 316 | 373 | } |
| 374 | return lastMatch | |
| 317 | 375 | } |
| 318 | 376 | |
| 319 | 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 | 415 | let newPoint = map(point) |
| 358 | 416 | let newMarkers = markers.map { map($0.position) } |
| 417 | touched(start..<end) | |
| 359 | 418 | text.replaceCharacters(in: NSRange(start..<end), with: plain.string) |
| 419 | cachedString = nil | |
| 360 | 420 | point = newPoint |
| 361 | 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 | 433 | } catch { |
| 374 | 434 | return .failed("\(error)") |
| 375 | 435 | } |
| 376 | let old = context.text as NSString | |
| 377 | let new = buffer.text | |
| 436 | // Only the span between the untouched start and end can differ. | |
| 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 | 446 | var prefix = 0 |
| 379 | let shorter = min(old.length, new.length) | |
| 380 | while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 } | |
| 447 | let shorter = min(old.count, new.count) | |
| 448 | while prefix < shorter, old[prefix] == new[prefix] { prefix += 1 } | |
| 381 | 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 | 451 | // Keep surrogate pairs whole. |
| 384 | while prefix > 0, prefix < old.length, UTF16.isTrailSurrogate(old.character(at: prefix)) { prefix -= 1 } | |
| 385 | while suffix > 0, UTF16.isTrailSurrogate(old.character(at: old.length - suffix)) { suffix -= 1 } | |
| 452 | while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 } | |
| 453 | while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 } | |
| 386 | 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 | 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 | 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 @@ | ||
| 1 | import 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 | ||
| 6 | extension 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 | ||
| 273 | private func listCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { | |
| 274 | commitBuffer(context, body) | |
| 275 | } | |
| 276 | ||
| 277 | /// M-RET in a list: `org-insert-item`. | |
| 278 | public 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. | |
| 291 | public 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. | |
| 304 | private 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`. | |
| 309 | public 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`. | |
| 318 | public 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`. | |
| 327 | public 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`. | |
| 336 | public 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`. | |
| 345 | public 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`. | |
| 354 | public 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`. | |
| 363 | public 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 @@ | ||
| 1 | import 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`. | |
| 11 | struct 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. | |
| 23 | typealias ListStruct = [ListItem] | |
| 24 | ||
| 25 | extension 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. | |
| 136 | private 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. | |
| 146 | func 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 | ||
| 153 | extension 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 | 206 | [[bind]] |
| 207 | 207 | keys = "C-c C-q" |
| 208 | 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 | 296 | [[bind]] |
| 258 | 297 | keys = "C-s-q" |
| 259 | 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 | 34 | org-tags-column -77 |
| 35 | 35 | org-priority-highest ?A |
| 36 | 36 | org-priority-lowest ?C |
| 37 | org-priority-default ?B | |
| 38 | indent-tabs-mode nil) | |
| 37 | org-priority-default ?B) | |
| 38 | ;; Buffer-local when set, so set the default for the case buffers. | |
| 39 | (setq-default indent-tabs-mode nil) | |
| 39 | 40 | (let* ((input (with-temp-buffer |
| 40 | 41 | (let ((coding-system-for-read 'utf-8-unix)) |
| 41 | 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 | 115 | if !same { |
| 116 | 116 | mismatches += 1 |
| 117 | 117 | if mismatches <= 15 { |
| 118 | let (a, b) = excerpts(mine.text, emacs.text) | |
| 118 | 119 | Issue.record(""" |
| 119 | \(mine.label) | |
| 120 | ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)") | |
| 121 | emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)") | |
| 120 | \(mine.label.prefix(100)) | |
| 121 | ours: \(mine.failed ? "failed" : "\(a.debugDescription) @\(mine.caret)") | |
| 122 | emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(b.debugDescription) @\(emacsCaret)") | |
| 122 | 123 | """) |
| 123 | 124 | } |
| 124 | 125 | } |
| 125 | 126 | } |
| 126 | 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. | |
| 131 | func 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | ||
| 17 | struct 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 | } | |
Tests/OrgEditorAppKitTests/KeyTests.swift +19
| @@ -134,3 +134,22 @@ struct KeyTests { | ||
| 134 | 134 | } |
| 135 | 135 | } |
| 136 | 136 | } |
| 137 | ||
| 138 | @MainActor | |
| 139 | struct ListKeyTests { | |
| 140 | @Test func metaReturnInsertsAnItemInAListAndAHeadingElsewhere() { | |
| 141 | let h = Harness("* a\n- x\n") | |
| 142 | let event = { (flags: NSEvent.ModifierFlags) in | |
| 143 | NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, windowNumber: h.window.windowNumber, | |
| 144 | context: nil, characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)! | |
| 145 | } | |
| 146 | let meta = NSEvent.ModifierFlags(rawValue: NSEvent.ModifierFlags.option.rawValue | KeyEvents.leftOption) | |
| 147 | h.caret(at: 7) | |
| 148 | h.textView.keyDown(with: event(meta)) | |
| 149 | #expect(h.string == "* a\n- x\n- \n") | |
| 150 | h.caret(at: 3) | |
| 151 | h.textView.keyDown(with: event(meta)) | |
| 152 | #expect(h.string.hasPrefix("* a\n* \n")) | |
| 153 | h.checkInSync() | |
| 154 | } | |
| 155 | } | |
docs/plans/2026-10-05-lists.md added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | # Lists and Checkboxes Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Org's plain list commands, byte- and caret-exact against Emacs: insert item (M-RET, M-S-RET with a checkbox), indent/outdent item and item tree (M-left/right, M-S-left/right), move item (M-up/down), toggle checkbox (C-c C-c on an item, C-c C-x C-b), with checkbox statistics cookies kept current. | |
| 6 | ||
| 7 | **Architecture:** `OrgList.swift` ports org-list.el's machinery onto `EmacsBuffer`: `org-list-context`, `org-in-item-p`, `org-list-struct` (items with indentation, bullet, counter, checkbox, tag, end), the prevs/parents alists, `org-list-insert-item`, `org-list-swap-items`, `org-list-send-item`, indent/outdent of structures, the repairs (`fix-bul`, `fix-ind`, `fix-box`, `fix-item-end`) and `org-list-struct-apply-struct`. `ListCommands.swift` holds the commands and `org-update-checkbox-count`, which finds cookies and their containers with the parser on the current section. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Commands and keymaps". | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Org defaults: no alphabetical bullets, both `.` and `)` terminators, indent offset 0, items get blank lines `auto`, hierarchical statistics, spaces for indentation. | |
| 16 | - Not handled: the ORDERED property, radio lists, timer items, regions. | |
| 17 | ||
| 18 | ## What the oracle found | |
| 19 | ||
| 20 | - ICU's `^` doesn't match after a final newline; Emacs's does. `EmacsBuffer.regex` rewrites a leading `^` as a lookbehind. | |
| 21 | - Splitting an item that owns a sub-list (M-S-RET at its end) leaves org's structure with stale positions; org then reuses the previous match data when `looking-at` fails. The port tracks match data the way Emacs does (`looking-at-p`, `save-match-data` and `string-match` included) and gives the same result. | |
| 22 | - `indent-line-to` shrinking indentation inside a tab uses `move-to-column` with FORCE, which turns the tab into spaces. | |
| 23 | - `org-at-item-p` asks the element parser, so items in comment and verse blocks aren't items, though `org-list-context` allows those blocks. | |
| 24 | - The oracle set `indent-tabs-mode` only in the batch buffer; it now sets the default. | |
| 25 | ||
| 26 | ## Performance | |
| 27 | ||
| 28 | `EmacsBuffer` keeps an immutable `NSString` copy for regexps (rebuilt after a change) and records the span edits touched, so the final diff reads only that span. On a 1.5 MB file every list and structure command takes 1–8 ms. | |
| 29 | ||
| 30 | --- | |
| 31 | ||
| 32 | ### Task 1: List machinery and commands | |
| 33 | ||
| 34 | - [ ] Port the machinery and the nine commands; unit tests; oracle over 13 variants at every caret and over the corpus (`ORGSTAR_ORACLE_CORPUS`, item lines in files up to 20 KB). | |
| 35 | - [ ] Commit "Port plain list commands from org". | |
| 36 | ||
| 37 | ### Task 2: Keys | |
| 38 | ||
| 39 | - [ ] Emacs preset: item bindings on M-RET, M-S-RET, M-arrows, M-S-arrows with `when = "item"`; C-c C-c on items; C-c C-x C-b. Mac preset: the same on its chords, ⌃⌘C to toggle. | |
| 40 | - [ ] Commit "Bind list keys". | |