krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 424b251694

424b251694c057659290bdc45000cd085f341417

parent: d92c7f39e4

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:49 UTC

Read #+STARTUP from keyword lines only

The parser collects #+STARTUP words outside blocks into
OrgSettings.startup; logging, inline images and folding read them
there. A #+STARTUP line inside a block no longer sets logging or
inline images.

Layout: unified · split

Sources/OrgCore/Commands/InlineImages.swift +3 −5
@@ -34,11 +34,9 @@ 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.settings.setupKeywords + tree.root.text).matches(of: #/(?mi)^[ \t]*#\+STARTUP:(.*)$/#) { 37 for word in tree.settings.startup {
38 for word in m.1.split(whereSeparator: \.isWhitespace) { 38 if word.lowercased() == "inlineimages" { shown = true }
39 if word.lowercased() == "inlineimages" { shown = true } 39 if word.lowercased() == "noinlineimages" { shown = false }
40 if word.lowercased() == "noinlineimages" { shown = false }
41 }
42 } 40 }
43 return shown 41 return shown
44 } 42 }
Sources/OrgCore/Commands/TodoLogging.swift +1 −9
@@ -82,15 +82,7 @@ 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" { 85 for word in settings.startup { log.apply(startup: word) }
86 for word in value.split(whereSeparator: \.isWhitespace) { log.apply(startup: String(word)) }
87 }
88 let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive])
89 for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) {
90 for word in substring(match.range(at: 1).location, NSMaxRange(match.range(at: 1))).split(whereSeparator: \.isWhitespace) {
91 log.apply(startup: String(word))
92 }
93 }
94 if let logging = inheritedProperty("LOGGING")?.value { 86 if let logging = inheritedProperty("LOGGING")?.value {
95 // `org-local-logging`. 87 // `org-local-logging`.
96 log.done = nil 88 log.done = nil
Sources/OrgCore/Parser/Incremental.swift +1 −1
@@ -58,7 +58,7 @@ private struct ReparseContext {
58 /// Change in UTF-16 length. 58 /// Change in UTF-16 length.
59 var delta: Int { edit.replacement.utf16.count - edit.range.count } 59 var delta: Int { edit.replacement.utf16.count - edit.range.count }
60 60
61 static let settingsKeys: Set<String> = ["TODO", "SEQ_TODO", "TYP_TODO", "PRIORITIES"] 61 static let settingsKeys: Set<String> = ["TODO", "SEQ_TODO", "TYP_TODO", "PRIORITIES", "STARTUP"]
62 62
63 /// Elements that parse the same in isolation as in place, as long as their lines keep 63 /// Elements that parse the same in isolation as in place, as long as their lines keep
64 /// their classes. 64 /// their classes.
Sources/OrgCore/Parser/Settings.swift +7 −1
@@ -50,6 +50,8 @@ public struct OrgSettings: Sendable, Equatable {
50 /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as 50 /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as
51 /// the file's own, before them. 51 /// the file's own, before them.
52 public var setupKeywords = "" 52 public var setupKeywords = ""
53 /// Words of the `#+STARTUP` keyword lines outside blocks, setup files' first.
54 public var startup: [String] = []
53 55
54 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) { 56 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) {
55 self.todoSequences = todoSequences 57 self.todoSequences = todoSequences
@@ -82,12 +84,13 @@ public struct OrgSettings: Sendable, Equatable {
82} 84}
83 85
84enum SettingsScanner { 86enum SettingsScanner {
85 /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO` and `#+PRIORITIES` outside blocks. Any TODO 87 /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO`, `#+PRIORITIES` and `#+STARTUP` outside blocks. Any TODO
86 /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then 88 /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then
87 /// `TODO`, then `SEQ_TODO`. 89 /// `TODO`, then `SEQ_TODO`.
88 static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings { 90 static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings {
89 var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = [] 91 var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = []
90 var priorities = defaults.priorities 92 var priorities = defaults.priorities
93 var startup: [String] = []
91 func apply(_ key: String, _ value: Substring) { 94 func apply(_ key: String, _ value: Substring) {
92 switch key { 95 switch key {
93 case "TODO": 96 case "TODO":
@@ -96,6 +99,8 @@ enum SettingsScanner {
96 if let s = todoSequence(value, kind: .sequence) { seqs.append(s) } 99 if let s = todoSequence(value, kind: .sequence) { seqs.append(s) }
97 case "TYP_TODO": 100 case "TYP_TODO":
98 if let s = todoSequence(value, kind: .type) { types.append(s) } 101 if let s = todoSequence(value, kind: .type) { types.append(s) }
102 case "STARTUP":
103 startup += value.split(whereSeparator: \.isWhitespace).map(String.init)
99 case "PRIORITIES": 104 case "PRIORITIES":
100 let words = value.split(whereSeparator: \.isWhitespace) 105 let words = value.split(whereSeparator: \.isWhitespace)
101 if words.count == 3 { 106 if words.count == 3 {
@@ -129,6 +134,7 @@ enum SettingsScanner {
129 radioTargets: radio.sorted { $0.count > $1.count }) 134 radioTargets: radio.sorted { $0.count > $1.count })
130 settings.listAllowAlphabetical = defaults.listAllowAlphabetical 135 settings.listAllowAlphabetical = defaults.listAllowAlphabetical
131 settings.setupKeywords = defaults.setupKeywords 136 settings.setupKeywords = defaults.setupKeywords
137 settings.startup = startup
132 return settings 138 return settings
133 } 139 }
134 140
Sources/OrgPresentation/Folding.swift +3 −22
@@ -217,28 +217,9 @@ extension Presentation {
217 return result 217 return result
218 } 218 }
219 219
220 /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside 220 /// Words of every `#+STARTUP:` keyword line (`OrgSettings.startup`); lines inside blocks
221 /// blocks and paragraphs is never read as a setting. 221 /// are not settings.
222 public static func startupOptions(_ tree: OrgTree) -> [String] { 222 public static func startupOptions(_ tree: OrgTree) -> [String] {
223 var options = tree.settings.setupKeywords.split(separator: "\n").filter { $0.uppercased().hasPrefix("#+STARTUP:") } 223 tree.settings.startup
224 .flatMap { $0.dropFirst("#+STARTUP:".count).split(whereSeparator: \.isWhitespace).map(String.init) }
225 func visit(_ node: GreenNode) {
226 for case .node(let child) in node.children {
227 switch child.kind {
228 case .keyword:
229 let text = child.text
230 if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"),
231 let colon = text.firstIndex(of: ":") {
232 options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init)
233 }
234 case .document, .zerothSection, .section, .drawer:
235 visit(child)
236 default:
237 break
238 }
239 }
240 }
241 visit(tree.green)
242 return options
243 } 224 }
244} 225}
Tests/OrgCoreTests/InlineImagesTests.swift +1
@@ -12,5 +12,6 @@ struct InlineImagesTests {
12 #expect(InlineImages.startup(OrgParser.parse(text as String)) == true) 12 #expect(InlineImages.startup(OrgParser.parse(text as String)) == true)
13 #expect(InlineImages.startup(OrgParser.parse("#+STARTUP: overview noinlineimages\n")) == false) 13 #expect(InlineImages.startup(OrgParser.parse("#+STARTUP: overview noinlineimages\n")) == false)
14 #expect(InlineImages.startup(OrgParser.parse("* x\n")) == nil) 14 #expect(InlineImages.startup(OrgParser.parse("* x\n")) == nil)
15 #expect(InlineImages.startup(OrgParser.parse("#+begin_src org\n#+STARTUP: inlineimages\n#+end_src\n")) == nil)
15 } 16 }
16} 17}
Tests/OrgCoreTests/TodoLoggingTests.swift +1
@@ -9,6 +9,7 @@ struct TodoLoggingTests {
9 "* TODO Task\n:LOGBOOK:\n- State \"TODO\" from [2026-01-01 Thu 10:00]\n:END:\n", 9 "* TODO Task\n:LOGBOOK:\n- State \"TODO\" from [2026-01-01 Thu 10:00]\n:END:\n",
10 "* DONE Task\nCLOSED: [2026-01-01 Thu 10:00]\n", 10 "* DONE Task\nCLOSED: [2026-01-01 Thu 10:00]\n",
11 "#+STARTUP: lognotedone logdrawer\n* TODO Task\n", 11 "#+STARTUP: lognotedone logdrawer\n* TODO Task\n",
12 "#+begin_example\n#+STARTUP: lognotedone logdrawer\n#+end_example\n* TODO Task\n",
12 "* TODO Task\n:PROPERTIES:\n:LOGGING: DONE(!) logrepeat\n:LOG_INTO_DRAWER: NOTES\n:END:\n", 13 "* TODO Task\n:PROPERTIES:\n:LOGGING: DONE(!) logrepeat\n:LOG_INTO_DRAWER: NOTES\n:END:\n",
13 "* TODO Task\nSCHEDULED: <2026-10-05 Mon +1w>\n", 14 "* TODO Task\nSCHEDULED: <2026-10-05 Mon +1w>\n",
14 "* TODO Task\n- item one\n", 15 "* TODO Task\n- item one\n",
Tests/OrgPresentationTests/PresentationTests.swift +1
@@ -128,6 +128,7 @@ struct CyclingTests {
128 let state = Presentation.startupFoldState(levels) 128 let state = Presentation.startupFoldState(levels)
129 #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2) 129 #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2)
130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) 130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
131 #expect(Presentation.startupOptions(OrgParser.parse("#+STARTUP: content\n* a\n:LOGBOOK:\n#+startup: hideblocks\n:END:\n")) == ["content", "hideblocks"])
131 } 132 }
132} 133}
133 134