Run shell blocks with :cmdline, :stdin and :shebang !136

merged merged by cmc on 2026-10-07 07:59 UTC · krz/orgstar:shell-cmdline into main

3 files changed, +69 −9

Layout: unified · split

Sources/OrgApp/BabelRunner.swift +7 −1
@@ -34,7 +34,13 @@ public enum BabelRunner {
34 process.environment = environment 34 process.environment = environment
35 let stdin = Pipe() 35 let stdin = Pipe()
36 switch job.runner { 36 switch job.runner {
37 case .process(let executable, let arguments): 37 case .process(let executable, var arguments):
38 if let script = job.script {
39 let file = folder.appendingPathComponent("script")
40 try script.write(to: file, atomically: true, encoding: .utf8)
41 try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path)
42 arguments = arguments.map { $0.replacingOccurrences(of: Babel.scriptFileToken, with: file.path) }
43 }
38 process.arguments = [executable] + arguments 44 process.arguments = [executable] + arguments
39 case .emacs: 45 case .emacs:
40 guard let emacs = EmacsRunner.executable() else { throw Failure(description: "Emacs isn't installed, so Emacs Lisp blocks can't run.") } 46 guard let emacs = EmacsRunner.executable() else { throw Failure(description: "Emacs isn't installed, so Emacs Lisp blocks can't run.") }
Sources/OrgCore/Compute/Babel.swift +46 −6
@@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable {
36 var resolved: [String: Babel.VarValue] = [:] 36 var resolved: [String: Babel.VarValue] = [:]
37 var caret = 0 37 var caret = 0
38 var options = Babel.Options() 38 var options = Babel.Options()
39 /// A script for the runner to write to an executable file, for `Babel.scriptFileToken`.
40 public internal(set) var script: String?
39 /// An Emacs Lisp block's form, for `Babel.evaluateNatively`. 41 /// An Emacs Lisp block's form, for `Babel.evaluateNatively`.
40 public internal(set) var lispForm: String? 42 public internal(set) var lispForm: String?
41 43
@@ -91,6 +93,8 @@ public struct BabelOutput: Sendable, Equatable {
91 93
92public enum Babel { 94public enum Babel {
93 public static let resultFileToken = "__ORGSTAR_RESULT_FILE__" 95 public static let resultFileToken = "__ORGSTAR_RESULT_FILE__"
96 /// Stands, in a job's arguments, for the executable file the runner writes `job.script` to.
97 public static let scriptFileToken = "__ORGSTAR_SCRIPT_FILE__"
94 static let shells: Set<String> = ["sh", "bash", "zsh", "shell", "fish", "ksh", "dash", "ash", "csh", "mksh", "posh"] 98 static let shells: Set<String> = ["sh", "bash", "zsh", "shell", "fish", "ksh", "dash", "ash", "csh", "mksh", "posh"]
95 static let generic: [String: (String, [String])] = [ 99 static let generic: [String: (String, [String])] = [
96 "ruby": ("ruby", []), "js": ("node", []), "javascript": ("node", []), "R": ("Rscript", ["-"]), 100 "ruby": ("ruby", []), "js": ("node", []), "javascript": ("node", []), "R": ("Rscript", ["-"]),
@@ -253,10 +257,11 @@ public enum Babel {
253 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { 257 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
254 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled.")) 258 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
255 } 259 }
256 if params.single[":cmdline"] != nil, !["dot", "plantuml", "mermaid"].contains(language) { 260 if params.single[":cmdline"] != nil, !["dot", "plantuml", "mermaid"].contains(language), !shells.contains(language) {
257 return .failure(.message(":cmdline isn't supported yet; nothing was run.")) 261 return .failure(.message(":cmdline isn't supported for \(language) yet; nothing was run."))
258 } 262 }
259 for key in [":prologue", ":epilogue", ":post", ":stdin", ":shebang"] where params.single[key] != nil { 263 for key in [":prologue", ":epilogue", ":post", ":stdin", ":shebang"] where params.single[key] != nil
264 && !(shells.contains(language) && [":stdin", ":shebang"].contains(key)) {
260 return .failure(.message("\(key) isn't supported yet; nothing was run.")) 265 return .failure(.message("\(key) isn't supported yet; nothing was run."))
261 } 266 }
262 let session = params.single[":session"].flatMap { $0 == "none" ? nil : ($0.isEmpty ? "*\(language)*" : $0) } 267 let session = params.single[":session"].flatMap { $0 == "none" ? nil : ($0.isEmpty ? "*\(language)*" : $0) }
@@ -285,8 +290,10 @@ public enum Babel {
285 file: toFile ? file : nil, fileDescription: params.single[":file-desc"]) 290 file: toFile ? file : nil, fileDescription: params.single[":file-desc"])
286 } 291 }
287 let vars: [(String, VarValue)] 292 let vars: [(String, VarValue)]
293 var stdin: VarValue?
288 do { 294 do {
289 vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: ns, refs: refs)) } 295 vars = try params.vars.map { ($0.name, try resolve($0.value, model: model, text: ns, refs: refs)) }
296 stdin = try params.single[":stdin"].map { try resolve($0, model: model, text: ns, refs: refs) }
290 } catch let needed as Needed { 297 } catch let needed as Needed {
291 // Asked about once, with every block the references reach. 298 // Asked about once, with every block the references reach.
292 let texts = [ns.substring(with: NSRange(location))] + referencedBlocks(params, model: model, text: ns, seen: []).map { ns.substring(with: NSRange($0.range)) } 299 let texts = [ns.substring(with: NSRange(location))] + referencedBlocks(params, model: model, text: ns, seen: []).map { ns.substring(with: NSRange($0.range)) }
@@ -314,8 +321,25 @@ public enum Babel {
314 } 321 }
315 lines.append("\(name)='" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'") 322 lines.append("\(name)='" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'")
316 } 323 }
317 let full = (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
318 let executable = language == "shell" ? options.shell : language 324 let executable = language == "shell" ? options.shell : language
325 let cmdline = params.single[":cmdline"]
326 let shebang = params.single[":shebang"].flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil }
327 if session == nil, stdin != nil || cmdline != nil || shebang != nil {
328 // `org-babel-sh-evaluate`: an executable script, run by the shell with the
329 // command line after it and `:stdin` as its input; the value is its last line.
330 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n"
331 if params.single[":padline"] != "no" { script += "\n" }
332 script += (lines + [body]).joined(separator: "\n")
333 let command = Babel.scriptFileToken + (stdin != nil || cmdline != nil ? " " + (cmdline ?? "") : "")
334 var job = BabelJob(
335 language: language, runner: .process(executable: executable, arguments: ["-c", command]),
336 input: stdin.map(shellString) ?? "", directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk,
337 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus))
338 )
339 job.script = script
340 return .success(job)
341 }
342 let full = (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
319 return .success(BabelJob( 343 return .success(BabelJob(
320 language: language, runner: session.map { .session(name: $0, executable: executable, python: false) } ?? .process(executable: executable, arguments: []), 344 language: language, runner: session.map { .session(name: $0, executable: executable, python: false) } ?? .process(executable: executable, arguments: []),
321 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir, 345 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir,
@@ -756,8 +780,8 @@ public enum Babel {
756 static func referencedBlocks(_ params: Params, model: DocumentModel, text: NSString, seen: Set<String>) -> [SrcBlockInfo] { 780 static func referencedBlocks(_ params: Params, model: DocumentModel, text: NSString, seen: Set<String>) -> [SrcBlockInfo] {
757 var found: [SrcBlockInfo] = [] 781 var found: [SrcBlockInfo] = []
758 var seen = seen 782 var seen = seen
759 for variable in params.vars { 783 for value in params.vars.map(\.value) + [params.single[":stdin"]].compactMap({ $0 }) {
760 guard let (block, arguments) = referencedBlock(variable.value, model: model), let name = block.name, seen.insert(name).inserted else { continue } 784 guard let (block, arguments) = referencedBlock(value, model: model), let name = block.name, seen.insert(name).inserted else { continue }
761 found.append(block) 785 found.append(block)
762 let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? [] 786 let extra = arguments.map { [splitArguments($0).map { (":var", $0) }] } ?? []
763 if let inner = try? Params(block: block, model: model, text: text, extra: extra) { 787 if let inner = try? Params(block: block, model: model, text: text, extra: extra) {
@@ -820,6 +844,22 @@ public enum Babel {
820 throw Failure.message("\(value) isn't a table or list.") 844 throw Failure.message("\(value) isn't a table or list.")
821 } 845 }
822 846
847 /// `org-babel-sh-var-to-string`: a table's rows as tab-separated lines, a list's items as
848 /// lines.
849 static func shellString(_ value: VarValue) -> String {
850 func cell(_ c: Cell) -> String {
851 switch c {
852 case .number(let n): n
853 case .text(let s): s
854 }
855 }
856 switch value {
857 case .scalar(let s), .number(let s): return s
858 case .list(let items): return items.map(cell).joined(separator: "\n")
859 case .table(let rows): return rows.map { $0.map(cell).joined(separator: "\t") }.joined(separator: "\n")
860 }
861 }
862
823 /// `org-babel-python-var-to-python`. 863 /// `org-babel-python-var-to-python`.
824 static func pythonLiteral(_ value: VarValue) -> String { 864 static func pythonLiteral(_ value: VarValue) -> String {
825 func cell(_ c: Cell) -> String { 865 func cell(_ c: Cell) -> String {
Tests/OrgCoreTests/BabelTests.swift +16 −2
@@ -15,7 +15,13 @@ enum TestBabelRunner {
15 let process = Process() 15 let process = Process()
16 process.executableURL = URL(fileURLWithPath: "/usr/bin/env") 16 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
17 switch job.runner { 17 switch job.runner {
18 case .process(let executable, let arguments): 18 case .process(let executable, var arguments):
19 if let script = job.script {
20 let file = folder.appendingPathComponent("script")
21 try script.write(to: file, atomically: true, encoding: .utf8)
22 try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path)
23 arguments = arguments.map { $0.replacingOccurrences(of: Babel.scriptFileToken, with: file.path) }
24 }
19 process.arguments = [executable] + arguments 25 process.arguments = [executable] + arguments
20 case .emacs: 26 case .emacs:
21 let script = folder.appendingPathComponent("babel.el") 27 let script = folder.appendingPathComponent("babel.el")
@@ -132,6 +138,14 @@ struct BabelTests {
132 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n", 138 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n",
133 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n", 139 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n",
134 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n", 140 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n",
141 // `:cmdline`, `:stdin` and `:shebang`.
142 "#+begin_src sh :cmdline one \"two words\" :results output\nfor a in \"$@\"; do echo \"[$a]\"; done\n#+end_src\n",
143 "#+begin_src sh :cmdline 3\necho $(( $1 * 2 ))\n#+end_src\n",
144 "#+NAME: rows\n| a | 1 |\n| b | 2 |\n\n#+begin_src sh :stdin rows :results output\nwhile IFS=$(printf '\\t') read -r k v; do echo \"$k=$v\"; done\n#+end_src\n",
145 "#+NAME: items\n- x\n- y\n\n#+begin_src sh :stdin items\nwc -l | tr -d ' '\n#+end_src\n",
146 "#+begin_src bash :shebang #!/bin/bash :results output\necho ${BASH_VERSION:+bash}\n#+end_src\n",
147 "#+begin_src sh :cmdline a b :padline no :var x=5 :results output\necho $x $#\n#+end_src\n",
148 "#+begin_src sh :stdin src :results output\ntr a-z A-Z\n#+end_src\n\n#+NAME: src\n#+begin_src sh\necho hello\n#+end_src\n",
135 // `:var` from other blocks' results. 149 // `:var` from other blocks' results.
136 "#+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", 150 "#+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",
137 "#+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", 151 "#+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",
@@ -231,7 +245,7 @@ struct BabelTests {
231 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."), 245 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."),
232 ("#+begin_src ruby :session s\nputs 1\n#+end_src\n", ":session isn't supported for ruby yet; nothing was run."), 246 ("#+begin_src ruby :session s\nputs 1\n#+end_src\n", ":session isn't supported for ruby yet; nothing was run."),
233 ("#+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."), 247 ("#+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."),
234 ("#+begin_src sh :cmdline -x\necho\n#+end_src\n", ":cmdline isn't supported yet; nothing was run."), 248 ("#+begin_src python :cmdline -x\nreturn 1\n#+end_src\n", ":cmdline isn't supported for python yet; nothing was run."),
235 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."), 249 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
236 ("#+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"), 250 ("#+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"),
237 ] { 251 ] {