Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ regexes are gone, replaced by hand-written scanners and a real syntax tree.
escapes → link family (`![[…]]`, `[[…]]`, `![…](…)`, `[…](…)`, `~~…~~`,
`$…$`) → emphasis (`*`/`_` delimiter runs) → `buildTree`. Each pass claims
spans only in regions not already claimed, so there are never partial
overlaps and the tree is a clean containment tree.
overlaps and the tree is a clean containment tree. That invariant is also
what keeps the pass linear in span count: claimed ranges are consulted
through a cursor rather than rescanned, and `buildTree` derives containment
from a sort instead of comparing spans pairwise.
3. **`MarkdownAST` / `DocumentAST.parse`** combines the two into the semantic
document AST — `[BlockNode]`, each inline-bearing block carrying its parsed
`[InlineNode]` children in absolute document coordinates. `BlockNode`,
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
wherever a fill should read as a block.

### Changed
- **Inline parse cost is linear in the spans per region, not quadratic.** Every
pass after the first consulted the claimed ranges by scanning the whole array
— once per character in `scanEscapes` and `collectDelimiterRuns`, once per
candidate in `scanLinkFamily` — and `buildTree` decided containment by testing
each span against every other. The passes walk the string left to right and
claimed ranges never partially overlap, so a cursor over the sorted ranges answers both
questions in amortised constant time, and sorting spans by start ascending /
length descending turns containment into a single ordered walk. A paragraph of
240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the
parse instead of ~30x. Affects every claimed-span construct — code, escapes,
links, images, wiki links, inline LaTeX, emphasis, and extension spans. No
parse result changes.
- `==highlight==` fills the line box. AppKit paints `.backgroundColor` over
ascent + descent only, so the marker fell short of the line height by the
leading plus `paragraph.lineHeightExtraSpacing`, and a highlight that wrapped
Expand Down
171 changes: 116 additions & 55 deletions Sources/MarkdownEngine/Parser/InlineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
// 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

Expand Down Expand Up @@ -92,9 +99,9 @@ enum InlineParser {
guard len > 0 else { return [] }

var claimed = scanCodeSpans(ns, len: len)
claimed += scanEscapes(ns, len: len, claimed: claimed.map(\.fullRange))
claimed += scanLinkFamily(ns, len: len, claimed: claimed.map(\.fullRange), registry: registry)
let emphasis = resolveEmphasis(ns, len: len, claimedRanges: claimed.map(\.fullRange))
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)
}

Expand Down Expand Up @@ -125,16 +132,59 @@ enum InlineParser {
return r
}
}
/// Region whose interior can own already-collected child spans.
var containerContent: NSRange? {
switch self {
case .emphasis(_, _, let open, let close):
return NSRange(location: NSMaxRange(open), length: close.location - NSMaxRange(open))
case .link(_, let textRange, _, _):
return textRange
default:
return nil
}

/// 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
}
}

Expand Down Expand Up @@ -186,12 +236,12 @@ enum InlineParser {

// MARK: - 2. Backslash escapes (claimed → escaped chars are inert everywhere)

private static func scanEscapes(_ ns: NSString, len: Int, claimed: [NSRange]) -> [Span] {
func inClaimed(_ idx: Int) -> Bool { claimed.contains { NSLocationInRange(idx, $0) } }
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, !inClaimed(i), isAsciiPunctuationChar(ns.character(at: i + 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),
Expand All @@ -207,23 +257,24 @@ enum InlineParser {

// MARK: - 3. Link family / inline LaTeX / extension spans

private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: [NSRange], registry: ExtensionRegistry) -> [Span] {
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 {
let overlaps = claimed.filter {
NSIntersectionRange($0, span.fullRange).length > 0
}
guard overlaps.isEmpty == false else { return false }
guard case .link(_, let textRange, _, _) = span else { return true }
return overlaps.contains {
rangeContains(textRange, $0) == false
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(where: { NSLocationInRange(i, $0) }) { i += 1; continue }
if claimed.contains(i) { i += 1; continue }
if let span = matchClaimedSpan(ns, len, at: i, registry: registry),
hasDisallowedClaimedOverlap(span) == false {
!hasDisallowedClaimedOverlap(span) {
spans.append(span)
i = NSMaxRange(span.fullRange)
} else {
Expand Down Expand Up @@ -515,8 +566,8 @@ enum InlineParser {
var remaining: Int { rightEdge - leftEdge }
}

private static func resolveEmphasis(_ ns: NSString, len: Int, claimedRanges: [NSRange]) -> [Span] {
var runs = collectDelimiterRuns(ns, len: len, claimedRanges: claimedRanges)
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] = []
Expand All @@ -531,18 +582,15 @@ enum InlineParser {
return spans
}

private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimedRanges: [NSRange]) -> [DelimRun] {
func inClaimed(_ idx: Int) -> Bool {
for r in claimedRanges where NSLocationInRange(idx, r) { return true }
return false
}
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, !inClaimed(i) else { 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 }

Expand Down Expand Up @@ -614,33 +662,46 @@ enum InlineParser {
// MARK: - 5. Containment tree

private static func buildTree(region: NSRange, spans: [Span], ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
let inRegion = spans.filter { rangeContains(region, $0.fullRange) }

func isChild(_ s: Span) -> Bool {
for parent in inRegion {
guard !equalRange(parent.fullRange, s.fullRange), let content = parent.containerContent else { continue }
if rangeContains(content, s.fullRange) { return true }
// 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
}
return false
}
var cursor = 0
return buildTree(region: region, ordered: ordered, cursor: &cursor, ns: ns, registry: registry)
}

let top = inRegion.filter { !isChild($0) }.sorted { $0.fullRange.location < $1.fullRange.location }
/// 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 cursor = region.location
var textStart = region.location

for span in top {
while cursor < ordered.count {
let span = ordered[cursor]
let fr = span.fullRange
if fr.location > cursor {
result.append(.text(NSRange(location: cursor, length: fr.location - cursor)))
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))
let childSpans = inRegion.filter { rangeContains(content, $0.fullRange) && !equalRange($0.fullRange, fr) }
result.append(.emphasis(kind, range: range, markers: [open, close],
children: buildTree(region: content, spans: childSpans, ns: ns, registry: registry)))
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)))
Expand All @@ -660,10 +721,14 @@ enum InlineParser {
children: parsesContent ? reparse(contentRange, ns: ns, registry: registry) : []
)))
}
cursor = NSMaxRange(fr)
// 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 cursor < NSMaxRange(region) {
result.append(.text(NSRange(location: cursor, length: NSMaxRange(region) - cursor)))
if textStart < NSMaxRange(region) {
result.append(.text(NSRange(location: textStart, length: NSMaxRange(region) - textStart)))
}
return result
}
Expand Down Expand Up @@ -701,10 +766,6 @@ enum InlineParser {
inner.location >= outer.location && NSMaxRange(inner) <= NSMaxRange(outer)
}

private static func equalRange(_ a: NSRange, _ b: NSRange) -> Bool {
a.location == b.location && a.length == b.length
}

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)
Expand Down
Loading