aboutsummaryrefslogtreecommitdiff
path: root/Sources
diff options
context:
space:
mode:
Diffstat (limited to 'Sources')
-rw-r--r--Sources/NorgKit/Extensions/Array+InlineSpan.swift7
-rw-r--r--Sources/NorgKit/Helpers/ASCIIByteSet.swift30
-rw-r--r--Sources/NorgKit/Helpers/ASCIIHelper.swift5
-rw-r--r--Sources/NorgKit/Helpers/TextHelper.swift95
-rw-r--r--Sources/NorgKit/Models/DetachedModifier.swift61
-rw-r--r--Sources/NorgKit/Models/InlineLink.swift15
-rw-r--r--Sources/NorgKit/Models/InlineSpan.swift12
-rw-r--r--Sources/NorgKit/Models/InlineStyle.swift28
-rw-r--r--Sources/NorgKit/Models/NorgBlock.swift108
-rw-r--r--Sources/NorgKit/Models/NorgDocument.swift16
-rw-r--r--Sources/NorgKit/Models/NorgNode.swift10
-rw-r--r--Sources/NorgKit/Models/NorgTask.swift20
-rw-r--r--Sources/NorgKit/Models/TaskStatus.swift33
-rw-r--r--Sources/NorgKit/Parsers/NorgInlineParser.swift248
-rw-r--r--Sources/NorgKit/Parsers/NorgParser.swift286
-rw-r--r--Sources/NorgKit/Parsers/TaskScanner.swift52
-rw-r--r--Sources/NorgKit/TreeFolder.swift82
17 files changed, 1108 insertions, 0 deletions
diff --git a/Sources/NorgKit/Extensions/Array+InlineSpan.swift b/Sources/NorgKit/Extensions/Array+InlineSpan.swift
new file mode 100644
index 0000000..167bfce
--- /dev/null
+++ b/Sources/NorgKit/Extensions/Array+InlineSpan.swift
@@ -0,0 +1,7 @@
+/// Extends behavior of InlineSpan arrays.
+extension Array where Element == InlineSpan {
+ /// The concatenated plain text of all spans, ignoring styling.
+ public var plainText: String {
+ map(\.text).joined()
+ }
+}
diff --git a/Sources/NorgKit/Helpers/ASCIIByteSet.swift b/Sources/NorgKit/Helpers/ASCIIByteSet.swift
new file mode 100644
index 0000000..59efbe7
--- /dev/null
+++ b/Sources/NorgKit/Helpers/ASCIIByteSet.swift
@@ -0,0 +1,30 @@
+/// A membership test over the ASCII range (bytes `0`–`127`)
+/// We use this for performance, since markers are always ASCII, and this this
+/// faster than using Character.
+struct ASCIIByteSet {
+ private let low: UInt64
+ private let high: UInt64
+
+ /// Builds a set from the ASCII values.
+ init(_ characters: String) {
+ var lo: UInt64 = 0
+ var hi: UInt64 = 0
+ for byte in characters.utf8 {
+ if byte < 64 {
+ lo |= 1 << UInt64(byte)
+ } else if byte < 128 {
+ hi |= 1 << UInt64(byte - 64)
+ }
+ }
+ low = lo
+ high = hi
+ }
+
+ /// Whether `byte` is in the set.
+ @inline(__always)
+ func contains(_ byte: UInt8) -> Bool {
+ if byte < 64 { return low & (1 << UInt64(byte)) != 0 }
+ if byte < 128 { return high & (1 << UInt64(byte &- 64)) != 0 }
+ return false
+ }
+}
diff --git a/Sources/NorgKit/Helpers/ASCIIHelper.swift b/Sources/NorgKit/Helpers/ASCIIHelper.swift
new file mode 100644
index 0000000..98cce42
--- /dev/null
+++ b/Sources/NorgKit/Helpers/ASCIIHelper.swift
@@ -0,0 +1,5 @@
+struct ASCIIHelper {
+ static func isWhitespace(_ b: UInt8) -> Bool {
+ b == 0x20 || (0x09...0x0D).contains(b)
+ }
+}
diff --git a/Sources/NorgKit/Helpers/TextHelper.swift b/Sources/NorgKit/Helpers/TextHelper.swift
new file mode 100644
index 0000000..b0d45fe
--- /dev/null
+++ b/Sources/NorgKit/Helpers/TextHelper.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+/// Text helpers that avoid `CharacterSet` and allocations for performance.
+enum TextHelper {
+
+ /// Splits text into lines as slices.
+ static func lineSlices(_ text: String) -> [Substring] {
+ var result: [Substring] = []
+ enumerateLines(in: text) { line, _ in result.append(line) }
+ return result
+ }
+
+ /// Invokes `body` once per line, passing a `Substring` view and its index.
+ static func enumerateLines(in text: String, _ body: (Substring, Int) -> Void) {
+ let utf8 = text.utf8
+ let end = utf8.endIndex
+ var lineStart = utf8.startIndex
+ var i = utf8.startIndex
+ var index = 0
+ while i < end {
+ if utf8[i] == 0x0A {
+ body(text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: i)], index)
+ index += 1
+ lineStart = utf8.index(after: i)
+ }
+ i = utf8.index(after: i)
+ }
+ body(text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: end)], index)
+ }
+
+ /// The end index of a line `[start, newline)`, backed up by one when the last
+ /// byte is a carriage return. Operating on the UTF-8 view avoids the
+ /// grapheme decode that `Substring.last` would pay on every line.
+ private static func strippedLineEnd(
+ _ utf8: String.UTF8View, from start: String.Index, to newline: String.Index
+ ) -> String.Index {
+ guard newline > start else { return newline }
+ let last = utf8.index(before: newline)
+ return utf8[last] == 0x0D ? last : newline
+ }
+
+ /// Returns the line at `index` (zero-based) as a `Substring`, or `nil` when
+ /// there is no such line. Trailing `\r` is stripped and line counting matches
+ /// ``lines(_:)`` / ``enumerateLines(in:_:)``. Stops as soon as the line is
+ /// found, so locating an early line in a large document is cheap.
+ static func line(in text: String, at index: Int) -> Substring? {
+ guard index >= 0 else { return nil }
+ let utf8 = text.utf8
+ let end = utf8.endIndex
+ var lineStart = utf8.startIndex
+ var i = utf8.startIndex
+ var current = 0
+ while i < end {
+ if utf8[i] == 0x0A {
+ if current == index {
+ return text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: i)]
+ }
+ current += 1
+ lineStart = utf8.index(after: i)
+ }
+ i = utf8.index(after: i)
+ }
+ return current == index
+ ? text[lineStart..<strippedLineEnd(utf8, from: lineStart, to: end)] : nil
+ }
+
+ /// Trims leading and trailing whitespace from a slice using
+ /// `Character.isWhitespace`, avoiding the `CharacterSet` bridging cost that
+ /// `trimmingCharacters(in:)` pays per call. The result is a slice of the
+ /// input, so nothing is copied. This is the single trimming primitive used
+ /// across the parser, scanner, and detached-modifier recogniser.
+ static func whitespaceTrimmed(_ s: Substring) -> Substring {
+ // Trim over the UTF-8 view: Norg indentation and trailing space is always
+ // ASCII whitespace, whose bytes are all `< 0x80` and so never part of a
+ // multi-byte scalar — the trimmed bounds stay on scalar boundaries. This
+ // avoids the grapheme decode `Character.isWhitespace` pays on every line.
+ let utf8 = s.utf8
+ var start = utf8.startIndex
+ var end = utf8.endIndex
+ while start < end, ASCIIHelper.isWhitespace(utf8[start]) { start = utf8.index(after: start) }
+ while start < end {
+ let prev = utf8.index(before: end)
+ guard ASCIIHelper.isWhitespace(utf8[prev]) else { break }
+ end = prev
+ }
+ return s[start..<end]
+ }
+
+ /// Trims leading and trailing whitespace.
+ static func whitespaceTrimmed(_ s: String) -> String {
+ let trimmed = whitespaceTrimmed(s[...])
+ return trimmed.startIndex == s.startIndex && trimmed.endIndex == s.endIndex
+ ? s : String(trimmed)
+ }
+}
diff --git a/Sources/NorgKit/Models/DetachedModifier.swift b/Sources/NorgKit/Models/DetachedModifier.swift
new file mode 100644
index 0000000..1f28c36
--- /dev/null
+++ b/Sources/NorgKit/Models/DetachedModifier.swift
@@ -0,0 +1,61 @@
+/// A detached modifier with its marker, nestling level, task status (if set),
+/// and content.
+struct DetachedModifier {
+ let marker: Character
+ let level: Int
+ let status: TaskStatus?
+ let statusIndex: String.Index?
+ let content: Substring
+
+ /// Finds and parses a detached modifier if present. The accepting markers
+ /// is used to reduce the markers being parsed.
+ static func parse(_ line: Substring, accepting markers: ASCIIByteSet) -> DetachedModifier? {
+
+ let utf8 = line.utf8
+ let end = utf8.endIndex
+ var i = utf8.startIndex
+
+ while i < end, ASCIIHelper.isWhitespace(utf8[i]) { i = utf8.index(after: i) }
+ guard i < end else { return nil }
+
+ let markerByte = utf8[i]
+ guard markers.contains(markerByte) else { return nil }
+
+ var level = 0
+ var run = i
+ while run < end, utf8[run] == markerByte {
+ level += 1
+ run = utf8.index(after: run)
+ }
+
+ guard run < end, ASCIIHelper.isWhitespace(utf8[run]) else { return nil }
+
+ var rest = run
+ while rest < end, ASCIIHelper.isWhitespace(utf8[rest]) { rest = utf8.index(after: rest) }
+
+ var status: TaskStatus?
+ var statusIndex: String.Index?
+ var contentStart = rest
+ if rest < end, utf8[rest] == UInt8(ascii: "(") {
+ let markerPosition = utf8.index(after: rest)
+ if markerPosition < end {
+ let closePosition = utf8.index(after: markerPosition)
+ let statusByte = utf8[markerPosition]
+ if closePosition < end, utf8[closePosition] == UInt8(ascii: ")"),
+ let parsed = TaskStatus(markerByte: statusByte) {
+ status = parsed
+ statusIndex = markerPosition
+ contentStart = utf8.index(after: closePosition)
+ }
+ }
+ }
+
+ return DetachedModifier(
+ marker: Character(Unicode.Scalar(markerByte)),
+ level: level,
+ status: status,
+ statusIndex: statusIndex,
+ content: TextHelper.whitespaceTrimmed(line[contentStart...])
+ )
+ }
+}
diff --git a/Sources/NorgKit/Models/InlineLink.swift b/Sources/NorgKit/Models/InlineLink.swift
new file mode 100644
index 0000000..b2f6a15
--- /dev/null
+++ b/Sources/NorgKit/Models/InlineLink.swift
@@ -0,0 +1,15 @@
+/// A link or anchor.
+public struct InlineLink: Equatable, Hashable, Sendable, Codable {
+ public enum Kind: Equatable, Hashable, Sendable, Codable {
+ case link
+ case anchor
+ }
+
+ public var kind: Kind
+ public var target: String?
+
+ public init(kind: Kind, target: String?) {
+ self.kind = kind
+ self.target = target
+ }
+}
diff --git a/Sources/NorgKit/Models/InlineSpan.swift b/Sources/NorgKit/Models/InlineSpan.swift
new file mode 100644
index 0000000..bebe60b
--- /dev/null
+++ b/Sources/NorgKit/Models/InlineSpan.swift
@@ -0,0 +1,12 @@
+/// A contiguous run of text sharing the same style and link.
+public struct InlineSpan: Equatable, Hashable, Sendable, Codable {
+ public var text: String
+ public var styles: InlineStyle
+ public var link: InlineLink?
+
+ public init(text: String, styles: InlineStyle = [], link: InlineLink? = nil) {
+ self.text = text
+ self.styles = styles
+ self.link = link
+ }
+}
diff --git a/Sources/NorgKit/Models/InlineStyle.swift b/Sources/NorgKit/Models/InlineStyle.swift
new file mode 100644
index 0000000..807de7b
--- /dev/null
+++ b/Sources/NorgKit/Models/InlineStyle.swift
@@ -0,0 +1,28 @@
+/// Composable inline styles that may be applied to a single run of text.
+public struct InlineStyle: OptionSet, Hashable, Sendable, Codable {
+ public let rawValue: Int
+
+ public init(rawValue: Int) {
+ self.rawValue = rawValue
+ }
+
+ public static let bold = InlineStyle(rawValue: 1 << 0)
+ public static let italic = InlineStyle(rawValue: 1 << 1)
+ public static let underline = InlineStyle(rawValue: 1 << 2)
+ public static let strikethrough = InlineStyle(rawValue: 1 << 3)
+ public static let verbatim = InlineStyle(rawValue: 1 << 4)
+ public static let superscript = InlineStyle(rawValue: 1 << 5)
+ public static let `subscript` = InlineStyle(rawValue: 1 << 6)
+ public static let spoiler = InlineStyle(rawValue: 1 << 7)
+ public static let math = InlineStyle(rawValue: 1 << 8)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ self.init(rawValue: try container.decode(Int.self))
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgBlock.swift b/Sources/NorgKit/Models/NorgBlock.swift
new file mode 100644
index 0000000..32e4eeb
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgBlock.swift
@@ -0,0 +1,108 @@
+/// A block-level element of a Norg document, including its line position in
+/// the source.
+public enum NorgBlock: Equatable, Hashable, Sendable, Codable {
+ case heading(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case paragraph(content: [InlineSpan], line: Int)
+ case unorderedListItem(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case orderedListItem(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case quote(level: Int, status: TaskStatus?, content: [InlineSpan], line: Int)
+ case codeBlock(language: String?, code: String, line: Int)
+ case definition(title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int)
+ case footnote(title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int)
+ case tableCell(title: [InlineSpan], status: TaskStatus?, body: [NorgBlock], line: Int)
+ case rangedTag(name: [String], parameters: [String], content: String, line: Int)
+ case horizontalRule(line: Int)
+ case weakDelimiter(line: Int)
+ case strongDelimiter(line: Int)
+
+ /// The source line on which the block begins.
+ public var line: Int {
+ switch self {
+ case .heading(_, _, _, let line),
+ .paragraph(_, let line),
+ .unorderedListItem(_, _, _, let line),
+ .orderedListItem(_, _, _, let line),
+ .quote(_, _, _, let line),
+ .codeBlock(_, _, let line),
+ .definition(_, _, _, let line),
+ .footnote(_, _, _, let line),
+ .tableCell(_, _, _, let line),
+ .rangedTag(_, _, _, let line),
+ .horizontalRule(let line),
+ .weakDelimiter(let line),
+ .strongDelimiter(let line):
+ return line
+ }
+ }
+
+ /// The task status attached to the block, if any.
+ public var status: TaskStatus? {
+ switch self {
+ case .heading(_, let status, _, _),
+ .unorderedListItem(_, let status, _, _),
+ .orderedListItem(_, let status, _, _),
+ .quote(_, let status, _, _),
+ .definition(_, let status, _, _),
+ .footnote(_, let status, _, _),
+ .tableCell(_, let status, _, _):
+ return status
+ default:
+ return nil
+ }
+ }
+
+ /// The block's inline styled content (eg. text of a heading/paragraph, or
+ /// title of a definition/footnote).
+ public var content: [InlineSpan]? {
+ switch self {
+ case .heading(_, _, let content, _),
+ .paragraph(let content, _),
+ .unorderedListItem(_, _, let content, _),
+ .orderedListItem(_, _, let content, _),
+ .quote(_, _, let content, _):
+ return content
+ case .definition(let title, _, _, _),
+ .footnote(let title, _, _, _),
+ .tableCell(let title, _, _, _):
+ return title
+ default:
+ return nil
+ }
+ }
+
+ /// The nested blocks owned by a range-able block (definition, footnote, or
+ /// table cell).
+ public var body: [NorgBlock]? {
+ switch self {
+ case .definition(_, _, let body, _),
+ .footnote(_, _, let body, _),
+ .tableCell(_, _, let body, _):
+ return body
+ default:
+ return nil
+ }
+ }
+
+ /// Returns a copy of the block with its task status replaced (pass `nil` to
+ /// clear it). Blocks that cannot carry a status are returned unchanged.
+ public func settingStatus(_ status: TaskStatus?) -> NorgBlock {
+ switch self {
+ case .heading(let level, _, let content, let line):
+ return .heading(level: level, status: status, content: content, line: line)
+ case .unorderedListItem(let level, _, let content, let line):
+ return .unorderedListItem(level: level, status: status, content: content, line: line)
+ case .orderedListItem(let level, _, let content, let line):
+ return .orderedListItem(level: level, status: status, content: content, line: line)
+ case .quote(let level, _, let content, let line):
+ return .quote(level: level, status: status, content: content, line: line)
+ case .definition(let title, _, let body, let line):
+ return .definition(title: title, status: status, body: body, line: line)
+ case .footnote(let title, _, let body, let line):
+ return .footnote(title: title, status: status, body: body, line: line)
+ case .tableCell(let title, _, let body, let line):
+ return .tableCell(title: title, status: status, body: body, line: line)
+ default:
+ return self
+ }
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgDocument.swift b/Sources/NorgKit/Models/NorgDocument.swift
new file mode 100644
index 0000000..73a3ed6
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgDocument.swift
@@ -0,0 +1,16 @@
+/// A fully parsed Norg document.
+public struct NorgDocument: Equatable, Hashable, Sendable, Codable {
+
+ /// The parsed blocks, as a flat list.
+ public var blocks: [NorgBlock]
+
+ /// Given an array of parsed blocks, create a document.
+ public init(blocks: [NorgBlock] = []) {
+ self.blocks = blocks
+ }
+
+ /// Converts the flat document into a tree.
+ public func tree() -> [NorgNode] {
+ TreeFolder(blocks).fold()
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgNode.swift b/Sources/NorgKit/Models/NorgNode.swift
new file mode 100644
index 0000000..99ae1f1
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgNode.swift
@@ -0,0 +1,10 @@
+/// A node in a tree view of a Norg document. Includes its block and children.
+public struct NorgNode: Equatable, Hashable, Sendable, Codable {
+ public var block: NorgBlock
+ public var children: [NorgNode]
+
+ public init(block: NorgBlock, children: [NorgNode] = []) {
+ self.block = block
+ self.children = children
+ }
+}
diff --git a/Sources/NorgKit/Models/NorgTask.swift b/Sources/NorgKit/Models/NorgTask.swift
new file mode 100644
index 0000000..7eb5c53
--- /dev/null
+++ b/Sources/NorgKit/Models/NorgTask.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+/// A single norg task / TODO.
+public struct NorgTask: Identifiable, Equatable, Hashable, Sendable, Codable {
+
+ public let fileURL: URL
+ /// This is zero-indexed
+ public let line: Int
+ public var status: TaskStatus
+ public let text: String
+
+ public init(fileURL: URL, line: Int, status: TaskStatus, text: String) {
+ self.fileURL = fileURL
+ self.line = line
+ self.status = status
+ self.text = text
+ }
+
+ public var id: String { "\(fileURL.absoluteString):\(line)" }
+}
diff --git a/Sources/NorgKit/Models/TaskStatus.swift b/Sources/NorgKit/Models/TaskStatus.swift
new file mode 100644
index 0000000..74badf3
--- /dev/null
+++ b/Sources/NorgKit/Models/TaskStatus.swift
@@ -0,0 +1,33 @@
+/// A Norg TODO status.
+public enum TaskStatus: String, CaseIterable, Identifiable, Sendable, Hashable, Codable {
+ case undone = " "
+ case done = "x"
+ case needsInput = "?"
+ case urgent = "!"
+ case recurring = "+"
+ case pending = "-"
+ case onHold = "="
+ case cancelled = "_"
+
+ /// Identifier, corresponds to its character.
+ public var id: String { rawValue }
+
+ /// Creates a TaskStatus based on a UTF-8 character.
+ public init?(marker: Character) {
+ self.init(rawValue: String(marker))
+ }
+
+ init?(markerByte byte: UInt8) {
+ switch byte {
+ case UInt8(ascii: " "): self = .undone
+ case UInt8(ascii: "x"): self = .done
+ case UInt8(ascii: "?"): self = .needsInput
+ case UInt8(ascii: "!"): self = .urgent
+ case UInt8(ascii: "+"): self = .recurring
+ case UInt8(ascii: "-"): self = .pending
+ case UInt8(ascii: "="): self = .onHold
+ case UInt8(ascii: "_"): self = .cancelled
+ default: return nil
+ }
+ }
+}
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
+ }
+}
diff --git a/Sources/NorgKit/TreeFolder.swift b/Sources/NorgKit/TreeFolder.swift
new file mode 100644
index 0000000..944d98f
--- /dev/null
+++ b/Sources/NorgKit/TreeFolder.swift
@@ -0,0 +1,82 @@
+/// Folds a flat list into a tree.
+final class TreeFolder {
+ private enum NestableKind { case unordered, ordered, quote }
+
+ private let blocks: [NorgBlock]
+ private var index = 0
+ private var strongReset = false
+
+ init(_ blocks: [NorgBlock]) {
+ self.blocks = blocks
+ }
+
+ func fold() -> [NorgNode] {
+ foldStructural(level: 0)
+ }
+
+ /// Folds structural items, that can have any block under them.
+ private func foldStructural(level: Int) -> [NorgNode] {
+ var nodes: [NorgNode] = []
+ while index < blocks.count {
+ let block = blocks[index]
+ switch block {
+ case .heading(let headingLevel, _, _, _):
+
+ // A heading of the same or higher level belongs to an ancestor;
+ // leave it for the caller.
+ if headingLevel <= level { return nodes }
+ index += 1
+ let children = foldStructural(level: headingLevel)
+ nodes.append(NorgNode(block: block, children: children))
+ if strongReset {
+ if level == 0 { strongReset = false } else { return nodes }
+ }
+
+ case .unorderedListItem(let itemLevel, _, _, _):
+ nodes.append(foldNestableItem(block, kind: .unordered, level: itemLevel))
+ case .orderedListItem(let itemLevel, _, _, _):
+ nodes.append(foldNestableItem(block, kind: .ordered, level: itemLevel))
+ case .quote(let itemLevel, _, _, _):
+ nodes.append(foldNestableItem(block, kind: .quote, level: itemLevel))
+
+ case .weakDelimiter:
+ index += 1
+ if level > 0 { return nodes }
+
+ case .strongDelimiter:
+ index += 1
+ if level > 0 {
+ strongReset = true
+ return nodes
+ }
+
+ default:
+ index += 1
+ nodes.append(NorgNode(block: block, children: []))
+ }
+ }
+ return nodes
+ }
+
+ /// Folds nestable items that can only consume more of their kind.
+ private func foldNestableItem(_ block: NorgBlock, kind: NestableKind, level: Int) -> NorgNode {
+ index += 1
+ var children: [NorgNode] = []
+ while index < blocks.count, let child = nestableDescriptor(blocks[index]),
+ child.kind == kind, child.level > level {
+ children.append(foldNestableItem(blocks[index], kind: child.kind, level: child.level))
+ }
+ return NorgNode(block: block, children: children)
+ }
+
+ /// The kind and nesting level of a nestable block, or `nil` if `block` is not
+ /// a nestable item.
+ private func nestableDescriptor(_ block: NorgBlock) -> (kind: NestableKind, level: Int)? {
+ switch block {
+ case .unorderedListItem(let level, _, _, _): return (.unordered, level)
+ case .orderedListItem(let level, _, _, _): return (.ordered, level)
+ case .quote(let level, _, _, _): return (.quote, level)
+ default: return nil
+ }
+ }
+}