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.. 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) } }