Commit 3e80720dbf
Verified · cmc
Layout: unified · split
Sources/OrgApp/BabelRunner.swift +2
| @@ -53,6 +53,8 @@ public enum BabelRunner { | ||
| 53 | 53 | let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory |
| 54 | 54 | guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") } |
| 55 | 55 | 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 | |
| 56 | 58 | let stdout = folder.appendingPathComponent("stdout") |
| 57 | 59 | FileManager.default.createFile(atPath: stdout.path, contents: nil) |
| 58 | 60 | let out = try FileHandle(forWritingTo: stdout) |
Sources/OrgApp/DocumentSession.swift +27 −1
| @@ -147,7 +147,26 @@ public final class DocumentSession { | ||
| 147 | 147 | public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 } |
| 148 | 148 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } |
| 149 | 149 | |
| 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 | } | |
| 151 | 170 | |
| 152 | 171 | public func isOpen(_ path: String) -> Bool { entry(path) != nil } |
| 153 | 172 | |
| @@ -727,6 +746,13 @@ public final class DocumentSession { | ||
| 727 | 746 | var job = job |
| 728 | 747 | let approved = job.approved |
| 729 | 748 | 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 | } | |
| 730 | 756 | while let need = job.needs.first { |
| 731 | 757 | guard agreed(need.job) else { return "The blocks changed while running; nothing more was run." } |
| 732 | 758 | let output = try await run(need.job) |
Sources/OrgCore/Compute/Babel.swift +33 −5
| @@ -44,6 +44,9 @@ public struct BabelJob: Sendable, Equatable { | ||
| 44 | 44 | public internal(set) var script: String? |
| 45 | 45 | /// An Emacs Lisp block's form, for `Babel.evaluateNatively`. |
| 46 | 46 | 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 | |
| 47 | 50 | |
| 48 | 51 | public struct Need: Sendable, Equatable { |
| 49 | 52 | /// The `:var` value, `name` or `name(args)`. |
| @@ -116,6 +119,8 @@ public enum Babel { | ||
| 116 | 119 | /// `shell-file-name`, from `SHELL` as Emacs sets it: runs blocks in the `shell` language |
| 117 | 120 | /// and chooses how variables are written in the `:cache` hash. |
| 118 | 121 | 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? | |
| 119 | 124 | public init() {} |
| 120 | 125 | } |
| 121 | 126 | |
| @@ -135,6 +140,14 @@ public enum Babel { | ||
| 135 | 140 | return plan(context, options: job.options, resolved: resolved) |
| 136 | 141 | } |
| 137 | 142 | |
| 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 | ||
| 138 | 151 | static func plan(_ context: EditContext, options: Options, resolved: [String: VarValue]) -> Result<BabelJob, Failure> { |
| 139 | 152 | let text = context.text |
| 140 | 153 | let ns = text as NSString |
| @@ -262,9 +275,23 @@ public enum Babel { | ||
| 262 | 275 | |
| 263 | 276 | static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options, |
| 264 | 277 | 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> { | |
| 265 | 291 | let refs = refs ?? References(resolved: [:], options: options) |
| 266 | 292 | 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 } | |
| 268 | 295 | guard let language = block.language else { return .failure(.message("This block has no language")) } |
| 269 | 296 | let eval = params.single[":eval"]?.lowercased() |
| 270 | 297 | if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { |
| @@ -326,7 +353,7 @@ public enum Babel { | ||
| 326 | 353 | } catch { |
| 327 | 354 | return .failure(.message("\(error)")) |
| 328 | 355 | } |
| 329 | if useCache, !inline, params.single[":cache"] == "yes" { | |
| 356 | if useCache, !inline, !needsFile, params.single[":cache"] == "yes" { | |
| 330 | 357 | do { |
| 331 | 358 | let state = try cacheState(language: language, params: params, tables: tables, body: body, location: location, name: name, |
| 332 | 359 | call: location != block.range, text: ns, options: options) |
| @@ -830,9 +857,10 @@ public enum Babel { | ||
| 830 | 857 | |
| 831 | 858 | /// `org-babel-read` on the Lisp values the interpreter can evaluate: a string or number |
| 832 | 859 | /// takes the form's place; other values are kept in `evaluated`. |
| 833 | mutating func evaluateLisp() { | |
| 860 | mutating func evaluateLisp(file: String?) { | |
| 834 | 861 | 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 } | |
| 836 | 864 | let printed = Elisp.printed(value) |
| 837 | 865 | if let s = value.string { |
| 838 | 866 | single[key] = s |
| @@ -1100,7 +1128,7 @@ public enum Babel { | ||
| 1100 | 1128 | var inner = refs |
| 1101 | 1129 | inner.visiting.insert(name) |
| 1102 | 1130 | var params = try Params(block: block, model: model, text: text, extra: extra) |
| 1103 | params.evaluateLisp() | |
| 1131 | params.evaluateLisp(file: refs.options.file) | |
| 1104 | 1132 | // A cached block gives its result without running, as `org-babel-execute-src-block` does. |
| 1105 | 1133 | var useCache = true |
| 1106 | 1134 | if params.single[":cache"] == "yes", let language = block.language { |
Sources/OrgCore/Compute/Tangle.swift +10 −4
| @@ -135,10 +135,9 @@ public enum Tangle { | ||
| 135 | 135 | return params |
| 136 | 136 | } |
| 137 | 137 | |
| 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 { | |
| 142 | 141 | let lisp = Elisp() |
| 143 | 142 | let directory = (path as NSString).deletingLastPathComponent + "/" |
| 144 | 143 | lisp.set("buffer-file-name", .string(path)) |
| @@ -170,6 +169,13 @@ public enum Tangle { | ||
| 170 | 169 | let name = try string(args, 0) |
| 171 | 170 | return .string(name.hasSuffix("/") ? name : name + "/") |
| 172 | 171 | } |
| 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) | |
| 173 | 179 | guard let form = try? LispReader.readFirst(value).sexp, let result = try? lisp.eval(form), !Elisp.isNil(result) else { throw refusal } |
| 174 | 180 | switch result { |
| 175 | 181 | case .string(let s): return s |
Sources/Orgstar/ContentView.swift +4 −9
| @@ -204,7 +204,7 @@ struct ContentView: View { | ||
| 204 | 204 | } |
| 205 | 205 | } |
| 206 | 206 | } |
| 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") | |
| 208 | 208 | .navigationSubtitle(session.isReadOnly ? "Read-only: not UTF-8" : "") |
| 209 | 209 | .searchable(text: $query, placement: .toolbar, prompt: "Search notes") |
| 210 | 210 | .searchFocused($searchFocused) |
| @@ -511,12 +511,7 @@ struct ContentView: View { | ||
| 511 | 511 | /// `SPC b b`: open buffers, most recent first, by name (with the folder when names repeat). |
| 512 | 512 | private func switchBuffer() { |
| 513 | 513 | 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) | |
| 520 | 515 | guard !labels.isEmpty else { return commands.show("No buffers are open") } |
| 521 | 516 | commands.ask(Prompt(key: "buffer", message: "Switch to buffer:", choices: labels)) { answer in |
| 522 | 517 | guard let answer, let index = labels.firstIndex(of: answer) else { return } |
| @@ -896,7 +891,7 @@ struct BufferTabs: View { | ||
| 896 | 891 | private func tab(_ path: String) -> some View { |
| 897 | 892 | let current = path == session.path |
| 898 | 893 | return HStack(spacing: 6) { |
| 899 | Text((path as NSString).lastPathComponent) | |
| 894 | Text(session.bufferName(path)) | |
| 900 | 895 | .lineLimit(1) |
| 901 | 896 | .foregroundStyle(current ? .primary : .secondary) |
| 902 | 897 | .accessibilityAddTraits(current ? [.isButton, .isSelected] : .isButton) |
| @@ -914,7 +909,7 @@ struct BufferTabs: View { | ||
| 914 | 909 | } |
| 915 | 910 | .buttonStyle(.borderless) |
| 916 | 911 | .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" : "")) | |
| 918 | 913 | } |
| 919 | 914 | .font(.callout) |
| 920 | 915 | .padding(.horizontal, 12) |
Sources/OrgstarMobile/EditorScreen.swift +1 −1
| @@ -55,7 +55,7 @@ struct EditorScreen: View { | ||
| 55 | 55 | ProgressView() |
| 56 | 56 | } |
| 57 | 57 | } |
| 58 | .navigationTitle((path as NSString).lastPathComponent + (session.dirtyPaths.contains(path) ? " •" : "")) | |
| 58 | .navigationTitle(session.bufferName(path) + (session.dirtyPaths.contains(path) ? " •" : "")) | |
| 59 | 59 | .navigationBarTitleDisplayMode(.inline) |
| 60 | 60 | .toolbar(.hidden, for: .tabBar) |
| 61 | 61 | .toolbar { |
Tests/OrgAppTests/AppTests.swift +27
| @@ -190,6 +190,33 @@ struct DocumentSessionTests { | ||
| 190 | 190 | #expect(session.revealRequest?.offset == 12) |
| 191 | 191 | } |
| 192 | 192 | |
| 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 | ||
| 193 | 220 | @Test func openReadsSetupFiles() throws { |
| 194 | 221 | let notes = try Folder() |
| 195 | 222 | try notes.write("setup.org", "#+TODO: NEXT | DONE\n") |
Tests/OrgCoreTests/BabelCacheTests.swift +30 −3
| @@ -96,13 +96,40 @@ struct BabelCacheTests { | ||
| 96 | 96 | #expect(second.failure == "Cached: \"hi\"") |
| 97 | 97 | } |
| 98 | 98 | |
| 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 | ||
| 99 | 124 | @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" | |
| 101 | 126 | 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 { | |
| 103 | 130 | Issue.record("planned") |
| 104 | 131 | return |
| 105 | 132 | } |
| 106 | #expect(message.hasPrefix(":cache with :dir (expand-file-name")) | |
| 133 | #expect(message.hasPrefix(":cache with :dir (user-full-name)")) | |
| 107 | 134 | } |
| 108 | 135 | } |
Tests/OrgCoreTests/BabelTests.swift +3 −1
| @@ -30,7 +30,8 @@ enum TestBabelRunner { | ||
| 30 | 30 | case .session: |
| 31 | 31 | return BabelOutput(stdout: "") |
| 32 | 32 | } |
| 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 } | |
| 34 | 35 | let stdin = Pipe() |
| 35 | 36 | let stdout = Pipe() |
| 36 | 37 | process.standardInput = stdin |
| @@ -192,6 +193,7 @@ struct BabelTests { | ||
| 192 | 193 | "TBL#+begin_src python :var t=tbl :colnames '(\"x\" \"y\")\nreturn t\n#+end_src\n", |
| 193 | 194 | "TBL#+begin_src python :var t=tbl :rownames '(\"r1\" \"r2\")\nreturn t\n#+end_src\n", |
| 194 | 195 | "#+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", | |
| 195 | 197 | "#+NAME: items\n- one\n- two\n\n#+begin_src shell :var l=items :results output\necho \"$l\"\n#+end_src\n", |
| 196 | 198 | // Lisp values. |
| 197 | 199 | "#+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", |