Files
i7t5--edmund/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift
T
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

550 lines
26 KiB
Swift

import Foundation
import Markdown
// MARK: - AST Walker
//
// SpanCollector walks the swift-markdown AST and records a Span for each inline
// construct it recognizes (headings, emphasis, code, links, tables, lists, …),
// translating swift-markdown SourceRanges into NSRanges over the original text.
// Constructs swift-markdown does not model (==highlight==, $math$, indented
// list items) are handled by the regex passes in +CustomParsers.
extension SyntaxHighlighter {
struct SpanCollector: MarkupWalker {
let source: String
private let lines: [String]
var spans: [Span] = []
/// Track nesting depth so we can detect bold-inside-italic (= boldItalic)
/// and avoid emitting duplicate spans. `insideEmphasis`/`insideStrong`
/// are internal (not private) so the inline visitors in
/// SyntaxHighlighter+WalkerInline can read and set them.
var insideEmphasis = false
var insideStrong = false
private var insideOrderedList = false
/// >0 while walking inside a *plain* block quote. Nested block-level
/// constructs (code blocks, headings, lists, tables, rules, nested
/// quotes) are left literal there — only inline emphasis renders.
/// Callouts are not walked at all (their bodies are rendered
/// recursively by the styling layer), so this never counts them.
private var plainQuoteDepth = 0
init(source: String) {
self.source = source
self.lines = source.components(separatedBy: "\n")
}
// MARK: - Source offset conversion
/// Converts a SourceLocation (1-indexed line, 1-indexed UTF-8 column)
/// to a UTF-16 offset suitable for NSRange.
func utf16Offset(for loc: SourceLocation) -> Int {
var utf8Offset = 0
for i in 0..<(loc.line - 1) {
if i < lines.count {
utf8Offset += lines[i].utf8.count + 1
}
}
utf8Offset += loc.column - 1
let utf8View = source.utf8
let targetIdx = utf8View.index(utf8View.startIndex,
offsetBy: min(utf8Offset, utf8View.count))
return source.utf16.distance(
from: source.utf16.startIndex,
to: String.Index(targetIdx, within: source.utf16) ?? source.utf16.endIndex
)
}
func nsRange(for range: SourceRange) -> NSRange {
let start = utf16Offset(for: range.lowerBound)
let end = utf16Offset(for: range.upperBound)
return NSRange(location: start, length: max(0, end - start))
}
/// Computes delimiter ranges by subtracting direct child ranges from parent.
func delimiterRanges(parent: NSRange, children: some Sequence<Markup>) -> [NSRange] {
// Collect child ranges (only direct children with source ranges)
var childRanges: [NSRange] = []
for child in children {
if let cr = child.range {
childRanges.append(nsRange(for: cr))
}
}
guard !childRanges.isEmpty else { return [] }
var delims: [NSRange] = []
let firstChild = childRanges[0]
if firstChild.location > parent.location {
delims.append(NSRange(location: parent.location,
length: firstChild.location - parent.location))
}
let lastChild = childRanges[childRanges.count - 1]
if lastChild.upperBound < parent.upperBound {
delims.append(NSRange(location: lastChild.upperBound,
length: parent.upperBound - lastChild.upperBound))
}
return delims
}
/// Trims delimiter ranges to the expected width for emphasis types.
/// When cmark includes unmatched delimiter characters in the emphasis
/// node's source range (e.g. `**here*` → italic with opening `**`),
/// this trims them so only the real delimiter chars are styled.
/// Returns adjusted (fullRange, delimiterRanges).
func trimEmphasisDelimiters(
expectedWidth: Int, full: NSRange, delims: [NSRange]
) -> (NSRange, [NSRange]) {
guard delims.count == 2 else { return (full, delims) }
var trimmedDelims = delims
var trimmedFull = full
// Opening delimiter: keep only the last `expectedWidth` chars
if delims[0].length > expectedWidth {
let excess = delims[0].length - expectedWidth
trimmedDelims[0] = NSRange(location: delims[0].location + excess,
length: expectedWidth)
trimmedFull = NSRange(location: trimmedFull.location + excess,
length: trimmedFull.length - excess)
}
// Closing delimiter: keep only the first `expectedWidth` chars
if delims[1].length > expectedWidth {
let excess = delims[1].length - expectedWidth
trimmedDelims[1] = NSRange(location: delims[1].location,
length: expectedWidth)
trimmedFull = NSRange(location: trimmedFull.location,
length: trimmedFull.length - excess)
}
return (trimmedFull, trimmedDelims)
}
/// Compute content range from full range and delimiter ranges.
func contentRange(full: NSRange, delims: [NSRange]) -> NSRange {
var start = full.location
var end = full.upperBound
if let first = delims.first, first.location == full.location {
start = first.upperBound
}
if let last = delims.last, last.upperBound == full.upperBound {
end = last.location
}
return NSRange(location: start, length: max(0, end - start))
}
// MARK: - Visitors
mutating func visitHeading(_ heading: Heading) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = heading.range else { return }
let full = nsRange(for: range)
let text = (source as NSString).substring(with: full)
// Setext heading (`Title\n===`, or `Foo\nbar\n===` per GFM Example
// 51 — content can span multiple lines): no `#` prefix. Everything
// up to the *last* line is the content; that last line is the
// underline delimiter (hidden when rendered, dimmed when active).
// The `\n`s stay untouched so the line structure survives.
if !text.drop(while: { $0 == " " }).hasPrefix("#") {
let nl = (text as NSString).range(of: "\n", options: .backwards)
guard nl.location != NSNotFound else { return } // setext is 2+ lines
spans.append(Span(
kind: .heading(heading.level),
fullRange: full,
contentRange: NSRange(location: full.location, length: nl.location),
delimiterRanges: [NSRange(location: full.location + nl.upperBound,
length: full.length - nl.upperBound)]
))
descendInto(heading) // inline children style at heading size
return
}
let delimLen = heading.level + 1
// cmark already recognizes and trims a valid optional closing
// sequence (GFM 4.2, e.g. `# foo ###`) out of `heading.range` —
// it can even trim `full` down to just the opening `#` run when
// the heading is otherwise empty (`## ##`), shorter than
// `delimLen`. Clamp so an empty-content heading doesn't push
// `cStart` past what `full` actually covers.
let openDelimLen = min(delimLen, full.length)
let cStart = full.location + openDelimLen
let cLen = max(0, full.length - openDelimLen)
var delimiterRanges = [NSRange(location: full.location, length: openDelimLen)]
// Whatever raw text follows `full` to the end of this
// single-line block is exactly what cmark trimmed as the
// closing sequence (its required separating whitespace, the
// `#` run, and any trailing whitespace) — hide it too.
let nsSource = source as NSString
let lineEnd = nsSource.length
if full.upperBound < lineEnd {
delimiterRanges.append(NSRange(location: full.upperBound, length: lineEnd - full.upperBound))
}
spans.append(Span(
kind: .heading(heading.level),
fullRange: full,
contentRange: NSRange(location: cStart, length: cLen),
delimiterRanges: delimiterRanges
))
// The heading span is appended first, so inner spans read the
// heading font as their context and keep its size.
descendInto(heading)
}
// MARK: - Code Blocks
mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = codeBlock.range else { return }
let full = nsRange(for: range)
guard full.length > 0 else { return }
let nsSource = source as NSString
let blockText = nsSource.substring(with: full) as NSString
// Indented code block: no fence, so no delimiters — every
// character (indentation included) is content. swift-markdown's
// node range starts *after* the first line's 4-space indent, so
// expand back over the leading whitespace or those characters
// would keep the body font and misalign the first line.
let opener = (blockText as String).drop(while: { $0 == " " })
if !(opener.hasPrefix("```") || opener.hasPrefix("~~~")) {
var start = full.location
while start > 0 {
let c = nsSource.character(at: start - 1)
guard c == 0x20 || c == 0x09 else { break }
start -= 1
}
let expanded = NSRange(location: start, length: full.upperBound - start)
spans.append(Span(
kind: .codeBlock(language: nil),
fullRange: expanded,
contentRange: expanded,
delimiterRanges: []
))
return
}
var delims: [NSRange] = []
var cStart = full.location
var cEnd = full.upperBound
let firstNL = blockText.range(of: "\n")
if firstNL.location != NSNotFound {
// Opening fence line (including newline)
let openLen = firstNL.location + 1
delims.append(NSRange(location: full.location, length: openLen))
cStart = full.location + openLen
// Look for closing fence line
let lastNL = blockText.range(of: "\n", options: .backwards)
if lastNL.location != NSNotFound && lastNL.location != firstNL.location {
let lastLineStart = lastNL.location + 1
if lastLineStart < blockText.length {
let lastLine = blockText.substring(from: lastLineStart)
.trimmingCharacters(in: .whitespaces)
if lastLine.hasPrefix("```") || lastLine.hasPrefix("~~~") {
let closeStart = full.location + lastNL.location
delims.append(NSRange(location: closeStart,
length: full.upperBound - closeStart))
cEnd = closeStart
}
}
}
} else {
// Single line (shouldn't normally happen with fenced code blocks)
delims.append(full)
cStart = full.upperBound
}
let content = NSRange(location: cStart, length: max(0, cEnd - cStart))
spans.append(Span(
kind: .codeBlock(language: codeBlock.language),
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
}
// MARK: - Block Quotes
mutating func visitBlockQuote(_ blockQuote: BlockQuote) {
guard let range = blockQuote.range else {
descendInto(blockQuote)
return
}
let full = nsRange(for: range)
let nsSource = source as NSString
let opensCallout = Self.quoteOpensCallout(firstLineOf: full, in: nsSource)
// A callout nested inside a plain quote is fully literal: emit no
// span and don't descend (matches showing `> [!note]` raw inside an
// outer quote — callouts render via their own recursive splice,
// which isn't set up to stack with an enclosing quote's bar). A
// nested *plain* quote is the one exception to "nested blocks stay
// literal": it gets its own span (below) so its marker hides and it
// draws its own bar, stacked with its ancestors'.
if plainQuoteDepth > 0 && opensCallout { return }
// `plainQuoteDepth` doubles as this quote's nesting depth (0 =
// outermost) — captured before incrementing for our own descent.
let depth = plainQuoteDepth
// Scan each line within the blockquote for its OWN "> " marker.
// swift-markdown's nested-BlockQuote `range` only skips ancestor
// markers on the *first* line (its start position lands right on
// this quote's own `>`) — every subsequent line is the raw source
// verbatim, ancestor markers and all. So each later line must peel
// exactly `depth` ancestor markers before this quote's own can be
// at hand.
//
// A no-marker line is CommonMark "lazy continuation". Which quote
// it belongs to depends on depth: at the OUTERMOST quote (depth 0)
// it continues *this* quote's paragraph — BlockParser already
// merged it into the block, so keep it in the span (the bar extends
// over it) with no marker to hide. Deeper in (depth > 0), a line
// that runs out of ancestor markers, or lacks this quote's own,
// belongs to a shallower ancestor's span — clip `fullRange` before
// it.
var delims: [NSRange] = []
var cursor = full.location
var clippedEnd = full.upperBound
var isFirstLine = true
while cursor < clippedEnd {
let remaining = NSRange(location: cursor, length: clippedEnd - cursor)
let nlRange = nsSource.range(of: "\n", options: [], range: remaining)
let lineEnd = nlRange.location != NSNotFound ? nlRange.location : clippedEnd
var p = cursor
var ranOut = false
for _ in 0..<(isFirstLine ? 0 : depth) {
guard let after = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) else {
ranOut = true
break
}
p = after
}
if ranOut {
// Ran out of ancestor markers: line belongs to a shallower
// ancestor (nested lazy continuation). Clip here.
clippedEnd = cursor
break
}
if let markerEnd = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) {
delims.append(NSRange(location: p, length: markerEnd - p))
} else if depth == 0 {
// Lazy continuation of the outermost quote: keep it in the
// span, no marker to hide, and keep scanning.
} else {
// Nested quote missing its own marker: belongs to a
// shallower ancestor. Clip here.
clippedEnd = cursor
break
}
cursor = nlRange.location != NSNotFound ? nlRange.location + 1 : clippedEnd
isFirstLine = false
}
let clippedFull = NSRange(location: full.location, length: clippedEnd - full.location)
let content = contentRange(full: clippedFull, delims: delims)
spans.append(Span(
kind: .blockquote(depth: depth),
fullRange: clippedFull,
contentRange: content,
delimiterRanges: delims
))
// A callout's body is rendered recursively by the styling layer
// (which strips the `>` prefixes and re-parses), so don't descend —
// doing so would emit nested spans over `>`-prefixed source ranges.
// A plain quote descends, but with a depth guard so only inline
// content and further nested plain quotes render (other nested
// blocks stay literal).
if opensCallout { return }
plainQuoteDepth += 1
descendInto(blockQuote)
plainQuoteDepth -= 1
}
/// Peels one `>` marker (optional leading spaces, `>`, optional single
/// trailing space) starting at `from`, returning the position right
/// after it — or `nil` if `[from, lineEnd)` doesn't start with `>`
/// (after skipping spaces). Used both to skip `depth` ancestor
/// markers and to locate this quote's own marker on a line (see
/// `visitBlockQuote`).
private static func peelOneMarker(_ source: NSString, from: Int, lineEnd: Int) -> Int? {
var q = from
while q < lineEnd, source.character(at: q) == 0x20 { q += 1 }
guard q < lineEnd, source.character(at: q) == 0x3E else { return nil }
q += 1
if q < lineEnd, source.character(at: q) == 0x20 { q += 1 }
return q
}
/// Whether the first line of a block quote (its source `range`) opens a
/// callout — `> [!type]` (known or unknown type). Mirrors
/// `BlockParser.quoteRunOpensCallout` over an NSRange.
private static func quoteOpensCallout(firstLineOf range: NSRange, in source: NSString) -> Bool {
let bound = NSRange(location: range.location, length: range.length)
let nl = source.range(of: "\n", options: [], range: bound)
let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location
let line = source.substring(with: NSRange(location: range.location,
length: lineEnd - range.location))
let trimmed = line.drop(while: { $0 == " " })
guard trimmed.first == ">" else { return false }
return Callout.parseMarker(String(trimmed.dropFirst())) != nil
}
// MARK: - Tables
mutating func visitTable(_ table: Table) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = table.range else {
descendInto(table)
return
}
let full = nsRange(for: range)
// Compute gaps between child rows (head/body) as delimiters
// (this captures the separator row between head and body)
var childRanges: [NSRange] = []
for child in table.children {
if let cr = child.range {
childRanges.append(nsRange(for: cr))
}
}
var delims: [NSRange] = []
if let first = childRanges.first, first.location > full.location {
delims.append(NSRange(location: full.location,
length: first.location - full.location))
}
for i in 0..<(childRanges.count - 1) {
let gapStart = childRanges[i].upperBound
let gapEnd = childRanges[i + 1].location
if gapEnd > gapStart {
delims.append(NSRange(location: gapStart,
length: gapEnd - gapStart))
}
}
if let last = childRanges.last, last.upperBound < full.upperBound {
delims.append(NSRange(location: last.upperBound,
length: full.upperBound - last.upperBound))
}
spans.append(Span(
kind: .table,
fullRange: full,
contentRange: full,
delimiterRanges: delims
))
}
// MARK: - Lists
mutating func visitOrderedList(_ orderedList: OrderedList) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
insideOrderedList = true
descendInto(orderedList)
insideOrderedList = false
}
mutating func visitListItem(_ listItem: ListItem) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = listItem.range else {
descendInto(listItem)
return
}
let full = nsRange(for: range)
var delims = delimiterRanges(parent: full, children: listItem.children)
// An empty list item (just "- " / "1. " / "- [ ] " with no content,
// e.g. a marker freshly created by pressing Return) has no child
// nodes, so `delimiterRanges` finds no marker and the whole item is
// treated as content. That collapses the marker's width to zero and
// pushes the freshly-typed marker a full slot too deep. Synthesize
// the marker delimiter from the leading text so the content begins
// after it, matching a non-empty item.
if delims.isEmpty, let markerLen = Self.leadingListMarkerLength(in: source, range: full) {
delims = [NSRange(location: full.location, length: markerLen)]
}
let content = contentRange(full: full, delims: delims)
// swift-markdown flags an item as a task list item via `checkbox`,
// but it reports the STATE by scanning the whole line for `[x]` — so
// an unchecked `- [ ]` whose body merely contains `[x]` (e.g. in a
// code span) is wrongly reported as checked. Take only the "is this a
// task item" signal from swift-markdown and read the actual state
// from the leading `[ ]`/`[x]` marker ourselves.
let checkbox: Span.Kind.CheckboxState?
if listItem.checkbox != nil {
let markerLen = max(0, content.location - full.location)
let marker = (source as NSString).substring(
with: NSRange(location: full.location, length: markerLen))
checkbox = Self.leadingCheckboxState(inMarker: marker)
?? (listItem.checkbox == .checked ? .checked : .unchecked)
} else {
checkbox = nil
}
spans.append(Span(
kind: .listItem(ordered: insideOrderedList, checkbox: checkbox),
fullRange: full,
contentRange: content,
delimiterRanges: delims
))
descendInto(listItem)
}
/// Matches a list item's leading marker (optional indentation +
/// `-`/`*`/`+` or `N.`, plus an optional `[ ]`/`[x]` checkbox), used to
/// recover the marker range for an empty item that has no child nodes.
private static let listMarkerRegex = try! NSRegularExpression(
pattern: #"^[ \t]*(?:[-*+][ \t]+(?:\[[ xX]\][ \t]*)?|\d+\.[ \t]+)"#)
/// Length (UTF-16) of the leading list marker within `range` of `source`,
/// or nil if the text there doesn't begin with a marker.
private static func leadingListMarkerLength(in source: String, range: NSRange) -> Int? {
let line = (source as NSString).substring(with: range)
let m = listMarkerRegex.firstMatch(
in: line, range: NSRange(location: 0, length: (line as NSString).length))
guard let m, m.range.location == 0, m.range.length > 0 else { return nil }
return m.range.length
}
/// Reads a task-list checkbox state from the item's leading marker text
/// (e.g. `"- [ ] "` → unchecked, `"1. [x] "` → checked) by inspecting the
/// character inside the first `[...]`. Returns nil if no bracket is found.
private static func leadingCheckboxState(inMarker marker: String)
-> Span.Kind.CheckboxState? {
let ns = marker as NSString
let open = ns.range(of: "[")
guard open.location != NSNotFound, open.upperBound < ns.length else { return nil }
switch ns.substring(with: NSRange(location: open.upperBound, length: 1)) {
case "x", "X": return .checked
case " ": return .unchecked
default: return nil
}
}
// MARK: - Thematic Break
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) {
if plainQuoteDepth > 0 { return } // literal inside a plain quote
guard let range = thematicBreak.range else { return }
let full = nsRange(for: range)
spans.append(Span(
kind: .thematicBreak,
fullRange: full,
contentRange: full,
delimiterRanges: [full]
))
}
}
}