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/Parsers/InlineScanner.swift | |
| parent | c796b061f2d1a1da02c92b71d518af6cef16d33a (diff) | |
Diffstat (limited to 'Sources/NorgKit/Parsers/InlineScanner.swift')
| -rw-r--r-- | Sources/NorgKit/Parsers/InlineScanner.swift | 187 |
1 files changed, 187 insertions, 0 deletions
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 + } +} |