krz/orgstar

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

Commit d07ba5a707

d07ba5a7070e7d2dd5decd464f823ba596622e1e

parent: ce4e5e4baa

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-04 23:36 UTC

Add document model with inheritance and header arguments

Layout: unified · split

Sources/OrgCore/Semantic/DocumentModel.swift added +291
@@ -0,0 +1,291 @@
1import Foundation
2
3/// Typed facts read from one parsed document. Built once per tree; commands and the index read
4/// this rather than raw text.
5public struct DocumentModel: Sendable {
6 public let settings: SemanticSettings
7 public let orgSettings: OrgSettings
8 public private(set) var headings: [HeadingInfo] = []
9 public private(set) var fileTags: [String] = []
10 /// `#+PROPERTY:` lines in order.
11 public private(set) var fileProperties: [Property] = []
12 public private(set) var tables: [TableModel] = []
13 public private(set) var srcBlocks: [SrcBlockInfo] = []
14
15 /// Always inherited, whatever the inheritance setting.
16 static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"]
17 /// Identify one heading, so never inherited.
18 static let neverInherited: Set<String> = ["ID", "CUSTOM_ID"]
19
20 private typealias PendingBlock = (node: SyntaxNode, heading: Int?, affiliated: [SyntaxNode])
21
22 public init(tree: OrgTree, settings: SemanticSettings = .default) {
23 self.settings = settings
24 self.orgSettings = tree.settings
25 var pending: [PendingBlock] = []
26 walk(tree.root, heading: nil, pending: &pending)
27 // Header arguments depend on file and heading properties, so blocks resolve last.
28 srcBlocks = pending.map { srcBlock($0.node, heading: $0.heading, affiliated: $0.affiliated) }
29 }
30
31 // MARK: - Queries
32
33 /// Ancestor indexes of a heading, outermost first.
34 public func ancestors(of index: Int) -> [Int] {
35 var result: [Int] = []
36 var current = headings[index].parent
37 while let parent = current {
38 result.insert(parent, at: 0)
39 current = headings[parent].parent
40 }
41 return result
42 }
43
44 /// Titles from the outermost ancestor down to the heading itself.
45 public func outlinePath(of index: Int) -> [String] {
46 (ancestors(of: index) + [index]).map { headings[$0].title }
47 }
48
49 public func inherits(_ key: String) -> Bool {
50 let key = key.uppercased()
51 if Self.neverInherited.contains(key) { return false }
52 if Self.alwaysInherited.contains(key) || key.hasPrefix("HEADER-ARGS") { return true }
53 switch settings.propertyInheritance {
54 case .none: return false
55 case .all: return true
56 case .only(let keys): return keys.contains(key)
57 }
58 }
59
60 /// The value of `key` for a heading, or for the file when `index` is nil. Inherited keys
61 /// start from `#+PROPERTY` and walk down the ancestors; `KEY+` lines append with a space.
62 public func property(_ key: String, of index: Int?) -> Resolved<String>? {
63 let key = key.uppercased()
64 var layers: [(entries: [Property], source: ValueSource)] = []
65 if let index {
66 if inherits(key) {
67 layers.append((fileProperties, .file))
68 layers += ancestors(of: index).map { (headings[$0].properties, .heading($0)) }
69 }
70 layers.append((headings[index].properties, .heading(index)))
71 } else {
72 layers.append((fileProperties, .file))
73 }
74 var result: Resolved<String>?
75 for layer in layers {
76 for entry in layer.entries where entry.key.uppercased() == key {
77 if entry.additive, let current = result {
78 result = Resolved(current.value + " " + entry.value, layer.source)
79 } else {
80 result = Resolved(entry.value, layer.source)
81 }
82 }
83 }
84 return result
85 }
86
87 /// Inherited tags first (file tags, then ancestors outermost first), then the heading's own.
88 /// A tag the heading has itself counts as its own.
89 public func tags(of index: Int) -> [Resolved<String>] {
90 let local = headings[index].localTags
91 var inherited: [Resolved<String>] = []
92 if settings.tagInheritance {
93 let layers = [(fileTags, ValueSource.file)] + ancestors(of: index).map { (headings[$0].localTags, ValueSource.heading($0)) }
94 for (tags, source) in layers {
95 for tag in tags where !settings.tagsExcludedFromInheritance.contains(tag)
96 && !local.contains(tag) && !inherited.contains(where: { $0.value == tag }) {
97 inherited.append(Resolved(tag, source))
98 }
99 }
100 }
101 var own: [Resolved<String>] = []
102 for tag in local where !own.contains(where: { $0.value == tag }) {
103 own.append(Resolved(tag, .heading(index)))
104 }
105 return inherited + own
106 }
107
108 // MARK: - Building
109
110 private mutating func walk(_ node: SyntaxNode, heading: Int?, pending: inout [PendingBlock]) {
111 var previous: SyntaxNode?
112 var affiliated: [SyntaxNode] = []
113 for child in node.children {
114 // Blank lines between nodes detach affiliated keywords.
115 if let previous, previous.range.upperBound != child.range.lowerBound { affiliated = [] }
116 switch child.kind {
117 case .section:
118 section(child, parent: heading, pending: &pending)
119 case .heading, .affiliatedKeyword:
120 break
121 case .planning:
122 if let heading { planning(child, heading) }
123 case .propertyDrawer:
124 if let heading {
125 headings[heading].properties += child.children.filter { $0.kind == .nodeProperty }.compactMap { Property(line: $0.text) }
126 }
127 case .clock:
128 if let heading, let clock = clock(child) { headings[heading].clocks.append(clock) }
129 case .timestamp:
130 if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) }
131 case .link:
132 if let heading { headings[heading].links.append(linkTarget(child)) }
133 case .keyword:
134 fileKeyword(child)
135 case .table:
136 tables.append(TableModel(node: child, heading: heading))
137 walk(child, heading: heading, pending: &pending)
138 case .block:
139 if Self.isSrcBlock(child) { pending.append((child, heading, affiliated)) }
140 default:
141 walk(child, heading: heading, pending: &pending)
142 }
143 affiliated = child.kind == .affiliatedKeyword ? affiliated + [child] : []
144 previous = child
145 }
146 }
147
148 private mutating func section(_ node: SyntaxNode, parent: Int?, pending: inout [PendingBlock]) {
149 guard let headingNode = node.children.first(where: { $0.kind == .heading }) else { return }
150 let index = headings.count
151 let tokens = headingNode.tokens
152 let todo = tokens.first { $0.kind == .todoKeyword }?.text
153 let title = headingNode.children.first { $0.kind == .title }
154 headings.append(HeadingInfo(
155 sectionRange: node.range,
156 headingRange: headingNode.range,
157 level: tokens.first { $0.kind == .stars }?.text.count ?? 0,
158 parent: parent,
159 todo: todo,
160 isDone: todo.map(orgSettings.isDone) ?? false,
161 priority: tokens.first { $0.kind == .priority }.map { String($0.text.dropFirst(2).dropLast()) },
162 title: title?.text ?? "",
163 localTags: tokens.first { $0.kind == .tags }.map { $0.text.split(separator: ":").map(String.init) } ?? [],
164 properties: [], scheduled: nil, deadline: nil, closed: nil, clocks: [], timestamps: [], links: []
165 ))
166 if let title { walk(title, heading: index, pending: &pending) }
167 walk(node, heading: index, pending: &pending)
168 }
169
170 /// Each timestamp belongs to the keyword just before it.
171 private mutating func planning(_ node: SyntaxNode, _ heading: Int) {
172 let text = node.text
173 for stampNode in node.children where stampNode.kind == .timestamp {
174 guard let stamp = Timestamp.parse(stampNode.text) else { continue }
175 let end = String.Index(utf16Offset: stampNode.offset - node.offset, in: text)
176 let before = text[..<end].trimmingCharacters(in: .whitespaces)
177 if before.hasSuffix("SCHEDULED:") {
178 headings[heading].scheduled = stamp
179 } else if before.hasSuffix("DEADLINE:") {
180 headings[heading].deadline = stamp
181 } else if before.hasSuffix("CLOSED:") {
182 headings[heading].closed = stamp
183 }
184 }
185 }
186
187 private func clock(_ node: SyntaxNode) -> Clock? {
188 guard let stampNode = node.children.first(where: { $0.kind == .timestamp }),
189 let stamp = Timestamp.parse(stampNode.text) else { return nil }
190 var minutes: Int?
191 if let arrow = node.text.range(of: "=>") {
192 let parts = node.text[arrow.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ":")
193 if parts.count == 2, let hours = Int(parts[0]), let mins = Int(parts[1]) { minutes = hours * 60 + mins }
194 }
195 return Clock(start: stamp.start, end: stamp.end, minutes: minutes)
196 }
197
198 private func linkTarget(_ node: SyntaxNode) -> String {
199 if let path = node.tokens.first(where: { $0.kind == .linkPath }) { return path.text }
200 let text = node.text
201 if text.hasPrefix("<"), text.hasSuffix(">") { return String(text.dropFirst().dropLast()) }
202 return text
203 }
204
205 private mutating func fileKeyword(_ node: SyntaxNode) {
206 let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
207 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)
210 switch key {
211 case "FILETAGS":
212 fileTags += value.split(whereSeparator: { $0 == ":" || $0.isWhitespace }).map(String.init)
213 case "PROPERTY":
214 let parts = value.split(maxSplits: 1, whereSeparator: \.isWhitespace)
215 guard var name = parts.first.map(String.init) else { return }
216 let additive = name.hasSuffix("+")
217 if additive { name.removeLast() }
218 fileProperties.append(Property(key: name, value: parts.count > 1 ? String(parts[1]) : "", additive: additive))
219 default:
220 break
221 }
222 }
223
224 // MARK: - Source blocks
225
226 static func isSrcBlock(_ node: SyntaxNode) -> Bool {
227 node.text.drop { $0 == " " || $0 == "\t" }.lowercased().hasPrefix("#+begin_src")
228 }
229
230 private func srcBlock(_ node: SyntaxNode, heading: Int?, affiliated: [SyntaxNode]) -> SrcBlockInfo {
231 let raw = splitRawLines(node.text)
232 let beginLine = raw[0].content.drop { $0 == " " || $0 == "\t" }.dropFirst("#+begin_src".count)
233 let words = beginLine.split(whereSeparator: \.isWhitespace)
234 var language: String?
235 var switches: [String] = []
236 var rest = words[...]
237 if let first = rest.first, !first.hasPrefix("-"), !first.hasPrefix("+"), !first.hasPrefix(":") {
238 language = String(first)
239 rest = rest.dropFirst()
240 }
241 while let word = rest.first, !word.hasPrefix(":") {
242 switches.append(String(word))
243 rest = rest.dropFirst()
244 }
245 let body = raw.dropFirst().dropLast().map { String($0.content) + String($0.ending) }.joined()
246
247 var name: String?
248 var headerLines: [String] = []
249 for keyword in affiliated {
250 let text = keyword.text.trimmingCharacters(in: .whitespacesAndNewlines)
251 let value = SettingsScanner.keywordValue(text[...]).trimmingCharacters(in: .whitespaces)
252 if text.uppercased().hasPrefix("#+NAME:") { name = value }
253 if text.uppercased().hasPrefix("#+HEADER:") { headerLines.append(value) }
254 }
255
256 var arguments = HeaderArguments.defaults.mapValues { Resolved($0, .defaults) }
257 var keys = ["header-args"]
258 if let language { keys.append("header-args:\(language)") }
259 for key in keys {
260 for pair in headerArgumentLayers(key, heading: heading) {
261 arguments[pair.key] = Resolved(pair.value, pair.source)
262 }
263 }
264 for line in headerLines + [rest.joined(separator: " ")] {
265 for pair in HeaderArguments.parse(line) {
266 arguments[pair.key] = Resolved(pair.value, .element)
267 }
268 }
269 return SrcBlockInfo(
270 range: node.range, heading: heading, name: name, language: language,
271 switches: switches, body: body, headerArguments: arguments
272 )
273 }
274
275 /// `header-args` pairs from `#+PROPERTY` down to the heading. As in org, a plain property
276 /// replaces everything above it; `header-args+` adds to it.
277 private func headerArgumentLayers(_ key: String, heading: Int?) -> [(key: String, value: String, source: ValueSource)] {
278 var layers: [(entries: [Property], source: ValueSource)] = [(fileProperties, .file)]
279 if let heading {
280 layers += (ancestors(of: heading) + [heading]).map { (headings[$0].properties, .heading($0)) }
281 }
282 var pairs: [(key: String, value: String, source: ValueSource)] = []
283 for layer in layers {
284 for entry in layer.entries where entry.key.lowercased() == key {
285 let parsed = HeaderArguments.parse(entry.value).map { (key: $0.key, value: $0.value, source: layer.source) }
286 pairs = entry.additive ? pairs + parsed : parsed
287 }
288 }
289 return pairs
290 }
291}
Tests/OrgCoreTests/SemanticTests.swift added +159
@@ -0,0 +1,159 @@
1import Testing
2@testable import OrgCore
3
4func model(_ text: String, _ settings: SemanticSettings = .default) -> DocumentModel {
5 DocumentModel(tree: OrgParser.parse(text), settings: settings)
6}
7
8struct HeadingInfoTests {
9 @Test func basics() {
10 let m = model("* TODO [#A] Write *plan* :work:\n** DONE Sub\n")
11 #expect(m.headings.count == 2)
12 let top = m.headings[0]
13 #expect(top.level == 1)
14 #expect(top.todo == "TODO")
15 #expect(!top.isDone)
16 #expect(top.priority == "A")
17 #expect(top.title == "Write *plan*")
18 #expect(top.localTags == ["work"])
19 #expect(top.parent == nil)
20 #expect(m.headings[1].parent == 0)
21 #expect(m.headings[1].isDone)
22 #expect(m.outlinePath(of: 1) == ["Write *plan*", "Sub"])
23 }
24
25 @Test func planningPropertiesClocksAndBody() throws {
26 let text = """
27 * a
28 SCHEDULED: <2026-10-04 Sun> DEADLINE: <2026-10-10 Sat -2d>
29 :PROPERTIES:
30 :ID: abc
31 :Effort: 1:00
32 :END:
33 :LOGBOOK:
34 CLOCK: [2026-10-04 Sun 10:00]--[2026-10-04 Sun 11:30] => 1:30
35 CLOCK: [2026-10-05 Mon 09:00]
36 :END:
37 See <2026-10-07 Wed> and [[https://a.b][x]].
38
39 """
40 let h = model(text).headings[0]
41 #expect(h.scheduled?.start.day == 4)
42 #expect(h.deadline?.warning?.interval.value == 2)
43 #expect(h.closed == nil)
44 #expect(h.id == "abc")
45 #expect(h.properties == [Property(key: "ID", value: "abc"), Property(key: "Effort", value: "1:00")])
46 #expect(h.clocks.count == 2)
47 #expect(h.clocks[0].minutes == 90)
48 #expect(h.clocks[0].end?.hour == 11)
49 #expect(h.clocks[1].end == nil)
50 #expect(h.timestamps == [try #require(Timestamp.parse("<2026-10-07 Wed>"))])
51 #expect(h.links == ["https://a.b"])
52 }
53
54 @Test func childContentStaysWithChild() {
55 let m = model("* a\n** b\n<2026-10-04 Sun>\n")
56 #expect(m.headings[0].timestamps.isEmpty)
57 #expect(m.headings[1].timestamps.count == 1)
58 }
59
60 @Test func titleTimestampsAndLinks() {
61 let h = model("* Call <2026-10-04 Sun> [[id:x][y]]\n").headings[0]
62 #expect(h.timestamps.count == 1)
63 #expect(h.links == ["id:x"])
64 }
65
66 @Test func doneStatesComeFromFileSettings() {
67 let m = model("#+TODO: NEXT | SHIPPED\n* SHIPPED x\n")
68 #expect(m.headings[0].isDone)
69 }
70}
71
72struct InheritanceTests {
73 @Test func tagInheritance() {
74 let text = "#+FILETAGS: :f:\n* a :x:noinherit:\n** b :y:\n"
75 let m = model(text, SemanticSettings(tagsExcludedFromInheritance: ["noinherit"]))
76 #expect(m.tags(of: 1).map(\.value) == ["f", "x", "y"])
77 #expect(m.tags(of: 1).map(\.source) == [.file, .heading(0), .heading(1)])
78 #expect(model(text, SemanticSettings(tagInheritance: false)).tags(of: 1).map(\.value) == ["y"])
79 }
80
81 @Test func ownTagWinsOverInherited() {
82 #expect(model("* a :x:\n** b :x:\n").tags(of: 1) == [Resolved("x", .heading(1))])
83 }
84
85 @Test func propertyPolicies() {
86 let text = "#+PROPERTY: owner team\n* a\n:PROPERTIES:\n:OWNER: alice\n:CATEGORY: work\n:ID: p\n:END:\n** b\n"
87 let plain = model(text)
88 #expect(plain.property("OWNER", of: 1) == nil)
89 #expect(plain.property("CATEGORY", of: 1) == Resolved("work", .heading(0)))
90 #expect(plain.property("OWNER", of: nil) == Resolved("team", .file))
91
92 let all = model(text, SemanticSettings(propertyInheritance: .all))
93 #expect(all.property("owner", of: 1) == Resolved("alice", .heading(0)))
94 #expect(all.property("ID", of: 1) == nil)
95
96 let some = model(text, SemanticSettings(propertyInheritance: .only(["OWNER"])))
97 #expect(some.property("OWNER", of: 1) == Resolved("alice", .heading(0)))
98 }
99
100 @Test func additiveProperties() {
101 let text = "* a\n:PROPERTIES:\n:VAR: x=1\n:END:\n** b\n:PROPERTIES:\n:VAR+: y=2\n:END:\n"
102 #expect(model(text, SemanticSettings(propertyInheritance: .all)).property("VAR", of: 1) == Resolved("x=1 y=2", .heading(1)))
103 #expect(model(text).property("VAR", of: 1) == Resolved("y=2", .heading(1)))
104 }
105}
106
107struct SrcBlockTests {
108 @Test func resolution() throws {
109 let text = """
110 #+PROPERTY: header-args :dir /a :cache yes
111 #+PROPERTY: header-args:sh :results output
112 * a
113 :PROPERTIES:
114 :header-args+: :dir /b
115 :END:
116 #+NAME: hello
117 #+HEADER: :var x=1
118 #+begin_src sh -n :results silent
119 echo $x
120 #+end_src
121
122 """
123 let block = try #require(model(text).srcBlocks.first)
124 #expect(block.name == "hello")
125 #expect(block.language == "sh")
126 #expect(block.switches == ["-n"])
127 #expect(block.body == "echo $x\n")
128 #expect(block.heading == 0)
129 #expect(block.headerArguments[":dir"] == Resolved("/b", .heading(0)))
130 #expect(block.headerArguments[":cache"] == Resolved("yes", .file))
131 #expect(block.headerArguments[":results"] == Resolved("silent", .element))
132 #expect(block.headerArguments[":var"] == Resolved("x=1", .element))
133 #expect(block.headerArguments[":exports"] == Resolved("code", .defaults))
134 }
135
136 @Test func plainPropertyReplacesInheritedHeaderArguments() {
137 let text = "#+PROPERTY: header-args :dir /a\n* a\n:PROPERTIES:\n:header-args: :cache yes\n:END:\n#+begin_src sh\n#+end_src\n"
138 let block = model(text).srcBlocks[0]
139 #expect(block.headerArguments[":dir"] == nil)
140 #expect(block.headerArguments[":cache"] == Resolved("yes", .heading(0)))
141 }
142
143 @Test func blankLineDetachesAffiliatedKeywords() {
144 #expect(model("#+NAME: x\n\n#+begin_src sh\n#+end_src\n").srcBlocks[0].name == nil)
145 }
146
147 @Test func otherBlocksAreNotSourceBlocks() {
148 #expect(model("#+begin_example\nx\n#+end_example\n").srcBlocks.isEmpty)
149 }
150}
151
152struct TableModelTests {
153 @Test func rowsFormulasAndWidths() {
154 let table = model("| a | 日本 |\n|---+---|\n| 😀 | b |\n#+TBLFM: $2=$1\n").tables[0]
155 #expect(table.rows == [.cells(["a", "日本"]), .rule, .cells(["😀", "b"])])
156 #expect(table.formulas == ["$2=$1"])
157 #expect(table.columnWidths == [2, 4])
158 }
159}