Files
Outpost/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Parser/InlineParser.swift
T
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00

793 lines
37 KiB
Swift

//
// InlineParser.swift
// MarkdownEngine
//
// Phase 2 of the regex→AST refactor: the inline-structure pass. Given the
// text of a single inline-bearing block, it produces an inline AST node tree
// with correct CommonMark precedence — replacing the per-construct regex soup
// that the current tokenizer uses inside each block.
//
// Built construct by construct, test-first. Ranges in the returned tree are
// relative to the parsed string; callers offset to document coordinates.
//
// Pipeline (each pass claims spans only in regions not already claimed, so
// there are never partial overlaps and buildTree is a clean containment tree):
// 1. scanCodeSpans — highest precedence, opaque interior.
// 2. scanEscapes — `\x` becomes a claimed span, so the escaped char is
// automatically inert for every pass below.
// 3. scanLinkFamily — ![[…]], [[…]], ![…](…), […](…), $…$ (+ registered
// extension spans) in precedence order. URLs allow
// balanced parens. A candidate overlapping a claimed
// span is rejected (kept literal), except for opaque
// spans wholly nested inside a Markdown link's label.
// This keeps `[x](y)` inert inside code while allowing
// valid labels such as [`x`](y).
// 4. resolveEmphasis — `*`/`_` delimiter runs over text outside every
// claimed span; may wrap claimed spans.
// 5. buildTree — containment tree. Emphasis nests already-collected
// spans; link/extension-span content is re-parsed
// recursively; code/image/wiki/embed/latex/escape are
// opaque leaves.
//
// Claimed spans are therefore either disjoint or properly NESTED — a link
// label may hold one, nothing else may. That is load-bearing for cost as well
// as correctness: it's what lets `ClaimedIndex` answer "is this claimed?" with
// a cursor and `buildTree` derive containment from a sort. A pass that claimed
// a PARTIALLY overlapping span would break both, so keep claiming whole or not
// at all.
//
import Foundation
enum EmphasisKind: Equatable { case italic, bold, boldItalic }
/// A node in the inline AST.
indirect enum InlineNode: Equatable {
case text(NSRange)
/// `` `code` `` — opaque; `range` covers the backticks, `content` strips single-space padding.
case code(range: NSRange, content: NSRange)
/// `*`/`_` emphasis. `markers` is `[openMarker, closeMarker]`.
case emphasis(EmphasisKind, range: NSRange, markers: [NSRange], children: [InlineNode])
/// `[text](url)`. `markers` is `[ "[", "]", "(", ")" ]`; text is recursively parsed.
case link(range: NSRange, textRange: NSRange, url: NSRange, markers: [NSRange], children: [InlineNode])
/// `![alt](url)`. `markers` is `[ "![", "]", "(", ")" ]`. Alt is opaque.
case image(range: NSRange, alt: NSRange, url: NSRange, markers: [NSRange])
/// `[[Name|id]]`. `markers` is `[ "[[", "]]" ]`; `id` is nil when no `|`.
case wikiLink(range: NSRange, name: NSRange, id: NSRange?, markers: [NSRange])
/// `![[target]]`. `markers` is `[ "![[", "]]" ]`.
case imageEmbed(range: NSRange, target: NSRange, markers: [NSRange])
/// `$math$` — opaque. `markers` is `[ "$", "$" ]`.
case inlineLatex(range: NSRange, content: NSRange, markers: [NSRange])
/// Backslash escape `\x`; `marker` is the `\`, `character` the now-literal punctuation.
case escape(range: NSRange, character: NSRange, marker: NSRange)
/// A span contributed by a registered `MarkdownExtension`
/// (e.g. `==highlight==`). Pure data — behavior lives in the extension,
/// looked up by `extensionID` at styling/render time.
case ext(ExtensionInlineNode)
}
/// An extension-contributed inline span. `children` is empty for opaque
/// (non-`parsesContent`) spans.
struct ExtensionInlineNode: Equatable {
let extensionID: String
let range: NSRange
let contentRange: NSRange
let markers: [NSRange] // [open, close]
let children: [InlineNode]
}
enum InlineParser {
private static let backtick: unichar = 0x60
private static let asterisk: unichar = 0x2A
private static let underscore: unichar = 0x5F
private static let newline: unichar = 0x0A
private static let bang: unichar = 0x21
private static let lbracket: unichar = 0x5B
private static let rbracket: unichar = 0x5D
private static let lparen: unichar = 0x28
private static let rparen: unichar = 0x29
private static let pipe: unichar = 0x7C
private static let backslash: unichar = 0x5C
private static let dollar: unichar = 0x24
// MARK: - Entry point
static func parse(_ text: String, registry: ExtensionRegistry = .empty) -> [InlineNode] {
let ns = text as NSString
let len = ns.length
guard len > 0 else { return [] }
var claimed = scanCodeSpans(ns, len: len)
claimed += scanEscapes(ns, len: len, claimed: ClaimedIndex(claimed))
claimed += scanLinkFamily(ns, len: len, claimed: ClaimedIndex(claimed), registry: registry)
let emphasis = resolveEmphasis(ns, len: len, claimed: ClaimedIndex(claimed))
return buildTree(region: NSRange(location: 0, length: len), spans: claimed + emphasis, ns: ns, registry: registry)
}
/// Parse the inline content of `range` within `ns`, returning nodes in absolute document coordinates.
static func parse(_ ns: NSString, range: NSRange, registry: ExtensionRegistry = .empty) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location)
}
// MARK: - Span model
private enum Span {
case code(range: NSRange, content: NSRange)
case emphasis(kind: EmphasisKind, range: NSRange, open: NSRange, close: NSRange)
case link(range: NSRange, textRange: NSRange, url: NSRange, markers: [NSRange])
case image(range: NSRange, alt: NSRange, url: NSRange, markers: [NSRange])
case wikiLink(range: NSRange, name: NSRange, id: NSRange?, markers: [NSRange])
case imageEmbed(range: NSRange, target: NSRange, markers: [NSRange])
case inlineLatex(range: NSRange, content: NSRange, markers: [NSRange])
case escape(range: NSRange, character: NSRange, marker: NSRange)
case ext(id: String, range: NSRange, contentRange: NSRange, markers: [NSRange], parsesContent: Bool)
var fullRange: NSRange {
switch self {
case .code(let r, _), .emphasis(_, let r, _, _), .link(let r, _, _, _),
.image(let r, _, _, _), .wikiLink(let r, _, _, _), .imageEmbed(let r, _, _),
.inlineLatex(let r, _, _), .escape(let r, _, _),
.ext(_, let r, _, _, _):
return r
}
}
}
/// The already-claimed ranges, in a form the later passes can consult in
/// amortised constant time.
///
/// Every pass that asks "is this claimed?" walks the string left to right
/// and never looks back, and claimed ranges never PARTIALLY overlap (each
/// pass only claims inside regions no earlier pass took). So a cursor over
/// the sorted ranges answers without rescanning: the answer for index `i`
/// only ever involves the first range that ends after `i`.
///
/// A nested range (a code span inside a link label) sorts after its
/// container, which already covers it, so `contains` stays correct without
/// looking past the cursor. `overlapping` is the one query that must, and
/// it peeks rather than advances.
///
/// Sortedness is established here rather than assumed of callers, so no
/// call site carries an ordering obligation.
private struct ClaimedIndex {
private let ranges: [NSRange]
private var cursor = 0
init(_ spans: [Span]) {
ranges = spans.map(\.fullRange).sorted { $0.location < $1.location }
}
/// Discard ranges that end at or before `idx`. `idx` must not move backwards.
private mutating func advance(to idx: Int) {
while cursor < ranges.count, NSMaxRange(ranges[cursor]) <= idx { cursor += 1 }
}
mutating func contains(_ idx: Int) -> Bool {
advance(to: idx)
return cursor < ranges.count && NSLocationInRange(idx, ranges[cursor])
}
mutating func overlaps(_ range: NSRange) -> Bool {
advance(to: range.location)
return cursor < ranges.count && ranges[cursor].location < NSMaxRange(range)
}
/// Every claimed range overlapping `range`. Peeks forward from the
/// cursor without consuming, so the caller's left-to-right walk is
/// unaffected.
mutating func overlapping(_ range: NSRange) -> [NSRange] {
advance(to: range.location)
var out: [NSRange] = []
var k = cursor
while k < ranges.count, ranges[k].location < NSMaxRange(range) {
if NSIntersectionRange(ranges[k], range).length > 0 { out.append(ranges[k]) }
k += 1
}
return out
}
}
// MARK: - 1. Code spans
private static func scanCodeSpans(_ ns: NSString, len: Int) -> [Span] {
var spans: [Span] = []
var i = 0
while i < len {
guard ns.character(at: i) == backtick, !isEscaped(i, ns) else { i += 1; continue }
let runStart = i
var j = i
while j < len, ns.character(at: j) == backtick { j += 1 }
let runLen = j - runStart
guard let close = closingBacktickRun(in: ns, from: j, length: len, runLen: runLen) else {
i = j; continue
}
let codeRange = NSRange(location: runStart, length: (close + runLen) - runStart)
let rawContent = NSRange(location: j, length: close - j)
spans.append(.code(range: codeRange, content: strippedCodeContent(rawContent, in: ns)))
i = close + runLen
}
return spans
}
private static func closingBacktickRun(in ns: NSString, from: Int, length len: Int, runLen: Int) -> Int? {
var k = from
while k < len {
guard ns.character(at: k) == backtick, !isEscaped(k, ns) else { k += 1; continue }
let start = k
while k < len, ns.character(at: k) == backtick { k += 1 }
if k - start == runLen { return start }
}
return nil
}
private static func strippedCodeContent(_ raw: NSRange, in ns: NSString) -> NSRange {
let space: unichar = 0x20
guard raw.length >= 2,
ns.character(at: raw.location) == space,
ns.character(at: NSMaxRange(raw) - 1) == space else { return raw }
var allSpaces = true
for k in raw.location..<NSMaxRange(raw) where ns.character(at: k) != space {
allSpaces = false; break
}
guard !allSpaces else { return raw }
return NSRange(location: raw.location + 1, length: raw.length - 2)
}
// MARK: - 2. Backslash escapes (claimed → escaped chars are inert everywhere)
private static func scanEscapes(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var claimed = claimed
var spans: [Span] = []
var i = 0
while i < len - 1 {
if ns.character(at: i) == backslash, !claimed.contains(i), isAsciiPunctuationChar(ns.character(at: i + 1)) {
spans.append(.escape(
range: NSRange(location: i, length: 2),
character: NSRange(location: i + 1, length: 1),
marker: NSRange(location: i, length: 1)
))
i += 2 // the escaped char can't itself start a new escape (even/odd `\\`)
} else {
i += 1
}
}
return spans
}
// MARK: - 3. Link family / inline LaTeX / extension spans
private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: ClaimedIndex, registry: ExtensionRegistry) -> [Span] {
var claimed = claimed
// A candidate overlapping a claimed span is rejected, except for spans
// wholly nested inside a Markdown link's label (#118). Only that case
// needs the full overlap list; everything else short-circuits on the
// first one.
func hasDisallowedClaimedOverlap(_ span: Span) -> Bool {
guard case .link(_, let textRange, _, _) = span else {
return claimed.overlaps(span.fullRange)
}
return claimed.overlapping(span.fullRange).contains { !rangeContains(textRange, $0) }
}
var spans: [Span] = []
var i = 0
while i < len {
if claimed.contains(i) { i += 1; continue }
if let span = matchClaimedSpan(ns, len, at: i, registry: registry),
!hasDisallowedClaimedOverlap(span) {
spans.append(span)
i = NSMaxRange(span.fullRange)
} else {
i += 1
}
}
return spans
}
private static func matchClaimedSpan(_ ns: NSString, _ len: Int, at i: Int, registry: ExtensionRegistry) -> Span? {
if let span = matchBuiltIn(ns, len, at: i) { return span }
// Extensions match after every built-in, in registration order. A
// built-in trigger that matched-and-FAILED (e.g. `$50$` rejected by
// the math heuristic) falls through here, so an extension sharing a
// built-in's first character is still reachable.
let c = ns.character(at: i)
for entry in registry.entries where entry.open.first == c {
if let span = matchExtensionSpan(ns, len, start: i, entry: entry) { return span }
}
return nil
}
/// The built-in constructs, tried exclusively in fixed precedence order —
/// the first branch whose trigger matches decides (nil = stays literal
/// for built-ins), exactly the pre-extension behavior.
private static func matchBuiltIn(_ ns: NSString, _ len: Int, at i: Int) -> Span? {
let c = ns.character(at: i)
let c1 = peek(ns, i + 1, len)
let c2 = peek(ns, i + 2, len)
if c == bang, c1 == lbracket, c2 == lbracket { return matchImageEmbed(ns, len, start: i) }
if c == lbracket, c1 == lbracket { return matchWikiLink(ns, len, start: i) }
if c == bang, c1 == lbracket { return matchImage(ns, len, start: i) }
if c == lbracket { return matchLink(ns, len, start: i) }
if c == dollar, c1 != dollar { return matchInlineLatex(ns, len, start: i) }
return nil
}
/// Generic scanner for extension-contributed delimited spans. Mirrors the
/// built-in `~~`/`==` semantics: the span opens at an exact `open` match,
/// closes at the FIRST exact `close` match on the same line, and a lone
/// occurrence of `close`'s first character inside the content aborts the
/// candidate (it stays literal).
private static func matchExtensionSpan(_ ns: NSString, _ len: Int, start i: Int, entry: ExtensionRegistry.Entry) -> Span? {
let open = entry.open, close = entry.close
guard !open.isEmpty, !close.isEmpty else { return nil }
guard matches(ns, len, at: i, chars: open) else { return nil }
if entry.syntax.rejectsOpenerRun, i > 0, ns.character(at: i - 1) == open[0] { return nil }
let contentStart = i + open.count
let closeFirst = close[0]
var k = contentStart
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == closeFirst {
guard matches(ns, len, at: k, chars: close) else { return nil }
if entry.syntax.requiresNonEmptyContent, k == contentStart { return nil }
if entry.syntax.rejectsCloserRun,
let after = peek(ns, k + close.count, len), after == close[close.count - 1] { return nil }
return .ext(
id: entry.id,
range: NSRange(location: i, length: (k + close.count) - i),
contentRange: NSRange(location: contentStart, length: k - contentStart),
markers: [NSRange(location: i, length: open.count), NSRange(location: k, length: close.count)],
parsesContent: entry.syntax.parsesContent
)
}
k += 1
}
return nil
}
/// Exact UTF-16 sequence match at `i`.
private static func matches(_ ns: NSString, _ len: Int, at i: Int, chars: [unichar]) -> Bool {
guard i + chars.count <= len else { return false }
for (offset, u) in chars.enumerated() where ns.character(at: i + offset) != u { return false }
return true
}
private static func peek(_ ns: NSString, _ idx: Int, _ len: Int) -> unichar? {
(idx >= 0 && idx < len) ? ns.character(at: idx) : nil
}
/// `![[ target ]]`
private static func matchImageEmbed(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let contentStart = i + 3
guard let close = closeDoubleBracket(ns, len, from: contentStart) else { return nil }
return .imageEmbed(
range: NSRange(location: i, length: (close + 2) - i),
target: NSRange(location: contentStart, length: close - contentStart),
markers: [NSRange(location: i, length: 3), NSRange(location: close, length: 2)]
)
}
/// `[[ name (| id)? ]]`
private static func matchWikiLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let contentStart = i + 2
var k = contentStart
var pipeIdx = -1
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == pipe, pipeIdx == -1 { pipeIdx = k }
if ch == rbracket {
guard peek(ns, k + 1, len) == rbracket else { return nil }
let range = NSRange(location: i, length: (k + 2) - i)
let markers = [NSRange(location: i, length: 2), NSRange(location: k, length: 2)]
if pipeIdx >= 0 {
return .wikiLink(range: range,
name: NSRange(location: contentStart, length: pipeIdx - contentStart),
id: NSRange(location: pipeIdx + 1, length: k - (pipeIdx + 1)),
markers: markers)
}
return .wikiLink(range: range,
name: NSRange(location: contentStart, length: k - contentStart),
id: nil, markers: markers)
}
k += 1
}
return nil
}
/// `![ alt ]( url )`
private static func matchImage(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let altStart = i + 2
guard let closeBracket = findChar(ns, len, from: altStart, char: rbracket),
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
let urlStart = closeBracket + 2
guard closeParen > urlStart else { return nil }
return .image(
range: NSRange(location: i, length: (closeParen + 1) - i),
alt: NSRange(location: altStart, length: closeBracket - altStart),
url: NSRange(location: urlStart, length: closeParen - urlStart),
markers: [
NSRange(location: i, length: 2),
NSRange(location: closeBracket, length: 1),
NSRange(location: closeBracket + 1, length: 1),
NSRange(location: closeParen, length: 1),
]
)
}
/// `[ text ]( url )`
private static func matchLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let textStart = i + 1
guard let closeBracket = findChar(ns, len, from: textStart, char: rbracket),
closeBracket > textStart,
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
let urlStart = closeBracket + 2
guard closeParen > urlStart else { return nil }
return .link(
range: NSRange(location: i, length: (closeParen + 1) - i),
textRange: NSRange(location: textStart, length: closeBracket - textStart),
url: NSRange(location: urlStart, length: closeParen - urlStart),
markers: [
NSRange(location: i, length: 1),
NSRange(location: closeBracket, length: 1),
NSRange(location: closeBracket + 1, length: 1),
NSRange(location: closeParen, length: 1),
]
)
}
/// `$ math $` — single dollars, content has no `$`, passes the math heuristic.
private static func matchInlineLatex(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
if i > 0, ns.character(at: i - 1) == dollar { return nil }
let contentStart = i + 1
var k = contentStart
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == dollar {
guard k > contentStart, peek(ns, k + 1, len) != dollar else { return nil }
let content = NSRange(location: contentStart, length: k - contentStart)
guard isInlineMathContent(ns.substring(with: content)) else { return nil }
return .inlineLatex(
range: NSRange(location: i, length: (k + 1) - i),
content: content,
markers: [NSRange(location: i, length: 1), NSRange(location: k, length: 1)]
)
}
k += 1
}
return nil
}
private static func closeDoubleBracket(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == rbracket { return peek(ns, k + 1, len) == rbracket ? k : nil }
k += 1
}
return nil
}
private static func findChar(_ ns: NSString, _ len: Int, from: Int, char: unichar) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == char { return k }
if ch == newline { return nil }
k += 1
}
return nil
}
private static func balancedParen(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var depth = 1
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == lparen { depth += 1 }
else if ch == rparen { depth -= 1; if depth == 0 { return k } }
k += 1
}
return nil
}
/// Rejects currency-looking and trivially short non-mathy `$…$` so prose isn't misread as math.
private static func isInlineMathContent(_ content: String) -> Bool {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if isCurrencyLike(trimmed) { return false }
let mathyMatches = mathyCharCount(trimmed)
if mathyMatches == 0 {
return trimmed.count <= 3 && isAllAsciiLetters(trimmed)
}
let tokenCount = trimmed.split(whereSeparator: { $0.isWhitespace }).count
if mathyMatches >= 3 { return tokenCount <= 120 }
if mathyMatches == 2 { return tokenCount <= 40 }
return tokenCount <= 6
}
/// A plain signed/thousands-grouped/decimal number (`50`, `1,000.50`, `-5`), regex-free, so currency isn't math.
private static func isCurrencyLike(_ s: String) -> Bool {
let u = Array(s.utf16)
let n = u.count
func digit(_ x: Int) -> Bool { x >= 0 && x < n && u[x] >= 0x30 && u[x] <= 0x39 }
var i = 0
if i < n, u[i] == 0x2B || u[i] == 0x2D { i += 1 } // + / -
guard digit(i) else { return false }
var sawDigit = false
while i < n {
if digit(i) { sawDigit = true; i += 1 }
else if u[i] == 0x2C, digit(i + 1), digit(i + 2), digit(i + 3), !digit(i + 4) {
i += 4 // a strict `,DDD` thousands group
} else { break }
}
guard sawDigit else { return false }
if i < n, u[i] == 0x2E { // optional `.DDD+`
i += 1
guard digit(i) else { return false }
while digit(i) { i += 1 }
}
return i == n
}
/// Count of "mathy" characters `\ ^ _ { } = + - * / < >`.
private static func mathyCharCount(_ s: String) -> Int {
let mathy: Set<unichar> = [0x5C, 0x5E, 0x5F, 0x7B, 0x7D, 0x3D, 0x2B, 0x2D, 0x2A, 0x2F, 0x3C, 0x3E]
var count = 0
for u in s.utf16 where mathy.contains(u) { count += 1 }
return count
}
/// True when `s` is one or more ASCII letters only.
private static func isAllAsciiLetters(_ s: String) -> Bool {
let u = Array(s.utf16)
guard !u.isEmpty else { return false }
for x in u where !((x >= 0x41 && x <= 0x5A) || (x >= 0x61 && x <= 0x7A)) { return false }
return true
}
// MARK: - 4. Emphasis (delimiter runs)
private struct DelimRun {
let char: unichar
let originalLength: Int
var leftEdge: Int
var rightEdge: Int
let canOpen: Bool
let canClose: Bool
let lineIdx: Int
var remaining: Int { rightEdge - leftEdge }
}
private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var runs = collectDelimiterRuns(ns, len: len, claimed: claimed)
guard !runs.isEmpty else { return [] }
var stack: [Int] = []
var spans: [Span] = []
for idx in runs.indices {
if runs[idx].canClose {
closeAgainstStack(closerIdx: idx, runs: &runs, stack: &stack, spans: &spans)
}
if runs[idx].canOpen && runs[idx].remaining > 0 {
stack.append(idx)
}
}
return spans
}
private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [DelimRun] {
var claimed = claimed
var runs: [DelimRun] = []
var lineIdx = 0
var i = 0
while i < len {
let c = ns.character(at: i)
if c == newline { lineIdx += 1; i += 1; continue }
guard c == asterisk || c == underscore, !claimed.contains(i) else { i += 1; continue }
var j = i
while j < len, ns.character(at: j) == c { j += 1 }
let before = i - 1, after = j
let beforeWs = isWhitespaceOrBoundary(before, ns, len)
let beforePunct = isAsciiPunctuation(before, ns, len)
let afterWs = isWhitespaceOrBoundary(after, ns, len)
let afterPunct = isAsciiPunctuation(after, ns, len)
let leftFlanking = !afterWs && (!afterPunct || beforeWs || beforePunct)
let rightFlanking = !beforeWs && (!beforePunct || afterWs || afterPunct)
let canOpen: Bool, canClose: Bool
if c == underscore {
canOpen = leftFlanking && (!rightFlanking || beforePunct)
canClose = rightFlanking && (!leftFlanking || afterPunct)
} else {
canOpen = leftFlanking
canClose = rightFlanking
}
runs.append(DelimRun(
char: c, originalLength: j - i, leftEdge: i, rightEdge: j,
canOpen: canOpen, canClose: canClose, lineIdx: lineIdx
))
i = j
}
return runs
}
private static func closeAgainstStack(
closerIdx: Int, runs: inout [DelimRun], stack: inout [Int], spans: inout [Span]
) {
var sp = stack.count - 1
while sp >= 0, runs[closerIdx].remaining > 0 {
let openerIdx = stack[sp]
if runs[openerIdx].char != runs[closerIdx].char { sp -= 1; continue }
if runs[openerIdx].lineIdx != runs[closerIdx].lineIdx {
stack.remove(at: sp); sp -= 1; continue
}
let avail = min(runs[openerIdx].remaining, runs[closerIdx].remaining)
if avail == 0 { stack.remove(at: sp); sp -= 1; continue }
let openerBoth = runs[openerIdx].canOpen && runs[openerIdx].canClose
let closerBoth = runs[closerIdx].canOpen && runs[closerIdx].canClose
if openerBoth || closerBoth {
let sum = runs[openerIdx].originalLength + runs[closerIdx].originalLength
let bothMod3 = runs[openerIdx].originalLength % 3 == 0 && runs[closerIdx].originalLength % 3 == 0
if sum % 3 == 0 && !bothMod3 { sp -= 1; continue }
}
let matchLen = avail >= 3 ? 3 : (avail >= 2 ? 2 : 1)
let openerMarkerStart = runs[openerIdx].rightEdge - matchLen
let closerMarkerStart = runs[closerIdx].leftEdge
let kind: EmphasisKind = matchLen == 3 ? .boldItalic : (matchLen == 2 ? .bold : .italic)
spans.append(.emphasis(
kind: kind,
range: NSRange(location: openerMarkerStart, length: (closerMarkerStart + matchLen) - openerMarkerStart),
open: NSRange(location: openerMarkerStart, length: matchLen),
close: NSRange(location: closerMarkerStart, length: matchLen)
))
runs[openerIdx].rightEdge -= matchLen
runs[closerIdx].leftEdge += matchLen
if runs[openerIdx].remaining == 0 { stack.remove(at: sp) }
sp -= 1
}
}
// MARK: - 5. Containment tree
private static func buildTree(region: NSRange, spans: [Span], ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
// Spans are non-overlapping or properly nested (each pass claims only
// inside regions no earlier pass took), so ordering by start ascending
// and length descending puts every span immediately after the one that
// contains it. Containment then falls out of a single ordered walk,
// instead of testing each span against every other span.
let ordered = spans
.filter { rangeContains(region, $0.fullRange) }
.sorted { a, b in
let (x, y) = (a.fullRange, b.fullRange)
return x.location == y.location ? x.length > y.length : x.location < y.location
}
var cursor = 0
return buildTree(region: region, ordered: ordered, cursor: &cursor, ns: ns, registry: registry)
}
/// Consumes spans from `cursor` for as long as they fall inside `region`,
/// leaving `cursor` on the first span that doesn't.
private static func buildTree(
region: NSRange, ordered: [Span], cursor: inout Int, ns: NSString, registry: ExtensionRegistry
) -> [InlineNode] {
var result: [InlineNode] = []
var textStart = region.location
while cursor < ordered.count {
let span = ordered[cursor]
let fr = span.fullRange
guard rangeContains(region, fr) else { break }
cursor += 1
if fr.location > textStart {
result.append(.text(NSRange(location: textStart, length: fr.location - textStart)))
}
switch span {
case .code(let range, let content):
result.append(.code(range: range, content: content))
case .emphasis(let kind, let range, let open, let close):
let content = NSRange(location: NSMaxRange(open), length: close.location - NSMaxRange(open))
result.append(.emphasis(kind, range: range, markers: [open, close],
children: buildTree(region: content, ordered: ordered,
cursor: &cursor, ns: ns, registry: registry)))
case .link(let range, let textRange, let url, let markers):
result.append(.link(range: range, textRange: textRange, url: url, markers: markers,
children: reparse(textRange, ns: ns, registry: registry)))
case .image(let range, let alt, let url, let markers):
result.append(.image(range: range, alt: alt, url: url, markers: markers))
case .wikiLink(let range, let name, let id, let markers):
result.append(.wikiLink(range: range, name: name, id: id, markers: markers))
case .imageEmbed(let range, let target, let markers):
result.append(.imageEmbed(range: range, target: target, markers: markers))
case .inlineLatex(let range, let content, let markers):
result.append(.inlineLatex(range: range, content: content, markers: markers))
case .escape(let range, let character, let marker):
result.append(.escape(range: range, character: character, marker: marker))
case .ext(let id, let range, let contentRange, let markers, let parsesContent):
result.append(.ext(ExtensionInlineNode(
extensionID: id, range: range, contentRange: contentRange, markers: markers,
children: parsesContent ? reparse(contentRange, ns: ns, registry: registry) : []
)))
}
// Every span but emphasis is opaque, so nothing should remain
// inside one. Skipping keeps the walk well-formed if that ever
// changes, rather than emitting a node past the cursor.
while cursor < ordered.count, rangeContains(fr, ordered[cursor].fullRange) { cursor += 1 }
textStart = NSMaxRange(fr)
}
if textStart < NSMaxRange(region) {
result.append(.text(NSRange(location: textStart, length: NSMaxRange(region) - textStart)))
}
return result
}
/// Recursively parse a sub-range's content, offset back to absolute coordinates.
private static func reparse(_ range: NSRange, ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location)
}
// MARK: - Helpers
private static func offsetNodes(_ nodes: [InlineNode], by delta: Int) -> [InlineNode] {
nodes.map { offset($0, by: delta) }
}
private static func offset(_ node: InlineNode, by d: Int) -> InlineNode {
func s(_ r: NSRange) -> NSRange { NSRange(location: r.location + d, length: r.length) }
switch node {
case .text(let r): return .text(s(r))
case .code(let r, let c): return .code(range: s(r), content: s(c))
case .emphasis(let k, let r, let m, let ch): return .emphasis(k, range: s(r), markers: m.map(s), children: offsetNodes(ch, by: d))
case .link(let r, let tr, let u, let m, let ch): return .link(range: s(r), textRange: s(tr), url: s(u), markers: m.map(s), children: offsetNodes(ch, by: d))
case .image(let r, let a, let u, let m): return .image(range: s(r), alt: s(a), url: s(u), markers: m.map(s))
case .wikiLink(let r, let n, let id, let m): return .wikiLink(range: s(r), name: s(n), id: id.map(s), markers: m.map(s))
case .imageEmbed(let r, let t, let m): return .imageEmbed(range: s(r), target: s(t), markers: m.map(s))
case .inlineLatex(let r, let c, let m): return .inlineLatex(range: s(r), content: s(c), markers: m.map(s))
case .escape(let r, let c, let m): return .escape(range: s(r), character: s(c), marker: s(m))
case .ext(let n): return .ext(ExtensionInlineNode(
extensionID: n.extensionID, range: s(n.range), contentRange: s(n.contentRange),
markers: n.markers.map(s), children: offsetNodes(n.children, by: d)))
}
}
private static func rangeContains(_ outer: NSRange, _ inner: NSRange) -> Bool {
inner.location >= outer.location && NSMaxRange(inner) <= NSMaxRange(outer)
}
private static func isWhitespaceOrBoundary(_ idx: Int, _ ns: NSString, _ len: Int) -> Bool {
guard idx >= 0, idx < len else { return true }
let c = ns.character(at: idx)
return c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D
}
private static func isAsciiPunctuation(_ idx: Int, _ ns: NSString, _ len: Int) -> Bool {
guard idx >= 0, idx < len else { return false }
return isAsciiPunctuationChar(ns.character(at: idx))
}
private static func isAsciiPunctuationChar(_ c: unichar) -> Bool {
(c >= 0x21 && c <= 0x2F) || (c >= 0x3A && c <= 0x40)
|| (c >= 0x5B && c <= 0x60) || (c >= 0x7B && c <= 0x7E)
}
/// A character is backslash-escaped when preceded by an odd run of `\`.
private static func isEscaped(_ idx: Int, _ ns: NSString) -> Bool {
var count = 0
var k = idx - 1
while k >= 0, ns.character(at: k) == backslash { count += 1; k -= 1 }
return count % 2 == 1
}
}