Sources/OrgCore/Parser/Lines.swift
184 lines · 7386 bytes
7 symbols in this file
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}