Resolve :var from other src blocks' results !128

merged merged by cmc on 2026-10-07 05:33 UTC · krz/orgstar:babel-var-refs into main

4 files changed, +154 −12

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +11
@@ -692,6 +692,17 @@ public final class DocumentSession {
692 guard let entry = current else { return "No file is open." } 692 guard let entry = current else { return "No file is open." }
693 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent() 693 let directory = URL(fileURLWithPath: entry.path).deletingLastPathComponent()
694 do { 694 do {
695 // Blocks the `:var`s refer to run first, one at a time.
696 var job = job
697 while let need = job.needs.first {
698 let output = try await BabelRunner.run(need.job, fileDirectory: directory)
699 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
700 let document = entry.buffer.document
701 switch Babel.resume(job, need: need, output: output, text: document.text, tree: document.tree) {
702 case .failure(.message(let message)): return message
703 case .success(let next): job = next
704 }
705 }
695 let output = try await BabelRunner.run(job, fileDirectory: directory) 706 let output = try await BabelRunner.run(job, fileDirectory: directory)
696 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } 707 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
697 if let file = Babel.fileResult(job, output: output) { 708 if let file = Babel.fileResult(job, output: output) {
Sources/OrgCore/Compute/Babel.swift +109 −10
@@ -30,6 +30,18 @@ public struct BabelJob: Sendable, Equatable {
30 /// Asks every time (`:eval query`) rather than trusting by content. 30 /// Asks every time (`:eval query`) rather than trusting by content.
31 public let alwaysAsk: Bool 31 public let alwaysAsk: Bool
32 public let finish: Finish 32 public let finish: Finish
33 /// A named block a `:var` refers to: run it first, then `Babel.resume` with its output.
34 public var needs: [Need] = []
35 /// Values of the references resolved so far, and how to plan again.
36 var resolved: [String: Babel.VarValue] = [:]
37 var caret = 0
38 var options = Babel.Options()
39
40 public struct Need: Sendable, Equatable {
41 /// The `:var` value, `name` or `name(args)`.
42 public let reference: String
43 public let job: BabelJob
44 }
33 45
34 public struct Finish: Sendable, Equatable { 46 public struct Finish: Sendable, Equatable {
35 enum Kind: Sendable, Equatable { case shell(valueIsExitStatus: Bool), python, generic, emacsLisp } 47 enum Kind: Sendable, Equatable { case shell(valueIsExitStatus: Bool), python, generic, emacsLisp }
@@ -94,19 +106,40 @@ public enum Babel {
94 106
95 /// The run for the src block at the caret, or why it can't run. 107 /// The run for the src block at the caret, or why it can't run.
96 public static func plan(_ context: EditContext, options: Options = Options()) -> Result<BabelJob, Failure> { 108 public static func plan(_ context: EditContext, options: Options = Options()) -> Result<BabelJob, Failure> {
109 plan(context, options: options, resolved: [:])
110 }
111
112 /// Plans the block again with the value of the block `job` needed: the output of
113 /// `need.job`, as `org-babel-ref-resolve` reads it.
114 public static func resume(_ job: BabelJob, need: BabelJob.Need, output: BabelOutput, text: String, tree: OrgTree) -> Result<BabelJob, Failure> {
115 var resolved = job.resolved
116 resolved[need.reference] = varValue(need.job.finish, output)
117 let context = EditContext(revision: 0, text: text, tree: tree, selection: [job.caret..<job.caret])
118 return plan(context, options: job.options, resolved: resolved)
119 }
120
121 static func plan(_ context: EditContext, options: Options, resolved: [String: VarValue]) -> Result<BabelJob, Failure> {
97 let text = context.text 122 let text = context.text
98 let ns = text as NSString 123 let ns = text as NSString
99 let model = DocumentModel(tree: context.tree) 124 let model = DocumentModel(tree: context.tree)
125 let refs = References(resolved: resolved, options: options)
126 func located(_ result: Result<BabelJob, Failure>) -> Result<BabelJob, Failure> {
127 result.map { job in
128 var job = job
129 (job.caret, job.resolved, job.options) = (context.caret, resolved, options)
130 return job
131 }
132 }
100 do { 133 do {
101 if let block = model.srcBlocks.first(where: { $0.range.contains(context.caret) || $0.range.upperBound == context.caret }) { 134 if let block = model.srcBlocks.first(where: { $0.range.contains(context.caret) || $0.range.upperBound == context.caret }) {
102 return plan(block, params: try Params(block: block, model: model, text: ns), model: model, text: ns, options: options, 135 return located(plan(block, params: try Params(block: block, model: model, text: ns), model: model, text: ns, options: options,
103 location: block.range, name: block.name) 136 location: block.range, name: block.name, refs: refs))
104 } 137 }
105 if let call = try callAt(context.caret, model: model, text: ns) { 138 if let call = try callAt(context.caret, model: model, text: ns) {
106 return plan(call.block, params: call.params, model: model, text: ns, options: options, location: call.location, name: call.name) 139 return located(plan(call.block, params: call.params, model: model, text: ns, options: options, location: call.location, name: call.name, refs: refs))
107 } 140 }
108 if let inline = try inlineAt(context.caret, tree: context.tree, model: model, text: ns) { 141 if let inline = try inlineAt(context.caret, tree: context.tree, model: model, text: ns) {
109 return plan(inline.block, params: inline.params, model: model, text: ns, options: options, location: inline.block.range, name: nil, inline: true) 142 return located(plan(inline.block, params: inline.params, model: model, text: ns, options: options, location: inline.block.range, name: nil, inline: true, refs: refs))
110 } 143 }
111 } catch let error as Failure { 144 } catch let error as Failure {
112 return .failure(error) 145 return .failure(error)
@@ -175,8 +208,22 @@ public enum Babel {
175 return (block, try Params(block: block, model: model, text: text, extra: extra, inline: true)) 208 return (block, try Params(block: block, model: model, text: text, extra: extra, inline: true))
176 } 209 }
177 210
211 /// What `:var` references resolve to while planning.
212 struct References {
213 var resolved: [String: VarValue]
214 var options: Options
215 /// Blocks being planned for a reference, to stop cycles.
216 var visiting: Set<String> = []
217 }
218
219 /// A reference to a block that has to run first.
220 struct Needed: Error {
221 let need: BabelJob.Need
222 }
223
178 static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options, 224 static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options,
179 location: Range<Int>, name: String?, inline: Bool = false) -> Result<BabelJob, Failure> { 225 location: Range<Int>, name: String?, inline: Bool = false, refs: References? = nil) -> Result<BabelJob, Failure> {
226 let refs = refs ?? References(resolved: [:], options: options)
180 guard let language = block.language else { return .failure(.message("This block has no language")) } 227 guard let language = block.language else { return .failure(.message("This block has no language")) }
181 let eval = params.single[":eval"]?.lowercased() 228 let eval = params.single[":eval"]?.lowercased()
182 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { 229 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
@@ -215,7 +262,14 @@ public enum Babel {
215 } 262 }
216 let vars: [(String, VarValue)] 263 let vars: [(String, VarValue)]
217 do { 264 do {
218 vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: ns)) } 265 vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: ns, refs: refs)) }
266 } catch let needed as Needed {
267 // Asked about once, with every block the references reach.
268 let texts = [ns.substring(with: NSRange(location))] + referencedBlocks(params, model: model, text: ns, seen: []).map { ns.substring(with: NSRange($0.range)) }
269 return .success(BabelJob(
270 language: language, runner: .process(executable: "", arguments: []), input: "", directory: params.single[":dir"],
271 block: location, blockText: texts.joined(separator: "\n"), alwaysAsk: eval == "query", finish: finishBase(.generic), needs: [needed.need]
272 ))
219 } catch let error as Failure { 273 } catch let error as Failure {
220 return .failure(error) 274 return .failure(error)
221 } catch { 275 } catch {
@@ -604,14 +658,23 @@ public enum Babel {
604 658
605 // MARK: - Variables 659 // MARK: - Variables
606 660
607 enum VarValue: Equatable { 661 /// A referenced block's result as a `:var` value.
662 static func varValue(_ finish: BabelJob.Finish, _ output: BabelOutput) -> VarValue {
663 switch value(finish, output) {
664 case .none: return .scalar("")
665 case .string(let s): return number(s).map(VarValue.number) ?? .scalar(s)
666 case .table(let rows): return .table(rows.compactMap { $0?.map(cell) })
667 }
668 }
669
670 enum VarValue: Equatable, Sendable {
608 case scalar(String) 671 case scalar(String)
609 case number(String) 672 case number(String)
610 case table([[Cell]]) 673 case table([[Cell]])
611 case list([Cell]) 674 case list([Cell])
612 } 675 }
613 676
614 enum Cell: Equatable { 677 enum Cell: Equatable, Sendable {
615 case text(String) 678 case text(String)
616 case number(String) 679 case number(String)
617 } 680 }
@@ -645,10 +708,46 @@ public enum Babel {
645 return number(t).map(Cell.number) ?? .text(t) 708 return number(t).map(Cell.number) ?? .text(t)
646 } 709 }
647 710
648 /// `org-babel-ref-resolve` for literals and named tables and lists in the file. 711 /// `name` or `name(args)` naming a src block in the file: the block and its call arguments.
649 static func resolve(_ value: String, model: DocumentModel, text: NSString) throws -> VarValue { 712 static func referencedBlock(_ value: String, model: DocumentModel) -> (block: SrcBlockInfo, arguments: String?)? {
713 guard let m = value.firstMatch(of: /^([^\s()\[\]"]+)(?:\((.*)\))?$/),
714 let block = model.srcBlocks.first(where: { $0.name == String(m.1) }) else { return nil }
715 return (block, m.2.map(String.init))
716 }
717
718 /// The blocks `params`' references run, directly or through theirs.
719 static func referencedBlocks(_ params: Params, model: DocumentModel, text: NSString, seen: Set<String>) -> [SrcBlockInfo] {
720 var found: [SrcBlockInfo] = []
721 var seen = seen
722 for variable in params.vars {
723 guard let (block, arguments) = referencedBlock(variable.value, model: model), let name = block.name, seen.insert(name).inserted else { continue }
724 found.append(block)
725 let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? []
726 if let inner = try? Params(block: block, model: model, text: text, extra: extra) {
727 found += referencedBlocks(inner, model: model, text: text, seen: seen)
728 }
729 }
730 return found
731 }
732
733 /// `org-babel-ref-resolve` for literals, named tables and lists in the file, and named src
734 /// blocks, which run first (`Needed`) and give their result.
735 static func resolve(_ value: String, model: DocumentModel, text: NSString, refs: References) throws -> VarValue {
650 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { return .scalar(Params.unescape(String(value.dropFirst().dropLast()))) } 736 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { return .scalar(Params.unescape(String(value.dropFirst().dropLast()))) }
651 if let n = number(value) { return .number(n) } 737 if let n = number(value) { return .number(n) }
738 if let known = refs.resolved[value] { return known }
739 if let (block, arguments) = referencedBlock(value, model: model), let name = block.name {
740 guard !refs.visiting.contains(name) else { throw Failure.message("\(name) refers to itself through :var") }
741 let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? []
742 var inner = refs
743 inner.visiting.insert(name)
744 switch plan(block, params: try Params(block: block, model: model, text: text, extra: extra), model: model, text: text,
745 options: refs.options, location: block.range, name: name, refs: inner) {
746 case .failure(let failure): throw failure
747 case .success(let job):
748 throw Needed(need: job.needs.first ?? BabelJob.Need(reference: value, job: job))
749 }
750 }
652 if value.contains("(") || value.contains("[") { throw Failure.message("References like \(value) aren't supported yet.") } 751 if value.contains("(") || value.contains("[") { throw Failure.message("References like \(value) aren't supported yet.") }
653 // A named element: `#+NAME: value` above a table or list. 752 // A named element: `#+NAME: value` above a table or list.
654 let pattern = "(?im)^[ \\t]*#\\+name:[ \\t]*" + NSRegularExpression.escapedPattern(for: value) + "[ \\t]*$" 753 let pattern = "(?im)^[ \\t]*#\\+name:[ \\t]*" + NSRegularExpression.escapedPattern(for: value) + "[ \\t]*$"
Sources/OrgCore/Compute/Tangle.swift +8 −1
@@ -186,7 +186,14 @@ public enum Tangle {
186 /// `org-babel-expand-body:LANG`, or the generic expansion with the language's variable 186 /// `org-babel-expand-body:LANG`, or the generic expansion with the language's variable
187 /// assignments. 187 /// assignments.
188 static func expandBody(_ body: String, language: String, params: Babel.Params, model: DocumentModel, text: NSString) throws -> String { 188 static func expandBody(_ body: String, language: String, params: Babel.Params, model: DocumentModel, text: NSString) throws -> String {
189 let vars = try params.vars.map { ($0.name, try Babel.resolve($0.value, model: model, text: text)) } 189 let refs = Babel.References(resolved: [:], options: Babel.Options())
190 let vars = try params.vars.map { variable in
191 do {
192 return (variable.name, try Babel.resolve(variable.value, model: model, text: text, refs: refs))
193 } catch is Babel.Needed {
194 throw Babel.Failure.message(":var \(variable.value) would run a block while tangling, which isn't supported yet; nothing was tangled.")
195 }
196 }
190 let prologue = params.single[":prologue"] 197 let prologue = params.single[":prologue"]
191 let epilogue = params.single[":epilogue"] 198 let epilogue = params.single[":epilogue"]
192 if language == "emacs-lisp" || language == "elisp" { 199 if language == "emacs-lisp" || language == "elisp" {
Tests/OrgCoreTests/BabelTests.swift +26 −1
@@ -41,7 +41,13 @@ enum TestBabelRunner {
41 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) 41 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
42 switch Babel.plan(context) { 42 switch Babel.plan(context) {
43 case .failure(.message(let message)): return (text, message) 43 case .failure(.message(let message)): return (text, message)
44 case .success(let job): 44 case .success(var job):
45 while let need = job.needs.first {
46 switch Babel.resume(job, need: need, output: try run(need.job, directory: directory), text: text, tree: context.tree) {
47 case .failure(.message(let message)): return (text, message)
48 case .success(let next): job = next
49 }
50 }
45 let output = try run(job, directory: directory) 51 let output = try run(job, directory: directory)
46 if let file = Babel.fileResult(job, output: output) { 52 if let file = Babel.fileResult(job, output: output) {
47 try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8) 53 try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8)
@@ -112,6 +118,14 @@ struct BabelTests {
112 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n", 118 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n",
113 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n", 119 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n",
114 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n", 120 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n",
121 // `:var` from other blocks' results.
122 "#+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",
123 "#+begin_src python :var t=tbl\nreturn [len(t), t[1][0]]\n#+end_src\n\n#+NAME: tbl\n#+begin_src python\nreturn [[1, 2], [3, 4]]\n#+end_src\n",
124 "#+begin_src sh :var x=double(n=4)\necho $x\n#+end_src\n\n#+NAME: double\n#+begin_src sh :var n=2\necho $((n*2))\n#+end_src\n",
125 "#+begin_src python :var a=outer\nreturn a * 2\n#+end_src\n\n#+NAME: outer\n#+begin_src python :var b=inner\nreturn b + 1\n#+end_src\n\n#+NAME: inner\n#+begin_src python\nreturn 20\n#+end_src\n",
126 "#+begin_src emacs-lisp :var s=greet\n(concat s \"!\")\n#+end_src\n\n#+NAME: greet\n#+begin_src emacs-lisp\n\"hi\"\n#+end_src\n",
127 "#+begin_src python :var s=words\nreturn s.upper()\n#+end_src\n\n#+NAME: words\n#+begin_src sh :results output\nprintf 'two words'\n#+end_src\n",
128 "#+begin_src emacs-lisp :var rows=pairs\n(length rows)\n#+end_src\n\n#+NAME: pairs\n#+begin_src sh\nprintf 'a 1\\nb 2\\nc 3\\n'\n#+end_src\n",
115 ] 129 ]
116 130
117 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 131 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
@@ -139,6 +153,16 @@ struct BabelTests {
139 } 153 }
140 } 154 }
141 155
156 @Test func asksOnceForEveryReferencedBlock() throws {
157 let text = BabelTests.cases.first { $0.contains(":var a=outer") }!
158 guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {
159 Issue.record("not planned")
160 return
161 }
162 #expect(job.needs.first?.reference == "inner")
163 #expect(job.blockText.contains("return a * 2") && job.blockText.contains("return b + 1") && job.blockText.contains("return 20"))
164 }
165
142 @Test func refusesWhatWouldRunDifferently() { 166 @Test func refusesWhatWouldRunDifferently() {
143 for (text, message) in [ 167 for (text, message) in [
144 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."), 168 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."),
@@ -146,6 +170,7 @@ struct BabelTests {
146 ("#+begin_src sh :noweb yes\n<<x()>>\n#+end_src\n", "Noweb references that run a block (x()) aren't supported yet; nothing was run."), 170 ("#+begin_src sh :noweb yes\n<<x()>>\n#+end_src\n", "Noweb references that run a block (x()) aren't supported yet; nothing was run."),
147 ("#+begin_src sh :cmdline -x\necho\n#+end_src\n", ":cmdline isn't supported yet; nothing was run."), 171 ("#+begin_src sh :cmdline -x\necho\n#+end_src\n", ":cmdline isn't supported yet; nothing was run."),
148 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."), 172 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
173 ("#+begin_src sh :var x=loop\necho\n#+end_src\n#+NAME: loop\n#+begin_src sh :var y=loop\necho\n#+end_src\n", "loop refers to itself through :var"),
149 ] { 174 ] {
150 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2]) 175 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
151 guard case .failure(.message(let got)) = Babel.plan(context) else { 176 guard case .failure(.message(let got)) = Babel.plan(context) else {