diff options
Diffstat (limited to 'Sources/NorgKit/Parsers/NorgParser.swift')
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgParser.swift | 286 |
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 + } +} |