krz/orgstar

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

docs/plans/2026-10-04-semantic-layer.md

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/docs/plans/2026-10-04-semantic-layer.md rendered · source · history · blame · raw

844 lines · 34840 bytes

  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```