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