Sources/OrgCore/Parser/Lines.swift
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}