Commit ce4e5e4baa
Verified · cmc
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. | |
| 3 | public func displayWidth(_ text: some StringProtocol) -> Int { | |
| 4 | text.reduce(0) { $0 + displayWidth(of: $1) } | |
| 5 | } | |
| 6 | ||
| 7 | func 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | public 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 | ||
| 30 | public 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. | |
| 39 | public 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. | |
| 2 | public 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. | |
| 29 | public 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 | ||
| 39 | public 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 @@ | ||
| 1 | public 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 | ||
| 15 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | public 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 @@ | ||
| 1 | import Testing | |
| 2 | @testable import OrgCore | |
| 3 | ||
| 4 | struct 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 | } | |