i7t5--edmund
715 行
35 KiB
Swift
715 行
35 KiB
Swift
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 1–5 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 1–6 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 1–5 (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" }
|
|
}
|
|
}
|