diff options
Diffstat (limited to 'Sources/NorgKit/Parsers/NorgInlineParser.swift')
| -rw-r--r-- | Sources/NorgKit/Parsers/NorgInlineParser.swift | 248 |
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) + } +} |