Semantic layer !3

merged merged by cmc on 2026-10-04 23:46 UTC · krz/orgstar:phase1-semantic-layer into main

10 files changed, +1520 −0

Layout: unified · split

Sources/OrgCore/Semantic/DisplayWidth.swift added +17
@@ -0,0 +1,17 @@
1/// Columns a string occupies in a monospaced table: East Asian wide and fullwidth characters
2/// and emoji take two, everything else one. Combining marks belong to their Character.
3public func displayWidth(_ text: some StringProtocol) -> Int {
4 text.reduce(0) { $0 + displayWidth(of: $1) }
5}
6
7func displayWidth(of character: Character) -> Int {
8 guard let scalar = character.unicodeScalars.first else { return 0 }
9 if scalar.properties.isEmojiPresentation { return 2 }
10 switch scalar.value {
11 case 0x1100...0x115F, 0x2E80...0x303E, 0x3041...0xA4CF, 0xAC00...0xD7A3, 0xF900...0xFAFF,
12 0xFE30...0xFE4F, 0xFF00...0xFF60, 0xFFE0...0xFFE6, 0x20000...0x3FFFD:
13 return 2
14 default:
15 return 1
16 }
17}
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}
Sources/OrgCore/Semantic/HeadingInfo.swift added +67
@@ -0,0 +1,67 @@
1import Foundation
2
3public struct Property: Sendable, Equatable {
4 public let key: String
5 public let value: String
6 /// `:KEY+:` appends to the value instead of replacing it.
7 public let additive: Bool
8
9 public init(key: String, value: String, additive: Bool = false) {
10 self.key = key
11 self.value = value
12 self.additive = additive
13 }
14
15 /// A `:KEY: value` or `:KEY+: value` drawer line.
16 init?(line: String) {
17 let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
18 guard trimmed.hasPrefix(":") else { return nil }
19 let rest = trimmed.dropFirst()
20 guard let colon = rest.firstIndex(of: ":") else { return nil }
21 var key = rest[..<colon]
22 guard !key.isEmpty else { return nil }
23 let additive = key.hasSuffix("+")
24 if additive { key = key.dropLast() }
25 let value = rest[rest.index(after: colon)...].trimmingCharacters(in: .whitespaces)
26 self.init(key: String(key), value: value, additive: additive)
27 }
28}
29
30public struct Clock: Sendable, Equatable {
31 public let start: Timestamp.Point
32 /// Nil while the clock is running.
33 public let end: Timestamp.Point?
34 /// From `=> H:MM`, when present.
35 public let minutes: Int?
36}
37
38/// One heading and the facts that belong to it directly, not to its children.
39public struct HeadingInfo: Sendable, Equatable {
40 public internal(set) var sectionRange: Range<Int>
41 public internal(set) var headingRange: Range<Int>
42 public internal(set) var level: Int
43 /// Index of the parent heading in `DocumentModel.headings`.
44 public internal(set) var parent: Int?
45 public internal(set) var todo: String?
46 public internal(set) var isDone: Bool
47 /// The cookie's value (`A`, `10`), when the heading has one.
48 public internal(set) var priority: String?
49 /// The title as written, markup included.
50 public internal(set) var title: String
51 public internal(set) var localTags: [String]
52 /// Property drawer lines in order.
53 public internal(set) var properties: [Property]
54 public internal(set) var scheduled: Timestamp?
55 public internal(set) var deadline: Timestamp?
56 public internal(set) var closed: Timestamp?
57 public internal(set) var clocks: [Clock]
58 /// Timestamps in the title and body, excluding planning and clock lines.
59 public internal(set) var timestamps: [Timestamp]
60 /// Link targets in the title and body.
61 public internal(set) var links: [String]
62
63 /// The heading's own `ID`. Never inherited.
64 public var id: String? {
65 properties.last { $0.key.uppercased() == "ID" && !$0.additive }?.value
66 }
67}
Sources/OrgCore/Semantic/SemanticSettings.swift added +47
@@ -0,0 +1,47 @@
1/// App-wide settings that change what a document means without changing how it parses.
2public struct SemanticSettings: Sendable, Equatable {
3 public enum PropertyInheritance: Sendable, Equatable {
4 /// org's default: properties apply only to their own heading.
5 case none
6 case all
7 /// Upper-cased property keys that inherit.
8 case only(Set<String>)
9 }
10
11 public var tagInheritance: Bool
12 public var tagsExcludedFromInheritance: Set<String>
13 public var propertyInheritance: PropertyInheritance
14
15 public init(
16 tagInheritance: Bool = true,
17 tagsExcludedFromInheritance: Set<String> = [],
18 propertyInheritance: PropertyInheritance = .none
19 ) {
20 self.tagInheritance = tagInheritance
21 self.tagsExcludedFromInheritance = tagsExcludedFromInheritance
22 self.propertyInheritance = propertyInheritance
23 }
24
25 public static let `default` = SemanticSettings()
26}
27
28/// Where a resolved value came from.
29public enum ValueSource: Sendable, Equatable {
30 case defaults
31 /// A file-level keyword such as `#+FILETAGS` or `#+PROPERTY`.
32 case file
33 /// A heading, by index into `DocumentModel.headings`.
34 case heading(Int)
35 /// The element itself: a `#+HEADER` line or a block's begin line.
36 case element
37}
38
39public struct Resolved<Value: Sendable & Equatable>: Sendable, Equatable {
40 public let value: Value
41 public let source: ValueSource
42
43 public init(_ value: Value, _ source: ValueSource) {
44 self.value = value
45 self.source = source
46 }
47}
Sources/OrgCore/Semantic/SrcBlockInfo.swift added +36
@@ -0,0 +1,36 @@
1public struct SrcBlockInfo: Sendable, Equatable {
2 public let range: Range<Int>
3 public let heading: Int?
4 /// From an attached `#+NAME:` line.
5 public let name: String?
6 public let language: String?
7 /// Words like `-n` or `-r` between the language and the first header argument.
8 public let switches: [String]
9 /// The lines between the begin and end lines, unchanged.
10 public let body: String
11 /// Keys keep their leading colon (`:results`).
12 public let headerArguments: [String: Resolved<String>]
13}
14
15public enum HeaderArguments {
16 /// org-babel's global defaults.
17 public static let defaults: [String: String] = [
18 ":session": "none", ":results": "replace", ":exports": "code", ":cache": "no",
19 ":noweb": "no", ":hlines": "no", ":tangle": "no",
20 ]
21
22 /// `:key value :other value two` as ordered pairs. Keys are lower-cased and keep their colon;
23 /// words before the first key are dropped.
24 public static func parse(_ text: some StringProtocol) -> [(key: String, value: String)] {
25 var pairs: [(key: String, value: String)] = []
26 for word in text.split(whereSeparator: \.isWhitespace) {
27 if word.hasPrefix(":"), word.count > 1 {
28 pairs.append((word.lowercased(), ""))
29 } else if !pairs.isEmpty {
30 let current = pairs[pairs.count - 1].value
31 pairs[pairs.count - 1].value = current.isEmpty ? String(word) : current + " " + word
32 }
33 }
34 return pairs
35 }
36}
Sources/OrgCore/Semantic/TableModel.swift added +41
@@ -0,0 +1,41 @@
1import Foundation
2
3public struct TableModel: Sendable, Equatable {
4 public enum Row: Sendable, Equatable {
5 case rule
6 /// Cell text with surrounding blanks trimmed.
7 case cells([String])
8 }
9
10 public let range: Range<Int>
11 public let heading: Int?
12 public let rows: [Row]
13 /// `#+TBLFM:` values in order.
14 public let formulas: [String]
15 /// Widest cell per column, in display columns.
16 public let columnWidths: [Int]
17
18 public var columnCount: Int { columnWidths.count }
19
20 init(node: SyntaxNode, heading: Int?) {
21 range = node.range
22 self.heading = heading
23 rows = node.children.filter { $0.kind == .tableRow }.map { row in
24 let cells = row.children.filter { $0.kind == .tableCell }
25 if cells.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") { return .rule }
26 return .cells(cells.map { $0.text.trimmingCharacters(in: .whitespaces) })
27 }
28 formulas = node.children.filter { $0.kind == .tableFormula }.map {
29 SettingsScanner.keywordValue($0.text.trimmingCharacters(in: .whitespacesAndNewlines)[...])
30 .trimmingCharacters(in: .whitespaces)
31 }
32 var widths: [Int] = []
33 for case .cells(let cells) in rows {
34 for (column, cell) in cells.enumerated() {
35 if column == widths.count { widths.append(0) }
36 widths[column] = max(widths[column], displayWidth(cell))
37 }
38 }
39 columnWidths = widths
40 }
41}
Tests/OrgCoreTests/RoundTripTests.swift +2
@@ -45,6 +45,7 @@ struct RoundTripTests {
45 let tree = OrgParser.parse(text) 45 let tree = OrgParser.parse(text)
46 #expect(tree.text == text, "document \(n)") 46 #expect(tree.text == text, "document \(n)")
47 #expect(checkLengths(tree.root), "document \(n)") 47 #expect(checkLengths(tree.root), "document \(n)")
48 _ = DocumentModel(tree: tree)
48 } 49 }
49 } 50 }
50 51
@@ -74,6 +75,7 @@ struct RoundTripTests {
74 let source = SourceText(bytes: bytes) 75 let source = SourceText(bytes: bytes)
75 let tree = OrgParser.parse(source.text) 76 let tree = OrgParser.parse(source.text)
76 #expect(source.encode(tree.text) == bytes, "\(file.path)") 77 #expect(source.encode(tree.text) == bytes, "\(file.path)")
78 _ = DocumentModel(tree: tree)
77 } 79 }
78 } 80 }
79} 81}
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}
Tests/OrgCoreTests/SemanticValueTests.swift added +16
@@ -0,0 +1,16 @@
1import Testing
2@testable import OrgCore
3
4struct SemanticValueTests {
5 @Test func parseHeaderArguments() {
6 let pairs = HeaderArguments.parse("ignored :results output silent :dir /tmp :VAR x=1")
7 #expect(pairs.map { "\($0.key)=\($0.value)" } == [":results=output silent", ":dir=/tmp", ":var=x=1"])
8 }
9
10 @Test func widths() {
11 #expect(displayWidth("abc") == 3)
12 #expect(displayWidth("日本") == 4)
13 #expect(displayWidth("e\u{301}") == 1)
14 #expect(displayWidth("😀") == 2)
15 }
16}
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```