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

merged merged by cmc on 2026-10-07 22:29 UTC · krz/orgstar:review-behaviour into main

13 files changed, +95 −20

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +2 −1
@@ -102,7 +102,8 @@ public final class AgendaModel {
102 let attributes = try? FileManager.default.attributesOfItem(atPath: path) 102 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
103 let mtime = attributes?[.modificationDate] as? Date ?? .distantPast 103 let mtime = attributes?[.modificationDate] as? Date ?? .distantPast
104 let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1 104 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 }
106 reads.append((path, cached, mtime, size, nil)) 107 reads.append((path, cached, mtime, size, nil))
107 } 108 }
108 let loaded = await Task.detached { 109 let loaded = await Task.detached {
Sources/OrgApp/DocumentSession.swift +11 −4
@@ -90,12 +90,17 @@ public final class DocumentSession {
90 /// C-c C-c on a keyword line: the open file's setup files read again (`org-mode-restart`). 90 /// C-c C-c on a keyword line: the open file's setup files read again (`org-mode-restart`).
91 public func refreshSetup() { 91 public func refreshSetup() {
92 guard let entry = current else { return } 92 guard let entry = current else { return }
93 let settings = defaults.resolvingSetupFiles(in: entry.buffer.document.text, path: entry.path, read: readText) 93 resolveSetup(entry)
94 entry.buffer.update { $0.setDefaults(settings) }
95 refresh() 94 refresh()
96 editVersion += 1 95 editVersion += 1
97 } 96 }
98 97
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
99 /// One open file. 104 /// One open file.
100 @MainActor 105 @MainActor
101 final class Entry { 106 final class Entry {
@@ -358,7 +363,8 @@ public final class DocumentSession {
358 do { 363 do {
359 guard let bytes = try fileSystem.read(url) else { return } 364 guard let bytes = try fileSystem.read(url) else { return }
360 if isDirty { try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") } 365 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) }
362 current?.conflict = nil 368 current?.conflict = nil
363 } catch { 369 } catch {
364 lastError = "Can't read \(name): \(error.localizedDescription)" 370 lastError = "Can't read \(name): \(error.localizedDescription)"
@@ -383,7 +389,7 @@ public final class DocumentSession {
383 try recovery.keep(try buffer.document.encodedText(), for: url, label: "local") 389 try recovery.keep(try buffer.document.encodedText(), for: url, label: "local")
384 let merged = mergeWithMarkers(base: versions.base, ours: versions.mine, theirs: versions.disk, oursLabel: "yours", theirsLabel: "disk") 390 let merged = mergeWithMarkers(base: versions.base, ours: versions.mine, theirs: versions.disk, oursLabel: "yours", theirsLabel: "disk")
385 try buffer.update { state in 391 try buffer.update { state in
386 state = DocumentState(bytes: disk, defaults: state.defaults) 392 state = loaded(disk, path: url.path)
387 try state.apply(lineEdits(from: versions.disk, to: merged), baseRevision: state.revision) 393 try state.apply(lineEdits(from: versions.disk, to: merged), baseRevision: state.revision)
388 } 394 }
389 current?.conflict = nil 395 current?.conflict = nil
@@ -835,6 +841,7 @@ public final class DocumentSession {
835 entry.conflict = "\(name) changed on disk, and the changes conflict with yours." 841 entry.conflict = "\(name) changed on disk, and the changes conflict with yours."
836 case .merged, .reloaded: 842 case .merged, .reloaded:
837 entry.conflict = nil 843 entry.conflict = nil
844 resolveSetup(entry)
838 // Edits merged with the disk's still need saving. 845 // Edits merged with the disk's still need saving.
839 scheduleAutosave(entry) 846 scheduleAutosave(entry)
840 case .unchanged: 847 case .unchanged:
Sources/OrgCore/Agenda/AgendaSource.swift +3
@@ -4,6 +4,8 @@ import Foundation
4/// and the active timestamps and date ranges org's agenda would consider. Positions are UTF-16. 4/// and the active timestamps and date ranges org's agenda would consider. Positions are UTF-16.
5public struct AgendaSource: Sendable { 5public struct AgendaSource: Sendable {
6 public let path: String 6 public let path: String
7 /// The file names `#+SETUPFILE`s.
8 public let readsSetupFiles: Bool
7 9
8 struct Heading: Sendable { 10 struct Heading: Sendable {
9 /// Start of the heading line. 11 /// Start of the heading line.
@@ -96,6 +98,7 @@ public struct AgendaSource: Sendable {
96 98
97 public init(path: String, text: String, defaults: OrgSettings = .default) { 99 public init(path: String, text: String, defaults: OrgSettings = .default) {
98 self.path = path 100 self.path = path
101 readsSetupFiles = text.range(of: "#+setupfile:", options: .caseInsensitive) != nil
99 let tree = OrgParser.parse(text, defaults: defaults) 102 let tree = OrgParser.parse(text, defaults: defaults)
100 let model = DocumentModel(tree: tree) 103 let model = DocumentModel(tree: tree)
101 let ns = text as NSString 104 let ns = text as NSString
Sources/OrgCore/Compute/Babel.swift +11 −7
@@ -337,7 +337,7 @@ public enum Babel {
337 // command line after it and `:stdin` as its input; the value is its last line. 337 // command line after it and `:stdin` as its input; the value is its last line.
338 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n" 338 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n"
339 if params.single[":padline"] != "no" { script += "\n" } 339 if params.single[":padline"] != "no" { script += "\n" }
340 script += (lines + [body]).joined(separator: "\n") 340 script += (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
341 let command = Babel.scriptFileToken + (stdin != nil || cmdline != nil ? " " + (cmdline ?? "") : "") 341 let command = Babel.scriptFileToken + (stdin != nil || cmdline != nil ? " " + (cmdline ?? "") : "")
342 var job = BabelJob( 342 var job = BabelJob(
343 language: language, runner: .process(executable: executable, arguments: ["-c", command]), 343 language: language, runner: .process(executable: executable, arguments: ["-c", command]),
@@ -562,6 +562,8 @@ public enum Babel {
562 var single: [String: String] = [:] 562 var single: [String: String] = [:]
563 var vars: [(name: String, value: String)] = [] 563 var vars: [(name: String, value: String)] = []
564 var wrap: String? 564 var wrap: String?
565 /// Keys whose value is Lisp to evaluate (`org-babel-read`): unquoted, starting with `(`.
566 var lisp: Set<String> = []
565 567
566 static let resultGroups: [Set<String>] = [ 568 static let resultGroups: [Set<String>] = [
567 ["file", "list", "vector", "table", "scalar", "verbatim"], 569 ["file", "list", "vector", "table", "scalar", "verbatim"],
@@ -610,7 +612,12 @@ public enum Babel {
610 layers += extra 612 layers += extra
611 613
612 for layer in layers { 614 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 }
614 switch key { 621 switch key {
615 case ":results": 622 case ":results":
616 for word in value.split(whereSeparator: \.isWhitespace).map(String.init) { 623 for word in value.split(whereSeparator: \.isWhitespace).map(String.init) {
@@ -715,11 +722,8 @@ public enum Babel {
715 let trimmed = part.trimmingCharacters(in: .whitespaces) 722 let trimmed = part.trimmingCharacters(in: .whitespaces)
716 guard trimmed.hasPrefix(":") else { continue } 723 guard trimmed.hasPrefix(":") else { continue }
717 let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased() 724 let key = String(trimmed.prefix { !$0.isWhitespace }).lowercased()
718 var value = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces) 725 // Quotes stay until the value is used, so a quoted string isn't taken for Lisp.
719 if key != ":var", value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { 726 pairs.append((key, trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)))
720 value = Params.unescape(String(value.dropFirst().dropLast()))
721 }
722 pairs.append((key, value))
723 } 727 }
724 return pairs 728 return pairs
725 } 729 }
Sources/OrgCore/Compute/Tangle.swift +2 −1
@@ -128,7 +128,8 @@ public enum Tangle {
128 128
129 static func params(_ block: SrcBlockInfo, model: DocumentModel, text: NSString, path: String) throws -> Babel.Params { 129 static func params(_ block: SrcBlockInfo, model: DocumentModel, text: NSString, path: String) throws -> Babel.Params {
130 var params = try Babel.Params(block: block, model: model, text: text) 130 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 }
132 params.single[key] = try evaluate(value, key: key, path: path) 133 params.single[key] = try evaluate(value, key: key, path: path)
133 } 134 }
134 return params 135 return params
Sources/OrgCore/Parser/SetupFile.swift +5 −4
@@ -5,9 +5,10 @@ import Foundation
5/// naming file's folder; URLs aren't fetched. 5/// naming file's folder; URLs aren't fetched.
6public enum SetupFile { 6public enum SetupFile {
7 /// The keyword lines the setup files `text` names bring, in order, as `#+KEY: value` lines. 7 /// 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. 8 /// `read` gives a file's text, or nil when it can't be read. `path`, the naming file's,
9 public static func keywordLines(in text: String, directory: String, read: (String) -> String?) -> String { 9 /// counts as read already, as `buffer-file-name` does in Org.
10 lines(in: text, directory: directory, read: read, own: false, seen: []) 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] } ?? [])
11 } 12 }
12 13
13 private static func lines(in text: String, directory: String, read: (String) -> String?, own: Bool, seen: Set<String>) -> String { 14 private static func lines(in text: String, directory: String, read: (String) -> String?, own: Bool, seen: Set<String>) -> String {
@@ -45,7 +46,7 @@ extension OrgSettings {
45 /// These settings with the keywords of the setup files `text` names, for a file at `path`. 46 /// These settings with the keywords of the setup files `text` names, for a file at `path`.
46 public func resolvingSetupFiles(in text: String, path: String, read: (String) -> String?) -> OrgSettings { 47 public func resolvingSetupFiles(in text: String, path: String, read: (String) -> String?) -> OrgSettings {
47 var copy = self 48 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)
49 return copy 50 return copy
50 } 51 }
51 52
Sources/OrgIndex/FileRecord.swift +21 −1
@@ -120,13 +120,33 @@ public struct FileRecord: Sendable, Equatable {
120 self.size = bytes.count 120 self.size = bytes.count
121 self.mtime = mtime 121 self.mtime = mtime
122 self.hash = Self.hash(bytes) 122 self.hash = Self.hash(bytes)
123 self.settingsVersion = settings.version
124 let source = SourceText(bytes: bytes) 123 let source = SourceText(bytes: bytes)
125 var resolved = settings 124 var resolved = settings
126 resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile) 125 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)
127 headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved) 127 headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved)
128 } 128 }
129 129
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
130 /// A file the index lists without reading: `hash` stands for its size and mtime. 150 /// A file the index lists without reading: `hash` stands for its size and mtime.
131 public init(path: String, root: String, unreadSize size: Int, mtime: Double, settings: IndexSettings) { 151 public init(path: String, root: String, unreadSize size: Int, mtime: Double, settings: IndexSettings) {
132 self.path = path 152 self.path = path
Sources/OrgWorkspace/Reconciler.swift +4 −2
@@ -90,7 +90,8 @@ public struct Reconciler: Sendable {
90 guard let data = try? Data(contentsOf: file.url) else { continue } 90 guard let data = try? Data(contentsOf: file.url) else { continue }
91 let bytes = [UInt8](data) 91 let bytes = [UInt8](data)
92 let hash = FileRecord.hash(bytes) 92 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 {
94 change.touches.append((path, file.mtime)) 95 change.touches.append((path, file.mtime))
95 report.touched.append(path) 96 report.touched.append(path)
96 } else { 97 } else {
@@ -105,7 +106,8 @@ public struct Reconciler: Sendable {
105 let path = candidate.file.url.path 106 let path = candidate.file.url.path
106 if known[path] == nil, 107 if known[path] == nil,
107 let (oldPath, _) = missing.first(where: { 108 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))
109 }) { 111 }) {
110 missing[oldPath] = nil 112 missing[oldPath] = nil
111 change.moves.append((oldPath, path, candidate.file.mtime)) 113 change.moves.append((oldPath, path, candidate.file.mtime))
Tests/OrgAppTests/AppTests.swift +12
@@ -216,6 +216,18 @@ struct DocumentSessionTests {
216 #expect(session.document?.isDirty == false) 216 #expect(session.document?.isDirty == false)
217 } 217 }
218 218
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
219 @Test func missingFileReportsAnError() { 231 @Test func missingFileReportsAnError() {
220 let session = DocumentSession() 232 let session = DocumentSession()
221 session.open("/nonexistent/a.org") 233 session.open("/nonexistent/a.org")
Tests/OrgCoreTests/BabelTests.swift +3
@@ -145,6 +145,9 @@ struct BabelTests {
145 "#+NAME: items\n- x\n- y\n\n#+begin_src sh :stdin items\nwc -l | tr -d ' '\n#+end_src\n", 145 "#+NAME: items\n- x\n- y\n\n#+begin_src sh :stdin items\nwc -l | tr -d ' '\n#+end_src\n",
146 "#+begin_src bash :shebang #!/bin/bash :results output\necho ${BASH_VERSION:+bash}\n#+end_src\n", 146 "#+begin_src bash :shebang #!/bin/bash :results output\necho ${BASH_VERSION:+bash}\n#+end_src\n",
147 "#+begin_src sh :cmdline a b :padline no :var x=5 :results output\necho $x $#\n#+end_src\n", 147 "#+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",
148 "#+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", 151 "#+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",
149 // `:var` from other blocks' results. 152 // `:var` from other blocks' results.
150 "#+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", 153 "#+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 {
36 #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n")) 36 #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n"))
37 #expect(lines.components(separatedBy: "#+TODO:").count == 2) 37 #expect(lines.components(separatedBy: "#+TODO:").count == 2)
38 #expect(SetupFile.keywordLines(in: "* x\n", directory: "/w") { files[$0] } == "") 38 #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")
39 #expect(SetupFile.keywordLines(in: "#+SETUPFILE: https://example.com/a.org\n", directory: "/w") { _ in "#+TODO: X\n" } == "") 44 #expect(SetupFile.keywordLines(in: "#+SETUPFILE: https://example.com/a.org\n", directory: "/w") { _ in "#+TODO: X\n" } == "")
40 } 45 }
41 46
Tests/OrgCoreTests/TangleTests.swift +3
@@ -223,6 +223,9 @@ struct TangleTests {
223 #+begin_src sh :tangle (file-name-sans-extension (file-name-nondirectory buffer-file-name)) 223 #+begin_src sh :tangle (file-name-sans-extension (file-name-nondirectory buffer-file-name))
224 echo named after the file 224 echo named after the file
225 #+end_src 225 #+end_src
226 #+begin_src sh :tangle quoted.sh :prologue "(cd /tmp && ls)"
227 echo quoted
228 #+end_src
226 #+begin_src python -r -l "#(ref:%s)" :tangle refs.py 229 #+begin_src python -r -l "#(ref:%s)" :tangle refs.py
227 x = 1 #(ref:one) 230 x = 1 #(ref:one)
228 y = 2 (ref:kept) 231 y = 2 (ref:kept)
Tests/OrgWorkspaceTests/ReconcileTests.swift +13
@@ -42,6 +42,19 @@ struct ReconcileTests {
42 #expect(try reconciler.reconcile(root: folder.url).touched == [a.path]) 42 #expect(try reconciler.reconcile(root: folder.url).touched == [a.path])
43 } 43 }
44 44
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
45 @Test func newSettingsVersionReindexes() throws { 58 @Test func newSettingsVersionReindexes() throws {
46 let folder = try Folder() 59 let folder = try Folder()
47 let index = try IndexStore() 60 let index = try IndexStore()