Read #+SETUPFILE keywords !125

merged merged by cmc on 2026-10-07 03:34 UTC · krz/orgstar:setupfile into main

25 files changed, +245 −56

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +2 −2
@@ -111,10 +111,10 @@ public final class AgendaModel {
111111 if let cached = read.cached {
112112 sources.append((read.path, read.mtime, read.size, cached, true))
113113 } 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))
115115 } else if let data = FileManager.default.contents(atPath: read.path) {
116116 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))
118118 }
119119 }
120120 return sources
Sources/OrgApp/DocumentSession.swift +13 −5
@@ -77,6 +77,14 @@ public final class DocumentSession {
7777 /// TODO keywords and priorities for files without their own settings.
7878 public var defaults: OrgSettings = .default
7979
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
8088 /// One open file.
8189 @MainActor
8290 final class Entry {
@@ -154,7 +162,7 @@ public final class DocumentSession {
154162 lastError = "\((path as NSString).lastPathComponent) no longer exists."
155163 return
156164 }
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)))
158166 let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count
159167 entries.insert(entry, at: index)
160168 activate(entry)
@@ -467,7 +475,7 @@ public final class DocumentSession {
467475 state = open.buffer.document
468476 } else {
469477 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)
471479 }
472480 guard state.isEditable else { return nil }
473481 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 {
510518 }
511519 do {
512520 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)
514522 guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." }
515523 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
516524 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 {
554562 do {
555563 let url = URL(fileURLWithPath: destination.path)
556564 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)
558566 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
559567 let edits: Archive.Edits
560568 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
@@ -631,7 +639,7 @@ public final class DocumentSession {
631639 let name = url.lastPathComponent
632640 do {
633641 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)
635643 guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") }
636644 guard matches(state.text) else { return stale }
637645 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 {
106106 collect(tree.root)
107107 func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] }
108108
109 var keywordCategory: String?
109 var keywordCategory = SetupFile.keywords(tree.settings.setupKeywords).last { $0.key == "CATEGORY" }?.value.trimmingCharacters(in: .whitespaces)
110110 var srcBodies: [Range<Int>] = []
111111 var planningLines: [Range<Int>] = []
112112 var timestampNodes: [Int] = []
Sources/OrgCore/Commands/Archive.swift +3 −1
@@ -9,7 +9,9 @@ extension EmacsBuffer {
99 func keyword(_ name: String) -> String? {
1010 saveExcursion {
1111 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 }
1315 return substring(match[1]!.lowerBound, match[1]!.upperBound)
1416 }
1517 }
Sources/OrgCore/Commands/ColumnView.swift +1 −1
@@ -239,7 +239,7 @@ public enum ColumnView {
239239 }
240240 if top == nil { top = here }
241241 }
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) }
243243 let specs = compile(format ?? defaultFormat)
244244 // The entries in scope, without archived or commented subtrees.
245245 var scope: [Int] = []
Sources/OrgCore/Commands/InlineImages.swift +1 −1
@@ -34,7 +34,7 @@ public enum InlineImages {
3434 /// `#+STARTUP: inlineimages` or `noinlineimages`, if the file says.
3535 public static func startup(_ tree: OrgTree) -> Bool? {
3636 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:(.*)$/#) {
3838 for word in m.1.split(whereSeparator: \.isWhitespace) {
3939 if word.lowercased() == "inlineimages" { shown = true }
4040 if word.lowercased() == "noinlineimages" { shown = false }
Sources/OrgCore/Commands/TagCommands.swift +1 −1
@@ -37,7 +37,7 @@ public struct SetTags: OrgCommand {
3737 }
3838 let currentText = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? ""
3939 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) }
4141 let tags: [String]
4242 if let fast = FastSelection.tags(table: table, current: current, inherited: inheritedTags(context)) {
4343 guard let answer = context.answers["tags"] else {
Sources/OrgCore/Commands/TodoLogging.swift +3
@@ -82,6 +82,9 @@ extension EmacsBuffer {
8282 if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) }
8383 }
8484 }
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 }
8588 let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive])
8689 for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) {
8790 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 {
113113 var p = caret
114114 while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), CharacterSet(charactersIn: "-_").union(.alphanumerics).contains(ch), ch.isASCII { p -= 1 }
115115 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 + ":" }
117117 return make(.link, from: p, abbreviations + linkTypes.map { $0 + ":" })
118118 }
119119 }
@@ -136,7 +136,7 @@ public enum OrgCompletion {
136136 let from = colon + ((typed as NSString).range(of: ":", options: .backwards).toOptional.map { NSMaxRange($0) } ?? 0)
137137 let heading = DocumentModel(tree: context.tree).headings.last { $0.headingRange.lowerBound <= caret }
138138 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
140140 if case .option(_, let name) = item { return name }
141141 return nil
142142 } ?? []
Sources/OrgCore/Compute/TableFormulas.swift +1 −1
@@ -369,7 +369,7 @@ extension EmacsBuffer {
369369 func substituteConstants(_ formula: String) -> String {
370370 guard formula.range(of: "\\$[a-zA-Z]", options: .regularExpression) != nil else { return formula }
371371 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:(.*)$/#) {
373373 for pair in m.1.split(whereSeparator: \.isWhitespace) {
374374 let parts = pair.split(separator: "=", maxSplits: 1)
375375 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 {
179179 /// `{{{name(arg, …)}}}` replaced: `#+MACRO` definitions with `$1`… and the built-in title,
180180 /// author, date, email, input-file, keyword, property, n and time macros. Others become
181181 /// 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 {
183184 guard text.contains("{{{") else { return text }
184185 var definitions: [String: String] = [:]
185186 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]*$/#) {
187188 let key = m.1.uppercased()
188189 let value = String(m.2)
189190 if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) {
Sources/OrgCore/Export/HTMLExport.swift +15 −14
@@ -19,18 +19,19 @@ public enum HTMLExport {
1919 public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata {
2020 let tree = OrgParser.parse(text, defaults: settings)
2121 var meta = ExportMetadata()
22 for node in tree.root.children where node.kind == .zerothSection {
23 for child in node.children where child.kind == .keyword {
24 let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
25 guard let colon = line.firstIndex(of: ":") else { continue }
26 let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
27 let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
28 switch key {
29 case "TITLE": meta.title = value
30 case "AUTHOR": meta.author = value
31 case "DATE": meta.date = value
32 default: break
33 }
22 // Setup files' keywords come first, as Org reads them where `#+SETUPFILE` is.
23 let setup = settings.setupKeywords.split(separator: "\n").map(String.init)
24 let own = tree.root.children.filter { $0.kind == .zerothSection }.flatMap { $0.children.filter { $0.kind == .keyword } }.map(\.text)
25 for text in setup + own {
26 let line = text.trimmingCharacters(in: .whitespacesAndNewlines)
27 guard let colon = line.firstIndex(of: ":") else { continue }
28 let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
29 let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
30 switch key {
31 case "TITLE": meta.title = value
32 case "AUTHOR": meta.author = value
33 case "DATE": meta.date = value
34 default: break
3435 }
3536 }
3637 return meta
@@ -41,8 +42,8 @@ public enum HTMLExport {
4142 /// `{{{input-file}}}`.
4243 public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String {
4344 let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }
44 let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now)
45 let options = ExportOptions.parse(text)
45 let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords)
46 let options = ExportOptions.parse(settings.setupKeywords + text)
4647 let meta = metadata(text, settings: settings)
4748 let title = meta.title ?? fallbackTitle
4849 let renderer = HTMLRenderer(settings: settings, options: options)
Sources/OrgCore/Links/LinkStore.swift +1 −1
@@ -247,7 +247,7 @@ public struct InsertLink: OrgCommand {
247247 guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) }
248248 link = answer
249249 } 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
251251 guard let answer = context.answers["link"] else {
252252 let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:"
253253 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 {
133133 guard let node = found else { return nil }
134134 let whole = node.text
135135 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)
137137 }
138138 let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole
139139 return (parse(plain: plain), node.range)
Sources/OrgCore/Parser/Settings.swift +27 −16
@@ -47,6 +47,9 @@ public struct OrgSettings: Sendable, Equatable {
4747 public var radioTargets: [String] = []
4848 /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets.
4949 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 = ""
5053
5154 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) {
5255 self.todoSequences = todoSequences
@@ -80,35 +83,42 @@ public struct OrgSettings: Sendable, Equatable {
8083
8184enum SettingsScanner {
8285 /// 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`.
8488 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] = []
8690 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) }
87109 var k = 0
88110 while k < lines.count {
89111 switch info[k].cls {
90112 case .blockBegin, .dynamicBegin:
91113 if let end = blockEnds[k] { k = end }
92114 case .keyword(let key):
93 let value = 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 }
115 apply(key, keywordValue(lines[k].content))
107116 default:
108117 break
109118 }
110119 k += 1
111120 }
121 let sequences = types + todos + seqs
112122 var radio: [String] = []
113123 for line in lines where line.content.contains("<<<") {
114124 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 {
118128 var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities,
119129 radioTargets: radio.sorted { $0.count > $1.count })
120130 settings.listAllowAlphabetical = defaults.listAllowAlphabetical
131 settings.setupKeywords = defaults.setupKeywords
121132 return settings
122133 }
123134
Sources/OrgCore/Parser/SetupFile.swift added +56
@@ -0,0 +1,56 @@
1import 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.
6public 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
44extension 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 {
2222 public init(tree: OrgTree, settings: SemanticSettings = .default) {
2323 self.settings = settings
2424 self.orgSettings = tree.settings
25 for (key, value) in SetupFile.keywords(tree.settings.setupKeywords) { fileKeyword(key, value.trimmingCharacters(in: .whitespaces)) }
2526 var pending: [PendingBlock] = []
2627 walk(tree.root, heading: nil, pending: &pending)
2728 // Header arguments depend on file and heading properties, so blocks resolve last.
@@ -205,8 +206,10 @@ public struct DocumentModel: Sendable {
205206 private mutating func fileKeyword(_ node: SyntaxNode) {
206207 let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
207208 guard let colon = text.firstIndex(of: ":") else { return }
208 let key = text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased()
209 let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
209 fileKeyword(text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased(), text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces))
210 }
211
212 private mutating func fileKeyword(_ key: String, _ value: String) {
210213 switch key {
211214 case "FILETAGS":
212215 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 {
122122 self.hash = Self.hash(bytes)
123123 self.settingsVersion = settings.version
124124 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)
126128 }
127129
128130 /// 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 {
220220 /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside
221221 /// blocks and paragraphs is never read as a setting.
222222 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) }
224225 func visit(_ node: GreenNode) {
225226 for case .node(let child) in node.children {
226227 switch child.kind {
Sources/Orgstar/ContentView.swift +1 −1
@@ -382,7 +382,7 @@ struct ContentView: View {
382382 }
383383 let url = URL(fileURLWithPath: path)
384384 let base = url.deletingPathExtension()
385 let defaults = Preferences.orgSettings
385 let defaults = Preferences.orgSettings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile)
386386 let open = open || format == "html-open"
387387 switch format {
388388 case "html", "html-open", "markdown":
Sources/OrgstarMobile/ExportMenu.swift +1
@@ -32,6 +32,7 @@ struct OrgExport: Transferable {
3232
3333 private func write() throws -> SentTransferredFile {
3434 let base = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
35 let settings = settings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile)
3536 let content = switch format {
3637 case .html: HTMLExport.page(text, fallbackTitle: base, settings: settings, path: path)
3738 case .markdown: MarkdownExport.document(text, settings: settings)
Sources/OrgstarQuickLook/PreviewProvider.swift +1 −1
@@ -12,7 +12,7 @@ final class PreviewProvider: QLPreviewProvider, QLPreviewingController {
1212 func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
1313 let text = try String(contentsOf: request.fileURL, encoding: .utf8)
1414 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)
1616 return QLPreviewReply(dataOfContentType: .html, contentSize: CGSize(width: 800, height: 800)) { _ in Data(html.utf8) }
1717 }
1818}
Tests/OrgAppTests/AppTests.swift +9
@@ -190,6 +190,15 @@ struct DocumentSessionTests {
190190 #expect(session.revealRequest?.offset == 12)
191191 }
192192
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
193202 @Test func missingFileReportsAnError() {
194203 let session = DocumentSession()
195204 session.open("/nonexistent/a.org")
Tests/OrgCoreTests/SettingsTests.swift +1 −1
@@ -33,7 +33,7 @@ struct SettingsTests {
3333 @Test func severalLinesMakeSeveralSequences() {
3434 let settings = scan("#+TODO: A | B\n#+TYP_TODO: X | Y\n")
3535 #expect(settings.todoSequences.count == 2)
36 #expect(settings.todoSequences[1].kind == .type)
36 #expect(settings.todoSequences.map(\.kind) == [.type, .sequence])
3737 }
3838
3939 @Test func keywordsInsideBlocksAreIgnored() {
Tests/OrgCoreTests/SetupFileTests.swift added +91
@@ -0,0 +1,91 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// `#+SETUPFILE` keywords, nested setup files included, as Org reads them.
6struct 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}