Editor and Vim differences from Emacs !164
11 files changed, +357 −96
Layout: unified · split
Sources/OrgCore/Commands/Fill.swift +74 −16
| @@ -302,8 +302,9 @@ extension EmacsBuffer { | ||
| 302 | 302 | } |
| 303 | 303 | } |
| 304 | 304 | |
| 305 | /// `fill-region-as-paragraph` with left justification and adaptive fill. | |
| 306 | func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil) { | |
| 305 | /// `fill-region-as-paragraph` with left justification and adaptive fill. With `nosqueeze`, | |
| 306 | /// spaces between words and at the end stay. | |
| 307 | func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil, nosqueeze: Bool = false) { | |
| 307 | 308 | point = min(fromOffset, toOffset) |
| 308 | 309 | let to = max(fromOffset, toOffset) |
| 309 | 310 | skipForward(Self.whitespace) |
| @@ -349,13 +350,15 @@ extension EmacsBuffer { | ||
| 349 | 350 | if character(at: at) == 10 { replace(at..<(at + 1), with: " ") } |
| 350 | 351 | at += 1 |
| 351 | 352 | } |
| 352 | point = from | |
| 353 | skipForward(Self.blanks) | |
| 354 | canonicallySpace(point, toMarker.position) | |
| 355 | point = toMarker.position | |
| 356 | let end = point | |
| 357 | skipBackward(Self.blanks) | |
| 358 | if point < end { deleteRegion(point, end) } | |
| 353 | if !nosqueeze { | |
| 354 | point = from | |
| 355 | skipForward(Self.blanks) | |
| 356 | canonicallySpace(point, toMarker.position) | |
| 357 | point = toMarker.position | |
| 358 | let end = point | |
| 359 | skipBackward(Self.blanks) | |
| 360 | if point < end { deleteRegion(point, end) } | |
| 361 | } | |
| 359 | 362 | point = from |
| 360 | 363 | while point < toMarker.position { |
| 361 | 364 | let lineBegin = point |
| @@ -467,9 +470,51 @@ extension EmacsBuffer { | ||
| 467 | 470 | } |
| 468 | 471 | } |
| 469 | 472 | |
| 473 | /// `org-fill-paragraph` with an active region: from the region's end back to `start`, M-q at | |
| 474 | /// the end of each paragraph's text, moving back a paragraph at a time. | |
| 475 | func fillParagraphs(_ start: Int, _ end: Int) { | |
| 476 | saveExcursion { | |
| 477 | point = end | |
| 478 | skipBackward(Self.whitespace) | |
| 479 | while point > start { | |
| 480 | let before = point | |
| 481 | fillParagraph() | |
| 482 | point = paragraphBackwardStop() | |
| 483 | skipBackward(Self.whitespace) | |
| 484 | if point >= before { break } | |
| 485 | } | |
| 486 | } | |
| 487 | } | |
| 488 | ||
| 489 | /// Where `org--backward-paragraph-once` goes from the end of a line's text. | |
| 490 | private func paragraphBackwardStop() -> Int { | |
| 491 | guard let element = element(at: point) else { return lineStart(point) } | |
| 492 | switch element.kind { | |
| 493 | case .block where ["comment", "example", "export", "src", "verse"].contains(element.blockType ?? ""): | |
| 494 | // Between empty lines in the block. | |
| 495 | let contentsStart = lineEnd(element.range.lowerBound) + 1 | |
| 496 | guard point > contentsStart else { return element.range.lowerBound } | |
| 497 | let contentsEnd = saveExcursion { () -> Int in | |
| 498 | point = element.range.upperBound | |
| 499 | skipBackward(Self.whitespace) | |
| 500 | return lineStart(point) | |
| 501 | } | |
| 502 | if point > contentsEnd { return contentsEnd } | |
| 503 | return saveExcursion { | |
| 504 | skipBackward(Self.whitespace, bound: element.range.lowerBound) | |
| 505 | return searchBackward("^[ \t]*\n", bound: contentsStart, caseInsensitive: false)?[0]!.lowerBound ?? contentsStart | |
| 506 | } | |
| 507 | case .block, .drawer, .propertyDrawer, .dynamicBlock: | |
| 508 | // On the closing line: inside, to the contents' end. | |
| 509 | return lineStart(point) > element.range.lowerBound ? lineStart(point) : element.range.lowerBound | |
| 510 | default: | |
| 511 | return lineStart(element.range.lowerBound) | |
| 512 | } | |
| 513 | } | |
| 514 | ||
| 470 | 515 | /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a |
| 471 | 516 | /// paragraph. Other elements stay. |
| 472 | func fillRegion(_ from: Int, _ to: Int) { | |
| 517 | func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) { | |
| 473 | 518 | var spans: [Range<Int>] = [] |
| 474 | 519 | var line = lineStart(from) |
| 475 | 520 | while line < to, line < length { |
| @@ -486,7 +531,7 @@ extension EmacsBuffer { | ||
| 486 | 531 | line = next |
| 487 | 532 | } |
| 488 | 533 | // Back to front, so the spans before stay where they are. |
| 489 | for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound) } | |
| 534 | for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound, nosqueeze: nosqueeze) } | |
| 490 | 535 | } |
| 491 | 536 | |
| 492 | 537 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that |
| @@ -496,18 +541,31 @@ extension EmacsBuffer { | ||
| 496 | 541 | } |
| 497 | 542 | } |
| 498 | 543 | |
| 499 | /// M-q: `org-fill-paragraph`. | |
| 544 | /// M-q: `org-fill-paragraph`. With a selection, as with an active region, every paragraph | |
| 545 | /// in it; `point` is where the caret is in it, and stays. | |
| 500 | 546 | public struct FillParagraph: OrgCommand { |
| 501 | public init() {} | |
| 547 | public let point: Int? | |
| 548 | ||
| 549 | public init(point: Int? = nil) { self.point = point } | |
| 502 | 550 | public var id: String { "org.fill-paragraph" } |
| 503 | 551 | public var title: String { "Fill Paragraph" } |
| 504 | 552 | public func applies(in context: EditContext) -> Bool { true } |
| 505 | public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } } | |
| 553 | public func run(in context: EditContext) -> CommandStep { | |
| 554 | commitBuffer(context) { buffer in | |
| 555 | if let selection = context.selection.first, !selection.isEmpty { | |
| 556 | if let point { buffer.point = point } | |
| 557 | buffer.fillParagraphs(selection.lowerBound, selection.upperBound) | |
| 558 | } else { | |
| 559 | buffer.fillParagraph() | |
| 560 | } | |
| 561 | } | |
| 562 | } | |
| 506 | 563 | } |
| 507 | 564 | |
| 508 | 565 | /// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`. |
| 509 | 566 | /// With `keep` (`gw`) the caret stays there; without (`gq`) it goes to the first non-blank |
| 510 | /// of the range's last line. | |
| 567 | /// of the range's last line. Spaces are not squeezed, as Doom's `+evil--no-squeeze-on-fill-a` | |
| 568 | /// has it. | |
| 511 | 569 | public struct FillRegion: OrgCommand { |
| 512 | 570 | public let range: Range<Int> |
| 513 | 571 | public let keep: Int? |
| @@ -524,7 +582,7 @@ public struct FillRegion: OrgCommand { | ||
| 524 | 582 | commitBuffer(context) { buffer in |
| 525 | 583 | let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1)) |
| 526 | 584 | defer { buffer.release(target) } |
| 527 | buffer.fillRegion(range.lowerBound, range.upperBound) | |
| 585 | buffer.fillRegion(range.lowerBound, range.upperBound, nosqueeze: true) | |
| 528 | 586 | buffer.point = target.position |
| 529 | 587 | if keep == nil { |
| 530 | 588 | buffer.beginningOfLine() |
Sources/OrgCore/Commands/TimestampCommands.swift +12 −9
| @@ -14,12 +14,17 @@ extension EmacsBuffer { | ||
| 14 | 14 | |
| 15 | 15 | /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. |
| 16 | 16 | func timestampCommand(_ answer: String, inactive: Bool) { |
| 17 | let existing = atTimestamp() | |
| 18 | let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | |
| 17 | let stamp = timestampForUpdate() | |
| 19 | 18 | let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) |
| 20 | 19 | let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } |
| 21 | 20 | let result = readDate(answer, default: defaultDate) |
| 22 | 21 | let components = result.components(calendar) |
| 22 | // Between a range's dashes, the end is replaced. | |
| 23 | var existing = atTimestamp() | |
| 24 | if stamp != nil, existing == nil { | |
| 25 | skipForward([45]) | |
| 26 | existing = atTimestamp() | |
| 27 | } | |
| 23 | 28 | if let existing { |
| 24 | 29 | replace(existing.match[0]!, with: "") |
| 25 | 30 | insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) |
| @@ -95,8 +100,8 @@ private func atTimestamp(_ context: EditContext) -> Bool { | ||
| 95 | 100 | /// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. |
| 96 | 101 | private func timeOfDayAtCaret(_ context: EditContext) -> String { |
| 97 | 102 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 98 | guard let match = buffer.atTimestamp()?.match[0] else { return "" } | |
| 99 | return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? "" | |
| 103 | guard let stamp = buffer.timestampForUpdate() else { return "" } | |
| 104 | return EmacsBuffer.compactTimeOfDay(stamp) ?? "" | |
| 100 | 105 | } |
| 101 | 106 | |
| 102 | 107 | /// C-c . and C-c !: `org-timestamp`. |
| @@ -110,16 +115,14 @@ public struct InsertTimestamp: OrgCommand { | ||
| 110 | 115 | guard let answer = context.answers["date"] else { |
| 111 | 116 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 112 | 117 | buffer.calendar = context.calendar |
| 113 | let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | |
| 114 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" | |
| 115 | 118 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), |
| 116 | date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp), inactive: inactive))) | |
| 119 | date: .init(defaultDate: buffer.dateDefault(buffer.timestampForUpdate()), inactive: inactive))) | |
| 117 | 120 | } |
| 118 | 121 | // Twice in a row on a timestamp: a range to a second one. |
| 119 | 122 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" |
| 120 | if repeated, EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil { | |
| 123 | if repeated, EmacsBuffer(context.text, point: context.caret).timestampForUpdate() != nil { | |
| 121 | 124 | return timestampCommand(context) { buffer in |
| 122 | let result = buffer.readDate(answer, default: nil) | |
| 125 | let result = buffer.readDate(answer, default: buffer.dateDefault(buffer.timestampForUpdate())) | |
| 123 | 126 | buffer.insert("--") |
| 124 | 127 | buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) |
| 125 | 128 | } |
Sources/OrgCore/Commands/Timestamps.swift +25
| @@ -66,6 +66,31 @@ extension EmacsBuffer { | ||
| 66 | 66 | return (part, match) |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /// `org-at-date-range-p` with INACTIVE-OK: the range around point, groups 1 and 2 the insides | |
| 70 | /// of its two timestamps. | |
| 71 | func atDateRange() -> [Range<Int>?]? { | |
| 72 | let pattern = "[\\[<](" + AgendaSource.tsInternal + ")[\\]>]--?-?[\\[<](" + AgendaSource.tsInternal + ")[\\]>]" | |
| 73 | return saveExcursion { | |
| 74 | let pos = point | |
| 75 | for _ in 0..<2 { | |
| 76 | while let c = character(at: point - 1), ![91, 60, 13, 10].contains(c) { point -= 1 } | |
| 77 | skipBackward([60, 91]) | |
| 78 | if let match = lookingAt(pattern), match[0]!.upperBound >= pos { return match } | |
| 79 | } | |
| 80 | return nil | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | /// The timestamp `org-timestamp` takes its defaults from: in a date range the start before | |
| 85 | /// the dashes and the end from the second-to-last dash on, otherwise the one at point. | |
| 86 | func timestampForUpdate() -> String? { | |
| 87 | if let range = atDateRange() { | |
| 88 | let stamp = point < range[2]!.lowerBound - 2 ? range[1]! : range[2]! | |
| 89 | return substring(stamp.lowerBound, stamp.upperBound) | |
| 90 | } | |
| 91 | return atTimestamp().map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | |
| 92 | } | |
| 93 | ||
| 69 | 94 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. |
| 70 | 95 | static func parseTimeString(_ text: String) -> DateComponents? { |
| 71 | 96 | guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } |
Sources/OrgCore/Keymap/Vim.swift +76 −66
| @@ -88,10 +88,11 @@ public struct Vim: Sendable { | ||
| 88 | 88 | public var clipboardRecord: (text: String, linewise: Bool)? |
| 89 | 89 | var anchor = 0 |
| 90 | 90 | var goalColumn: Int? |
| 91 | var lastFind: (kind: Character, char: String)? | |
| 92 | var lastSnipe: (forward: Bool, chars: String, count: Int)? | |
| 93 | /// `;` and `,` repeat a snipe when one came after the last f, F, t or T. | |
| 94 | var lastWasSnipe = false | |
| 91 | /// The last snipe: s, S, f, F, t or T. `consume` lands on the match (s, f, F) rather | |
| 92 | /// than next to it (t, T); `keys` repeat it right after it, forward and back. | |
| 93 | var lastSnipe: (forward: Bool, chars: String, count: Int, consume: Bool, keys: (forward: KeyChord, backward: KeyChord))? | |
| 94 | /// Set after a snipe: its keys repeat it as the next key, as evil-snipe's transient map has it. | |
| 95 | var snipeRepeatKeys: (forward: KeyChord, backward: KeyChord)? | |
| 95 | 96 | var lastSearch: (pattern: String, forward: Bool)? |
| 96 | 97 | /// The keys and inserted text of the last change, for `.`. |
| 97 | 98 | var lastChange: (keys: [KeyChord], inserted: String?)? |
| @@ -129,6 +130,11 @@ public struct Vim: Sendable { | ||
| 129 | 130 | case .insert: |
| 130 | 131 | return insertKey(key, context) |
| 131 | 132 | case .normal, .visual, .visualLine, .visualBlock: |
| 133 | var key = key | |
| 134 | if pending.isEmpty, let repeatKeys = snipeRepeatKeys { | |
| 135 | if key == repeatKeys.forward { key = KeyChord(";") } else if key == repeatKeys.backward { key = KeyChord(",") } | |
| 136 | } | |
| 137 | if pending.isEmpty { snipeRepeatKeys = nil } | |
| 132 | 138 | pending.append(key) |
| 133 | 139 | let keys = pending |
| 134 | 140 | caretForApply = context.caret |
| @@ -611,36 +617,23 @@ public struct Vim: Sendable { | ||
| 611 | 617 | return .invalid |
| 612 | 618 | } |
| 613 | 619 | case ("f", []), ("F", []), ("t", []), ("T", []): |
| 620 | // evil-snipe-override-mode: one-character snipes. | |
| 614 | 621 | guard keys.count >= 2 else { return .incomplete } |
| 615 | let char = keys[1].key == "SPC" ? " " : keys[1].key | |
| 622 | let char = keys[1].key == "SPC" ? " " : keys[1].key == "TAB" ? "\t" : keys[1].key | |
| 616 | 623 | guard char.count == 1 else { return .invalid } |
| 617 | lastFind = (Character(key.key), char) | |
| 618 | lastWasSnipe = false | |
| 619 | guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } | |
| 620 | return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive) | |
| 621 | case (";", []) where lastWasSnipe, (",", []) where lastWasSnipe: | |
| 624 | let forward = key.key == "f" || key.key == "t" | |
| 625 | lastSnipe = (forward, char, n, key.key == "f" || key.key == "F", (KeyChord(key.key.lowercased()), KeyChord(key.key.uppercased()))) | |
| 626 | return snipe(forward: forward, count: n, buffer, c, operatorPending: operatorPending, repeating: false) | |
| 627 | case (";", []), (",", []): | |
| 622 | 628 | // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible. |
| 623 | 629 | guard let last = lastSnipe else { return .invalid } |
| 624 | 630 | let forward = key.key == ";" ? last.forward : !last.forward |
| 625 | let scope = contextVisible.map { min($0.lowerBound, buffer.length)..<min($0.upperBound, buffer.length) } | |
| 626 | guard let p = snipe(forward: forward, last.chars, buffer, c, count: n * last.count, scope: scope) else { return .invalid } | |
| 627 | return .moved(p, .exclusive) | |
| 628 | case (";", []), (",", []): | |
| 629 | guard let last = lastFind else { return .invalid } | |
| 630 | var kind = last.kind | |
| 631 | if key.key == "," { | |
| 632 | kind = ["f": "F", "F": "f", "t": "T", "T": "t"][kind]! | |
| 633 | } | |
| 634 | guard let p = find(kind, last.char, buffer, c, count: n, repeating: true) else { return .invalid } | |
| 635 | return .moved(p, kind == "f" || kind == "t" ? .inclusive : .exclusive) | |
| 631 | return snipe(forward: forward, count: n * last.count, buffer, c, operatorPending: operatorPending, repeating: true) | |
| 636 | 632 | case ("s", []) where !operatorPending, ("S", []) where !operatorPending: |
| 637 | // evil-snipe with `evil-snipe-scope` line. | |
| 638 | 633 | guard keys.count >= 3 else { return .incomplete } |
| 639 | 634 | let chars = keys[1].key + keys[2].key |
| 640 | lastSnipe = (key.key == "s", chars, n) | |
| 641 | lastWasSnipe = true | |
| 642 | guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid } | |
| 643 | return .moved(p, .exclusive) | |
| 635 | lastSnipe = (key.key == "s", chars, n, true, (KeyChord("s"), KeyChord("S"))) | |
| 636 | return snipe(forward: key.key == "s", count: n, buffer, c, operatorPending: operatorPending, repeating: false) | |
| 644 | 637 | case ("%", []): |
| 645 | 638 | guard let p = buffer.matchingBracket(c) else { return .invalid } |
| 646 | 639 | return .moved(p, .inclusive) |
| @@ -686,51 +679,68 @@ public struct Vim: Sendable { | ||
| 686 | 679 | } |
| 687 | 680 | } |
| 688 | 681 | |
| 689 | func find(_ kind: Character, _ char: String, _ buffer: Buffer, _ c: Int, count n: Int, repeating: Bool = false) -> Int? { | |
| 690 | let target = char.utf16.first! | |
| 691 | let forward = kind == "f" || kind == "t" | |
| 692 | var p = c | |
| 693 | for i in 0..<n { | |
| 694 | var q = p | |
| 695 | // Repeating t/T starts past the character it stopped before. | |
| 696 | let skip = repeating && i == 0 && (kind == "t" || kind == "T") ? 1 : 0 | |
| 697 | if forward { | |
| 698 | q += 1 + skip | |
| 699 | while q < buffer.lineEnd(c), buffer.char(q) != target { q += 1 } | |
| 700 | guard q < buffer.lineEnd(c) else { return nil } | |
| 701 | } else { | |
| 702 | q -= 1 + skip | |
| 703 | while q >= buffer.lineStart(c), buffer.char(q) != target { q -= 1 } | |
| 704 | guard q >= buffer.lineStart(c) else { return nil } | |
| 682 | /// `evil-snipe--seek` for the last snipe: the `n`th match from `c` in the line after (or | |
| 683 | /// before) the search start, or for a repeat in the visible text, with smart case and | |
| 684 | /// `evil-snipe-skip-leading-whitespace`. | |
| 685 | mutating func snipe(forward: Bool, count n: Int, _ buffer: Buffer, _ c: Int, operatorPending: Bool, repeating: Bool) -> MotionResult { | |
| 686 | guard let last = lastSnipe else { return .invalid } | |
| 687 | // f, s and F search from the next or this character, t and T one further. | |
| 688 | let start = forward ? c + (last.consume ? 1 : 2) : c - (last.consume ? 0 : 1) | |
| 689 | guard start >= 0, start <= buffer.length else { return .invalid } | |
| 690 | var bound = forward ? buffer.lineEnd(start) : buffer.lineStart(start) | |
| 691 | if repeating, let visible = contextVisible { | |
| 692 | bound = forward ? min(visible.upperBound, buffer.length) - 1 : min(visible.lowerBound, start) | |
| 693 | } | |
| 694 | let blankKeys = last.chars.first == " " || last.chars.first == "\t" | |
| 695 | var pattern = NSRegularExpression.escapedPattern(for: last.chars) | |
| 696 | if blankKeys, let ch = buffer.char(start), Buffer.isBlank(ch) { pattern += "[^ \t]" } | |
| 697 | let regex = try! NSRegularExpression(pattern: pattern, options: last.chars == last.chars.lowercased() ? .caseInsensitive : []) | |
| 698 | var match: NSRange? | |
| 699 | if forward, start <= bound { | |
| 700 | var from = start | |
| 701 | for _ in 0..<n { | |
| 702 | match = regex.firstMatch(in: buffer.ns as String, range: NSRange(location: from, length: bound - from))?.range | |
| 703 | guard let found = match else { break } | |
| 704 | from = NSMaxRange(found) | |
| 705 | } | |
| 706 | } else if !forward { | |
| 707 | var from = start | |
| 708 | for _ in 0..<n { | |
| 709 | match = nil | |
| 710 | var p = from - 1 | |
| 711 | while p >= bound, match == nil { | |
| 712 | match = regex.firstMatch(in: buffer.ns as String, options: .anchored, range: NSRange(location: p, length: from - p))?.range | |
| 713 | p -= 1 | |
| 714 | } | |
| 715 | guard let found = match else { break } | |
| 716 | from = found.location | |
| 705 | 717 | } |
| 706 | p = q | |
| 707 | 718 | } |
| 708 | if kind == "t" { return p - 1 } | |
| 709 | if kind == "T" { return p + 1 } | |
| 710 | return p | |
| 711 | } | |
| 712 | ||
| 713 | /// Searches the caret's line, or `scope` when given. | |
| 714 | func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? { | |
| 715 | let range = scope ?? buffer.lineStart(c)..<buffer.lineEnd(c) | |
| 716 | let line = buffer.substring(range) as NSString | |
| 717 | let start = range.lowerBound | |
| 718 | guard range.lowerBound <= c, c <= range.upperBound else { return nil } | |
| 719 | let smart = chars == chars.lowercased() | |
| 720 | var p = c - start | |
| 721 | for _ in 0..<n { | |
| 722 | let range: NSRange | |
| 723 | if forward { | |
| 724 | guard p + 1 < line.length else { return nil } | |
| 725 | range = line.range(of: chars, options: smart ? .caseInsensitive : [], range: NSRange(location: p + 1, length: line.length - p - 1)) | |
| 719 | guard let found = match else { | |
| 720 | if repeating { snipeRepeatKeys = last.keys } | |
| 721 | return .invalid | |
| 722 | } | |
| 723 | if !operatorPending { snipeRepeatKeys = last.keys } | |
| 724 | var end = NSMaxRange(found) | |
| 725 | var length = found.length | |
| 726 | if blankKeys { | |
| 727 | end -= 1 | |
| 728 | length -= 1 | |
| 729 | } | |
| 730 | let visual = mode == .visual || mode == .visualLine || mode == .visualBlock | |
| 731 | var p: Int | |
| 732 | if !forward { | |
| 733 | p = last.consume ? found.location : end | |
| 734 | } else { | |
| 735 | p = visual ? (last.consume ? end : found.location) : operatorPending ? end : found.location | |
| 736 | if last.consume { | |
| 737 | if visual { p -= 1 } | |
| 726 | 738 | } else { |
| 727 | guard p > 0 else { return nil } | |
| 728 | range = line.range(of: chars, options: smart ? [.caseInsensitive, .backwards] : .backwards, range: NSRange(location: 0, length: p)) | |
| 739 | p -= length | |
| 740 | if length > 1, !operatorPending { p += 1 } | |
| 729 | 741 | } |
| 730 | guard range.location != NSNotFound else { return nil } | |
| 731 | p = range.location | |
| 732 | 742 | } |
| 733 | return start + p | |
| 743 | return .moved(p, .exclusive) | |
| 734 | 744 | } |
| 735 | 745 | |
| 736 | 746 | // MARK: - Text objects |
Sources/OrgCore/Keymap/VimExtras.swift +1 −1
| @@ -11,7 +11,7 @@ extension Vim { | ||
| 11 | 11 | /// Motions evil records in the jump list. |
| 12 | 12 | static func isJump(_ keys: [KeyChord]) -> Bool { |
| 13 | 13 | guard let key = keys.first, key.modifiers.isEmpty else { return false } |
| 14 | if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L"].contains(key.key) { return true } | |
| 14 | if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L", "s", "S", "f", "F", "t", "T"].contains(key.key) { return true } | |
| 15 | 15 | return key.key == "g" && keys.count > 1 && keys[1].key == "g" |
| 16 | 16 | } |
| 17 | 17 | |
Sources/OrgEditorAppKit/Modal.swift +22 −1
| @@ -34,7 +34,11 @@ extension OrgEditor { | ||
| 34 | 34 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 35 | 35 | let context = editContext() |
| 36 | 36 | if let binding = candidates.first(where: { modalRuns($0, in: context) }) { |
| 37 | run(binding.command) | |
| 37 | if binding.command == "org.fill-paragraph", vim.mode == .visual || vim.mode == .visualLine { | |
| 38 | fillVisual() | |
| 39 | } else { | |
| 40 | run(binding.command) | |
| 41 | } | |
| 38 | 42 | updateCursor() |
| 39 | 43 | return true |
| 40 | 44 | } |
| @@ -53,6 +57,23 @@ extension OrgEditor { | ||
| 53 | 57 | } |
| 54 | 58 | } |
| 55 | 59 | |
| 60 | /// M-q in visual states: `org-fill-paragraph` on the region, the cursor kept. A change | |
| 61 | /// deactivates the region, which leaves visual state. | |
| 62 | private func fillVisual() { | |
| 63 | let selected = textView.selectedRange() | |
| 64 | let cursor = visualCaret ?? selected.location | |
| 65 | let text = document.text | |
| 66 | perform(FillParagraph(point: cursor)) | |
| 67 | if document.text == text { | |
| 68 | textView.setSelectedRange(selected) | |
| 69 | visualCaret = cursor | |
| 70 | } else { | |
| 71 | vim.reset() | |
| 72 | visualCaret = nil | |
| 73 | clampCaret() | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 56 | 77 | /// Plays keys into the engine. False hands a single key in insert state to the text |
| 57 | 78 | /// system. |
| 58 | 79 | func feedVim(_ keys: [KeyChord]) -> Bool { |
Tests/OrgCoreTests/FillTests.swift +40
| @@ -37,6 +37,46 @@ struct FillTests { | ||
| 37 | 37 | try compareWithEmacs(inputs, commands, options: doomOptions) |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | /// M-q with an active region: `org-fill-paragraph` called interactively. | |
| 41 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 42 | func regionFillMatchesEmacs() throws { | |
| 43 | try #require(EmacsOracle.isAvailable) | |
| 44 | let texts = [ | |
| 45 | "Short\nlines\n\n" + Self.long + "\nmore\n\nlast\npara\n", | |
| 46 | "* H\nintro\ntext\n- item " + Self.long + "\n- b\n c\n# comment\n# lines\n#\n# again\n", | |
| 47 | "a\nb\n#+begin_comment\none\ntwo\n\nthree\nfour\n#+end_comment\n:NOTES:\nx\ny\n:END:\n#+begin_quote\nq\nr\n#+end_quote\nz\nw\n", | |
| 48 | "Words with extra spaces\n\n and more\n", | |
| 49 | ] | |
| 50 | var cases: [EmacsOracle.Case] = [] | |
| 51 | var ours: [(label: String, text: String, caret: Int)] = [] | |
| 52 | for text in texts { | |
| 53 | let positions = EmacsOracle.positions(text) | |
| 54 | for (i, from) in positions.enumerated() where i % 3 == 0 { | |
| 55 | for to in positions where to > from && (to - from) % 5 == 1 { | |
| 56 | let form = "(progn (transient-mark-mode 1) (set-mark \(EmacsOracle.point(to, in: text))) (activate-mark) (org-fill-paragraph nil t))" | |
| 57 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(from, in: text), form: withOptions(.org, form))) | |
| 58 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [from..<to], options: .org) | |
| 59 | guard case .commit(let result) = FillParagraph().run(in: context) else { | |
| 60 | ours.append(("\(from)..<\(to) failed", text, -1)) | |
| 61 | continue | |
| 62 | } | |
| 63 | let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) } | |
| 64 | ours.append(("\(from)..<\(to) in \(text.debugDescription)", new, result.selection?.first?.lowerBound ?? from)) | |
| 65 | } | |
| 66 | } | |
| 67 | } | |
| 68 | var mismatches = 0 | |
| 69 | for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) { | |
| 70 | let caret = EmacsOracle.offset(emacs.point, in: emacs.text) | |
| 71 | guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue } | |
| 72 | mismatches += 1 | |
| 73 | if mismatches <= 12 { | |
| 74 | Issue.record("\(mine.label)\n ours: \(mine.text.debugDescription) @\(mine.caret)\n emacs: \(emacs.error.isEmpty ? "\(emacs.text.debugDescription) @\(caret)" : emacs.error)") | |
| 75 | } | |
| 76 | } | |
| 77 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | |
| 78 | } | |
| 79 | ||
| 40 | 80 | /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines |
| 41 | 81 | /// of up to 60 files. |
| 42 | 82 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) |
Tests/OrgCoreTests/TimestampCommandTests.swift +46 −2
| @@ -6,10 +6,10 @@ struct TimestampCommandTests { | ||
| 6 | 6 | static let now = ReadDateTests.now |
| 7 | 7 | static let calendar = ReadDateTests.utc |
| 8 | 8 | |
| 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) { | |
| 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil, lastCommand: String? = nil) -> (text: String, caret: Int, failure: String?) { | |
| 10 | 10 | let context = EditContext( |
| 11 | 11 | revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], |
| 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:] | |
| 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:], lastCommand: lastCommand | |
| 13 | 13 | ) |
| 14 | 14 | switch command.run(in: context) { |
| 15 | 15 | case .commit(let result): |
| @@ -100,6 +100,50 @@ struct TimestampCommandTests { | ||
| 100 | 100 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | /// `org-timestamp` in and around date ranges: the start before the dashes, the end from the | |
| 104 | /// second dash on, and twice in a row a new range wherever there is a stamp to default from. | |
| 105 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 106 | func timestampsInRangesMatchEmacs() throws { | |
| 107 | try #require(EmacsOracle.isAvailable) | |
| 108 | let texts = [ | |
| 109 | "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] => 1:30\n:END:\n", | |
| 110 | "x <2026-09-01 Tue>--<2026-09-03 Thu> y\n", | |
| 111 | "[2026-09-01 Tue]-[2026-09-02 Wed]\n", | |
| 112 | "<2026-09-01 Tue 10:00>---<2026-09-01 Tue 12:00 +1w>\n", | |
| 113 | ] | |
| 114 | var cases: [EmacsOracle.Case] = [] | |
| 115 | var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] | |
| 116 | for text in texts { | |
| 117 | for caret in EmacsOracle.positions(text) { | |
| 118 | for (command, form) in [(InsertTimestamp(), "(org-timestamp nil)"), (InsertTimestamp(inactive: true), "(org-timestamp nil t)")] { | |
| 119 | for answer in ["", "+1", "10:00", "++2d"] { | |
| 120 | for repeated in [false, true] { | |
| 121 | let commands = repeated ? "(last-command 'org-timestamp) (this-command 'org-timestamp)" : "" | |
| 122 | let wrapped = frozenClock(1_789_000_000, """ | |
| 123 | (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(answer)"))) | |
| 124 | (let ((org-read-date-popup-calendar nil) \(commands)) \(form))) | |
| 125 | """) | |
| 126 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped)) | |
| 127 | let result = run(command, text, caret: caret, answer: answer, lastCommand: repeated ? "org.timestamp.active" : nil) | |
| 128 | ours.append(("\(command.id) \(answer.debugDescription)\(repeated ? " repeated" : "") at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | } | |
| 133 | } | |
| 134 | var mismatches = 0 | |
| 135 | for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) { | |
| 136 | let failed = !emacs.error.isEmpty | |
| 137 | let caret = EmacsOracle.offset(emacs.point, in: emacs.text) | |
| 138 | guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue } | |
| 139 | mismatches += 1 | |
| 140 | if mismatches <= 12 { | |
| 141 | Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")") | |
| 142 | } | |
| 143 | } | |
| 144 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | |
| 145 | } | |
| 146 | ||
| 103 | 147 | /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). |
| 104 | 148 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 105 | 149 | func clockShiftsMatchEmacs() throws { |
Tests/OrgCoreTests/VimExtrasTests.swift +8
| @@ -165,6 +165,8 @@ struct VimExtrasTests { | ||
| 165 | 165 | ("a\nb\nc\nd\n", 2, "g q j"), |
| 166 | 166 | ("a\nb\nc\n", 0, "V j g q"), |
| 167 | 167 | ("a\nb\n\nc\nd\n", 0, "g q j j ."), |
| 168 | ("one two three\nfour five \nsix\n", 0, "g q G"), | |
| 169 | ("End. Next word, " + long + " \nmore\n", 3, "g w j"), | |
| 168 | 170 | ] |
| 169 | 171 | }() |
| 170 | 172 | |
| @@ -201,6 +203,12 @@ struct VimExtrasTests { | ||
| 201 | 203 | (dolist (d '("evil" "goto-chg" "evil-surround" "evil-org-mode")) (add-to-list 'load-path (concat \(lispString(repos.path)) "/" d))) |
| 202 | 204 | (require 'evil) |
| 203 | 205 | (require 'evil-surround) |
| 206 | (defun oracle-no-squeeze (fn &rest args) | |
| 207 | (cl-letf* ((fill (symbol-function 'fill-region)) | |
| 208 | ((symbol-function 'fill-region) (lambda (from to &optional justify _nosqueeze to-eop) (funcall fill from to justify t to-eop)))) | |
| 209 | (apply fn args))) | |
| 210 | (advice-add 'evil-fill :around #'oracle-no-squeeze) | |
| 211 | (advice-add 'evil-fill-and-move :around #'oracle-no-squeeze) | |
| 204 | 212 | (setq kill-ring nil) |
| 205 | 213 | (switch-to-buffer (current-buffer)) |
| 206 | 214 | \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects navigation)) (evil-org-mode 1)" : "") |
Tests/OrgCoreTests/VimTests.swift +34 −1
| @@ -193,6 +193,36 @@ struct VimTests { | ||
| 193 | 193 | ("one two three\n", 0, "3 l v l d"), |
| 194 | 194 | ("one two three\n", 8, "v b d"), |
| 195 | 195 | ("one two three\n", 0, "v 2 e c X ESC"), |
| 196 | // evil-snipe-override-mode: f, F, t and T snipe one character, ; and , repeat over | |
| 197 | // the visible text, and the snipe's own keys repeat it right after. | |
| 198 | ("abc abc\nxbz abc\n", 0, "f c ; ; x"), | |
| 199 | ("abc abc\nxbz abc\n", 0, "f c f f x"), | |
| 200 | ("abc abc\nxbz abc\n", 0, "f c f F x"), | |
| 201 | ("abc abc abc\n", 10, "F b f x"), | |
| 202 | ("abc abc abc abc\n", 10, "F b F x"), | |
| 203 | ("abc abc\nxbz abc\n", 14, "T a ; x"), | |
| 204 | ("abc abc\nxbz abc\n", 0, "t c t x"), | |
| 205 | ("abc abc\nxbz abc\n", 0, "f c l f c x"), | |
| 206 | ("aAa\n", 0, "f a x"), | |
| 207 | ("aAa\n", 0, "f A x"), | |
| 208 | ("abab\n", 0, "t b x"), | |
| 209 | ("ab ab\n", 4, "T a x"), | |
| 210 | ("abc\nxbz\n", 2, "t z x"), | |
| 211 | ("a b c\n", 0, "f SPC x"), | |
| 212 | ("a b\n", 0, "d f SPC"), | |
| 213 | ("a b c\n", 0, "t SPC x"), | |
| 214 | ("one two three\n", 0, "d t e"), | |
| 215 | ("one two three\n", 0, "c f o X ESC"), | |
| 216 | ("one two three\n", 12, "d F o"), | |
| 217 | ("one two three\n", 12, "d T o"), | |
| 218 | ("one two three\n", 0, "v f e d"), | |
| 219 | ("one two three\n", 0, "v t e d"), | |
| 220 | ("one two three\n", 0, "d f e ."), | |
| 221 | ("a1a2a3a4a5a6\n", 0, "2 f a ; x"), | |
| 222 | ("ab ab ab\n", 0, "s a b s S x"), | |
| 223 | ("ab ab ab\n", 0, "s a b d ;"), | |
| 224 | ("ab ab ab\n", 0, "v s a b d"), | |
| 225 | ("ab x\ncd ab\nab\n", 5, "s a b ; , x"), | |
| 196 | 226 | ] |
| 197 | 227 | |
| 198 | 228 | /// Thirty lines of seven characters: line `i` starts at `7 * i`. |
| @@ -272,11 +302,12 @@ struct VimTests { | ||
| 272 | 302 | (add-to-list 'load-path "\(evil.path)/evil-snipe") |
| 273 | 303 | (require 'evil) |
| 274 | 304 | (require 'evil-snipe) |
| 275 | (setq evil-snipe-smart-case t evil-snipe-scope 'line) | |
| 305 | (setq evil-snipe-smart-case t evil-snipe-scope 'line evil-snipe-repeat-scope 'visible evil-snipe-char-fold t) | |
| 276 | 306 | (setq kill-ring nil) |
| 277 | 307 | (switch-to-buffer (current-buffer)) |
| 278 | 308 | (evil-local-mode 1) |
| 279 | 309 | (evil-snipe-local-mode 1) |
| 310 | (evil-snipe-override-local-mode 1) | |
| 280 | 311 | (evil-normal-state) |
| 281 | 312 | (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))")) |
| 282 | 313 | (let ((p (point)) (s evil-state)) |
| @@ -288,6 +319,8 @@ struct VimTests { | ||
| 288 | 319 | let results = try EmacsOracle.run(forms) |
| 289 | 320 | for ((text, point, keys), result) in zip(Self.cases, results) { |
| 290 | 321 | var driver = VimDriver(text, caret: point) |
| 322 | // A batch Emacs window shows the whole buffer. | |
| 323 | driver.visible = 0..<text.utf16.count | |
| 291 | 324 | driver.type(Self.keys(keys)) |
| 292 | 325 | guard let marker = result.text.range(of: "\n@@STATE ") else { |
| 293 | 326 | Issue.record("\(keys): \(result.error)") |
Tests/OrgEditorAppKitTests/ModalTests.swift +19
| @@ -142,6 +142,25 @@ struct ModalTests { | ||
| 142 | 142 | #expect(h.editor.document.text == "short\nlines\n\nnext\n") |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | /// M-q in visual states fills the region's paragraphs and leaves visual state, as in | |
| 146 | /// Doom (checked against evil with `org-fill-paragraph`). | |
| 147 | @Test func fillParagraphFillsTheVisualRegion() { | |
| 148 | let h = doom("aaa\nbbb\n\nccc\nddd\n\neee\nfff\n") | |
| 149 | h.caret(at: 0) | |
| 150 | type(h, "v j j j") | |
| 151 | KeyTests().press(h, "q", option: KeyEvents.leftOption, typed: "œ") | |
| 152 | #expect(h.editor.document.text == "aaa bbb\n\nccc ddd\n\neee\nfff\n") | |
| 153 | #expect(h.textView.selectedRange() == NSRange(location: 9, length: 0)) | |
| 154 | #expect(h.editor.vim.mode == .normal) | |
| 155 | let lines = doom("aaa\nbbb\nccc\n\nx\ny\n") | |
| 156 | lines.caret(at: 8) | |
| 157 | type(lines, "v k") | |
| 158 | KeyTests().press(lines, "q", option: KeyEvents.leftOption, typed: "œ") | |
| 159 | #expect(lines.editor.document.text == "aaa bbb ccc\n\nx\ny\n") | |
| 160 | #expect(lines.textView.selectedRange() == NSRange(location: 4, length: 0)) | |
| 161 | #expect(lines.editor.vim.mode == .normal) | |
| 162 | } | |
| 163 | ||
| 145 | 164 | @Test func zzSavesAndClosesAsWq() { |
| 146 | 165 | let h = doom("text\n") |
| 147 | 166 | var app: [String] = [] |