Files
i7t5--edmund/Sources/EdmundCore/Parsing/SyntaxHighlighter.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

203 lines
9.5 KiB
Swift

import Foundation
import Markdown
/// Parses raw markdown using Apple's swift-markdown (cmark-gfm) and returns
/// spans identifying inline formatting with their delimiter and content ranges.
///
/// This ensures the active block's syntax highlighting is consistent with the
/// rendered (non-active) blocks, including mismatched-delimiter edge cases
/// like `**hi*` (treated as literal `*` + italic `hi`).
///
/// This file holds the public model (`Span`/`Kind`) and the `parse` entry
/// point. The heavy lifting lives in two siblings:
/// - SyntaxHighlighter+Walker.swift — the swift-markdown AST walker
/// - SyntaxHighlighter+CustomParsers.swift — regex passes for constructs the
/// AST doesn't model (==highlight==, $math$, indented list items)
public enum SyntaxHighlighter {
/// Inline HTML element names that *render* their formatting rather than show
/// as colored source. Single source of truth shared by the editor's
/// `parseHTMLTags` (Edit mode) and `HTMLRenderer.sanitizeInlineHTML` (Read
/// mode), so the two back-ends can't drift on which tags are allowed.
public static let htmlFormatTags: Set<String> = ["u", "kbd", "mark", "sub", "sup", "small"]
// MARK: - Model
public struct Span: Sendable {
public let kind: Kind
public let fullRange: NSRange
public let contentRange: NSRange
public let delimiterRanges: [NSRange]
public enum Kind: Equatable, Sendable {
case bold
case italic
case boldItalic
case code
case codeBlock(language: String?)
case strikethrough
case highlight
case heading(Int)
case link(destination: String)
/// A markdown `![alt](src)` image, or an HTML `<img src="…">` tag
/// (which may carry declared pixel dimensions).
case image(destination: String, width: Int?, height: Int?)
/// `depth` is the nesting level (0 = outermost, not itself inside
/// another plain quote). A `> > text` emits two spans, depth 0 and
/// depth 1, so each level's own marker hides and draws its own bar.
case blockquote(depth: Int)
case listItem(ordered: Bool, checkbox: CheckboxState? = nil)
case table
case thematicBreak
case lineBreak
case math(display: Bool)
/// An inline `[^id]` footnote reference.
case footnoteReference(id: String)
/// A `[^id]:` footnote definition marker at the start of a block.
case footnoteDefinition(id: String)
/// An Obsidian-style `%%comment%%` (hidden in reading view).
case comment
/// An Obsidian-style `[[target]]` internal link. `target` is the raw
/// `path#heading` portion (before any `|alias`); the visible display
/// text is the span's contentRange.
case wikilink(target: String)
/// A CommonMark backslash escape `\X`. The backslash is the delimiter
/// (hidden when inactive, dimmed when active); the escaped character
/// `X` renders literally as its content.
case escape
/// A single inline HTML tag (`<tag …>` or `</tag>`) shown only as
/// colored source: the `<`/`>`/`/` dim and the tag name colors red
/// (like math). Used for unknown / unpaired tags. `contentRange` is
/// the tag name.
case htmlTag
/// A whitelisted HTML formatting tag pair (`<u>…</u>`, etc.). When the
/// caret is outside, the open/close tags hide and the corresponding
/// attribute is applied to the inner `contentRange`; inside, the raw
/// tags show colored. `tag` is the lowercased element name; the two
/// delimiterRanges are the open and close tags.
case htmlFormat(tag: String)
public enum CheckboxState: Equatable, Sendable {
case checked, unchecked
}
}
}
// MARK: - Parsing
/// Returns all inline syntax spans found in `text`, ordered by position.
///
/// `linkDefinitions` (the document's collected `[label]: url` lines, from
/// `LinkDefinitionState.defsText`) is appended after the block so
/// swift-markdown can resolve GFM reference links whose definition lives in
/// another block; spans landing in the appended region are dropped. Empty
/// (the common case) means no append and no cost.
public static func parse(_ text: String, linkDefinitions: String = "") -> [Span] {
guard !text.isEmpty else { return [] }
// Walk the AST over the block plus any appended reference definitions,
// then keep only spans within the original block. Custom parsers below
// still run on `text`, so their offsets need no adjustment.
let textLen = (text as NSString).length
let parseText = linkDefinitions.isEmpty ? text : text + "\n\n" + linkDefinitions
let doc = Document(parsing: parseText, options: [.disableSmartOpts])
var walker = SpanCollector(source: parseText)
walker.visit(doc)
if !linkDefinitions.isEmpty {
walker.spans.removeAll { $0.fullRange.upperBound > textLen }
}
// ==highlight== is not supported by swift-markdown; parse with regex.
parseHighlight(text, into: &walker.spans)
// $$…$$ display math (the block is pre-merged by BlockParser), then
// $…$ inline math.
parseDisplayMath(text, into: &walker.spans)
parseMath(text, into: &walker.spans)
// Trailing backslash line break (single-line blocks only).
parseLineBreak(text, into: &walker.spans)
// Deeply indented list items (4+ spaces) that swift-markdown treats as code.
parseIndentedListItem(text, into: &walker.spans)
// [^id] footnote references and [^id]: definition markers.
parseFootnotes(text, into: &walker.spans)
// %%comments%% and [[wikilinks]]. Both are opaque: their inner text is
// a raw note / link target, not markdown — drop any span fully inside
// one so the content isn't re-styled.
parseComments(text, into: &walker.spans)
parseWikiLinks(text, into: &walker.spans)
// CommonMark backslash escapes (`\*`, `\$`, …). Runs after math/line-break
// so it can defer to them; before HTML tags so `\<` defers to the escape.
parseEscapes(text, into: &walker.spans)
// HTML `<!-- comments -->` share the `.comment` kind (and its opaque
// treatment below). Before parseHTMLTags so a tag inside a comment
// belongs to the comment, not the tag pass.
parseHTMLComments(text, into: &walker.spans)
// Inline HTML tags: whitelist pairs render (`<u>…</u>`); any other tag is
// colored source. Runs after escapes so an escaped `\<` isn't seen as a tag.
parseHTMLTags(text, into: &walker.spans)
// Bare www./http(s)/email autolinks (GFM extension). Last, so every
// guard (code, math, real links, HTML tags, …) is already in place.
parseAutolinks(text, into: &walker.spans)
let opaqueRanges: [NSRange] = walker.spans.compactMap { span in
switch span.kind {
case .comment, .wikilink: return span.fullRange
default: return nil
}
}
if !opaqueRanges.isEmpty {
walker.spans.removeAll { span in
switch span.kind {
case .comment, .wikilink: return false
default: break
}
return opaqueRanges.contains {
$0.location <= span.fullRange.location && $0.upperBound >= span.fullRange.upperBound
}
}
}
// A callout's body is rendered recursively by the styling layer (it
// strips the `>` prefixes and re-parses the inner markdown), so drop any
// other span the custom parsers placed inside a callout — keeping it
// would double-style the body. Plain block quotes are unaffected: their
// inline spans are intentionally kept.
let calloutRanges: [NSRange] = walker.spans.compactMap { span in
guard case .blockquote(_) = span.kind,
isCalloutFirstLine(of: span.fullRange, in: text) else { return nil }
return span.fullRange
}
if !calloutRanges.isEmpty {
walker.spans.removeAll { span in
if case .blockquote(_) = span.kind { return false }
return calloutRanges.contains {
$0.location <= span.fullRange.location && $0.upperBound >= span.fullRange.upperBound
}
}
}
return walker.spans.sorted { $0.fullRange.location < $1.fullRange.location }
}
/// Whether the first line of `range` in `text` opens a callout (`> [!type]`).
private static func isCalloutFirstLine(of range: NSRange, in text: String) -> Bool {
let ns = text as NSString
let nl = ns.range(of: "\n", options: [], range: range)
let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location
let line = ns.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
}
}