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