aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Parsers
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgKit/Parsers')
-rw-r--r--Sources/NorgKit/Parsers/NorgInlineParser.swift248
-rw-r--r--Sources/NorgKit/Parsers/NorgParser.swift286
-rw-r--r--Sources/NorgKit/Parsers/TaskScanner.swift52
3 files changed, 586 insertions, 0 deletions
diff --git a/Sources/NorgKit/Parsers/NorgInlineParser.swift b/Sources/NorgKit/Parsers/NorgInlineParser.swift
new file mode 100644
index 0000000..1f19c43
--- /dev/null
+++ b/Sources/NorgKit/Parsers/NorgInlineParser.swift
@@ -0,0 +1,248 @@
+import Foundation
+
+/// Converts inline Norg markup into a list of `InlineSpan`s.
+public enum NorgInlineParser {
+
+ /// Attached modifiers whose content is parsed recursively for nesting.
+ private static let modifiers: [Unicode.Scalar: InlineStyle] = [
+ "*": .bold,
+ "/": .italic,
+ "_": .underline,
+ "-": .strikethrough,
+ "^": .superscript,
+ ",": .subscript,
+ "!": .spoiler,
+ ]
+
+ /// Modifiers whose content is taken verbatim.
+ private static let literalModifiers: [Unicode.Scalar: InlineStyle] = [
+ "`": .verbatim,
+ "$": .math,
+ ]
+
+ /// Bytes that can begin an inline object. If none are found, it's plain
+ /// text.
+ private static let significant = ASCIIByteSet("*/_-^,!\u{60}$%{[\\")
+
+ /// Parses a string to a list of `InlineSpan`s
+ public static func parse(_ text: String) -> [InlineSpan] {
+ if text.isEmpty { return [] }
+ if !text.utf8.contains(where: significant.contains) {
+ return [InlineSpan(text: text, styles: [])]
+ }
+
+ let chars = Array(text.unicodeScalars)
+ return parse(chars, from: 0, to: chars.count, base: [])
+ }
+
+ /// Renders the text as plain text, without styling or markup.
+ static func plainText(_ text: Substring) -> String {
+ if text.isEmpty { return "" }
+ if !text.utf8.contains(where: significant.contains) {
+ return String(text)
+ }
+
+ let chars = Array(text.unicodeScalars)
+ return parse(chars, from: 0, to: chars.count, base: []).plainText
+ }
+
+ private static func parse(_ chars: [Unicode.Scalar], from lo: Int, to hi: Int, base: InlineStyle)
+ -> [InlineSpan] {
+ var spans: [InlineSpan] = []
+ var buffer = String.UnicodeScalarView()
+
+ func flush() {
+ guard !buffer.isEmpty else { return }
+ spans.append(InlineSpan(text: String(buffer), styles: base))
+ buffer = String.UnicodeScalarView()
+ }
+
+ var i = lo
+ while i < hi {
+ let c = chars[i]
+ // `prev`/`next` deliberately peek outside `[lo, hi)`: within a nested
+ // range the enclosing modifier (e.g. the `_` around `_/x/_`) is a
+ // valid boundary, so boundary detection uses the whole line.
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
+
+ // Escapes: the next scalar is taken literally.
+ if c == "\\" {
+ if i + 1 < hi {
+ buffer.append(chars[i + 1])
+ i += 2
+ } else {
+ i += 1
+ }
+ continue
+ }
+
+ // Comments are dropped from the rendered output.
+ if c == "%", isOpener(prev: prev, next: next),
+ let close = literalClose(chars, from: i + 1, to: hi, char: "%") {
+ flush()
+ i = close + 1
+ continue
+ }
+
+ // Verbatim / math objects: literal inner content.
+ if let style = literalModifiers[c], isOpener(prev: prev, next: next),
+ let close = literalClose(chars, from: i + 1, to: hi, char: c) {
+ flush()
+ spans.append(InlineSpan(text: slice(chars, i + 1, close), styles: base.union(style)))
+ i = close + 1
+ continue
+ }
+
+ // Links: {location} optionally followed by [description].
+ if c == "{", let close = bracketClose(chars, from: i + 1, to: hi, char: "}") {
+ let target = slice(chars, i + 1, close)
+ var j = close + 1
+ var label = linkLabel(target)
+ if j < hi, chars[j] == "[", let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
+ // An explicit description replaces the derived label verbatim.
+ label = slice(chars, j + 1, dclose)
+ j = dclose + 1
+ }
+ flush()
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: target)))
+ i = j
+ continue
+ }
+
+ // Anchors: [name] (declaration), [name]{location} (definition), or
+ // [name][description] (declaration with a custom description).
+ if c == "[", let close = bracketClose(chars, from: i + 1, to: hi, char: "]") {
+ let name = slice(chars, i + 1, close)
+ var j = close + 1
+ var label = name
+ var target: String?
+ if j < hi, chars[j] == "{", let tclose = bracketClose(chars, from: j + 1, to: hi, char: "}") {
+ target = slice(chars, j + 1, tclose)
+ j = tclose + 1
+ } else if j < hi, chars[j] == "[",
+ let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") {
+ label = slice(chars, j + 1, dclose)
+ j = dclose + 1
+ }
+ flush()
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target)))
+ i = j
+ continue
+ }
+
+ // Attached modifiers with recursively parsed content.
+ if let style = modifiers[c], isOpener(prev: prev, next: next),
+ let close = modifierClose(chars, from: i + 1, to: hi, char: c) {
+ flush()
+ spans.append(contentsOf: parse(chars, from: i + 1, to: close, base: base.union(style)))
+ i = close + 1
+ continue
+ }
+
+ buffer.append(c)
+ i += 1
+ }
+
+ flush()
+ return spans
+ }
+
+ // MARK: - Boundary helpers
+
+ /// Builds a `String` from a half-open scalar range `[from, to)`.
+ private static func slice(_ chars: [Unicode.Scalar], _ from: Int, _ to: Int) -> String {
+ String(String.UnicodeScalarView(chars[from..<to]))
+ }
+
+ private static func isSpace(_ c: Unicode.Scalar?) -> Bool {
+ guard let c else { return true }
+ return c.properties.isWhitespace
+ }
+
+ /// Whether a scalar (or the absence of one, at a line edge) counts as a
+ /// modifier boundary: whitespace, punctuation, or a symbol.
+ private static func isBoundary(_ c: Unicode.Scalar?) -> Bool {
+ guard let c else { return true }
+ if c.properties.isWhitespace { return true }
+ switch c.properties.generalCategory {
+ case .connectorPunctuation, .dashPunctuation, .openPunctuation,
+ .closePunctuation, .initialPunctuation, .finalPunctuation, .otherPunctuation,
+ .mathSymbol, .currencySymbol, .modifierSymbol, .otherSymbol:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// A valid opener is preceded by a boundary and followed by non-whitespace.
+ private static func isOpener(prev: Unicode.Scalar?, next: Unicode.Scalar?) -> Bool {
+ isBoundary(prev) && !isSpace(next)
+ }
+
+ /// Finds the closing modifier of the same character: preceded by
+ /// non-whitespace and followed by a boundary. Honours escapes.
+ private static func modifierClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == "\\" {
+ i += 2
+ continue
+ }
+ if chars[i] == char {
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ let next: Unicode.Scalar? = i + 1 < chars.count ? chars[i + 1] : nil
+ if !isSpace(prev) && isBoundary(next) { return i }
+ }
+ i += 1
+ }
+ return nil
+ }
+
+ /// Finds the closing character for verbatim/comment content. No escapes.
+ private static func literalClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == char {
+ let prev: Unicode.Scalar? = i > 0 ? chars[i - 1] : nil
+ if !isSpace(prev) { return i }
+ }
+ i += 1
+ }
+ return nil
+ }
+
+ /// Finds a matching closing bracket, honouring escapes.
+ private static func bracketClose(
+ _ chars: [Unicode.Scalar], from start: Int, to hi: Int, char: Unicode.Scalar
+ ) -> Int? {
+ var i = start
+ while i < hi {
+ if chars[i] == "\\" {
+ i += 2
+ continue
+ }
+ if chars[i] == char { return i }
+ i += 1
+ }
+ return nil
+ }
+
+ /// Produces display text for a link target that has no explicit description
+ /// by stripping the leading location prefix (`*`, `#`, `/`, `$`, `:file:`).
+ private static func linkLabel(_ target: String) -> String {
+ var s = Substring(target)
+ // Strip a leading `:path:` file specifier.
+ if s.first == ":", let end = s.dropFirst().firstIndex(of: ":") {
+ s = s[s.index(after: end)...]
+ }
+ s = s.drop { "*#/$ ".contains($0) }
+ return s.isEmpty ? target : String(s)
+ }
+}
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
+ }
+}
diff --git a/Sources/NorgKit/Parsers/TaskScanner.swift b/Sources/NorgKit/Parsers/TaskScanner.swift
new file mode 100644
index 0000000..39c16b5
--- /dev/null
+++ b/Sources/NorgKit/Parsers/TaskScanner.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+/// Scans Norg source for tasks and rewrites their status markers.
+/// Handy to extract TODOs quicker than the parser can.
+public enum TaskScanner {
+
+ private static let markers = ASCIIByteSet("*-~>$^:")
+
+ /// Extracts every task in `content`, attributing each to `fileURL`.
+ public static func scan(content: String, fileURL: URL) -> [NorgTask] {
+ var tasks: [NorgTask] = []
+ TextHelper.enumerateLines(in: content) { line, index in
+ if let task = task(in: line, fileURL: fileURL, line: index) {
+ tasks.append(task)
+ }
+ }
+ return tasks
+ }
+
+ /// Parses a single line into a task, if it carries a status extension.
+ public static func task(in raw: String, fileURL: URL, line: Int) -> NorgTask? {
+ task(in: raw[...], fileURL: fileURL, line: line)
+ }
+
+ /// Span-free fast path over a line slice.
+ static func task(in line: Substring, fileURL: URL, line index: Int) -> NorgTask? {
+ guard let m = DetachedModifier.parse(line, accepting: markers), let status = m.status else {
+ return nil
+ }
+ return NorgTask(
+ fileURL: fileURL,
+ line: index,
+ status: status,
+ text: NorgInlineParser.plainText(m.content)
+ )
+ }
+
+ /// Returns `content` with the status marker on `line` replaced by `status`,
+ /// or `nil` if the line carries no recognisable task marker. Line endings are
+ /// preserved, so a CRLF file round-trips unchanged.
+ public static func updatedContent(_ content: String, line index: Int, to status: TaskStatus)
+ -> String? {
+ guard let line = TextHelper.line(in: content, at: index),
+ let m = DetachedModifier.parse(line, accepting: markers),
+ let statusIndex = m.statusIndex
+ else { return nil }
+
+ var updated = content
+ updated.replaceSubrange(statusIndex...statusIndex, with: status.rawValue)
+ return updated
+ }
+}