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

715 lines
35 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Markdown
/// Splits a document string into `Block`s and preserves block identity
/// across re-parses so the "active block" doesn't jump around.
///
/// Strategy:
/// 1. Split the raw string on single newlines (`\n`) to get paragraphs,
/// tagging each with its `BlockKind`.
/// 2. Compute each paragraph's `NSRange` within the full string.
/// 3. Preserve UUIDs positionally: blocks in the unchanged prefix and
/// suffix (by content equality from both ends) keep their previous IDs;
/// the changed window in between gets fresh ones. The window is also the
/// exact set of blocks whose styling may have changed — the dirty set
/// the recompose engine restyles.
public enum BlockParser {
public static func parse(_ text: String, previous: [Block] = []) -> [Block] {
parseWithDiff(text, previous: previous).blocks
}
/// Parses `text` and returns the blocks plus the changed window: the range
/// of indices (in the new list) outside the unchanged prefix/suffix.
public static func parseWithDiff(
_ text: String, previous: [Block] = []
) -> (blocks: [Block], changed: Range<Int>) {
let nsText = text as NSString
let paragraphs = splitParagraphs(text)
var blocks: [Block] = []
blocks.reserveCapacity(paragraphs.count)
var cursor = 0
for (para, kind) in paragraphs {
let length = (para as NSString).length
let range = NSRange(location: cursor, length: length)
blocks.append(Block(content: para, range: range, kind: kind))
// Advance past this paragraph.
cursor = range.upperBound
// Skip the single \n separator (if present).
if cursor < nsText.length && nsText.character(at: cursor) == UInt16(0x0A) {
cursor += 1
}
}
let changed = assignIdentity(old: previous, new: &blocks)
return (blocks, changed)
}
/// Re-parses only the lines affected by an edit, splicing the untouched
/// prefix and (shifted) suffix of the previous parse around the re-split
/// window — O(edit), not O(document).
///
/// The window starts one block before the first affected block: every
/// merge rule needs at most that much left context (quote-run adjacency,
/// the indented-code prevLine check, and the table-separator /
/// setext-underline lookaheads are single-step; an unclosed
/// fence/math opener further up would already contain the edit inside its
/// merged block). Downstream, the re-split continues until a produced
/// block boundary lands on an old block start at/after the edit's end —
/// from there the old parse is provably identical, because a block's
/// parse depends only on its own and following lines.
///
/// Returns nil when the inputs don't allow it (caller falls back to the
/// full parse).
public static func incrementalParse(
text: String,
old: [Block],
editedOldRange: NSRange,
delta: Int
) -> (blocks: [Block], changed: Range<Int>)? {
guard !old.isEmpty else { return nil }
let newLength = (text as NSString).length
let oldLength = newLength - delta
guard editedOldRange.location >= 0,
editedOldRange.upperBound <= oldLength else { return nil }
guard let firstAffected = blockIndex(in: old, forOffset: editedOldRange.location)
else { return nil }
var windowStartIndex = max(0, firstAffected - 1)
// A setext underline merges the whole run of single-line paragraph
// blocks above it into one heading (consumeBlock's multi-line setext
// scan), so the window must start before that entire run, not just
// one block back — keep walking back while the block at the window
// start is itself a `.paragraph` block. This is a superset of the
// one-block-back rule above (a non-paragraph block one back leaves
// the loop immediately), so every other merge rule stays covered.
while windowStartIndex > 0, case .paragraph = old[windowStartIndex].kind {
windowStartIndex -= 1
}
let windowStartOffset = old[windowStartIndex].range.location
let editEndNew = editedOldRange.upperBound + delta
var buf = LineBuffer(text, from: windowStartOffset)
var window: [Block] = []
var cursor = windowStartOffset // new-coords offset of the next block
var lineIndex = 0
var suffixStart: Int? = nil // old block index to splice from
// Backward context for the indented-code rule. The block before the
// window is untouched by the edit, so its old content is current.
var prevLine: String? = windowStartIndex > 0
? lastLine(of: old[windowStartIndex - 1].content) : nil
while true {
// Resync probe at this block boundary (not at the initial one).
if cursor > windowStartOffset && cursor >= editEndNew {
let oldOffset = cursor - delta
if let j = blockIndex(in: old, forOffset: oldOffset),
old[j].range.location == oldOffset,
oldOffset >= editedOldRange.upperBound {
// An indented-code-ish start depends on the line above it
// (blank vs not), which the edit may have changed — the
// old parse from here isn't provably identical. Bail to
// the full parse (rare and cheap).
if isIndentedCodeLine(firstLine(of: old[j].content)) { return nil }
// Same bail for HTML-block-ish starts: type 7 depends on the
// line above (which the edit may have changed), so an old
// block whose first line looks like ANY html-block opener
// isn't provably re-derivable — full parse (rare and cheap).
// prevLine: nil = most permissive check.
if htmlBlockStart(firstLine(of: old[j].content), prevLine: nil) != nil { return nil }
suffixStart = j
break
}
}
guard let (content, kind, next) = consumeBlock(&buf, at: lineIndex,
prevLine: prevLine) else {
break // end of document: the window runs to the end
}
let length = (content as NSString).length
window.append(Block(content: content,
range: NSRange(location: cursor, length: length),
kind: kind))
lineIndex = next
prevLine = lastLine(of: content)
cursor += length
// Skip the `\n` separator if another line follows; otherwise this
// was the document's final block — stop before re-probing (the
// boundary we'd probe is the block we just consumed).
if buf.line(at: next) != nil { cursor += 1 } else { break }
}
// Trim unchanged leading window blocks (the lookback block usually
// re-parses identically): preserve their identity and styling, and
// keep the changed window tight.
let spliceLimit = suffixStart ?? old.count
var keep = 0
while keep < window.count, windowStartIndex + keep < spliceLimit,
old[windowStartIndex + keep].content == window[keep].content {
window[keep].id = old[windowStartIndex + keep].id
window[keep].isStyled = old[windowStartIndex + keep].isStyled
keep += 1
}
var blocks = Array(old[0..<windowStartIndex])
blocks.append(contentsOf: window)
if let s = suffixStart {
for j in s..<old.count {
var b = old[j]
b.range.location += delta
blocks.append(b)
}
}
return (blocks, (windowStartIndex + keep) ..< (windowStartIndex + window.count))
}
/// Binary search over sorted, adjacent block ranges — the same
/// inclusive-upper-bound semantics as the editor's lookup (an offset at a
/// block's trailing separator maps to that block; past-end clamps to last).
private static func blockIndex(in blocks: [Block], forOffset offset: Int) -> Int? {
guard !blocks.isEmpty else { return nil }
var lo = 0
var hi = blocks.count - 1
while lo < hi {
let mid = (lo + hi) / 2
if blocks[mid].range.upperBound < offset { lo = mid + 1 } else { hi = mid }
}
return lo
}
/// Positional prefix/suffix diff: scans content equality from the front
/// and the back, copies old IDs onto the matches, and returns the changed
/// window in new-list indices. O(unchanged + changed); never matches a
/// block across the edit (no cross-document ID stealing).
static func assignIdentity(old: [Block], new: inout [Block]) -> Range<Int> {
var prefix = 0
while prefix < old.count && prefix < new.count
&& old[prefix].content == new[prefix].content {
new[prefix].id = old[prefix].id
new[prefix].isStyled = old[prefix].isStyled
prefix += 1
}
var suffix = 0
let maxSuffix = min(old.count, new.count) - prefix // overlap clamp
while suffix < maxSuffix
&& old[old.count - 1 - suffix].content == new[new.count - 1 - suffix].content {
new[new.count - 1 - suffix].id = old[old.count - 1 - suffix].id
new[new.count - 1 - suffix].isStyled = old[old.count - 1 - suffix].isStyled
suffix += 1
}
return prefix ..< (new.count - suffix)
}
// MARK: - Helpers
/// Lazily materializes the `\n`-separated line segments of a text starting
/// at a given UTF-16 offset. `components(separatedBy: "\n")` semantics:
/// number of segments = number of newlines + 1, so a trailing `\n` yields
/// a final empty segment. Both the full and incremental parses consume
/// lines through this buffer, so they cannot diverge.
struct LineBuffer {
private let ns: NSString
private(set) var lines: [String] = []
private var nextOffset: Int
private var exhausted = false
/// Setext-scan memo: a failed underline scan that terminated at line
/// `k` proves no scan starting before `k` can find an underline (the
/// intervening lines are all plain paragraphs and `k` isn't an
/// underline), so `consumeBlock` skips the scan for start lines below
/// this bound. Without it, a long blank-line-free paragraph run makes
/// the parse quadratic (each line re-scans to the run's end).
var noSetextUnderlineBefore = 0
init(_ text: String, from offset: Int = 0) {
self.ns = text as NSString
self.nextOffset = offset
}
/// The line at index `i` (buffer-relative), fetching as needed.
/// Returns nil past the end of the text.
mutating func line(at i: Int) -> String? {
while lines.count <= i && !exhausted {
fetchNext()
}
return i < lines.count ? lines[i] : nil
}
private mutating func fetchNext() {
guard !exhausted else { return }
let remaining = NSRange(location: nextOffset, length: ns.length - nextOffset)
let nl = ns.range(of: "\n", options: [], range: remaining)
if nl.location == NSNotFound {
lines.append(ns.substring(with: remaining))
exhausted = true
} else {
lines.append(ns.substring(
with: NSRange(location: nextOffset, length: nl.location - nextOffset)))
nextOffset = nl.upperBound
if nextOffset == ns.length {
// Trailing newline: one final empty segment.
lines.append("")
exhausted = true
}
}
}
}
/// Consumes one block starting at line `i`, merging multi-line constructs
/// (fences, display math, quote runs, tables, indented code runs, setext
/// headings). `prevLine` is the last line before `i` (nil at document
/// start) — the only backward context any rule uses: an indented code
/// block may start only after a blank line. Returns the block's
/// content/kind and the index of the line after it.
static func consumeBlock(_ buf: inout LineBuffer, at i: Int, prevLine: String?)
-> (content: String, kind: BlockKind, next: Int)? {
guard let first = buf.line(at: i) else { return nil }
// Detect opening code fence
if let fence = codeFenceInfo(first) {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j) {
merged.append(line)
j += 1
if isClosingFence(line, char: fence.char, count: fence.count) {
break
}
}
return (merged.joined(separator: "\n"), .fence, j)
}
// Detect display-math fence: a line starting with `$$`.
if let closedOnSameLine = displayMathClosedOnSameLine(first) {
if closedOnSameLine {
return (first, .mathDisplay, i + 1)
}
var merged = [first]
var j = i + 1
while let line = buf.line(at: j) {
merged.append(line)
j += 1
if line.contains("$$") { break }
}
return (merged.joined(separator: "\n"), .mathDisplay, j)
}
// Merge block-quote lines into one block (the editor's styling /
// activation unit per quote).
if isBlockquoteLine(first) {
// Callouts stay strict: only consecutive `>` lines. Lazy
// continuation is deliberately suppressed so a following `> [!type]`
// can't be pulled into a prior callout's paragraph (GFM ex. 228).
// Read mode matches this (HTMLRenderer.renderCallout splits at the
// first non-`>` line).
if quoteRunOpensCallout(first) {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j), isBlockquoteLine(line) {
merged.append(line)
j += 1
}
return (merged.joined(separator: "\n"), .quoteRun(isCallout: true), j)
}
// Plain block quote: honor CommonMark lazy continuation (a bare
// non-blank line after a quote paragraph joins the quote). The
// extent depends only on this line and following lines up to the
// next blank (a blank always ends a quote), so the parse stays
// forward-only and the incremental invariant holds.
if let (content, next) = mergePlainQuote(&buf, at: i) {
return (content, .quoteRun(isCallout: false), next)
}
// Fallback (candidate's first child wasn't a BlockQuote — shouldn't
// happen): strict `>`-run.
var merged = [first]
var j = i + 1
while let line = buf.line(at: j), isBlockquoteLine(line) {
merged.append(line)
j += 1
}
return (merged.joined(separator: "\n"), .quoteRun(isCallout: false), j)
}
// Detect table: header row followed by separator row with the same
// cell count (GFM: a mismatched delimiter row isn't a table at all).
if isTableRow(first), let second = buf.line(at: i + 1), isTableSeparator(second),
splitTableRow(first).count == splitTableRow(second).count {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j), isTableRow(line) || isTableSeparator(line) {
merged.append(line)
j += 1
}
return (merged.joined(separator: "\n"), .table, j)
}
// Indented code block (GFM): a run of lines indented 4+ spaces (or a
// tab), starting only after a blank line / document start so list
// continuation text isn't swallowed. Deeply indented list items keep
// priority (the indentedListRegex rescue — deliberate divergence).
// Interior blank lines belong to the block (GFM Examples 82/87); a
// run of blanks only ends the block if code doesn't resume after
// them — trailing blanks stay separate `.blank` blocks.
if isIndentedCodeLine(first), prevLine == nil || isBlankLine(prevLine!) {
var merged = [first]
var j = i + 1
while let line = buf.line(at: j) {
if isIndentedCodeLine(line) {
merged.append(line)
j += 1
continue
}
guard isBlankLine(line) else { break }
var k = j
while let blank = buf.line(at: k), isBlankLine(blank) { k += 1 }
guard let resumed = buf.line(at: k), isIndentedCodeLine(resumed) else { break }
for m in j..<k { merged.append(buf.line(at: m)!) }
j = k
}
return (merged.joined(separator: "\n"), .indentedCode, j)
}
// GFM §4.6 HTML block. Types 15 scan forward for the end-condition line
// (included; the end may already be on the start line; unterminated runs
// to EOF — spec: end of document closes it). Types 6/7 end BEFORE the
// first blank line (the blank stays its own `.blank` block). Type 7
// can't interrupt a paragraph — htmlBlockStart gates it on prevLine.
// Placement: after indented code (the ≤3-space guard keeps a 4-space-
// indented `<div>` as indented code); a `<`-line forming a valid table
// header+separator still becomes a table (deliberate divergence, tables
// win — this branch sits below the table branch); must precede the
// setext scan so an HTML start isn't swallowed as heading content.
// Edit mode shows the block as colored SOURCE (read mode renders it) —
// same split as GitHub's editor; rendered HTML in edit mode is
// impossible under the storage==rawSource invariant.
if let type = htmlBlockStart(first, prevLine: prevLine) {
var merged = [first]
var j = i + 1
switch type {
case .scriptPreStyle, .comment, .processing, .declaration, .cdata:
if !htmlBlockEnds(first, type: type) {
while let line = buf.line(at: j) {
merged.append(line)
j += 1
if htmlBlockEnds(line, type: type) { break }
}
}
case .blockTag, .completeTag:
while let line = buf.line(at: j), !isBlankLine(line) {
merged.append(line)
j += 1
}
}
return (merged.joined(separator: "\n"), .htmlBlock, j)
}
// Setext heading: a paragraph line underlined by `===` (h1) or `---`
// (h2). Consuming the underline here means a `---` after a paragraph
// is a heading underline (GFM setext wins over thematic break); only
// a `---` after a blank line / non-paragraph stays a rule.
//
// The underline can follow any number of plain paragraph lines, not
// just the first (GFM Example 51: "Foo\nbar\n---" is one heading
// whose content is "Foo\nbar") — so scan forward through a run of
// paragraph lines looking for the underline, checking each line for
// a setext underline *before* classifying it (an underline reads as
// `.paragraph`/`.thematicBreak` under `classifyLine`, and must
// terminate-and-merge the run rather than continue or break it). A
// table start also breaks the run, mirroring the table branch above
// so a table isn't swallowed as heading content. If no underline is
// found, fall through and return just `first` as a single-line
// paragraph block — Edmund deliberately keeps one block per
// paragraph line when there's no setext underline beneath it.
if case .paragraph = classifyLine(first), i >= buf.noSetextUnderlineBefore {
var j = i + 1
while let line = buf.line(at: j) {
if let level = setextUnderlineLevel(line) {
let merged = (i...j).map { buf.line(at: $0)! }
return (merged.joined(separator: "\n"), .heading(level: level), j + 1)
}
guard case .paragraph = classifyLine(line) else { break }
if isTableRow(line), let next = buf.line(at: j + 1), isTableSeparator(next),
splitTableRow(line).count == splitTableRow(next).count {
break
}
// An HTML block start (types 16 interrupt paragraphs; type 7 is
// gated on the previous line) terminates the run, mirroring the
// table break above — otherwise "Foo\n<div>\n---" would merge
// into a setext heading instead of paragraph + HTML block
// (GFM: the `---` belongs to the HTML block).
if htmlBlockStart(line, prevLine: buf.line(at: j - 1)) != nil { break }
j += 1
}
// No underline: everything up to the terminator at `j` is plain
// paragraph lines, so no scan starting before `j` can succeed.
buf.noSetextUnderlineBefore = j
}
return (first, classifyLine(first), i + 1)
}
/// Splits text into paragraphs on single newlines, merging fenced code blocks
/// and table rows into single multi-line blocks. Each paragraph is tagged
/// with its `BlockKind`.
private static func splitParagraphs(_ text: String) -> [(content: String, kind: BlockKind)] {
if text.isEmpty { return [("", .blank)] }
var buf = LineBuffer(text)
var result: [(content: String, kind: BlockKind)] = []
var i = 0
var prevLine: String? = nil
while let (content, kind, next) = consumeBlock(&buf, at: i, prevLine: prevLine) {
result.append((content, kind))
i = next
prevLine = lastLine(of: content)
}
return result
}
/// The text after the last `\n` (the whole string when single-line) —
/// the `prevLine` context for the block that follows.
private static func lastLine(of content: String) -> String {
if let nl = content.range(of: "\n", options: .backwards) {
return String(content[nl.upperBound...])
}
return content
}
/// The text before the first `\n` (the whole string when single-line).
private static func firstLine(of content: String) -> String {
if let nl = content.range(of: "\n") {
return String(content[..<nl.lowerBound])
}
return content
}
// MARK: - Line Classification
/// Classifies a single (non-merged) line. Advisory: see `BlockKind`.
private static func classifyLine(_ line: String) -> BlockKind {
if line.allSatisfy({ $0 == " " || $0 == "\t" }) { return .blank }
let trimmed = line.drop(while: { $0 == " " })
let hashes = trimmed.prefix(while: { $0 == "#" }).count
if (1...6).contains(hashes),
trimmed.count == hashes || trimmed.dropFirst(hashes).first == " " {
return .heading(level: hashes)
}
if isThematicBreakLine(line) { return .thematicBreak }
if isListLine(line) { return .listItem }
return .paragraph
}
/// Returns true if the line is a bullet (`- `, `* `, `+ `) or ordered
/// (`1. `, `1) `) list item, with any leading-space indent.
static func isListLine(_ line: String) -> Bool {
let trimmed = line.drop(while: { $0 == " " })
if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
return true
}
let digits = trimmed.prefix(while: { $0.isNumber })
guard !digits.isEmpty else { return false }
let rest = trimmed.dropFirst(digits.count)
return rest.hasPrefix(". ") || rest.hasPrefix(") ")
}
/// Returns the heading level if the line is a setext underline: ≤3 leading
/// spaces, then 1+ of the same character (`=` → level 1, `-` → level 2),
/// then only trailing spaces/tabs. Internal spaces (`- - -`) disqualify it,
/// so a spaced thematic break after a paragraph stays a rule.
private static func setextUnderlineLevel(_ line: String) -> Int? {
let trimmed = line.drop(while: { $0 == " " })
guard line.count - trimmed.count <= 3,
let first = trimmed.first, first == "=" || first == "-" else { return nil }
let run = trimmed.prefix(while: { $0 == first })
guard trimmed.dropFirst(run.count).allSatisfy({ $0 == " " || $0 == "\t" }) else { return nil }
return first == "=" ? 1 : 2
}
/// Returns true if the line opens/continues an indented code block: some
/// content indented by ≥4 spaces or a tab, that isn't a deeply indented
/// list item (the indentedListRegex rescue keeps priority).
static func isIndentedCodeLine(_ line: String) -> Bool {
guard !isBlankLine(line) else { return false }
let indent = line.prefix(while: { $0 == " " || $0 == "\t" })
guard indent.contains("\t") || indent.count >= 4 else { return false }
let range = NSRange(location: 0, length: (line as NSString).length)
return SyntaxHighlighter.indentedListRegex.firstMatch(in: line, range: range) == nil
}
private static func isBlankLine(_ line: String) -> Bool {
line.allSatisfy { $0 == " " || $0 == "\t" }
}
/// GFM §4.6 HTML block start conditions.
enum HTMLBlockType {
case scriptPreStyle // 1: <script|<pre|<style — ends ON the line containing </script>|</pre>|</style>
case comment // 2: <!-- — ends ON the line containing -->
case processing // 3: <? — ends ON the line containing ?>
case declaration // 4: <! + ASCII uppercase — ends ON the line containing >
case cdata // 5: <![CDATA[ — ends ON the line containing ]]>
case blockTag // 6: one of the 62 block tag names — ends BEFORE a blank line
case completeTag // 7: a complete lone tag — ends BEFORE a blank line; can't interrupt a paragraph
}
// Tag set pinned to CommonMark 0.29 / GFM (script|pre|style — no textarea,
// which later CommonMark added); deliberate, documented in ARCHITECTURE §10.
private static let htmlType1Regex = try! NSRegularExpression(
pattern: #"^ {0,3}<(?:script|pre|style)(?:[ \t>]|$)"#, options: [.caseInsensitive])
private static let htmlType6Regex = try! NSRegularExpression(
pattern: #"^ {0,3}</?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:[ \t]|/?>|$)"#,
options: [.caseInsensitive])
/// One COMPLETE open tag (full §6.10 attribute grammar — quoted values may
/// contain `>`) or closing tag, alone on the line. Check order 1→7 means a
/// normal `<script …>` is always claimed by type 1 first; the one leak is a
/// self-closing `<script/>` lone tag, which spec calls a paragraph but we
/// call type 7 — deliberate, harmless divergence (ARCHITECTURE §10).
private static let htmlType7Regex = try! NSRegularExpression(
pattern: #"^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*/?>|</[A-Za-z][A-Za-z0-9-]*\s*>)[ \t]*$"#)
/// GFM §4.6: the HTML-block type `line` opens, or nil. `prevLine` gates type 7
/// (it cannot interrupt a paragraph — same backward context as indented code).
/// O(line), and only `<`-prefixed lines get past the cheap guard.
static func htmlBlockStart(_ line: String, prevLine: String?) -> HTMLBlockType? {
let trimmed = line.drop(while: { $0 == " " })
guard line.count - trimmed.count <= 3, trimmed.first == "<" else { return nil }
let range = NSRange(location: 0, length: (line as NSString).length)
if htmlType1Regex.firstMatch(in: line, range: range) != nil { return .scriptPreStyle }
if trimmed.hasPrefix("<!--") { return .comment }
if trimmed.hasPrefix("<?") { return .processing }
if trimmed.hasPrefix("<![CDATA[") { return .cdata }
if trimmed.hasPrefix("<!"), let c = trimmed.dropFirst(2).first,
c.isASCII, c.isUppercase { return .declaration }
if htmlType6Regex.firstMatch(in: line, range: range) != nil { return .blockTag }
if prevLine == nil || isBlankLine(prevLine!),
htmlType7Regex.firstMatch(in: line, range: range) != nil { return .completeTag }
return nil
}
/// End condition for types 15 (the matching line is INCLUDED in the block).
private static func htmlBlockEnds(_ line: String, type: HTMLBlockType) -> Bool {
switch type {
case .scriptPreStyle:
let l = line.lowercased()
return l.contains("</script>") || l.contains("</pre>") || l.contains("</style>")
case .comment: return line.contains("-->")
case .processing: return line.contains("?>")
case .declaration: return line.contains(">")
case .cdata: return line.contains("]]>")
case .blockTag, .completeTag: return false
}
}
/// Returns true for a thematic break: 3+ of the same `-`/`*`/`_` character
/// and nothing else but spaces.
private static func isThematicBreakLine(_ line: String) -> Bool {
let stripped = line.filter { $0 != " " && $0 != "\t" }
guard stripped.count >= 3, let first = stripped.first,
first == "-" || first == "*" || first == "_" else { return false }
return stripped.allSatisfy { $0 == first }
}
/// Returns true if the first line of a quote run opens a callout
/// (`> [!type]`, known or unknown type).
private static func quoteRunOpensCallout(_ firstLine: String) -> Bool {
let trimmed = firstLine.drop(while: { $0 == " " })
guard trimmed.first == ">" else { return false }
return Callout.parseMarker(String(trimmed.dropFirst())) != nil
}
/// If the line (after optional leading whitespace) starts with `$$`, returns
/// whether a second `$$` also appears on the same line (a one-line `$$…$$`
/// block). Returns nil when the line is not a display-math opener.
private static func displayMathClosedOnSameLine(_ line: String) -> Bool? {
let trimmed = line.drop(while: { $0 == " " })
guard trimmed.hasPrefix("$$") else { return nil }
return trimmed.dropFirst(2).contains("$$")
}
/// Returns true if the line is a block-quote line (optional leading spaces
/// then `>`).
private static func isBlockquoteLine(_ line: String) -> Bool {
return line.drop(while: { $0 == " " }).first == ">"
}
/// Extent of a plain block quote starting at line `i`, honoring CommonMark
/// lazy continuation. Gathers the run of non-blank candidate lines (a blank
/// always ends a quote), parses them with swift-markdown, and truncates the
/// quote to the first `BlockQuote` node's line span — so the exact rules
/// (heading/list/fence/thematic-break interrupt; an empty `>` closes the
/// paragraph; a bare paragraph line continues it) come straight from the
/// CommonMark parser, matching read mode. Returns the quote content and the
/// index of the line after it, or nil when the candidate's first child
/// isn't a BlockQuote (caller falls back to the strict `>`-run).
private static func mergePlainQuote(_ buf: inout LineBuffer, at i: Int)
-> (content: String, next: Int)? {
var candidate: [String] = []
var j = i
while let line = buf.line(at: j), !isBlankLine(line) {
candidate.append(line)
j += 1
}
let doc = Document(parsing: candidate.joined(separator: "\n"),
options: [.disableSmartOpts])
guard doc.child(at: 0) is BlockQuote else { return nil }
// The candidate has no blank lines, so its top-level blocks are
// contiguous: the quote spans lines 1 ..< (second child's start line).
// swift-markdown line numbers are 1-based within the candidate, and
// candidate line 1 == buffer line `i`.
let quoteLineCount: Int
if doc.childCount > 1, let nextStart = doc.child(at: 1)?.range?.lowerBound.line {
quoteLineCount = min(max(nextStart - 1, 1), candidate.count)
} else {
quoteLineCount = candidate.count
}
let content = candidate[0..<quoteLineCount].joined(separator: "\n")
return (content, i + quoteLineCount)
}
/// Returns true if the line contains a pipe character (potential table row).
private static func isTableRow(_ line: String) -> Bool {
return line.contains("|")
}
/// Returns true if the line is a table separator (e.g., "| --- | --- |").
private static func isTableSeparator(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.contains("|") && trimmed.contains("---") else { return false }
return trimmed.allSatisfy { "|:- \t".contains($0) }
}
/// Returns fence info (character and count) if the line is an opening code fence.
private static func codeFenceInfo(_ line: String) -> (char: Character, count: Int)? {
let trimmed = line.drop(while: { $0 == " " })
let leadingSpaces = line.count - trimmed.count
guard leadingSpaces <= 3 else { return nil }
guard let first = trimmed.first, (first == "`" || first == "~") else { return nil }
let count = trimmed.prefix(while: { $0 == first }).count
guard count >= 3 else { return nil }
if first == "`" {
let afterFence = trimmed.dropFirst(count)
if afterFence.contains("`") { return nil }
}
return (first, count)
}
/// Returns true if the line is a valid closing fence for the given char/count.
private static func isClosingFence(_ line: String, char: Character, count: Int) -> Bool {
let trimmed = line.drop(while: { $0 == " " })
let leadingSpaces = line.count - trimmed.count
guard leadingSpaces <= 3 else { return false }
guard let first = trimmed.first, first == char else { return false }
let fenceCount = trimmed.prefix(while: { $0 == char }).count
guard fenceCount >= count else { return false }
let after = trimmed.dropFirst(fenceCount)
return after.allSatisfy { $0 == " " || $0 == "\t" }
}
}