Babel and export fixes from the manual review !161

merged merged by cmc on 2026-10-08 04:05 UTC · krz/orgstar:babel-export-fixes into main

14 files changed, +445 −62

Layout: unified · split

Sources/OrgCore/Compute/Babel.swift +49 −11
@@ -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.
@@ -294,7 +329,7 @@ public enum Babel {
294329 let needsFile = options.file == nil && params.lisp.contains { params.evaluated[$0] == nil }
295330 guard let language = block.language else { return .failure(.message("This block has no language")) }
296331 let eval = params.single[":eval"]?.lowercased()
297 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" {
332 if eval == "never" || eval == "no" {
298333 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
299334 }
300335 if params.single[":cmdline"] != nil, !["dot", "plantuml", "mermaid"].contains(language), !shells.contains(language) {
@@ -378,7 +413,8 @@ public enum Babel {
378413 let shebang = params.single[":shebang"].flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil }
379414 if session == nil, stdin != nil || cmdline != nil || shebang != nil {
380415 // `org-babel-sh-evaluate`: an executable script, run by the shell with the
381 // command line after it and `:stdin` as its input; the value is its last line.
416 // command line after it and `:stdin` as its input; the result is its standard
417 // output, or its exit status for `:results value`.
382418 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n"
383419 if params.single[":padline"] != "no" { script += "\n" }
384420 script += (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
@@ -882,8 +918,10 @@ public enum Babel {
882918 ["output", "value"],
883919 ]
884920
885 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws {
886 var layers: [[(String, String)]] = []
921 /// `defaults` go under the properties, as `org-babel-default-inline-header-args` do.
922 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, defaults: [(String, String)] = [], extra: [[(String, String)]] = [],
923 inline: Bool = false) throws {
924 var layers: [[(String, String)]] = [defaults]
887925 let language = block.language ?? ""
888926 // `org-babel-params-from-properties`: the inherited `header-args`, then the inherited
889927 // `header-args:LANG`, each the nearest value with `+` additions after it.
@@ -1903,14 +1941,14 @@ public struct ExecuteSrcBlock: OrgCommand {
19031941 public var title: String { "Run Source Block" }
19041942 public func applies(in context: EditContext) -> Bool { KeyContext.holds("src", in: context) || Self.atCallOrInline(context) }
19051943
1906 /// On a `#+CALL:` line or an inline `src_` block.
1944 /// On a `#+CALL:` line, an inline `call_` or an inline `src_` block.
19071945 static func atCallOrInline(_ context: EditContext) -> Bool {
19081946 let ns = context.text as NSString
19091947 let line = ns.substring(with: ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0)))
19101948 if line.range(of: "(?i)^[ \\t]*#\\+CALL:", options: .regularExpression) != nil { return true }
19111949 var node = context.tree.root
19121950 while let child = node.child(containing: context.caret) {
1913 if child.kind == .inlineSourceBlock { return true }
1951 if child.kind == .inlineSourceBlock || child.kind == .inlineBabelCall { return true }
19141952 node = child
19151953 }
19161954 return false
Sources/OrgCore/Compute/Tangle.swift +7 −2
@@ -31,8 +31,13 @@ public enum Tangle {
3131 public var blocks: Int
3232 }
3333
34 /// `org-babel-tangle-lang-exts`.
35 static let extensions = ["emacs-lisp": "el", "elisp": "el", "bibtex": "bib"]
34 /// `org-babel-tangle-lang-exts`, with what Org 9.8.7's ob-LANG files add to it.
35 static let extensions = [
36 "emacs-lisp": "el", "elisp": "el", "bibtex": "bib",
37 "C++": "cpp", "D": "d", "awk": "awk", "clojure": "clj", "clojurescript": "cljs", "csharp": "cs", "fortran": "F90",
38 "groovy": "groovy", "haskell": "hs", "java": "java", "julia": "jl", "latex": "tex", "LilyPond": "ly", "lisp": "lisp",
39 "lua": "lua", "maxima": "max", "ocaml": "ml", "perl": "pl", "processing": "pde", "python": "py", "ruby": "rb", "sed": "sed",
40 ]
3641 /// `org-babel-shell-names`, and `shell` itself.
3742 static let shells: Set<String> = ["sh", "bash", "zsh", "fish", "csh", "ash", "dash", "ksh", "mksh", "posh", "shell"]
3843
Sources/OrgCore/Export/ExportOptions.swift +33 −2
@@ -194,6 +194,28 @@ public enum ExportPreprocess {
194194 }
195195 }
196196 var counters: [String: Int] = [:]
197 var model: DocumentModel?
198 /// `org-entry-get` with `selective` inheritance at the heading holding `location`, or
199 /// at the entry `search` finds (`*Title`, `#custom-id` or a title).
200 func property(_ name: String, _ search: String?, at location: Int) -> String {
201 if model == nil { model = DocumentModel(tree: OrgParser.parse(text)) }
202 let headings = model!.headings
203 let index: Int?
204 if let search, !search.isEmpty {
205 if search.hasPrefix("#") {
206 let id = String(search.dropFirst())
207 index = headings.firstIndex { $0.properties.contains { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id } }
208 } else {
209 let title = search.hasPrefix("*") ? String(search.dropFirst()) : search
210 index = headings.firstIndex { Links.displayFormat($0.title).trimmingCharacters(in: .whitespaces) == title || $0.title == title }
211 }
212 } else {
213 index = headings.lastIndex { $0.headingRange.lowerBound <= location }
214 }
215 guard let index else { return "" }
216 if name.uppercased() == "ITEM" { return headings[index].title }
217 return model!.property(name, of: index)?.value ?? ""
218 }
197219 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}")
198220 var out = ""
199221 var at = 0
@@ -208,8 +230,16 @@ public enum ExportPreprocess {
208230 continue
209231 }
210232 let name = ns.substring(with: m.range(at: 1)).lowercased()
233 // Inline results stay for the exporter, which shows them as `:exports` says.
234 if name == "results", definitions[name] == nil {
235 out += ns.substring(with: m.range)
236 continue
237 }
211238 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2)))
212 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now)
239 let location = m.range.location
240 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now) {
241 property($0, $1, at: location)
242 }
213243 }
214244 out += ns.substring(from: at)
215245 return out
@@ -240,7 +270,7 @@ public enum ExportPreprocess {
240270 }
241271
242272 static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String],
243 counters: inout [String: Int], path: String?, now: Date) -> String {
273 counters: inout [String: Int], path: String?, now: Date, property: (String, String?) -> String) -> String {
244274 if let template = definitions[name] {
245275 guard !template.hasPrefix("(eval") else { return "" }
246276 var result = template
@@ -251,6 +281,7 @@ public enum ExportPreprocess {
251281 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? ""
252282 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? ""
253283 case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? ""
284 case "property": return args.first.map { property($0, args.count > 1 ? args[1] : nil) } ?? ""
254285 case "n":
255286 let counter = args.first ?? ""
256287 let action = args.count > 1 ? args[1] : ""
Sources/OrgCore/Export/HTMLExport.swift +76 −23
@@ -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,20 +260,18 @@ 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 }
226267 switch node.kind {
227268 case .section:
228269 guard let heading = node.firstChild(.heading) else { fallthrough }
229 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
230 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
231 // `org-export--skip-p`: excluded tags, COMMENT, unselected trees.
232 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return "" }
233 if let selected, !selected.contains(heading.range.lowerBound) { return "" }
234 // Archived trees export their heading only.
235 if tags.contains("ARCHIVE") { return self.heading(heading) }
270 switch Self.sectionExport(heading, options: options, selected: selected) {
271 case .nothing: return ""
272 case .heading: return self.heading(heading)
273 case .all: break
274 }
236275 var out = ""
237276 for child in node.children { out += element(child) }
238277 return out
@@ -311,14 +350,6 @@ final class HTMLRenderer {
311350 }
312351 }
313352
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
322353 private func slug(_ text: String) -> String {
323354 var slug = ""
324355 var dash = false
@@ -344,6 +375,20 @@ final class HTMLRenderer {
344375
345376 /// `org-export--selected-trees`: headings with a select tag, everything below them and
346377 /// their ancestors; nil when no heading has one.
378 enum SectionExport {
379 case all, heading, nothing
380 }
381
382 /// `org-export--skip-p` for the section under `heading`: excluded tags, COMMENT and
383 /// unselected trees export nothing, archived trees their heading only.
384 static func sectionExport(_ heading: SyntaxNode, options: ExportOptions, selected: Set<Int>?) -> SectionExport {
385 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
386 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
387 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return .nothing }
388 if let selected, !selected.contains(heading.range.lowerBound) { return .nothing }
389 return tags.contains("ARCHIVE") ? .heading : .all
390 }
391
347392 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
348393 let model = DocumentModel(tree: tree)
349394 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) }
@@ -706,8 +751,14 @@ final class HTMLRenderer {
706751 let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
707752 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
708753 case .lineBreak: return "<br>\n"
709 case .target, .macro: return ""
754 case .macro:
755 guard case .some(let value) = source?.resultsMacro(node) else { return "" }
756 return value.map(inlineString) ?? ""
757 case .target: return ""
710758 case .inlineSourceBlock:
759 let exports = source?.exports(at: node.range.lowerBound) ?? "results"
760 source?.inlineResults = exports == "results" || exports == "both"
761 guard exports == "code" || exports == "both" else { return "" }
711762 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
712763 return Self.escape(node.text)
713764 case .latexFragment:
@@ -724,7 +775,9 @@ final class HTMLRenderer {
724775 if inTitle { return "" }
725776 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
726777 return render(pieces)
727 case .inlineBabelCall: return Self.escape(node.text)
778 case .inlineBabelCall:
779 source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
780 return ""
728781 default: return inline(node)
729782 }
730783 }
Sources/OrgCore/Export/MarkdownExport.swift +62 −18
@@ -3,14 +3,18 @@ import Foundation
33/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and
44/// `[^n]` footnotes. The title, when there is one, becomes the top heading.
55public enum MarkdownExport {
6 /// `path` is the file's, for `#+bibliography:`.
7 public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil) -> String {
8 let renderer = MarkdownRenderer(settings: settings)
6 /// As the HTML page: `#+INCLUDE`, macros, `#+OPTIONS` and the select and exclude tags.
7 /// `path` is the file's, for `#+INCLUDE`, `{{{input-file}}}` and `#+bibliography:`.
8 public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String {
9 let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }
10 let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords)
11 let options = ExportOptions.parse(settings.setupKeywords + text)
12 let renderer = MarkdownRenderer(settings: settings, options: options)
913 var out = ""
1014 renderer.inTitle = true
11 if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
15 if options.title, let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
1216 renderer.inTitle = false
13 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() })
17 renderer.citations = CiteProcessor(text: text, directory: directory)
1418 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
1519 return out.trimmingCharacters(in: .newlines) + "\n"
1620 }
@@ -18,6 +22,9 @@ public enum MarkdownExport {
1822
1923final class MarkdownRenderer {
2024 let settings: OrgSettings
25 let options: ExportOptions
26 /// With select tags in the document: the sections to export, by heading start.
27 private var selected: Set<Int>?
2128 private var definitions: [String: String] = [:]
2229 private var numbers: [String: Int] = [:]
2330 private var notes: [(Int, String)] = []
@@ -27,8 +34,9 @@ final class MarkdownRenderer {
2734 /// Rendering the title, where Org exports citations as nothing.
2835 var inTitle = false
2936
30 init(settings: OrgSettings) {
37 init(settings: OrgSettings, options: ExportOptions) {
3138 self.settings = settings
39 self.options = options
3240 }
3341
3442 func document(_ text: String) -> String {
@@ -38,7 +46,8 @@ final class MarkdownRenderer {
3846 definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
3947 }
4048 }
41 var out = blocks(tree.root)
49 selected = HTMLRenderer.selection(tree, tags: options.selectTags)
50 var out = rendering(tree, text) { blocks(tree.root) }
4251 if !notes.isEmpty {
4352 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
4453 }
@@ -48,7 +57,17 @@ final class MarkdownRenderer {
4857 func inlineString(_ text: String) -> String {
4958 let tree = OrgParser.parse(text, defaults: settings)
5059 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return text }
51 return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
60 return rendering(tree, text) { inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) }
61 }
62
63 private var source: ExportSource?
64
65 /// `body` with `tree` as the source of `:exports`.
66 private func rendering<T>(_ tree: OrgTree, _ text: String, _ body: () -> T) -> T {
67 let saved = source
68 source = ExportSource(tree: tree, text: text)
69 defer { source = saved }
70 return body()
5271 }
5372
5473 private var lastExports = "code"
@@ -66,7 +85,7 @@ final class MarkdownRenderer {
6685 defer { lastExports = "code" }
6786 if lastExports != "results", lastExports != "both" { return "" }
6887 }
69 if child.kind == .block, let exports = HTMLRenderer.srcExports(child) {
88 if child.kind == .block || child.kind == .keyword, let exports = source?.exports(at: child.range.lowerBound) {
7089 lastExports = exports
7190 if exports == "results" || exports == "none" { return "" }
7291 }
@@ -76,20 +95,28 @@ final class MarkdownRenderer {
7695 }
7796
7897 private func blocks(text: String) -> String {
79 blocks(OrgParser.parse(text, defaults: settings).root)
98 let tree = OrgParser.parse(text, defaults: settings)
99 return rendering(tree, text) { blocks(tree.root) }
80100 }
81101
82102 private func element(_ node: SyntaxNode, indent: String) -> String {
83103 switch node.kind {
84104 case .zerothSection, .section:
105 if node.kind == .section, let heading = node.firstChild(.heading) {
106 switch HTMLRenderer.sectionExport(heading, options: options, selected: selected) {
107 case .nothing: return ""
108 case .heading: return element(heading, indent: indent)
109 case .all: break
110 }
111 }
85112 return blocks(node, indent: indent).trimmingCharacters(in: .newlines)
86113 case .heading:
87114 let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1
88115 var parts: [String] = []
89 if let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) }
90 if let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) }
116 if options.todo, let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) }
117 if options.priority, let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) }
91118 if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
92 if let tags = node.tokens.first(where: { $0.kind == .tags })?.text {
119 if options.tags, let tags = node.tokens.first(where: { $0.kind == .tags })?.text {
93120 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
94121 }
95122 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
@@ -271,10 +298,21 @@ final class MarkdownRenderer {
271298 return String(node.text.dropFirst(3).dropLast(3))
272299 case .superscript, .subscript:
273300 let body = node.text.dropFirst()
274 let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
301 let braced = body.hasPrefix("{") && body.hasSuffix("}")
302 if options.scripts == .off || (options.scripts == .braces && !braced) { return node.text }
303 let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
275304 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
276305 case .lineBreak: return " \n"
277 case .target, .macro: return ""
306 case .macro:
307 guard case .some(let value) = source?.resultsMacro(node) else { return "" }
308 return value.map(inlineString) ?? ""
309 case .target: return ""
310 case .inlineSourceBlock:
311 let exports = source?.exports(at: node.range.lowerBound) ?? "results"
312 source?.inlineResults = exports == "results" || exports == "both"
313 guard exports == "code" || exports == "both" else { return "" }
314 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return code(String(m.1)) }
315 return node.text
278316 // ox-md uses ox-html's translator: only HTML snippets.
279317 case .exportSnippet:
280318 guard node.tokens.first?.text == "@@html:" else { return "" }
@@ -283,7 +321,9 @@ final class MarkdownRenderer {
283321 if inTitle { return "" }
284322 guard let pieces = citations?.next(node.text) else { return node.text }
285323 return render(pieces)
286 case .inlineBabelCall: return node.text
324 case .inlineBabelCall:
325 source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
326 return ""
287327 default: return inline(node)
288328 }
289329 }
@@ -297,9 +337,13 @@ final class MarkdownRenderer {
297337 return "<\(target)>"
298338 }
299339 var href = path
300 if path.hasPrefix("file:") { href = String(path.dropFirst(5)) }
340 if path.hasPrefix("file:") {
341 // ox-md drops the search option and, as `org-md-link-org-files-as-md`, writes .org as .md.
342 href = String(path.dropFirst(5))
343 if let search = href.range(of: "::") { href = String(href[..<search.lowerBound]) }
344 }
301345 if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) }
302 if !path.contains(":"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" }
346 if !path.contains(":") || path.hasPrefix("file:"), href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".md" }
303347 if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" }
304348 let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? ""
305349 if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" }
Sources/OrgCore/Keymap/Presets.swift −3
@@ -753,9 +753,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
753753 keys = "C-c C-x A"
754754 command = "org.archive.to-sibling"
755755 [[bind]]
756 keys = "C-c C-e"
757 command = "app.export-dialog"
758 [[bind]]
759756 keys = "C-c C-x C-v"
760757 command = "org.toggle-inline-images"
761758 [[bind]]
Sources/OrgCore/Semantic/HeadingInfo.swift +4 −1
@@ -17,7 +17,10 @@ public struct Property: Sendable, Equatable {
1717 let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
1818 guard trimmed.hasPrefix(":") else { return nil }
1919 let rest = trimmed.dropFirst()
20 guard let colon = rest.firstIndex(of: ":") else { return nil }
20 // `org-property-re`: the key ends at the first colon followed by a blank or the end,
21 // so `:header-args:sh:` is one key.
22 guard let colon = rest.indices.first(where: { rest[$0] == ":" && (rest.index(after: $0) == rest.endIndex || rest[rest.index(after: $0)].isWhitespace) })
23 else { return nil }
2124 var key = rest[..<colon]
2225 guard !key.isEmpty else { return nil }
2326 let additive = key.hasSuffix("+")
Sources/OrgPresentation/Presentation.swift +1 −1
@@ -51,7 +51,7 @@ public enum SyntaxCategory: String, Hashable, Sendable, CaseIterable {
5151 }
5252}
5353
54/// Highlights source code; the platform supplies one (tree-sitter on macOS and iOS).
54/// Highlights source code; the platform supplies one (tree-sitter on macOS; iOS supplies none).
5555public protocol CodeHighlighter: Sendable {
5656 /// Categories for ranges of `code`, in UTF-16 offsets; later runs win over earlier ones.
5757 /// Empty for languages it doesn't know.
Sources/Orgstar/ExportSheet.swift +1 −1
@@ -1,7 +1,7 @@
11import AppKit
22import SwiftUI
33
4/// C-c C-e: the format, where the file goes, and whether to open it after.
4/// Export… (`app.export-dialog`): the format, where the file goes, and whether to open it after.
55struct ExportSheet: View {
66 let source: URL
77 let export: (_ format: String, _ destination: URL, _ open: Bool) -> Void
Tests/OrgCoreTests/BabelTests.swift +26
@@ -143,6 +143,7 @@ struct BabelTests {
143143 "#+begin_src emacs-lisp :var x=4\n(* x x)\n#+end_src\n",
144144 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n",
145145 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n",
146 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src python\nreturn [[1, 2]]\n#+end_src\n",
146147 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n",
147148 // `:cmdline`, `:stdin` and `:shebang`.
148149 "#+begin_src sh :cmdline one \"two words\" :results output\nfor a in \"$@\"; do echo \"[$a]\"; done\n#+end_src\n",
@@ -328,6 +329,17 @@ struct BabelTests {
328329 #expect(got == message)
329330 }
330331 }
332
333 @Test func noExportStillRunsInteractively() {
334 for eval in ["never-export", "no-export"] {
335 let text = "#+begin_src sh :eval \(eval)\necho 1\n#+end_src\n"
336 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [2..<2])
337 guard case .success = Babel.plan(context) else {
338 Issue.record("refused :eval \(eval)")
339 continue
340 }
341 }
342 }
331343}
332344
333345struct BabelCallTests {
@@ -378,6 +390,20 @@ struct BabelCallTests {
378390 }
379391}
380392
393extension BabelCallTests {
394 @Test func ctrlCCtrlCRunsInlineCalls() {
395 let text = Self.named + "Inline call_double(n=6) here.\n"
396 let caret = (text as NSString).range(of: "call_").location + 2
397 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
398 #expect(ExecuteSrcBlock().applies(in: context))
399 guard case .external(.babel(let job)) = CtrlCCtrlC().run(in: context) else {
400 Issue.record("inline call not run")
401 return
402 }
403 #expect((job.script ?? job.input).contains("echo $((n*2))"))
404 }
405}
406
381407struct BabelFileTests {
382408 static let cases = [
383409 "#+begin_src sh :results file :file out.txt\necho hello\necho there\n#+end_src\n",
Tests/OrgCoreTests/ExportOptionsTests.swift +28
@@ -44,6 +44,34 @@ struct ExportOptionsTests {
4444 #expect(!selected.contains("<p>a</p>") && selected.contains("<p>b</p>") && selected.contains("<p>c</p>"))
4545 }
4646
47 @Test(.enabled(if: oracle))
48 func expandsPropertyMacroLikeOrg() throws {
49 let text = """
50 #+PROPERTY: color filecolor
51 Top: [{{{property(color)}}}]
52 * One
53 :PROPERTIES:
54 :color: red
55 :Size: big
56 :CUSTOM_ID: one
57 :END:
58 In one: [{{{property(color)}}}] [{{{property(SIZE)}}}] [{{{property(missing)}}}] [{{{property(ITEM)}}}]
59 ** Child
60 Child: [{{{property(color)}}}]
61 * Two
62 Two: [{{{property(color,*One)}}}] [{{{property(color,#one)}}}] [{{{property(Size, One)}}}]
63 """ + "\n"
64 let emacs = try EmacsOracle.evaluate(text, """
65 (progn
66 (require 'ox)
67 (org-macro-initialize-templates)
68 (org-macro-replace-all org-macro-templates)
69 (list (buffer-substring-no-properties (point-min) (point-max))))
70 """)
71 let ours = ExportPreprocess.macros(text, path: nil)
72 #expect(ours.contains("[red] [big] [] [One]") && ours == emacs.first, "ours:\n\(ours)\nemacs:\n\(emacs.first ?? "")")
73 }
74
4775 @Test(.enabled(if: oracle))
4876 func expandsIncludesAndMacrosLikeOrg() throws {
4977 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-include-\(UUID().uuidString)")
Tests/OrgCoreTests/ExportTests.swift +123
@@ -168,6 +168,48 @@ struct MarkdownExportTests {
168168 let ours = MarkdownExport.document(org)
169169 #expect(ours == expected)
170170 }
171
172 /// Words that ox-md keeps or drops for `#+OPTIONS`, the tags, COMMENT, `#+INCLUDE` and
173 /// macros, and where links to Org files point.
174 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
175 func documentLevelLikeOrgMd() throws {
176 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-md-\(UUID().uuidString)")
177 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
178 defer { try? FileManager.default.removeItem(at: folder) }
179 try "* Included\nwordincluded\n".write(to: folder.appendingPathComponent("part.org"), atomically: true, encoding: .utf8)
180 let text = """
181 #+TITLE: wordtitle
182 #+OPTIONS: todo:nil tags:nil ^:nil toc:nil num:nil
183 #+EXCLUDE_TAGS: private
184 #+MACRO: greet wordmacro $1
185 {{{greet(wordarg)}}} a_wordsub
186 * TODO wordtodo :wordtag:
187 * Kept :noexport:
188 wordnoexport
189 * Private :private:
190 wordprivate
191 * COMMENT Hidden
192 wordcomment
193 * Links
194 [[file:x.org]] [[file:x.org][X]] [[./y.org]] [[file:sub/z.ORG::*Head][Z]]
195 #+INCLUDE: "part.org"
196 """ + "\n"
197 let path = folder.appendingPathComponent("main.org").path
198 try text.write(toFile: path, atomically: true, encoding: .utf8)
199 let emacs = try EmacsOracle.evaluate(text, """
200 (with-current-buffer (find-file-noselect \(Babel.lispString(path)))
201 (require 'ox-md)
202 (list (org-export-as 'md)))
203 """)[0]
204 let ours = MarkdownExport.document(text, path: path)
205 // ox-md writes no title; this export makes it the top heading.
206 for word in text.matches(of: /\bword[a-z]+\b|\bTODO\b/).map({ String($0.0) }) where word != "wordtitle" {
207 #expect(ours.contains(word) == emacs.contains(word), "\(word)\nours:\n\(ours)\nemacs:\n\(emacs)")
208 }
209 #expect(ours.contains("<sub>") == emacs.contains("<sub>"))
210 let targets = { (s: String) in Set(s.matches(of: /[\w.\/]+\.(?:md|org|ORG)\b/).map { String($0.0) }) }
211 #expect(targets(ours) == targets(emacs), "ours:\n\(ours)\nemacs:\n\(emacs)")
212 }
171213}
172214
173215struct ExportCorpusTests {
@@ -220,4 +262,85 @@ struct ExportResultsTests {
220262 #expect(html.contains("echo b") && html.contains(">b</pre>"))
221263 #expect(!html.contains("echo c") && html.contains(">c</pre>"))
222264 }
265
266 static let exports = """
267 #+PROPERTY: header-args :exports both
268 * Heading
269 :PROPERTIES:
270 :header-args:sh: :exports results
271 :END:
272 #+begin_src sh
273 echo codeprop
274 #+end_src
275
276 #+RESULTS:
277 : resprop
278
279 #+HEADER: :exports code
280 #+begin_src sh
281 echo codeheader
282 #+end_src
283
284 #+RESULTS:
285 : resheader
286
287 #+begin_src python
288 codefile
289 #+end_src
290
291 #+RESULTS:
292 : resfile
293
294 #+NAME: named
295 #+begin_src sh :exports none
296 echo codenamed
297 #+end_src
298
299 #+CALL: named()
300
301 #+RESULTS:
302 : rescall
303
304 #+CALL: named() :exports code
305
306 #+RESULTS:
307 : rescallcode
308
309 #+begin_src dot :file graph.png
310 digraph { codedot }
311 #+end_src
312
313 #+RESULTS:
314 : resdot
315
316 Inline src_sh{echo codeinline} {{{results(=resinline=)}}} one.
317
318 Inline src_sh[:exports code]{echo codeinlinecode} {{{results(=resinlinecode=)}}} two.
319
320 Inline src_sh[:exports both]{echo codeinlineboth} {{{results(=resinlineboth=)}}} three.
321
322 Inline call_named() {{{results(=resinlinecall=)}}} four.
323 """ + "\n"
324
325 /// Which code and results the HTML and Markdown exports keep, against ox-html with nothing
326 /// evaluated.
327 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
328 func exportsLikeOrg() throws {
329 let emacs = try EmacsOracle.evaluate(Self.exports, """
330 (progn
331 (require 'ox-html) (require 'ob-shell) (require 'ob-python) (require 'ob-dot)
332 (dolist (v '(org-babel-default-header-args org-babel-default-inline-header-args org-babel-default-lob-header-args))
333 (set v (cons '(:eval . "never-export") (symbol-value v))))
334 (list (org-export-as 'html nil nil t)))
335 """)[0]
336 let words = Self.exports.matches(of: /\b(?:code|res)[a-z]+\b/).map { String($0.0) }
337 let expected = Set(words.filter { emacs.contains($0) })
338 #expect(expected.count > 4 && expected.count < words.count)
339 let html = HTMLExport.page(Self.exports, fallbackTitle: "t")
340 let markdown = MarkdownExport.document(Self.exports)
341 for word in words {
342 #expect(html.contains(word) == expected.contains(word), "HTML \(word)")
343 #expect(markdown.contains(word) == expected.contains(word), "Markdown \(word)")
344 }
345 }
223346}
Tests/OrgCoreTests/KeymapTests.swift +11
@@ -126,6 +126,17 @@ struct KeymapTests {
126126 #expect(KeymapPreset.mac.keymap.keys(for: "org.deadline") == [KeyChord("e", [.control, .command])])
127127 }
128128
129 /// A binding whose keys start a longer live binding never runs: the dispatcher waits for more.
130 @Test func presetsBindNoPrefixes() {
131 for preset in KeymapPreset.allCases {
132 let keymap = preset.keymap
133 for binding in keymap.bindings where !binding.unbinds && keymap.isLive(binding) {
134 #expect(!keymap.isPrefix(binding.keys, mode: binding.mode),
135 "\(preset): \(KeySequence.format(binding.keys)) (\(binding.command)) is also a prefix")
136 }
137 }
138 }
139
129140 @Test func shadowedBindingsShowNoKeys() throws {
130141 var problems: [String] = []
131142 let map = Keymap.layered([KeymapPreset.emacs.keymap, try Keymap(toml: "[[bind]]\nkeys = \"C-c C-t\"\ncommand = \"app.save\"\n", problems: &problems)])
Tests/OrgCoreTests/TangleTests.swift +24
@@ -291,6 +291,30 @@ struct TangleTests {
291291 #expect(Self.describe(ours, root: "/oracle") == theirs.text, "ours:\n\(Self.describe(ours, root: "/oracle"))\nemacs:\n\(theirs.text)\n\(theirs.error)")
292292 }
293293
294 /// The file names only: Emacs also wraps C, D, Java, Fortran and Clojure bodies with org-babel-expand-body:LANG.
295 @Test func extensionsLikeOrg() throws {
296 let languages = ["python", "ruby", "perl", "C", "C++", "D", "awk", "sed", "lua", "haskell", "java", "latex", "LilyPond",
297 "fortran", "clojure", "ocaml", "julia", "sh", "text"]
298 let text = languages.map { "#+begin_src \($0) :tangle yes\n\($0)\n#+end_src\n" }.joined()
299 let features = ["ob-python", "ob-ruby", "ob-perl", "ob-C", "ob-awk", "ob-sed", "ob-lua", "ob-haskell", "ob-java", "ob-latex",
300 "ob-lilypond", "ob-fortran", "ob-clojure", "ob-ocaml", "ob-julia", "ob-shell"]
301 let form = """
302 (let* ((dir (file-truename (make-temp-file "orgstar-tangle" t)))
303 (src (expand-file-name "notes.org" dir))
304 (text (buffer-string)) out)
305 \(features.map { "(require '\($0))" }.joined(separator: " ")) (setq org-babel-clojure-backend 'babashka)
306 (with-temp-file src (insert text))
307 (with-current-buffer (find-file-noselect src)
308 (let ((files (org-babel-tangle)))
309 (setq out (mapconcat (lambda (f) (concat (file-relative-name f dir) "\\n")) (sort (copy-sequence files) #'string<) ""))))
310 (erase-buffer) (insert out))
311 """
312 let theirs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: 1, form: form)])[0]
313 let ours = try Tangle.run(text: text, path: "/oracle/notes.org").get()
314 let names = ours.files.map { ($0.path as NSString).lastPathComponent + "\n" }.sorted().joined()
315 #expect(names == theirs.text, "ours:\n\(names)\nemacs:\n\(theirs.text)\n\(theirs.error)")
316 }
317
294318 @Test func refusesWhatItCantDo() {
295319 for (language, header) in [("haskell", ":comments link"), ("sh", ":tangle (concat user-emacs-directory \"a.sh\")"), ("sh", ":tangle-mode go+q")] {
296320 let text = "#+begin_src \(language) :tangle yes \(header)\necho\n#+end_src\n"