///| Incremental parsing support
///| Allows partial re-parsing when document changes are localized
///| Describes a text edit operation
///|
/// Using #valtype to avoid heap allocation for this small struct
#valtype
pub(all) struct EditInfo {
offset : Int // Where the edit starts in old source
old_len : Int // How many chars were deleted
new_len : Int // How many chars were inserted
}
///|
/// Create an edit info for insertion
pub fn EditInfo::insert(offset : Int, len : Int) -> EditInfo {
{ offset, old_len: 0, new_len: len }
}
///|
/// Create an edit info for deletion
pub fn EditInfo::delete(offset : Int, len : Int) -> EditInfo {
{ offset, old_len: len, new_len: 0 }
}
///|
/// Create an edit info for replacement
pub fn EditInfo::replace(
offset : Int,
old_len : Int,
new_len : Int,
) -> EditInfo {
{ offset, old_len, new_len }
}
///|
/// Result of incremental parsing
pub(all) struct IncrementalResult {
document : Document
reused_before : Int // Number of blocks reused from before edit
reparsed : Int // Number of blocks that were re-parsed
reused_after : Int // Number of blocks reused from after edit
}
///|
/// Parse incrementally using edit hint
pub fn parse_incremental(
old_doc : Document,
old_source : String,
new_source : String,
edit : EditInfo,
wikilinks? : Bool = false,
) -> IncrementalResult {
let old_blocks = old_doc.children
// Find blocks affected by the edit
let (before_idx, after_idx) = find_affected_range(
old_blocks,
edit,
old_source.length(),
)
// Determine the source range to re-parse
let reparse_start = if before_idx > 0 {
old_blocks[before_idx - 1].get_span().to
} else {
match old_doc.frontmatter {
Some(fm) => fm.span.to
None => 0
}
}
let reparse_end_old = if after_idx < old_blocks.length() {
old_blocks[after_idx].get_span().from
} else {
old_source.length()
}
// Adjust reparse_end for new source (account for edit delta)
let delta = edit.new_len - edit.old_len
let reparse_end_new = reparse_end_old + delta
// Re-parse the region with the first reused block appended, so the parse
// itself can show whether the edit leaked past the region.
//
// A construct the edit leaves open - an unterminated fence is the usual one -
// swallows whatever follows, so the blocks we were about to reuse would not
// survive a full parse of the same text. When that happens the appended block
// is absorbed and no block starts at the old boundary any more; when nothing
// leaked, a block starts exactly there and everything past it is unchanged.
let region_end = clamp_int(reparse_end_new, reparse_start, new_source.length())
let has_guard_block = after_idx < old_blocks.length()
let guard_end = if has_guard_block {
clamp_int(
old_blocks[after_idx].get_span().to + delta,
region_end,
new_source.length(),
)
} else {
region_end
}
let reparse_region = new_source.unsafe_substring(
start=reparse_start,
end=guard_end,
)
// Re-parse just this region
let region_result = parse(reparse_region, wikilinks~)
// Where the appended block has to start for the reuse to be sound.
let boundary = region_end - reparse_start
let leaked = has_guard_block &&
!has_block_starting_at(region_result.document.children, boundary)
if leaked {
let full = parse(new_source, wikilinks~)
return {
document: full.document,
reused_before: 0,
reparsed: full.document.children.length(),
reused_after: 0,
}
}
// The appended block was re-parsed along with the region, so the reused tail
// starts after it.
let after_idx = if has_guard_block { after_idx + 1 } else { after_idx }
// Link reference definitions are document-scoped: a reused block can hold a
// reference that this edit resolves or breaks, and the definition itself may
// live outside the re-parsed region. Fall back to a full parse whenever any
// are involved.
if !old_doc.definitions.is_empty() || !region_result.definitions.is_empty() {
let full = parse(new_source, wikilinks~)
return {
document: full.document,
reused_before: 0,
reparsed: full.document.children.length(),
reused_after: 0,
}
}
let new_blocks_raw = region_result.document.children
// Adjust spans in re-parsed blocks
let new_blocks = adjust_spans(new_blocks_raw, reparse_start)
// Build final document
let final_blocks : Array[Block] = []
// 1. Reuse blocks before the edit
for i = 0; i < before_idx; i = i + 1 {
final_blocks.push(old_blocks[i])
}
let reused_before = before_idx
// 2. Add re-parsed blocks
for block in new_blocks {
final_blocks.push(block)
}
let reparsed = new_blocks.length()
// 3. Reuse blocks after the edit (with adjusted spans)
let reused_after_count = old_blocks.length() - after_idx
for i = after_idx; i < old_blocks.length(); i = i + 1 {
final_blocks.push(shift_block_span(old_blocks[i], delta))
}
let new_doc = Document::{
frontmatter: old_doc.frontmatter,
children: final_blocks,
definitions: old_doc.definitions,
span: Span::new(0, new_source.length()),
}
{
document: new_doc,
reused_before,
reparsed,
reused_after: reused_after_count,
}
}
///|
/// Is this block nothing but blank lines?
fn is_blank_lines(block : Block) -> Bool {
match block {
Block::BlankLines(..) => true
_ => false
}
}
///|
/// Is there a block starting exactly at `offset`?
///
/// Used to tell whether a re-parsed region still breaks where it used to. If
/// nothing starts at the old boundary, an open construct absorbed what followed
/// and the blocks past it can no longer be reused.
fn has_block_starting_at(blocks : Array[Block], offset : Int) -> Bool {
for block in blocks {
if block.get_span().from == offset {
return true
}
}
false
}
///| Find the range of blocks affected by the edit
///|
/// Returns (first_affected_idx, first_unaffected_after_idx)
///
/// Blocks are matched on their *extent*, not their span. `get_span()` covers
/// only the block proper: a fenced block's closing fence and the blank lines
/// after any block are trivia that sit outside it, so an edit there belongs to
/// no span at all and used to be attributed to whichever block came next -
/// which left the block it actually changed out of the re-parse. Giving each
/// block everything up to where the next one starts makes every offset in the
/// document belong to exactly one block.
///
/// Bounds are inclusive so that an insertion, which has `edit_start ==
/// edit_end`, affects the block starting at the caret as well as the one
/// ending there: typing "> " or "- " at the start of a paragraph turns that
/// paragraph into a quote or a list item.
fn find_affected_range(
blocks : Array[Block],
edit : EditInfo,
source_len : Int,
) -> (Int, Int) {
let n = blocks.length()
if n == 0 {
return (0, 0)
}
let edit_start = edit.offset
let edit_end = edit.offset + edit.old_len
let mut first = n
let mut last_plus_one = 0
for i = 0; i < n; i = i + 1 {
let extent_from = if i == 0 { 0 } else { blocks[i].get_span().from }
// Run on to the next block that carries content. Blank-line blocks sit in
// the gap where a fenced block's closing fence also lives, and letting one
// of them own that gap is what hid an edit on a closing fence from the
// block it actually belongs to.
let mut extent_to = source_len
for j = i + 1; j < n; j = j + 1 {
if !is_blank_lines(blocks[j]) {
extent_to = blocks[j].get_span().from
break
}
}
if edit_start <= extent_to && edit_end >= extent_from {
if first == n {
first = i
}
last_plus_one = i + 1
}
}
if first == n {
(n, n)
} else {
(first, last_plus_one)
}
}
///|
/// Adjust spans in blocks by adding an offset
fn adjust_spans(blocks : Array[Block], offset : Int) -> Array[Block] {
let result : Array[Block] = []
for block in blocks {
result.push(shift_block_span(block, offset))
}
result
}
///|
/// Shift list items and every nested block by the same document offset.
fn shift_list_item_spans(
items : Array[ListItem],
delta : Int,
) -> Array[ListItem] {
let result : Array[ListItem] = []
for item in items {
result.push({
..item,
children: adjust_spans(item.children, delta),
span: Span::new(item.span.from + delta, item.span.to + delta),
})
}
result
}
///|
/// Shift a block's span by delta
fn shift_block_span(block : Block, delta : Int) -> Block {
match block {
Block::ThematicBreak(
marker~,
count~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::ThematicBreak(
marker~,
count~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Heading(
level~,
style~,
children~,
closing_hashes~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::Heading(
level~,
style~,
children~,
closing_hashes~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Paragraph(children~, span~, leading_trivia~, trailing_trivia~) =>
Block::Paragraph(
children~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::FencedCode(
fence_marker~,
fence_length~,
info~,
code~,
indent~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::FencedCode(
fence_marker~,
fence_length~,
info~,
code~,
indent~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::MathBlock(
value~,
fence_length~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::MathBlock(
value~,
fence_length~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Directive(
name~,
meta~,
children~,
fence_length~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::Directive(
name~,
meta~,
children=adjust_spans(children, delta),
fence_length~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::DefinitionList(items~, span~, leading_trivia~, trailing_trivia~) =>
Block::DefinitionList(
items~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Attributed(block~, attributes~, span~) =>
Block::Attributed(
block=shift_block_span(block, delta),
attributes~,
span=Span::new(span.from + delta, span.to + delta),
)
Block::IndentedCode(code~, span~, leading_trivia~, trailing_trivia~) =>
Block::IndentedCode(
code~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Blockquote(children~, span~, leading_trivia~, trailing_trivia~) =>
Block::Blockquote(
children=adjust_spans(children, delta),
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Alert(kind~, children~, span~, leading_trivia~, trailing_trivia~) =>
Block::Alert(
kind~,
children=adjust_spans(children, delta),
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::BulletList(
marker~,
tight~,
items~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::BulletList(
marker~,
tight~,
items=shift_list_item_spans(items, delta),
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::OrderedList(
start~,
delimiter~,
tight~,
items~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::OrderedList(
start~,
delimiter~,
tight~,
items=shift_list_item_spans(items, delta),
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::Table(
header~,
alignments~,
rows~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::Table(
header~,
alignments~,
rows~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::HtmlBlock(html~, span~, leading_trivia~, trailing_trivia~) =>
Block::HtmlBlock(
html~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
Block::BlankLines(count~, span~) =>
Block::BlankLines(
count~,
span=Span::new(span.from + delta, span.to + delta),
)
Block::FootnoteDefinition(
label~,
children~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::FootnoteDefinition(
label~,
children~,
span=Span::new(span.from + delta, span.to + delta),
leading_trivia~,
trailing_trivia~,
)
}
}
///|
/// Helper to get span from a block
fn Block::get_span(self : Block) -> Span {
match self {
Block::ThematicBreak(span~, ..) => span
Block::Heading(span~, ..) => span
Block::Paragraph(span~, ..) => span
Block::FencedCode(span~, ..) => span
Block::MathBlock(span~, ..) => span
Block::Directive(span~, ..) => span
Block::DefinitionList(span~, ..) => span
Block::Attributed(span~, ..) => span
Block::IndentedCode(span~, ..) => span
Block::Blockquote(span~, ..) => span
Block::Alert(span~, ..) => span
Block::BulletList(span~, ..) => span
Block::OrderedList(span~, ..) => span
Block::Table(span~, ..) => span
Block::HtmlBlock(span~, ..) => span
Block::BlankLines(span~, ..) => span
Block::FootnoteDefinition(span~, ..) => span
}
}
///|
/// Clamp a value between min and max (local helper)
fn clamp_int(value : Int, min : Int, max : Int) -> Int {
if value < min {
min
} else if value > max {
max
} else {
value
}
}