Toggle comments, headings and items; mark subtrees !76

merged merged by cmc on 2026-10-06 03:08 UTC · krz/orgstar:toggles into main

5 files changed, +483 −1

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +1 −1
@@ -210,7 +210,7 @@ public enum Commands {
210210 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
211211 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
212212 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
213 OpenLinkAtPoint(), CreateID(),
213 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
214214 ]
215215
216216 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/ToggleCommands.swift added +342
@@ -0,0 +1,342 @@
1import Foundation
2
3// Converting lines between headings, items and text, commenting headings and marking subtrees
4// (Org 9.8.7): `org-toggle-comment`, `org-toggle-heading` (C-c *), `org-ctrl-c-minus` with
5// `org-toggle-item` and `org-cycle-list-bullet` (C-c -), `org-mark-subtree`. Without a region
6// the commands work on the caret's line; a selection is the region.
7
8/// C-c ;: `org-toggle-comment`.
9public struct ToggleComment: OrgCommand {
10 public init() {}
11 public var id: String { "org.heading.toggle-comment" }
12 public var title: String { "Toggle COMMENT" }
13 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
14 public func run(in context: EditContext) -> CommandStep {
15 guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
16 return commitBuffer(context) { buffer in
17 buffer.saveExcursion {
18 _ = try? buffer.backToHeading()
19 guard let match = buffer.lookingAt(buffer.complexHeadingPattern) else { return }
20 let commented = match[4].map { buffer.substring($0.lowerBound, $0.upperBound) }.map { $0 == "COMMENT" || $0.hasPrefix("COMMENT ") } ?? false
21 buffer.point = (match[3] ?? match[2] ?? match[1]!).upperBound
22 buffer.skipForward(EmacsBuffer.blanks)
23 if buffer.point == 0 || ![32, 9].contains(buffer.character(at: buffer.point - 1)) { buffer.insert(" ") }
24 if commented {
25 let start = buffer.point
26 let end = buffer.lineEnd(start)
27 if let space = buffer.searchForward(" ", bound: end, caseInsensitive: false) {
28 buffer.point = space[0]!.upperBound
29 } else {
30 buffer.point = end
31 }
32 buffer.skipForward(EmacsBuffer.blanks)
33 buffer.deleteRegion(start, buffer.point)
34 } else {
35 buffer.insert("COMMENT")
36 if !buffer.eolp { buffer.insert(" ") }
37 }
38 }
39 }
40 }
41}
42
43extension EmacsBuffer {
44 /// `org-at-comment-p`.
45 var atCommentLine: Bool { lookingAtLine("^[ \\t]*#(?: |$)") }
46
47 /// The region's (or the line's) bounds as the toggles take them: from the first non-blank
48 /// line, to the region's end (the line's end when it ends inside a line).
49 func toggleBounds(_ selection: Range<Int>, skipComments: Bool) -> (begin: Int, end: Marker) {
50 func skipBlanks(_ pos: Int) -> Int {
51 saveExcursion {
52 point = pos
53 if skipComments { while atCommentLine, forwardLine(1) == 0 {} }
54 skipForward([32, 13, 9, 10])
55 return lineStart(point)
56 }
57 }
58 if selection.isEmpty {
59 return (skipBlanks(lineStart(selection.lowerBound)), marker(lineEnd(selection.lowerBound)))
60 }
61 let end = selection.upperBound
62 let begin = skipComments ? skipBlanks(selection.lowerBound) : saveExcursion { point = selection.lowerBound; skipForward([32, 13, 9, 10]); return lineStart(point) }
63 return (begin, marker(skipComments ? (lineStart(end) == end ? end : lineEnd(end)) : end))
64 }
65
66 /// `org-current-level` of point's line, or nil before the first heading.
67 var levelAtPoint: Int? { beforeFirstHeading ? nil : outlineLevel }
68}
69
70/// C-c *: `org-toggle-heading`. Headings lose their stars; items become headings, their
71/// checkboxes TODO or DONE; other lines become headings one level below the current entry.
72public struct ToggleHeading: OrgCommand {
73 public init() {}
74 public var id: String { "org.heading.toggle" }
75 public var title: String { "Toggle Heading" }
76 public func applies(in context: EditContext) -> Bool { true }
77 public func run(in context: EditContext) -> CommandStep {
78 var toggled = false
79 let step = commitBuffer(context) { buffer in
80 let (begin, end) = buffer.toggleBounds(context.selection.first ?? context.caret..<context.caret, skipComments: true)
81 defer { buffer.release(end) }
82 // Point is at the region's end, as in Emacs.
83 let caret = buffer.marker(context.selection.first?.upperBound ?? buffer.point)
84 defer {
85 buffer.point = caret.position
86 buffer.release(caret)
87 }
88 buffer.point = begin
89 if buffer.atHeading {
90 while buffer.point < end.position {
91 if buffer.atHeading, let stars = buffer.lookingAt("\\*+ ") {
92 buffer.deleteRegion(stars[0]!.lowerBound, stars[0]!.upperBound)
93 toggled = true
94 }
95 if buffer.forwardLine(1) != 0 { break }
96 }
97 } else if buffer.inItem() == buffer.point {
98 let done = context.tree.settings.todoSequences.first?.done.first?.name ?? "DONE"
99 let todo = context.tree.settings.todoSequences.first?.active.first?.name ?? "TODO"
100 let structure = buffer.listStruct()
101 let selected = structure.filter { $0.pos >= begin && $0.pos < end.position }
102 let base = selected.map(\.ind).min() ?? 0
103 // Org takes the level after deleting the items: that of whatever follows them
104 // (a heading's own, or its section's), one deeper.
105 let bottom = structure.map(\.end).max() ?? end.position
106 let level = buffer.saveExcursion { () -> Int in
107 buffer.point = min(bottom, end.position + 1, buffer.length)
108 return buffer.beforeFirstHeading ? 0 : buffer.outlineLevel
109 }
110 // The last first, so earlier positions stay.
111 for item in selected.reversed() {
112 let depth = Set(selected.filter { $0.ind < item.ind && $0.ind >= base }.map(\.ind)).count
113 buffer.point = item.pos
114 let lineEnd = buffer.lineEnd(item.pos)
115 var text = buffer.substring(item.pos, lineEnd)
116 text = text.replacingOccurrences(of: "^[ \\t]*\\S+[ \\t]+(\\[@[^\\]]*\\][ \\t]*)?", with: "", options: .regularExpression)
117 var keyword = ""
118 if let box = item.checkbox {
119 text = text.replacingOccurrences(of: "^\\[[ X-]\\][ \\t]*", with: "", options: .regularExpression)
120 keyword = (box == "[X]" ? done : todo) + " "
121 }
122 buffer.deleteRegion(item.pos, lineEnd)
123 buffer.insert(String(repeating: "*", count: level + 1 + depth) + " " + keyword + text)
124 toggled = true
125 }
126 // Org replaces the items as one piece, so a point inside them ends at its start.
127 if let first = selected.first, caret.position > first.pos { caret.position = first.pos }
128 } else {
129 let stars = String(repeating: "*", count: buffer.levelAtPoint ?? 0) + "*" + " "
130 while buffer.point < end.position {
131 if !buffer.atHeading, buffer.inItem() != buffer.point, !buffer.atCommentLine,
132 let match = buffer.lookingAt("([ \\t]*)(\\S)") {
133 buffer.deleteRegion(match[1]!.lowerBound, match[1]!.upperBound)
134 buffer.point = buffer.lineStart(buffer.point)
135 buffer.insert(stars)
136 toggled = true
137 }
138 if buffer.forwardLine(1) != 0 { break }
139 }
140 }
141 }
142 if case .commit = step, !toggled { return .failed("Cannot toggle heading from here") }
143 return step
144 }
145}
146
147/// C-c - outside tables: `org-ctrl-c-minus`. On an item without a region the list's bullets
148/// cycle (`org-cycle-list-bullet`); otherwise `org-toggle-item`: items lose their bullets,
149/// headings become items with their text, other lines become items.
150public struct ToggleItem: OrgCommand {
151 public init() {}
152 public var id: String { "org.item.toggle" }
153 public var title: String { "Toggle Item" }
154 public func applies(in context: EditContext) -> Bool { true }
155 public func run(in context: EditContext) -> CommandStep {
156 let region = context.selection.first.map { !$0.isEmpty } ?? false
157 if !region, EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).inItem() != nil {
158 return commitBuffer(context) { try $0.cycleListBullet() }
159 }
160 return commitBuffer(context) { buffer in
161 let (begin, end) = buffer.toggleBounds(context.selection.first ?? context.caret..<context.caret, skipComments: false)
162 defer { buffer.release(end) }
163 // Point is at the region's end, as in Emacs.
164 let caret = buffer.marker(context.selection.first?.upperBound ?? buffer.point)
165 defer {
166 buffer.point = caret.position
167 buffer.release(caret)
168 }
169 buffer.point = begin
170 if buffer.inItem() == buffer.point {
171 while buffer.point < end.position {
172 if buffer.inItem() == buffer.point, let match = buffer.lookingAt("[ \\t]*(\\S+[ \\t]+)") {
173 buffer.deleteRegion(match[1]!.lowerBound, match[1]!.upperBound)
174 }
175 if buffer.forwardLine(1) != 0 { break }
176 }
177 } else if buffer.atHeading {
178 let refLevel = buffer.outlineLevel
179 var reference = refLevel
180 while buffer.point < end.position {
181 guard buffer.atHeading else {
182 if buffer.forwardLine(1) != 0 { break }
183 continue
184 }
185 let level = buffer.outlineLevel
186 let delta = max(0, level - reference)
187 if level < reference { reference = level }
188 let todo = buffer.todoKeyword
189 buffer.deleteMetadata()
190 guard let match = buffer.lookingAt(buffer.todoLinePattern) else { break }
191 buffer.deleteRegion(buffer.point, match[3]?.lowerBound ?? buffer.lineEnd(buffer.point))
192 buffer.insert("- ")
193 buffer.indentLineTo(delta * 2)
194 if let todo {
195 // The checkbox goes through the list's repair, with any list above.
196 buffer.point = buffer.lineStart(buffer.point)
197 let at = buffer.point
198 var structure = buffer.listStruct()
199 let old = structure
200 structure.set(at) { $0.checkbox = context.tree.settings.isDone(todo) ? "[X]" : "[ ]" }
201 buffer.writeStruct(&structure, structure.parents, old: old)
202 buffer.point = at
203 }
204 // The section's text down to the region's end belongs to the item.
205 let sectionEnd = buffer.saveExcursion { () -> Int in
206 Capture.nextHeading(buffer)
207 return buffer.point
208 }
209 if buffer.forwardLine(1) != 0 { break }
210 buffer.shiftText(to: (delta + 1) * 2, until: min(end.position, sectionEnd))
211 }
212 } else {
213 while buffer.point < end.position {
214 if !buffer.atHeading, buffer.inItem() != buffer.point, let match = buffer.lookingAt("([ \\t]*)(\\S)") {
215 buffer.point = match[2]!.lowerBound
216 buffer.insert("- ")
217 buffer.point = buffer.lineStart(buffer.point)
218 }
219 if buffer.forwardLine(1) != 0 { break }
220 }
221 }
222 }
223 }
224}
225
226extension EmacsBuffer {
227 /// `org-list--delete-metadata`: the heading's tags, planning line and property drawer go.
228 func deleteMetadata() {
229 saveExcursion {
230 let start = point
231 if let line = lookingAt(SetTags.tagLine), let tags = line[1] {
232 var from = tags.lowerBound
233 while from > start, let c = character(at: from - 1), c == 32 || c == 9 { from -= 1 }
234 deleteRegion(from, lineEnd(start))
235 }
236 point = start
237 let afterHeading = lineEnd(start) < length ? lineEnd(start) + 1 : length
238 point = afterHeading
239 if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) }
240 if let drawer = looking(Self.propertyDrawerPattern) {
241 point = drawer[0]!.upperBound
242 forwardLine(1)
243 }
244 skipForward([32, 9, 10, 13])
245 let end = eobp ? point : lineStart(point)
246 if end > afterHeading { deleteRegion(afterHeading, end) }
247 }
248 }
249
250 /// The `shift-text` of `org-toggle-item`: lines from point to `end` move so the least
251 /// indented one sits at `column`; blank lines and headings stay. Point ends at `end`'s line.
252 func shiftText(to column: Int, until limit: Int) {
253 let end = marker(limit)
254 defer { release(end) }
255 var minimum = 1000
256 saveExcursion {
257 while point < end.position {
258 let indentation = currentIndentation
259 if lookingAt("^[ \\t]*$") != nil || atHeading {
260 } else if indentation == 0 {
261 minimum = 0
262 break
263 } else if indentation < minimum {
264 minimum = indentation
265 }
266 if forwardLine(1) != 0 { break }
267 }
268 }
269 let delta = column - minimum
270 while point < end.position {
271 if lookingAt("^[ \\t]*$") == nil, !atHeading { indentLineTo(currentIndentation + delta) }
272 if forwardLine(1) != 0 { break }
273 }
274 }
275
276 var currentIndentation: Int {
277 saveExcursion {
278 point = lineStart(point)
279 skipForward([32, 9])
280 return currentColumn
281 }
282 }
283
284 /// `org-cycle-list-bullet`: the list's bullets go through `-`, `+`, `*` (not at the
285 /// margin), `1.`, `1)` and, with alphabetical lists, `a.`, `A.`, `a)`, `A)`.
286 func cycleListBullet() throws {
287 guard let item = inItem() else { throw UserError(message: "Not at an item") }
288 let origin = marker(point)
289 defer { release(origin) }
290 point = item
291 var structure = listStruct()
292 let prevs = structure.prevs
293 let parents = structure.parents
294 let first = structure.listBegin(point, prevs)
295 let ind = structure.ind(point)
296 let bulletLength = (structure.bullet(point) as NSString).length
297 let offset = origin.position - (point + ind)
298 let offset2 = origin.position - (point + ind + bulletLength)
299 let bullet = structure.bullet(first)
300 let alpha = useAlphabeticalBullets(first, structure, prevs)
301 func has(_ pattern: String) -> Bool { bullet.range(of: pattern, options: .regularExpression) != nil }
302 let current = has("[a-z]\\.") ? "a." : has("[a-z]\\)") ? "a)" : has("[A-Z]\\.") ? "A." : has("[A-Z]\\)") ? "A)"
303 : has("\\.") ? "1." : has("\\)") ? "1)" : bullet.trimmingCharacters(in: .whitespaces)
304 let description = structure.item(point)?.tag != nil
305 var choices = ["-", "+"]
306 if lookingAt("\\S") == nil { choices.append("*") }
307 if !description { choices += ["1.", "1)"] }
308 if alpha, !description { choices += ["a.", "A.", "a)", "A)"] }
309 let index = choices.firstIndex(of: current).map { $0 + 1 } ?? choices.count
310 let new = choices[index % choices.count]
311 let old = structure
312 structure.set(first) { $0.bullet = bulletString(new) }
313 fixBullets(&structure, prevs)
314 fixIndentation(&structure, parents)
315 applyStruct(structure, old)
316 point = origin.position
317 let updated = listStruct()
318 point = lineStart(point)
319 if offset2 >= 0, let at = updated.item(point) {
320 point += at.ind + (at.bullet as NSString).length + offset2
321 } else if offset >= 0, let at = updated.item(point) {
322 point += at.ind + offset
323 } else {
324 point = origin.position
325 }
326 }
327}
328
329/// C-c @ and M-h on a heading: `org-mark-subtree`, the subtree selected from its heading.
330public struct MarkSubtree: OrgCommand {
331 public init() {}
332 public var id: String { "org.subtree.mark" }
333 public var title: String { "Mark Subtree" }
334 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
335 public func run(in context: EditContext) -> CommandStep {
336 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
337 guard !buffer.beforeFirstHeading, (try? buffer.backToHeading()) != nil else { return .failed("Not in a subtree") }
338 let start = buffer.point
339 Capture.endOfSubtree(buffer, heading: start)
340 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [start..<buffer.point]))
341 }
342}
Sources/OrgCore/Keymap/Presets.swift +20
@@ -190,6 +190,14 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
190190
191191 # Localleader (SPC m)
192192 [[bind]]
193 keys = "SPC m h"
194 command = "org.heading.toggle"
195 mode = "normal"
196 [[bind]]
197 keys = "SPC m i"
198 command = "org.item.toggle"
199 mode = "normal"
200 [[bind]]
193201 keys = "SPC m l l"
194202 command = "org.link.insert"
195203 mode = "normal"
@@ -580,6 +588,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
580588 keys = "C-c l"
581589 command = "org.link.store"
582590 [[bind]]
591 keys = "C-c ;"
592 command = "org.heading.toggle-comment"
593 [[bind]]
594 keys = "C-c *"
595 command = "org.heading.toggle"
596 [[bind]]
597 keys = "C-c -"
598 command = "org.item.toggle"
599 [[bind]]
600 keys = "C-c @"
601 command = "org.subtree.mark"
602 [[bind]]
583603 keys = "C-x b"
584604 command = "app.buffer.switch"
585605 [[bind]]
Tests/OrgCoreTests/KeymapTests.swift +19
@@ -161,3 +161,22 @@ struct KeymapTests {
161161 for preset in KeymapPreset.allCases { #expect(!preset.keymap.bindings.isEmpty) }
162162 }
163163}
164
165struct ToggleKeyTests {
166 @Test func ctrlCStarAndMinusPreferTables() {
167 let keymap = KeymapPreset.emacs.keymap
168 let table = "| a |\n"
169 let tableContext = EditContext(revision: 0, text: table, tree: OrgParser.parse(table), selection: [2..<2])
170 let star = KeySequence.parse("C-c *")!
171 let minus = KeySequence.parse("C-c -")!
172 func first(_ keys: [KeyChord], _ context: EditContext) -> String? {
173 keymap.candidates(for: keys).first { $0.when.map { KeyContext.holds($0, in: context) } ?? true }?.command
174 }
175 #expect(first(star, tableContext) == "org.table.recalc")
176 #expect(first(minus, tableContext) == "org.table.insert-hline")
177 let text = "* a\nb\n"
178 let plain = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [5..<5])
179 #expect(first(star, plain) == "org.heading.toggle")
180 #expect(first(minus, plain) == "org.item.toggle")
181 }
182}
Tests/OrgCoreTests/ToggleCommandTests.swift added +101
@@ -0,0 +1,101 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ToggleCommandTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 Intro line
10 # a comment
11 * TODO [#A] Task :tag:
12 SCHEDULED: <2026-10-05 Mon>
13 :PROPERTIES:
14 :X: 1
15 :END:
16 Body text
17 indented body
18 - plain item
19 - [ ] open box
20 - [X] done box
21 - nested item
22 ** COMMENT Commented
23 *** Deep
24 1. first
25 2) second
26 * DONE Closed one
27 last
28
29 """
30
31 /// Runs `command` with `selection` (a caret when empty) and returns the text and caret.
32 static func ours(_ command: any OrgCommand, _ text: String, _ selection: Range<Int>) -> (String, Int) {
33 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [selection], options: .org)
34 guard case .commit(let result) = command.run(in: context) else { return (text, selection.upperBound) }
35 var new = text
36 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
37 return (new, result.selection?.first?.lowerBound ?? selection.upperBound)
38 }
39
40 static func compare(_ command: any OrgCommand, _ form: String, regions: [(String, String)] = []) throws {
41 let text = Self.text
42 let ns = text as NSString
43 var cases: [EmacsOracle.Case] = []
44 var mine: [(String, Int)] = []
45 var labels: [String] = []
46 // Each line's start and a point inside it.
47 var lineStart = 0
48 while lineStart < ns.length {
49 let line = ns.lineRange(for: NSRange(location: lineStart, length: 0))
50 for offset in Set([line.location, min(line.location + 3, NSMaxRange(line) - 1)]).sorted() {
51 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
52 mine.append(ours(command, text, offset..<offset))
53 labels.append("at \(offset)")
54 }
55 lineStart = NSMaxRange(line)
56 }
57 for (from, to) in regions {
58 let start = ns.range(of: from).location
59 let end = NSMaxRange(ns.range(of: to))
60 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(end, in: text), form: "(progn (transient-mark-mode 1) (set-mark \(EmacsOracle.point(start, in: text))) (activate-mark) \(form))"))
61 mine.append(ours(command, text, start..<end))
62 labels.append("region \(from.debugDescription)…\(to.debugDescription)")
63 }
64 let emacs = try EmacsOracle.run(cases)
65 for (i, (ours, theirs)) in zip(mine, emacs).enumerated() {
66 let expectedText = theirs.error.isEmpty ? theirs.text : text
67 #expect(ours.0 == expectedText, "\(form) \(labels[i])\nours: \(ours.0.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
68 if ours.0 == expectedText, theirs.error.isEmpty {
69 #expect(ours.1 == EmacsOracle.offset(theirs.point, in: theirs.text), "\(form) \(labels[i]) caret")
70 }
71 }
72 }
73
74 @Test(.enabled(if: oracle))
75 func commentsLikeOrg() throws {
76 try Self.compare(ToggleComment(), "(org-toggle-comment)")
77 }
78
79 @Test(.enabled(if: oracle))
80 func togglesHeadingsLikeOrg() throws {
81 try Self.compare(ToggleHeading(), "(org-toggle-heading)", regions: [("Body text", "indented body"), ("* TODO", "Body"), ("1. first", "2) second"), ("Intro", "comment")])
82 }
83
84 @Test(.enabled(if: oracle))
85 func togglesItemsLikeOrgCtrlCMinus() throws {
86 try Self.compare(ToggleItem(), "(org-ctrl-c-minus)", regions: [("Body text", "indented body"), ("- plain", "open box"), ("** COMMENT", "Deep"), ("* DONE", "last")])
87 }
88
89 @Test(.enabled(if: oracle))
90 func marksSubtreesLikeOrg() throws {
91 let text = Self.text
92 let offsets = [(text as NSString).range(of: "Body").location, (text as NSString).range(of: "*** Deep").location + 2]
93 let emacs = try EmacsOracle.evaluate(text, "(mapcar (lambda (p) (goto-char p) (transient-mark-mode 1) (org-mark-subtree) (format \"%d %d\" (1- (region-beginning)) (1- (region-end)))) '(\(offsets.map { String(EmacsOracle.point($0, in: text)) }.joined(separator: " "))))")
94 let ours = offsets.map { offset -> String in
95 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [offset..<offset])
96 guard case .commit(let result) = MarkSubtree().run(in: context), let range = result.selection?.first else { return "none" }
97 return "\(range.lowerBound) \(range.upperBound)"
98 }
99 #expect(ours == emacs)
100 }
101}