krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 9518e3948b

9518e3948b486c48b294372712fa2f6f814f1f64

parent: 51ec07469f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 04:55 UTC

Make org editing options settings

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +8 −4
@@ -63,6 +63,10 @@ public final class DocumentSession {
6363 public var lastError: String?
6464 public var saveMode: SaveMode = .automatic
6565 public var autosaveDelay: Duration = .seconds(1)
66 /// Org options for commands the session runs (refile, archive).
67 public var options: EditingOptions = .org
68 /// TODO keywords and priorities for files without their own settings.
69 public var defaults: OrgSettings = .default
6670
6771 private let fileSystem: FileSystem
6872 private let recovery: RecoveryStore
@@ -90,7 +94,7 @@ public final class DocumentSession {
9094 }
9195 autosave?.cancel()
9296 self.path = path
93 buffer = StateBuffer(DocumentState(bytes: bytes))
97 buffer = StateBuffer(DocumentState(bytes: bytes, defaults: defaults))
9498 conflict = nil
9599 revealRequest = nil
96100 refresh()
@@ -197,7 +201,7 @@ public final class DocumentSession {
197201 private func editContext(now: Date) -> EditContext? {
198202 guard let buffer else { return nil }
199203 let document = buffer.document
200 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now)
204 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options)
201205 }
202206
203207 /// Moves the subtree at the caret under `target`. For another file, that file is written
@@ -215,7 +219,7 @@ public final class DocumentSession {
215219 let url = URL(fileURLWithPath: target.path)
216220 do {
217221 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." }
218 var state = DocumentState(bytes: bytes)
222 var state = DocumentState(bytes: bytes, defaults: defaults)
219223 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." }
220224 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
221225 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
@@ -241,7 +245,7 @@ public final class DocumentSession {
241245 do {
242246 let url = URL(fileURLWithPath: destination.path)
243247 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
244 var state = DocumentState(bytes: existing ?? [])
248 var state = DocumentState(bytes: existing ?? [], defaults: defaults)
245249 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
246250 let edits: Archive.Edits
247251 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
Sources/OrgCore/Commands/Archive.swift +1 −1
@@ -234,7 +234,7 @@ public enum Archive {
234234 formatter.timeZone = context.calendar.timeZone
235235 formatter.dateFormat = "yyyy-MM-dd EEE HH:mm"
236236 let time = formatter.string(from: context.now)
237 let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings)
237 let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings, options: context.options)
238238 var archiveEdit: TextEdit?
239239 let step = commitBuffer(context) { source in
240240 let target = archive ?? source
Sources/OrgCore/Commands/Command.swift +27 −1
@@ -1,5 +1,28 @@
11import Foundation
22
3/// Org options that change what commands write, as the user's Emacs has them.
4public struct EditingOptions: Sendable, Equatable, Codable {
5 /// `org-tags-column`: negative aligns tags to end at that column, 0 puts them one space
6 /// after the title.
7 public var tagsColumn: Int
8 /// `org-insert-heading-respect-content`: M-RET adds the heading after the subtree.
9 public var insertHeadingRespectContent: Bool
10 /// `org-M-RET-may-split-line`: M-RET in the middle of a line splits it.
11 public var metaReturnMaySplitLine: Bool
12 /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets.
13 public var listAllowAlphabetical: Bool
14
15 public init(tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, listAllowAlphabetical: Bool = false) {
16 self.tagsColumn = tagsColumn
17 self.insertHeadingRespectContent = insertHeadingRespectContent
18 self.metaReturnMaySplitLine = metaReturnMaySplitLine
19 self.listAllowAlphabetical = listAllowAlphabetical
20 }
21
22 /// Org's own defaults.
23 public static let org = EditingOptions()
24}
25
326/// Everything a command may read. Commands never read the clock or the file system, so the
427/// same context always gives the same result.
528public struct EditContext: Sendable {
@@ -16,10 +39,12 @@ public struct EditContext: Sendable {
1639 public let answers: [String: String]
1740 /// Ranges the view hides (folded text); motion commands skip headings inside them.
1841 public let hidden: [Range<Int>]
42 public let options: EditingOptions
1943
2044 public init(
2145 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
22 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = []
46 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [],
47 options: EditingOptions = .org
2348 ) {
2449 self.revision = revision
2550 self.text = text
@@ -29,6 +54,7 @@ public struct EditContext: Sendable {
2954 self.calendar = calendar
3055 self.answers = answers
3156 self.hidden = hidden
57 self.options = options
3258 }
3359
3460 public var caret: Int { selection.first?.lowerBound ?? 0 }
Sources/OrgCore/Commands/EmacsBuffer.swift +7 −4
@@ -28,10 +28,13 @@ final class EmacsBuffer {
2828 /// Groups of the last successful match, as Emacs's match data.
2929 var lastMatch: [Range<Int>?] = []
3030
31 init(_ text: String, point: Int, settings: OrgSettings = .default) {
31 let options: EditingOptions
32
33 init(_ text: String, point: Int, settings: OrgSettings = .default, options: EditingOptions = .org) {
3234 self.text = NSMutableString(string: text)
3335 self.point = point
3436 self.settings = settings
37 self.options = options
3538 unchangedPrefix = self.text.length
3639 unchangedSuffix = self.text.length
3740 }
@@ -407,14 +410,14 @@ final class EmacsBuffer {
407410 let end = lineEnd(offset)
408411 let line = substring(start, end)
409412 var plain = LineBuffer(line, caret: nil)
410 OrgCore.alignTags(&plain)
413 OrgCore.alignTags(&plain, tagsColumn: options.tagsColumn)
411414 guard plain.string != line else { return }
412415 func map(_ position: Int) -> Int {
413416 guard position >= start, position <= end else {
414417 return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position
415418 }
416419 var buffer = LineBuffer(line, caret: position - start)
417 OrgCore.alignTags(&buffer)
420 OrgCore.alignTags(&buffer, tagsColumn: options.tagsColumn)
418421 return start + buffer.caret!
419422 }
420423 let newPoint = map(point)
@@ -430,7 +433,7 @@ final class EmacsBuffer {
430433/// Runs a buffer-editing body against a context and turns the result into one minimal edit
431434/// and the new caret. A user error becomes `.failed`.
432435func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
433 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
436 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
434437 do {
435438 try body(buffer)
436439 } catch let error as EmacsBuffer.UserError {
Sources/OrgCore/Commands/HeadingCommands.swift +4 −4
@@ -247,7 +247,7 @@ public struct TodoCycle: OrgCommand {
247247 }
248248 }
249249 buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
250 alignTags(&buffer)
250 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
251251 }
252252 }
253253}
@@ -295,7 +295,7 @@ struct PriorityChange {
295295 buffer.insert(cookie + " ", at: line.titleStart - line.start)
296296 }
297297 }
298 alignTags(&buffer)
298 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
299299 }
300300 }
301301
@@ -341,7 +341,7 @@ public struct PromoteHeading: OrgCommand {
341341 guard line.level > 1 else { return .failed("Cannot promote to level 0") }
342342 return line.commit(context) { buffer in
343343 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
344 alignTags(&buffer)
344 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
345345 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
346346 }
347347 }
@@ -358,7 +358,7 @@ public struct DemoteHeading: OrgCommand {
358358 let line = HeadingLine(heading)
359359 return line.commit(context) { buffer in
360360 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
361 alignTags(&buffer)
361 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
362362 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
363363 }
364364 }
Sources/OrgCore/Commands/ListCommands.swift +5 −5
@@ -11,7 +11,7 @@ extension EmacsBuffer {
1111 var structure = saveExcursion { point = start; return listStruct() }
1212 let prevs = structure.prevs
1313 let description = structure.listType(start, prevs) == .descriptive ? " :: " : nil
14 structure = insertItem(at: pos, structure, prevs, checkbox: checkbox, afterBullet: description)
14 structure = insertItem(at: pos, structure, prevs, checkbox: checkbox, afterBullet: description, splitLine: options.metaReturnMaySplitLine)
1515 writeStruct(&structure, structure.parents)
1616 if checkbox { updateCheckboxCount() }
1717 forwardLine(0)
@@ -91,17 +91,17 @@ extension EmacsBuffer {
9191 let upper = marker(lineEnd(point))
9292 defer { release(upper) }
9393 point = lower
94 let current = looking(Self.itemPattern).flatMap { match -> String? in
94 let current = looking(itemPattern).flatMap { match -> String? in
9595 let box = saveExcursion { () -> [Range<Int>?]? in
9696 point = match[0]!.upperBound
97 if let counter = looking("(?:\\[@(?:start:)?[0-9]+\\][ \\t]*)") { point = counter[0]!.upperBound }
97 if let counter = looking(options.listAllowAlphabetical ? "(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)" : "(?:\\[@(?:start:)?[0-9]+\\][ \\t]*)") { point = counter[0]!.upperBound }
9898 return looking("(\\[[- X]\\])[ \\t]+")
9999 }
100100 return box.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
101101 }
102102 let reference = current == "[X]" ? "[ ]" : "[X]"
103103 point = lower
104 while point < upper.position, searchForward(Self.itemBeginningPattern, bound: upper.position, move: true) != nil {
104 while point < upper.position, searchForward(itemBeginningPattern, bound: upper.position, move: true) != nil {
105105 var structure = listStruct()
106106 let old = structure
107107 let parents = structure.parents
@@ -302,7 +302,7 @@ public struct InsertCheckboxItem: OrgCommand {
302302
303303/// An item command that needs point on an item's first line.
304304private func atItemLine(_ context: EditContext) -> Bool {
305 EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).atItem
305 EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options).atItem
306306}
307307
308308/// M-right on an item: `org-indent-item`.
Sources/OrgCore/Commands/OrgList.swift +57 −24
@@ -247,8 +247,13 @@ extension EmacsBuffer {
247247
248248 // MARK: - Context
249249
250 static let itemPattern = "([ \\t]*([-+]|(([0-9]+)[.)]))|[ \\t]+\\*)([ \\t]+|$)"
251 static let itemBeginningPattern = "^" + itemPattern
250 /// `org-item-re`.
251 var itemPattern: String {
252 let alpha = options.listAllowAlphabetical ? "|[A-Za-z]" : ""
253 return "([ \\t]*([-+]|(([0-9]+\(alpha))[.)]))|[ \\t]+\\*)([ \\t]+|$)"
254 }
255
256 var itemBeginningPattern: String { "^" + itemPattern }
252257 static let fullItemPattern = "^[ \\t]*((?:[-+*]|(?:[0-9]+|[A-Za-z])[.)])(?:[ \\t]+|$))(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(?:(\\[[ X-]\\])(?:[ \\t]+|$))?(?:(.*)[ \\t]+::(?:[ \\t]+|$))?"
253258 static let listEndPattern = "^[ \\t]*\\n[ \\t]*\\n"
254259 static let drawerPattern = "^[ \\t]*:([-_\\w]+):[ \\t]*$"
@@ -307,7 +312,7 @@ extension EmacsBuffer {
307312 let context = listContext()
308313 var reference = looking("^[ \\t]*$") != nil ? 10000 : currentTextIndentation
309314 if context.kind == .invalid { return nil }
310 if looking(Self.itemPattern) != nil { return point }
315 if looking(itemPattern) != nil { return point }
311316 // Inside `org-list-end-re`: start above it.
312317 let windowStart = saveExcursion { forwardLine(-2); return point }
313318 let windowEnd = saveExcursion { forwardLine(2); return lineEnd(point) }
@@ -322,7 +327,7 @@ extension EmacsBuffer {
322327 }
323328 while true {
324329 let ind = currentTextIndentation
325 if looking(Self.itemPattern) != nil, ind < reference { return point }
330 if looking(itemPattern) != nil, ind < reference { return point }
326331 if point <= context.up { return nil }
327332 if looking(Self.listEndPattern) != nil { return nil }
328333 if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil {
@@ -350,7 +355,7 @@ extension EmacsBuffer {
350355 beginningOfLine()
351356 switch listContext().kind {
352357 case .invalid, .block("comment"), .block("verse"): return false
353 default: return looking(Self.itemPattern) != nil
358 default: return looking(itemPattern) != nil
354359 }
355360 }
356361 }
@@ -385,7 +390,7 @@ extension EmacsBuffer {
385390 while true {
386391 let ind = currentTextIndentation
387392 if point <= context.up {
388 if looking(Self.itemPattern) == nil {
393 if looking(itemPattern) == nil {
389394 trimToBegin()
390395 } else {
391396 beginCell = (point, ind)
@@ -397,7 +402,7 @@ extension EmacsBuffer {
397402 trimToBegin()
398403 return
399404 }
400 if looking(Self.itemPattern) != nil {
405 if looking(itemPattern) != nil {
401406 items.insert(assocAtPoint(ind), at: 0)
402407 ends.insert((ind, point), at: 0)
403408 if ind < textMinInd { beginCell = (point, ind) }
@@ -429,7 +434,7 @@ extension EmacsBuffer {
429434 ends2.append((0, point))
430435 break
431436 }
432 if looking(Self.itemPattern) != nil {
437 if looking(itemPattern) != nil {
433438 items2.append(assocAtPoint(ind))
434439 ends2.append((ind, point))
435440 forwardLine(1)
@@ -747,27 +752,38 @@ extension EmacsBuffer {
747752
748753 /// `org-list-struct-fix-bul` (alphabetical bullets off).
749754 func fixBullets(_ structure: inout ListStruct, _ prevs: [Int: Int]) {
755 func find(_ pattern: String, _ string: String) -> Range<String.Index>? {
756 guard let range = string.range(of: pattern, options: .regularExpression) else { return nil }
757 stringMatched(string, range)
758 return range
759 }
750760 for pos in structure.map(\.pos) {
751761 let previous = prevs[pos]
752762 let counter = structure.item(pos)!.counter
753763 let bullet = structure.bullet(pos)
764 let previousBullet = previous.map { structure.bullet($0) }
765 let alphap = previous == nil && useAlphabeticalBullets(pos, structure, prevs)
754766 let new: String
755 if let previous, let counter, counter.range(of: "[a-zA-Z]", options: .regularExpression) != nil,
756 structure.bullet(previous).range(of: "[a-zA-Z]", options: .regularExpression) != nil {
757 let previousBullet = structure.bullet(previous)
758 let real = previousBullet.range(of: "[a-z]", options: .regularExpression) != nil ? counter.lowercased() : counter.uppercased()
759 let range = previousBullet.range(of: "[a-zA-Z]", options: .regularExpression)!
760 new = previousBullet.replacingCharacters(in: range, with: real)
761 } else if let previous, let counter, let counterRange = counter.range(of: "[0-9]+", options: .regularExpression),
762 let range = structure.bullet(previous).range(of: "[0-9]+", options: .regularExpression) {
763 new = structure.bullet(previous).replacingCharacters(in: range, with: counter[counterRange])
764 } else if let previous {
765 new = incrementBullet(structure.bullet(previous))
766 } else if let counter, let counterRange = counter.range(of: "[0-9]+", options: .regularExpression),
767 let range = bullet.range(of: "[0-9]+", options: .regularExpression) {
767 if previous != nil, let counter, let previousBullet, find("[a-zA-Z]", counter) != nil, find("[a-zA-Z]", previousBullet) != nil {
768 let lower = find("[a-z]", previousBullet)
769 let range = lower ?? find("[A-Z]", previousBullet)!
770 new = previousBullet.replacingCharacters(in: range, with: lower != nil ? counter.lowercased() : counter.uppercased())
771 } else if previous != nil, let counter, let previousBullet, let counterRange = find("[0-9]+", counter),
772 let range = find("[0-9]+", previousBullet) {
773 new = previousBullet.replacingCharacters(in: range, with: counter[counterRange])
774 } else if let previousBullet {
775 new = incrementBullet(previousBullet)
776 } else if let counter, useAlphabeticalBullets(pos, structure, prevs), find("[A-Za-z]", counter) != nil, find("[A-Za-z]", bullet) != nil {
777 let lower = find("[a-z]", bullet)
778 let range = lower ?? find("[A-Z]", bullet)!
779 new = bullet.replacingCharacters(in: range, with: lower != nil ? counter.lowercased() : counter.uppercased())
780 } else if let counter, let counterRange = find("[0-9]+", counter), let range = find("[0-9]+", bullet) {
768781 new = bullet.replacingCharacters(in: range, with: counter[counterRange])
769 } else if let range = bullet.range(of: "([0-9]+|[A-Za-z])", options: .regularExpression) {
770 stringMatched(bullet, range)
782 } else if alphap, let range = find("[A-Z]", bullet) {
783 new = bullet.replacingCharacters(in: range, with: "A")
784 } else if alphap, let range = find("[a-z]", bullet) {
785 new = bullet.replacingCharacters(in: range, with: "a")
786 } else if let range = find("([0-9]+|[A-Za-z])", bullet) {
771787 new = bullet.replacingCharacters(in: range, with: "1")
772788 } else {
773789 new = bullet
@@ -776,6 +792,23 @@ extension EmacsBuffer {
776792 }
777793 }
778794
795 /// `org-list-use-alpha-bul-p`: the list starting at `first` fits in the alphabet.
796 func useAlphabeticalBullets(_ first: Int, _ structure: ListStruct, _ prevs: [Int: Int]) -> Bool {
797 guard options.listAllowAlphabetical else { return false }
798 var item: Int? = first
799 var ascii = 64
800 while let current = item {
801 if let counter = structure.item(current)?.counter, let letter = counter.first, letter.isASCII, letter.isLetter {
802 ascii = Int(letter.uppercased().unicodeScalars.first!.value)
803 } else {
804 ascii += 1
805 }
806 if ascii > 90 { return false }
807 item = structure.nextItem(current, prevs)
808 }
809 return true
810 }
811
779812 /// `org-list-struct-fix-ind`.
780813 func fixIndentation(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], bulletSize: Int? = nil) {
781814 let topInd = structure.ind(structure.top)
@@ -851,7 +884,7 @@ extension EmacsBuffer {
851884 point = end
852885 skipBackward(Self.whitespace)
853886 beginningOfLine()
854 while point > begin || (point == begin && looking(Self.itemPattern) == nil) {
887 while point > begin || (point == begin && looking(itemPattern) == nil) {
855888 if looking("^[ \\t]*\\S", setsMatch: false) != nil {
856889 indentLineTo(max(currentTextIndentation + delta, ind.map { $0 + 1 } ?? -1))
857890 }
Sources/OrgCore/Commands/Refile.swift +1 −1
@@ -126,7 +126,7 @@ public enum Refile {
126126 }
127127
128128 public static func toOtherFile(source context: EditContext, target text: String, under heading: Int?) -> Result? {
129 let target = EmacsBuffer(text, point: 0)
129 let target = EmacsBuffer(text, point: 0, settings: context.tree.settings, options: context.options)
130130 var tree = ""
131131 let step = commitBuffer(context) { buffer in
132132 tree = try buffer.copySubtree()
Sources/OrgCore/Commands/StructureCommands.swift +5 −3
@@ -74,7 +74,8 @@ extension EmacsBuffer {
7474 saveExcursion { insert(stars + " \n") }
7575 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
7676 endOfLine()
77 } else if let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }),
77 } else if options.metaReturnMaySplitLine,
78 let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }),
7879 let title = match[4], title.lowerBound <= point, point <= title.upperBound {
7980 let split = substring(point, title.upperBound)
8081 deleteRegion(point, title.upperBound)
@@ -99,6 +100,7 @@ extension EmacsBuffer {
99100 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
100101 addBlankAfter(blank)
101102 } else {
103 if !options.metaReturnMaySplitLine { endOfLine() }
102104 insert("\n" + stars + " ")
103105 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
104106 addBlankAfter(blank)
@@ -264,7 +266,7 @@ public struct InsertHeading: OrgCommand {
264266 public var title: String { "Insert Heading" }
265267 public func applies(in context: EditContext) -> Bool { true }
266268 public func run(in context: EditContext) -> CommandStep {
267 headingCommand(context) { try $0.insertHeading(respectContent: false, hidden: context.hidden) }
269 headingCommand(context) { try $0.insertHeading(respectContent: context.options.insertHeadingRespectContent, hidden: context.hidden) }
268270 }
269271}
270272
@@ -287,7 +289,7 @@ public struct InsertTodoHeading: OrgCommand {
287289 public func applies(in context: EditContext) -> Bool { true }
288290 public func run(in context: EditContext) -> CommandStep {
289291 headingCommand(context) { buffer in
290 try buffer.insertHeading(respectContent: false, hidden: context.hidden)
292 try buffer.insertHeading(respectContent: context.options.insertHeadingRespectContent, hidden: context.hidden)
291293 let previous = buffer.saveExcursion { () -> String? in
292294 try? buffer.forwardHeadingSameLevel(-1)
293295 guard let match = buffer.lookingAt(buffer.todoLinePattern), let keyword = match[2] else { return nil }
Sources/OrgCore/Parser/Settings.swift +9
@@ -58,6 +58,15 @@ public struct OrgSettings: Sendable, Equatable {
5858 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) })
5959 }
6060
61 /// These settings with TODO keywords from `#+TODO:` syntax (`TODO NEXT | DONE`); nil when
62 /// the line has no keywords.
63 public func withTodoKeywords(_ line: String) -> OrgSettings? {
64 guard let sequence = SettingsScanner.todoSequence(Substring(line), kind: .sequence) else { return nil }
65 var copy = self
66 copy.todoSequences = [sequence]
67 return copy
68 }
69
6170 public func isDone(_ name: String) -> Bool {
6271 todoSequences.contains { $0.done.contains { $0.name == name } }
6372 }
Sources/OrgDocument/Buffer.swift +2 −1
@@ -19,6 +19,7 @@ public protocol DocumentBuffer: AnyObject {
1919public final class StateBuffer: DocumentBuffer {
2020 public private(set) var document: DocumentState
2121 public var selection: [Range<Int>] = [0..<0]
22 public var options: EditingOptions = .org
2223
2324 public init(_ document: DocumentState) {
2425 self.document = document
@@ -26,7 +27,7 @@ public final class StateBuffer: DocumentBuffer {
2627
2728 public func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep {
2829 do {
29 let step = try document.run(command, selection: selection, now: now, answers: answers)
30 let step = try document.run(command, selection: selection, now: now, answers: answers, options: options)
3031 if case .commit(let result) = step, let new = result.selection { selection = new }
3132 return step
3233 } catch {
Sources/OrgDocument/DocumentState.swift +2 −2
@@ -101,11 +101,11 @@ public struct DocumentState: Sendable {
101101 /// Runs `command` at `selection` and applies its edits as one undo step.
102102 public mutating func run(
103103 _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(),
104 calendar: Calendar = .current, answers: [String: String] = [:]
104 calendar: Calendar = .current, answers: [String: String] = [:], options: EditingOptions = .org
105105 ) throws -> CommandStep {
106106 let context = EditContext(
107107 revision: revision, text: text, tree: tree, selection: selection,
108 now: now, calendar: calendar, answers: answers
108 now: now, calendar: calendar, answers: answers, options: options
109109 )
110110 let step = command.run(in: context)
111111 if case .commit(let result) = step, !result.edits.isEmpty {
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
@@ -187,6 +187,8 @@ public final class OrgEditor: NSObject {
187187 }
188188
189189 public var optionAsMeta: OptionAsMeta = [.left]
190 /// Org options commands follow.
191 public var options: EditingOptions = .org
190192 private var dispatcher: KeyDispatcher
191193 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
192194 private(set) var markActive = false
@@ -196,7 +198,7 @@ public final class OrgEditor: NSObject {
196198 return EditContext(
197199 revision: document.revision, text: document.text, tree: document.tree,
198200 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers,
199 hidden: hidden.all
201 hidden: hidden.all, options: options
200202 )
201203 }
202204
Sources/OrgIndex/FileRecord.swift +11
@@ -16,6 +16,17 @@ public struct IndexSettings: Sendable, Equatable {
1616 public var org: OrgSettings
1717 public var semantic: SemanticSettings
1818
19 /// Settings whose version follows the TODO keywords, so changing them reindexes.
20 public static func with(todoKeywords org: OrgSettings) -> IndexSettings {
21 let names = org.todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }.joined(separator: ";")
22 var hash: UInt64 = 0xcbf29ce484222325
23 for byte in names.utf8 {
24 hash ^= UInt64(byte)
25 hash = hash &* 0x100000001b3
26 }
27 return IndexSettings(version: 1 + Int(hash % 1_000_000), org: org)
28 }
29
1930 public init(version: Int = 1, org: OrgSettings = .default, semantic: SemanticSettings = .default) {
2031 self.version = version
2132 self.org = org
Tests/OrgCoreTests/EmacsOracle.swift +11 −2
@@ -122,9 +122,18 @@ enum EmacsOracle {
122122 }
123123}
124124
125/// The options as Emacs Lisp bindings around `form`.
126func withOptions(_ options: EditingOptions, _ form: String) -> String {
127 func flag(_ value: Bool) -> String { value ? "t" : "nil" }
128 return "(let ((org-tags-column \(options.tagsColumn)) (org-insert-heading-respect-content \(flag(options.insertHeadingRespectContent))) (org-M-RET-may-split-line \(flag(options.metaReturnMaySplitLine))) (org-list-allow-alphabetical \(flag(options.listAllowAlphabetical)))) \(form))"
129}
130
131/// The options in the user's Doom Emacs.
132let doomOptions = EditingOptions(tagsColumn: 0, insertHeadingRespectContent: true, metaReturnMaySplitLine: false, listAllowAlphabetical: true)
133
125134/// Runs a command the way an editor would: build a context, run, apply the edits.
126func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:]) -> (text: String, caret: Int, failure: String?) {
127 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers)
135func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:], options: EditingOptions = .org) -> (text: String, caret: Int, failure: String?) {
136 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers, options: options)
128137 switch command.run(in: context) {
129138 case .commit(let result):
130139 var new = text
Tests/OrgCoreTests/HeadingCommandTests.swift +8 −3
@@ -66,6 +66,11 @@ struct HeadingOracleTests {
6666 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
6767 }
6868
69 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
70 func headingCommandsMatchEmacsWithDoomOptions() throws {
71 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
72 }
73
6974 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
7075 /// lines from the folder's files, with the caret at the line start, the title start and
7176 /// the line end.
@@ -94,7 +99,7 @@ struct HeadingOracleTests {
9499
95100/// Runs every command at every caret of every input here and in Emacs, and records each
96101/// difference in text, caret or failure.
97func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)]) throws {
102func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)], options: EditingOptions = .org) throws {
98103 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
99104 var cases: [EmacsOracle.Case] = []
100105 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
@@ -102,8 +107,8 @@ func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(c
102107 for offset in carets {
103108 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
104109 for (command, form, headingLineOnly) in commands where onHeadingLine || !headingLineOnly {
105 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
106 let result = runCommand(command, text, caret: offset)
110 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: withOptions(options, form)))
111 let result = runCommand(command, text, caret: offset, options: options)
107112 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
108113 }
109114 }
Tests/OrgCoreTests/ListCommandTests.swift +6
@@ -48,6 +48,12 @@ struct ListOracleTests {
4848 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
4949 }
5050
51 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
52 func listCommandsMatchEmacsWithDoomOptions() throws {
53 let alphabetical = ["a. x\nb. y\n", "A) one\n - sub\nB) two\n", "- [@c] x\n- y\n", "1. a\n2. b\n"]
54 try compareWithEmacs((Self.variants + alphabetical).map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
55 }
56
5157 /// Real lists: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every list command in files up to
5258 /// 20 KB with the caret at the start, bullet end and end of up to six item lines per file.
5359 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
Tests/OrgCoreTests/StructureCommandTests.swift +5
@@ -72,6 +72,11 @@ struct StructureOracleTests {
7272 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
7373 }
7474
75 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
76 func structureCommandsMatchEmacsWithDoomOptions() throws {
77 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
78 }
79
7580 /// Real files: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every structure command in files up to
7681 /// 20 KB, with the caret at the start and end of up to eight heading lines per file and on
7782 /// a body line, for up to 60 files.