krz/orgstar

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

Commit 3e80720dbf

3e80720dbff31d31d0bb2f4b66335e8b8b81f78e

parent: 3ed8024d96

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 02:05 UTC

Babel file Lisp, PWD, and buffers by truename

Lisp header values that need the file (buffer-file-name, expand-file-name)
are evaluated by planning again with the path before the run, so :cache
hashes them as Emacs does. Blocks run with PWD set, as call-process does.
Opening a file by another path shows its open buffer, and buffers of the
same name are named as uniquify does.

Layout: unified · split

Sources/OrgApp/BabelRunner.swift +2
@@ -53,6 +53,8 @@ public enum BabelRunner {
5353 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
5454 guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") }
5555 process.currentDirectoryURL = directory
56 // As Emacs's `call-process` does, so `pwd` prints the folder as written, not resolved.
57 process.environment?["PWD"] = directory.path
5658 let stdout = folder.appendingPathComponent("stdout")
5759 FileManager.default.createFile(atPath: stdout.path, contents: nil)
5860 let out = try FileHandle(forWritingTo: stdout)
Sources/OrgApp/DocumentSession.swift +27 −1
@@ -147,7 +147,26 @@ public final class DocumentSession {
147147 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
148148 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
149149
150 private func entry(_ path: String) -> Entry? { entries.first { $0.path == path } }
150 /// The open buffer of `path`, or of the same file by another path (`find-buffer-visiting`
151 /// by truename).
152 private func entry(_ path: String) -> Entry? {
153 if let exact = entries.first(where: { $0.path == path }) { return exact }
154 let truename = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
155 return entries.first { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath().path == truename }
156 }
157
158 /// The buffer's name as uniquify writes it (`post-forward-angle-brackets`): the file name,
159 /// with as many of its folders as tell it apart from other open files of the same name.
160 public func bufferName(_ path: String) -> String {
161 let name = (path as NSString).lastPathComponent
162 let others = openPaths.filter { $0 != path && ($0 as NSString).lastPathComponent == name }
163 guard !others.isEmpty else { return name }
164 func folders(_ p: String) -> [String] { (p as NSString).deletingLastPathComponent.split(separator: "/").map(String.init).reversed() }
165 let mine = folders(path)
166 var count = 1
167 while count < mine.count, others.contains(where: { folders($0).prefix(count) == mine.prefix(count) }) { count += 1 }
168 return name + "<" + mine.prefix(count).reversed().joined(separator: "/") + ">"
169 }
151170
152171 public func isOpen(_ path: String) -> Bool { entry(path) != nil }
153172
@@ -727,6 +746,13 @@ public final class DocumentSession {
727746 var job = job
728747 let approved = job.approved
729748 func agreed(_ block: BabelJob) -> Bool { approved.isEmpty || approved.contains(block.blockText) }
749 if job.needsFile {
750 let document = entry.buffer.document
751 switch Babel.replan(job, file: entry.path, text: document.text, tree: document.tree) {
752 case .failure(.message(let message)): return message
753 case .success(let next): job = next
754 }
755 }
730756 while let need = job.needs.first {
731757 guard agreed(need.job) else { return "The blocks changed while running; nothing more was run." }
732758 let output = try await run(need.job)
Sources/OrgCore/Compute/Babel.swift +33 −5
@@ -44,6 +44,9 @@ public struct BabelJob: Sendable, Equatable {
4444 public internal(set) var script: String?
4545 /// An Emacs Lisp block's form, for `Babel.evaluateNatively`.
4646 public internal(set) var lispForm: String?
47 /// A Lisp header value couldn't be evaluated without the file's path: plan again with
48 /// `Babel.replan` before running.
49 public internal(set) var needsFile = false
4750
4851 public struct Need: Sendable, Equatable {
4952 /// The `:var` value, `name` or `name(args)`.
@@ -116,6 +119,8 @@ public enum Babel {
116119 /// `shell-file-name`, from `SHELL` as Emacs sets it: runs blocks in the `shell` language
117120 /// and chooses how variables are written in the `:cache` hash.
118121 public var shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/sh"
122 /// The file the block is in, for Lisp header values that use it.
123 public var file: String?
119124 public init() {}
120125 }
121126
@@ -135,6 +140,14 @@ public enum Babel {
135140 return plan(context, options: job.options, resolved: resolved)
136141 }
137142
143 /// Plans `job`'s block again knowing the file it is in.
144 public static func replan(_ job: BabelJob, file: String, text: String, tree: OrgTree) -> Result<BabelJob, Failure> {
145 var options = job.options
146 options.file = file
147 let context = EditContext(revision: 0, text: text, tree: tree, selection: [job.caret..<job.caret])
148 return plan(context, options: options, resolved: job.resolved)
149 }
150
138151 static func plan(_ context: EditContext, options: Options, resolved: [String: VarValue]) -> Result<BabelJob, Failure> {
139152 let text = context.text
140153 let ns = text as NSString
@@ -262,9 +275,23 @@ public enum Babel {
262275
263276 static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options,
264277 location: Range<Int>, name: String?, inline: Bool = false, refs: References? = nil, useCache: Bool = true) -> Result<BabelJob, Failure> {
278 var evaluated = params
279 evaluated.evaluateLisp(file: options.file)
280 let needsFile = options.file == nil && evaluated.lisp.contains { evaluated.evaluated[$0] == nil }
281 return planBlock(block, params: params, model: model, text: ns, options: options, location: location, name: name, inline: inline,
282 refs: refs, useCache: useCache).map { job in
283 var job = job
284 job.needsFile = needsFile
285 return job
286 }
287 }
288
289 static func planBlock(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options,
290 location: Range<Int>, name: String?, inline: Bool, refs: References?, useCache: Bool) -> Result<BabelJob, Failure> {
265291 let refs = refs ?? References(resolved: [:], options: options)
266292 var params = params
267 params.evaluateLisp()
293 params.evaluateLisp(file: options.file)
294 let needsFile = options.file == nil && params.lisp.contains { params.evaluated[$0] == nil }
268295 guard let language = block.language else { return .failure(.message("This block has no language")) }
269296 let eval = params.single[":eval"]?.lowercased()
270297 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
@@ -326,7 +353,7 @@ public enum Babel {
326353 } catch {
327354 return .failure(.message("\(error)"))
328355 }
329 if useCache, !inline, params.single[":cache"] == "yes" {
356 if useCache, !inline, !needsFile, params.single[":cache"] == "yes" {
330357 do {
331358 let state = try cacheState(language: language, params: params, tables: tables, body: body, location: location, name: name,
332359 call: location != block.range, text: ns, options: options)
@@ -830,9 +857,10 @@ public enum Babel {
830857
831858 /// `org-babel-read` on the Lisp values the interpreter can evaluate: a string or number
832859 /// takes the form's place; other values are kept in `evaluated`.
833 mutating func evaluateLisp() {
860 mutating func evaluateLisp(file: String?) {
834861 for key in lisp {
835 guard let form = try? LispReader.readFirst(single[key] ?? "").sexp, let value = try? Elisp().eval(form) else { continue }
862 guard let form = try? LispReader.readFirst(single[key] ?? "").sexp,
863 let value = try? (file.map { Tangle.fileLisp(path: $0) } ?? Elisp()).eval(form) else { continue }
836864 let printed = Elisp.printed(value)
837865 if let s = value.string {
838866 single[key] = s
@@ -1100,7 +1128,7 @@ public enum Babel {
11001128 var inner = refs
11011129 inner.visiting.insert(name)
11021130 var params = try Params(block: block, model: model, text: text, extra: extra)
1103 params.evaluateLisp()
1131 params.evaluateLisp(file: refs.options.file)
11041132 // A cached block gives its result without running, as `org-babel-execute-src-block` does.
11051133 var useCache = true
11061134 if params.single[":cache"] == "yes", let language = block.language {
Sources/OrgCore/Compute/Tangle.swift +10 −4
@@ -135,10 +135,9 @@ public enum Tangle {
135135 return params
136136 }
137137
138 /// `org-babel-read` of a header value written as Lisp, with what a file's buffer knows:
139 /// `buffer-file-name`, `default-directory` and `system-type`.
140 static func evaluate(_ value: String, key: String, path: String) throws -> String {
141 let refusal = Babel.Failure.message("\(key) \(value) is Lisp tangling can't evaluate yet; nothing was tangled.")
138 /// An interpreter with what a file's buffer knows: `buffer-file-name`, `default-directory`,
139 /// `system-type` and the file name functions.
140 static func fileLisp(path: String) -> Elisp {
142141 let lisp = Elisp()
143142 let directory = (path as NSString).deletingLastPathComponent + "/"
144143 lisp.set("buffer-file-name", .string(path))
@@ -170,6 +169,13 @@ public enum Tangle {
170169 let name = try string(args, 0)
171170 return .string(name.hasSuffix("/") ? name : name + "/")
172171 }
172 return lisp
173 }
174
175 /// `org-babel-read` of a header value written as Lisp, in `fileLisp`.
176 static func evaluate(_ value: String, key: String, path: String) throws -> String {
177 let refusal = Babel.Failure.message("\(key) \(value) is Lisp tangling can't evaluate yet; nothing was tangled.")
178 let lisp = fileLisp(path: path)
173179 guard let form = try? LispReader.readFirst(value).sexp, let result = try? lisp.eval(form), !Elisp.isNil(result) else { throw refusal }
174180 switch result {
175181 case .string(let s): return s
Sources/Orgstar/ContentView.swift +4 −9
@@ -204,7 +204,7 @@ struct ContentView: View {
204204 }
205205 }
206206 }
207 .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar")
207 .navigationTitle(previewPath.map { ($0 as NSString).lastPathComponent } ?? session.path.map(session.bufferName) ?? "Orgstar")
208208 .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "")
209209 .searchable(text: $query, placement: .toolbar, prompt: "Search notes")
210210 .searchFocused($searchFocused)
@@ -511,12 +511,7 @@ struct ContentView: View {
511511 /// `SPC b b`: open buffers, most recent first, by name (with the folder when names repeat).
512512 private func switchBuffer() {
513513 let paths = session.recentPaths.dropFirst() + session.recentPaths.prefix(1)
514 let names = paths.map { ($0 as NSString).lastPathComponent }
515 let labels = paths.map { path in
516 let name = (path as NSString).lastPathComponent
517 guard names.filter({ $0 == name }).count > 1 else { return name }
518 return name + " — " + ((path as NSString).deletingLastPathComponent as NSString).lastPathComponent
519 }
514 let labels = paths.map(session.bufferName)
520515 guard !labels.isEmpty else { return commands.show("No buffers are open") }
521516 commands.ask(Prompt(key: "buffer", message: "Switch to buffer:", choices: labels)) { answer in
522517 guard let answer, let index = labels.firstIndex(of: answer) else { return }
@@ -896,7 +891,7 @@ struct BufferTabs: View {
896891 private func tab(_ path: String) -> some View {
897892 let current = path == session.path
898893 return HStack(spacing: 6) {
899 Text((path as NSString).lastPathComponent)
894 Text(session.bufferName(path))
900895 .lineLimit(1)
901896 .foregroundStyle(current ? .primary : .secondary)
902897 .accessibilityAddTraits(current ? [.isButton, .isSelected] : .isButton)
@@ -914,7 +909,7 @@ struct BufferTabs: View {
914909 }
915910 .buttonStyle(.borderless)
916911 .help("Close")
917 .accessibilityLabel("Close \((path as NSString).lastPathComponent)" + (session.dirtyPaths.contains(path) ? ", unsaved" : ""))
912 .accessibilityLabel("Close \(session.bufferName(path))" + (session.dirtyPaths.contains(path) ? ", unsaved" : ""))
918913 }
919914 .font(.callout)
920915 .padding(.horizontal, 12)
Sources/OrgstarMobile/EditorScreen.swift +1 −1
@@ -55,7 +55,7 @@ struct EditorScreen: View {
5555 ProgressView()
5656 }
5757 }
58 .navigationTitle((path as NSString).lastPathComponent + (session.dirtyPaths.contains(path) ? " •" : ""))
58 .navigationTitle(session.bufferName(path) + (session.dirtyPaths.contains(path) ? " •" : ""))
5959 .navigationBarTitleDisplayMode(.inline)
6060 .toolbar(.hidden, for: .tabBar)
6161 .toolbar {
Tests/OrgAppTests/AppTests.swift +27
@@ -190,6 +190,33 @@ struct DocumentSessionTests {
190190 #expect(session.revealRequest?.offset == 12)
191191 }
192192
193 @Test func sameFileByAnotherPathSharesItsBuffer() throws {
194 let notes = try Folder()
195 let file = try notes.write("a.org", "* One\n")
196 let link = notes.url.appendingPathComponent("link")
197 try FileManager.default.createSymbolicLink(at: link, withDestinationURL: notes.url)
198 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
199 session.open(file.path)
200 session.open(link.appendingPathComponent("a.org").path)
201 #expect(session.openPaths == [file.path])
202 #expect(session.path == file.path)
203 }
204
205 @Test func buffersOfTheSameNameShowTheirFolders() throws {
206 let notes = try Folder()
207 let a = try notes.write("x/blog/index.org", "a\n")
208 let b = try notes.write("y/blog/index.org", "b\n")
209 let c = try notes.write("y/other.org", "c\n")
210 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
211 session.open(a.path)
212 #expect(session.bufferName(a.path) == "index.org")
213 session.open(b.path)
214 session.open(c.path)
215 #expect(session.bufferName(a.path) == "index.org<x/blog>")
216 #expect(session.bufferName(b.path) == "index.org<y/blog>")
217 #expect(session.bufferName(c.path) == "other.org")
218 }
219
193220 @Test func openReadsSetupFiles() throws {
194221 let notes = try Folder()
195222 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
Tests/OrgCoreTests/BabelCacheTests.swift +30 −3
@@ -96,13 +96,40 @@ struct BabelCacheTests {
9696 #expect(second.failure == "Cached: \"hi\"")
9797 }
9898
99 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
100 func fileLispMatchesEmacs() throws {
101 let text = "#+begin_src sh :cache yes :dir (file-name-directory buffer-file-name) :foo (file-name-nondirectory buffer-file-name)\npwd\n#+end_src\n"
102 let emacs = try EmacsOracle.evaluate(text, """
103 (progn (require 'ob-shell) (goto-char (point-min))
104 (let ((info (org-babel-get-src-block-info)))
105 (setf (nth 2 info) (org-babel-process-params (nth 2 info)))
106 (list buffer-file-name (org-babel-sha1-hash info :eval))))
107 """)
108 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
109 guard case .success(let first) = Babel.plan(context) else {
110 Issue.record("not planned")
111 return
112 }
113 #expect(first.needsFile)
114 #expect(first.finish.hash == nil)
115 guard case .success(let job) = Babel.replan(first, file: emacs[0], text: text, tree: context.tree) else {
116 Issue.record("not replanned")
117 return
118 }
119 #expect(!job.needsFile)
120 #expect(job.finish.hash == emacs[1])
121 #expect(job.directory == (emacs[0] as NSString).deletingLastPathComponent + "/")
122 }
123
99124 @Test func lispHeadersAreRefused() {
100 let text = "#+begin_src sh :cache yes :dir (expand-file-name \"x\")\necho hi\n#+end_src\n"
125 let text = "#+begin_src sh :cache yes :dir (user-full-name)\necho hi\n#+end_src\n"
101126 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
102 guard case .failure(.message(let message)) = Babel.plan(context) else {
127 var options = Babel.Options()
128 options.file = "/tmp/a.org"
129 guard case .failure(.message(let message)) = Babel.plan(context, options: options) else {
103130 Issue.record("planned")
104131 return
105132 }
106 #expect(message.hasPrefix(":cache with :dir (expand-file-name"))
133 #expect(message.hasPrefix(":cache with :dir (user-full-name)"))
107134 }
108135}
Tests/OrgCoreTests/BabelTests.swift +3 −1
@@ -30,7 +30,8 @@ enum TestBabelRunner {
3030 case .session:
3131 return BabelOutput(stdout: "")
3232 }
33 process.currentDirectoryURL = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? directory
33 process.currentDirectoryURL = job.directory.map { URL(fileURLWithPath: $0, relativeTo: directory).standardizedFileURL } ?? directory
34 process.environment = ProcessInfo.processInfo.environment.merging(["PWD": process.currentDirectoryURL!.path]) { $1 }
3435 let stdin = Pipe()
3536 let stdout = Pipe()
3637 process.standardInput = stdin
@@ -192,6 +193,7 @@ struct BabelTests {
192193 "TBL#+begin_src python :var t=tbl :colnames '(\"x\" \"y\")\nreturn t\n#+end_src\n",
193194 "TBL#+begin_src python :var t=tbl :rownames '(\"r1\" \"r2\")\nreturn t\n#+end_src\n",
194195 "#+begin_src sh :dir (concat \"/\" \"usr\") :results output\npwd\n#+end_src\n",
196 "#+begin_src sh :dir /tmp :results output\npwd\n#+end_src\n",
195197 "#+NAME: items\n- one\n- two\n\n#+begin_src shell :var l=items :results output\necho \"$l\"\n#+end_src\n",
196198 // Lisp values.
197199 "#+begin_src python :var l='(1 2) m='((1 2) (3 4)) n=(+ 1 2) s='(\"a\" \"b\")\nreturn [l, m, n, s]\n#+end_src\n",