krz/orgstar

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

Sources/OrgCore/Parser/Lines.swift

056befa97fffa110959cb6010c1986461c09dc0b
orgstar/Sources/OrgCore/Parser/Lines.swift history · blame · raw

154 lines · 5664 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    // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
 77    // is not a heading.
 78    if columnZero, rest.first == "*" {
 79        let stars = rest.prefix { $0 == "*" }
 80        if rest.dropFirst(stars.count).first == " " {
 81            return .heading(level: stars.count)
 82        }
 83    }
 84
 85    if rest.hasPrefix("#+") {
 86        let lower = trimmed.lowercased()
 87        if lower.hasPrefix("#+begin_") {
 88            let name = lower.dropFirst(8).prefix { !$0.isWhitespace }
 89            if !name.isEmpty { return .blockBegin(name: String(name)) }
 90        }
 91        if lower.hasPrefix("#+end_") {
 92            let name = lower.dropFirst(6)
 93            if !name.isEmpty, !name.contains(where: \.isWhitespace) { return .blockEnd(name: String(name)) }
 94        }
 95        if lower.hasPrefix("#+begin:") { return .dynamicBegin }
 96        if lower == "#+end:" { return .dynamicEnd }
 97        if let colon = rest.firstIndex(of: ":") {
 98            let key = rest[rest.index(rest.startIndex, offsetBy: 2)..<colon]
 99            if !key.isEmpty, !key.contains(where: \.isWhitespace) { return .keyword(key: key.uppercased()) }
100        }
101    }
102
103    if trimmed == "#" || rest.hasPrefix("# ") || rest.hasPrefix("#\t") { return .comment }
104
105    if rest.first == ":" {
106        if trimmed == ":" || rest.hasPrefix(": ") || rest.hasPrefix(":\t") { return .fixedWidth }
107        if trimmed.uppercased() == ":END:" { return .drawerEnd }
108        if trimmed.count >= 3, trimmed.last == ":" {
109            let name = trimmed.dropFirst().dropLast()
110            if name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) {
111                return .drawerBegin(name: String(name))
112            }
113        }
114    }
115
116    if rest.first == "|" { return .tableRow }
117    if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule }
118
119    if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"),
120       close > rest.index(rest.startIndex, offsetBy: 4) {
121        return .footnoteDefinition
122    }
123
124    if rest.hasPrefix("CLOCK:") { return .clock }
125    if rest.hasPrefix("SCHEDULED:") || rest.hasPrefix("DEADLINE:") || rest.hasPrefix("CLOSED:") { return .planning }
126    if isListBullet(rest, indented: !columnZero) { return .listItem }
127    return .plain
128}
129
130/// `-`, `+`, `*` (indented only), `1.` or `1)`, followed by whitespace or end of line.
131/// Alphabetical bullets are off, as in org's default.
132private func isListBullet(_ rest: Substring, indented: Bool) -> Bool {
133    guard let first = rest.first else { return false }
134    let afterBullet: Substring
135    if first == "-" || first == "+" || (first == "*" && indented) {
136        afterBullet = rest.dropFirst()
137    } else if first.isASCII, first.isNumber {
138        let digits = rest.prefix { $0.isASCII && $0.isNumber }
139        let tail = rest.dropFirst(digits.count)
140        guard let separator = tail.first, separator == "." || separator == ")" else { return false }
141        afterBullet = tail.dropFirst()
142    } else {
143        return false
144    }
145    return afterBullet.isEmpty || afterBullet.first == " " || afterBullet.first == "\t"
146}
147
148extension Substring {
149    var trimmingTrailingWhitespace: Substring {
150        var s = self
151        while let last = s.last, last == " " || last == "\t" { s = s.dropLast() }
152        return s
153    }
154}