Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
another presentation.

### Changed
- The span-density regression tests assert on counted work instead of elapsed
time, so they run on CI again. `InlineParser.parse` can report an
`InlineParseCost` — claimed-range probes and containment tests — which is a
pure function of the input and therefore reads the same on a laptop and on a
contended runner. Linear measures 6.0x for 6x the spans; the pre-rewrite
pairwise containment measures 33.9x. The wall-clock assertions stay for
absolute numbers, still opt-in via `MDE_PERF=1`.
- An ordered list's painted number no longer reverts to the source digit under
the caret or a selection. The number is positional, so in a run written
`1./1./1.` a click inside a marker — or a select-all — flipped every number
Expand Down
107 changes: 83 additions & 24 deletions Sources/MarkdownEngine/Parser/InlineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ import Foundation

enum EmphasisKind: Equatable { case italic, bold, boldItalic }

/// What the claimed-range and containment scans did during one parse.
///
/// Both quantities were quadratic in the spans per region before the ordered
/// walk, and both are a pure function of the input — which is the point. The
/// span-density tests assert on these instead of on elapsed time, so they mean
/// the same thing on a laptop and on a loaded CI runner.
struct InlineParseCost: Equatable {
/// Claimed ranges inspected by `ClaimedIndex` across every pass.
var claimedProbes = 0
/// Span-in-region containment tests performed while building the tree.
var containmentTests = 0
}

/// A node in the inline AST.
indirect enum InlineNode: Equatable {
case text(NSRange)
Expand Down Expand Up @@ -94,15 +107,38 @@ enum InlineParser {
// MARK: - Entry point

static func parse(_ text: String, registry: ExtensionRegistry = .empty) -> [InlineNode] {
var cost = InlineParseCost()
return parse(text, registry: registry, cost: &cost)
}

/// Parse, reporting the work the claimed-range and containment scans did.
///
/// The counts are what the span-density tests assert on. A wall-clock ratio
/// looked like the natural measure and isn't portable — the same parser
/// reads 5.3x on an idle laptop and 10.9x on a contended CI runner, which
/// is above the pre-rewrite floor, so no threshold separates them. These
/// counts are a pure function of the input: identical everywhere, and
/// quadratic vs. linear differ by orders of magnitude rather than by 1.4x.
static func parse(_ text: String, registry: ExtensionRegistry = .empty,
cost: inout InlineParseCost) -> [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)

var escapeIndex = ClaimedIndex(claimed)
claimed += scanEscapes(ns, len: len, claimed: &escapeIndex)

var linkIndex = ClaimedIndex(claimed)
claimed += scanLinkFamily(ns, len: len, claimed: &linkIndex, registry: registry)

var emphasisIndex = ClaimedIndex(claimed)
let emphasis = resolveEmphasis(ns, len: len, claimed: &emphasisIndex)

cost.claimedProbes += escapeIndex.probes + linkIndex.probes + emphasisIndex.probes
return buildTree(region: NSRange(location: 0, length: len), spans: claimed + emphasis,
ns: ns, registry: registry, cost: &cost)
}

/// Parse the inline content of `range` within `ns`, returning nodes in absolute document coordinates.
Expand Down Expand Up @@ -154,23 +190,37 @@ enum InlineParser {
private let ranges: [NSRange]
private var cursor = 0

/// How many claimed ranges the queries have inspected. The scans that
/// used to be quadratic all ran through here, so this is the number
/// `InlineSpanDensityTests` holds to a linear budget. An `Int` bumped
/// beside comparisons the loop already does — cheap enough to leave in
/// release, where the alternative is a global the tests race on.
private(set) var probes = 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 }
while cursor < ranges.count, NSMaxRange(ranges[cursor]) <= idx {
cursor += 1
probes += 1
}
}

mutating func contains(_ idx: Int) -> Bool {
advance(to: idx)
return cursor < ranges.count && NSLocationInRange(idx, ranges[cursor])
guard cursor < ranges.count else { return false }
probes += 1
return NSLocationInRange(idx, ranges[cursor])
}

