aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Parsers/NorgInlineParser.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgKit/Parsers/NorgInlineParser.swift')
-rw-r--r--Sources/NorgKit/Parsers/NorgInlineParser.swift248
1 files changed, 248 insertions, 0 deletions
diff --git a/Sources/NorgKit/Parsers/NorgInlineParser.swift b/Sources/NorgKit/Parsers/NorgInlineParser.swift
new file mode 100644
index 0000000..1f19c43
--- /dev/null
+++ b/Sources/NorgKit/Parsers/NorgInlineParser.swift
@@ -0,0 +1,248 @@
+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) {
+ return [InlineSpan(text: text, styles: [])]
+ }
+
+ let chars = Array(text.unicodeScalars)
+ return parse(chars, from: 0, to: chars.count, base: [])
+ }
+
+ /// 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) {
+ return String(text)
+ }
+
+ let chars = Array(text.unicodeScalars)
+ return parse(chars, from: 0, to: chars.count, base: []).plainText
+ }
+
+ private static func parse(_ chars: [Unicode.Scalar], from lo: Int, to hi: Int, base: InlineStyle)
+ -> [InlineSpan] {
+ var spans: [InlineSpan] = []
+ var buffer = String.UnicodeScalarView()
+
+ func flush() {
+ guard !buffer.isEmpty else { return }
+ spans.append(InlineSpan(text: String(buffer), styles: base))
+ buffer = String.UnicodeScalarView()
+ }
+
+ 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
+ }
+ 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: "%") {
+ 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) {
+ flush()
+ spans.append(InlineSpan(text: slice(chars, i + 1, close), styles: base.union(style)))
+ i = close + 1
+ continue
+ }
+
+ // 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
+ }
+ flush()
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: target)))
+ i = j
+ continue
+ }
+
+ // 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
+ }
+ flush()
+ spans.append(
+ InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target)))
+ i = j
+ continue
+ }
+
+ // 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) {
+ flush()
+ spans.append(contentsOf: parse(chars, from: i + 1, to: close, base: base.union(style)))
+ i = close + 1
+ continue
+ }
+
+ buffer.append(c)
+ i += 1
+ }
+
+ 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)
+ }
+
+ /// 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
+ }
+
+ /// Produces display text for a link target that has no explicit description
+ /// by stripping the leading location prefix (`*`, `#`, `/`, `$`, `:file:`).
+ private static func linkLabel(_ target: String) -> String {
+ var s = Substring(target)
+ // Strip a leading `:path:` file specifier.
+ if s.first == ":", let end = s.dropFirst().firstIndex(of: ":") {
+ s = s[s.index(after: end)...]
+ }
+ s = s.drop { "*#/$ ".contains($0) }
+ return s.isEmpty ? target : String(s)
+ }
+}