krz/orgstar

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

Commit ce36aceaee

ce36aceaee378790a01cd9e6d4ec8ac0c4330859

parent: 13075fe5f9

Verified · cmc

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

Add semantic layer plan

Layout: unified · split

docs/plans/2026-10-04-semantic-layer.md added +844
@@ -0,0 +1,844 @@
1# Semantic Layer Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Typed facts over the parsed tree, so the index and commands never read raw text: headings with their planning, properties, clocks, timestamps and links; tag and property inheritance with provenance; table models; source blocks with resolved header arguments.
6
7**Architecture:** `DocumentModel(tree:settings:)` walks the tree once. Each heading's own facts go into `HeadingInfo`; content of child sections stays with the child. Inheritance is computed on request from local values, so `HeadingInfo` stores only what is written under the heading. Every resolved value is a `Resolved<Value>` carrying a `ValueSource`. Source blocks resolve after the walk because their header arguments depend on file and heading properties.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Foundation only.
10
11**Spec:** `docs/design.md`, "Semantic layer" and the inheritance rules under it.
12
13## Global Constraints
14
15- Local values and resolved values stay separate; inheritance never writes into `HeadingInfo`.
16- Tags inherit by default minus an exclusion list; `#+FILETAGS` count as inherited.
17- Properties don't inherit by default; `CATEGORY`, `ARCHIVE`, `COLUMNS`, `LOGGING` and `header-args*` always do; `ID` and `CUSTOM_ID` never do. `KEY+` appends with a space.
18- Header arguments: org-babel defaults, then `header-args`, then `header-args:LANG` (each from `#+PROPERTY` down to the heading, a plain property replacing what is above it), then `#+HEADER` lines, then the begin line.
19
20## Out of scope
21
22Merging `:results` values by group (collection, type, format, handling), language default header arguments, `CATEGORY` defaulting to the file name (needs the workspace), and source body unescaping (`,*`). Each lands with the phase that needs it.
23
24## File structure
25
26| File | Responsibility |
27| --- | --- |
28| `Sources/OrgCore/Semantic/SemanticSettings.swift` | Inheritance settings, `ValueSource`, `Resolved` |
29| `Sources/OrgCore/Semantic/HeadingInfo.swift` | `HeadingInfo`, `Property`, `Clock` |
30| `Sources/OrgCore/Semantic/DisplayWidth.swift` | Monospace column width of text |
31| `Sources/OrgCore/Semantic/TableModel.swift` | Rows, rules, formulas, column widths |
32| `Sources/OrgCore/Semantic/SrcBlockInfo.swift` | `SrcBlockInfo`, header argument parsing and defaults |
33| `Sources/OrgCore/Semantic/DocumentModel.swift` | The walk, inheritance queries, header argument resolution |
34
35---
36
37### Task 1: Value types
38
39**Files:**
40- Create: `Sources/OrgCore/Semantic/SemanticSettings.swift`, `HeadingInfo.swift`, `DisplayWidth.swift`, `TableModel.swift`, `SrcBlockInfo.swift`
41- Test: `Tests/OrgCoreTests/SemanticValueTests.swift`
42
43**Interfaces:**
44- Produces: `SemanticSettings` (`.default`, `PropertyInheritance` `.none`/`.all`/`.only(Set<String>)` of upper-cased keys), `ValueSource` (`.defaults`, `.file`, `.heading(Int)`, `.element`), `Resolved<Value>(_:_:)`, `Property(key:value:additive:)` and internal `Property(line:)`, `Clock(start:end:minutes:)`, `HeadingInfo`, `displayWidth(_:)`, `TableModel` (internal `init(node:heading:)`), `SrcBlockInfo`, `HeaderArguments.defaults`, `HeaderArguments.parse(_:) -> [(key: String, value: String)]`.
45
46- [ ] **Step 1: Write the failing tests**
47
48```swift
49import Testing
50@testable import OrgCore
51
52struct SemanticValueTests {
53 @Test func parseHeaderArguments() {
54 let pairs = HeaderArguments.parse("ignored :results output silent :dir /tmp :VAR x=1")
55 #expect(pairs.map { "\($0.key)=\($0.value)" } == [":results=output silent", ":dir=/tmp", ":var=x=1"])
56 }
57
58 @Test func widths() {
59 #expect(displayWidth("abc") == 3)
60 #expect(displayWidth("日本") == 4)
61 #expect(displayWidth("e\u{301}") == 1)
62 #expect(displayWidth("😀") == 2)
63 }
64}
65```
66
67- [ ] **Step 2: Run to verify failure**
68
69Run: `swift test --filter SemanticValueTests`
70Expected: build failure, `cannot find 'HeaderArguments' in scope`.
71
72- [ ] **Step 3: Implement `SemanticSettings.swift`**
73
74```swift
75/// App-wide settings that change what a document means without changing how it parses.
76public struct SemanticSettings: Sendable, Equatable {
77 public enum PropertyInheritance: Sendable, Equatable {
78 /// org's default: properties apply only to their own heading.
79 case none
80 case all
81 /// Upper-cased property keys that inherit.
82 case only(Set<String>)
83 }
84
85 public var tagInheritance: Bool
86 public var tagsExcludedFromInheritance: Set<String>
87 public var propertyInheritance: PropertyInheritance
88
89 public init(
90 tagInheritance: Bool = true,
91 tagsExcludedFromInheritance: Set<String> = [],
92 propertyInheritance: PropertyInheritance = .none
93 ) {
94 self.tagInheritance = tagInheritance
95 self.tagsExcludedFromInheritance = tagsExcludedFromInheritance
96 self.propertyInheritance = propertyInheritance
97 }
98
99 public static let `default` = SemanticSettings()
100}
101
102/// Where a resolved value came from.
103public enum ValueSource: Sendable, Equatable {
104 case defaults
105 /// A file-level keyword such as `#+FILETAGS` or `#+PROPERTY`.
106 case file
107 /// A heading, by index into `DocumentModel.headings`.
108 case heading(Int)
109 /// The element itself: a `#+HEADER` line or a block's begin line.
110 case element
111}
112
113public struct Resolved<Value: Sendable & Equatable>: Sendable, Equatable {
114 public let value: Value
115 public let source: ValueSource
116
117 public init(_ value: Value, _ source: ValueSource) {
118 self.value = value
119 self.source = source
120 }
121}
122```
123
124- [ ] **Step 4: Implement `HeadingInfo.swift`**
125
126```swift
127import Foundation
128
129public struct Property: Sendable, Equatable {
130 public let key: String
131 public let value: String
132 /// `:KEY+:` appends to the value instead of replacing it.
133 public let additive: Bool
134
135 public init(key: String, value: String, additive: Bool = false) {
136 self.key = key
137 self.value = value
138 self.additive = additive
139 }
140
141 /// A `:KEY: value` or `:KEY+: value` drawer line.
142 init?(line: String) {
143 let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
144 guard trimmed.hasPrefix(":") else { return nil }
145 let rest = trimmed.dropFirst()
146 guard let colon = rest.firstIndex(of: ":") else { return nil }
147 var key = rest[..<colon]
148 guard !key.isEmpty else { return nil }
149 let additive = key.hasSuffix("+")
150 if additive { key = key.dropLast() }
151 let value = rest[rest.index(after: colon)...].trimmingCharacters(in: .whitespaces)
152 self.init(key: String(key), value: value, additive: additive)
153 }
154}
155
156public struct Clock: Sendable, Equatable {
157 public let start: Timestamp.Point
158 /// Nil while the clock is running.
159 public let end: Timestamp.Point?
160 /// From `=> H:MM`, when present.
161 public let minutes: Int?
162}
163
164/// One heading and the facts that belong to it directly, not to its children.
165public struct HeadingInfo: Sendable, Equatable {
166 public internal(set) var sectionRange: Range<Int>
167 public internal(set) var headingRange: Range<Int>
168 public internal(set) var level: Int
169 /// Index of the parent heading in `DocumentModel.headings`.
170 public internal(set) var parent: Int?
171 public internal(set) var todo: String?
172 public internal(set) var isDone: Bool
173 /// The cookie's value (`A`, `10`), when the heading has one.
174 public internal(set) var priority: String?
175 /// The title as written, markup included.
176 public internal(set) var title: String
177 public internal(set) var localTags: [String]
178 /// Property drawer lines in order.
179 public internal(set) var properties: [Property]
180 public internal(set) var scheduled: Timestamp?
181 public internal(set) var deadline: Timestamp?
182 public internal(set) var closed: Timestamp?
183 public internal(set) var clocks: [Clock]
184 /// Timestamps in the title and body, excluding planning and clock lines.
185 public internal(set) var timestamps: [Timestamp]
186 /// Link targets in the title and body.
187 public internal(set) var links: [String]
188
189 /// The heading's own `ID`. Never inherited.
190 public var id: String? {
191 properties.last { $0.key.uppercased() == "ID" && !$0.additive }?.value
192 }
193}
194```
195
196- [ ] **Step 5: Implement `DisplayWidth.swift`**
197
198```swift
199/// Columns a string occupies in a monospaced table: East Asian wide and fullwidth characters
200/// and emoji take two, everything else one. Combining marks belong to their Character.
201public func displayWidth(_ text: some StringProtocol) -> Int {
202 text.reduce(0) { $0 + displayWidth(of: $1) }
203}
204
205func displayWidth(of character: Character) -> Int {
206 guard let scalar = character.unicodeScalars.first else { return 0 }
207 if scalar.properties.isEmojiPresentation { return 2 }
208 switch scalar.value {
209 case 0x1100...0x115F, 0x2E80...0x303E, 0x3041...0xA4CF, 0xAC00...0xD7A3, 0xF900...0xFAFF,
210 0xFE30...0xFE4F, 0xFF00...0xFF60, 0xFFE0...0xFFE6, 0x20000...0x3FFFD:
211 return 2
212 default:
213 return 1
214 }
215}
216```
217
218- [ ] **Step 6: Implement `TableModel.swift`**
219
220```swift
221import Foundation
222
223public struct TableModel: Sendable, Equatable {
224 public enum Row: Sendable, Equatable {
225 case rule
226 /// Cell text with surrounding blanks trimmed.
227 case cells([String])
228 }
229
230 public let range: Range<Int>
231 public let heading: Int?
232 public let rows: [Row]
233 /// `#+TBLFM:` values in order.
234 public let formulas: [String]
235 /// Widest cell per column, in display columns.
236 public let columnWidths: [Int]
237
238 public var columnCount: Int { columnWidths.count }
239
240 init(node: SyntaxNode, heading: Int?) {
241 range = node.range
242 self.heading = heading
243 rows = node.children.filter { $0.kind == .tableRow }.map { row in
244 let cells = row.children.filter { $0.kind == .tableCell }
245 if cells.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") { return .rule }
246 return .cells(cells.map { $0.text.trimmingCharacters(in: .whitespaces) })
247 }
248 formulas = node.children.filter { $0.kind == .tableFormula }.map {
249 SettingsScanner.keywordValue($0.text.trimmingCharacters(in: .whitespacesAndNewlines)[...])
250 .trimmingCharacters(in: .whitespaces)
251 }
252 var widths: [Int] = []
253 for case .cells(let cells) in rows {
254 for (column, cell) in cells.enumerated() {
255 if column == widths.count { widths.append(0) }
256 widths[column] = max(widths[column], displayWidth(cell))
257 }
258 }
259 columnWidths = widths
260 }
261}
262```
263
264- [ ] **Step 7: Implement `SrcBlockInfo.swift`**
265
266```swift
267public struct SrcBlockInfo: Sendable, Equatable {
268 public let range: Range<Int>
269 public let heading: Int?
270 /// From an attached `#+NAME:` line.
271 public let name: String?
272 public let language: String?
273 /// Words like `-n` or `-r` between the language and the first header argument.
274 public let switches: [String]
275 /// The lines between the begin and end lines, unchanged.
276 public let body: String
277 /// Keys keep their leading colon (`:results`).
278 public let headerArguments: [String: Resolved<String>]
279}
280
281public enum HeaderArguments {
282 /// org-babel's global defaults.
283 public static let defaults: [String: String] = [
284 ":session": "none", ":results": "replace", ":exports": "code", ":cache": "no",
285 ":noweb": "no", ":hlines": "no", ":tangle": "no",
286 ]
287
288 /// `:key value :other value two` as ordered pairs. Keys are lower-cased and keep their colon;
289 /// words before the first key are dropped.
290 public static func parse(_ text: some StringProtocol) -> [(key: String, value: String)] {
291 var pairs: [(key: String, value: String)] = []
292 for word in text.split(whereSeparator: \.isWhitespace) {
293 if word.hasPrefix(":"), word.count > 1 {
294 pairs.append((word.lowercased(), ""))
295 } else if !pairs.isEmpty {
296 let current = pairs[pairs.count - 1].value
297 pairs[pairs.count - 1].value = current.isEmpty ? String(word) : current + " " + word
298 }
299 }
300 return pairs
301 }
302}
303```
304
305- [ ] **Step 8: Run to verify pass**
306
307Run: `swift test --filter SemanticValueTests`
308Expected: all pass.
309
310- [ ] **Step 9: Commit**
311
312```bash
313git add Sources/OrgCore/Semantic Tests/OrgCoreTests/SemanticValueTests.swift
314git commit -m "Add semantic value types"
315```
316
317---
318
319### Task 2: Document model
320
321**Files:**
322- Create: `Sources/OrgCore/Semantic/DocumentModel.swift`
323- Test: `Tests/OrgCoreTests/SemanticTests.swift`
324
325**Interfaces:**
326- Consumes: Task 1 types, `OrgTree`, `SyntaxNode`, `Timestamp.parse`, `SettingsScanner.keywordValue`, `splitRawLines`.
327- Produces: `DocumentModel(tree:settings:)` with `headings`, `fileTags`, `fileProperties`, `tables`, `srcBlocks`, `ancestors(of:)`, `outlinePath(of:)`, `inherits(_:)`, `property(_:of:) -> Resolved<String>?`, `tags(of:) -> [Resolved<String>]`.
328
329- [ ] **Step 1: Write the failing tests**
330
331```swift
332import Testing
333@testable import OrgCore
334
335func model(_ text: String, _ settings: SemanticSettings = .default) -> DocumentModel {
336 DocumentModel(tree: OrgParser.parse(text), settings: settings)
337}
338
339struct HeadingInfoTests {
340 @Test func basics() {
341 let m = model("* TODO [#A] Write *plan* :work:\n** DONE Sub\n")
342 #expect(m.headings.count == 2)
343 let top = m.headings[0]
344 #expect(top.level == 1)
345 #expect(top.todo == "TODO")
346 #expect(!top.isDone)
347 #expect(top.priority == "A")
348 #expect(top.title == "Write *plan*")
349 #expect(top.localTags == ["work"])
350 #expect(top.parent == nil)
351 #expect(m.headings[1].parent == 0)
352 #expect(m.headings[1].isDone)
353 #expect(m.outlinePath(of: 1) == ["Write *plan*", "Sub"])
354 }
355
356 @Test func planningPropertiesClocksAndBody() throws {
357 let text = """
358 * a
359 SCHEDULED: <2026-10-04 Sun> DEADLINE: <2026-10-10 Sat -2d>
360 :PROPERTIES:
361 :ID: abc
362 :Effort: 1:00
363 :END:
364 :LOGBOOK:
365 CLOCK: [2026-10-04 Sun 10:00]--[2026-10-04 Sun 11:30] => 1:30
366 CLOCK: [2026-10-05 Mon 09:00]
367 :END:
368 See <2026-10-07 Wed> and [[https://a.b][x]].
369
370 """
371 let h = model(text).headings[0]
372 #expect(h.scheduled?.start.day == 4)
373 #expect(h.deadline?.warning?.interval.value == 2)
374 #expect(h.closed == nil)
375 #expect(h.id == "abc")
376 #expect(h.properties == [Property(key: "ID", value: "abc"), Property(key: "Effort", value: "1:00")])
377 #expect(h.clocks.count == 2)
378 #expect(h.clocks[0].minutes == 90)
379 #expect(h.clocks[0].end?.hour == 11)
380 #expect(h.clocks[1].end == nil)
381 #expect(h.timestamps == [try #require(Timestamp.parse("<2026-10-07 Wed>"))])
382 #expect(h.links == ["https://a.b"])
383 }
384
385 @Test func childContentStaysWithChild() {
386 let m = model("* a\n** b\n<2026-10-04 Sun>\n")
387 #expect(m.headings[0].timestamps.isEmpty)
388 #expect(m.headings[1].timestamps.count == 1)
389 }
390
391 @Test func titleTimestampsAndLinks() {
392 let h = model("* Call <2026-10-04 Sun> [[id:x][y]]\n").headings[0]
393 #expect(h.timestamps.count == 1)
394 #expect(h.links == ["id:x"])
395 }
396
397 @Test func doneStatesComeFromFileSettings() {
398 let m = model("#+TODO: NEXT | SHIPPED\n* SHIPPED x\n")
399 #expect(m.headings[0].isDone)
400 }
401}
402
403struct InheritanceTests {
404 @Test func tagInheritance() {
405 let text = "#+FILETAGS: :f:\n* a :x:noinherit:\n** b :y:\n"
406 let m = model(text, SemanticSettings(tagsExcludedFromInheritance: ["noinherit"]))
407 #expect(m.tags(of: 1).map(\.value) == ["f", "x", "y"])
408 #expect(m.tags(of: 1).map(\.source) == [.file, .heading(0), .heading(1)])
409 #expect(model(text, SemanticSettings(tagInheritance: false)).tags(of: 1).map(\.value) == ["y"])
410 }
411
412 @Test func ownTagWinsOverInherited() {
413 #expect(model("* a :x:\n** b :x:\n").tags(of: 1) == [Resolved("x", .heading(1))])
414 }
415
416 @Test func propertyPolicies() {
417 let text = "#+PROPERTY: owner team\n* a\n:PROPERTIES:\n:OWNER: alice\n:CATEGORY: work\n:ID: p\n:END:\n** b\n"
418 let plain = model(text)
419 #expect(plain.property("OWNER", of: 1) == nil)
420 #expect(plain.property("CATEGORY", of: 1) == Resolved("work", .heading(0)))
421 #expect(plain.property("OWNER", of: nil) == Resolved("team", .file))
422
423 let all = model(text, SemanticSettings(propertyInheritance: .all))
424 #expect(all.property("owner", of: 1) == Resolved("alice", .heading(0)))
425 #expect(all.property("ID", of: 1) == nil)
426
427 let some = model(text, SemanticSettings(propertyInheritance: .only(["OWNER"])))
428 #expect(some.property("OWNER", of: 1) == Resolved("alice", .heading(0)))
429 }
430
431 @Test func additiveProperties() {
432 let text = "* a\n:PROPERTIES:\n:VAR: x=1\n:END:\n** b\n:PROPERTIES:\n:VAR+: y=2\n:END:\n"
433 #expect(model(text, SemanticSettings(propertyInheritance: .all)).property("VAR", of: 1) == Resolved("x=1 y=2", .heading(1)))
434 #expect(model(text).property("VAR", of: 1) == Resolved("y=2", .heading(1)))
435 }
436}
437
438struct SrcBlockTests {
439 @Test func resolution() throws {
440 let text = """
441 #+PROPERTY: header-args :dir /a :cache yes
442 #+PROPERTY: header-args:sh :results output
443 * a
444 :PROPERTIES:
445 :header-args+: :dir /b
446 :END:
447 #+NAME: hello
448 #+HEADER: :var x=1
449 #+begin_src sh -n :results silent
450 echo $x
451 #+end_src
452
453 """
454 let block = try #require(model(text).srcBlocks.first)
455 #expect(block.name == "hello")
456 #expect(block.language == "sh")
457 #expect(block.switches == ["-n"])
458 #expect(block.body == "echo $x\n")
459 #expect(block.heading == 0)
460 #expect(block.headerArguments[":dir"] == Resolved("/b", .heading(0)))
461 #expect(block.headerArguments[":cache"] == Resolved("yes", .file))
462 #expect(block.headerArguments[":results"] == Resolved("silent", .element))
463 #expect(block.headerArguments[":var"] == Resolved("x=1", .element))
464 #expect(block.headerArguments[":exports"] == Resolved("code", .defaults))
465 }
466
467 @Test func plainPropertyReplacesInheritedHeaderArguments() {
468 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"
469 let block = model(text).srcBlocks[0]
470 #expect(block.headerArguments[":dir"] == nil)
471 #expect(block.headerArguments[":cache"] == Resolved("yes", .heading(0)))
472 }
473
474 @Test func blankLineDetachesAffiliatedKeywords() {
475 #expect(model("#+NAME: x\n\n#+begin_src sh\n#+end_src\n").srcBlocks[0].name == nil)
476 }
477
478 @Test func otherBlocksAreNotSourceBlocks() {
479 #expect(model("#+begin_example\nx\n#+end_example\n").srcBlocks.isEmpty)
480 }
481}
482
483struct TableModelTests {
484 @Test func rowsFormulasAndWidths() {
485 let table = model("| a | 日本 |\n|---+---|\n| 😀 | b |\n#+TBLFM: $2=$1\n").tables[0]
486 #expect(table.rows == [.cells(["a", "日本"]), .rule, .cells(["😀", "b"])])
487 #expect(table.formulas == ["$2=$1"])
488 #expect(table.columnWidths == [2, 4])
489 }
490}
491```
492
493- [ ] **Step 2: Run to verify failure**
494
495Run: `swift test --filter HeadingInfoTests`
496Expected: build failure, `cannot find 'DocumentModel' in scope`.
497
498- [ ] **Step 3: Implement**
499
500```swift
501import Foundation
502
503/// Typed facts read from one parsed document. Built once per tree; commands and the index read
504/// this rather than raw text.
505public struct DocumentModel: Sendable {
506 public let settings: SemanticSettings
507 public let orgSettings: OrgSettings
508 public private(set) var headings: [HeadingInfo] = []
509 public private(set) var fileTags: [String] = []
510 /// `#+PROPERTY:` lines in order.
511 public private(set) var fileProperties: [Property] = []
512 public private(set) var tables: [TableModel] = []
513 public private(set) var srcBlocks: [SrcBlockInfo] = []
514
515 /// Always inherited, whatever the inheritance setting.
516 static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"]
517 /// Identify one heading, so never inherited.
518 static let neverInherited: Set<String> = ["ID", "CUSTOM_ID"]
519
520 private typealias PendingBlock = (node: SyntaxNode, heading: Int?, affiliated: [SyntaxNode])
521
522 public init(tree: OrgTree, settings: SemanticSettings = .default) {
523 self.settings = settings
524 self.orgSettings = tree.settings
525 var pending: [PendingBlock] = []
526 walk(tree.root, heading: nil, pending: &pending)
527 // Header arguments depend on file and heading properties, so blocks resolve last.
528 srcBlocks = pending.map { srcBlock($0.node, heading: $0.heading, affiliated: $0.affiliated) }
529 }
530
531 // MARK: - Queries
532
533 /// Ancestor indexes of a heading, outermost first.
534 public func ancestors(of index: Int) -> [Int] {
535 var result: [Int] = []
536 var current = headings[index].parent
537 while let parent = current {
538 result.insert(parent, at: 0)
539 current = headings[parent].parent
540 }
541 return result
542 }
543
544 /// Titles from the outermost ancestor down to the heading itself.
545 public func outlinePath(of index: Int) -> [String] {
546 (ancestors(of: index) + [index]).map { headings[$0].title }
547 }
548
549 public func inherits(_ key: String) -> Bool {
550 let key = key.uppercased()
551 if Self.neverInherited.contains(key) { return false }
552 if Self.alwaysInherited.contains(key) || key.hasPrefix("HEADER-ARGS") { return true }
553 switch settings.propertyInheritance {
554 case .none: return false
555 case .all: return true
556 case .only(let keys): return keys.contains(key)
557 }
558 }
559
560 /// The value of `key` for a heading, or for the file when `index` is nil. Inherited keys
561 /// start from `#+PROPERTY` and walk down the ancestors; `KEY+` lines append with a space.
562 public func property(_ key: String, of index: Int?) -> Resolved<String>? {
563 let key = key.uppercased()
564 var layers: [(entries: [Property], source: ValueSource)] = []
565 if let index {
566 if inherits(key) {
567 layers.append((fileProperties, .file))
568 layers += ancestors(of: index).map { (headings[$0].properties, .heading($0)) }
569 }
570 layers.append((headings[index].properties, .heading(index)))
571 } else {
572 layers.append((fileProperties, .file))
573 }
574 var result: Resolved<String>?
575 for layer in layers {
576 for entry in layer.entries where entry.key.uppercased() == key {
577 if entry.additive, let current = result {
578 result = Resolved(current.value + " " + entry.value, layer.source)
579 } else {
580 result = Resolved(entry.value, layer.source)
581 }
582 }
583 }
584 return result
585 }
586
587 /// Inherited tags first (file tags, then ancestors outermost first), then the heading's own.
588 /// A tag the heading has itself counts as its own.
589 public func tags(of index: Int) -> [Resolved<String>] {
590 let local = headings[index].localTags
591 var inherited: [Resolved<String>] = []
592 if settings.tagInheritance {
593 let layers = [(fileTags, ValueSource.file)] + ancestors(of: index).map { (headings[$0].localTags, ValueSource.heading($0)) }
594 for (tags, source) in layers {
595 for tag in tags where !settings.tagsExcludedFromInheritance.contains(tag)
596 && !local.contains(tag) && !inherited.contains(where: { $0.value == tag }) {
597 inherited.append(Resolved(tag, source))
598 }
599 }
600 }
601 var own: [Resolved<String>] = []
602 for tag in local where !own.contains(where: { $0.value == tag }) {
603 own.append(Resolved(tag, .heading(index)))
604 }
605 return inherited + own
606 }
607
608 // MARK: - Building
609
610 private mutating func walk(_ node: SyntaxNode, heading: Int?, pending: inout [PendingBlock]) {
611 var previous: SyntaxNode?
612 var affiliated: [SyntaxNode] = []
613 for child in node.children {
614 // Blank lines between nodes detach affiliated keywords.
615 if let previous, previous.range.upperBound != child.range.lowerBound { affiliated = [] }
616 switch child.kind {
617 case .section:
618 section(child, parent: heading, pending: &pending)
619 case .heading, .affiliatedKeyword:
620 break
621 case .planning:
622 if let heading { planning(child, heading) }
623 case .propertyDrawer:
624 if let heading {
625 headings[heading].properties += child.children.filter { $0.kind == .nodeProperty }.compactMap { Property(line: $0.text) }
626 }
627 case .clock:
628 if let heading, let clock = clock(child) { headings[heading].clocks.append(clock) }
629 case .timestamp:
630 if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) }
631 case .link:
632 if let heading { headings[heading].links.append(linkTarget(child)) }
633 case .keyword:
634 fileKeyword(child)
635 case .table:
636 tables.append(TableModel(node: child, heading: heading))
637 walk(child, heading: heading, pending: &pending)
638 case .block:
639 if Self.isSrcBlock(child) { pending.append((child, heading, affiliated)) }
640 default:
641 walk(child, heading: heading, pending: &pending)
642 }
643 affiliated = child.kind == .affiliatedKeyword ? affiliated + [child] : []
644 previous = child
645 }
646 }
647
648 private mutating func section(_ node: SyntaxNode, parent: Int?, pending: inout [PendingBlock]) {
649 guard let headingNode = node.children.first(where: { $0.kind == .heading }) else { return }
650 let index = headings.count
651 let tokens = headingNode.tokens
652 let todo = tokens.first { $0.kind == .todoKeyword }?.text
653 let title = headingNode.children.first { $0.kind == .title }
654 headings.append(HeadingInfo(
655 sectionRange: node.range,
656 headingRange: headingNode.range,
657 level: tokens.first { $0.kind == .stars }?.text.count ?? 0,
658 parent: parent,
659 todo: todo,
660 isDone: todo.map(orgSettings.isDone) ?? false,
661 priority: tokens.first { $0.kind == .priority }.map { String($0.text.dropFirst(2).dropLast()) },
662 title: title?.text ?? "",
663 localTags: tokens.first { $0.kind == .tags }.map { $0.text.split(separator: ":").map(String.init) } ?? [],
664 properties: [], scheduled: nil, deadline: nil, closed: nil, clocks: [], timestamps: [], links: []
665 ))
666 if let title { walk(title, heading: index, pending: &pending) }
667 walk(node, heading: index, pending: &pending)
668 }
669
670 /// Each timestamp belongs to the keyword just before it.
671 private mutating func planning(_ node: SyntaxNode, _ heading: Int) {
672 let text = node.text
673 for stampNode in node.children where stampNode.kind == .timestamp {
674 guard let stamp = Timestamp.parse(stampNode.text) else { continue }
675 let end = String.Index(utf16Offset: stampNode.offset - node.offset, in: text)
676 let before = text[..<end].trimmingCharacters(in: .whitespaces)
677 if before.hasSuffix("SCHEDULED:") {
678 headings[heading].scheduled = stamp
679 } else if before.hasSuffix("DEADLINE:") {
680 headings[heading].deadline = stamp
681 } else if before.hasSuffix("CLOSED:") {
682 headings[heading].closed = stamp
683 }
684 }
685 }
686
687 private func clock(_ node: SyntaxNode) -> Clock? {
688 guard let stampNode = node.children.first(where: { $0.kind == .timestamp }),
689 let stamp = Timestamp.parse(stampNode.text) else { return nil }
690 var minutes: Int?
691 if let arrow = node.text.range(of: "=>") {
692 let parts = node.text[arrow.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ":")
693 if parts.count == 2, let hours = Int(parts[0]), let mins = Int(parts[1]) { minutes = hours * 60 + mins }
694 }
695 return Clock(start: stamp.start, end: stamp.end, minutes: minutes)
696 }
697
698 private func linkTarget(_ node: SyntaxNode) -> String {
699 if let path = node.tokens.first(where: { $0.kind == .linkPath }) { return path.text }
700 let text = node.text
701 if text.hasPrefix("<"), text.hasSuffix(">") { return String(text.dropFirst().dropLast()) }
702 return text
703 }
704
705 private mutating func fileKeyword(_ node: SyntaxNode) {
706 let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
707 guard let colon = text.firstIndex(of: ":") else { return }
708 let key = text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased()
709 let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
710 switch key {
711 case "FILETAGS":
712 fileTags += value.split(whereSeparator: { $0 == ":" || $0.isWhitespace }).map(String.init)
713 case "PROPERTY":
714 let parts = value.split(maxSplits: 1, whereSeparator: \.isWhitespace)
715 guard var name = parts.first.map(String.init) else { return }
716 let additive = name.hasSuffix("+")
717 if additive { name.removeLast() }
718 fileProperties.append(Property(key: name, value: parts.count > 1 ? String(parts[1]) : "", additive: additive))
719 default:
720 break
721 }
722 }
723
724 // MARK: - Source blocks
725
726 static func isSrcBlock(_ node: SyntaxNode) -> Bool {
727 node.text.drop { $0 == " " || $0 == "\t" }.lowercased().hasPrefix("#+begin_src")
728 }
729
730 private func srcBlock(_ node: SyntaxNode, heading: Int?, affiliated: [SyntaxNode]) -> SrcBlockInfo {
731 let raw = splitRawLines(node.text)
732 let beginLine = raw[0].content.drop { $0 == " " || $0 == "\t" }.dropFirst("#+begin_src".count)
733 let words = beginLine.split(whereSeparator: \.isWhitespace)
734 var language: String?
735 var switches: [String] = []
736 var rest = words[...]
737 if let first = rest.first, !first.hasPrefix("-"), !first.hasPrefix("+"), !first.hasPrefix(":") {
738 language = String(first)
739 rest = rest.dropFirst()
740 }
741 while let word = rest.first, !word.hasPrefix(":") {
742 switches.append(String(word))
743 rest = rest.dropFirst()
744 }
745 let body = raw.dropFirst().dropLast().map { String($0.content) + String($0.ending) }.joined()
746
747 var name: String?
748 var headerLines: [String] = []
749 for keyword in affiliated {
750 let text = keyword.text.trimmingCharacters(in: .whitespacesAndNewlines)
751 let value = SettingsScanner.keywordValue(text[...]).trimmingCharacters(in: .whitespaces)
752 if text.uppercased().hasPrefix("#+NAME:") { name = value }
753 if text.uppercased().hasPrefix("#+HEADER:") { headerLines.append(value) }
754 }
755
756 var arguments = HeaderArguments.defaults.mapValues { Resolved($0, .defaults) }
757 var keys = ["header-args"]
758 if let language { keys.append("header-args:\(language)") }
759 for key in keys {
760 for pair in headerArgumentLayers(key, heading: heading) {
761 arguments[pair.key] = Resolved(pair.value, pair.source)
762 }
763 }
764 for line in headerLines + [rest.joined(separator: " ")] {
765 for pair in HeaderArguments.parse(line) {
766 arguments[pair.key] = Resolved(pair.value, .element)
767 }
768 }
769 return SrcBlockInfo(
770 range: node.range, heading: heading, name: name, language: language,
771 switches: switches, body: body, headerArguments: arguments
772 )
773 }
774
775 /// `header-args` pairs from `#+PROPERTY` down to the heading. As in org, a plain property
776 /// replaces everything above it; `header-args+` adds to it.
777 private func headerArgumentLayers(_ key: String, heading: Int?) -> [(key: String, value: String, source: ValueSource)] {
778 var layers: [(entries: [Property], source: ValueSource)] = [(fileProperties, .file)]
779 if let heading {
780 layers += (ancestors(of: heading) + [heading]).map { (headings[$0].properties, .heading($0)) }
781 }
782 var pairs: [(key: String, value: String, source: ValueSource)] = []
783 for layer in layers {
784 for entry in layer.entries where entry.key.lowercased() == key {
785 let parsed = HeaderArguments.parse(entry.value).map { (key: $0.key, value: $0.value, source: layer.source) }
786 pairs = entry.additive ? pairs + parsed : parsed
787 }
788 }
789 return pairs
790 }
791}
792```
793
794- [ ] **Step 4: Run all tests**
795
796Run: `swift test`
797Expected: all pass.
798
799- [ ] **Step 5: Commit**
800
801```bash
802git add Sources/OrgCore/Semantic/DocumentModel.swift Tests/OrgCoreTests/SemanticTests.swift
803git commit -m "Add document model with inheritance and header arguments"
804```
805
806---
807
808### Task 3: Build the model in fuzz and corpus tests
809
810**Files:**
811- Modify: `Tests/OrgCoreTests/RoundTripTests.swift`
812
813- [ ] **Step 1: Build a model for every parsed document**
814
815```diff
816@@ -45,6 +45,7 @@ struct RoundTripTests {
817 let tree = OrgParser.parse(text)
818 #expect(tree.text == text, "document \(n)")
819 #expect(checkLengths(tree.root), "document \(n)")
820+ _ = DocumentModel(tree: tree)
821 }
822 }
823
824@@ -74,6 +75,7 @@ struct RoundTripTests {
825 let source = SourceText(bytes: bytes)
826 let tree = OrgParser.parse(source.text)
827 #expect(source.encode(tree.text) == bytes, "\(file.path)")
828+ _ = DocumentModel(tree: tree)
829 }
830 }
831 }
832```
833
834- [ ] **Step 2: Run**
835
836Run: `swift test` then `ORGSTAR_CORPUS=~/Documents/notes swift test --filter corpusRoundTrips`
837Expected: all pass, no crashes.
838
839- [ ] **Step 3: Commit**
840
841```bash
842git add Tests/OrgCoreTests/RoundTripTests.swift
843git commit -m "Build document models in fuzz and corpus tests"
844```