krz/orgstar

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

Commit 6a36ba0352

6a36ba0352b0f5419d22dd2e4100ecf4f38b8ea7

parent: e4d9b52b0e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 05:12 UTC

Babel :noweb, #+CALL and inline src blocks

- :noweb yes/eval/no-export/strip-export: <<name>> expands to the named
  block, the blocks with that :noweb-ref (joined by :noweb-sep), or the
  entry with that ID, prefixed like the line, recursively.
- C-c C-c on #+CALL: name[inside](args) end runs the named block with
  those arguments; its results go after the call.
- C-c C-c on src_lang[headers]{body} writes {{{results(=value=)}}} after
  it, replacing an older one.

Layout: unified · split

Sources/OrgCore/Commands/CtrlCCtrlC.swift +1 −1
@@ -16,7 +16,7 @@ public struct CtrlCCtrlC: OrgCommand {
16 16
17 public func run(in context: EditContext) -> CommandStep { 17 public func run(in context: EditContext) -> CommandStep {
18 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) 18 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
19 if KeyContext.holds("src", in: context) { return ExecuteSrcBlock().run(in: context) } 19 if KeyContext.holds("src", in: context) || ExecuteSrcBlock.atCallOrInline(context) { return ExecuteSrcBlock().run(in: context) }
20 if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil { 20 if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil {
21 // At the table's very start the context is the table itself, which org recalculates. 21 // At the table's very start the context is the table itself, which org recalculates.
22 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret 22 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
Sources/OrgCore/Compute/Babel.swift +220 −21
@@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable {
36 let output: Bool 36 let output: Bool
37 let wrap: String? 37 let wrap: String?
38 let name: String? 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 let text = context.text 89 let text = context.text
88 let ns = text as NSString 90 let ns = text as NSString
89 let model = DocumentModel(tree: context.tree) 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 do { 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 } catch let error as Failure { 103 } catch let error as Failure {
98 return .failure(error) 104 return .failure(error)
99 } catch { 105 } catch {
100 return .failure(.message("\(error)")) 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 let eval = params.single[":eval"]?.lowercased() 173 let eval = params.single[":eval"]?.lowercased()
103 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { 174 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
104 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled.")) 175 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
@@ -107,15 +178,21 @@ public enum Babel {
107 return .failure(.message("\(key) isn't supported yet; nothing was run.")) 178 return .failure(.message("\(key) isn't supported yet; nothing was run."))
108 } 179 }
109 if let session = params.single[":session"], session != "none" { return .failure(.message(":session isn't supported yet; nothing was run.")) } 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 if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) } 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 let output = params.results.contains("output") 193 let output = params.results.contains("output")
117 let finishBase = { (kind: BabelJob.Finish.Kind) in 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 let vars: [(String, VarValue)] 197 let vars: [(String, VarValue)]
121 do { 198 do {
@@ -125,7 +202,7 @@ public enum Babel {
125 } catch { 202 } catch {
126 return .failure(.message("\(error)")) 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 let dir = params.single[":dir"] 206 let dir = params.single[":dir"]
130 let alwaysAsk = eval == "query" 207 let alwaysAsk = eval == "query"
131 208
@@ -145,7 +222,7 @@ public enum Babel {
145 return .success(BabelJob( 222 return .success(BabelJob(
146 language: language, runner: .process(executable: executable, arguments: []), 223 language: language, runner: .process(executable: executable, arguments: []),
147 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir, 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 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus)) 226 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus))
150 )) 227 ))
151 } 228 }
@@ -162,7 +239,7 @@ public enum Babel {
162 } 239 }
163 return .success(BabelJob( 240 return .success(BabelJob(
164 language: language, runner: .process(executable: params.single[":python"] ?? options.python, arguments: []), 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 finish: finishBase(.python) 243 finish: finishBase(.python)
167 )) 244 ))
168 } 245 }
@@ -172,15 +249,15 @@ public enum Babel {
172 let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)" 249 let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)"
173 return .success(BabelJob( 250 return .success(BabelJob(
174 language: language, runner: .emacs, input: emacsScript(form: form, params: params.results), 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 if let (executable, arguments) = generic[language] { 255 if let (executable, arguments) = generic[language] {
179 guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) } 256 guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) }
180 return .success(BabelJob( 257 return .success(BabelJob(
181 language: language, runner: .process(executable: params.single[":cmd"] ?? executable, arguments: arguments), 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, 259 input: body + "\n", directory: dir, block: location, 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) 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 return .failure(.message("No way to run \(language) blocks yet.")) 263 return .failure(.message("No way to run \(language) blocks yet."))
@@ -190,6 +267,78 @@ public enum Babel {
190 case message(String) 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 /// `org-babel--normalize-body`: without the final newline, and without the indentation 342 /// `org-babel--normalize-body`: without the final newline, and without the indentation
194 /// common to its lines unless `-i` keeps it. 343 /// common to its lines unless `-i` keeps it.
195 static func normalizedBody(_ block: SrcBlockInfo) -> String { 344 static func normalizedBody(_ block: SrcBlockInfo) -> String {
@@ -220,7 +369,7 @@ public enum Babel {
220 ["output", "value"], 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 var layers: [[(String, String)]] = [] 373 var layers: [[(String, String)]] = []
225 let language = block.language ?? "" 374 let language = block.language ?? ""
226 func properties(_ entries: [Property]) { 375 func properties(_ entries: [Property]) {
@@ -235,7 +384,7 @@ public enum Babel {
235 if let heading = block.heading { 384 if let heading = block.heading {
236 for index in model.ancestors(of: heading) + [heading] { properties(model.headings[index].properties) } 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 if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) { 388 if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) {
240 var rest = String(firstLine[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) 389 var rest = String(firstLine[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
241 while let first = rest.split(separator: " ").first, first.hasPrefix("-") || first.hasPrefix("+") { 390 while let first = rest.split(separator: " ").first, first.hasPrefix("-") || first.hasPrefix("+") {
@@ -246,7 +395,7 @@ public enum Babel {
246 // `#+HEADER:` lines above the block. 395 // `#+HEADER:` lines above the block.
247 var headers: [String] = [] 396 var headers: [String] = []
248 var location = block.range.lowerBound 397 var location = block.range.lowerBound
249 while location > 0 { 398 while !inline, location > 0 {
250 let previous = text.lineRange(for: NSRange(location: location - 1, length: 0)) 399 let previous = text.lineRange(for: NSRange(location: location - 1, length: 0))
251 let line = text.substring(with: previous).trimmingCharacters(in: .whitespacesAndNewlines) 400 let line = text.substring(with: previous).trimmingCharacters(in: .whitespacesAndNewlines)
252 guard line.range(of: "(?i)^#\\+(header|name|caption|attr_[a-z]+|results|plot|label):", options: .regularExpression) != nil else { break } 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 location = previous.location 403 location = previous.location
255 } 404 }
256 for header in headers { layers.append(Params.parse(header)) } 405 for header in headers { layers.append(Params.parse(header)) }
406 layers += extra
257 407
258 for layer in layers { 408 for layer in layers {
259 for (key, value) in layer { 409 for (key, value) in layer {
@@ -546,6 +696,16 @@ public enum Babel {
546 let value = Self.value(job.finish, output) 696 let value = Self.value(job.finish, output)
547 if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) } 697 if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) }
548 let buffer = EmacsBuffer(text, point: 0, settings: settings) 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 insertResult(value, job.finish, block: blockRange, buffer: buffer) 709 insertResult(value, job.finish, block: blockRange, buffer: buffer)
550 return .success(TextEdit.between(text, buffer.text as String)) 710 return .success(TextEdit.between(text, buffer.text as String))
551 } 711 }
@@ -818,6 +978,31 @@ public enum Babel {
818 } 978 }
819 979
820 /// `org-babel-insert-result` for a src block. 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 static func insertResult(_ value: Value, _ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) { 1006 static func insertResult(_ value: Value, _ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) {
822 let params = finish.params 1007 let params = finish.params
823 let keyword = resultsLine(finish, block: block, buffer: buffer) 1008 let keyword = resultsLine(finish, block: block, buffer: buffer)
@@ -942,7 +1127,21 @@ public struct ExecuteSrcBlock: OrgCommand {
942 public init() {} 1127 public init() {}
943 public var id: String { "org.babel.execute" } 1128 public var id: String { "org.babel.execute" }
944 public var title: String { "Run Source Block" } 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 public func run(in context: EditContext) -> CommandStep { 1145 public func run(in context: EditContext) -> CommandStep {
947 switch Babel.plan(context) { 1146 switch Babel.plan(context) {
948 case .success(let job): return .external(.babel(job)) 1147 case .success(let job): return .external(.babel(job))
Tests/OrgCoreTests/BabelTests.swift +43 −1
@@ -138,7 +138,7 @@ struct BabelTests {
138 for (text, message) in [ 138 for (text, message) in [
139 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."), 139 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."),
140 ("#+begin_src sh :session s\necho\n#+end_src\n", ":session isn't supported yet; nothing was run."), 140 ("#+begin_src sh :session s\necho\n#+end_src\n", ":session isn't supported yet; nothing was run."),
141 ("#+begin_src sh :noweb yes\necho\n#+end_src\n", ":noweb isn't supported yet; nothing was run."), 141 ("#+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."),
142 ("#+begin_src sh :file out.txt\necho\n#+end_src\n", ":file isn't supported yet; nothing was run."), 142 ("#+begin_src sh :file out.txt\necho\n#+end_src\n", ":file isn't supported yet; nothing was run."),
143 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."), 143 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
144 ] { 144 ] {
@@ -151,3 +151,45 @@ struct BabelTests {
151 } 151 }
152 } 152 }
153} 153}
154
155struct BabelCallTests {
156 static let named = "#+NAME: double\n#+begin_src sh :var n=2\necho $((n*2))\n#+end_src\n\n"
157 static let cases: [(String, String)] = [
158 (named + "#+CALL: double(n=5)\n", "#+CALL"),
159 (named + "#+CALL: double()\n", "#+CALL"),
160 (named + "#+CALL: double(n=5)\n\n#+RESULTS:\n: 99\n\nAfter.\n", "#+CALL"),
161 (named + "#+CALL: double[:results raw](n=7)\n", "#+CALL"),
162 (named + "#+CALL: double(n=3) :results verbatim\n", "#+CALL"),
163 (named + "#+NAME: six\n#+CALL: double(n=3)\n", "#+CALL"),
164 ("Text src_sh{echo hi} end.\n", "src_sh"),
165 ("Text src_sh{echo hi} {{{results(=old=)}}} end.\n", "src_sh"),
166 ("Text src_sh[:results raw]{echo raw} end.\n", "src_sh"),
167 ("Text src_python{return 6*7} end.\n", "src_python"),
168 ("Text src_sh[:var x=3]{echo $x} end.\n", "src_sh"),
169 ]
170
171 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
172 func matchesEmacs() throws {
173 let folder = FileManager.default.temporaryDirectory
174 var emacsCases: [EmacsOracle.Case] = []
175 var ours: [(text: String, failure: String?)] = []
176 for (text, at) in Self.cases {
177 let caret = (text as NSString).range(of: at).location + 2
178 ours.append(try TestBabelRunner.execute(text, caret: caret, directory: folder))
179 emacsCases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """
180 (progn
181 (require 'ob-shell)
182 (require 'ob-python)
183 (setq org-confirm-babel-evaluate nil
184 org-babel-python-command "python3"
185 default-directory temporary-file-directory)
186 (org-ctrl-c-ctrl-c))
187 """))
188 }
189 let emacs = try EmacsOracle.run(emacsCases)
190 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
191 #expect(mine.text == theirs.text && (mine.failure != nil) == !theirs.error.isEmpty,
192 "case \(i):\n\(Self.cases[i].0)\nours (\(mine.failure ?? "")):\n\(mine.text)\nemacs (\(theirs.error)):\n\(theirs.text)")
193 }
194 }
195}
Tests/OrgCoreTests/NowebTests.swift added +65
@@ -0,0 +1,65 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct NowebTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 * Code
10 #+NAME: greet
11 #+begin_src sh
12 echo hello
13 echo world
14 #+end_src
15
16 #+begin_src sh :noweb-ref parts
17 part one
18 #+end_src
19
20 #+begin_src sh :noweb-ref parts :noweb-sep "\\n# --\\n"
21 part two
22 #+end_src
23
24 #+NAME: nested
25 #+begin_src sh :noweb yes
26 start
27 <<greet>>
28 #+end_src
29
30 #+begin_src sh :noweb yes
31 # <<greet>>
32 x <<nested>> y
33 <<parts>>
34 <<missing>> after
35 <<notes>>
36 #+end_src
37
38 #+begin_src sh :noweb yes :noweb-prefix no
39 # <<greet>>
40 #+end_src
41 * Notes
42 :PROPERTIES:
43 :ID: notes
44 :END:
45 Some text
46 here.
47 * COMMENT hidden
48 #+begin_src sh :noweb-ref parts
49 hidden part
50 #+end_src
51 """ + "\n"
52
53 @Test(.enabled(if: oracle))
54 func expandsLikeOrg() throws {
55 let tree = OrgParser.parse(Self.text)
56 let model = DocumentModel(tree: tree)
57 let ns = Self.text as NSString
58 let blocks = model.srcBlocks.filter { $0.body.contains("<<") }
59 let forms = blocks.map { "(progn (goto-char \(EmacsOracle.point($0.range.lowerBound, in: Self.text) + 2)) (org-babel-expand-noweb-references))" }
60 let emacs = try EmacsOracle.evaluate(Self.text, "(with-current-buffer (find-file-noselect \"oracle.org\") (list \(forms.joined(separator: " "))))")
61 let ours = try blocks.map { try Babel.expandNoweb($0, params: try Babel.Params(block: $0, model: model, text: ns), model: model, text: ns) }
62 #expect(ours == emacs)
63 #expect(ours.count == 3 && ours[1].contains("# echo world") && ours[1].contains("x echo world y\npart one\npart two") && ours[1].contains("Some text"))
64 }
65}