krz/orgstar

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

Sources/OrgCore/Parser/Lines.swift

0271e942ff3d2f72a407b3d9a6463fab8e4b48cf
orgstar/Sources/OrgCore/Parser/Lines.swift history · blame · raw

153 lines · 5608 bytes

  1/// One line of source: its content and its terminator ("", "\n" or "\r\n"), both as slices of
  2/// the original text.
  3struct RawLine {
  4    let content: Substring
  5    let ending: Substring
  6}
  7
  8/// Splits on "\n" without normalizing anything. Works on unicode scalars, because String
  9/// treats "\r\n" as a single Character.
 10func splitRawLines(_ text: String) -> [RawLine] {
 11    var lines: [RawLine] = []
 12    let scalars = text.unicodeScalars
 13    var lineStart = scalars.startIndex
 14    var i = lineStart
 15    while i != scalars.endIndex {
 16        if scalars[i] == "\n" {
 17            var contentEnd = i
 18            if contentEnd > lineStart, scalars[scalars.index(before: i)] == "\r" {
 19                contentEnd = scalars.index(before: i)
 20            }
 21            let next = scalars.index(after: i)
 22            lines.append(RawLine(content: text[lineStart..<contentEnd], ending: text[contentEnd..<next]))
 23            lineStart = next
 24            i = next
 25        } else {
 26            i = scalars.index(after: i)
 27        }
 28    }
 29    if lineStart != scalars.endIndex {
 30        lines.append(RawLine(content: text[lineStart...], ending: text[text.endIndex...]))
 31    }
 32    return lines
 33}
 34
 35enum LineClass: Equatable {
 36    case blank
 37    case heading(level: Int)
 38    case blockBegin(name: String)
 39    case blockEnd(name: String)
 40    case dynamicBegin
 41    case dynamicEnd
 42    case drawerBegin(name: String)
 43    case drawerEnd
 44    case keyword(key: String)
 45    case comment
 46    case fixedWidth
 47    case horizontalRule
 48    case tableRow
 49    case footnoteDefinition
 50    case clock
 51    case planning
 52    case listItem
 53    case plain
 54}
 55
 56struct ClassifiedLine {
 57    let cls: LineClass
 58    /// Column of the first non-blank character, with tabs advancing to the next multiple of 8.
 59    let indent: Int
 60}
 61
 62func classifyLine(_ line: Substring) -> ClassifiedLine {
 63    var column = 0
 64    var rest = line
 65    while let c = rest.first, c == " " || c == "\t" {
 66        column = c == "\t" ? (column / 8 + 1) * 8 : column + 1
 67        rest = rest.dropFirst()
 68    }
 69    if rest.isEmpty { return ClassifiedLine(cls: .blank, indent: column) }
 70    return ClassifiedLine(cls: lineClass(rest, columnZero: column == 0), indent: column)
 71}
 72
 73private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
 74    let trimmed = rest.trimmingTrailingWhitespace
 75
 76    if columnZero, rest.first == "*" {
 77        let stars = rest.prefix { $0 == "*" }
 78        let after = rest.dropFirst(stars.count)
 79        if after.isEmpty || after.first == " " || after.first == "\t" {
 80            return .heading(level: stars.count)
 81        }
 82    }
 83
 84    if rest.hasPrefix("#+") {
 85        let lower = trimmed.lowercased()
 86        if lower.hasPrefix("#+begin_") {
 87            let name = lower.dropFirst(8).prefix { !$0.isWhitespace }
 88            if !name.isEmpty { return .blockBegin(name: String(name)) }
 89        }
 90        if lower.hasPrefix("#+end_") {
 91            let name = lower.dropFirst(6)
 92            if !name.isEmpty, !name.contains(where: \.isWhitespace) { return .blockEnd(name: String(name)) }
 93        }
 94        if lower.hasPrefix("#+begin:") { return .dynamicBegin }
 95        if lower == "#+end:" { return .dynamicEnd }
 96        if let colon = rest.firstIndex(of: ":") {
 97            let key = rest[rest.index(rest.startIndex, offsetBy: 2)..<colon]
 98            if !key.isEmpty, !key.contains(where: \.isWhitespace) { return .keyword(key: key.uppercased()) }
 99        }
100    }
101
102    if trimmed == "#" || rest.hasPrefix("# ") || rest.hasPrefix("#\t") { return .comment }
103
104    if rest.first == ":" {
105        if trimmed == ":" || rest.hasPrefix(": ") || rest.hasPrefix(":\t") { return .fixedWidth }
106        if trimmed.uppercased() == ":END:" { return .drawerEnd }
107        if trimmed.count >= 3, trimmed.last == ":" {
108            let name = trimmed.dropFirst().dropLast()
109            if name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) {
110                return .drawerBegin(name: String(name))
111            }
112        }
113    }
114
115    if rest.first == "|" { return .tableRow }
116    if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule }
117
118    if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"),
119       close > rest.index(rest.startIndex, offsetBy: 4) {
120        return .footnoteDefinition
121    }
122
123    if rest.hasPrefix("CLOCK:") { return .clock }
124    if rest.hasPrefix("SCHEDULED:") || rest.hasPrefix("DEADLINE:") || rest.hasPrefix("CLOSED:") { return .planning }
125    if isListBullet(rest, indented: !columnZero) { return .listItem }
126    return .plain
127}
128
129/// `-`, `+`, `*` (indented only), `1.` or `1)`, followed by whitespace or end of line.
130/// Alphabetical bullets are off, as in org's default.
131private func isListBullet(_ rest: Substring, indented: Bool) -> Bool {
132    guard let first = rest.first else { return false }
133    let afterBullet: Substring
134    if first == "-" || first == "+" || (first == "*" && indented) {
135        afterBullet = rest.dropFirst()
136    } else if first.isASCII, first.isNumber {
137        let digits = rest.prefix { $0.isASCII && $0.isNumber }
138        let tail = rest.dropFirst(digits.count)
139        guard let separator = tail.first, separator == "." || separator == ")" else { return false }
140        afterBullet = tail.dropFirst()
141    } else {
142        return false
143    }
144    return afterBullet.isEmpty || afterBullet.first == " " || afterBullet.first == "\t"
145}
146
147extension Substring {
148    var trimmingTrailingWhitespace: Substring {
149        var s = self
150        while let last = s.last, last == " " || last == "\t" { s = s.dropLast() }
151        return s
152    }
153}