krz/orgstar

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

Sources/OrgCore/Parser/Lines.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgCore/Parser/Lines.swift history · blame · raw

184 lines · 7386 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    /// Fifteen stars or more (`org-inlinetask-min-level`): an inline task, not a heading.
 39    case inlineTask(level: Int)
 40    case inlineTaskEnd
 41    case blockBegin(name: String)
 42    case blockEnd(name: String)
 43    case dynamicBegin
 44    case dynamicEnd
 45    case drawerBegin(name: String)
 46    case drawerEnd
 47    /// `\begin{NAME}` and `\end{NAME}` of a LaTeX environment.
 48    case latexBegin(name: String)
 49    case latexEnd(name: String)
 50    case keyword(key: String)
 51    case comment
 52    case fixedWidth
 53    case horizontalRule
 54    case tableRow
 55    /// `+--+--+`, which may start a table.el table.
 56    case tableElRule
 57    /// `%%(…)` at the start of a line.
 58    case diarySexp
 59    case footnoteDefinition
 60    case clock
 61    case planning
 62    case listItem
 63    case plain
 64}
 65
 66struct ClassifiedLine {
 67    let cls: LineClass
 68    /// Column of the first non-blank character, with tabs advancing to the next multiple of 8.
 69    let indent: Int
 70}
 71
 72func classifyLine(_ line: Substring, alphabetical: Bool = false) -> ClassifiedLine {
 73    var column = 0
 74    var rest = line
 75    while let c = rest.first, c == " " || c == "\t" {
 76        column = c == "\t" ? (column / 8 + 1) * 8 : column + 1
 77        rest = rest.dropFirst()
 78    }
 79    if rest.isEmpty { return ClassifiedLine(cls: .blank, indent: column) }
 80    return ClassifiedLine(cls: lineClass(rest, columnZero: column == 0, alphabetical: alphabetical), indent: column)
 81}
 82
 83/// `org-inlinetask-min-level`.
 84let inlineTaskLevel = 15
 85
 86private func lineClass(_ rest: Substring, columnZero: Bool, alphabetical: Bool) -> LineClass {
 87    let trimmed = rest.trimmingTrailingWhitespace
 88
 89    // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
 90    // is not a heading.
 91    if columnZero, rest.first == "*" {
 92        let stars = rest.prefix { $0 == "*" }
 93        if rest.dropFirst(stars.count).first == " " {
 94            guard stars.count >= inlineTaskLevel else { return .heading(level: stars.count) }
 95            return trimmed.dropFirst(stars.count).trimmingCharacters(in: .whitespaces) == "END" ? .inlineTaskEnd : .inlineTask(level: stars.count)
 96        }
 97    }
 98
 99    if rest.hasPrefix("#+") {
100        let lower = trimmed.lowercased()
101        if lower.hasPrefix("#+begin_") {
102            let name = lower.dropFirst(8).prefix { !$0.isWhitespace }
103            if !name.isEmpty { return .blockBegin(name: String(name)) }
104        }
105        if lower.hasPrefix("#+end_") {
106            let name = lower.dropFirst(6)
107            if !name.isEmpty, !name.contains(where: \.isWhitespace) { return .blockEnd(name: String(name)) }
108        }
109        if lower.hasPrefix("#+begin:") { return .dynamicBegin }
110        if lower == "#+end:" { return .dynamicEnd }
111        if let colon = rest.firstIndex(of: ":") {
112            let key = rest[rest.index(rest.startIndex, offsetBy: 2)..<colon]
113            if !key.isEmpty, !key.contains(where: \.isWhitespace) { return .keyword(key: key.uppercased()) }
114        }
115    }
116
117    if trimmed == "#" || rest.hasPrefix("# ") || rest.hasPrefix("#\t") { return .comment }
118
119    if rest.hasPrefix("\\begin{") || rest.hasPrefix("\\end{") {
120        let begin = rest.hasPrefix("\\begin{")
121        let after = rest.dropFirst(begin ? 7 : 5)
122        let name = after.prefix { $0.isLetter || $0.isNumber || $0 == "*" }
123        if !name.isEmpty, after.dropFirst(name.count).first == "}" {
124            // The end line has nothing after it.
125            let tail = after.dropFirst(name.count + 1)
126            if begin { return .latexBegin(name: String(name)) }
127            if tail.trimmingCharacters(in: .whitespaces).isEmpty { return .latexEnd(name: String(name)) }
128        }
129    }
130
131    if rest.first == ":" {
132        if trimmed == ":" || rest.hasPrefix(": ") || rest.hasPrefix(":\t") { return .fixedWidth }
133        if trimmed.uppercased() == ":END:" { return .drawerEnd }
134        if trimmed.count >= 3, trimmed.last == ":" {
135            let name = trimmed.dropFirst().dropLast()
136            if name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) {
137                return .drawerBegin(name: String(name))
138            }
139        }
140    }
141
142    if rest.first == "|" { return .tableRow }
143    if rest.first == "+", trimmed.wholeMatch(of: /\+(?:-+\+)+/) != nil { return .tableElRule }
144    if columnZero, rest.hasPrefix("%%(") { return .diarySexp }
145    if trimmed.count >= 5, trimmed.allSatisfy({ $0 == "-" }) { return .horizontalRule }
146
147    if columnZero, rest.hasPrefix("[fn:"), let close = rest.firstIndex(of: "]"),
148       close > rest.index(rest.startIndex, offsetBy: 4) {
149        return .footnoteDefinition
150    }
151
152    if rest.hasPrefix("CLOCK:") { return .clock }
153    if rest.hasPrefix("SCHEDULED:") || rest.hasPrefix("DEADLINE:") || rest.hasPrefix("CLOSED:") { return .planning }
154    if isListBullet(rest, indented: !columnZero, alphabetical: alphabetical) { return .listItem }
155    return .plain
156}
157
158/// `-`, `+`, `*` (indented only), `1.` or `1)`, and with `org-list-allow-alphabetical` `a.` or
159/// `A)`, followed by whitespace or end of line.
160private func isListBullet(_ rest: Substring, indented: Bool, alphabetical: Bool) -> Bool {
161    guard let first = rest.first else { return false }
162    let afterBullet: Substring
163    if first == "-" || first == "+" || (first == "*" && indented) {
164        afterBullet = rest.dropFirst()
165    } else if alphabetical, first.isASCII, first.isLetter, let separator = rest.dropFirst().first, separator == "." || separator == ")" {
166        afterBullet = rest.dropFirst(2)
167    } else if first.isASCII, first.isNumber {
168        let digits = rest.prefix { $0.isASCII && $0.isNumber }
169        let tail = rest.dropFirst(digits.count)
170        guard let separator = tail.first, separator == "." || separator == ")" else { return false }
171        afterBullet = tail.dropFirst()
172    } else {
173        return false
174    }
175    return afterBullet.isEmpty || afterBullet.first == " " || afterBullet.first == "\t"
176}
177
178extension Substring {
179    var trimmingTrailingWhitespace: Substring {
180        var s = self
181        while let last = s.last, last == " " || last == "\t" { s = s.dropLast() }
182        return s
183    }
184}