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 | 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 @@ | |||
| 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 | [[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. | ||
| 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". | ||