Editor and Vim differences from Emacs !164

merged merged by cmc on 2026-10-08 04:58 UTC · krz/orgstar:editor-differences into main

11 files changed, +357 −96

Layout: unified · split

Sources/OrgCore/Commands/Fill.swift +74 −16
@@ -302,8 +302,9 @@ extension EmacsBuffer {
302302 }
303303 }
304304
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) {
307308 point = min(fromOffset, toOffset)
308309 let to = max(fromOffset, toOffset)
309310 skipForward(Self.whitespace)
@@ -349,13 +350,15 @@ extension EmacsBuffer {
349350 if character(at: at) == 10 { replace(at..<(at + 1), with: " ") }
350351 at += 1
351352 }
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 }
359362 point = from
360363 while point < toMarker.position {
361364 let lineBegin = point
@@ -467,9 +470,51 @@ extension EmacsBuffer {
467470 }
468471 }
469472
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
470515 /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a
471516 /// paragraph. Other elements stay.
472 func fillRegion(_ from: Int, _ to: Int) {
517 func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) {
473518 var spans: [Range<Int>] = []
474519 var line = lineStart(from)
475520 while line < to, line < length {
@@ -486,7 +531,7 @@ extension EmacsBuffer {
486531 line = next
487532 }
488533 // 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) }
490535 }
491536
492537 /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that
@@ -496,18 +541,31 @@ extension EmacsBuffer {
496541 }
497542}
498543
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.
500546public struct FillParagraph: OrgCommand {
501 public init() {}
547 public let point: Int?
548
549 public init(point: Int? = nil) { self.point = point }
502550 public var id: String { "org.fill-paragraph" }
503551 public var title: String { "Fill Paragraph" }
504552 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 }
506563}
507564
508565/// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`.
509566/// 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.
511569public struct FillRegion: OrgCommand {
512570 public let range: Range<Int>
513571 public let keep: Int?
@@ -524,7 +582,7 @@ public struct FillRegion: OrgCommand {
524582 commitBuffer(context) { buffer in
525583 let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1))
526584 defer { buffer.release(target) }
527 buffer.fillRegion(range.lowerBound, range.upperBound)
585 buffer.fillRegion(range.lowerBound, range.upperBound, nosqueeze: true)
528586 buffer.point = target.position
529587 if keep == nil {
530588 buffer.beginningOfLine()
Sources/OrgCore/Commands/TimestampCommands.swift +12 −9
@@ -14,12 +14,17 @@ extension EmacsBuffer {
1414
1515 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
1616 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()
1918 let defaultDate = stamp.flatMap(Self.parseTimeString).map(date)
2019 let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } }
2120 let result = readDate(answer, default: defaultDate)
2221 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 }
2328 if let existing {
2429 replace(existing.match[0]!, with: "")
2530 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
@@ -95,8 +100,8 @@ private func atTimestamp(_ context: EditContext) -> Bool {
95100/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it.
96101private func timeOfDayAtCaret(_ context: EditContext) -> String {
97102 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) ?? ""
100105}
101106
102107/// C-c . and C-c !: `org-timestamp`.
@@ -110,16 +115,14 @@ public struct InsertTimestamp: OrgCommand {
110115 guard let answer = context.answers["date"] else {
111116 let buffer = EmacsBuffer(context.text, point: context.caret)
112117 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"
115118 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)))
117120 }
118121 // Twice in a row on a timestamp: a range to a second one.
119122 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 {
121124 return timestampCommand(context) { buffer in
122 let result = buffer.readDate(answer, default: nil)
125 let result = buffer.readDate(answer, default: buffer.dateDefault(buffer.timestampForUpdate()))
123126 buffer.insert("--")
124127 buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive)
125128 }
Sources/OrgCore/Commands/Timestamps.swift +25
@@ -66,6 +66,31 @@ extension EmacsBuffer {
6666 return (part, match)
6767 }
6868
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
6994 /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
7095 static func parseTimeString(_ text: String) -> DateComponents? {
7196 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 {
8888 public var clipboardRecord: (text: String, linewise: Bool)?
8989 var anchor = 0
9090 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)?
9596 var lastSearch: (pattern: String, forward: Bool)?
9697 /// The keys and inserted text of the last change, for `.`.
9798 var lastChange: (keys: [KeyChord], inserted: String?)?
@@ -129,6 +130,11 @@ public struct Vim: Sendable {
129130 case .insert:
130131 return insertKey(key, context)
131132 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 }
132138 pending.append(key)
133139 let keys = pending
134140 caretForApply = context.caret
@@ -611,36 +617,23 @@ public struct Vim: Sendable {
611617 return .invalid
612618 }
613619 case ("f", []), ("F", []), ("t", []), ("T", []):
620 // evil-snipe-override-mode: one-character snipes.
614621 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
616623 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 (";", []), (",", []):
622628 // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible.
623629 guard let last = lastSnipe else { return .invalid }
624630 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)
636632 case ("s", []) where !operatorPending, ("S", []) where !operatorPending:
637 // evil-snipe with `evil-snipe-scope` line.
638633 guard keys.count >= 3 else { return .incomplete }
639634 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)
644637 case ("%", []):
645638 guard let p = buffer.matchingBracket(c) else { return .invalid }
646639 return .moved(p, .inclusive)
@@ -686,51 +679,68 @@ public struct Vim: Sendable {
686679 }
687680 }
688681
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
705717 }
706 p = q
707718 }
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 }
726738 } 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 }
729741 }
730 guard range.location != NSNotFound else { return nil }
731 p = range.location
732742 }
733 return start + p
743 return .moved(p, .exclusive)
734744 }
735745
736746 // MARK: - Text objects
Sources/OrgCore/Keymap/VimExtras.swift +1 −1
@@ -11,7 +11,7 @@ extension Vim {
1111 /// Motions evil records in the jump list.
1212 static func isJump(_ keys: [KeyChord]) -> Bool {
1313 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 }
1515 return key.key == "g" && keys.count > 1 && keys[1].key == "g"
1616 }
1717
Sources/OrgEditorAppKit/Modal.swift +22 −1
@@ -34,7 +34,11 @@ extension OrgEditor {
3434 if keys.count > 1 { onKeysPending?(nil, []) }
3535 let context = editContext()
3636 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 }
3842 updateCursor()
3943 return true
4044 }
@@ -53,6 +57,23 @@ extension OrgEditor {
5357 }
5458 }
5559
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
5677 /// Plays keys into the engine. False hands a single key in insert state to the text
5778 /// system.
5879 func feedVim(_ keys: [KeyChord]) -> Bool {
Tests/OrgCoreTests/FillTests.swift +40
@@ -37,6 +37,46 @@ struct FillTests {
3737 try compareWithEmacs(inputs, commands, options: doomOptions)
3838 }
3939
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
4080 /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines
4181 /// of up to 60 files.
4282 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
Tests/OrgCoreTests/TimestampCommandTests.swift +46 −2
@@ -6,10 +6,10 @@ struct TimestampCommandTests {
66 static let now = ReadDateTests.now
77 static let calendar = ReadDateTests.utc
88
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?) {
1010 let context = EditContext(
1111 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
1313 )
1414 switch command.run(in: context) {
1515 case .commit(let result):
@@ -100,6 +100,50 @@ struct TimestampCommandTests {
100100 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
101101 }
102102
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
103147 /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`).
104148 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
105149 func clockShiftsMatchEmacs() throws {
Tests/OrgCoreTests/VimExtrasTests.swift +8
@@ -165,6 +165,8 @@ struct VimExtrasTests {
165165 ("a\nb\nc\nd\n", 2, "g q j"),
166166 ("a\nb\nc\n", 0, "V j g q"),
167167 ("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"),
168170 ]
169171 }()
170172
@@ -201,6 +203,12 @@ struct VimExtrasTests {
201203 (dolist (d '("evil" "goto-chg" "evil-surround" "evil-org-mode")) (add-to-list 'load-path (concat \(lispString(repos.path)) "/" d)))
202204 (require 'evil)
203205 (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)
204212 (setq kill-ring nil)
205213 (switch-to-buffer (current-buffer))
206214 \(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 {
193193 ("one two three\n", 0, "3 l v l d"),
194194 ("one two three\n", 8, "v b d"),
195195 ("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"),
196226 ]
197227
198228 /// Thirty lines of seven characters: line `i` starts at `7 * i`.
@@ -272,11 +302,12 @@ struct VimTests {
272302 (add-to-list 'load-path "\(evil.path)/evil-snipe")
273303 (require 'evil)
274304 (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)
276306 (setq kill-ring nil)
277307 (switch-to-buffer (current-buffer))
278308 (evil-local-mode 1)
279309 (evil-snipe-local-mode 1)
310 (evil-snipe-override-local-mode 1)
280311 (evil-normal-state)
281312 (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))"))
282313 (let ((p (point)) (s evil-state))
@@ -288,6 +319,8 @@ struct VimTests {
288319 let results = try EmacsOracle.run(forms)
289320 for ((text, point, keys), result) in zip(Self.cases, results) {
290321 var driver = VimDriver(text, caret: point)
322 // A batch Emacs window shows the whole buffer.
323 driver.visible = 0..<text.utf16.count
291324 driver.type(Self.keys(keys))
292325 guard let marker = result.text.range(of: "\n@@STATE ") else {
293326 Issue.record("\(keys): \(result.error)")
Tests/OrgEditorAppKitTests/ModalTests.swift +19
@@ -142,6 +142,25 @@ struct ModalTests {
142142 #expect(h.editor.document.text == "short\nlines\n\nnext\n")
143143 }
144144
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
145164 @Test func zzSavesAndClosesAsWq() {
146165 let h = doom("text\n")
147166 var app: [String] = []