///| 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,
) -> 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)
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,
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 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::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::BulletList(
marker~,
tight~,
items~,
span~,
leading_trivia~,
trailing_trivia~
) =>
Block::BulletList(
marker~,
tight~,
items~, // TODO: adjust item spans
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~, // TODO: adjust item spans
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::IndentedCode(span~, ..) => span
Block::Blockquote(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
}
}