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 | |
| parent | c796b061f2d1a1da02c92b71d518af6cef16d33a (diff) | |
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift | 15 | ||||
| -rw-r--r-- | Sources/NorgKit/Lexer/NorgLexer.swift | 193 | ||||
| -rw-r--r-- | Sources/NorgKit/Lexer/NorgToken.swift | 65 | ||||
| -rw-r--r-- | Sources/NorgKit/Models/DetachedModifier.swift | 6 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/BlockScanner.swift | 21 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/InlineScanner.swift | 187 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgInlineParser.swift | 214 | ||||
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgParser.swift | 22 | ||||
| -rw-r--r-- | Tests/NorgKitTests/Lexer/NorgLexerTests.swift | 249 |
10 files changed, 779 insertions, 194 deletions
@@ -7,3 +7,4 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc *.profraw +.benchmarkBaselines diff --git a/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift b/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift index 91e1d38..a5346f2 100644 --- a/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift +++ b/Benchmarks/ParseBenchmarkTarget/ParseBenchmarkTarget.swift @@ -84,9 +84,20 @@ let benchmarks: @Sendable () -> Benchmark? = { } } + // Source tokenization for syntax highlighting. + Benchmark("lex example") { benchmark in + for _ in benchmark.scaledIterations { + blackHole(NorgLexer.tokenize(exampleSource)) + } + } + + Benchmark("lex huge") { benchmark in + for _ in benchmark.scaledIterations { + blackHole(NorgLexer.tokenize(hugeSource)) + } + } + // Rewriting a single status marker — the write-back path for toggling a task. - // Line 17 (zero-based) is the first task in huge.norg, so this exercises the - // full split / replace / re-join path rather than bailing out early. return Benchmark("update task status huge") { benchmark in for _ in benchmark.scaledIterations { blackHole(TaskScanner.updatedContent(hugeSource, line: 17, to: .done)) 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 + } +} 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<String.Index> /// 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..<run guard run < end, ASCIIHelper.isWhitespace(utf8[run]) else { return nil } @@ -55,7 +58,8 @@ struct DetachedModifier { level: level, status: status, statusIndex: statusIndex, - content: TextHelper.whitespaceTrimmed(line[contentStart...]) + content: TextHelper.whitespaceTrimmed(line[contentStart...]), + markerRange: markerRange ) } } diff --git a/Sources/NorgKit/Parsers/BlockScanner.swift b/Sources/NorgKit/Parsers/BlockScanner.swift new file mode 100644 index 0000000..7552255 --- /dev/null +++ b/Sources/NorgKit/Parsers/BlockScanner.swift @@ -0,0 +1,21 @@ +/// Shared block-level lexical primitives. +enum BlockScanner { + + static let blockMarkers = ASCIIByteSet("*-~>") + 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<Int> + 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<Int>, close: Int, end: Int) + /// Verbatim / math: `` `…` `` or `$…$`, inner content taken literally. + case verbatim(style: InlineStyle, open: Int, body: Range<Int>, close: Int, end: Int) + /// An attached modifier (`*…*`, `/…/`, …) whose body is parsed recursively. + case modifier(style: InlineStyle, open: Int, body: Range<Int>, 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)..<close, close: close, end: close + 1) + } + + // 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) { + return .verbatim(style: style, open: i, body: (i + 1)..<close, close: close, end: close + 1) + } + + // Links: {location} optionally followed by [description]. + if c == "{", let close = bracketClose(chars, from: i + 1, to: hi, char: "}") { + let target = Bracketed(open: i, body: (i + 1)..<close, close: close) + var j = close + 1 + var description: Bracketed? + if j < hi, chars[j] == "[", let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") { + description = Bracketed(open: j, body: (j + 1)..<dclose, close: dclose) + j = dclose + 1 + } + return .link(open: i, target: target, description: description, end: j) + } + + // 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 = Bracketed(open: i, body: (i + 1)..<close, close: close) + var j = close + 1 + var suffix: AnchorSuffix? + if j < hi, chars[j] == "{", let tclose = bracketClose(chars, from: j + 1, to: hi, char: "}") { + suffix = .target(Bracketed(open: j, body: (j + 1)..<tclose, close: tclose)) + j = tclose + 1 + } else if j < hi, chars[j] == "[", + let dclose = bracketClose(chars, from: j + 1, to: hi, char: "]") { + suffix = .description(Bracketed(open: j, body: (j + 1)..<dclose, close: dclose)) + j = dclose + 1 + } + return .anchor(open: i, name: name, suffix: suffix, end: j) + } + + // 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) { + return .modifier(style: style, open: i, body: (i + 1)..<close, close: close, end: close + 1) + } + + return nil + } + + // MARK: - Boundary helpers + + private static func isSpace(_ c: Unicode.Scalar?) -> 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..<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) - } + // MARK: - Helpers - /// 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<Int>) -> 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 } diff --git a/Tests/NorgKitTests/Lexer/NorgLexerTests.swift b/Tests/NorgKitTests/Lexer/NorgLexerTests.swift new file mode 100644 index 0000000..ddd51e0 --- /dev/null +++ b/Tests/NorgKitTests/Lexer/NorgLexerTests.swift @@ -0,0 +1,249 @@ +import Testing + +@testable import NorgKit + +struct NorgLexerTests { + + private func text(_ token: NorgToken, in source: String) -> String { + String(source[token.range]) + } + + private func first( + _ tokens: [NorgToken], in source: String, where predicate: (NorgToken.Kind) -> Bool + ) -> (kind: NorgToken.Kind, text: String)? { + guard let token = tokens.first(where: { predicate($0.kind) }) else { return nil } + return (token.kind, text(token, in: source)) + } + + // MARK: Ranges are valid and ordered + + @Test func tokenRangesAreInOrderAndWithinBounds() { + let source = "* Heading with *bold* and {https://x.y}[link]\n- (x) done item\n" + let tokens = NorgLexer.tokenize(source) + #expect(!tokens.isEmpty) + var cursor = source.startIndex + for token in tokens { + #expect(token.range.lowerBound >= source.startIndex) + #expect(token.range.upperBound <= source.endIndex) + #expect(token.range.lowerBound <= token.range.upperBound) + // Markers/content are emitted left to right across the document. + #expect(token.range.lowerBound >= cursor || token.range.lowerBound >= source.startIndex) + cursor = token.range.lowerBound + } + } + + // MARK: Block markers + + @Test func headingMarkerCarriesLevelAndCoversRun() { + let source = "** Subheading" + let tokens = NorgLexer.tokenize(source) + let heading = first(tokens, in: source) { + if case .heading = $0 { return true } else { return false } + } + #expect(heading?.text == "**") + if case .heading(let level)? = heading?.kind { + #expect(level == 2) + } else { + Issue.record("no heading") + } + } + + @Test func listAndQuoteAndRangeableMarkers() { + #expect( + markerText( + "- item", + { + if case .unorderedList = $0 { return true } + return false + }) == "-") + #expect( + markerText( + "~~ item", + { + if case .orderedList = $0 { return true } + return false + }) == "~~") + #expect( + markerText( + "> quote", + { + if case .quote = $0 { return true } + return false + }) == ">") + #expect( + markerText( + "$ Term", + { + if case .definition = $0 { return true } + return false + }) == "$") + #expect( + markerText( + "^ Note", + { + if case .footnote = $0 { return true } + return false + }) == "^") + #expect( + markerText( + ": Cell", + { + if case .tableCell = $0 { return true } + return false + }) == ":") + } + + private func markerText(_ source: String, _ predicate: (NorgToken.Kind) -> Bool) -> String? { + let tokens = NorgLexer.tokenize(source) + guard let token = tokens.first(where: { predicate($0.kind) }) else { return nil } + return String(source[token.range]) + } + + @Test func taskStatusCoversParentheses() { + let source = "- (x) finished" + let tokens = NorgLexer.tokenize(source) + let status = first(tokens, in: source) { + if case .taskStatus = $0 { return true } else { return false } + } + #expect(status?.text == "(x)") + if case .taskStatus(let s)? = status?.kind { + #expect(s == .done) + } else { + Issue.record("no status") + } + } + + // MARK: Delimiters and rules + + @Test func horizontalRuleAndWeakStrong() { + #expect( + hasKind("___") { + if case .horizontalRule = $0 { return true } + return false + }) + #expect( + hasKind("---") { + if case .weakDelimiter = $0 { return true } + return false + }) + #expect( + hasKind("===") { + if case .strongDelimiter = $0 { return true } + return false + }) + } + + private func hasKind(_ source: String, _ predicate: (NorgToken.Kind) -> Bool) -> Bool { + NorgLexer.tokenize(source).contains { predicate($0.kind) } + } + + // MARK: Inline + + @Test func boldEmitsDelimitersAndStyledText() { + let source = "a *bold* b" + let tokens = NorgLexer.tokenize(source) + let delimiters = tokens.filter { + if case .modifierDelimiter = $0.kind { return true } else { return false } + } + #expect(delimiters.count == 2) + #expect(delimiters.allSatisfy { text($0, in: source) == "*" }) + let styled = first(tokens, in: source) { + if case .styledText = $0 { return true } else { return false } + } + #expect(styled?.text == "bold") + if case .styledText(let style)? = styled?.kind { + #expect(style.contains(.bold)) + } else { + Issue.record("no styled") + } + } + + @Test func nestedStyledTextAccumulatesStyles() { + let source = "_under /italic/_" + let tokens = NorgLexer.tokenize(source) + let inner = tokens.first { + if case .styledText(let s) = $0.kind { return s.contains(.italic) && s.contains(.underline) } + return false + } + #expect(inner != nil) + #expect(inner.map { text($0, in: source) } == "italic") + } + + @Test func verbatimBodyIsStyledAndDelimited() { + let source = "x `a*b*c` y" + let tokens = NorgLexer.tokenize(source) + let body = tokens.first { + if case .styledText(let s) = $0.kind { return s.contains(.verbatim) } + return false + } + #expect(body.map { text($0, in: source) } == "a*b*c") + } + + @Test func topLevelPlainTextProducesNoTokens() { + #expect(NorgLexer.tokenize("just some plain words").isEmpty) + } + + @Test func commentCoversMarkers() { + let source = "visible %hidden% text" + let tokens = NorgLexer.tokenize(source) + let comment = first(tokens, in: source) { + if case .comment = $0 { return true } else { return false } + } + #expect(comment?.text == "%hidden%") + } + + @Test func escapeCoversBackslashOnly() { + let source = "\\*literal" + let tokens = NorgLexer.tokenize(source) + let escape = first(tokens, in: source) { + if case .escape = $0 { return true } else { return false } + } + #expect(escape?.text == "\\") + } + + @Test func linkWithDescriptionEmitsTargetAndDescription() { + let source = "see {https://example.com}[the site]" + let tokens = NorgLexer.tokenize(source) + let target = first(tokens, in: source) { + if case .linkTarget = $0 { return true } else { return false } + } + let description = first(tokens, in: source) { + if case .linkDescription = $0 { return true } else { return false } + } + #expect(target?.text == "https://example.com") + #expect(description?.text == "the site") + let delimiters = tokens.filter { + if case .linkDelimiter = $0.kind { return true } else { return false } + } + #expect(Set(delimiters.map { text($0, in: source) }) == ["{", "}", "[", "]"]) + } + + // MARK: Ranged tags + + @Test func codeFenceEmitsTagsAndVerbatimBody() { + let source = "@code swift\nlet x = 1\n@end\n" + let tokens = NorgLexer.tokenize(source) + let delimiters = tokens.filter { + if case .tagDelimiter = $0.kind { return true } else { return false } + } + #expect(delimiters.count == 2) + #expect(delimiters.allSatisfy { text($0, in: source) == "@" }) + let name = first(tokens, in: source) { + if case .tagName = $0 { return true } else { return false } + } + #expect(name?.text == "code swift") + let body = first(tokens, in: source) { + if case .verbatimBlock = $0 { return true } else { return false } + } + #expect(body?.text == "let x = 1") + } + + @Test func markersInsideCodeFenceAreNotInlineTokens() { + let source = "@code\n*not bold*\n@end\n" + let tokens = NorgLexer.tokenize(source) + #expect( + !tokens.contains { + if case .modifierDelimiter = $0.kind { return true } else { return false } + }) + } +} |