diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 22:06:26 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 22:15:42 +0200 |
| commit | 647296d5a0799c2e9de05cd41b50bd161cdeac85 (patch) | |
| tree | 61dd769ceadba7f139d2c299119e6b1b86d849ab /Sources/NorgKit/Lexer | |
| parent | c796b061f2d1a1da02c92b71d518af6cef16d33a (diff) | |
Diffstat (limited to 'Sources/NorgKit/Lexer')
| -rw-r--r-- | Sources/NorgKit/Lexer/NorgLexer.swift | 193 | ||||
| -rw-r--r-- | Sources/NorgKit/Lexer/NorgToken.swift | 65 |
2 files changed, 258 insertions, 0 deletions
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..<trimmed.endIndex)) + i += 1 + continue + } + + if let m = DetachedModifier.parse(raw, accepting: BlockScanner.rangeableMarkers) { + detachedTokens(m, into: &tokens) + } else if let m = DetachedModifier.parse(raw, accepting: BlockScanner.blockMarkers) { + detachedTokens(m, into: &tokens) + } else { + inlineTokens(in: raw, into: &tokens) + } + i += 1 + } + + return tokens + } + + // MARK: - Block-level tokens + + private static func tagBlock( + _ lines: [Substring], at i: Int, into tokens: inout [NorgToken] + ) -> 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..<body.endIndex)) + } + j += 1 + } + return j + } + + private static func tagHeaderTokens(_ raw: Substring, into tokens: inout [NorgToken]) { + guard let at = raw.firstIndex(of: "@") else { return } + let afterAt = raw.index(after: at) + tokens.append(NorgToken(kind: .tagDelimiter, range: at..<afterAt)) + let header = TextHelper.whitespaceTrimmed(raw[afterAt...]) + if !header.isEmpty { + tokens.append(NorgToken(kind: .tagName, range: header.startIndex..<header.endIndex)) + } + } + + private static func detachedTokens(_ m: DetachedModifier, into tokens: inout [NorgToken]) { + if let kind = markerKind(m.marker, level: m.level) { + tokens.append(NorgToken(kind: kind, range: m.markerRange)) + } + if let status = m.status, let statusIndex = m.statusIndex { + let base = m.content.base + let open = base.index(before: statusIndex) + let close = base.index(after: base.index(after: statusIndex)) + tokens.append(NorgToken(kind: .taskStatus(status), range: open..<close)) + } + inlineTokens(in: m.content, into: &tokens) + } + + private static func markerKind(_ marker: Character, level: Int) -> 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]..<bound[to])) + } + + func bracketed(_ b: InlineScanner.Bracketed, body kind: NorgToken.Kind) { + append(.linkDelimiter, b.open, b.open + 1) + if b.body.lowerBound < b.body.upperBound { + append(kind, b.body.lowerBound, b.body.upperBound) + } + append(.linkDelimiter, b.close, b.close + 1) + } + + func emit(_ lo: Int, _ hi: Int, style: InlineStyle) { + var runStart = -1 + + func flushRun(_ upTo: Int) { + if runStart >= 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<String.Index> + + public init(kind: Kind, range: Range<String.Index>) { + self.kind = kind + self.range = range + } +} |