krz/orgstar

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

Commit 273e4d176e

273e4d176eec0f10ccac2b529a998dc78a39cc3f

parent: b4ec29e3b2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 22:28 UTC

Fix quoted header values, shell values and setup-file staleness

Babel keeps track of quoted header values, so tangling evaluates
only unquoted (...) as Lisp, as org-babel-read does. Shell blocks run
as a script return the exit status for :results value, as ob-shell
appends echo $? there too.

#+SETUPFILE: the naming file counts as read, so a file naming itself
adds nothing. Setup files are read again when a file reloads from
disk, on useDiskVersion and on a conflict merge; the agenda doesn't
reuse its cache for files naming setup files; and an index row's
settings version includes its setup keywords, so a changed setup file
indexes the files naming it again.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +2 −1
@@ -102,7 +102,8 @@ public final class AgendaModel {
102102 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
103103 let mtime = attributes?[.modificationDate] as? Date ?? .distantPast
104104 let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1
105 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
105 // A file reading setup files is read again: they may have changed.
106 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size && !$0.source.readsSetupFiles ? $0.source : nil }
106107 reads.append((path, cached, mtime, size, nil))
107108 }
108109 let loaded = await Task.detached {
Sources/OrgApp/DocumentSession.swift +11 −4
@@ -90,12 +90,17 @@ public final class DocumentSession {
9090 /// C-c C-c on a keyword line: the open file's setup files read again (`org-mode-restart`).
9191 public func refreshSetup() {
9292 guard let entry = current else { return }
93 let settings = defaults.resolvingSetupFiles(in: entry.buffer.document.text, path: entry.path, read: readText)
94 entry.buffer.update { $0.setDefaults(settings) }
93 resolveSetup(entry)
9594 refresh()
9695 editVersion += 1
9796 }
9897
98 /// The entry's setup files read again, as its text names them now.
99 private func resolveSetup(_ entry: Entry) {
100 let settings = defaults.resolvingSetupFiles(in: entry.buffer.document.text, path: entry.path, read: readText)
101 if settings != entry.buffer.document.defaults { entry.buffer.update { $0.setDefaults(settings) } }
102 }
103
99104 /// One open file.
100105 @MainActor
101106 final class Entry {
@@ -358,7 +363,8 @@ public final class DocumentSession {
358363 do {
359364 guard let bytes = try fileSystem.read(url) else { return }
360365 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") }
361 buffer.update { $0 = DocumentState(bytes: bytes, defaults: $0.defaults) }
366 let path = url.path
367 buffer.update { $0 = loaded(bytes, path: path) }
362368 current?.conflict = nil
363369 } catch {
364370 lastError = "Can't read \(name): \(error.localizedDescription)"
@@ -383,7 +389,7 @@ public final class DocumentSession {
383389 try recovery.keep(try buffer.document.encodedText(), for: url, label: "local")
384390 let merged = mergeWithMarkers(base: versions.base, ours: versions.mine, theirs: versions.disk, oursLabel: "yours", theirsLabel: "disk")
385391 try buffer.update { state in
386 state = DocumentState(bytes: disk, defaults: state.defaults)
392 state = loaded(disk, path: url.path)
387393 try state.apply(lineEdits(from: versions.disk, to: merged), baseRevision: state.revision)
388394 }
389395 current?.conflict = nil
@@ -835,6 +841,7 @@ public final class DocumentSession {
835841 entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
836842 case .merged, .reloaded:
837843 entry.conflict = nil
844 resolveSetup(entry)
838845 // Edits merged with the disk's still need saving.
839846 scheduleAutosave(entry)
840847 case .unchanged:
Sources/OrgCore/Agenda/AgendaSource.swift +3
@@ -4,6 +4,8 @@ import Foundation
44/// and the active timestamps and date ranges org's agenda would consider. Positions are UTF-16.
55public struct AgendaSource: Sendable {
66 public let path: String
7 /// The file names `#+SETUPFILE`s.
8 public let readsSetupFiles: Bool
79
810 struct Heading: Sendable {
911 /// Start of the heading line.
@@ -96,6 +98,7 @@ public struct AgendaSource: Sendable {
9698
9799 public init(path: String, text: String, defaults: OrgSettings = .default) {
98100 self.path = path
101 readsSetupFiles = text.range(of: "#+setupfile:", options: .caseInsensitive) != nil
99102 let tree = OrgParser.parse(text, defaults: defaults)
100103 let model = DocumentModel(tree: tree)
101104 let ns = text as NSString
Sources/OrgCore/Compute/Babel.swift +11 −7
@@ -337,7 +337,7 @@ public enum Babel {
337337 // command line after it and `:stdin` as its input; the value is its last line.
338338 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n"
339339 if params.single[":padline"] != "no" { script += "\n" }
340 script += (lines + [body]).joined(separator: "\n")
340 script += (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
341341 let command = Babel.scriptFileToken + (stdin != nil || cmdline != nil ? " " + (cmdline ?? "") : "")
342342 var job = BabelJob(
343343 language: language, runner: .process(executable: executable, arguments: ["-c", command]),
@@ -562,6 +562,8 @@ public enum Babel {
562562 var single: [String: String] = [:]
563563 var vars: [(name: String, value: String)] = []
564564 var wrap: String?
565 /// Keys whose value is Lisp to evaluate (`org-babel-read`): unquoted, starting with `(`.
566 var lisp: Set<String> = []
565567
566568 static let resultGroups: [Set<String>] = [
567569 ["file", "list", "vector", "table", "scalar", "verbatim"],
@@ -610,7 +612,12 @@ public enum Babel {
610612 layers += extra
611613
612614 for layer in layers {
613 for (key, value) in layer {
615 for (key, raw) in layer {
616 let quoted = key != ":var" && raw.count >= 2 && raw.hasPrefix("\"") && raw.hasSuffix("\"")
617 let value = quoted ? Params.unescape(String(raw.dropFirst().dropLast())) : raw
618 if key != ":var" {
619 if !quoted, value.hasPrefix("(") { lisp.insert(key) } else { lisp.remove(key) }
620 }
614621 switch key {
615622 case ":results":
616623 for word in value.split(whereSeparator: \.isWhitespace).map(String.init) {
@@ -715,11 +722,8 @@ public enum Babel {
715722 let trimmed = part.trimmingCharacters(in: .whitespaces)
716723 guard trimmed.hasPrefix(":") else { continue }
717724 let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased()
718 var value = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)
719 if key != ":var", value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") {
720 value = Params.unescape(String(value.dropFirst().dropLast()))
721 }
722 pairs.append((key, value))
725 // Quotes stay until the value is used, so a quoted string isn't taken for Lisp.
726 pairs.append((key, trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)))
723727 }
724728 return pairs
725729 }
Sources/OrgCore/Compute/Tangle.swift +2 −1
@@ -128,7 +128,8 @@ public enum Tangle {
128128
129129 static func params(_ block: SrcBlockInfo, model: DocumentModel, text: NSString, path: String) throws -> Babel.Params {
130130 var params = try Babel.Params(block: block, model: model, text: text)
131 for (key, value) in params.single where value.hasPrefix("(") && key != ":tangle-mode" {
131 for key in params.lisp where key != ":tangle-mode" {
132 guard let value = params.single[key] else { continue }
132133 params.single[key] = try evaluate(value, key: key, path: path)
133134 }
134135 return params
Sources/OrgCore/Parser/SetupFile.swift +5 −4
@@ -5,9 +5,10 @@ import Foundation
55/// naming file's folder; URLs aren't fetched.
66public enum SetupFile {
77 /// The keyword lines the setup files `text` names bring, in order, as `#+KEY: value` lines.
8 /// `read` gives a file's text, or nil when it can't be read.
9 public static func keywordLines(in text: String, directory: String, read: (String) -> String?) -> String {
10 lines(in: text, directory: directory, read: read, own: false, seen: [])
8 /// `read` gives a file's text, or nil when it can't be read. `path`, the naming file's,
9 /// counts as read already, as `buffer-file-name` does in Org.
10 public static func keywordLines(in text: String, directory: String, path: String? = nil, read: (String) -> String?) -> String {
11 lines(in: text, directory: directory, read: read, own: false, seen: path.map { [($0 as NSString).standardizingPath] } ?? [])
1112 }
1213
1314 private static func lines(in text: String, directory: String, read: (String) -> String?, own: Bool, seen: Set<String>) -> String {
@@ -45,7 +46,7 @@ extension OrgSettings {
4546 /// These settings with the keywords of the setup files `text` names, for a file at `path`.
4647 public func resolvingSetupFiles(in text: String, path: String, read: (String) -> String?) -> OrgSettings {
4748 var copy = self
48 copy.setupKeywords = SetupFile.keywordLines(in: text, directory: (path as NSString).deletingLastPathComponent, read: read)
49 copy.setupKeywords = SetupFile.keywordLines(in: text, directory: (path as NSString).deletingLastPathComponent, path: path, read: read)
4950 return copy
5051 }
5152
Sources/OrgIndex/FileRecord.swift +21 −1
@@ -120,13 +120,33 @@ public struct FileRecord: Sendable, Equatable {
120120 self.size = bytes.count
121121 self.mtime = mtime
122122 self.hash = Self.hash(bytes)
123 self.settingsVersion = settings.version
124123 let source = SourceText(bytes: bytes)
125124 var resolved = settings
126125 resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile)
126 self.settingsVersion = Self.settingsVersion(settings, text: source.text, setupKeywords: resolved.org.setupKeywords)
127127 headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved)
128128 }
129129
130 /// The settings version a file's rows are built under: the index's, with the keywords of
131 /// the setup files it names mixed in, so a changed setup file indexes it again.
132 public static func settingsVersion(_ settings: IndexSettings, text: String, setupKeywords: String) -> Int {
133 guard text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return settings.version }
134 var hash: UInt64 = 0xcbf29ce484222325
135 for byte in setupKeywords.utf8 {
136 hash ^= UInt64(byte)
137 hash = hash &* 0x100000001b3
138 }
139 return settings.version + 1_000_000 * (1 + Int(hash % 1_000_000))
140 }
141
142 /// `settingsVersion` for a file's bytes, reading the setup files it names.
143 public static func settingsVersion(_ settings: IndexSettings, bytes: [UInt8], path: String) -> Int {
144 let text = SourceText(bytes: bytes).text
145 guard text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return settings.version }
146 let keywords = settings.org.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile).setupKeywords
147 return settingsVersion(settings, text: text, setupKeywords: keywords)
148 }
149
130150 /// A file the index lists without reading: `hash` stands for its size and mtime.
131151 public init(path: String, root: String, unreadSize size: Int, mtime: Double, settings: IndexSettings) {
132152 self.path = path
Sources/OrgWorkspace/Reconciler.swift +4 −2
@@ -90,7 +90,8 @@ public struct Reconciler: Sendable {
9090 guard let data = try? Data(contentsOf: file.url) else { continue }
9191 let bytes = [UInt8](data)
9292 let hash = FileRecord.hash(bytes)
93 if let state = known[path], state.hash == hash, state.settingsVersion == settings.version, state.kind == file.kind {
93 if let state = known[path], state.hash == hash, state.settingsVersion == FileRecord.settingsVersion(settings, bytes: bytes, path: path),
94 state.kind == file.kind {
9495 change.touches.append((path, file.mtime))
9596 report.touched.append(path)
9697 } else {
@@ -105,7 +106,8 @@ public struct Reconciler: Sendable {
105106 let path = candidate.file.url.path
106107 if known[path] == nil,
107108 let (oldPath, _) = missing.first(where: {
108 $0.value.hash == candidate.hash && $0.value.settingsVersion == settings.version && $0.value.kind == candidate.file.kind
109 $0.value.hash == candidate.hash && $0.value.kind == candidate.file.kind
110 && $0.value.settingsVersion == (candidate.file.kind == .other ? settings.version : FileRecord.settingsVersion(settings, bytes: candidate.bytes, path: path))
109111 }) {
110112 missing[oldPath] = nil
111113 change.moves.append((oldPath, path, candidate.file.mtime))
Tests/OrgAppTests/AppTests.swift +12
@@ -216,6 +216,18 @@ struct DocumentSessionTests {
216216 #expect(session.document?.isDirty == false)
217217 }
218218
219 @Test func reloadingFromDiskReadsSetupFilesAgain() throws {
220 let notes = try Folder()
221 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
222 let file = try notes.write("a.org", "* WAIT One\n")
223 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
224 session.open(file.path)
225 try notes.write("a.org", "#+SETUPFILE: setup.org\n* WAIT One\n")
226 try notes.write("setup.org", "#+TODO: WAIT | DONE\n")
227 session.fileChanged(file.path)
228 #expect(session.document?.tree.settings.todoKeywordNames.contains("WAIT") == true)
229 }
230
219231 @Test func missingFileReportsAnError() {
220232 let session = DocumentSession()
221233 session.open("/nonexistent/a.org")
Tests/OrgCoreTests/BabelTests.swift +3
@@ -145,6 +145,9 @@ struct BabelTests {
145145 "#+NAME: items\n- x\n- y\n\n#+begin_src sh :stdin items\nwc -l | tr -d ' '\n#+end_src\n",
146146 "#+begin_src bash :shebang #!/bin/bash :results output\necho ${BASH_VERSION:+bash}\n#+end_src\n",
147147 "#+begin_src sh :cmdline a b :padline no :var x=5 :results output\necho $x $#\n#+end_src\n",
148 "#+NAME: in\n| hello |\n\n#+begin_src sh :results value :stdin in\ncat\nfalse\n#+end_src\n",
149 "#+begin_src sh :results value :cmdline x\necho one\n#+end_src\n",
150 "#+begin_src bash :shebang #!/bin/bash :cmdline \"a b\" c :results output\necho $#\n#+end_src\n",
148151 "#+begin_src sh :stdin src :results output\ntr a-z A-Z\n#+end_src\n\n#+NAME: src\n#+begin_src sh\necho hello\n#+end_src\n",
149152 // `:var` from other blocks' results.
150153 "#+begin_src sh :var x=five\necho $((x+1))\n#+end_src\n\n#+NAME: five\n#+begin_src sh\necho 5\n#+end_src\n",
Tests/OrgCoreTests/SetupFileTests.swift +5
@@ -36,6 +36,11 @@ struct SetupFileTests {
3636 #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n"))
3737 #expect(lines.components(separatedBy: "#+TODO:").count == 2)
3838 #expect(SetupFile.keywordLines(in: "* x\n", directory: "/w") { files[$0] } == "")
39 // A file naming itself adds nothing; a setup file naming it back adds only its own.
40 let own = "#+SETUPFILE: a.org\n#+TODO: A B | C\n"
41 #expect(SetupFile.keywordLines(in: own, directory: "/w", path: "/w/a.org") { $0 == "/w/a.org" ? own : nil } == "")
42 let back = ["/w/a.org": "#+SETUPFILE: b.org\n#+TODO: A | B\n", "/w/b.org": "#+SETUPFILE: a.org\n#+TODO: X | Y\n"]
43 #expect(SetupFile.keywordLines(in: back["/w/a.org"]!, directory: "/w", path: "/w/a.org") { back[$0] } == "#+TODO: X | Y\n")
3944 #expect(SetupFile.keywordLines(in: "#+SETUPFILE: https://example.com/a.org\n", directory: "/w") { _ in "#+TODO: X\n" } == "")
4045 }
4146
Tests/OrgCoreTests/TangleTests.swift +3
@@ -223,6 +223,9 @@ struct TangleTests {
223223 #+begin_src sh :tangle (file-name-sans-extension (file-name-nondirectory buffer-file-name))
224224 echo named after the file
225225 #+end_src
226 #+begin_src sh :tangle quoted.sh :prologue "(cd /tmp && ls)"
227 echo quoted
228 #+end_src
226229 #+begin_src python -r -l "#(ref:%s)" :tangle refs.py
227230 x = 1 #(ref:one)
228231 y = 2 (ref:kept)
Tests/OrgWorkspaceTests/ReconcileTests.swift +13
@@ -42,6 +42,19 @@ struct ReconcileTests {
4242 #expect(try reconciler.reconcile(root: folder.url).touched == [a.path])
4343 }
4444
45 @Test func changedSetupFileReindexesTheFilesNamingIt() throws {
46 let folder = try Folder()
47 let index = try IndexStore()
48 let reconciler = Reconciler(index: index, settings: IndexSettings())
49 try folder.write("setup.txt", "#+TODO: TODO | DONE\n")
50 let a = try folder.write("a.org", "#+SETUPFILE: setup.txt\n* NEXT Alpha\n")
51 _ = try reconciler.reconcile(root: folder.url)
52 #expect(try reconciler.reconcile(root: folder.url).indexed.isEmpty)
53 try folder.write("setup.txt", "#+TODO: NEXT | DONE\n")
54 #expect(try reconciler.reconcile(root: folder.url).indexed.contains(a.path))
55 #expect(try reconciler.reconcile(root: folder.url).indexed.isEmpty)
56 }
57
4558 @Test func newSettingsVersionReindexes() throws {
4659 let folder = try Folder()
4760 let index = try IndexStore()