krz/orgstar

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

Tests/OrgCoreTests/BabelTests.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Tests/OrgCoreTests/BabelTests.swift history · blame · raw

481 lines · 30557 bytes

  1import Foundation
  2import Testing
  3@testable import OrgCore
  4
  5/// Runs a Babel job the way the app does, for tests.
  6struct NativeUnsupported: Error {}
  7
  8enum TestBabelRunner {
  9    static func run(_ job: BabelJob, directory: URL) throws -> BabelOutput {
 10        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
 11        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
 12        defer { try? FileManager.default.removeItem(at: folder) }
 13        let resultFile = folder.appendingPathComponent("result")
 14        let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
 15        let process = Process()
 16        process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
 17        switch job.runner {
 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            }
 25            process.arguments = [executable] + arguments
 26        case .emacs:
 27            let script = folder.appendingPathComponent("babel.el")
 28            try input.write(to: script, atomically: true, encoding: .utf8)
 29            process.arguments = [EmacsOracle.executable, "-Q", "--batch", "-l", script.path]
 30        case .session:
 31            return BabelOutput(stdout: "")
 32        }
 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 }
 35        let stdin = Pipe()
 36        let stdout = Pipe()
 37        process.standardInput = stdin
 38        process.standardOutput = stdout
 39        process.standardError = FileHandle.nullDevice
 40        try process.run()
 41        if case .process = job.runner { stdin.fileHandleForWriting.write(Data(input.utf8)) }
 42        try stdin.fileHandleForWriting.close()
 43        let data = stdout.fileHandleForReading.readDataToEndOfFile()
 44        process.waitUntilExit()
 45        return BabelOutput(stdout: String(decoding: data, as: UTF8.self), resultFile: try? String(contentsOf: resultFile, encoding: .utf8))
 46    }
 47
 48    /// Plans, runs and finishes the block at `caret`: the new text, or the failure.
 49    /// `native` runs Emacs Lisp blocks with `Babel.evaluateNatively` instead of Emacs.
 50    static func execute(_ text: String, caret: Int, directory: URL, native: Bool = false) throws -> (text: String, failure: String?) {
 51        func run(_ job: BabelJob, directory: URL) throws -> BabelOutput {
 52            guard native else { return try TestBabelRunner.run(job, directory: directory) }
 53            guard let output = try Babel.evaluateNatively(job) else { throw NativeUnsupported() }
 54            return output
 55        }
 56        let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
 57        switch Babel.plan(context) {
 58        case .failure(.message(let message)): return (text, message)
 59        case .success(var job):
 60            while let need = job.needs.first {
 61                switch Babel.resume(job, need: need, output: try run(need.job, directory: directory), text: text, tree: context.tree) {
 62                case .failure(.message(let message)): return (text, message)
 63                case .success(let next): job = next
 64                }
 65            }
 66            let output: BabelOutput
 67            do {
 68                output = try run(job, directory: directory)
 69            } catch let failure as Babel.Failure {
 70                guard case .message(let message) = failure else { return (text, nil) }
 71                return (text, message)
 72            }
 73            if let file = Babel.fileResult(job, output: output) {
 74                try file.contents.write(to: directory.appendingPathComponent(file.path), atomically: true, encoding: .utf8)
 75            }
 76            switch Babel.finish(job, output: output, text: text) {
 77            case .failure(.message(let message)): return (text, message)
 78            case .success(let edits):
 79                var new = text
 80                for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
 81                return (new, nil)
 82            }
 83        }
 84    }
 85}
 86
 87struct BabelTests {
 88    static let cases: [String] = rawCases.map {
 89        $0.replacingOccurrences(of: "TBL#+begin_src", with: "#+NAME: tbl\n| a | b |\n|---+---|\n| 1 | x |\n| 2 | y |\n\n#+begin_src")
 90            .replacingOccurrences(of: "MULTI#+begin_src", with: "#+NAME: multi\n|---+---|\n| a | b |\n|---+---|\n| 1 | x |\n|---+---|\n| 2 | y |\n\n#+begin_src")
 91    }
 92
 93    static let rawCases: [String] = [
 94        "#+begin_src sh\necho hello\n#+end_src\n",
 95        "#+begin_src sh\necho hello\n#+end_src\nNext paragraph.\n",
 96        "#+begin_src sh\necho hello\n#+end_src\n\nNext paragraph.\n",
 97        "#+begin_src sh\necho hello\n#+end_src",
 98        "#+begin_src sh\necho one; echo two\n#+end_src\n",
 99        "#+begin_src sh\nprintf 'a b c\\nd e f\\n'\n#+end_src\n",
100        "#+begin_src sh\nprintf 'a\\tb\\nc\\td\\n'\n#+end_src\n",
101        "#+begin_src sh\nprintf '1,2\\n3,4\\n'\n#+end_src\n",
102        "#+begin_src sh :results output\nprintf 'a b c\\nd e f\\n'\n#+end_src\n",
103        "#+begin_src sh :results output\nseq 1 12\n#+end_src\n",
104        "#+begin_src sh :results output\nprintf '* not a heading\\n#+not a keyword\\n'\nseq 1 9\n#+end_src\n",
105        "#+begin_src sh :results value\necho hi\n#+end_src\n",
106        "#+begin_src sh :results verbatim\necho 007\n#+end_src\n",
107        "#+begin_src sh\necho 007\n#+end_src\n",
108        "#+begin_src sh :results raw\necho '*bold*'\n#+end_src\n",
109        "#+begin_src sh :results drawer\necho in a drawer\n#+end_src\n",
110        "#+begin_src sh :results list\nprintf 'a\\nb\\n'\n#+end_src\n",
111        "#+begin_src sh :results silent\necho quiet\n#+end_src\n",
112        "#+begin_src sh :results output code\necho 'x=1'\n#+end_src\n",
113        "#+begin_src sh :wrap example\necho wrapped\n#+end_src\n",
114        "#+begin_src sh\necho new\n#+end_src\n\n#+RESULTS:\n: old\n\nAfter.\n",
115        "#+begin_src sh\necho new\n#+end_src\n#+RESULTS:\n| old | table |\n| a   | b     |\nAfter.\n",
116        "#+begin_src sh :results append\necho more\n#+end_src\n\n#+RESULTS:\n: first\n",
117        "#+NAME: greet\n#+begin_src sh\necho named\n#+end_src\n\nText.\n\n#+RESULTS: greet\n: stale\n",
118        "#+NAME: fresh\n#+begin_src sh\necho named\n#+end_src\n",
119        "- item\n  #+begin_src sh\n  echo indented\n  #+end_src\n",
120        "#+begin_src sh :var x=5 :var y=\"two words\"\necho $x $y\n#+end_src\n",
121        "#+begin_src bash\necho ${BASH_VERSION:+bash}\n#+end_src\n",
122        "#+begin_src sh :results output\n#+end_src\n",
123        "#+begin_src sh\nprintf ''\n#+end_src\n",
124        "#+begin_src python\nreturn 42\n#+end_src\n",
125        "#+begin_src python\nreturn 'hello'\n#+end_src\n",
126        "#+begin_src python\nreturn [[1, 2], [3, 4]]\n#+end_src\n",
127        "#+begin_src python\nreturn [1, 'a', 2.5]\n#+end_src\n",
128        "#+begin_src python\nreturn 1/3\n#+end_src\n",
129        "#+begin_src python\nx = 2\nif x:\n    return x * 21\n#+end_src\n",
130        "#+begin_src python :results output\nprint('one')\nprint('two')\n#+end_src\n",
131        "#+begin_src python :results output\nfor i in range(12): print(i)\n#+end_src\n",
132        "#+begin_src python :var n=3 :var s=\"hi\"\nreturn s * n\n#+end_src\n",
133        "#+NAME: nums\n| 1 | 2 |\n| 3 | 4 |\n\n#+begin_src python :var t=nums\nreturn [[c * 10 for c in r] for r in t]\n#+end_src\n",
134        "#+begin_src python :results list\nreturn ['a', 'b']\n#+end_src\n",
135        "#+begin_src python :results verbatim\nreturn [1, 2]\n#+end_src\n",
136        "#+begin_src python\ns = '''multi\n  line'''\nreturn s\n#+end_src\n",
137        "#+begin_src python\nreturn None\n#+end_src\n",
138        "#+begin_src emacs-lisp\n(+ 1 2)\n#+end_src\n",
139        "#+begin_src emacs-lisp\n'((1 2) (3 4))\n#+end_src\n",
140        "#+begin_src elisp\n(concat \"a\" \"b\")\n#+end_src\n",
141        "#+begin_src emacs-lisp :results verbatim\n\"quoted\"\n#+end_src\n",
142        "#+begin_src emacs-lisp :results output\n(princ \"printed\")\n#+end_src\n",
143        "#+begin_src emacs-lisp :var x=4\n(* x x)\n#+end_src\n",
144        "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n",
145        "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n",
146        "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src python\nreturn [[1, 2]]\n#+end_src\n",
147        "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n",
148        // `:cmdline`, `:stdin` and `:shebang`.
149        "#+begin_src sh :cmdline one \"two words\" :results output\nfor a in \"$@\"; do echo \"[$a]\"; done\n#+end_src\n",
150        "#+begin_src sh :cmdline 3\necho $(( $1 * 2 ))\n#+end_src\n",
151        "#+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",
152        "#+NAME: items\n- x\n- y\n\n#+begin_src sh :stdin items\nwc -l | tr -d ' '\n#+end_src\n",
153        "#+begin_src bash :shebang #!/bin/bash :results output\necho ${BASH_VERSION:+bash}\n#+end_src\n",
154        "#+begin_src sh :cmdline a b :padline no :var x=5 :results output\necho $x $#\n#+end_src\n",
155        "#+NAME: in\n| hello |\n\n#+begin_src sh :results value :stdin in\ncat\nfalse\n#+end_src\n",
156        "#+begin_src sh :results value :cmdline x\necho one\n#+end_src\n",
157        "#+begin_src bash :shebang #!/bin/bash :cmdline \"a b\" c :results output\necho $#\n#+end_src\n",
158        "#+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",
159        // `:stdin` and `:cmdline` run a script rather than the session.
160        "#+begin_src sh :session s1 :cmdline a b :results output\necho $# $1\n#+end_src\n",
161        "#+NAME: in\n| hello |\n\n#+begin_src sh :session s2 :stdin in :results output\ncat\n#+end_src\n",
162        "#+begin_src bash :session s3 :shebang #!/bin/bash :cmdline x :var v=3\necho $v $1 ${BASH_VERSION:+bash}\n#+end_src\n",
163        // `:var` from other blocks' results.
164        "#+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",
165        "#+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",
166        "#+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",
167        "#+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",
168        "#+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",
169        "#+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",
170        "#+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",
171        // Tables with rules, `:colnames`, `:rownames` and `:hlines`.
172        "TBL#+begin_src python :var t=tbl\nreturn t\n#+end_src\n",
173        "TBL#+begin_src python :var t=tbl :colnames yes\nreturn t\n#+end_src\n",
174        "TBL#+begin_src python :var t=tbl :colnames no\nreturn t\n#+end_src\n",
175        "TBL#+begin_src python :var t=tbl :rownames yes\nreturn t\n#+end_src\n",
176        "TBL#+begin_src python :var t=tbl :rownames yes :colnames yes\nreturn [[c * 2 for c in r] for r in t]\n#+end_src\n",
177        "TBL#+begin_src python :var t=tbl :colnames yes\nreturn [r + [9] for r in t]\n#+end_src\n",
178        "MULTI#+begin_src python :var t=multi :hlines yes\nreturn t\n#+end_src\n",
179        "MULTI#+begin_src python :var t=multi\nreturn t\n#+end_src\n",
180        "MULTI#+begin_src python :var t=multi :colnames yes\nreturn t\n#+end_src\n",
181        "TBL#+begin_src emacs-lisp :var tb=tbl\ntb\n#+end_src\n",
182        "TBL#+begin_src emacs-lisp :var tb=tbl :colnames yes\n(mapcar #'reverse tb)\n#+end_src\n",
183        "MULTI#+begin_src emacs-lisp :var tb=multi :hlines yes\ntb\n#+end_src\n",
184        "MULTI#+begin_src sh :stdin multi :results output\ncat\n#+end_src\n",
185        "#+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",
186        // Tables and lists in shell variables.
187        "TBL#+begin_src sh :var t=tbl :results output\necho \"$t\"\n#+end_src\n",
188        "MULTI#+begin_src sh :var t=multi :hlines yes :separator , :results output\necho \"$t\"\n#+end_src\n",
189        "MULTI#+begin_src sh :var t=multi :hlines yes :hline-string -- :results output\necho \"$t\"\n#+end_src\n",
190        "#+NAME: items\n- one\n- it's\n\n#+begin_src sh :var l=items :results output\necho \"$l\"\n#+end_src\n",
191        "#+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",
192        "#+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",
193        "#+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",
194        "#+NAME: col\n| 5 |\n| 6 |\n\n#+begin_src bash :var t=col :results output\necho ${t[@]}\n#+end_src\n",
195        "#+begin_src bash :var l='(1 \"two\" 3) :results output\necho ${l[2]} ${l[1]}\n#+end_src\n",
196        "#+NAME: items\n- one\n- two\n\n#+begin_src zsh :var l=items :results output\necho \"$l\"\n#+end_src\n",
197        "TBL#+begin_src shell :var t=tbl :results output\necho \"$t\"\n#+end_src\n",
198        "TBL#+begin_src python :var t=tbl :colnames '(\"x\" \"y\")\nreturn t\n#+end_src\n",
199        "TBL#+begin_src python :var t=tbl :rownames '(\"r1\" \"r2\")\nreturn t\n#+end_src\n",
200        "#+begin_src sh :dir (concat \"/\" \"usr\") :results output\npwd\n#+end_src\n",
201        "#+begin_src sh :dir /tmp :results output\npwd\n#+end_src\n",
202        "#+NAME: items\n- one\n- two\n\n#+begin_src shell :var l=items :results output\necho \"$l\"\n#+end_src\n",
203        // Lisp values.
204        "#+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",
205        "#+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",
206        "#+begin_src sh :var n=(* 6 7)\necho $n\n#+end_src\n",
207    ]
208
209    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
210    func matchesEmacs() throws {
211        let folder = FileManager.default.temporaryDirectory
212        var emacsCases: [EmacsOracle.Case] = []
213        var ours: [(text: String, failure: String?)] = []
214        for text in Self.cases {
215            let caret = (text as NSString).range(of: "#+begin_src").location + 2
216            ours.append(try TestBabelRunner.execute(text, caret: caret, directory: folder))
217            emacsCases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """
218            (progn
219              (require 'ob-shell)
220              (require 'ob-python)
221              (require 'ob-emacs-lisp)
222              (setq org-confirm-babel-evaluate nil
223                    org-babel-python-command "python3"
224                    default-directory temporary-file-directory)
225              ;; No shell session in batch; the cases with one don't use it.
226              (advice-add 'org-babel-sh-initiate-session :override (lambda (s &rest _) (unless (member s '(nil "none")) s)))
227              (org-babel-execute-src-block))
228            """))
229        }
230        let emacs = try EmacsOracle.run(emacsCases)
231        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
232            #expect(mine.text == theirs.text, "case \(i):\n\(Self.cases[i])\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)")
233        }
234    }
235
236    static let lispCases = [
237        "#+begin_src emacs-lisp\n(list 1 2.5 \"three\" 'four)\n#+end_src\n",
238        "#+begin_src emacs-lisp\n(list '(a 1) 'hline '(b 2))\n#+end_src\n",
239        "#+begin_src emacs-lisp\n(format \"%05.2f|%s\" 3.14159 'sym)\n#+end_src\n",
240        "#+begin_src emacs-lisp\n\"line one\\nline \\\"two\\\"\"\n#+end_src\n",
241        "#+begin_src emacs-lisp :results verbatim\n\"with \\\"quotes\\\" and \\\\ slash\"\n#+end_src\n",
242        "#+begin_src emacs-lisp\nnil\n#+end_src\n",
243        "#+begin_src emacs-lisp\nt\n#+end_src\n",
244        "#+begin_src emacs-lisp\n[1 2 3]\n#+end_src\n",
245        "#+begin_src emacs-lisp\n(cons 1 2)\n#+end_src\n",
246        "#+begin_src emacs-lisp\n(/ 10 4.0)\n#+end_src\n",
247        "#+begin_src emacs-lisp\n(let ((s 0)) (dotimes (i 5) (setq s (+ s i))) s)\n#+end_src\n",
248        "#+begin_src emacs-lisp\n(mapcar (lambda (x) (* x x)) '(1 2 3))\n#+end_src\n",
249        "#+begin_src emacs-lisp :results output\n(princ \"a\")\n(terpri)\n(prin1 \"b\")\n(print 'c)\n#+end_src\n",
250        "#+begin_src emacs-lisp :results value scalar\n'(1 2)\n#+end_src\n",
251        "#+begin_src emacs-lisp :results list\n'(\"x\" \"y\")\n#+end_src\n",
252        "#+NAME: tbl\n| 1 | 2 |\n| 3 | 4 |\n\n#+begin_src emacs-lisp :var rows=tbl\n(mapcar (lambda (r) (apply #'+ r)) rows)\n#+end_src\n",
253        "#+begin_src emacs-lisp :var n=6 :var s=\"hi\"\n(concat s (number-to-string (* n 7)))\n#+end_src\n",
254        "#+begin_src emacs-lisp\n(car 1)\n#+end_src\n",
255        "#+begin_src emacs-lisp :results raw\n\"*bold*\"\n#+end_src\n",
256        "#+begin_src emacs-lisp :wrap example\n(upcase \"wrapped\")\n#+end_src\n",
257    ]
258
259    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
260    func lispRunsNativelyLikeEmacs() throws {
261        let folder = FileManager.default.temporaryDirectory
262        let texts = Self.lispCases + Self.cases.filter { ($0.contains("emacs-lisp") || $0.contains("begin_src elisp")) && !$0.contains("begin_src sh") }
263        var emacsCases: [EmacsOracle.Case] = []
264        var ours: [(text: String, failure: String?)] = []
265        for text in texts {
266            let caret = (text as NSString).range(of: "#+begin_src").location + 2
267            do {
268                ours.append(try TestBabelRunner.execute(text, caret: caret, directory: folder, native: true))
269            } catch is NativeUnsupported {
270                Issue.record("not run natively:\n\(text)")
271                ours.append((text, "unsupported"))
272            }
273            emacsCases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """
274            (progn
275              (setq org-confirm-babel-evaluate nil)
276              (org-babel-execute-src-block))
277            """))
278        }
279        let emacs = try EmacsOracle.run(emacsCases)
280        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
281            #expect(mine.text == theirs.text, "case \(i):\n\(texts[i])\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)")
282        }
283    }
284
285    @Test func referencedQueryBlocksAskEveryTime() {
286        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"
287        guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {
288            Issue.record("not planned")
289            return
290        }
291        #expect(job.alwaysAsk)
292    }
293
294    @Test func aChangedBufferLeavesTheApprovedBlocks() throws {
295        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"
296        guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])),
297              let need = job.needs.first else {
298            Issue.record("not planned")
299            return
300        }
301        #expect(job.approved.contains(need.job.blockText))
302        // Another block now sits where the approved one was.
303        let changed = "#+begin_src sh\nrm -rf nothing\n#+end_src\n" + text
304        guard case .success(let next) = Babel.resume(job, need: need, output: BabelOutput(stdout: "1\n"), text: changed, tree: OrgParser.parse(changed)) else {
305            Issue.record("not resumed")
306            return
307        }
308        #expect(!job.approved.contains(next.blockText))
309    }
310
311    @Test func asksOnceForEveryReferencedBlock() throws {
312        let text = BabelTests.cases.first { $0.contains(":var a=outer") }!
313        guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {
314            Issue.record("not planned")
315            return
316        }
317        #expect(job.needs.first?.reference == "inner")
318        #expect(job.blockText.contains("return a * 2") && job.blockText.contains("return b + 1") && job.blockText.contains("return 20"))
319    }
320
321    @Test func refusesWhatWouldRunDifferently() {
322        for (text, message) in [
323            ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."),
324            ("#+begin_src ruby :session s\nputs 1\n#+end_src\n", ":session isn't supported for ruby yet; nothing was run."),
325            ("#+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."),
326            ("#+begin_src python :cmdline -x\nreturn 1\n#+end_src\n", ":cmdline isn't supported for python yet; nothing was run."),
327            ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
328            ("#+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"),
329        ] {
330            let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
331            guard case .failure(.message(let got)) = Babel.plan(context) else {
332                Issue.record("ran: \(text)")
333                continue
334            }
335            #expect(got == message)
336        }
337    }
338
339    @Test func noExportStillRunsInteractively() {
340        for eval in ["never-export", "no-export"] {
341            let text = "#+begin_src sh :eval \(eval)\necho 1\n#+end_src\n"
342            let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
343            guard case .success = Babel.plan(context) else {
344                Issue.record("refused :eval \(eval)")
345                continue
346            }
347        }
348    }
349}
350
351struct BabelCallTests {
352    static let named = "#+NAME: double\n#+begin_src sh :var n=2\necho $((n*2))\n#+end_src\n\n"
353    static let cases: [(String, String)] = [
354        (named + "#+CALL: double(n=5)\n", "#+CALL"),
355        (named + "#+CALL: double()\n", "#+CALL"),
356        (named + "#+CALL: double(n=5)\n\n#+RESULTS:\n: 99\n\nAfter.\n", "#+CALL"),
357        (named + "#+CALL: double[:results raw](n=7)\n", "#+CALL"),
358        (named + "#+CALL: double(n=3) :results verbatim\n", "#+CALL"),
359        (named + "#+NAME: six\n#+CALL: double(n=3)\n", "#+CALL"),
360        ("Text src_sh{echo hi} end.\n", "src_sh"),
361        ("Text src_sh{echo hi} {{{results(=old=)}}} end.\n", "src_sh"),
362        ("Text src_sh[:results raw]{echo raw} end.\n", "src_sh"),
363        ("Text src_python{return 6*7} end.\n", "src_python"),
364        ("Text src_sh[:var x=3]{echo $x} end.\n", "src_sh"),
365        (named + "Inline call_double(n=6) here.\n", "call_"),
366        (named + "Inline call_double() here.\n", "call_"),
367        (named + "Inline call_double(n=6)[:results raw] here.\n", "call_"),
368        (named + "Inline call_double[:results verbatim](n=1) here.\n", "call_"),
369        (named + "Inline call_double(n=2) {{{results(=0=)}}} here.\n", "call_"),
370        (named + "Inline call_double(n=2) and call_double(n=3) here.\n", "call_double(n=3"),
371        // Properties where the call is, not where the block is.
372        (underHeadings + "#+CALL: double()\n", "#+CALL"),
373        (underHeadings + "#+CALL: double() :var n=1\n", "#+CALL"),
374        (underHeadings + "Inline call_double() here.\n", "call_"),
375    ]
376    static let underHeadings = """
377        * Library
378        :PROPERTIES:
379        :header-args: :results verbatim
380        :END:
381        #+NAME: double
382        #+begin_src sh :var n=2
383        echo $((n*2))
384        #+end_src
385        * Use
386        :PROPERTIES:
387        :header-args:sh: :var n=7
388        :header-args: :results raw
389        :END:
390
391        """
392
393    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
394    func matchesEmacs() throws {
395        let folder = FileManager.default.temporaryDirectory
396        var emacsCases: [EmacsOracle.Case] = []
397        var ours: [(text: String, failure: String?)] = []
398        for (text, at) in Self.cases {
399            let caret = (text as NSString).range(of: at).location + 2
400            ours.append(try TestBabelRunner.execute(text, caret: caret, directory: folder))
401            emacsCases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """
402            (progn
403              (require 'ob-shell)
404              (require 'ob-python)
405              (setq org-confirm-babel-evaluate nil
406                    org-babel-python-command "python3"
407                    default-directory temporary-file-directory)
408              (org-ctrl-c-ctrl-c))
409            """))
410        }
411        let emacs = try EmacsOracle.run(emacsCases)
412        for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
413            #expect(mine.text == theirs.text && (mine.failure != nil) == !theirs.error.isEmpty,
414                    "case \(i):\n\(Self.cases[i].0)\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)")
415        }
416    }
417}
418
419extension BabelCallTests {
420    @Test func ctrlCCtrlCRunsInlineCalls() {
421        let text = Self.named + "Inline call_double(n=6) here.\n"
422        let caret = (text as NSString).range(of: "call_").location + 2
423        let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
424        #expect(ExecuteSrcBlock().applies(in: context))
425        guard case .external(.babel(let job)) = CtrlCCtrlC().run(in: context) else {
426            Issue.record("inline call not run")
427            return
428        }
429        #expect((job.script ?? job.input).contains("echo $((n*2))"))
430    }
431}
432
433struct BabelFileTests {
434    static let cases = [
435        "#+begin_src sh :results file :file out.txt\necho hello\necho there\n#+end_src\n",
436        "#+begin_src sh :results output file :file x.txt :file-desc Desc\necho out\n#+end_src\n",
437        "#+begin_src sh :results file :file y.txt :file-desc\necho y\n#+end_src\n",
438        "#+begin_src python :results file :file p.txt\nreturn [[1,2],[3,4]]\n#+end_src\n",
439        "#+begin_src sh :results file\necho no-file.txt\n#+end_src\n",
440        "#+begin_src sh :results file :file w.txt :output-dir outd\necho w\n#+end_src\n",
441        "#+begin_src sh :file plain.txt\necho not to a file\n#+end_src\n",
442    ]
443
444    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
445    func matchesEmacs() throws {
446        for (i, text) in Self.cases.enumerated() {
447            let ours = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-file-ours-\(UUID().uuidString)")
448            let theirs = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-file-emacs-\(UUID().uuidString)")
449            for folder in [ours, theirs] { try FileManager.default.createDirectory(at: folder.appendingPathComponent("outd"), withIntermediateDirectories: true) }
450            defer { for folder in [ours, theirs] { try? FileManager.default.removeItem(at: folder) } }
451            let mine = try TestBabelRunner.execute(text, caret: 2, directory: ours)
452            let emacs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: 3, form: """
453                (progn (require 'ob-shell) (require 'ob-python)
454                  (setq org-confirm-babel-evaluate nil org-babel-python-command "python3" default-directory \(lispString(theirs.path + "/")))
455                  (org-babel-execute-src-block))
456                """)])[0]
457            #expect(mine.text == emacs.text, "case \(i)\nours: \(mine.text) \(mine.failure ?? "")\nemacs: \(emacs.text) \(emacs.error)")
458            for name in ["out.txt", "x.txt", "y.txt", "p.txt", "outd/w.txt", "plain.txt"] {
459                let a = try? String(contentsOf: ours.appendingPathComponent(name), encoding: .utf8)
460                let b = try? String(contentsOf: theirs.appendingPathComponent(name), encoding: .utf8)
461                #expect(a == b, "case \(i) \(name): \(a.debugDescription) vs \(b.debugDescription)")
462            }
463        }
464    }
465
466    @Test func plansDiagrams() throws {
467        let text = "#+begin_src dot :file g.svg\ndigraph { a -> b }\n#+end_src\n"
468        guard case .success(let job) = Babel.plan(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])) else {
469            Issue.record("not planned")
470            return
471        }
472        #expect(job.runner == .process(executable: "/bin/sh", arguments: ["-c", "dot -Tsvg -o 'g.svg'"]))
473        guard case .success(let edits) = Babel.finish(job, output: BabelOutput(stdout: ""), text: text) else { return }
474        let new = edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
475        #expect(new.hasSuffix("#+RESULTS:\n[[file:g.svg]]\n"))
476        #expect(Babel.fileResult(job, output: BabelOutput(stdout: "")) == nil)
477        let uml = "#+begin_src plantuml :file u.png\nA -> B\n#+end_src\n"
478        guard case .success(let umlJob) = Babel.plan(EditContext(revision: 0, text: uml, tree: OrgParser.parse(uml), selection: [2..<2])) else { return }
479        #expect(umlJob.input == "@startuml\nA -> B\n@enduml\n")
480    }
481}