krz/orgstar

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

Commit 28b91f82ea

28b91f82ea7c0f4a1db451096b61e238c724eba1

parent: 8dea72acea

Verified · cmc

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

Run only the blocks agreed to when :var runs others

The run asked about lists every block its references reach; a job
planned again after a referenced block ran, for a block not among
them (the text changed meanwhile), is refused. A referenced block with
:eval query makes the run ask every time.

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +6
@@ -716,8 +716,13 @@ public final class DocumentSession {
716716 }
717717 do {
718718 // Blocks the `:var`s refer to run first, one at a time.
719 // Each is one of the blocks agreed to, or the run stops: the text may have changed
720 // while a block ran.
719721 var job = job
722 let approved = job.approved
723 func agreed(_ block: BabelJob) -> Bool { approved.isEmpty || approved.contains(block.blockText) }
720724 while let need = job.needs.first {
725 guard agreed(need.job) else { return "The blocks changed while running; nothing more was run." }
721726 let output = try await run(need.job)
722727 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
723728 let document = entry.buffer.document
@@ -726,6 +731,7 @@ public final class DocumentSession {
726731 case .success(let next): job = next
727732 }
728733 }
734 guard agreed(job) else { return "The blocks changed while running; nothing more was run." }
729735 let output = try await run(job)
730736 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
731737 if let file = Babel.fileResult(job, output: output) {
Sources/OrgCore/Compute/Babel.swift +13 −5
@@ -32,6 +32,9 @@ public struct BabelJob: Sendable, Equatable {
3232 public let finish: Finish
3333 /// A named block a `:var` refers to: run it first, then `Babel.resume` with its output.
3434 public var needs: [Need] = []
35 /// With `needs`: the text of every block the run may execute, the one asked about and
36 /// those its references reach. A later job for a block not among them is refused.
37 public internal(set) var approved: [String] = []
3538 /// Values of the references resolved so far, and how to plan again.
3639 var resolved: [String: Babel.VarValue] = [:]
3740 var caret = 0
@@ -295,12 +298,17 @@ public enum Babel {
295298 vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: ns, refs: refs)) }
296299 stdin = try params.single[":stdin"].map { try resolve($0, model: model, text: ns, refs: refs) }
297300 } catch let needed as Needed {
298 // Asked about once, with every block the references reach.
299 let texts = [ns.substring(with: NSRange(location))] + referencedBlocks(params, model: model, text: ns, seen: []).map { ns.substring(with: NSRange($0.range)) }
300 return .success(BabelJob(
301 // Asked about once, with every block the references reach; asked every time when
302 // any of them has `:eval query`.
303 let reached = referencedBlocks(params, model: model, text: ns, seen: [])
304 let texts = [ns.substring(with: NSRange(location))] + reached.map { ns.substring(with: NSRange($0.range)) }
305 let query = eval == "query" || reached.contains { (try? Params(block: $0, model: model, text: ns))?.single[":eval"]?.lowercased() == "query" }
306 var job = BabelJob(
301307 language: language, runner: .process(executable: "", arguments: []), input: "", directory: params.single[":dir"],
302 block: location, blockText: texts.joined(separator: "\n"), alwaysAsk: eval == "query", finish: finishBase(.generic), needs: [needed.need]
303 ))
308 block: location, blockText: texts.joined(separator: "\n"), alwaysAsk: query, finish: finishBase(.generic), needs: [needed.need]
309 )
310 job.approved = texts
311 return .success(job)
304312 } catch let error as Failure {
305313 return .failure(error)
306314 } catch {
Tests/OrgCoreTests/BabelTests.swift +26
@@ -230,6 +230,32 @@ struct BabelTests {
230230 }
231231 }
232232
233 @Test func referencedQueryBlocksAskEveryTime() {
234 let text = "#+begin_src sh :var x=gen\necho $x\n#+end_src\n\n#+NAME: gen\n#+begin_src sh :eval query\necho 1\n#+end_src\n"
235 guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {
236 Issue.record("not planned")
237 return
238 }
239 #expect(job.alwaysAsk)
240 }
241
242 @Test func aChangedBufferLeavesTheApprovedBlocks() throws {
243 let text = "#+begin_src sh :var x=gen\necho $x\n#+end_src\n\n#+NAME: gen\n#+begin_src sh\necho 1\n#+end_src\n"
244 guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])),
245 let need = job.needs.first else {
246 Issue.record("not planned")
247 return
248 }
249 #expect(job.approved.contains(need.job.blockText))
250 // Another block now sits where the approved one was.
251 let changed = "#+begin_src sh\nrm -rf nothing\n#+end_src\n" + text
252 guard case .success(let next) = Babel.resume(job, need: need, output: BabelOutput(stdout: "1\n"), text: changed, tree: OrgParser.parse(changed)) else {
253 Issue.record("not resumed")
254 return
255 }
256 #expect(!job.approved.contains(next.blockText))
257 }
258
233259 @Test func asksOnceForEveryReferencedBlock() throws {
234260 let text = BabelTests.cases.first { $0.contains(":var a=outer") }!
235261 guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {