1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
|
/// Parses a Norg document into a list of `NorgBlock`s.
public enum NorgParser {
private enum Delimiter { case weak, strong, rule }
private static let blockMarkers = ASCIIByteSet("*-~>")
private static let rangeableMarkers = ASCIIByteSet("$^:")
private struct Source {
let raw: [Substring]
let trimmed: [Substring]
}
/// Parses as a list
public static func parse(_ text: String) -> NorgDocument {
let raw = TextHelper.lineSlices(text)
let source = Source(raw: raw, trimmed: raw.map(TextHelper.whitespaceTrimmed))
return NorgDocument(blocks: parseBlocks(in: source, from: 0, to: raw.count))
}
/// Parses as a tree
public static func parseTree(_ text: String) -> [NorgNode] {
parse(text).tree()
}
/// Parses the half-open line range `[lo, hi)` into blocks. Line numbers are
/// the absolute indices into the source, so the recursively parsed body of a
/// range-able block keeps the true source line of every nested block: the
/// line arrays are shared, never re-sliced.
private static func parseBlocks(in source: Source, from lo: Int, to hi: Int) -> [NorgBlock] {
var blocks: [NorgBlock] = []
var i = lo
while i < hi {
let lineNo = i
let line = source.trimmed[i]
if line.isEmpty {
i += 1
continue
}
// Verbatim ranged tags: @code … @end, @document.meta … @end, etc.
if line.hasPrefix("@") {
let (block, next) = verbatimTagBlock(at: i, in: source, to: hi)
if let block { blocks.append(block) }
i = next
continue
}
// Delimiting modifiers (lines of two or more identical -, = or _).
// All three are emitted as blocks: `___` renders as a rule, while
// `---`/`===` are reset signals the tree fold consumes.
if let delimiter = delimiter(line) {
switch delimiter {
case .rule: blocks.append(.horizontalRule(line: lineNo))
case .weak: blocks.append(.weakDelimiter(line: lineNo))
case .strong: blocks.append(.strongDelimiter(line: lineNo))
}
i += 1
continue
}
// Range-able detached modifiers: definitions, footnotes, table cells.
if let m = DetachedModifier.parse(line[...], accepting: rangeableMarkers) {
let (block, next) = rangeableBlock(m, at: i, in: source, to: hi, line: lineNo)
if let block { blocks.append(block) }
i = next
continue
}
// Structural (headings) and nestable (lists, quotes) modifiers.
if let m = DetachedModifier.parse(line[...], accepting: blockMarkers) {
let (block, next) = detachedBlock(m, at: i, in: source, to: hi, line: lineNo)
if let block { blocks.append(block) }
i = next
continue
}
let (paragraph, next) = paragraphBlock(at: i, in: source, to: hi, line: lineNo)
blocks.append(paragraph)
i = next
}
return blocks
}
// MARK: - Block recognisers
/// Builds a verbatim ranged tag (`@name … @end`) and returns the next line.
/// A stray `@end` with no matching opener is a no-op, not a tag named "end"
/// that would otherwise swallow the rest of the document.
private static func verbatimTagBlock(at i: Int, in source: Source, to hi: Int) -> (
block: NorgBlock?, next: Int
) {
let header = TextHelper.whitespaceTrimmed(String(source.trimmed[i].dropFirst()))
if header == "end" { return (nil, i + 1) }
var body: [String] = []
var j = i + 1
while j < hi, source.trimmed[j] != "@end" {
body.append(String(source.raw[j]))
j += 1
}
return (rangedTagBlock(header: header, body: body, line: i), (j < hi) ? j + 1 : j)
}
/// Merges consecutive soft-wrapped lines into a single paragraph block.
private static func paragraphBlock(
at i: Int, in source: Source, to hi: Int, line lineNo: Int
) -> (block: NorgBlock, next: Int) {
var paragraph: [Substring] = [source.trimmed[i]]
var j = i + 1
while j < hi {
let next = source.trimmed[j]
if next.isEmpty || isBlockStart(next) { break }
paragraph.append(next)
j += 1
}
return (
.paragraph(content: NorgInlineParser.parse(paragraph.joined(separator: " ")), line: lineNo), j
)
}
/// Builds the block for a recognised detached modifier, merging soft-wrapped
/// continuation lines into its content, and returns the next line to parse.
///
/// Headings are *structural*: they take only a single paragraph segment as
/// their title. The *nestable* modifiers (lists, quotes) consume a whole
/// paragraph as content, so continuation lines are merged in until a
/// paragraph break or a new block — see Norg 1.0 §"Structural"/"Nestable
/// Detached Modifiers".
private static func detachedBlock(
_ m: DetachedModifier, at i: Int, in source: Source, to hi: Int, line lineNo: Int
) -> (block: NorgBlock?, next: Int) {
var text = String(m.content)
var j = i + 1
if m.marker != "*" {
while j < hi {
let next = source.trimmed[j]
if next.isEmpty || isBlockStart(next) { break }
if !text.isEmpty { text += " " }
text += next
j += 1
}
}
return (block(m, content: NorgInlineParser.parse(text), line: lineNo), j)
}
/// Builds a range-able block (definition, footnote, or table cell) and
/// returns the next line to parse.
///
/// The single form (`$ Term`) takes its title from the marker line and the
/// immediately following paragraph as its body. The ranged form (`$$ Term`)
/// takes every block up to the matching closer — the doubled marker alone on
/// a line — tracking nested openers of the same marker so an inner range does
/// not close the outer one. The body is parsed by recursing over the shared
/// line arrays, which preserves absolute source line numbers.
private static func rangeableBlock(
_ m: DetachedModifier, at i: Int, in source: Source, to hi: Int, line lineNo: Int
) -> (block: NorgBlock?, next: Int) {
let title = NorgInlineParser.parse(String(m.content))
let bodyEnd: Int
let next: Int
if m.level >= 2 {
let closer = String(repeating: m.marker, count: m.level)
var depth = 1
var j = i + 1
while j < hi {
let t = source.trimmed[j]
if t == closer {
depth -= 1
if depth == 0 { break }
} else if isRangedOpener(t, marker: m.marker) {
depth += 1
}
j += 1
}
bodyEnd = j
next = (j < hi) ? j + 1 : j // skip the closer line itself
} else {
var j = i + 1
while j < hi {
let t = source.trimmed[j]
if t.isEmpty || isBlockStart(t) { break }
j += 1
}
bodyEnd = j
next = j
}
let body = parseBlocks(in: source, from: i + 1, to: bodyEnd)
return (rangeable(m.marker, title: title, status: m.status, body: body, line: lineNo), next)
}
private static func rangeable(
_ marker: Character, title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int
) -> NorgBlock? {
switch marker {
case "$": return .definition(title: title, status: status, body: body, line: line)
case "^": return .footnote(title: title, status: status, body: body, line: line)
case ":": return .tableCell(title: title, status: status, body: body, line: line)
default: return nil
}
}
/// Whether `line` opens a ranged detached modifier with the given `marker`
/// (two or more leading markers), used to balance nested ranges.
private static func isRangedOpener(_ line: Substring, marker: Character) -> Bool {
guard let m = DetachedModifier.parse(line[...], accepting: ASCIIByteSet(String(marker))) else {
return false
}
return m.level >= 2
}
/// Maps a recognised detached modifier and its content to the corresponding block.
private static func block(_ m: DetachedModifier, content: [InlineSpan], line lineNo: Int)
-> NorgBlock? {
switch m.marker {
case "*": return .heading(level: m.level, status: m.status, content: content, line: lineNo)
case "-":
return .unorderedListItem(level: m.level, status: m.status, content: content, line: lineNo)
case "~":
return .orderedListItem(level: m.level, status: m.status, content: content, line: lineNo)
case ">": return .quote(level: m.level, status: m.status, content: content, line: lineNo)
default: return nil
}
}
private static func rangedTagBlock(header: String, body: [String], line: Int) -> NorgBlock? {
// header e.g. "code swift", "document.meta", "math". Name and parameters are
// whitespace-separated; the name is dot-split as in the reference parser.
let fields = header.split(maxSplits: 1, whereSeparator: \.isWhitespace)
let nameField = fields.first.map(String.init) ?? ""
// Hidden metadata/comment tags produce no rendered block.
if nameField == "document.meta" || nameField == "comment" { return nil }
let name = nameField.split(separator: ".").map(String.init)
let parameters =
fields.count > 1
? fields[1].split(whereSeparator: \.isWhitespace).map(String.init)
: []
let content = dedent(body)
if name.first == "code" {
return .codeBlock(language: parameters.first, code: content, line: line)
}
return .rangedTag(name: name, parameters: parameters, content: content, line: line)
}
/// Joins a verbatim body and strips the common leading indentation shared by
/// all non-blank lines, matching rust-norg's `textwrap::dedent`.
private static func dedent(_ body: [String]) -> String {
let indents = body.compactMap { line -> Int? in
line.allSatisfy(\.isWhitespace) ? nil : line.prefix(while: \.isWhitespace).count
}
let common = indents.min() ?? 0
guard common > 0 else { return body.joined(separator: "\n") }
return body.map { line in
line.allSatisfy(\.isWhitespace) ? line : String(line.dropFirst(common))
}.joined(separator: "\n")
}
// MARK: - Helpers
/// Identifies a delimiting-modifier line (two or more identical `-`, `=`, `_`).
private static func delimiter(_ s: Substring) -> Delimiter? {
guard s.count >= 2, let first = s.first, "-=_".contains(first),
s.allSatisfy({ $0 == first })
else { return nil }
switch first {
case "-": return .weak
case "=": return .strong
default: return .rule
}
}
/// Whether a trimmed line starts a new block (used to break paragraphs).
private static func isBlockStart(_ trimmed: Substring) -> Bool {
if trimmed.hasPrefix("@") { return true }
if delimiter(trimmed) != nil { return true }
if DetachedModifier.parse(trimmed[...], accepting: blockMarkers) != nil { return true }
return DetachedModifier.parse(trimmed[...], accepting: rangeableMarkers) != nil
}
}
|