krz/orgstar

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

Commit ebd884b5da

ebd884b5dad3fccbaba63cd292e9fa28a996b6d6

parent: 78d3547c3b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:51 UTC

Export :exports as Babel resolves header arguments

HTML and Markdown export read :exports with Babel.Params, so header-args
properties, #+HEADER: lines and the language defaults (dot, plantuml,
latex and others: results) count, not only the #+begin_src line.
#+CALL: lines and inline calls export their results, as
org-babel-default-lob-header-args says, and never code. Inline src
blocks default to :exports results; {{{results(...)}}} after an inline
block or call shows as :exports allows, and elsewhere as its argument.
Calls get the lob defaults over the called block's header arguments
when evaluated too, and inline defaults sit under the properties.

Layout: unified · split

Sources/OrgCore/Compute/Babel.swift +44 −7
@@ -190,7 +190,7 @@ public enum Babel {
190190 guard let m = line.firstMatch(of: #/(?i)^[ \t]*#\+CALL:[ \t]*([^\s(\[]+)(?:\[([^\]]*)\])?\((.*?)\)[ \t]*(.*?)[ \t]*\n?$/#) else { return nil }
191191 let name = String(m.1)
192192 guard let block = model.srcBlocks.first(where: { $0.name == name }) else { throw Failure.message("Reference '\(name)' not found in this buffer") }
193 var extra: [[(String, String)]] = []
193 var extra = [lobDefaults]
194194 if let inside = m.2 { extra.append(Params.parse(String(inside))) }
195195 extra.append(splitArguments(String(m.3)).map { (":var", $0) })
196196 if !m.4.isEmpty { extra.append(Params.parse(String(m.4))) }
@@ -216,13 +216,49 @@ public enum Babel {
216216 guard let object = found, let m = object.text.firstMatch(of: /^call_([^()\[\]\s]+)(?:\[([^\]]*)\])?\((.*?)\)(?:\[([^\]]*)\])?$/) else { return nil }
217217 let name = String(m.1)
218218 guard let block = model.srcBlocks.first(where: { $0.name == name }) else { throw Failure.message("Reference '\(name)' not found in this buffer") }
219 var extra: [[(String, String)]] = []
219 var extra = [lobDefaults]
220220 if let inside = m.2 { extra.append(Params.parse(String(inside))) }
221221 extra.append(splitArguments(String(m.3)).map { (":var", $0) })
222222 if let end = m.4 { extra.append(Params.parse(String(end))) }
223223 return (block, try Params(block: block, model: model, text: text, extra: extra), object.range)
224224 }
225225
226 /// `org-babel-default-lob-header-args`, over the called block's own header arguments.
227 static let lobDefaults = [(":exports", "results")]
228
229 /// `:exports` from the language's `org-babel-default-header-args:LANG` in Org 9.8.7.
230 static let languageExports = ["dot": "results", "plantuml": "results", "ditaa": "results", "gnuplot": "results",
231 "latex": "results", "lilypond": "results"]
232
233 /// What export shows of the src block, `#+CALL:` line, inline call or inline src block
234 /// starting at `offset` (`org-babel-exp-process-buffer`): `code`, `results`, `both` or
235 /// `none`, from the header arguments as evaluation resolves them. Calls never show code.
236 /// Nil when nothing there runs.
237 public static func exports(at offset: Int, tree: OrgTree, model: DocumentModel, text: NSString) -> String? {
238 func value(_ params: Params?, language: String?) -> String {
239 (params?.single[":exports"] ?? language.flatMap { languageExports[$0] } ?? "code").lowercased()
240 }
241 if let block = model.srcBlocks.first(where: { $0.range.lowerBound == offset }) {
242 return value(try? Params(block: block, model: model, text: text), language: block.language)
243 }
244 let line = text.substring(with: text.lineRange(for: NSRange(location: min(offset, text.length), length: 0)))
245 let call: Params?
246 if line.range(of: "(?i)^[ \\t]*#\\+CALL:", options: .regularExpression) != nil {
247 call = (try? callAt(offset, model: model, text: text))?.params
248 } else if tree.root.descendants().contains(where: { $0.kind == .inlineBabelCall && $0.range.lowerBound == offset }) {
249 call = (try? inlineCallAt(offset, tree: tree, model: model, text: text))?.params
250 } else if let inline = try? inlineAt(offset, tree: tree, model: model, text: text), inline.block.range.lowerBound == offset {
251 return value(inline.params, language: inline.block.language)
252 } else {
253 return nil
254 }
255 // `org-babel-exp-code` for a call is `org-babel-exp-call-line-template`, empty.
256 switch call?.single[":exports"]?.lowercased() ?? "results" {
257 case "both", "results": return "results"
258 default: return "none"
259 }
260 }
261
226262 /// Call arguments split on commas outside quotes and parentheses.
227263 static func splitArguments(_ s: String) -> [String] {
228264 var parts: [String] = []
@@ -255,9 +291,8 @@ public enum Babel {
255291 guard let object = found, let m = object.text.firstMatch(of: /^src_([^\s\[{]+)(?:\[([^\]]*)\])?\{(.*)\}$/) else { return nil }
256292 let heading = model.headings.lastIndex { $0.headingRange.lowerBound <= object.range.lowerBound }
257293 let block = SrcBlockInfo(range: object.range, heading: heading, name: nil, language: String(m.1), switches: [], body: String(m.3), headerArguments: [:])
258 var extra: [[(String, String)]] = [[(":exports", "results"), (":hlines", "yes")]]
259 if let headers = m.2 { extra.append(Params.parse(String(headers))) }
260 return (block, try Params(block: block, model: model, text: text, extra: extra, inline: true))
294 let extra = m.2.map { [Params.parse(String($0))] } ?? []
295 return (block, try Params(block: block, model: model, text: text, defaults: [(":exports", "results"), (":hlines", "yes")], extra: extra, inline: true))
261296 }
262297
263298 /// What `:var` references resolve to while planning.
@@ -882,8 +917,10 @@ public enum Babel {
882917 ["output", "value"],
883918 ]
884919
885 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws {
886 var layers: [[(String, String)]] = []
920 /// `defaults` go under the properties, as `org-babel-default-inline-header-args` do.
921 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, defaults: [(String, String)] = [], extra: [[(String, String)]] = [],
922 inline: Bool = false) throws {
923 var layers: [[(String, String)]] = [defaults]
887924 let language = block.language ?? ""
888925 // `org-babel-params-from-properties`: the inherited `header-args`, then the inherited
889926 // `header-args:LANG`, each the nearest value with `+` additions after it.
Sources/OrgCore/Export/ExportOptions.swift +5
@@ -208,6 +208,11 @@ public enum ExportPreprocess {
208208 continue
209209 }
210210 let name = ns.substring(with: m.range(at: 1)).lowercased()
211 // Inline results stay for the exporter, which shows them as `:exports` says.
212 if name == "results", definitions[name] == nil {
213 out += ns.substring(with: m.range)
214 continue
215 }
211216 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2)))
212217 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now)
213218 }
Sources/OrgCore/Export/HTMLExport.swift +57 −16
@@ -93,6 +93,33 @@ public enum HTMLExport {
9393 }
9494}
9595
96/// The tree being exported, for what its src blocks, calls and inline blocks export.
97final class ExportSource {
98 let tree: OrgTree
99 let text: NSString
100 private lazy var model = DocumentModel(tree: tree)
101 /// Whether the last inline src block or call lets the `{{{results(…)}}}` after it show;
102 /// nil when nothing asks.
103 var inlineResults: Bool?
104
105 init(tree: OrgTree, text: String) {
106 self.tree = tree
107 self.text = text as NSString
108 }
109
110 func exports(at offset: Int) -> String? {
111 Babel.exports(at: offset, tree: tree, model: model, text: text)
112 }
113
114 /// `{{{results(…)}}}`, Org's `$1` macro, as the inline block or call before it allows:
115 /// nil for other macros, the text to show (or nil) for this one.
116 func resultsMacro(_ node: SyntaxNode) -> String?? {
117 guard let m = node.text.firstMatch(of: /(?i)^\{\{\{results\((.*)\)\}\}\}$/) else { return .none }
118 defer { inlineResults = nil }
119 return inlineResults ?? true ? ExportPreprocess.arguments(String(m.1)).first : .some(nil)
120 }
121}
122
96123final class HTMLRenderer {
97124 let settings: OrgSettings
98125 let options: ExportOptions
@@ -144,11 +171,23 @@ final class HTMLRenderer {
144171 collectDefinitions(tree.root)
145172 selected = Self.selection(tree, tags: options.selectTags)
146173 var out = ""
147 for child in tree.root.children { out += element(child) }
174 rendering(tree, text) {
175 for child in tree.root.children { out += element(child) }
176 }
148177 out += footnoteSection()
149178 return out
150179 }
151180
181 private var source: ExportSource?
182
183 /// `body` with `tree` as the source of `:exports`.
184 private func rendering<T>(_ tree: OrgTree, _ text: String, _ body: () -> T) -> T {
185 let saved = source
186 source = ExportSource(tree: tree, text: text)
187 defer { source = saved }
188 return body()
189 }
190
152191 private func collectDefinitions(_ node: SyntaxNode) {
153192 for child in node.children {
154193 if child.kind == .footnoteDefinition {
@@ -181,7 +220,9 @@ final class HTMLRenderer {
181220 func blocks(_ text: String) -> String {
182221 let tree = OrgParser.parse(text, defaults: settings)
183222 var out = ""
184 for child in tree.root.children { out += element(child) }
223 rendering(tree, text) {
224 for child in tree.root.children { out += element(child) }
225 }
185226 return out
186227 }
187228
@@ -189,7 +230,7 @@ final class HTMLRenderer {
189230 func inlineString(_ text: String) -> String {
190231 let tree = OrgParser.parse(text, defaults: settings)
191232 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) }
192 return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
233 return rendering(tree, text) { inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) }
193234 }
194235
195236 /// A citation's or a bibliography entry's pieces: text escaped, prefixes and suffixes as
@@ -209,8 +250,8 @@ final class HTMLRenderer {
209250 // MARK: - Elements
210251
211252 private var affiliated: [String: String] = [:]
212 /// `:exports` of the last src block: its results are exported only for `results` and
213 /// `both`; nothing runs.
253 /// `:exports` of the last src block or call: its results are exported only for `results`
254 /// and `both`; nothing runs.
214255 private var lastExports = "code"
215256
216257 func element(_ node: SyntaxNode) -> String {
@@ -219,7 +260,7 @@ final class HTMLRenderer {
219260 defer { lastExports = "code" }
220261 if lastExports != "results", lastExports != "both" { return "" }
221262 }
222 if node.kind == .block, let exports = Self.srcExports(node) {
263 if node.kind == .block || node.kind == .keyword, let exports = source?.exports(at: node.range.lowerBound) {
223264 lastExports = exports
224265 if exports == "results" || exports == "none" { return "" }
225266 }
@@ -311,14 +352,6 @@ final class HTMLRenderer {
311352 }
312353 }
313354
314 /// The `:exports` value of a src block, or nil for other blocks.
315 static func srcExports(_ node: SyntaxNode) -> String? {
316 guard DocumentModel.isSrcBlock(node) else { return nil }
317 let first = node.text.components(separatedBy: "\n").first ?? ""
318 if let m = first.firstMatch(of: /:exports[ \t]+(\S+)/) { return m.1.lowercased() }
319 return "code"
320 }
321
322355 private func slug(_ text: String) -> String {
323356 var slug = ""
324357 var dash = false
@@ -706,8 +739,14 @@ final class HTMLRenderer {
706739 let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
707740 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
708741 case .lineBreak: return "<br>\n"
709 case .target, .macro: return ""
742 case .macro:
743 guard case .some(let value) = source?.resultsMacro(node) else { return "" }
744 return value.map(inlineString) ?? ""
745 case .target: return ""
710746 case .inlineSourceBlock:
747 let exports = source?.exports(at: node.range.lowerBound) ?? "results"
748 source?.inlineResults = exports == "results" || exports == "both"
749 guard exports == "code" || exports == "both" else { return "" }
711750 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
712751 return Self.escape(node.text)
713752 case .latexFragment:
@@ -724,7 +763,9 @@ final class HTMLRenderer {
724763 if inTitle { return "" }
725764 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
726765 return render(pieces)
727 case .inlineBabelCall: return Self.escape(node.text)
766 case .inlineBabelCall:
767 source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
768 return ""
728769 default: return inline(node)
729770 }
730771 }
Sources/OrgCore/Export/MarkdownExport.swift +28 −6
@@ -38,7 +38,7 @@ final class MarkdownRenderer {
3838 definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
3939 }
4040 }
41 var out = blocks(tree.root)
41 var out = rendering(tree, text) { blocks(tree.root) }
4242 if !notes.isEmpty {
4343 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
4444 }
@@ -48,7 +48,17 @@ final class MarkdownRenderer {
4848 func inlineString(_ text: String) -> String {
4949 let tree = OrgParser.parse(text, defaults: settings)
5050 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return text }
51 return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
51 return rendering(tree, text) { inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) }
52 }
53
54 private var source: ExportSource?
55
56 /// `body` with `tree` as the source of `:exports`.
57 private func rendering<T>(_ tree: OrgTree, _ text: String, _ body: () -> T) -> T {
58 let saved = source
59 source = ExportSource(tree: tree, text: text)
60 defer { source = saved }
61 return body()
5262 }
5363
5464 private var lastExports = "code"
@@ -66,7 +76,7 @@ final class MarkdownRenderer {
6676 defer { lastExports = "code" }
6777 if lastExports != "results", lastExports != "both" { return "" }
6878 }
69 if child.kind == .block, let exports = HTMLRenderer.srcExports(child) {
79 if child.kind == .block || child.kind == .keyword, let exports = source?.exports(at: child.range.lowerBound) {
7080 lastExports = exports
7181 if exports == "results" || exports == "none" { return "" }
7282 }
@@ -76,7 +86,8 @@ final class MarkdownRenderer {
7686 }
7787
7888 private func blocks(text: String) -> String {
79 blocks(OrgParser.parse(text, defaults: settings).root)
89 let tree = OrgParser.parse(text, defaults: settings)
90 return rendering(tree, text) { blocks(tree.root) }
8091 }
8192
8293 private func element(_ node: SyntaxNode, indent: String) -> String {
@@ -274,7 +285,16 @@ final class MarkdownRenderer {
274285 let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
275286 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
276287 case .lineBreak: return " \n"
277 case .target, .macro: return ""
288 case .macro:
289 guard case .some(let value) = source?.resultsMacro(node) else { return "" }
290 return value.map(inlineString) ?? ""
291 case .target: return ""
292 case .inlineSourceBlock:
293 let exports = source?.exports(at: node.range.lowerBound) ?? "results"
294 source?.inlineResults = exports == "results" || exports == "both"
295 guard exports == "code" || exports == "both" else { return "" }
296 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return code(String(m.1)) }
297 return node.text
278298 // ox-md uses ox-html's translator: only HTML snippets.
279299 case .exportSnippet:
280300 guard node.tokens.first?.text == "@@html:" else { return "" }
@@ -283,7 +303,9 @@ final class MarkdownRenderer {
283303 if inTitle { return "" }
284304 guard let pieces = citations?.next(node.text) else { return node.text }
285305 return render(pieces)
286 case .inlineBabelCall: return node.text
306 case .inlineBabelCall:
307 source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
308 return ""
287309 default: return inline(node)
288310 }
289311 }
Tests/OrgCoreTests/ExportTests.swift +81
@@ -220,4 +220,85 @@ struct ExportResultsTests {
220220 #expect(html.contains("echo b") && html.contains(">b</pre>"))
221221 #expect(!html.contains("echo c") && html.contains(">c</pre>"))
222222 }
223
224 static let exports = """
225 #+PROPERTY: header-args :exports both
226 * Heading
227 :PROPERTIES:
228 :header-args:sh: :exports results
229 :END:
230 #+begin_src sh
231 echo codeprop
232 #+end_src
233
234 #+RESULTS:
235 : resprop
236
237 #+HEADER: :exports code
238 #+begin_src sh
239 echo codeheader
240 #+end_src
241
242 #+RESULTS:
243 : resheader
244
245 #+begin_src python
246 codefile
247 #+end_src
248
249 #+RESULTS:
250 : resfile
251
252 #+NAME: named
253 #+begin_src sh :exports none
254 echo codenamed
255 #+end_src
256
257 #+CALL: named()
258
259 #+RESULTS:
260 : rescall
261
262 #+CALL: named() :exports code
263
264 #+RESULTS:
265 : rescallcode
266
267 #+begin_src dot :file graph.png
268 digraph { codedot }
269 #+end_src
270
271 #+RESULTS:
272 : resdot
273
274 Inline src_sh{echo codeinline} {{{results(=resinline=)}}} one.
275
276 Inline src_sh[:exports code]{echo codeinlinecode} {{{results(=resinlinecode=)}}} two.
277
278 Inline src_sh[:exports both]{echo codeinlineboth} {{{results(=resinlineboth=)}}} three.
279
280 Inline call_named() {{{results(=resinlinecall=)}}} four.
281 """ + "\n"
282
283 /// Which code and results the HTML and Markdown exports keep, against ox-html with nothing
284 /// evaluated.
285 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
286 func exportsLikeOrg() throws {
287 let emacs = try EmacsOracle.evaluate(Self.exports, """
288 (progn
289 (require 'ox-html) (require 'ob-shell) (require 'ob-python) (require 'ob-dot)
290 (dolist (v '(org-babel-default-header-args org-babel-default-inline-header-args org-babel-default-lob-header-args))
291 (set v (cons '(:eval . "never-export") (symbol-value v))))
292 (list (org-export-as 'html nil nil t)))
293 """)[0]
294 let words = Self.exports.matches(of: /\b(?:code|res)[a-z]+\b/).map { String($0.0) }
295 let expected = Set(words.filter { emacs.contains($0) })
296 #expect(expected.count > 4 && expected.count < words.count)
297 let html = HTMLExport.page(Self.exports, fallbackTitle: "t")
298 let markdown = MarkdownExport.document(Self.exports)
299 for word in words {
300 #expect(html.contains(word) == expected.contains(word), "HTML \(word)")
301 #expect(markdown.contains(word) == expected.contains(word), "Markdown \(word)")
302 }
303 }
223304}