Commit 9879b1b41a
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +2 −2
| @@ -111,10 +111,10 @@ public final class AgendaModel { | |||
| 111 | if let cached = read.cached { | 111 | if let cached = read.cached { |
| 112 | sources.append((read.path, read.mtime, read.size, cached, true)) | 112 | sources.append((read.path, read.mtime, read.size, cached, true)) |
| 113 | } else if let text = read.text { | 113 | } else if let text = read.text { |
| 114 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), false)) | 114 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults.resolvingSetupFiles(in: text, path: read.path, read: OrgSettings.readFile)), false)) |
| 115 | } else if let data = FileManager.default.contents(atPath: read.path) { | 115 | } else if let data = FileManager.default.contents(atPath: read.path) { |
| 116 | let text = SourceText(bytes: [UInt8](data)).text | 116 | let text = SourceText(bytes: [UInt8](data)).text |
| 117 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) | 117 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults.resolvingSetupFiles(in: text, path: read.path, read: OrgSettings.readFile)), true)) |
| 118 | } | 118 | } |
| 119 | } | 119 | } |
| 120 | return sources | 120 | return sources |
Sources/OrgApp/DocumentSession.swift +13 −5
| @@ -77,6 +77,14 @@ public final class DocumentSession { | |||
| 77 | /// TODO keywords and priorities for files without their own settings. | 77 | /// TODO keywords and priorities for files without their own settings. |
| 78 | public var defaults: OrgSettings = .default | 78 | public var defaults: OrgSettings = .default |
| 79 | 79 | ||
| 80 | /// A buffer for `bytes` read from `path`, with the keywords of the setup files it names. | ||
| 81 | func loaded(_ bytes: [UInt8], path: String) -> DocumentState { | ||
| 82 | let settings = defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path) { file in | ||
| 83 | (try? self.fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) } | ||
| 84 | } | ||
| 85 | return DocumentState(bytes: bytes, defaults: settings) | ||
| 86 | } | ||
| 87 | |||
| 80 | /// One open file. | 88 | /// One open file. |
| 81 | @MainActor | 89 | @MainActor |
| 82 | final class Entry { | 90 | final class Entry { |
| @@ -154,7 +162,7 @@ public final class DocumentSession { | |||
| 154 | lastError = "\((path as NSString).lastPathComponent) no longer exists." | 162 | lastError = "\((path as NSString).lastPathComponent) no longer exists." |
| 155 | return | 163 | return |
| 156 | } | 164 | } |
| 157 | let entry = Entry(path: path, buffer: StateBuffer(DocumentState(bytes: bytes, defaults: defaults))) | 165 | let entry = Entry(path: path, buffer: StateBuffer(loaded(bytes, path: path))) |
| 158 | let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count | 166 | let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count |
| 159 | entries.insert(entry, at: index) | 167 | entries.insert(entry, at: index) |
| 160 | activate(entry) | 168 | activate(entry) |
| @@ -467,7 +475,7 @@ public final class DocumentSession { | |||
| 467 | state = open.buffer.document | 475 | state = open.buffer.document |
| 468 | } else { | 476 | } else { |
| 469 | guard let bytes = try? fileSystem.read(URL(fileURLWithPath: from.path)) else { return nil } | 477 | guard let bytes = try? fileSystem.read(URL(fileURLWithPath: from.path)) else { return nil } |
| 470 | state = DocumentState(bytes: bytes, defaults: defaults) | 478 | state = loaded(bytes, path: from.path) |
| 471 | } | 479 | } |
| 472 | guard state.isEditable else { return nil } | 480 | guard state.isEditable else { return nil } |
| 473 | let context = EditContext(revision: state.revision, text: state.text, tree: state.tree, selection: [from.offset..<from.offset], now: now, options: options) | 481 | let context = EditContext(revision: state.revision, text: state.text, tree: state.tree, selection: [from.offset..<from.offset], now: now, options: options) |
| @@ -510,7 +518,7 @@ public final class DocumentSession { | |||
| 510 | } | 518 | } |
| 511 | do { | 519 | do { |
| 512 | guard let bytes = try fileSystem.read(url) else { return "\(targetName) no longer exists." } | 520 | guard let bytes = try fileSystem.read(url) else { return "\(targetName) no longer exists." } |
| 513 | var state = DocumentState(bytes: bytes, defaults: defaults) | 521 | var state = loaded(bytes, path: url.path) |
| 514 | guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." } | 522 | guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." } |
| 515 | guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." } | 523 | guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." } |
| 516 | guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } | 524 | guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } |
| @@ -554,7 +562,7 @@ public final class DocumentSession { | |||
| 554 | do { | 562 | do { |
| 555 | let url = URL(fileURLWithPath: destination.path) | 563 | let url = URL(fileURLWithPath: destination.path) |
| 556 | let existing = destination.isSameFile ? nil : try fileSystem.read(url) | 564 | let existing = destination.isSameFile ? nil : try fileSystem.read(url) |
| 557 | var state = DocumentState(bytes: existing ?? [], defaults: defaults) | 565 | var state = loaded(existing ?? [], path: url.path) |
| 558 | if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." } | 566 | if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." } |
| 559 | let edits: Archive.Edits | 567 | let edits: Archive.Edits |
| 560 | switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) { | 568 | switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) { |
| @@ -631,7 +639,7 @@ public final class DocumentSession { | |||
| 631 | let name = url.lastPathComponent | 639 | let name = url.lastPathComponent |
| 632 | do { | 640 | do { |
| 633 | guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") } | 641 | guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") } |
| 634 | var state = DocumentState(bytes: bytes, defaults: defaults) | 642 | var state = loaded(bytes, path: path) |
| 635 | guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") } | 643 | guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") } |
| 636 | guard matches(state.text) else { return stale } | 644 | guard matches(state.text) else { return stale } |
| 637 | let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) | 645 | let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) |
Sources/OrgCore/Agenda/AgendaSource.swift +1 −1
| @@ -106,7 +106,7 @@ public struct AgendaSource: Sendable { | |||
| 106 | collect(tree.root) | 106 | collect(tree.root) |
| 107 | func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] } | 107 | func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] } |
| 108 | 108 | ||
| 109 | var keywordCategory: String? | 109 | var keywordCategory = SetupFile.keywords(tree.settings.setupKeywords).last { $0.key == "CATEGORY" }?.value.trimmingCharacters(in: .whitespaces) |
| 110 | var srcBodies: [Range<Int>] = [] | 110 | var srcBodies: [Range<Int>] = [] |
| 111 | var planningLines: [Range<Int>] = [] | 111 | var planningLines: [Range<Int>] = [] |
| 112 | var timestampNodes: [Int] = [] | 112 | var timestampNodes: [Int] = [] |
Sources/OrgCore/Commands/Archive.swift +3 −1
| @@ -9,7 +9,9 @@ extension EmacsBuffer { | |||
| 9 | func keyword(_ name: String) -> String? { | 9 | func keyword(_ name: String) -> String? { |
| 10 | saveExcursion { | 10 | saveExcursion { |
| 11 | point = 0 | 11 | point = 0 |
| 12 | guard let match = searchForward("^[ \\t]*#\\+" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]*(.*?)[ \\t]*$") else { return nil } | 12 | guard let match = searchForward("^[ \\t]*#\\+" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]*(.*?)[ \\t]*$") else { |
| 13 | return SetupFile.keywords(settings.setupKeywords).last { $0.key == name.uppercased() }?.value.trimmingCharacters(in: .whitespaces) | ||
| 14 | } | ||
| 13 | return substring(match[1]!.lowerBound, match[1]!.upperBound) | 15 | return substring(match[1]!.lowerBound, match[1]!.upperBound) |
| 14 | } | 16 | } |
| 15 | } | 17 | } |
Sources/OrgCore/Commands/ColumnView.swift +1 −1
| @@ -239,7 +239,7 @@ public enum ColumnView { | |||
| 239 | } | 239 | } |
| 240 | if top == nil { top = here } | 240 | if top == nil { top = here } |
| 241 | } | 241 | } |
| 242 | if format == nil, let keyword = text.firstMatch(of: #/(?mi)^[ \t]*#\+COLUMNS:[ \t]+(.+?)[ \t]*$/#) { format = String(keyword.1) } | 242 | if format == nil, let keyword = (tree.settings.setupKeywords + text).firstMatch(of: #/(?mi)^[ \t]*#\+COLUMNS:[ \t]+(.+?)[ \t]*$/#) { format = String(keyword.1) } |
| 243 | let specs = compile(format ?? defaultFormat) | 243 | let specs = compile(format ?? defaultFormat) |
| 244 | // The entries in scope, without archived or commented subtrees. | 244 | // The entries in scope, without archived or commented subtrees. |
| 245 | var scope: [Int] = [] | 245 | var scope: [Int] = [] |
Sources/OrgCore/Commands/InlineImages.swift +1 −1
| @@ -34,7 +34,7 @@ public enum InlineImages { | |||
| 34 | /// `#+STARTUP: inlineimages` or `noinlineimages`, if the file says. | 34 | /// `#+STARTUP: inlineimages` or `noinlineimages`, if the file says. |
| 35 | public static func startup(_ tree: OrgTree) -> Bool? { | 35 | public static func startup(_ tree: OrgTree) -> Bool? { |
| 36 | var shown: Bool? | 36 | var shown: Bool? |
| 37 | for m in tree.root.text.matches(of: #/(?mi)^[ \t]*#\+STARTUP:(.*)$/#) { | 37 | for m in (tree.settings.setupKeywords + tree.root.text).matches(of: #/(?mi)^[ \t]*#\+STARTUP:(.*)$/#) { |
| 38 | for word in m.1.split(whereSeparator: \.isWhitespace) { | 38 | for word in m.1.split(whereSeparator: \.isWhitespace) { |
| 39 | if word.lowercased() == "inlineimages" { shown = true } | 39 | if word.lowercased() == "inlineimages" { shown = true } |
| 40 | if word.lowercased() == "noinlineimages" { shown = false } | 40 | if word.lowercased() == "noinlineimages" { shown = false } |
Sources/OrgCore/Commands/TagCommands.swift +1 −1
| @@ -37,7 +37,7 @@ public struct SetTags: OrgCommand { | |||
| 37 | } | 37 | } |
| 38 | let currentText = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? "" | 38 | let currentText = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? "" |
| 39 | let current = currentText.split(separator: ":").map(String.init) | 39 | let current = currentText.split(separator: ":").map(String.init) |
| 40 | let table = FastSelection.tagTable(context.text) ?? Self.bufferTags(context).map { .option(key: "\0", name: $0) } | 40 | let table = FastSelection.tagTable(context.tree.settings.setupKeywords + context.text) ?? Self.bufferTags(context).map { .option(key: "\0", name: $0) } |
| 41 | let tags: [String] | 41 | let tags: [String] |
| 42 | if let fast = FastSelection.tags(table: table, current: current, inherited: inheritedTags(context)) { | 42 | if let fast = FastSelection.tags(table: table, current: current, inherited: inheritedTags(context)) { |
| 43 | guard let answer = context.answers["tags"] else { | 43 | guard let answer = context.answers["tags"] else { |
Sources/OrgCore/Commands/TodoLogging.swift +3
| @@ -82,6 +82,9 @@ extension EmacsBuffer { | |||
| 82 | if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) } | 82 | if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) } |
| 83 | } | 83 | } |
| 84 | } | 84 | } |
| 85 | for (key, value) in SetupFile.keywords(settings.setupKeywords) where key == "STARTUP" { | ||
| 86 | for word in value.split(whereSeparator: \.isWhitespace) { log.apply(startup: String(word)) } | ||
| 87 | } | ||
| 85 | let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive]) | 88 | let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive]) |
| 86 | for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) { | 89 | for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) { |
| 87 | for word in substring(match.range(at: 1).location, NSMaxRange(match.range(at: 1))).split(whereSeparator: \.isWhitespace) { | 90 | for word in substring(match.range(at: 1).location, NSMaxRange(match.range(at: 1))).split(whereSeparator: \.isWhitespace) { |
Sources/OrgCore/Completion/OrgCompletion.swift +2 −2
| @@ -113,7 +113,7 @@ public enum OrgCompletion { | |||
| 113 | var p = caret | 113 | var p = caret |
| 114 | while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), CharacterSet(charactersIn: "-_").union(.alphanumerics).contains(ch), ch.isASCII { p -= 1 } | 114 | while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), CharacterSet(charactersIn: "-_").union(.alphanumerics).contains(ch), ch.isASCII { p -= 1 } |
| 115 | if p - 2 >= start, ns.character(at: p - 1) == 91, ns.character(at: p - 2) == 91 { | 115 | if p - 2 >= start, ns.character(at: p - 1) == 91, ns.character(at: p - 2) == 91 { |
| 116 | let abbreviations = Links.abbreviations(in: context.text).keys.map { $0 + ":" } | 116 | let abbreviations = Links.abbreviations(in: context.tree.settings.setupKeywords + context.text).keys.map { $0 + ":" } |
| 117 | return make(.link, from: p, abbreviations + linkTypes.map { $0 + ":" }) | 117 | return make(.link, from: p, abbreviations + linkTypes.map { $0 + ":" }) |
| 118 | } | 118 | } |
| 119 | } | 119 | } |
| @@ -136,7 +136,7 @@ public enum OrgCompletion { | |||
| 136 | let from = colon + ((typed as NSString).range(of: ":", options: .backwards).toOptional.map { NSMaxRange($0) } ?? 0) | 136 | let from = colon + ((typed as NSString).range(of: ":", options: .backwards).toOptional.map { NSMaxRange($0) } ?? 0) |
| 137 | let heading = DocumentModel(tree: context.tree).headings.last { $0.headingRange.lowerBound <= caret } | 137 | let heading = DocumentModel(tree: context.tree).headings.last { $0.headingRange.lowerBound <= caret } |
| 138 | let own = Set(heading?.localTags ?? []) | 138 | let own = Set(heading?.localTags ?? []) |
| 139 | let alist = FastSelection.tagTable(context.text)?.compactMap { item -> String? in | 139 | let alist = FastSelection.tagTable(context.tree.settings.setupKeywords + context.text)?.compactMap { item -> String? in |
| 140 | if case .option(_, let name) = item { return name } | 140 | if case .option(_, let name) = item { return name } |
| 141 | return nil | 141 | return nil |
| 142 | } ?? [] | 142 | } ?? [] |
Sources/OrgCore/Compute/TableFormulas.swift +1 −1
| @@ -369,7 +369,7 @@ extension EmacsBuffer { | |||
| 369 | func substituteConstants(_ formula: String) -> String { | 369 | func substituteConstants(_ formula: String) -> String { |
| 370 | guard formula.range(of: "\\$[a-zA-Z]", options: .regularExpression) != nil else { return formula } | 370 | guard formula.range(of: "\\$[a-zA-Z]", options: .regularExpression) != nil else { return formula } |
| 371 | var constants: [String: String] = [:] | 371 | var constants: [String: String] = [:] |
| 372 | for m in string.matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) { | 372 | for m in (settings.setupKeywords + string).matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) { |
| 373 | for pair in m.1.split(whereSeparator: \.isWhitespace) { | 373 | for pair in m.1.split(whereSeparator: \.isWhitespace) { |
| 374 | let parts = pair.split(separator: "=", maxSplits: 1) | 374 | let parts = pair.split(separator: "=", maxSplits: 1) |
| 375 | if parts.count == 2, constants[String(parts[0])] == nil { constants[String(parts[0])] = String(parts[1]) } | 375 | if parts.count == 2, constants[String(parts[0])] == nil { constants[String(parts[0])] = String(parts[1]) } |
Sources/OrgCore/Export/ExportOptions.swift +3 −2
| @@ -179,11 +179,12 @@ public enum ExportPreprocess { | |||
| 179 | /// `{{{name(arg, …)}}}` replaced: `#+MACRO` definitions with `$1`… and the built-in title, | 179 | /// `{{{name(arg, …)}}}` replaced: `#+MACRO` definitions with `$1`… and the built-in title, |
| 180 | /// author, date, email, input-file, keyword, property, n and time macros. Others become | 180 | /// author, date, email, input-file, keyword, property, n and time macros. Others become |
| 181 | /// empty, as Lisp ones do here. | 181 | /// empty, as Lisp ones do here. |
| 182 | public static func macros(_ text: String, path: String?, now: Date = Date()) -> String { | 182 | /// `setup` is the setup files' keyword lines, whose definitions count too. |
| 183 | public static func macros(_ text: String, path: String?, now: Date = Date(), setup: String = "") -> String { | ||
| 183 | guard text.contains("{{{") else { return text } | 184 | guard text.contains("{{{") else { return text } |
| 184 | var definitions: [String: String] = [:] | 185 | var definitions: [String: String] = [:] |
| 185 | var keywords: [String: String] = [:] | 186 | var keywords: [String: String] = [:] |
| 186 | for m in text.matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) { | 187 | for m in (setup + text).matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) { |
| 187 | let key = m.1.uppercased() | 188 | let key = m.1.uppercased() |
| 188 | let value = String(m.2) | 189 | let value = String(m.2) |
| 189 | if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) { | 190 | if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) { |
Sources/OrgCore/Export/HTMLExport.swift +15 −14
| @@ -19,18 +19,19 @@ public enum HTMLExport { | |||
| 19 | public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata { | 19 | public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata { |
| 20 | let tree = OrgParser.parse(text, defaults: settings) | 20 | let tree = OrgParser.parse(text, defaults: settings) |
| 21 | var meta = ExportMetadata() | 21 | var meta = ExportMetadata() |
| 22 | for node in tree.root.children where node.kind == .zerothSection { | 22 | // Setup files' keywords come first, as Org reads them where `#+SETUPFILE` is. |
| 23 | for child in node.children where child.kind == .keyword { | 23 | let setup = settings.setupKeywords.split(separator: "\n").map(String.init) |
| 24 | let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines) | 24 | let own = tree.root.children.filter { $0.kind == .zerothSection }.flatMap { $0.children.filter { $0.kind == .keyword } }.map(\.text) |
| 25 | guard let colon = line.firstIndex(of: ":") else { continue } | 25 | for text in setup + own { |
| 26 | let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased() | 26 | let line = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 27 | let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) | 27 | guard let colon = line.firstIndex(of: ":") else { continue } |
| 28 | switch key { | 28 | let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased() |
| 29 | case "TITLE": meta.title = value | 29 | let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) |
| 30 | case "AUTHOR": meta.author = value | 30 | switch key { |
| 31 | case "DATE": meta.date = value | 31 | case "TITLE": meta.title = value |
| 32 | default: break | 32 | case "AUTHOR": meta.author = value |
| 33 | } | 33 | case "DATE": meta.date = value |
| 34 | default: break | ||
| 34 | } | 35 | } |
| 35 | } | 36 | } |
| 36 | return meta | 37 | return meta |
| @@ -41,8 +42,8 @@ public enum HTMLExport { | |||
| 41 | /// `{{{input-file}}}`. | 42 | /// `{{{input-file}}}`. |
| 42 | public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String { | 43 | public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String { |
| 43 | let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() } | 44 | let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() } |
| 44 | let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now) | 45 | let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords) |
| 45 | let options = ExportOptions.parse(text) | 46 | let options = ExportOptions.parse(settings.setupKeywords + text) |
| 46 | let meta = metadata(text, settings: settings) | 47 | let meta = metadata(text, settings: settings) |
| 47 | let title = meta.title ?? fallbackTitle | 48 | let title = meta.title ?? fallbackTitle |
| 48 | let renderer = HTMLRenderer(settings: settings, options: options) | 49 | let renderer = HTMLRenderer(settings: settings, options: options) |
Sources/OrgCore/Links/LinkStore.swift +1 −1
| @@ -247,7 +247,7 @@ public struct InsertLink: OrgCommand { | |||
| 247 | guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) } | 247 | guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) } |
| 248 | link = answer | 248 | link = answer |
| 249 | } else { | 249 | } else { |
| 250 | let prefixes = Array(Links.abbreviations(in: context.text).keys).sorted() + Links.types | 250 | let prefixes = Array(Links.abbreviations(in: context.tree.settings.setupKeywords + context.text).keys).sorted() + Links.types |
| 251 | guard let answer = context.answers["link"] else { | 251 | guard let answer = context.answers["link"] else { |
| 252 | let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:" | 252 | let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:" |
| 253 | let choices = stored.map(\.link) + stored.compactMap(\.description) + prefixes.map { $0 + ":" } | 253 | let choices = stored.map(\.link) + stored.compactMap(\.description) + prefixes.map { $0 + ":" } |
Sources/OrgCore/Links/Links.swift +1 −1
| @@ -133,7 +133,7 @@ public enum Links { | |||
| 133 | guard let node = found else { return nil } | 133 | guard let node = found else { return nil } |
| 134 | let whole = node.text | 134 | let whole = node.text |
| 135 | if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) { | 135 | if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) { |
| 136 | return (parse(bracket: path.text, abbreviations: abbreviations(in: text)), node.range) | 136 | return (parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text)), node.range) |
| 137 | } | 137 | } |
| 138 | let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole | 138 | let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole |
| 139 | return (parse(plain: plain), node.range) | 139 | return (parse(plain: plain), node.range) |
Sources/OrgCore/Parser/Settings.swift +27 −16
| @@ -47,6 +47,9 @@ public struct OrgSettings: Sendable, Equatable { | |||
| 47 | public var radioTargets: [String] = [] | 47 | public var radioTargets: [String] = [] |
| 48 | /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets. | 48 | /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets. |
| 49 | public var listAllowAlphabetical = false | 49 | public var listAllowAlphabetical = false |
| 50 | /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as | ||
| 51 | /// the file's own, before them. | ||
| 52 | public var setupKeywords = "" | ||
| 50 | 53 | ||
| 51 | public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) { | 54 | public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) { |
| 52 | self.todoSequences = todoSequences | 55 | self.todoSequences = todoSequences |
| @@ -80,35 +83,42 @@ public struct OrgSettings: Sendable, Equatable { | |||
| 80 | 83 | ||
| 81 | enum SettingsScanner { | 84 | enum SettingsScanner { |
| 82 | /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO` and `#+PRIORITIES` outside blocks. Any TODO | 85 | /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO` and `#+PRIORITIES` outside blocks. Any TODO |
| 83 | /// line replaces the default sequences, as in org. | 86 | /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then |
| 87 | /// `TODO`, then `SEQ_TODO`. | ||
| 84 | static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings { | 88 | static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings { |
| 85 | var sequences: [TodoSequence] = [] | 89 | var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = [] |
| 86 | var priorities = defaults.priorities | 90 | var priorities = defaults.priorities |
| 91 | func apply(_ key: String, _ value: Substring) { | ||
| 92 | switch key { | ||
| 93 | case "TODO": | ||
| 94 | if let s = todoSequence(value, kind: .sequence) { todos.append(s) } | ||
| 95 | case "SEQ_TODO": | ||
| 96 | if let s = todoSequence(value, kind: .sequence) { seqs.append(s) } | ||
| 97 | case "TYP_TODO": | ||
| 98 | if let s = todoSequence(value, kind: .type) { types.append(s) } | ||
| 99 | case "PRIORITIES": | ||
| 100 | let words = value.split(whereSeparator: \.isWhitespace) | ||
| 101 | if words.count == 3 { | ||
| 102 | priorities = Priorities(highest: String(words[0]), lowest: String(words[1]), default: String(words[2])) | ||
| 103 | } | ||
| 104 | default: | ||
| 105 | break | ||
| 106 | } | ||
| 107 | } | ||
| 108 | for (key, value) in SetupFile.keywords(defaults.setupKeywords) { apply(key, value) } | ||
| 87 | var k = 0 | 109 | var k = 0 |
| 88 | while k < lines.count { | 110 | while k < lines.count { |
| 89 | switch info[k].cls { | 111 | switch info[k].cls { |
| 90 | case .blockBegin, .dynamicBegin: | 112 | case .blockBegin, .dynamicBegin: |
| 91 | if let end = blockEnds[k] { k = end } | 113 | if let end = blockEnds[k] { k = end } |
| 92 | case .keyword(let key): | 114 | case .keyword(let key): |
| 93 | let value = keywordValue(lines[k].content) | 115 | apply(key, keywordValue(lines[k].content)) |
| 94 | switch key { | ||
| 95 | case "TODO", "SEQ_TODO": | ||
| 96 | if let s = todoSequence(value, kind: .sequence) { sequences.append(s) } | ||
| 97 | case "TYP_TODO": | ||
| 98 | if let s = todoSequence(value, kind: .type) { sequences.append(s) } | ||
| 99 | case "PRIORITIES": | ||
| 100 | let words = value.split(whereSeparator: \.isWhitespace) | ||
| 101 | if words.count == 3 { | ||
| 102 | priorities = Priorities(highest: String(words[0]), lowest: String(words[1]), default: String(words[2])) | ||
| 103 | } | ||
| 104 | default: | ||
| 105 | break | ||
| 106 | } | ||
| 107 | default: | 116 | default: |
| 108 | break | 117 | break |
| 109 | } | 118 | } |
| 110 | k += 1 | 119 | k += 1 |
| 111 | } | 120 | } |
| 121 | let sequences = types + todos + seqs | ||
| 112 | var radio: [String] = [] | 122 | var radio: [String] = [] |
| 113 | for line in lines where line.content.contains("<<<") { | 123 | for line in lines where line.content.contains("<<<") { |
| 114 | for m in String(line.content).matches(of: /<<<([^<>\n\r \t](?:[^<>\n\r]*[^<>\n\r \t])?)>>>/) where !radio.contains(String(m.1)) { | 124 | for m in String(line.content).matches(of: /<<<([^<>\n\r \t](?:[^<>\n\r]*[^<>\n\r \t])?)>>>/) where !radio.contains(String(m.1)) { |
| @@ -118,6 +128,7 @@ enum SettingsScanner { | |||
| 118 | var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities, | 128 | var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities, |
| 119 | radioTargets: radio.sorted { $0.count > $1.count }) | 129 | radioTargets: radio.sorted { $0.count > $1.count }) |
| 120 | settings.listAllowAlphabetical = defaults.listAllowAlphabetical | 130 | settings.listAllowAlphabetical = defaults.listAllowAlphabetical |
| 131 | settings.setupKeywords = defaults.setupKeywords | ||
| 121 | return settings | 132 | return settings |
| 122 | } | 133 | } |
| 123 | 134 | ||
Sources/OrgCore/Parser/SetupFile.swift added +56
| @@ -0,0 +1,56 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// `#+SETUPFILE` (`org--collect-keywords-1`): each setup file's keywords count as if they were | ||
| 4 | /// in the file that names it, setup files named there included. Paths are relative to the | ||
| 5 | /// naming file's folder; URLs aren't fetched. | ||
| 6 | public enum SetupFile { | ||
| 7 | /// The keyword lines the setup files `text` names bring, in order, as `#+KEY: value` lines. | ||
| 8 | /// `read` gives a file's text, or nil when it can't be read. | ||
| 9 | public static func keywordLines(in text: String, directory: String, read: (String) -> String?) -> String { | ||
| 10 | lines(in: text, directory: directory, read: read, own: false, seen: []) | ||
| 11 | } | ||
| 12 | |||
| 13 | private static func lines(in text: String, directory: String, read: (String) -> String?, own: Bool, seen: Set<String>) -> String { | ||
| 14 | guard own || text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return "" } | ||
| 15 | var out = "" | ||
| 16 | for node in OrgParser.parse(text).root.descendants() where node.kind == .keyword { | ||
| 17 | let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 18 | guard let colon = line.firstIndex(of: ":") else { continue } | ||
| 19 | let key = line[line.index(line.startIndex, offsetBy: 2)..<colon].uppercased() | ||
| 20 | guard key == "SETUPFILE" else { | ||
| 21 | if own { out += line + "\n" } | ||
| 22 | continue | ||
| 23 | } | ||
| 24 | var value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) | ||
| 25 | if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { value = String(value.dropFirst().dropLast()) } | ||
| 26 | guard !value.isEmpty, value.range(of: "^[a-zA-Z][a-zA-Z0-9+.-]*://", options: .regularExpression) == nil else { continue } | ||
| 27 | let expanded = (value as NSString).expandingTildeInPath | ||
| 28 | let path = ((expanded.hasPrefix("/") ? expanded : (directory as NSString).appendingPathComponent(expanded)) as NSString).standardizingPath | ||
| 29 | guard !seen.contains(path), let contents = read(path) else { continue } | ||
| 30 | out += lines(in: contents, directory: (path as NSString).deletingLastPathComponent, read: read, own: true, seen: seen.union([path])) | ||
| 31 | } | ||
| 32 | return out | ||
| 33 | } | ||
| 34 | |||
| 35 | /// `(key, value)` for each `#+KEY: value` line. | ||
| 36 | static func keywords(_ lines: String) -> [(key: String, value: Substring)] { | ||
| 37 | lines.split(separator: "\n").compactMap { line in | ||
| 38 | guard line.hasPrefix("#+"), let colon = line.firstIndex(of: ":") else { return nil } | ||
| 39 | return (line[line.index(line.startIndex, offsetBy: 2)..<colon].uppercased(), line[line.index(after: colon)...]) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | extension OrgSettings { | ||
| 45 | /// These settings with the keywords of the setup files `text` names, for a file at `path`. | ||
| 46 | public func resolvingSetupFiles(in text: String, path: String, read: (String) -> String?) -> OrgSettings { | ||
| 47 | var copy = self | ||
| 48 | copy.setupKeywords = SetupFile.keywordLines(in: text, directory: (path as NSString).deletingLastPathComponent, read: read) | ||
| 49 | return copy | ||
| 50 | } | ||
| 51 | |||
| 52 | /// A file's text readable as UTF-8, for `resolvingSetupFiles`. | ||
| 53 | public static func readFile(_ path: String) -> String? { | ||
| 54 | FileManager.default.contents(atPath: path).flatMap { String(data: $0, encoding: .utf8) } | ||
| 55 | } | ||
| 56 | } | ||
Sources/OrgCore/Semantic/DocumentModel.swift +5 −2
| @@ -22,6 +22,7 @@ public struct DocumentModel: Sendable { | |||
| 22 | public init(tree: OrgTree, settings: SemanticSettings = .default) { | 22 | public init(tree: OrgTree, settings: SemanticSettings = .default) { |
| 23 | self.settings = settings | 23 | self.settings = settings |
| 24 | self.orgSettings = tree.settings | 24 | self.orgSettings = tree.settings |
| 25 | for (key, value) in SetupFile.keywords(tree.settings.setupKeywords) { fileKeyword(key, value.trimmingCharacters(in: .whitespaces)) } | ||
| 25 | var pending: [PendingBlock] = [] | 26 | var pending: [PendingBlock] = [] |
| 26 | walk(tree.root, heading: nil, pending: &pending) | 27 | walk(tree.root, heading: nil, pending: &pending) |
| 27 | // Header arguments depend on file and heading properties, so blocks resolve last. | 28 | // Header arguments depend on file and heading properties, so blocks resolve last. |
| @@ -205,8 +206,10 @@ public struct DocumentModel: Sendable { | |||
| 205 | private mutating func fileKeyword(_ node: SyntaxNode) { | 206 | private mutating func fileKeyword(_ node: SyntaxNode) { |
| 206 | let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines) | 207 | let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 207 | guard let colon = text.firstIndex(of: ":") else { return } | 208 | guard let colon = text.firstIndex(of: ":") else { return } |
| 208 | let key = text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased() | 209 | fileKeyword(text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased(), text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)) |
| 209 | let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces) | 210 | } |
| 211 | |||
| 212 | private mutating func fileKeyword(_ key: String, _ value: String) { | ||
| 210 | switch key { | 213 | switch key { |
| 211 | case "FILETAGS": | 214 | case "FILETAGS": |
| 212 | fileTags += value.split(whereSeparator: { $0 == ":" || $0.isWhitespace }).map(String.init) | 215 | fileTags += value.split(whereSeparator: { $0 == ":" || $0.isWhitespace }).map(String.init) |
Sources/OrgIndex/FileRecord.swift +3 −1
| @@ -122,7 +122,9 @@ public struct FileRecord: Sendable, Equatable { | |||
| 122 | self.hash = Self.hash(bytes) | 122 | self.hash = Self.hash(bytes) |
| 123 | self.settingsVersion = settings.version | 123 | self.settingsVersion = settings.version |
| 124 | let source = SourceText(bytes: bytes) | 124 | let source = SourceText(bytes: bytes) |
| 125 | headings = kind == .conflict ? [] : Self.headings(source.text, settings: settings) | 125 | var resolved = settings |
| 126 | resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile) | ||
| 127 | headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved) | ||
| 126 | } | 128 | } |
| 127 | 129 | ||
| 128 | /// A file the index lists without reading: `hash` stands for its size and mtime. | 130 | /// A file the index lists without reading: `hash` stands for its size and mtime. |
Sources/OrgPresentation/Folding.swift +2 −1
| @@ -220,7 +220,8 @@ extension Presentation { | |||
| 220 | /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside | 220 | /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside |
| 221 | /// blocks and paragraphs is never read as a setting. | 221 | /// blocks and paragraphs is never read as a setting. |
| 222 | public static func startupOptions(_ tree: OrgTree) -> [String] { | 222 | public static func startupOptions(_ tree: OrgTree) -> [String] { |
| 223 | var options: [String] = [] | 223 | var options = tree.settings.setupKeywords.split(separator: "\n").filter { $0.uppercased().hasPrefix("#+STARTUP:") } |
| 224 | .flatMap { $0.dropFirst("#+STARTUP:".count).split(whereSeparator: \.isWhitespace).map(String.init) } | ||
| 224 | func visit(_ node: GreenNode) { | 225 | func visit(_ node: GreenNode) { |
| 225 | for case .node(let child) in node.children { | 226 | for case .node(let child) in node.children { |
| 226 | switch child.kind { | 227 | switch child.kind { |
Sources/Orgstar/ContentView.swift +1 −1
| @@ -382,7 +382,7 @@ struct ContentView: View { | |||
| 382 | } | 382 | } |
| 383 | let url = URL(fileURLWithPath: path) | 383 | let url = URL(fileURLWithPath: path) |
| 384 | let base = url.deletingPathExtension() | 384 | let base = url.deletingPathExtension() |
| 385 | let defaults = Preferences.orgSettings | 385 | let defaults = Preferences.orgSettings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile) |
| 386 | let open = open || format == "html-open" | 386 | let open = open || format == "html-open" |
| 387 | switch format { | 387 | switch format { |
| 388 | case "html", "html-open", "markdown": | 388 | case "html", "html-open", "markdown": |
Sources/OrgstarMobile/ExportMenu.swift +1
| @@ -32,6 +32,7 @@ struct OrgExport: Transferable { | |||
| 32 | 32 | ||
| 33 | private func write() throws -> SentTransferredFile { | 33 | private func write() throws -> SentTransferredFile { |
| 34 | let base = ((path as NSString).lastPathComponent as NSString).deletingPathExtension | 34 | let base = ((path as NSString).lastPathComponent as NSString).deletingPathExtension |
| 35 | let settings = settings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile) | ||
| 35 | let content = switch format { | 36 | let content = switch format { |
| 36 | case .html: HTMLExport.page(text, fallbackTitle: base, settings: settings, path: path) | 37 | case .html: HTMLExport.page(text, fallbackTitle: base, settings: settings, path: path) |
| 37 | case .markdown: MarkdownExport.document(text, settings: settings) | 38 | case .markdown: MarkdownExport.document(text, settings: settings) |
Sources/OrgstarQuickLook/PreviewProvider.swift +1 −1
| @@ -12,7 +12,7 @@ final class PreviewProvider: QLPreviewProvider, QLPreviewingController { | |||
| 12 | func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply { | 12 | func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply { |
| 13 | let text = try String(contentsOf: request.fileURL, encoding: .utf8) | 13 | let text = try String(contentsOf: request.fileURL, encoding: .utf8) |
| 14 | let title = request.fileURL.deletingPathExtension().lastPathComponent | 14 | let title = request.fileURL.deletingPathExtension().lastPathComponent |
| 15 | let html = HTMLExport.page(text, fallbackTitle: title, path: request.fileURL.path) | 15 | let html = HTMLExport.page(text, fallbackTitle: title, settings: OrgSettings.default.resolvingSetupFiles(in: text, path: request.fileURL.path, read: OrgSettings.readFile), path: request.fileURL.path) |
| 16 | return QLPreviewReply(dataOfContentType: .html, contentSize: CGSize(width: 800, height: 800)) { _ in Data(html.utf8) } | 16 | return QLPreviewReply(dataOfContentType: .html, contentSize: CGSize(width: 800, height: 800)) { _ in Data(html.utf8) } |
| 17 | } | 17 | } |
| 18 | } | 18 | } |
Tests/OrgAppTests/AppTests.swift +9
| @@ -190,6 +190,15 @@ struct DocumentSessionTests { | |||
| 190 | #expect(session.revealRequest?.offset == 12) | 190 | #expect(session.revealRequest?.offset == 12) |
| 191 | } | 191 | } |
| 192 | 192 | ||
| 193 | @Test func openReadsSetupFiles() throws { | ||
| 194 | let notes = try Folder() | ||
| 195 | try notes.write("setup.org", "#+TODO: NEXT | DONE\n") | ||
| 196 | let file = try notes.write("a.org", "#+SETUPFILE: setup.org\n* NEXT One\n") | ||
| 197 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 198 | session.open(file.path) | ||
| 199 | #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true) | ||
| 200 | } | ||
| 201 | |||
| 193 | @Test func missingFileReportsAnError() { | 202 | @Test func missingFileReportsAnError() { |
| 194 | let session = DocumentSession() | 203 | let session = DocumentSession() |
| 195 | session.open("/nonexistent/a.org") | 204 | session.open("/nonexistent/a.org") |
Tests/OrgCoreTests/SettingsTests.swift +1 −1
| @@ -33,7 +33,7 @@ struct SettingsTests { | |||
| 33 | @Test func severalLinesMakeSeveralSequences() { | 33 | @Test func severalLinesMakeSeveralSequences() { |
| 34 | let settings = scan("#+TODO: A | B\n#+TYP_TODO: X | Y\n") | 34 | let settings = scan("#+TODO: A | B\n#+TYP_TODO: X | Y\n") |
| 35 | #expect(settings.todoSequences.count == 2) | 35 | #expect(settings.todoSequences.count == 2) |
| 36 | #expect(settings.todoSequences[1].kind == .type) | 36 | #expect(settings.todoSequences.map(\.kind) == [.type, .sequence]) |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | @Test func keywordsInsideBlocksAreIgnored() { | 39 | @Test func keywordsInsideBlocksAreIgnored() { |
Tests/OrgCoreTests/SetupFileTests.swift added +91
| @@ -0,0 +1,91 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// `#+SETUPFILE` keywords, nested setup files included, as Org reads them. | ||
| 6 | struct SetupFileTests { | ||
| 7 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | ||
| 8 | |||
| 9 | static let common = """ | ||
| 10 | #+TODO: NEXT WAIT | DONE CANCELLED | ||
| 11 | #+PRIORITIES: A E C | ||
| 12 | #+TAGS: work home | ||
| 13 | #+FILETAGS: :shared: | ||
| 14 | #+PROPERTY: owner alice | ||
| 15 | #+PROPERTY: header-args :results silent | ||
| 16 | #+TITLE: Setup Title | ||
| 17 | #+OPTIONS: toc:nil num:nil | ||
| 18 | #+MACRO: who Alice | ||
| 19 | #+SETUPFILE: ../deep.org | ||
| 20 | #+SETUPFILE: ../loop.org | ||
| 21 | """ + "\n" | ||
| 22 | static let deep = "#+TYP_TODO: BUG | FIXED\n#+SETUPFILE: setup/common.org\n" | ||
| 23 | static let main = """ | ||
| 24 | #+SETUPFILE: setup/common.org | ||
| 25 | |||
| 26 | * NEXT [#E] Task :local: | ||
| 27 | {{{who}}} | ||
| 28 | * BUG Other | ||
| 29 | """ + "\n" | ||
| 30 | |||
| 31 | @Test func collectsNestedKeywordLines() { | ||
| 32 | let files = ["/w/setup/common.org": Self.common, "/w/deep.org": Self.deep] | ||
| 33 | let lines = SetupFile.keywordLines(in: Self.main, directory: "/w") { files[$0] } | ||
| 34 | #expect(lines.hasPrefix("#+TODO: NEXT WAIT | DONE CANCELLED\n")) | ||
| 35 | #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n#+TITLE") == false) | ||
| 36 | #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n")) | ||
| 37 | #expect(lines.components(separatedBy: "#+TODO:").count == 2) | ||
| 38 | #expect(SetupFile.keywordLines(in: "* x\n", directory: "/w") { files[$0] } == "") | ||
| 39 | #expect(SetupFile.keywordLines(in: "#+SETUPFILE: https://example.com/a.org\n", directory: "/w") { _ in "#+TODO: X\n" } == "") | ||
| 40 | } | ||
| 41 | |||
| 42 | @Test(.enabled(if: oracle)) | ||
| 43 | func matchesOrg() throws { | ||
| 44 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-setup-\(UUID().uuidString)") | ||
| 45 | try FileManager.default.createDirectory(at: folder.appendingPathComponent("setup"), withIntermediateDirectories: true) | ||
| 46 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 47 | try Self.common.write(to: folder.appendingPathComponent("setup/common.org"), atomically: true, encoding: .utf8) | ||
| 48 | try Self.deep.write(to: folder.appendingPathComponent("deep.org"), atomically: true, encoding: .utf8) | ||
| 49 | let path = folder.appendingPathComponent("main.org").path | ||
| 50 | try Self.main.write(toFile: path, atomically: true, encoding: .utf8) | ||
| 51 | |||
| 52 | let emacs = try EmacsOracle.evaluate("", """ | ||
| 53 | (with-current-buffer (find-file-noselect \(lispString(path))) | ||
| 54 | (require 'ox) | ||
| 55 | (goto-char (point-min)) | ||
| 56 | (re-search-forward "^\\\\* NEXT") | ||
| 57 | (let ((env (org-export-get-environment))) | ||
| 58 | (list (mapconcat #'identity org-todo-keywords-1 " ") | ||
| 59 | (format "%c %c %c" org-priority-highest org-priority-lowest org-priority-default) | ||
| 60 | (mapconcat #'identity (org-get-tags) " ") | ||
| 61 | (or (cdr (assoc "owner" org-keyword-properties)) "") | ||
| 62 | (mapconcat #'identity (delq nil (mapcar (lambda (x) (and (stringp (car x)) (car x))) org-current-tag-alist)) " ") | ||
| 63 | (org-element-interpret-data (plist-get env :title)) | ||
| 64 | (format "%s" (plist-get env :with-toc)) | ||
| 65 | (progn (org-macro-initialize-templates) (or (cdr (assoc "who" org-macro-templates)) ""))))) | ||
| 66 | """) | ||
| 67 | |||
| 68 | let settings = OrgSettings.default.resolvingSetupFiles(in: Self.main, path: path, read: OrgSettings.readFile) | ||
| 69 | let tree = OrgParser.parse(Self.main, defaults: settings) | ||
| 70 | let model = DocumentModel(tree: tree) | ||
| 71 | let task = try #require(model.headings.firstIndex { $0.title.contains("Task") }) | ||
| 72 | let priorities = tree.settings.priorities | ||
| 73 | let tagNames = (FastSelection.tagTable(settings.setupKeywords + Self.main) ?? []).compactMap { item -> String? in | ||
| 74 | if case .option(_, let name) = item { name } else { nil } | ||
| 75 | } | ||
| 76 | let options = ExportOptions.parse(settings.setupKeywords + Self.main) | ||
| 77 | let page = HTMLExport.page(Self.main, fallbackTitle: "main", settings: settings, path: path) | ||
| 78 | let ours = [ | ||
| 79 | tree.settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }.joined(separator: " "), | ||
| 80 | "\(priorities.highest) \(priorities.lowest) \(priorities.default)", | ||
| 81 | model.tags(of: task).map(\.value).joined(separator: " "), | ||
| 82 | model.property("owner", of: nil)?.value ?? "", | ||
| 83 | tagNames.joined(separator: " "), | ||
| 84 | HTMLExport.metadata(Self.main, settings: settings).title ?? "", | ||
| 85 | options.toc == nil ? "nil" : "\(options.toc!)", | ||
| 86 | page.contains("Alice") ? "Alice" : "", | ||
| 87 | ] | ||
| 88 | #expect(ours == emacs) | ||
| 89 | #expect(page.contains("<h1 class=\"title\">Setup Title</h1>") && !page.contains("id=\"table-of-contents\"")) | ||
| 90 | } | ||
| 91 | } | ||