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 {
190 guard let m = line.firstMatch(of: #/(?i)^[ \t]*#\+CALL:[ \t]*([^\s(\[]+)(?:\[([^\]]*)\])?\((.*?)\)[ \t]*(.*?)[ \t]*\n?$/#) else { return nil } 190 guard let m = line.firstMatch(of: #/(?i)^[ \t]*#\+CALL:[ \t]*([^\s(\[]+)(?:\[([^\]]*)\])?\((.*?)\)[ \t]*(.*?)[ \t]*\n?$/#) else { return nil }
191 let name = String(m.1) 191 let name = String(m.1)
192 guard let block = model.srcBlocks.first(where: { $0.name == name }) else { throw Failure.message("Reference '\(name)' not found in this buffer") } 192 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]
194 if let inside = m.2 { extra.append(Params.parse(String(inside))) } 194 if let inside = m.2 { extra.append(Params.parse(String(inside))) }
195 extra.append(splitArguments(String(m.3)).map { (":var", $0) }) 195 extra.append(splitArguments(String(m.3)).map { (":var", $0) })
196 if !m.4.isEmpty { extra.append(Params.parse(String(m.4))) } 196 if !m.4.isEmpty { extra.append(Params.parse(String(m.4))) }
@@ -216,13 +216,49 @@ public enum Babel {
216 guard let object = found, let m = object.text.firstMatch(of: /^call_([^()\[\]\s]+)(?:\[([^\]]*)\])?\((.*?)\)(?:\[([^\]]*)\])?$/) else { return nil } 216 guard let object = found, let m = object.text.firstMatch(of: /^call_([^()\[\]\s]+)(?:\[([^\]]*)\])?\((.*?)\)(?:\[([^\]]*)\])?$/) else { return nil }
217 let name = String(m.1) 217 let name = String(m.1)
218 guard let block = model.srcBlocks.first(where: { $0.name == name }) else { throw Failure.message("Reference '\(name)' not found in this buffer") } 218 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]
220 if let inside = m.2 { extra.append(Params.parse(String(inside))) } 220 if let inside = m.2 { extra.append(Params.parse(String(inside))) }
221 extra.append(splitArguments(String(m.3)).map { (":var", $0) }) 221 extra.append(splitArguments(String(m.3)).map { (":var", $0) })
222 if let end = m.4 { extra.append(Params.parse(String(end))) } 222 if let end = m.4 { extra.append(Params.parse(String(end))) }
223 return (block, try Params(block: block, model: model, text: text, extra: extra), object.range) 223 return (block, try Params(block: block, model: model, text: text, extra: extra), object.range)
224 } 224 }
225 225
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
226 /// Call arguments split on commas outside quotes and parentheses. 262 /// Call arguments split on commas outside quotes and parentheses.
227 static func splitArguments(_ s: String) -> [String] { 263 static func splitArguments(_ s: String) -> [String] {
228 var parts: [String] = [] 264 var parts: [String] = []
@@ -255,9 +291,8 @@ public enum Babel {
255 guard let object = found, let m = object.text.firstMatch(of: /^src_([^\s\[{]+)(?:\[([^\]]*)\])?\{(.*)\}$/) else { return nil } 291 guard let object = found, let m = object.text.firstMatch(of: /^src_([^\s\[{]+)(?:\[([^\]]*)\])?\{(.*)\}$/) else { return nil }
256 let heading = model.headings.lastIndex { $0.headingRange.lowerBound <= object.range.lowerBound } 292 let heading = model.headings.lastIndex { $0.headingRange.lowerBound <= object.range.lowerBound }
257 let block = SrcBlockInfo(range: object.range, heading: heading, name: nil, language: String(m.1), switches: [], body: String(m.3), headerArguments: [:]) 293 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")]] 294 let extra = m.2.map { [Params.parse(String($0))] } ?? []
259 if let headers = m.2 { extra.append(Params.parse(String(headers))) } 295 return (block, try Params(block: block, model: model, text: text, defaults: [(":exports", "results"), (":hlines", "yes")], extra: extra, inline: true))
260 return (block, try Params(block: block, model: model, text: text, extra: extra, inline: true))
261 } 296 }
262 297
263 /// What `:var` references resolve to while planning. 298 /// What `:var` references resolve to while planning.
@@ -294,7 +329,7 @@ public enum Babel {
294 let needsFile = options.file == nil && params.lisp.contains { params.evaluated[$0] == nil } 329 let needsFile = options.file == nil && params.lisp.contains { params.evaluated[$0] == nil }
295 guard let language = block.language else { return .failure(.message("This block has no language")) } 330 guard let language = block.language else { return .failure(.message("This block has no language")) }
296 let eval = params.single[":eval"]?.lowercased() 331 let eval = params.single[":eval"]?.lowercased()
297 if eval == "never" || eval == "no" || eval == "never-export" || eval == "no-export" { 332 if eval == "never" || eval == "no" {
298 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled.")) 333 return .failure(.message("Evaluation of this \(language) code block\(block.name.map { " (\($0)) " } ?? " ")is disabled."))
299 } 334 }
300 if params.single[":cmdline"] != nil, !["dot", "plantuml", "mermaid"].contains(language), !shells.contains(language) { 335 if params.single[":cmdline"] != nil, !["dot", "plantuml", "mermaid"].contains(language), !shells.contains(language) {
@@ -378,7 +413,8 @@ public enum Babel {
378 let shebang = params.single[":shebang"].flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil } 413 let shebang = params.single[":shebang"].flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil }
379 if session == nil, stdin != nil || cmdline != nil || shebang != nil { 414 if session == nil, stdin != nil || cmdline != nil || shebang != nil {
380 // `org-babel-sh-evaluate`: an executable script, run by the shell with the 415 // `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`.
382 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n" 418 var script = (shebang ?? "#!/usr/bin/env \(executable)") + "\n"
383 if params.single[":padline"] != "no" { script += "\n" } 419 if params.single[":padline"] != "no" { script += "\n" }
384 script += (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "") 420 script += (lines + [body]).joined(separator: "\n") + (valueIsExitStatus ? "\necho $?" : "")
@@ -882,8 +918,10 @@ public enum Babel {
882 ["output", "value"], 918 ["output", "value"],
883 ] 919 ]
884 920
885 init(block: SrcBlockInfo, model: DocumentModel, text: NSString, extra: [[(String, String)]] = [], inline: Bool = false) throws { 921 /// `defaults` go under the properties, as `org-babel-default-inline-header-args` do.
886 var layers: [[(String, String)]] = [] 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]
887 let language = block.language ?? "" 925 let language = block.language ?? ""
888 // `org-babel-params-from-properties`: the inherited `header-args`, then the inherited 926 // `org-babel-params-from-properties`: the inherited `header-args`, then the inherited
889 // `header-args:LANG`, each the nearest value with `+` additions after it. 927 // `header-args:LANG`, each the nearest value with `+` additions after it.
@@ -1903,14 +1941,14 @@ public struct ExecuteSrcBlock: OrgCommand {
1903 public var title: String { "Run Source Block" } 1941 public var title: String { "Run Source Block" }
1904 public func applies(in context: EditContext) -> Bool { KeyContext.holds("src", in: context) || Self.atCallOrInline(context) } 1942 public func applies(in context: EditContext) -> Bool { KeyContext.holds("src", in: context) || Self.atCallOrInline(context) }
1905 1943
1906 /// On a `#+CALL:` line or an inline `src_` block. 1944 /// On a `#+CALL:` line, an inline `call_` or an inline `src_` block.
1907 static func atCallOrInline(_ context: EditContext) -> Bool { 1945 static func atCallOrInline(_ context: EditContext) -> Bool {
1908 let ns = context.text as NSString 1946 let ns = context.text as NSString
1909 let line = ns.substring(with: ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))) 1947 let line = ns.substring(with: ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0)))
1910 if line.range(of: "(?i)^[ \\t]*#\\+CALL:", options: .regularExpression) != nil { return true } 1948 if line.range(of: "(?i)^[ \\t]*#\\+CALL:", options: .regularExpression) != nil { return true }
1911 var node = context.tree.root 1949 var node = context.tree.root
1912 while let child = node.child(containing: context.caret) { 1950 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 }
1914 node = child 1952 node = child
1915 } 1953 }
1916 return false 1954 return false
Sources/OrgCore/Compute/Tangle.swift +7 −2
@@ -31,8 +31,13 @@ public enum Tangle {
31 public var blocks: Int 31 public var blocks: Int
32 } 32 }
33 33
34 /// `org-babel-tangle-lang-exts`. 34 /// `org-babel-tangle-lang-exts`, with what Org 9.8.7's ob-LANG files add to it.
35 static let extensions = ["emacs-lisp": "el", "elisp": "el", "bibtex": "bib"] 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 ]
36 /// `org-babel-shell-names`, and `shell` itself. 41 /// `org-babel-shell-names`, and `shell` itself.
37 static let shells: Set<String> = ["sh", "bash", "zsh", "fish", "csh", "ash", "dash", "ksh", "mksh", "posh", "shell"] 42 static let shells: Set<String> = ["sh", "bash", "zsh", "fish", "csh", "ash", "dash", "ksh", "mksh", "posh", "shell"]
38 43
Sources/OrgCore/Export/ExportOptions.swift +33 −2
@@ -194,6 +194,28 @@ public enum ExportPreprocess {
194 } 194 }
195 } 195 }
196 var counters: [String: Int] = [:] 196 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 }
197 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}") 219 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}")
198 var out = "" 220 var out = ""
199 var at = 0 221 var at = 0
@@ -208,8 +230,16 @@ public enum ExportPreprocess {
208 continue 230 continue
209 } 231 }
210 let name = ns.substring(with: m.range(at: 1)).lowercased() 232 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 }
211 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2))) 238 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 }
213 } 243 }
214 out += ns.substring(from: at) 244 out += ns.substring(from: at)
215 return out 245 return out
@@ -240,7 +270,7 @@ public enum ExportPreprocess {
240 } 270 }
241 271
242 static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String], 272 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 {
244 if let template = definitions[name] { 274 if let template = definitions[name] {
245 guard !template.hasPrefix("(eval") else { return "" } 275 guard !template.hasPrefix("(eval") else { return "" }
246 var result = template 276 var result = template
@@ -251,6 +281,7 @@ public enum ExportPreprocess {
251 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? "" 281 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? ""
252 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? "" 282 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? ""
253 case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? "" 283 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) } ?? ""
254 case "n": 285 case "n":
255 let counter = args.first ?? "" 286 let counter = args.first ?? ""
256 let action = args.count > 1 ? args[1] : "" 287 let action = args.count > 1 ? args[1] : ""
Sources/OrgCore/Export/HTMLExport.swift +76 −23
@@ -93,6 +93,33 @@ public enum HTMLExport {
93 } 93 }
94} 94}
95 95
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
96final class HTMLRenderer { 123final class HTMLRenderer {
97 let settings: OrgSettings 124 let settings: OrgSettings
98 let options: ExportOptions 125 let options: ExportOptions
@@ -144,11 +171,23 @@ final class HTMLRenderer {
144 collectDefinitions(tree.root) 171 collectDefinitions(tree.root)
145 selected = Self.selection(tree, tags: options.selectTags) 172 selected = Self.selection(tree, tags: options.selectTags)
146 var out = "" 173 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 }
148 out += footnoteSection() 177 out += footnoteSection()
149 return out 178 return out
150 } 179 }
151 180
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
152 private func collectDefinitions(_ node: SyntaxNode) { 191 private func collectDefinitions(_ node: SyntaxNode) {
153 for child in node.children { 192 for child in node.children {
154 if child.kind == .footnoteDefinition { 193 if child.kind == .footnoteDefinition {
@@ -181,7 +220,9 @@ final class HTMLRenderer {
181 func blocks(_ text: String) -> String { 220 func blocks(_ text: String) -> String {
182 let tree = OrgParser.parse(text, defaults: settings) 221 let tree = OrgParser.parse(text, defaults: settings)
183 var out = "" 222 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 }
185 return out 226 return out
186 } 227 }
187 228
@@ -189,7 +230,7 @@ final class HTMLRenderer {
189 func inlineString(_ text: String) -> String { 230 func inlineString(_ text: String) -> String {
190 let tree = OrgParser.parse(text, defaults: settings) 231 let tree = OrgParser.parse(text, defaults: settings)
191 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) } 232 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) }
193 } 234 }
194 235
195 /// A citation's or a bibliography entry's pieces: text escaped, prefixes and suffixes as 236 /// A citation's or a bibliography entry's pieces: text escaped, prefixes and suffixes as
@@ -209,8 +250,8 @@ final class HTMLRenderer {
209 // MARK: - Elements 250 // MARK: - Elements
210 251
211 private var affiliated: [String: String] = [:] 252 private var affiliated: [String: String] = [:]
212 /// `:exports` of the last src block: its results are exported only for `results` and 253 /// `:exports` of the last src block or call: its results are exported only for `results`
213 /// `both`; nothing runs. 254 /// and `both`; nothing runs.
214 private var lastExports = "code" 255 private var lastExports = "code"
215 256
216 func element(_ node: SyntaxNode) -> String { 257 func element(_ node: SyntaxNode) -> String {
@@ -219,20 +260,18 @@ final class HTMLRenderer {
219 defer { lastExports = "code" } 260 defer { lastExports = "code" }
220 if lastExports != "results", lastExports != "both" { return "" } 261 if lastExports != "results", lastExports != "both" { return "" }
221 } 262 }
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) {
223 lastExports = exports 264 lastExports = exports
224 if exports == "results" || exports == "none" { return "" } 265 if exports == "results" || exports == "none" { return "" }
225 } 266 }
226 switch node.kind { 267 switch node.kind {
227 case .section: 268 case .section:
228 guard let heading = node.firstChild(.heading) else { fallthrough } 269 guard let heading = node.firstChild(.heading) else { fallthrough }
229 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? [] 270 switch Self.sectionExport(heading, options: options, selected: selected) {
230 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? "" 271 case .nothing: return ""
231 // `org-export--skip-p`: excluded tags, COMMENT, unselected trees. 272 case .heading: return self.heading(heading)
232 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return "" } 273 case .all: break
233 if let selected, !selected.contains(heading.range.lowerBound) { return "" } 274 }
234 // Archived trees export their heading only.
235 if tags.contains("ARCHIVE") { return self.heading(heading) }
236 var out = "" 275 var out = ""
237 for child in node.children { out += element(child) } 276 for child in node.children { out += element(child) }
238 return out 277 return out
@@ -311,14 +350,6 @@ final class HTMLRenderer {
311 } 350 }
312 } 351 }
313 352
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
322 private func slug(_ text: String) -> String { 353 private func slug(_ text: String) -> String {
323 var slug = "" 354 var slug = ""
324 var dash = false 355 var dash = false
@@ -344,6 +375,20 @@ final class HTMLRenderer {
344 375
345 /// `org-export--selected-trees`: headings with a select tag, everything below them and 376 /// `org-export--selected-trees`: headings with a select tag, everything below them and
346 /// their ancestors; nil when no heading has one. 377 /// 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
347 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? { 392 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
348 let model = DocumentModel(tree: tree) 393 let model = DocumentModel(tree: tree)
349 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) } 394 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) }
@@ -706,8 +751,14 @@ final class HTMLRenderer {
706 let inner = braced ? String(body.dropFirst().dropLast()) : String(body) 751 let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
707 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>" 752 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
708 case .lineBreak: return "<br>\n" 753 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 ""
710 case .inlineSourceBlock: 758 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 "" }
711 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" } 762 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
712 return Self.escape(node.text) 763 return Self.escape(node.text)
713 case .latexFragment: 764 case .latexFragment:
@@ -724,7 +775,9 @@ final class HTMLRenderer {
724 if inTitle { return "" } 775 if inTitle { return "" }
725 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) } 776 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
726 return render(pieces) 777 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 ""
728 default: return inline(node) 781 default: return inline(node)
729 } 782 }
730 } 783 }
Sources/OrgCore/Export/MarkdownExport.swift +62 −18
@@ -3,14 +3,18 @@ import Foundation
3/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and 3/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and
4/// `[^n]` footnotes. The title, when there is one, becomes the top heading. 4/// `[^n]` footnotes. The title, when there is one, becomes the top heading.
5public enum MarkdownExport { 5public enum MarkdownExport {
6 /// `path` is the file's, for `#+bibliography:`. 6 /// As the HTML page: `#+INCLUDE`, macros, `#+OPTIONS` and the select and exclude tags.
7 public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil) -> String { 7 /// `path` is the file's, for `#+INCLUDE`, `{{{input-file}}}` and `#+bibliography:`.
8 let renderer = MarkdownRenderer(settings: settings) 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)
9 var out = "" 13 var out = ""
10 renderer.inTitle = true 14 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" }
12 renderer.inTitle = false 16 renderer.inTitle = false
13 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }) 17 renderer.citations = CiteProcessor(text: text, directory: directory)
14 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text))) 18 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
15 return out.trimmingCharacters(in: .newlines) + "\n" 19 return out.trimmingCharacters(in: .newlines) + "\n"
16 } 20 }
@@ -18,6 +22,9 @@ public enum MarkdownExport {
18 22
19final class MarkdownRenderer { 23final class MarkdownRenderer {
20 let settings: OrgSettings 24 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>?
21 private var definitions: [String: String] = [:] 28 private var definitions: [String: String] = [:]
22 private var numbers: [String: Int] = [:] 29 private var numbers: [String: Int] = [:]
23 private var notes: [(Int, String)] = [] 30 private var notes: [(Int, String)] = []
@@ -27,8 +34,9 @@ final class MarkdownRenderer {
27 /// Rendering the title, where Org exports citations as nothing. 34 /// Rendering the title, where Org exports citations as nothing.
28 var inTitle = false 35 var inTitle = false
29 36
30 init(settings: OrgSettings) { 37 init(settings: OrgSettings, options: ExportOptions) {
31 self.settings = settings 38 self.settings = settings
39 self.options = options
32 } 40 }
33 41
34 func document(_ text: String) -> String { 42 func document(_ text: String) -> String {
@@ -38,7 +46,8 @@ final class MarkdownRenderer {
38 definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) 46 definitions[String(m.1)] = String(node.text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
39 } 47 }
40 } 48 }
41 var out = blocks(tree.root) 49 selected = HTMLRenderer.selection(tree, tags: options.selectTags)
50 var out = rendering(tree, text) { blocks(tree.root) }
42 if !notes.isEmpty { 51 if !notes.isEmpty {
43 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n" 52 out += notes.map { "[^\($0.0)]: " + $0.1 }.joined(separator: "\n") + "\n"
44 } 53 }
@@ -48,7 +57,17 @@ final class MarkdownRenderer {
48 func inlineString(_ text: String) -> String { 57 func inlineString(_ text: String) -> String {
49 let tree = OrgParser.parse(text, defaults: settings) 58 let tree = OrgParser.parse(text, defaults: settings)
50 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return text } 59 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()
52 } 71 }
53 72
54 private var lastExports = "code" 73 private var lastExports = "code"
@@ -66,7 +85,7 @@ final class MarkdownRenderer {
66 defer { lastExports = "code" } 85 defer { lastExports = "code" }
67 if lastExports != "results", lastExports != "both" { return "" } 86 if lastExports != "results", lastExports != "both" { return "" }
68 } 87 }
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) {
70 lastExports = exports 89 lastExports = exports
71 if exports == "results" || exports == "none" { return "" } 90 if exports == "results" || exports == "none" { return "" }
72 } 91 }
@@ -76,20 +95,28 @@ final class MarkdownRenderer {
76 } 95 }
77 96
78 private func blocks(text: String) -> String { 97 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) }
80 } 100 }
81 101
82 private func element(_ node: SyntaxNode, indent: String) -> String { 102 private func element(_ node: SyntaxNode, indent: String) -> String {
83 switch node.kind { 103 switch node.kind {
84 case .zerothSection, .section: 104 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 }
85 return blocks(node, indent: indent).trimmingCharacters(in: .newlines) 112 return blocks(node, indent: indent).trimmingCharacters(in: .newlines)
86 case .heading: 113 case .heading:
87 let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1 114 let level = node.tokens.first { $0.kind == .stars }?.text.count ?? 1
88 var parts: [String] = [] 115 var parts: [String] = []
89 if let todo = node.tokens.first(where: { $0.kind == .todoKeyword })?.text { parts.append(todo) } 116 if options.todo, 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) } 117 if options.priority, let priority = node.tokens.first(where: { $0.kind == .priority })?.text { parts.append(priority) }
91 if let title = node.firstChild(.title) { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) } 118 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 {
93 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " ")) 120 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
94 } 121 }
95 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ") 122 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
@@ -271,10 +298,21 @@ final class MarkdownRenderer {
271 return String(node.text.dropFirst(3).dropLast(3)) 298 return String(node.text.dropFirst(3).dropLast(3))
272 case .superscript, .subscript: 299 case .superscript, .subscript:
273 let body = node.text.dropFirst() 300 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)
275 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>" 304 return node.text.hasPrefix("_") ? "<sub>\(inner)</sub>" : "<sup>\(inner)</sup>"
276 case .lineBreak: return " \n" 305 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
278 // ox-md uses ox-html's translator: only HTML snippets. 316 // ox-md uses ox-html's translator: only HTML snippets.
279 case .exportSnippet: 317 case .exportSnippet:
280 guard node.tokens.first?.text == "@@html:" else { return "" } 318 guard node.tokens.first?.text == "@@html:" else { return "" }
@@ -283,7 +321,9 @@ final class MarkdownRenderer {
283 if inTitle { return "" } 321 if inTitle { return "" }
284 guard let pieces = citations?.next(node.text) else { return node.text } 322 guard let pieces = citations?.next(node.text) else { return node.text }
285 return render(pieces) 323 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 ""
287 default: return inline(node) 327 default: return inline(node)
288 } 328 }
289 } 329 }
@@ -297,9 +337,13 @@ final class MarkdownRenderer {
297 return "<\(target)>" 337 return "<\(target)>"
298 } 338 }
299 var href = path 339 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 }
301 if path.hasPrefix("id:") { href = "#" + path.dropFirst(3) } 345 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" }
303 if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" } 347 if let description = node.firstChild(.linkDescription) { return "[\(inline(description))](\(href))" }
304 let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? "" 348 let ext = href.split(separator: ".").last.map { $0.lowercased() } ?? ""
305 if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" } 349 if HTMLRenderer.imageExtensions.contains(ext) { return "![](\(href))" }
Sources/OrgCore/Keymap/Presets.swift −3
@@ -753,9 +753,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
753 keys = "C-c C-x A" 753 keys = "C-c C-x A"
754 command = "org.archive.to-sibling" 754 command = "org.archive.to-sibling"
755 [[bind]] 755 [[bind]]
756 keys = "C-c C-e"
757 command = "app.export-dialog"
758 [[bind]]
759 keys = "C-c C-x C-v" 756 keys = "C-c C-x C-v"
760 command = "org.toggle-inline-images" 757 command = "org.toggle-inline-images"
761 [[bind]] 758 [[bind]]
Sources/OrgCore/Semantic/HeadingInfo.swift +4 −1
@@ -17,7 +17,10 @@ public struct Property: Sendable, Equatable {
17 let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) 17 let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
18 guard trimmed.hasPrefix(":") else { return nil } 18 guard trimmed.hasPrefix(":") else { return nil }
19 let rest = trimmed.dropFirst() 19 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 }
21 var key = rest[..<colon] 24 var key = rest[..<colon]
22 guard !key.isEmpty else { return nil } 25 guard !key.isEmpty else { return nil }
23 let additive = key.hasSuffix("+") 26 let additive = key.hasSuffix("+")
Sources/OrgPresentation/Presentation.swift +1 −1
@@ -51,7 +51,7 @@ public enum SyntaxCategory: String, Hashable, Sendable, CaseIterable {
51 } 51 }
52} 52}
53 53
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).
55public protocol CodeHighlighter: Sendable { 55public protocol CodeHighlighter: Sendable {
56 /// Categories for ranges of `code`, in UTF-16 offsets; later runs win over earlier ones. 56 /// Categories for ranges of `code`, in UTF-16 offsets; later runs win over earlier ones.
57 /// Empty for languages it doesn't know. 57 /// Empty for languages it doesn't know.
Sources/Orgstar/ExportSheet.swift +1 −1
@@ -1,7 +1,7 @@
1import AppKit 1import AppKit
2import SwiftUI 2import SwiftUI
3 3
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.
5struct ExportSheet: View { 5struct ExportSheet: View {
6 let source: URL 6 let source: URL
7 let export: (_ format: String, _ destination: URL, _ open: Bool) -> Void 7 let export: (_ format: String, _ destination: URL, _ open: Bool) -> Void
Tests/OrgCoreTests/BabelTests.swift +26
@@ -143,6 +143,7 @@ struct BabelTests {
143 "#+begin_src emacs-lisp :var x=4\n(* x x)\n#+end_src\n", 143 "#+begin_src emacs-lisp :var x=4\n(* x x)\n#+end_src\n",
144 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n", 144 "#+PROPERTY: header-args :results output\n#+begin_src sh\nprintf 'a b\\nc d\\n'\n#+end_src\n",
145 "* Heading\n:PROPERTIES:\n:header-args:sh: :results verbatim\n:END:\n#+begin_src sh\necho 1 2\n#+end_src\n", 145 "* 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",
146 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n", 147 "#+HEADER: :results output\n#+begin_src sh\nprintf 'x y\\nz w\\n'\n#+end_src\n",
147 // `:cmdline`, `:stdin` and `:shebang`. 148 // `:cmdline`, `:stdin` and `:shebang`.
148 "#+begin_src sh :cmdline one \"two words\" :results output\nfor a in \"$@\"; do echo \"[$a]\"; done\n#+end_src\n", 149 "#+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 {
328 #expect(got == message) 329 #expect(got == message)
329 } 330 }
330 } 331 }
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 }
331} 343}
332 344
333struct BabelCallTests { 345struct BabelCallTests {
@@ -378,6 +390,20 @@ struct BabelCallTests {
378 } 390 }
379} 391}
380 392
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
381struct BabelFileTests { 407struct BabelFileTests {
382 static let cases = [ 408 static let cases = [
383 "#+begin_src sh :results file :file out.txt\necho hello\necho there\n#+end_src\n", 409 "#+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 {
44 #expect(!selected.contains("<p>a</p>") && selected.contains("<p>b</p>") && selected.contains("<p>c</p>")) 44 #expect(!selected.contains("<p>a</p>") && selected.contains("<p>b</p>") && selected.contains("<p>c</p>"))
45 } 45 }
46 46
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
47 @Test(.enabled(if: oracle)) 75 @Test(.enabled(if: oracle))
48 func expandsIncludesAndMacrosLikeOrg() throws { 76 func expandsIncludesAndMacrosLikeOrg() throws {
49 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-include-\(UUID().uuidString)") 77 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-include-\(UUID().uuidString)")
Tests/OrgCoreTests/ExportTests.swift +123
@@ -168,6 +168,48 @@ struct MarkdownExportTests {
168 let ours = MarkdownExport.document(org) 168 let ours = MarkdownExport.document(org)
169 #expect(ours == expected) 169 #expect(ours == expected)
170 } 170 }
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 }
171} 213}
172 214
173struct ExportCorpusTests { 215struct ExportCorpusTests {
@@ -220,4 +262,85 @@ struct ExportResultsTests {
220 #expect(html.contains("echo b") && html.contains(">b</pre>")) 262 #expect(html.contains("echo b") && html.contains(">b</pre>"))
221 #expect(!html.contains("echo c") && html.contains(">c</pre>")) 263 #expect(!html.contains("echo c") && html.contains(">c</pre>"))
222 } 264 }
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 }
223} 346}
Tests/OrgCoreTests/KeymapTests.swift +11
@@ -126,6 +126,17 @@ struct KeymapTests {
126 #expect(KeymapPreset.mac.keymap.keys(for: "org.deadline") == [KeyChord("e", [.control, .command])]) 126 #expect(KeymapPreset.mac.keymap.keys(for: "org.deadline") == [KeyChord("e", [.control, .command])])
127 } 127 }
128 128
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
129 @Test func shadowedBindingsShowNoKeys() throws { 140 @Test func shadowedBindingsShowNoKeys() throws {
130 var problems: [String] = [] 141 var problems: [String] = []
131 let map = Keymap.layered([KeymapPreset.emacs.keymap, try Keymap(toml: "[[bind]]\nkeys = \"C-c C-t\"\ncommand = \"app.save\"\n", problems: &problems)]) 142 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 {
291 #expect(Self.describe(ours, root: "/oracle") == theirs.text, "ours:\n\(Self.describe(ours, root: "/oracle"))\nemacs:\n\(theirs.text)\n\(theirs.error)") 291 #expect(Self.describe(ours, root: "/oracle") == theirs.text, "ours:\n\(Self.describe(ours, root: "/oracle"))\nemacs:\n\(theirs.text)\n\(theirs.error)")
292 } 292 }
293 293
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
294 @Test func refusesWhatItCantDo() { 318 @Test func refusesWhatItCantDo() {
295 for (language, header) in [("haskell", ":comments link"), ("sh", ":tangle (concat user-emacs-directory \"a.sh\")"), ("sh", ":tangle-mode go+q")] { 319 for (language, header) in [("haskell", ":comments link"), ("sh", ":tangle (concat user-emacs-directory \"a.sh\")"), ("sh", ":tangle-mode go+q")] {
296 let text = "#+begin_src \(language) :tangle yes \(header)\necho\n#+end_src\n" 320 let text = "#+begin_src \(language) :tangle yes \(header)\necho\n#+end_src\n"