krz/orgstar

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

Commit ce4e5e4baa

ce4e5e4baa2b7563a6db722695fb40d7ddeb2869

parent: ce36aceaee

Verified · cmc

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

Add semantic value types

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/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/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}