aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Parsers/NorgParser.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgKit/Parsers/NorgParser.swift')
-rw-r--r--Sources/NorgKit/Parsers/NorgParser.swift286
1 files changed, 286 insertions, 0 deletions
diff --git a/Sources/NorgKit/Parsers/NorgParser.swift b/Sources/NorgKit/Parsers/NorgParser.swift
new file mode 100644
index 0000000..aad66f4
--- /dev/null
+++ b/Sources/NorgKit/Parsers/NorgParser.swift
@@ -0,0 +1,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
+ }
+}