Babel and export fixes from the manual review !161
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. | ||
| 97 | final 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 | |||
| 96 | final class HTMLRenderer { | 123 | final 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. |
| 5 | public enum MarkdownExport { | 5 | public 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 | ||
| 19 | final class MarkdownRenderer { | 23 | final 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 ")" } | 349 | if HTMLRenderer.imageExtensions.contains(ext) { return ")" } |
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). |
| 55 | public protocol CodeHighlighter: Sendable { | 55 | public 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 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import SwiftUI | 2 | import 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. |
| 5 | struct ExportSheet: View { | 5 | struct 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 | ||
| 333 | struct BabelCallTests { | 345 | struct BabelCallTests { |
| @@ -378,6 +390,20 @@ struct BabelCallTests { | |||
| 378 | } | 390 | } |
| 379 | } | 391 | } |
| 380 | 392 | ||
| 393 | extension 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 | |||
| 381 | struct BabelFileTests { | 407 | struct 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 | ||
| 173 | struct ExportCorpusTests { | 215 | struct 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" |