From 647296d5a0799c2e9de05cd41b50bd161cdeac85 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Fri, 19 Jun 2026 22:06:26 +0200 Subject: Add NorgLexer --- Sources/NorgKit/Lexer/NorgLexer.swift | 193 ++++++++++++++++++++++ Sources/NorgKit/Lexer/NorgToken.swift | 65 ++++++++ Sources/NorgKit/Models/DetachedModifier.swift | 6 +- Sources/NorgKit/Parsers/BlockScanner.swift | 21 +++ Sources/NorgKit/Parsers/InlineScanner.swift | 187 +++++++++++++++++++++ Sources/NorgKit/Parsers/NorgInlineParser.swift | 214 +++++-------------------- Sources/NorgKit/Parsers/NorgParser.swift | 22 +-- 7 files changed, 516 insertions(+), 192 deletions(-) create mode 100644 Sources/NorgKit/Lexer/NorgLexer.swift create mode 100644 Sources/NorgKit/Lexer/NorgToken.swift create mode 100644 Sources/NorgKit/Parsers/BlockScanner.swift create mode 100644 Sources/NorgKit/Parsers/InlineScanner.swift (limited to 'Sources/NorgKit') diff --git a/Sources/NorgKit/Lexer/NorgLexer.swift b/Sources/NorgKit/Lexer/NorgLexer.swift new file mode 100644 index 0000000..ad9d457 --- /dev/null +++ b/Sources/NorgKit/Lexer/NorgLexer.swift @@ -0,0 +1,193 @@ +/// Tokenizes Norg source into `NorgToken`s for syntax highlighting. +public enum NorgLexer { + + /// Tokenizes text for syntax highlighting. + public static func tokenize(_ text: String) -> [NorgToken] { + var tokens: [NorgToken] = [] + let lines = TextHelper.lineSlices(text) + var i = 0 + + while i < lines.count { + let raw = lines[i] + let trimmed = TextHelper.whitespaceTrimmed(raw) + + if trimmed.isEmpty { + i += 1 + continue + } + + if trimmed.first == "@" { + i = tagBlock(lines, at: i, into: &tokens) + continue + } + + if let delimiter = BlockScanner.delimiter(trimmed) { + let kind: NorgToken.Kind + switch delimiter { + case .weak: kind = .weakDelimiter + case .strong: kind = .strongDelimiter + case .rule: kind = .horizontalRule + } + tokens.append(NorgToken(kind: kind, range: trimmed.startIndex.. Int { + tagHeaderTokens(lines[i], into: &tokens) + + let header = TextHelper.whitespaceTrimmed(TextHelper.whitespaceTrimmed(lines[i]).dropFirst()) + if header == "end" { return i + 1 } + + var j = i + 1 + while j < lines.count { + if TextHelper.whitespaceTrimmed(lines[j]) == "@end" { + tagHeaderTokens(lines[j], into: &tokens) + return j + 1 + } + let body = lines[j] + if body.startIndex < body.endIndex { + tokens.append(NorgToken(kind: .verbatimBlock, range: body.startIndex.. NorgToken.Kind? { + switch marker { + case "*": return .heading(level: level) + case "-": return .unorderedList(level: level) + case "~": return .orderedList(level: level) + case ">": return .quote(level: level) + case "$": return .definition(level: level) + case "^": return .footnote(level: level) + case ":": return .tableCell(level: level) + default: return nil + } + } + + // MARK: - Inline tokens + + private static func inlineTokens(in slice: Substring, into tokens: inout [NorgToken]) { + if slice.isEmpty { return } + if !slice.utf8.contains(where: InlineScanner.significant.contains) { return } + + let chars = Array(slice.unicodeScalars) + var bound = Array(slice.unicodeScalars.indices) + bound.append(slice.endIndex) + + func append(_ kind: NorgToken.Kind, _ from: Int, _ to: Int) { + tokens.append(NorgToken(kind: kind, range: bound[from]..= 0 { + if !style.isEmpty { append(.styledText(style), runStart, upTo) } + runStart = -1 + } + } + + var i = lo + while i < hi { + guard let object = InlineScanner.object(in: chars, at: i, to: hi) else { + if runStart < 0 { runStart = i } + i += 1 + continue + } + flushRun(i) + + switch object { + case .escape(let escaped, let end): + append(.escape, i, i + 1) + if let escaped, !style.isEmpty { append(.styledText(style), escaped, end) } + + case .comment(let open, _, _, let end): + append(.comment, open, end) + + case .verbatim(let vstyle, let open, let body, let close, _): + let s = style.union(vstyle) + append(.modifierDelimiter(s), open, open + 1) + if body.lowerBound < body.upperBound { + append(.styledText(s), body.lowerBound, body.upperBound) + } + append(.modifierDelimiter(s), close, close + 1) + + case .modifier(let mstyle, let open, let body, let close, _): + let s = style.union(mstyle) + append(.modifierDelimiter(s), open, open + 1) + emit(body.lowerBound, body.upperBound, style: s) + append(.modifierDelimiter(s), close, close + 1) + + case .link(_, let target, let description, _): + bracketed(target, body: .linkTarget) + if let description { bracketed(description, body: .linkDescription) } + + case .anchor(_, let name, let suffix, _): + bracketed(name, body: .linkDescription) + switch suffix { + case .target(let b): bracketed(b, body: .linkTarget) + case .description(let b): bracketed(b, body: .linkDescription) + case nil: break + } + } + + i = object.end + } + flushRun(hi) + } + + emit(0, chars.count, style: []) + } +} diff --git a/Sources/NorgKit/Lexer/NorgToken.swift b/Sources/NorgKit/Lexer/NorgToken.swift new file mode 100644 index 0000000..050c37e --- /dev/null +++ b/Sources/NorgKit/Lexer/NorgToken.swift @@ -0,0 +1,65 @@ +/// A lexical token over Norg source. +public struct NorgToken: Equatable, Sendable { + + /// What a token represents. + public enum Kind: Equatable, Sendable { + + // MARK: Block / line level + + /// A heading marker run (`*`…), carrying its level. + case heading(level: Int) + /// An unordered-list marker run (`-`…). + case unorderedList(level: Int) + /// An ordered-list marker run (`~`…). + case orderedList(level: Int) + /// A quote marker run (`>`…). + case quote(level: Int) + /// A definition marker run (`$`…). + case definition(level: Int) + /// A footnote marker run (`^`…). + case footnote(level: Int) + /// A table-cell marker run (`:`…). + case tableCell(level: Int) + /// A task status marker including its parentheses, eg. `(x)`. + case taskStatus(TaskStatus) + /// A weak delimiting line (`---`). + case weakDelimiter + /// A strong delimiting line (`===`). + case strongDelimiter + /// A horizontal rule (`___`). + case horizontalRule + /// A ranged-tag fence marker: the `@` of an opener and of `@end`. + case tagDelimiter + /// A ranged-tag header after `@` (name and parameters), eg. `code swift`. + case tagName + /// A raw body line inside a ranged tag (`@code` … `@end`). + case verbatimBlock + + // MARK: Inline level + + /// An attached- or verbatim-modifier delimiter (`*`, `/`, `` ` ``, `$`, …); + /// the style identifies which. + case modifierDelimiter(InlineStyle) + /// A run of text carrying a non-empty cumulative style (the content of one + /// or more nested modifiers). + case styledText(InlineStyle) + /// An inline comment, markers included (`%…%`). + case comment + /// An escape: the backslash of `\x` (the escaped character is not a token). + case escape + /// A link/anchor delimiter: `{`, `}`, `[`, or `]`. + case linkDelimiter + /// A link/anchor location (inside `{…}`). + case linkTarget + /// A link/anchor description or label (inside `[…]`). + case linkDescription + } + + public let kind: Kind + public let range: Range + + public init(kind: Kind, range: Range) { + self.kind = kind + self.range = range + } +} diff --git a/Sources/NorgKit/Models/DetachedModifier.swift b/Sources/NorgKit/Models/DetachedModifier.swift index 1f28c36..51d53ff 100644 --- a/Sources/NorgKit/Models/DetachedModifier.swift +++ b/Sources/NorgKit/Models/DetachedModifier.swift @@ -6,6 +6,7 @@ struct DetachedModifier { let status: TaskStatus? let statusIndex: String.Index? let content: Substring + let markerRange: Range /// Finds and parses a detached modifier if present. The accepting markers /// is used to reduce the markers being parsed. @@ -21,12 +22,14 @@ struct DetachedModifier { let markerByte = utf8[i] guard markers.contains(markerByte) else { return nil } + let markerStart = i var level = 0 var run = i while run < end, utf8[run] == markerByte { level += 1 run = utf8.index(after: run) } + let markerRange = markerStart..") + static let rangeableMarkers = ASCIIByteSet("$^:") + + enum Delimiter { + case weak, strong, rule + } + + 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 + } + } +} diff --git a/Sources/NorgKit/Parsers/InlineScanner.swift b/Sources/NorgKit/Parsers/InlineScanner.swift new file mode 100644 index 0000000..881a069 --- /dev/null +++ b/Sources/NorgKit/Parsers/InlineScanner.swift @@ -0,0 +1,187 @@ +/// Scans for inline norg constructs. +enum InlineScanner { + + struct Bracketed: Equatable { + let open: Int + let body: Range + let close: Int + } + + enum AnchorSuffix: Equatable { + case target(Bracketed) + case description(Bracketed) + } + + enum InlineObject: Equatable { + /// `\x` — `escaped` is the index of the literal scalar, or `nil` for a + /// trailing backslash at the end of the range. + case escape(escaped: Int?, end: Int) + /// `%…%`, dropped from rendered output. + case comment(open: Int, body: Range, close: Int, end: Int) + /// Verbatim / math: `` `…` `` or `$…$`, inner content taken literally. + case verbatim(style: InlineStyle, open: Int, body: Range, close: Int, end: Int) + /// An attached modifier (`*…*`, `/…/`, …) whose body is parsed recursively. + case modifier(style: InlineStyle, open: Int, body: Range, close: Int, end: Int) + /// `{target}` optionally followed by `[description]`. + case link(open: Int, target: Bracketed, description: Bracketed?, end: Int) + /// `[name]` optionally followed by `{target}` or `[description]`. + case anchor(open: Int, name: Bracketed, suffix: AnchorSuffix?, end: Int) + + var end: Int { + switch self { + case .escape(_, let end), .comment(_, _, _, let end), .verbatim(_, _, _, _, let end), + .modifier(_, _, _, _, let end), .link(_, _, _, let end), .anchor(_, _, _, let end): + return end + } + } + } + + static let modifiers: [Unicode.Scalar: InlineStyle] = [ + "*": .bold, + "/": .italic, + "_": .underline, + "-": .strikethrough, + "^": .superscript, + ",": .subscript, + "!": .spoiler, + ] + + static let literalModifiers: [Unicode.Scalar: InlineStyle] = [ + "`": .verbatim, + "$": .math, + ] + + static let significant = ASCIIByteSet("*/_-^,!\u{60}$%{[\\") + + static func object(in chars: [Unicode.Scalar], at i: Int, to hi: Int) -> InlineObject? { + let c = chars[i] + 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 == "\\" { + return i + 1 < hi ? .escape(escaped: i + 1, end: i + 2) : .escape(escaped: nil, end: i + 1) + } + + // Comments are dropped from the rendered output. + if c == "%", isOpener(prev: prev, next: next), + let close = literalClose(chars, from: i + 1, to: hi, char: "%") { + return .comment(open: i, body: (i + 1).. Bool { + guard let c else { return true } + return c.properties.isWhitespace + } + + 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 + } + } + + private static func isOpener(prev: Unicode.Scalar?, next: Unicode.Scalar?) -> Bool { + isBoundary(prev) && !isSpace(next) + } + + 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 + } + + 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 + } + + 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 + } +} diff --git a/Sources/NorgKit/Parsers/NorgInlineParser.swift b/Sources/NorgKit/Parsers/NorgInlineParser.swift index 1f19c43..b47ec50 100644 --- a/Sources/NorgKit/Parsers/NorgInlineParser.swift +++ b/Sources/NorgKit/Parsers/NorgInlineParser.swift @@ -3,31 +3,10 @@ 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) { + if !text.utf8.contains(where: InlineScanner.significant.contains) { return [InlineSpan(text: text, styles: [])] } @@ -38,7 +17,7 @@ public enum NorgInlineParser { /// 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) { + if !text.utf8.contains(where: InlineScanner.significant.contains) { return String(text) } @@ -59,179 +38,68 @@ public enum NorgInlineParser { 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 - } + guard let object = InlineScanner.object(in: chars, at: i, to: hi) else { + buffer.append(chars[i]) + 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: "%") { + switch object { + case .escape(let escaped, _): + if let escaped { buffer.append(chars[escaped]) } + + case .comment: 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) { + case .verbatim(let style, _, let body, _, _): flush() - spans.append(InlineSpan(text: slice(chars, i + 1, close), styles: base.union(style))) - i = close + 1 - continue - } + spans.append(InlineSpan(text: slice(chars, body), styles: base.union(style))) - // 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 - } + case .modifier(let style, _, let body, _, _): flush() spans.append( - InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: target))) - i = j - continue - } + contentsOf: parse( + chars, from: body.lowerBound, to: body.upperBound, base: base.union(style))) - // 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 - } + case .link(_, let target, let description, _): flush() + let targetText = slice(chars, target.body) + let label = description.map { slice(chars, $0.body) } ?? linkLabel(targetText) spans.append( - InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target))) - i = j - continue - } + InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: targetText))) - // 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) { + case .anchor(_, let name, let suffix, _): flush() - spans.append(contentsOf: parse(chars, from: i + 1, to: close, base: base.union(style))) - i = close + 1 - continue + let nameText = slice(chars, name.body) + let label: String + let target: String? + switch suffix { + case .target(let bracket): + label = nameText + target = slice(chars, bracket.body) + case .description(let bracket): + label = slice(chars, bracket.body) + target = nil + case nil: + label = nameText + target = nil + } + spans.append( + InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target))) } - buffer.append(c) - i += 1 + i = object.end } 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.. Bool { - guard let c else { return true } - return c.properties.isWhitespace - } + // MARK: - Helpers - /// 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 + /// Builds a `String` from a half-open scalar range. + private static func slice(_ chars: [Unicode.Scalar], _ range: Range) -> String { + String(String.UnicodeScalarView(chars[range])) } /// Produces display text for a link target that has no explicit description diff --git a/Sources/NorgKit/Parsers/NorgParser.swift b/Sources/NorgKit/Parsers/NorgParser.swift index aad66f4..20a76a0 100644 --- a/Sources/NorgKit/Parsers/NorgParser.swift +++ b/Sources/NorgKit/Parsers/NorgParser.swift @@ -1,10 +1,8 @@ /// 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 static let blockMarkers = BlockScanner.blockMarkers + private static let rangeableMarkers = BlockScanner.rangeableMarkers private struct Source { let raw: [Substring] @@ -51,7 +49,7 @@ public enum NorgParser { // 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) { + if let delimiter = BlockScanner.delimiter(line) { switch delimiter { case .rule: blocks.append(.horizontalRule(line: lineNo)) case .weak: blocks.append(.weakDelimiter(line: lineNo)) @@ -264,22 +262,10 @@ public enum NorgParser { // 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 BlockScanner.delimiter(trimmed) != nil { return true } if DetachedModifier.parse(trimmed[...], accepting: blockMarkers) != nil { return true } return DetachedModifier.parse(trimmed[...], accepting: rangeableMarkers) != nil } -- cgit