diff options
Diffstat (limited to 'Sources')
| -rw-r--r-- | Sources/NorgKit/Extensions/Array+InlineSpan.swift | 7 | ||||
| -rw-r--r-- | Sources/NorgKit/Helpers/ASCIIByteSet.swift | 30 | ||||
| -rw-r--r-- | Sources/NorgKit/Helpers/ASCIIHelper.swift | 5 | ||||
| -rw-r--r-- | Sources/NorgKit/Helpers/TextHelper.swift | 95 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/DetachedModifier.swift | 61 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/InlineLink.swift | 15 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/InlineSpan.swift | 12 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/InlineStyle.swift | 28 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/NorgBlock.swift | 108 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/NorgDocument.swift | 16 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/NorgNode.swift | 10 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/NorgTask.swift | 20 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/TaskStatus.swift | 33 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgInlineParser.swift | 248 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgParser.swift | 286 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/TaskScanner.swift | 52 | ||||
| -rw-r--r-- | Sources/NorgKit/TreeFolder.swift | 82 |
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 + } + } +} |