///| 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)
// 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
// Extract the region to re-parse from new source
let reparse_region = new_source.unsafe_substring(
start=reparse_start,
end=clamp_int(reparse_end_new, reparse_start, new_source.length()),
)
// Re-parse just this region
let region_result = parse(reparse_region, wikilinks~)
// 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,
}
}
///| Find the range of blocks affected by the edit
///|
/// Returns (first_affected_idx, first_unaffected_after_idx)
fn find_affected_range(blocks : Array[Block], edit : EditInfo) -> (Int, Int) {
let edit_start = edit.offset
let edit_end = edit.offset + edit.old_len
let mut first_affected = blocks.length()
let mut first_unaffected = blocks.length()
for i, block in blocks {
let span = block.get_span()
// Check if block overlaps with edit
if span.to > edit_start && span.from < edit_end {
if first_affected == blocks.length() {
first_affected = i
}
first_unaffected = i + 1
} else if span.from >= edit_end {
// Block is entirely after the edit
if first_unaffected == blocks.length() || first_unaffected <= i {
first_unaffected = i
}
break
}
}
// If edit is in a blank region between blocks, we still need to re-parse
if first_affected == blocks.length() {
// Find where the edit falls
for i, block in blocks {
let span = block.get_span()
if span.from > edit_start {
first_affected = i
first_unaffected = i
break
}
if span.to >= edit_start {
first_affected = i
first_unaffected = i + 1
break
}
}
}
(first_affected, first_unaffected)
}
///|
/// 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
}
}