Babel: show errors, cancel runs !62
7 files changed, +101 −7
Layout: unified · split
Sources/OrgApp/BabelRunner.swift +7 −2
| @@ -43,9 +43,12 @@ public enum BabelRunner { | |||
| 43 | let stdout = folder.appendingPathComponent("stdout") | 43 | let stdout = folder.appendingPathComponent("stdout") |
| 44 | FileManager.default.createFile(atPath: stdout.path, contents: nil) | 44 | FileManager.default.createFile(atPath: stdout.path, contents: nil) |
| 45 | let out = try FileHandle(forWritingTo: stdout) | 45 | let out = try FileHandle(forWritingTo: stdout) |
| 46 | let stderr = folder.appendingPathComponent("stderr") | ||
| 47 | FileManager.default.createFile(atPath: stderr.path, contents: nil) | ||
| 48 | let err = try FileHandle(forWritingTo: stderr) | ||
| 46 | process.standardInput = stdin | 49 | process.standardInput = stdin |
| 47 | process.standardOutput = out | 50 | process.standardOutput = out |
| 48 | process.standardError = FileHandle.nullDevice | 51 | process.standardError = err |
| 49 | do { | 52 | do { |
| 50 | try process.run() | 53 | try process.run() |
| 51 | } catch { | 54 | } catch { |
| @@ -76,11 +79,13 @@ public enum BabelRunner { | |||
| 76 | } | 79 | } |
| 77 | } | 80 | } |
| 78 | try out.close() | 81 | try out.close() |
| 82 | try err.close() | ||
| 79 | let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? "" | 83 | let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? "" |
| 84 | let errors = (try? String(contentsOf: stderr, encoding: .utf8)) ?? "" | ||
| 80 | let result = try? String(contentsOf: resultFile, encoding: .utf8) | 85 | let result = try? String(contentsOf: resultFile, encoding: .utf8) |
| 81 | if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty { | 86 | if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty { |
| 82 | throw Failure(description: "\(executable) isn't installed.") | 87 | throw Failure(description: "\(executable) isn't installed.") |
| 83 | } | 88 | } |
| 84 | return BabelOutput(stdout: output, resultFile: result) | 89 | return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus) |
| 85 | } | 90 | } |
| 86 | } | 91 | } |
Sources/OrgApp/DocumentSession.swift +30 −2
| @@ -536,21 +536,49 @@ public final class DocumentSession { | |||
| 536 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } | 536 | guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } |
| 537 | let current = entry.buffer | 537 | let current = entry.buffer |
| 538 | let document = current.document | 538 | let document = current.document |
| 539 | let done = output.problem ?? "Code block evaluation complete." | ||
| 539 | switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) { | 540 | switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) { |
| 540 | case .failure(.message(let message)): | 541 | case .failure(.message(let message)): |
| 541 | return message | 542 | return message |
| 542 | case .success(let edits): | 543 | case .success(let edits): |
| 543 | guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." } | 544 | guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.problem ?? output.stdout : done } |
| 544 | let result = EditResult(baseRevision: document.revision, edits: edits) | 545 | let result = EditResult(baseRevision: document.revision, edits: edits) |
| 545 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } | 546 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } |
| 546 | changed(entry) | 547 | changed(entry) |
| 547 | return "Code block evaluation complete." | 548 | return done |
| 548 | } | 549 | } |
| 550 | } catch is CancellationError { | ||
| 551 | return "Code block canceled." | ||
| 549 | } catch { | 552 | } catch { |
| 550 | return "\(error)" | 553 | return "\(error)" |
| 551 | } | 554 | } |
| 552 | } | 555 | } |
| 553 | 556 | ||
| 557 | /// The block running now, to cancel. | ||
| 558 | @ObservationIgnored private var babelTask: Task<String, Never>? | ||
| 559 | public private(set) var isRunningBabel = false | ||
| 560 | |||
| 561 | /// Runs a src block in the background; its message comes back through `report`. | ||
| 562 | public func startBabel(_ job: BabelJob, report: @escaping @MainActor (String) -> Void) { | ||
| 563 | babelTask?.cancel() | ||
| 564 | isRunningBabel = true | ||
| 565 | let task = Task { await runBabel(job) } | ||
| 566 | babelTask = task | ||
| 567 | Task { | ||
| 568 | let message = await task.value | ||
| 569 | if babelTask == task { | ||
| 570 | babelTask = nil | ||
| 571 | isRunningBabel = false | ||
| 572 | } | ||
| 573 | report(message) | ||
| 574 | } | ||
| 575 | } | ||
| 576 | |||
| 577 | /// Stops the running block; its results aren't inserted. | ||
| 578 | public func cancelBabel() { | ||
| 579 | babelTask?.cancel() | ||
| 580 | } | ||
| 581 | |||
| 554 | /// A buffer changed outside typing: the current one updates its views, another saves. | 582 | /// A buffer changed outside typing: the current one updates its views, another saves. |
| 555 | private func changed(_ entry: Entry) { | 583 | private func changed(_ entry: Entry) { |
| 556 | if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() } | 584 | if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() } |
Sources/OrgCore/Compute/Babel.swift +18 −1
| @@ -42,9 +42,26 @@ public struct BabelJob: Sendable, Equatable { | |||
| 42 | public struct BabelOutput: Sendable, Equatable { | 42 | public struct BabelOutput: Sendable, Equatable { |
| 43 | public var stdout: String | 43 | public var stdout: String |
| 44 | public var resultFile: String? | 44 | public var resultFile: String? |
| 45 | public init(stdout: String, resultFile: String? = nil) { | 45 | /// What the program wrote to standard error, and its exit status: Emacs shows them in |
| 46 | /// `*Org-Babel Error Output*` and still inserts the results. | ||
| 47 | public var stderr: String | ||
| 48 | public var status: Int32 | ||
| 49 | public init(stdout: String, resultFile: String? = nil, stderr: String = "", status: Int32 = 0) { | ||
| 46 | self.stdout = stdout | 50 | self.stdout = stdout |
| 47 | self.resultFile = resultFile | 51 | self.resultFile = resultFile |
| 52 | self.stderr = stderr | ||
| 53 | self.status = status | ||
| 54 | } | ||
| 55 | |||
| 56 | /// A line for the message area when the program failed or complained, else nil. | ||
| 57 | public var problem: String? { | ||
| 58 | let first = stderr.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init) | ||
| 59 | switch (status, first) { | ||
| 60 | case (0, nil): return nil | ||
| 61 | case (0, let line?): return line | ||
| 62 | case (let code, nil): return "Exited with status \(code)" | ||
| 63 | case (let code, let line?): return "Exited with status \(code): \(line)" | ||
| 64 | } | ||
| 48 | } | 65 | } |
| 49 | } | 66 | } |
| 50 | 67 | ||
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -48,6 +48,7 @@ public enum EditorCommands { | |||
| 48 | EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), | 48 | EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), |
| 49 | EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), | 49 | EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), |
| 50 | EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), | 50 | EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), |
| 51 | EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app), | ||
| 51 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), | 52 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
| 52 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), | 53 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 53 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), | 54 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
Sources/Orgstar/ContentView.swift +4 −2
| @@ -358,8 +358,8 @@ struct ContentView: View { | |||
| 358 | } | 358 | } |
| 359 | case .babel(let job): | 359 | case .babel(let job): |
| 360 | let run = { | 360 | let run = { |
| 361 | commands.show("Running \(job.language) block…") | 361 | commands.show("Running \(job.language) block… (⌘. to cancel)") |
| 362 | Task { commands.show(await session.runBabel(job)) } | 362 | session.startBabel(job) { commands.show($0) } |
| 363 | } | 363 | } |
| 364 | guard let path = session.path else { return } | 364 | guard let path = session.path else { return } |
| 365 | if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() } | 365 | if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() } |
| @@ -461,6 +461,8 @@ struct ContentView: View { | |||
| 461 | let all = session.openPaths | 461 | let all = session.openPaths |
| 462 | if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } | 462 | if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } |
| 463 | case "app.toggle-tab-bar": showTabBar.toggle() | 463 | case "app.toggle-tab-bar": showTabBar.toggle() |
| 464 | case "app.babel.cancel": | ||
| 465 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } | ||
| 464 | case "app.toggle-outline": toggleOutline() | 466 | case "app.toggle-outline": toggleOutline() |
| 465 | case "app.toggle-markup": showMarkup.toggle() | 467 | case "app.toggle-markup": showMarkup.toggle() |
| 466 | case "app.toggle-line-numbers": showLineNumbers.toggle() | 468 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
Sources/Orgstar/OrgstarApp.swift +3
| @@ -296,6 +296,9 @@ struct OrgstarApp: App { | |||
| 296 | FindMenu(commands: commands) | 296 | FindMenu(commands: commands) |
| 297 | } | 297 | } |
| 298 | CommandGroup(after: .textEditing) { | 298 | CommandGroup(after: .textEditing) { |
| 299 | Button("Cancel Running Block") { commands.run("app.babel.cancel") } | ||
| 300 | .keyboardShortcut(".") | ||
| 301 | .disabled(!session.isRunningBabel) | ||
| 299 | Button("Search Notes") { commands.focusSearch = true } | 302 | Button("Search Notes") { commands.focusSearch = true } |
| 300 | .keyboardShortcut("f", modifiers: [.command, .shift]) | 303 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 301 | } | 304 | } |
Tests/OrgAppTests/AppTests.swift +38
| @@ -550,6 +550,44 @@ struct BabelSessionTests { | |||
| 550 | } | 550 | } |
| 551 | } | 551 | } |
| 552 | 552 | ||
| 553 | @MainActor | ||
| 554 | struct BabelErrorTests { | ||
| 555 | func job(_ session: DocumentSession, _ path: String) throws -> BabelJob { | ||
| 556 | guard case .external(.babel(let job)) = session.perform(ExecuteSrcBlock(), path: path, at: 3, expect: nil) else { | ||
| 557 | throw CancellationError() | ||
| 558 | } | ||
| 559 | return job | ||
| 560 | } | ||
| 561 | |||
| 562 | /// Like `org-babel-eval`: the results go in, and the error output and status are shown. | ||
| 563 | @Test func reportsErrorsAndStillInsertsResults() async throws { | ||
| 564 | let notes = try Folder() | ||
| 565 | let file = try notes.write("e.org", "#+begin_src sh\necho out\necho oops >&2\nexit 3\n#+end_src\n") | ||
| 566 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 567 | session.open(file.path) | ||
| 568 | #expect(await session.runBabel(try job(session, file.path)) == "Exited with status 3: oops") | ||
| 569 | #expect(session.document?.text.hasSuffix("#+RESULTS:\n: out\n") == true) | ||
| 570 | } | ||
| 571 | |||
| 572 | @Test func cancelsARunningBlock() async throws { | ||
| 573 | let notes = try Folder() | ||
| 574 | let file = try notes.write("s.org", "#+begin_src sh\nsleep 20\necho late\n#+end_src\n") | ||
| 575 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 576 | session.open(file.path) | ||
| 577 | let start = ContinuousClock.now | ||
| 578 | var message: String? | ||
| 579 | session.startBabel(try job(session, file.path)) { message = $0 } | ||
| 580 | #expect(session.isRunningBabel) | ||
| 581 | try await Task.sleep(for: .milliseconds(200)) | ||
| 582 | session.cancelBabel() | ||
| 583 | while message == nil, ContinuousClock.now - start < .seconds(5) { try await Task.sleep(for: .milliseconds(20)) } | ||
| 584 | #expect(message == "Code block canceled.") | ||
| 585 | #expect(!session.isRunningBabel) | ||
| 586 | #expect(ContinuousClock.now - start < .seconds(5)) | ||
| 587 | #expect(session.document?.text.contains("#+RESULTS:") == false) | ||
| 588 | } | ||
| 589 | } | ||
| 590 | |||
| 553 | struct EmacsExportTests { | 591 | struct EmacsExportTests { |
| 554 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 592 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 555 | func exportsBesideTheFile() async throws { | 593 | func exportsBesideTheFile() async throws { |