mutating func overlaps(_ range: NSRange) -> Bool {
advance(to: range.location)
return cursor < ranges.count && ranges[cursor].location < NSMaxRange(range)
guard cursor < ranges.count else { return false }
probes += 1
return ranges[cursor].location < NSMaxRange(range)
}

/// Every claimed range overlapping `range`. Peeks forward from the
Expand All @@ -183,6 +233,7 @@ enum InlineParser {
while k < ranges.count, ranges[k].location < NSMaxRange(range) {
if NSIntersectionRange(ranges[k], range).length > 0 { out.append(ranges[k]) }
k += 1
probes += 1
}
return out
}
Expand Down Expand Up @@ -236,8 +287,7 @@ enum InlineParser {

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

private static func scanEscapes(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var claimed = claimed
private static func scanEscapes(_ ns: NSString, len: Int, claimed: inout ClaimedIndex) -> [Span] {
var spans: [Span] = []
var i = 0
while i < len - 1 {
Expand All @@ -257,8 +307,7 @@ enum InlineParser {

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

private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: ClaimedIndex, registry: ExtensionRegistry) -> [Span] {
var claimed = claimed
private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: inout ClaimedIndex, registry: ExtensionRegistry) -> [Span] {
// 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
Expand Down Expand Up @@ -594,8 +643,8 @@ enum InlineParser {
var remaining: Int { rightEdge - leftEdge }
}

private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] {
var runs = collectDelimiterRuns(ns, len: len, claimed: claimed)
private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: inout ClaimedIndex) -> [Span] {
var runs = collectDelimiterRuns(ns, len: len, claimed: &claimed)
guard !runs.isEmpty else { return [] }
var stack: [Int] = []
var spans: [Span] = []
Expand All @@ -610,8 +659,7 @@ enum InlineParser {
return spans
}

private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [DelimRun] {
var claimed = claimed
private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: inout ClaimedIndex) -> [DelimRun] {
var runs: [DelimRun] = []
var lineIdx = 0
var i = 0
Expand Down Expand Up @@ -689,33 +737,38 @@ enum InlineParser {

// MARK: - 5. Containment tree

private static func buildTree(region: NSRange, spans: [Span], ns: NSString, registry: ExtensionRegistry) -> [InlineNode] {
private static func buildTree(region: NSRange, spans: [Span], ns: NSString,
registry: ExtensionRegistry, cost: inout InlineParseCost) -> [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.
cost.containmentTests += spans.count
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)
return buildTree(region: region, ordered: ordered, cursor: &cursor,
ns: ns, registry: registry, cost: &cost)
}

/// 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
region: NSRange, ordered: [Span], cursor: inout Int, ns: NSString,
registry: ExtensionRegistry, cost: inout InlineParseCost
) -> [InlineNode] {
var result: [InlineNode] = []
var textStart = region.location

while cursor < ordered.count {
let span = ordered[cursor]
let fr = span.fullRange
cost.containmentTests += 1
guard rangeContains(region, fr) else { break }
cursor += 1

Expand All @@ -729,10 +782,11 @@ enum InlineParser {
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)))
cursor: &cursor, ns: ns,
registry: registry, cost: &cost)))
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)))
children: reparse(textRange, ns: ns, registry: registry, cost: &cost)))
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):
Expand All @@ -746,13 +800,17 @@ enum InlineParser {
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) : []
children: parsesContent ? reparse(contentRange, ns: ns, registry: registry, cost: &cost) : []
)))
}
// 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 }
while cursor < ordered.count, rangeContains(fr, ordered[cursor].fullRange) {
cursor += 1
cost.containmentTests += 1
}
cost.containmentTests += 1
textStart = NSMaxRange(fr)
}
if textStart < NSMaxRange(region) {
Expand All @@ -762,8 +820,9 @@ enum InlineParser {
}

/// 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)
private static func reparse(_ range: NSRange, ns: NSString, registry: ExtensionRegistry,
cost: inout InlineParseCost) -> [InlineNode] {
offsetNodes(parse(ns.substring(with: range), registry: registry, cost: &cost), by: range.location)
}

// MARK: - Helpers
Expand Down
Loading
Loading