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 {
1616
1717 public func run(in context: EditContext) -> CommandStep {
1818 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) }
2020 if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil {
2121 // At the table's very start the context is the table itself, which org recalculates.
2222 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
Sources/OrgCore/Compute/Babel.swift +220 −21
@@ -36,6 +36,8 @@ public struct BabelJob: Sendable, Equatable {
3636 let output: Bool
3737 let wrap: String?
3838 let name: String?
39 /// An inline block: the result goes after it, in `{{{results(…)}}}`.
40 var inline = false
3941 }
4042}
4143
@@ -87,18 +89,87 @@ public enum Babel {
8789 let text = context.text
8890 let ns = text as NSString
8991 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
9592 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 }
97103 } catch let error as Failure {
98104 return .failure(error)
99105 } catch {
100106 return .failure(.message("\(error)"))
101107 }
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")) }
102173 let eval = params.single[":eval"]?.lowercased()
103174 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
104175 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
@@ -107,15 +178,21 @@ public enum Babel {
107178 return .failure(.message("\(key) isn't supported yet; nothing was run."))
108179 }
109180 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 }
113181 if params.single[":cache"] == "yes" { return .failure(.message(":cache isn't supported yet; nothing was run.")) }
114182
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 }
116193 let output = params.results.contains("output")
117194 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)
119196 }
120197 let vars: [(String, VarValue)]
121198 do {
@@ -125,7 +202,7 @@ public enum Babel {
125202 } catch {
126203 return .failure(.message("\(error)"))
127204 }
128 let blockText = ns.substring(with: NSRange(block.range))
205 let blockText = ns.substring(with: NSRange(location))
129206 let dir = params.single[":dir"]
130207 let alwaysAsk = eval == "query"
131208
@@ -145,7 +222,7 @@ public enum Babel {
145222 return .success(BabelJob(
146223 language: language, runner: .process(executable: executable, arguments: []),
147224 input: full.trimmingCharacters(in: .whitespacesAndNewlines) + "\n", directory: dir,
148 block: block.range, blockText: blockText, alwaysAsk: alwaysAsk,
225 block: location, blockText: blockText, alwaysAsk: alwaysAsk,
149226 finish: finishBase(.shell(valueIsExitStatus: valueIsExitStatus))
150227 ))
151228 }
@@ -162,7 +239,7 @@ public enum Babel {
162239 }
163240 return .success(BabelJob(
164241 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,
166243 finish: finishBase(.python)
167244 ))
168245 }
@@ -172,15 +249,15 @@ public enum Babel {
172249 let form = output ? "(with-output-to-string \(expanded))" : "(progn \(expanded)\n)"
173250 return .success(BabelJob(
174251 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)
176253 ))
177254 }
178255 if let (executable, arguments) = generic[language] {
179256 guard vars.isEmpty else { return .failure(.message(":var isn't supported for \(language) yet.")) }
180257 return .success(BabelJob(
181258 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)
184261 ))
185262 }
186263 return .failure(.message("No way to run \(language) blocks yet."))
@@ -190,6 +267,78 @@ public enum Babel {
190267 case message(String)
191268 }
192269
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
193342 /// `org-babel--normalize-body`: without the final newline, and without the indentation
194343 /// common to its lines unless `-i` keeps it.
195344 static func normalizedBody(_ block: SrcBlockInfo) -> String {
@@ -220,7 +369,7 @@ public enum Babel {
220369 ["output", "value"],
221370 ]
222371
223 init(block: SrcBlockInfo, model: DocumentModel, text: NSString) throws {
372 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws {
224373 var layers: [[(String, String)]] = []
225374 let language = block.language ?? ""
226375 func properties(_ entries: [Property]) {
@@ -235,7 +384,7 @@ public enum Babel {
235384 if let heading = block.heading {
236385 for index in model.ancestors(of: heading) + [heading] { properties(model.headings[index].properties) }
237386 }
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)))
239388 if let r = firstLine.range(of: "(?i)#\\+begin_src[ \\t]+\\S+", options: .regularExpression) {
240389 var rest = String(firstLine[r.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
241390 while let first = rest.split(separator: " ").first, first.hasPrefix("-") || first.hasPrefix("+") {
@@ -246,7 +395,7 @@ public enum Babel {
246395 // `#+HEADER:` lines above the block.
247396 var headers: [String] = []
248397 var location = block.range.lowerBound
249 while location > 0 {
398 while !inline, location > 0 {
250399 let previous = text.lineRange(for: NSRange(location: location - 1, length: 0))
251400 let line = text.substring(with: previous).trimmingCharacters(in: .whitespacesAndNewlines)
252401 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 {
254403 location = previous.location
255404 }
256405 for header in headers { layers.append(Params.parse(header)) }
406 layers += extra
257407
258408 for layer in layers {
259409 for (key, value) in layer {
@@ -546,6 +696,16 @@ public enum Babel {
546696 let value = Self.value(job.finish, output)
547697 if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) }
548698 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 }
549709 insertResult(value, job.finish, block: blockRange, buffer: buffer)
550710 return .success(TextEdit.between(text, buffer.text as String))
551711 }
@@ -818,6 +978,31 @@ public enum Babel {
818978 }
819979
820980 /// `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
8211006 static func insertResult(_ value: Value, _ finish: BabelJob.Finish, block: Range<Int>, buffer: EmacsBuffer) {
8221007 let params = finish.params
8231008 let keyword = resultsLine(finish, block: block, buffer: buffer)
@@ -942,7 +1127,21 @@ public struct ExecuteSrcBlock: OrgCommand {
9421127 public init() {}
9431128 public var id: String { "org.babel.execute" }
9441129 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
9461145 public func run(in context: EditContext) -> CommandStep {
9471146 switch Babel.plan(context) {
9481147 case .success(let job): return .external(.babel(job))
Tests/OrgCoreTests/BabelTests.swift +43 −1
@@ -138,7 +138,7 @@ struct BabelTests {
138138 for (text, message) in [
139139 ("#+begin_src sh :eval never\necho\n#+end_src\n", "Evaluation of this sh code block is disabled."),
140140 ("#+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."),
142142 ("#+begin_src sh :file out.txt\necho\n#+end_src\n", ":file isn't supported yet; nothing was run."),
143143 ("#+begin_src cobol\nDISPLAY\n#+end_src\n", "No way to run cobol blocks yet."),
144144 ] {
@@ -151,3 +151,45 @@ struct BabelTests {
151151 }
152152 }
153153}
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}