krz/orgstar

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

Commit 2f73c9b694

2f73c9b694eb3141a4f93e86536192f09b879eb6

parent: d84591dd44

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 01:04 UTC

Babel tables and lists in shell :var

As org-babel-variable-assignments:shell: bash gets an indexed array for a
list and an associative array for a table of two or more columns, fish
gets set, other shells a quoted string with :separator and :hline-string.
The :cache hash uses the user's shell, as Emacs does.

Layout: unified · split

Sources/OrgCore/Compute/Babel.swift +43 −26
@@ -326,7 +326,7 @@ public enum Babel {
326326 if useCache, !inline, params.single[":cache"] == "yes" {
327327 do {
328328 let state = try cacheState(language: language, params: params, tables: tables, body: body, location: location, name: name,
329 call: location != block.range, text: ns)
329 call: location != block.range, text: ns, options: options)
330330 if state.current { return .failure(.message(cachedMessage(state.value))) }
331331 // Appended or prepended results accumulate, so they carry no hash.
332332 if params.results.contains("replace") { hash = state.hash }
@@ -342,16 +342,8 @@ public enum Babel {
342342
343343 if shells.contains(language) {
344344 let valueIsExitStatus = params.results.contains("value")
345 var lines: [String] = []
346 for (name, value) in vars {
347 let s: String
348 switch value {
349 case .scalar(let v), .number(let v): s = v
350 default: return .failure(.message("Tables and lists in :var aren't supported for \(language) yet."))
351 }
352 lines.append("\(name)='" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'")
353 }
354345 let executable = language == "shell" ? options.shell : language
346 let lines = shellAssignments(vars, shell: executable, params: params)
355347 let cmdline = params.single[":cmdline"]
356348 let shebang = params.single[":shebang"].flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil }
357349 if session == nil, stdin != nil || cmdline != nil || shebang != nil {
@@ -363,7 +355,7 @@ public enum Babel {
363355 let command = Babel.scriptFileToken + (stdin != nil || cmdline != nil ? " " + (cmdline ?? "") : "")
364356 var job = BabelJob(
365357 language: language, runner: .process(executable: executable, arguments: ["-c", command]),
366 input: stdin.map(shellString) ?? "", directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk,
358 input: stdin.map { shellString($0) } ?? "", directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk,
367359 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus))
368360 )
369361 job.script = script
@@ -682,9 +674,11 @@ public enum Babel {
682674 /// `:cache yes` (`org-babel-execute-src-block`): the block's hash, and whether its results
683675 /// already carry it, with their value when it can be read.
684676 static func cacheState(language: String, params: Params, tables: Tables, body: String, location: Range<Int>, name: String?,
685 call: Bool, text: NSString) throws -> (hash: String, current: Bool, value: VarValue?) {
677 call: Bool, text: NSString, options: Options) throws -> (hash: String, current: Bool, value: VarValue?) {
678 // The hash is taken outside the shell's own binding of `shell-file-name`, so its
679 // variables are written for the user's shell whatever the block's language.
686680 let input = try cacheInput(language: language, params: params, tables: tables,
687 expanded: expandedBody(language: language, vars: tables.vars, body: body), call: call)
681 expanded: expandedBody(language: language, vars: tables.vars, body: body, shell: options.shell, params: params), call: call)
688682 let hash = Insecure.SHA1.hash(data: Data(input.utf8)).map { String(format: "%02x", $0) }.joined()
689683 guard let line = existingResults(name: name, block: location, text: text), resultsHash(at: line, text: text) == hash else {
690684 return (hash, false, nil)
@@ -697,10 +691,9 @@ public enum Babel {
697691 }
698692
699693 /// The body with its variables, as the language's `org-babel-expand-body:` writes it.
700 static func expandedBody(language: String, vars: [(String, VarValue)], body: String) -> String {
694 static func expandedBody(language: String, vars: [(String, VarValue)], body: String, shell: String, params: Params) -> String {
701695 if shells.contains(language) {
702 let lines = vars.map { "\($0.0)='" + shellString($0.1).replacingOccurrences(of: "'", with: "'\"'\"'") + "'" }
703 return (lines + [body]).joined(separator: "\n")
696 return (shellAssignments(vars, shell: shell, params: params) + [body]).joined(separator: "\n")
704697 }
705698 if language == "python" {
706699 return (vars.map { "\($0.0)=\(pythonLiteral($0.1))" } + [body]).joined(separator: "\n")
@@ -1073,7 +1066,7 @@ public enum Babel {
10731066 if params.single[":cache"] == "yes", let language = block.language {
10741067 let tables = try disassemble(params.vars.map { ($0.name, try resolve($0.value, model: model, text: text, refs: inner)) }, params: params)
10751068 let body = nowebEvaluates(params.single[":noweb"]) ? try expandNoweb(block, params: params, model: model, text: text) : normalizedBody(block)
1076 let state = try cacheState(language: language, params: params, tables: tables, body: body, location: block.range, name: name, call: false, text: text)
1069 let state = try cacheState(language: language, params: params, tables: tables, body: body, location: block.range, name: name, call: false, text: text, options: refs.options)
10771070 if state.current {
10781071 if let value = state.value { return value }
10791072 useCache = false
@@ -1127,17 +1120,41 @@ public enum Babel {
11271120
11281121 /// `org-babel-sh-var-to-string`: a table's rows as tab-separated lines, a list's items as
11291122 /// lines.
1130 static func shellString(_ value: VarValue) -> String {
1131 func cell(_ c: Cell) -> String {
1132 switch c {
1133 case .number(let n): n
1134 case .text(let s): s
1135 }
1136 }
1123 static func shellString(_ value: VarValue, separator: String? = nil, hline: String? = nil) -> String {
11371124 switch value {
11381125 case .scalar(let s), .number(let s): return s
1139 case .list(let items): return items.map(cell).joined(separator: "\n")
1140 case .table(let rows): return rows.compactMap { $0?.map(cell).joined(separator: "\t") }.joined(separator: "\n")
1126 case .list(let items): return items.map(cellText).joined(separator: "\n")
1127 case .table(let rows): return rows.compactMap { $0?.map(cellText).joined(separator: separator ?? "\t") ?? hline }.joined(separator: "\n")
1128 }
1129 }
1130
1131 /// `org-babel-variable-assignments:shell`, by the shell's name: for bash, an array for a
1132 /// list and an associative array for a table of two or more columns; `set` for fish; a
1133 /// quoted string otherwise.
1134 static func shellAssignments(_ vars: [(String, VarValue)], shell: String, params: Params) -> [String] {
1135 let separator = params.single[":separator"]
1136 let hline = params.single[":hlines"] == "yes" ? params.single[":hline-string"] ?? "hline" : nil
1137 func quoted(_ value: VarValue) -> String {
1138 "'" + shellString(value, separator: separator, hline: hline).replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
1139 }
1140 return vars.map { name, value in
1141 if shell.hasSuffix("bash") {
1142 switch value {
1143 case .table(let rows) where (rows.first ?? nil).map({ $0.count >= 2 }) ?? false:
1144 let entries = rows.compactMap { $0 }.map { row in
1145 "\(name)[\(quoted(.scalar(cellText(row[0]))))]=\(quoted(.list(Array(row.dropFirst()))))"
1146 }
1147 return (["unset \(name)", "declare -A \(name)"] + entries).joined(separator: "\n")
1148 case .table(let rows):
1149 let items = rows.map { $0.map { quoted(.list($0)) } ?? quoted(.scalar("hline")) }
1150 return "unset \(name)\ndeclare -a \(name)=( \(items.joined(separator: " ")) )"
1151 case .list(let items):
1152 return "unset \(name)\ndeclare -a \(name)=( \(items.map { quoted(.scalar(cellText($0))) }.joined(separator: " ")) )"
1153 case .scalar, .number:
1154 break
1155 }
1156 }
1157 return (shell.hasSuffix("fish") ? "set \(name) " : "\(name)=") + quoted(value)
11411158 }
11421159 }
11431160
Tests/OrgCoreTests/BabelCacheTests.swift +4
@@ -29,6 +29,10 @@ struct BabelCacheTests {
2929 Case(text: "#+PROPERTY: header-args :cache yes\n#+begin_src sh :session none\necho prop\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"]),
3030 Case(text: "#+NAME: tbl\n| a | b |\n|---+---|\n| 1 | x |\n\n#+begin_src python :cache yes :var t=tbl :colnames yes :rownames yes\nreturn t\n#+end_src\n",
3131 runs: ["#+begin_src", "#+begin_src"]),
32 Case(text: "#+NAME: kv\n| a | 1 |\n| b | 2 |\n\n#+begin_src bash :cache yes :var t=kv :var l='(1 2)\necho ${t[a]} ${l[1]}\n#+end_src\n",
33 runs: ["#+begin_src", "#+begin_src"]),
34 Case(text: "#+NAME: kv\n| a | 1 |\n| b | 2 |\n\n#+begin_src sh :cache yes :var t=kv :separator ,\necho $t\n#+end_src\n",
35 runs: ["#+begin_src", "#+begin_src"]),
3236 // Edited after the first run, so it runs again.
3337 Case(text: "#+begin_src sh :cache yes\necho one\n#+end_src\n", runs: ["#+begin_src", "#+begin_src"], edits: [("echo one", "echo two")]),
3438 // A reference to a cached block takes its result without running it.
Tests/OrgCoreTests/BabelTests.swift +12
@@ -177,6 +177,18 @@ struct BabelTests {
177177 "MULTI#+begin_src emacs-lisp :var tb=multi :hlines yes\ntb\n#+end_src\n",
178178 "MULTI#+begin_src sh :stdin multi :results output\ncat\n#+end_src\n",
179179 "#+begin_src python :var t=tb2 :colnames yes\nreturn t\n#+end_src\n\n#+NAME: tb2\n#+begin_src python\nreturn [['h1', 'h2'], None, [1, 2]]\n#+end_src\n",
180 // Tables and lists in shell variables.
181 "TBL#+begin_src sh :var t=tbl :results output\necho \"$t\"\n#+end_src\n",
182 "MULTI#+begin_src sh :var t=multi :hlines yes :separator , :results output\necho \"$t\"\n#+end_src\n",
183 "MULTI#+begin_src sh :var t=multi :hlines yes :hline-string -- :results output\necho \"$t\"\n#+end_src\n",
184 "#+NAME: items\n- one\n- it's\n\n#+begin_src sh :var l=items :results output\necho \"$l\"\n#+end_src\n",
185 "#+NAME: items\n- one\n- it's\n\n#+begin_src bash :var l=items :results output\necho ${#l[@]} \"${l[1]}\"\n#+end_src\n",
186 "#+NAME: kv\n| a | 1 |\n| b | 2 |\n\n#+begin_src bash :var t=kv :results output\necho ${t[a]} ${t[b]} ${#t[@]}\n#+end_src\n",
187 "#+NAME: kv3\n| a | 1 | x |\n| b | 2 | y |\n\n#+begin_src bash :var t=kv3 :results output\necho \"${t[b]}\"\n#+end_src\n",
188 "#+NAME: col\n| 5 |\n| 6 |\n\n#+begin_src bash :var t=col :results output\necho ${t[@]}\n#+end_src\n",
189 "#+begin_src bash :var l='(1 \"two\" 3) :results output\necho ${l[2]} ${l[1]}\n#+end_src\n",
190 "#+NAME: items\n- one\n- two\n\n#+begin_src zsh :var l=items :results output\necho \"$l\"\n#+end_src\n",
191 "TBL#+begin_src shell :var t=tbl :results output\necho \"$t\"\n#+end_src\n",
180192 // Lisp values.
181193 "#+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",
182194 "#+begin_src emacs-lisp :var l='(1 2) h='((1 2) hline (3 4)) s=(concat \"a\" \"b\")\n(list l h s)\n#+end_src\n",