/// Parses a Norg document into a list of `NorgBlock`s. public enum NorgParser { private static let blockMarkers = BlockScanner.blockMarkers private static let rangeableMarkers = BlockScanner.rangeableMarkers 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 = BlockScanner.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 /// 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 BlockScanner.delimiter(trimmed) != nil { return true } if DetachedModifier.parse(trimmed[...], accepting: blockMarkers) != nil { return true } return DetachedModifier.parse(trimmed[...], accepting: rangeableMarkers) != nil } }