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 ++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 Sources/NorgKit/Lexer/NorgLexer.swift create mode 100644 Sources/NorgKit/Lexer/NorgToken.swift (limited to 'Sources/NorgKit/Lexer') 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 + } +} -- cgit