| @@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable { |
| 36 | 36 | let output: Bool |
| 37 | 37 | let wrap: String? |
| 38 | 38 | let name: String? |
| 39 | /// An inline block: the result goes after it, in `{{{results(…)}}}`. |
| 40 | var inline = false |
| 39 | 41 | } |
| 40 | 42 | } |
| 41 | 43 | |
| @@ -87,18 +89,87 @@ public enum Babel { |
| 87 | 89 | let text = context.text |
| 88 | 90 | let ns = text as NSString |
| 89 | 91 | let model = DocumentModel(tree: context.tree) |
| 90 | | guard let block = model.srcBlocks.first(where: { $0.range.contains(context.caret) || $0.range.upperBound == context.caret }) else { |
| 91 | | return .failure(.message("Not in a source block")) |
| 92 | | } |
| 93 | | guard let language = block.language else { return .failure(.message("This block has no language")) } |
| 94 | | let params: Params |
| 95 | 92 | do { |
| 96 | | params = try Params(block: block, model: model, text: ns) |
| 93 | if let block = model.srcBlocks.first(where: { $0.range.contains(context.caret) || $0.range.upperBound == context.caret }) { |
| 94 | return plan(block, params: try Params(block: block, model: model, text: ns), model: model, text: ns, options: options, |
| 95 | location: block.range, name: block.name) |
| 96 | } |
| 97 | if let call = try callAt(context.caret, model: model, text: ns) { |
| 98 | return plan(call.block, params: call.params, model: model, text: ns, options: options, location: call.location, name: call.name) |
| 99 | } |
| 100 | if let inline = try inlineAt(context.caret, tree: context.tree, model: model, text: ns) { |
| 101 | return plan(inline.block, params: inline.params, model: model, text: ns, options: options, location: inline.block.range, name: nil, inline: true) |
| 102 | } |
| 97 | 103 | } catch let error as Failure { |
| 98 | 104 | return .failure(error) |
| 99 | 105 | } catch { |
| 100 | 106 | return .failure(.message("\(error)")) |
| 101 | 107 | } |
| 108 | return .failure(.message("Not in a source block")) |
| 109 | } |
| 110 | |
| 111 | /// The `#+CALL:` line at `offset`: the block it names, with the call's inside header, its |
| 112 | /// arguments as `:var` and its end header over the block's own (`org-babel-lob-get-info`). |
| 113 | static func callAt(_ offset: Int, model: DocumentModel, text: NSString) throws -> (block: SrcBlockInfo, params: Params, location: Range<Int>, name: String?)? { |
| 114 | let lineRange = text.lineRange(for: NSRange(location: min(offset, text.length), length: 0)) |
| 115 | let line = text.substring(with: lineRange) |
| 116 | guard let m = line.firstMatch(of: #/(?i)^[ \t]*#\+CALL:[ \t]*([^\s(\[]+)(?:\[([^\]]*)\])?\((.*?)\)[ \t]*(.*?)[ \t]*\n?$/#) else { return nil } |
| 117 | let name = String(m.1) |
| 118 | guard let block = model.srcBlocks.first(where: { $0.name == name }) else { throw Failure.message("Reference '\(name)' not found in this buffer") } |
| 119 | var extra: [[(String, String)]] = [] |
| 120 | if let inside = m.2 { extra.append(Params.parse(String(inside))) } |
| 121 | extra.append(splitArguments(String(m.3)).map { (":var", $0) }) |
| 122 | if !m.4.isEmpty { extra.append(Params.parse(String(m.4))) } |
| 123 | // The call's own #+NAME names its results. |
| 124 | var callName: String? |
| 125 | if lineRange.location > 0 { |
| 126 | let previous = text.substring(with: text.lineRange(for: NSRange(location: lineRange.location - 1, length: 0))) |
| 127 | callName = previous.firstMatch(of: #/(?i)^[ \t]*#\+NAME:[ \t]*(\S+)/#).map { String($0.1) } |
| 128 | } |
| 129 | let location = lineRange.location..<NSMaxRange(lineRange) |
| 130 | return (block, try Params(block: block, model: model, text: text, extra: extra), location, callName) |
| 131 | } |
| 132 | |
| 133 | /// Call arguments split on commas outside quotes and parentheses. |
| 134 | static func splitArguments(_ s: String) -> [String] { |
| 135 | var parts: [String] = [] |
| 136 | var current = "" |
| 137 | var depth = 0 |
| 138 | var quoted = false |
| 139 | for c in s { |
| 140 | if c == "\"" { quoted.toggle() } |
| 141 | if !quoted, c == "(" { depth += 1 } |
| 142 | if !quoted, c == ")" { depth -= 1 } |
| 143 | if c == ",", !quoted, depth == 0 { |
| 144 | parts.append(current) |
| 145 | current = "" |
| 146 | } else { |
| 147 | current.append(c) |
| 148 | } |
| 149 | } |
| 150 | parts.append(current) |
| 151 | return parts.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } |
| 152 | } |
| 153 | |
| 154 | /// The inline `src_LANG[HEADERS]{BODY}` at `offset`. |
| 155 | static func inlineAt(_ offset: Int, tree: OrgTree, model: DocumentModel, text: NSString) throws -> (block: SrcBlockInfo, params: Params)? { |
| 156 | var node = tree.root |
| 157 | var found: SyntaxNode? |
| 158 | while let child = node.child(containing: offset) { |
| 159 | if child.kind == .inlineSourceBlock { found = child } |
| 160 | node = child |
| 161 | } |
| 162 | guard let object = found, let m = object.text.firstMatch(of: /^src_([^\s\[{]+)(?:\[([^\]]*)\])?\{(.*)\}$/) else { return nil } |
| 163 | let heading = model.headings.lastIndex { $0.headingRange.lowerBound <= object.range.lowerBound } |
| 164 | let block = SrcBlockInfo(range: object.range, heading: heading, name: nil, language: String(m.1), switches: [], body: String(m.3), headerArguments: [:]) |
| 165 | var extra: [[(String, String)]] = [[(":exports", "results"), (":hlines", "yes")]] |
| 166 | if let headers = m.2 { extra.append(Params.parse(String(headers))) } |
| 167 | return (block, try Params(block: block, model: model, text: text, extra: extra, inline: true)) |
| 168 | } |
| 169 | |
| 170 | static func plan(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text ns: NSString, options: Options, |
| 171 | location: Range<Int>, name: String?, inline: Bool = false) -> Result<BabelJob, Failure> { |
| 172 | guard let language = block.language else { return .failure(.message("This block has no language")) } |
| 102 | 173 | let eval = params.single[":eval"]?.lowercased() |
| 103 | 174 | if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { |
| 104 | 175 | return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled.")) |
| @@ -107,15 +178,21 @@ public enum Babel { |
| 107 | 178 | return .failure(.message("\(key) isn't supported yet; nothing was run.")) |
| 108 | 179 | } |
| 109 | 180 | if let session = params.single[":session"], session != "none" { return .failure(.message(":session isn't supported yet; nothing was run.")) } |
| 110 | | if let noweb = params.single[":noweb"], !["no", "strip-export", "tangle"].contains(noweb) { |
| 111 | | return .failure(.message(":noweb isn't supported yet; nothing was run.")) |
| 112 | | } |
| 113 | 181 | if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) } |
| 114 | 182 | |
| 115 | | let body = normalizedBody(block) |
| 183 | var body = normalizedBody(block) |
| 184 | if nowebEvaluates(params.single[":noweb"]) { |
| 185 | do { |
| 186 | body = try expandNoweb(block, params: params, model: model, text: ns) |
| 187 | } catch let error as Failure { |
| 188 | return .failure(error) |
| 189 | } catch { |
| 190 | return .failure(.message("\(error)")) |
| 191 | } |
| 192 | } |
| 116 | 193 | let output = params.results.contains("output") |
| 117 | 194 | let finishBase = { (kind: BabelJob.Finish.Kind) in |
| 118 | | BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: block.name) |
| 195 | BabelJob.Finish(kind: kind, language: language, params: params.results, output: output, wrap: params.wrap, name: name, inline: inline) |
| 119 | 196 | } |
| 120 | 197 | let vars: [(String, VarValue)] |
| 121 | 198 | do { |
| @@ -125,7 +202,7 @@ public enum Babel { |
| 125 | 202 | } catch { |
| 126 | 203 | return .failure(.message("\(error)")) |
| 127 | 204 | } |
| 128 | | let blockText = ns.substring(with: NSRange(block.range)) |
| 205 | let blockText = ns.substring(with: NSRange(location)) |
| 129 | 206 | let dir = params.single[":dir"] |
| 130 | 207 | let alwaysAsk = eval == "query" |
| 131 | 208 | |
| @@ -145,7 +222,7 @@ public enum Babel { |
| 145 | 222 | return .success(BabelJob( |
| 146 | 223 | language: language, runner: .process(executable: executable, arguments: []), |
| 147 | 224 | input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir, |
| 148 | | block: block.range, blockText: blockText, alwaysAsk: alwaysAsk, |
| 225 | block: location, blockText: blockText, alwaysAsk: alwaysAsk, |
| 149 | 226 | finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus)) |
| 150 | 227 | )) |
| 151 | 228 | } |
| @@ -162,7 +239,7 @@ public enum Babel { |
| 162 | 239 | } |
| 163 | 240 | return .success(BabelJob( |
| 164 | 241 | language: language, runner: .process(executable: params.single[":python"] ?? options.python, arguments: []), |
| 165 | | input: input, directory: dir, block: block.range, blockText: blockText, alwaysAsk: alwaysAsk, |
| 242 | input: input, directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, |
| 166 | 243 | finish: finishBase(.python) |
| 167 | 244 | )) |
| 168 | 245 | } |
| @@ -172,15 +249,15 @@ public enum Babel { |
| 172 | 249 | let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)" |
| 173 | 250 | return .success(BabelJob( |
| 174 | 251 | language: language, runner: .emacs, input: emacsScript(form: form, params: params.results), |
| 175 | | directory: dir, block: block.range, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp) |
| 252 | directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, finish: finishBase(.emacsLisp) |
| 176 | 253 | )) |
| 177 | 254 | } |
| 178 | 255 | if let (executable, arguments) = generic[language] { |
| 179 | 256 | guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) } |
| 180 | 257 | return .success(BabelJob( |
| 181 | 258 | language: language, runner: .process(executable: params.single[":cmd"] ?? executable, arguments: arguments), |
| 182 | | input: body + "\n", directory: dir, block: block.range, blockText: blockText, alwaysAsk: alwaysAsk, |
| 183 | | finish: BabelJob.Finish(kind: .generic, language: language, params: params.results + (output ? [] : ["output"]), output: true, wrap: params.wrap, name: block.name) |
| 259 | input: body + "\n", directory: dir, block: location, blockText: blockText, alwaysAsk: alwaysAsk, |
| 260 | finish: BabelJob.Finish(kind: .generic, language: language, params: params.results + (output ? [] : ["output"]), output: true, wrap: params.wrap, name: name, inline: inline) |
| 184 | 261 | )) |
| 185 | 262 | } |
| 186 | 263 | return .failure(.message("No way to run \(language) blocks yet.")) |
| @@ -190,6 +267,78 @@ public enum Babel { |
| 190 | 267 | case message(String) |
| 191 | 268 | } |
| 192 | 269 | |
| 270 | // MARK: - Noweb |
| 271 | |
| 272 | /// `org-babel-noweb-p` in the `:eval` context. |
| 273 | static func nowebEvaluates(_ value: String?) -> Bool { |
| 274 | (value ?? "").split(whereSeparator: \.isWhitespace).contains { ["yes", "no-export", "strip-export", "eval", "strip-tangle"].contains(String($0)) } |
| 275 | } |
| 276 | |
| 277 | /// `org-babel-expand-noweb-references` for evaluation: each `<<name>>` becomes the body of |
| 278 | /// the block named so, or of every block with that `:noweb-ref`, joined by `:noweb-sep`, or |
| 279 | /// the contents of the heading with that ID; the text before it on its line goes before |
| 280 | /// each line it brings. |
| 281 | static func expandNoweb(_ block: SrcBlockInfo, params: Params, model: DocumentModel, text: NSString, depth: Int = 0) throws -> String { |
| 282 | guard depth < 32 else { throw Failure.message("Noweb references nest too deeply") } |
| 283 | let body = normalizedBody(block) |
| 284 | let prefixing = params.single[":noweb-prefix"].map { $0 != "no" && $0 != "nil" } ?? true |
| 285 | func expand(_ other: SrcBlockInfo) throws -> String { |
| 286 | let otherParams = try Params(block: other, model: model, text: text) |
| 287 | return nowebEvaluates(otherParams.single[":noweb"]) |
| 288 | ? try expandNoweb(other, params: otherParams, model: model, text: text, depth: depth + 1) |
| 289 | : normalizedBody(other) |
| 290 | } |
| 291 | func inCommentedHeading(_ b: SrcBlockInfo) -> Bool { |
| 292 | guard let h = b.heading else { return false } |
| 293 | return ([h] + model.ancestors(of: h)).contains { model.headings[$0].title == "COMMENT" || model.headings[$0].title.hasPrefix("COMMENT ") } |
| 294 | } |
| 295 | let regex = try! NSRegularExpression(pattern: "(.*?)(<<([^ \\t\\n](?:.*?[^ \\t\\n])?)>>)") |
| 296 | let ns = body as NSString |
| 297 | var out = "" |
| 298 | var at = 0 |
| 299 | for m in regex.matches(in: body, range: NSRange(location: 0, length: ns.length)) { |
| 300 | let prefix = ns.substring(with: m.range(at: 1)) |
| 301 | let id = ns.substring(with: m.range(at: 3)) |
| 302 | out += ns.substring(with: NSRange(location: at, length: m.range(at: 2).location - at)) |
| 303 | at = NSMaxRange(m.range) |
| 304 | var expansion = "" |
| 305 | if id.range(of: "\\(.*\\)", options: .regularExpression) != nil { |
| 306 | throw Failure.message("Noweb references that run a block (\(id)) aren't supported yet; nothing was run.") |
| 307 | } else if let heading = model.headings.firstIndex(where: { h in |
| 308 | h.properties.contains { ($0.key.uppercased() == "ID" && $0.value == id) || ($0.key.uppercased() == "CUSTOM_ID" && "#" + $0.value == id) } |
| 309 | }) { |
| 310 | expansion = headlineBody(heading, model: model, text: text) |
| 311 | } else if let named = model.srcBlocks.first(where: { $0.name == id }), !inCommentedHeading(named) { |
| 312 | expansion = try expand(named) |
| 313 | } else { |
| 314 | var parts: [String] = [] |
| 315 | var separators: [String] = [] |
| 316 | for other in model.srcBlocks where !inCommentedHeading(other) { |
| 317 | let p = try Params(block: other, model: model, text: text) |
| 318 | guard p.single[":noweb-ref"] == id else { continue } |
| 319 | parts.append(try expand(other)) |
| 320 | separators.append(p.single[":noweb-sep"] ?? "\n") |
| 321 | } |
| 322 | for (i, part) in parts.enumerated() { |
| 323 | if i > 0 { expansion += separators[i - 1] } |
| 324 | expansion += part |
| 325 | } |
| 326 | } |
| 327 | out += prefixing ? expansion.components(separatedBy: CharacterSet(charactersIn: "\n\r")).joined(separator: "\n" + prefix) : expansion |
| 328 | } |
| 329 | return out + ns.substring(from: at) |
| 330 | } |
| 331 | |
| 332 | /// `org-babel-ref-headline-body`: the entry after its planning and drawers. |
| 333 | static func headlineBody(_ index: Int, model: DocumentModel, text: NSString) -> String { |
| 334 | let heading = model.headings[index] |
| 335 | let buffer = EmacsBuffer(text as String, point: heading.headingRange.lowerBound) |
| 336 | buffer.endOfMetaData() |
| 337 | let start = buffer.point |
| 338 | let end = model.headings[(index + 1)...].first.map { $0.headingRange.lowerBound } ?? text.length |
| 339 | return start < end ? text.substring(with: NSRange(location: start, length: end - start)).trimmingCharacters(in: .whitespacesAndNewlines) : "" |
| 340 | } |
| 341 | |
| 193 | 342 | /// `org-babel--normalize-body`: without the final newline, and without the indentation |
| 194 | 343 | /// common to its lines unless `-i` keeps it. |
| 195 | 344 | static func normalizedBody(_ block: SrcBlockInfo) -> String { |
| @@ -220,7 +369,7 @@ public enum Babel { |
| 220 | 369 | ["output", "value"], |
| 221 | 370 | ] |
| 222 | 371 | |
| 223 | | init(block: SrcBlockInfo, model: DocumentModel, text: NSString) throws { |
| 372 | init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws { |
| 224 | 373 | var layers: [[(String, String)]] = [] |
| 225 | 374 | let language = block.language ?? "" |
| 226 | 375 | func properties(_ entries: [Property]) { |
| @@ -235,7 +384,7 @@ public enum Babel { |
| 235 | 384 | if let heading = block.heading { |
| 236 | 385 | for index in model.ancestors(of: heading) + [heading] { properties(model.headings[index].properties) } |
| 237 | 386 | } |
| 238 | | let firstLine = text.substring(with: text.lineRange(for: NSRange(location: block.range.lowerBound, length: 0))) |
| 387 | let firstLine = inline ? "" : text.substring(with: text.lineRange(for: NSRange(location: block.range.lowerBound, length: 0))) |
| 239 | 388 | if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) { |
| 240 | 389 | var rest = String(firstLine[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) |
| 241 | 390 | while let first = rest.split(separator: " ").first, first.hasPrefix("-") || first.hasPrefix("+") { |
| @@ -246,7 +395,7 @@ public enum Babel { |
| 246 | 395 | // `#+HEADER:` lines above the block. |
| 247 | 396 | var headers: [String] = [] |
| 248 | 397 | var location = block.range.lowerBound |
| 249 | | while location > 0 { |
| 398 | while !inline, location > 0 { |
| 250 | 399 | let previous = text.lineRange(for: NSRange(location: location - 1, length: 0)) |
| 251 | 400 | let line = text.substring(with: previous).trimmingCharacters(in: .whitespacesAndNewlines) |
| 252 | 401 | guard line.range(of: "(?i)^#\\+(header|name|caption|attr_[a-z]+|results|plot|label):", options: .regularExpression) != nil else { break } |
| @@ -254,6 +403,7 @@ public enum Babel { |
| 254 | 403 | location = previous.location |
| 255 | 404 | } |
| 256 | 405 | for header in headers { layers.append(Params.parse(header)) } |
| 406 | layers += extra |
| 257 | 407 | |
| 258 | 408 | for layer in layers { |
| 259 | 409 | for (key, value) in layer { |
| @@ -546,6 +696,16 @@ public enum Babel { |
| 546 | 696 | let value = Self.value(job.finish, output) |
| 547 | 697 | if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) } |
| 548 | 698 | let buffer = EmacsBuffer(text, point: 0, settings: settings) |
| 699 | if job.finish.inline { |
| 700 | do { |
| 701 | try insertInlineResult(value, job.finish, at: blockRange, buffer: buffer) |
| 702 | } catch let error as Failure { |
| 703 | return .failure(error) |
| 704 | } catch { |
| 705 | return .failure(.message("\(error)")) |
| 706 | } |
| 707 | return .success(TextEdit.between(text, buffer.text as String)) |
| 708 | } |
| 549 | 709 | insertResult(value, job.finish, block: blockRange, buffer: buffer) |
| 550 | 710 | return .success(TextEdit.between(text, buffer.text as String)) |
| 551 | 711 | } |
| @@ -818,6 +978,31 @@ public enum Babel { |
| 818 | 978 | } |
| 819 | 979 | |
| 820 | 980 | /// `org-babel-insert-result` for a src block. |
| 981 | /// An inline block's result, after it: ` {{{results(=value=)}}}`, replacing one already |
| 982 | /// there; raw results go in as they are. |
| 983 | static func insertInlineResult(_ value: Value, _ finish: BabelJob.Finish, at object: Range<Int>, buffer: EmacsBuffer) throws { |
| 984 | var string: String |
| 985 | switch value { |
| 986 | case .none: string = "" |
| 987 | case .string(let s): string = s |
| 988 | case .table(let rows): |
| 989 | let cells = rows.compactMap { $0 } |
| 990 | guard cells.count == 1, cells[0].count == 1 else { throw Failure.message("Inline source block results can't be tables or lists") } |
| 991 | string = cells[0][0] |
| 992 | } |
| 993 | buffer.point = object.upperBound |
| 994 | if let existing = buffer.lookingAt("[ \\t]*\\{\\{\\{results\\(.*?\\)\\}\\}\\}") { |
| 995 | buffer.deleteRegion(existing[0]!.lowerBound, existing[0]!.upperBound) |
| 996 | } |
| 997 | if finish.params.contains("raw") { |
| 998 | buffer.insert(" " + string) |
| 999 | return |
| 1000 | } |
| 1001 | if string.hasSuffix("\n") { string.removeLast() } |
| 1002 | guard !string.contains("\n") else { throw Failure.message("Inline source block results must be one line") } |
| 1003 | buffer.insert(" {{{results(=" + string + "=)}}}") |
| 1004 | } |
| 1005 | |
| 821 | 1006 | static func insertResult(_ value: Value, _ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) { |
| 822 | 1007 | let params = finish.params |
| 823 | 1008 | let keyword = resultsLine(finish, block: block, buffer: buffer) |
| @@ -942,7 +1127,21 @@ public struct ExecuteSrcBlock: OrgCommand { |
| 942 | 1127 | public init() {} |
| 943 | 1128 | public var id: String { "org.babel.execute" } |
| 944 | 1129 | public var title: String { "Run Source Block" } |
| 945 | | public func applies(in context: EditContext) -> Bool { KeyContext.holds("src", in: context) } |
| 1130 | public func applies(in context: EditContext) -> Bool { KeyContext.holds("src", in: context) || Self.atCallOrInline(context) } |
| 1131 | |
| 1132 | /// On a `#+CALL:` line or an inline `src_` block. |
| 1133 | static func atCallOrInline(_ context: EditContext) -> Bool { |
| 1134 | let ns = context.text as NSString |
| 1135 | let line = ns.substring(with: ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))) |
| 1136 | if line.range(of: "(?i)^[ \\t]*#\\+CALL:", options: .regularExpression) != nil { return true } |
| 1137 | var node = context.tree.root |
| 1138 | while let child = node.child(containing: context.caret) { |
| 1139 | if child.kind == .inlineSourceBlock { return true } |
| 1140 | node = child |
| 1141 | } |
| 1142 | return false |
| 1143 | } |
| 1144 | |
| 946 | 1145 | public func run(in context: EditContext) -> CommandStep { |
| 947 | 1146 | switch Babel.plan(context) { |
| 948 | 1147 | case .success(let job): return .external(.babel(job)) |