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 {
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
81enum SettingsScanner { 84enum 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 @@
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 {
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 @@
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}