diff options
Diffstat (limited to 'Sources/NorgKit/Parsers')
| -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 |
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 + } +} |