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