feat: introduced region-based chunk targeting with @container, @prologue, @body, @epilogue selectors

- Added region-based chunk targeting with @container, @prologue, @body, @epilogue selectors for precise edit operations.
- Simplified chunk edit operations from 6 variants to 5 (replace, before, after, prepend, append) with unified region parameter.
- Removed checksum requirement from insert operations; only replace operations require #CRC suffix in selector.
- Added canonical indentation normalization with normalize_indent parameter for consistent tab-based rendering across file types.
- Refactored chunk selector format from separate crc/anchor fields to unified selector#CRC@region syntax for mutations.
- Added UnsupportedRegion status to chunk read operations for explicit handling of region availability per chunk type.
This commit is contained in:
can1357
2026-04-08 05:18:22 +02:00
parent d7261bcbeb
commit ca01e8a7a8
20 changed files with 1437 additions and 945 deletions
+8 -8
View File
@@ -406,8 +406,8 @@ pub fn build_notebook_tree_from_virtual(
start_byte: 0,
end_byte: virtual_source.len() as u32,
checksum_start_byte: 0,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum: root_checksum.clone(),
error: false,
indent: 0,
@@ -471,11 +471,11 @@ pub fn build_notebook_tree_from_virtual(
checksum_start_byte: sub_chunk
.checksum_start_byte
.saturating_add(region.content_start as u32),
body_start_byte: sub_chunk
.body_start_byte
prologue_end_byte: sub_chunk
.prologue_end_byte
.map(|b| b.saturating_add(region.content_start as u32)),
body_end_byte: sub_chunk
.body_end_byte
epilogue_start_byte: sub_chunk
.epilogue_start_byte
.map(|b| b.saturating_add(region.content_start as u32)),
checksum: sub_chunk.checksum,
error: sub_chunk.error,
@@ -518,8 +518,8 @@ pub fn build_notebook_tree_from_virtual(
start_byte: region.marker_start as u32,
end_byte: region.content_end as u32,
checksum_start_byte: region.content_start as u32,
body_start_byte: Some(region.content_start as u32),
body_end_byte: Some(region.content_end as u32),
prologue_end_byte: Some(region.content_start as u32),
epilogue_start_byte: Some(region.content_end as u32),
checksum: cell_checksum,
error: false,
indent: 0,
+2 -2
View File
@@ -266,8 +266,8 @@ fn translation_chunk(
start_byte,
end_byte,
checksum_start_byte: start_byte,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum,
error: false,
indent: 0,
+90
View File
@@ -270,6 +270,62 @@ pub fn child_by_field_or_kind<'tree>(
child_by_kind(node, kinds)
}
pub fn compute_body_inner_boundaries(
source: &str,
body_start: usize,
body_end: usize,
) -> (usize, usize) {
let bounded_start = body_start.min(source.len());
let bounded_end = body_end.min(source.len()).max(bounded_start);
let slice = &source[bounded_start..bounded_end];
let Some((first_non_ws_rel, first_non_ws)) = slice
.char_indices()
.find(|(_, ch)| !matches!(ch, ' ' | '\t' | '\n' | '\r'))
else {
return (bounded_start, bounded_end);
};
let Some((last_non_ws_rel, last_non_ws)) = slice
.char_indices()
.rev()
.find(|(_, ch)| !matches!(ch, ' ' | '\t' | '\n' | '\r'))
else {
return (bounded_start, bounded_end);
};
let has_delimiters = matches!((first_non_ws, last_non_ws), ('{', '}') | ('(', ')') | ('[', ']'));
if !has_delimiters {
let line_start = source[..bounded_start].rfind('\n').map_or(0, |pos| pos + 1);
let leading_indent = &source[line_start..bounded_start];
if !leading_indent.is_empty() && leading_indent.chars().all(|ch| matches!(ch, ' ' | '\t')) {
let mut inner_end = bounded_end;
let trailing = &source[bounded_end..];
if let Some(rel_newline) = trailing.find('\n') {
if trailing[..rel_newline]
.chars()
.all(|ch| matches!(ch, ' ' | '\t' | '\r'))
{
inner_end = bounded_end + rel_newline + 1;
}
} else if trailing.chars().all(|ch| matches!(ch, ' ' | '\t' | '\r')) {
inner_end = source.len();
}
return (line_start, inner_end);
}
return (bounded_start, bounded_end);
}
let mut inner_start = bounded_start + first_non_ws_rel + first_non_ws.len_utf8();
if source[inner_start..].starts_with("\r\n") {
inner_start += 2;
} else if source[inner_start..].starts_with('\n') {
inner_start += 1;
}
let inner_end = bounded_start + last_non_ws_rel;
(inner_start.min(bounded_end), inner_end.max(inner_start).min(bounded_end))
}
// ── Recurse helpers ──────────────────────────────────────────────────────
pub fn recurse_into<'tree>(
@@ -859,3 +915,37 @@ pub fn first_scalar_child(node: Node<'_>) -> Option<Node<'_>> {
)
})
}
#[cfg(test)]
mod tests {
use super::compute_body_inner_boundaries;
#[test]
fn compute_body_inner_boundaries_handles_brace_and_indent_bodies() {
let ts = "function main() {\n\treturn 1;\n}\n";
let ts_start = ts.find('{').expect("open brace");
let ts_end = ts.rfind('}').expect("close brace") + 1;
let (ts_inner_start, ts_inner_end) = compute_body_inner_boundaries(ts, ts_start, ts_end);
assert_eq!(&ts[ts_inner_start..ts_inner_end], "\treturn 1;\n");
let rust = "fn main() {\n println!(\"hi\");\n}\n";
let rust_start = rust.find('{').expect("open brace");
let rust_end = rust.rfind('}').expect("close brace") + 1;
let (rust_inner_start, rust_inner_end) =
compute_body_inner_boundaries(rust, rust_start, rust_end);
assert_eq!(&rust[rust_inner_start..rust_inner_end], " println!(\"hi\");\n");
let go = "func main() {\n\treturn\n}\n";
let go_start = go.find('{').expect("open brace");
let go_end = go.rfind('}').expect("close brace") + 1;
let (go_inner_start, go_inner_end) = compute_body_inner_boundaries(go, go_start, go_end);
assert_eq!(&go[go_inner_start..go_inner_end], "\treturn\n");
let py = "def main():\n return 1\n";
let py_body_start = py.find(" return 1").expect("body start");
let py_body_end = py_body_start + " return 1".len();
let (py_inner_start, py_inner_end) =
compute_body_inner_boundaries(py, py_body_start, py_body_end);
assert_eq!(&py[py_inner_start..py_inner_end], " return 1");
}
}
File diff suppressed because it is too large Load Diff
+67 -2
View File
@@ -74,6 +74,50 @@ pub fn count_indent_columns(whitespace: &str, space_step: usize) -> usize {
.sum()
}
pub fn normalize_to_tabs(line: &str, indent_char: char, indent_step: usize) -> String {
if indent_char == '\t' {
return line.to_owned();
}
let whitespace = leading_whitespace(line);
if whitespace.is_empty() {
return line.to_owned();
}
let step = indent_step.max(1);
let total_columns = count_indent_columns(whitespace, step);
let tabs = total_columns / step;
let remainder = total_columns % step;
format!("{}{}{}", "\t".repeat(tabs), " ".repeat(remainder), &line[whitespace.len()..])
}
pub fn denormalize_from_tabs(
line: &str,
file_indent_char: char,
file_indent_step: usize,
) -> String {
if file_indent_char != ' ' && file_indent_char != '\t' {
return line.to_owned();
}
let whitespace = leading_whitespace(line);
if whitespace.is_empty() {
return line.to_owned();
}
let step = file_indent_step.max(1);
let mut converted = String::with_capacity(whitespace.len() * step.max(1));
for ch in whitespace.chars() {
match ch {
'\t' if file_indent_char == '\t' => converted.push('\t'),
'\t' => converted.push_str(&file_indent_char.to_string().repeat(step)),
' ' => converted.push(' '),
_ => converted.push(ch),
}
}
format!("{converted}{}", &line[whitespace.len()..])
}
pub fn normalize_target_indent(target_indent: &str, sample_text: &str) -> String {
if target_indent.is_empty() {
return String::new();
@@ -481,8 +525,8 @@ mod tests {
start_byte: 0,
end_byte: 0,
checksum_start_byte: 0,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum: "ABCD".to_owned(),
error: false,
indent,
@@ -506,6 +550,27 @@ mod tests {
);
}
#[test]
fn canonical_indent_round_trips_common_profiles() {
let cases = [
(" value()", ' ', 4, "\tvalue()", " value()"),
(" value()", ' ', 3, "\t\tvalue()", " value()"),
(" value()", ' ', 2, "\tvalue()", " value()"),
("\tvalue()", '\t', 4, "\tvalue()", "\tvalue()"),
(" \t value()", ' ', 4, "\t value()", " value()"),
];
for (input, indent_char, indent_step, canonical, restored) in cases {
let normalized = normalize_to_tabs(input, indent_char, indent_step);
assert_eq!(normalized, canonical, "unexpected canonical indent for {input:?}");
assert_eq!(
denormalize_from_tabs(&normalized, indent_char, indent_step),
restored,
"unexpected restored indent for {input:?}"
);
}
}
#[test]
fn reindent_inserted_block_preserves_first_line_hanging_indent() {
let input = "call(\n alpha,\n beta,\n )";
+50 -12
View File
@@ -136,8 +136,8 @@ pub(crate) fn build_chunk_tree(source: &str, language: &str) -> Result<ChunkTree
start_byte: 0,
end_byte: source.len() as u32,
checksum_start_byte: 0,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum: root_checksum.clone(),
error: false,
indent: 0,
@@ -213,8 +213,8 @@ fn build_blank_line_tree(
start_byte: 0,
end_byte: source.len() as u32,
checksum_start_byte: 0,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum: checksum.clone(),
error: false,
indent: 0,
@@ -261,8 +261,8 @@ fn build_blank_line_tree(
start_byte: start_byte as u32,
end_byte: end_byte as u32,
checksum_start_byte: start_byte as u32,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum: chunk_checksum(
source
.as_bytes()
@@ -318,7 +318,9 @@ fn build_chunk(
.unwrap_or_default(),
);
let recurse = candidate.recurse;
let body_range = recurse.map(|r| (r.node.start_byte() as u32, r.node.end_byte() as u32));
let region_boundaries = recurse.map(|recurse| {
compute_body_inner_boundaries(source, recurse.node.start_byte(), recurse.node.end_byte())
});
let child_candidates = recurse
.map(|recurse| {
collect_children_for_context(recurse.node, recurse.context, source, classifier)
@@ -360,8 +362,8 @@ fn build_chunk(
start_byte: candidate.range_start_byte as u32,
end_byte: candidate.range_end_byte as u32,
checksum_start_byte: candidate.checksum_start_byte as u32,
body_start_byte: body_range.map(|(s, _)| s),
body_end_byte: body_range.map(|(_, e)| e),
prologue_end_byte: region_boundaries.map(|(start, _)| start as u32),
epilogue_start_byte: region_boundaries.map(|(_, end)| end as u32),
checksum,
error: candidate.error,
indent,
@@ -742,8 +744,8 @@ fn insert_preamble_chunk(
start_byte,
end_byte,
checksum_start_byte: start_byte,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum,
error: false,
indent: 0,
@@ -1590,12 +1592,16 @@ impl Config {
anchor_style: Some(ChunkAnchorStyle::Full),
absolute_line_range: None,
tab_replacement: Some(" ".to_string()),
normalize_indent: Some(true),
})
.unwrap_or_else(|err| panic!("selector {selector} should resolve: {err}"));
let resolved = result
.chunk
.expect("selector read should resolve a chunk target");
assert_eq!(resolved.selector, "fn_handleTerraform.try");
assert_eq!(
resolved.selector,
format!("fn_handleTerraform.try#{}@container", chunk.checksum)
);
}
}
@@ -1613,10 +1619,16 @@ impl Config {
anchor_style: Some(ChunkAnchorStyle::Full),
absolute_line_range: None,
tab_replacement: Some(" ".to_string()),
normalize_indent: Some(true),
})
.expect("listing should succeed");
assert!(result.text.contains("sample.ts chunks:"));
assert!(result.text.contains("fn_run#"));
assert!(
result
.text
.contains("regions: container, prologue, body, epilogue")
);
assert!(!result.text.contains("return 1"));
}
@@ -1639,6 +1651,7 @@ impl Config {
anchor_style: Some(ChunkAnchorStyle::Full),
absolute_line_range: None,
tab_replacement: Some(" ".to_string()),
normalize_indent: Some(true),
})
.expect("root read should succeed");
assert!(result.text.contains("[class_Worker.fn_run#"), "{}", result.text);
@@ -1665,6 +1678,7 @@ impl Config {
anchor_style: Some(ChunkAnchorStyle::Full),
absolute_line_range: None,
tab_replacement: Some(" ".to_string()),
normalize_indent: Some(true),
})
.expect("render_read should succeed");
@@ -1677,6 +1691,30 @@ impl Config {
assert!(text.contains("fn_loadSkills.try"), "{text}");
}
#[test]
fn read_reports_unsupported_region_distinctly() {
let source = "function run() {\n return 1;\n}\n";
let state = ChunkState::parse(source.to_string(), "typescript".to_string())
.expect("state should parse");
let result = state
.render_read(ReadRenderParams {
read_path: "sample.ts:fn_run@unknown".to_string(),
display_path: "sample.ts".to_string(),
language_tag: Some("ts".to_string()),
omit_checksum: false,
anchor_style: Some(ChunkAnchorStyle::Full),
absolute_line_range: None,
tab_replacement: Some(" ".to_string()),
normalize_indent: Some(true),
})
.expect("render_read should succeed");
let read_target = result.chunk.expect("should include read target");
assert_eq!(read_target.status, super::types::ChunkReadStatus::UnsupportedRegion);
assert_eq!(read_target.selector, "sample.ts:fn_run@unknown");
assert!(result.text.contains("Unknown chunk region"), "{}", result.text);
}
#[test]
fn go_struct_checksum_ignores_method_body_changes() {
let before = r"package main
+70 -9
View File
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use crate::{
chunk::{
indent::{detect_file_indent_char, detect_file_indent_step, normalize_to_tabs},
state::{ChunkStateInner, mask_chunk_display_source},
types::{
ChunkAnchorStyle, ChunkFocusMode, ChunkNode, ChunkTree, RenderParams, VisibleLineRange,
@@ -27,6 +28,17 @@ env_uint! {
static PREVIEW_TAIL_LINES: usize = "PI_CHUNK_PREVIEW_TAIL_LINES" or 4 => [1, usize::MAX];
}
fn normalize_rendered_line(
line: &str,
normalize_indent: Option<(char, usize)>,
tab_replacement: &str,
) -> String {
match normalize_indent {
Some((indent_char, indent_step)) => normalize_to_tabs(line, indent_char, indent_step),
None => line.replace('\t', tab_replacement),
}
}
pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
let tree = state.tree();
let lookup = build_lookup(tree);
@@ -43,6 +55,9 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
let preview_head_lines = *PREVIEW_HEAD_LINES;
let preview_tail_lines = *PREVIEW_TAIL_LINES;
let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" ");
let normalize_indent = params.normalize_indent.unwrap_or(false).then(|| {
(detect_file_indent_char(state.source(), tree), detect_file_indent_step(tree) as usize)
});
let anchor_style = params.anchor_style.unwrap_or_default();
let focus: Option<HashMap<&str, ChunkFocusMode>> = params
.focused_paths
@@ -57,6 +72,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
params.show_leaf_preview,
&source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -70,6 +86,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
params.show_leaf_preview,
&source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -90,6 +107,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String {
preview_head_lines,
preview_tail_lines,
tab_replacement,
normalize_indent,
focus,
inline_hunks: HashMap::new(),
};
@@ -247,10 +265,14 @@ fn chunk_body_anchor_indent(
source_lines: &[&str],
chunk: &ChunkNode,
tab_replacement: &str,
normalize_indent: Option<(char, usize)>,
) -> String {
source_lines
.get(chunk.start_line.saturating_sub(1) as usize)
.map_or(String::new(), |line| leading_whitespace(line).replace('\t', tab_replacement))
.map_or(String::new(), |line| {
leading_whitespace(&normalize_rendered_line(line, normalize_indent, tab_replacement))
.to_owned()
})
}
const fn chunk_anchor_label(chunk: &ChunkNode, style: ChunkAnchorStyle) -> &str {
@@ -299,6 +321,7 @@ fn build_leaf_entries(
source_lines: &[&str],
span: VisibleSpan,
tab_replacement: &str,
normalize_indent: Option<(char, usize)>,
full_display_threshold: usize,
preview_head_lines: usize,
preview_tail_lines: usize,
@@ -311,7 +334,9 @@ fn build_leaf_entries(
abs_line: line,
text: source_lines
.get(line.saturating_sub(1) as usize)
.map_or(String::new(), |text| text.replace('\t', tab_replacement)),
.map_or(String::new(), |text| {
normalize_rendered_line(text, normalize_indent, tab_replacement)
}),
})
.collect::<Vec<_>>();
@@ -393,6 +418,7 @@ fn for_each_rendered_source_line(
show_leaf_preview: bool,
source_lines: &[&str],
tab_replacement: &str,
normalize_indent: Option<(char, usize)>,
full_display_threshold: usize,
preview_head_lines: usize,
preview_tail_lines: usize,
@@ -415,6 +441,7 @@ fn for_each_rendered_source_line(
source_lines,
span,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -448,6 +475,7 @@ fn for_each_rendered_source_line(
show_leaf_preview,
source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -474,6 +502,7 @@ fn for_each_rendered_source_line(
show_leaf_preview,
source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -491,6 +520,7 @@ fn compute_rendered_line_count(
show_leaf_preview: bool,
source_lines: &[&str],
tab_replacement: &str,
normalize_indent: Option<(char, usize)>,
full_display_threshold: usize,
preview_head_lines: usize,
preview_tail_lines: usize,
@@ -523,6 +553,7 @@ fn compute_rendered_line_count(
show_leaf_preview,
source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -548,6 +579,7 @@ struct RenderCtx<'a> {
preview_head_lines: usize,
preview_tail_lines: usize,
tab_replacement: &'a str,
normalize_indent: Option<(char, usize)>,
focus: Option<HashMap<&'a str, ChunkFocusMode>>,
inline_hunks: HashMap<String, Vec<InlineHunk>>,
}
@@ -601,7 +633,9 @@ fn emit_line_gap(ctx: &mut RenderCtx<'_>, from: u32, to: u32) {
let text = ctx
.source_lines
.get(line.saturating_sub(1) as usize)
.map_or(String::new(), |text| text.replace('\t', ctx.tab_replacement));
.map_or(String::new(), |text| {
normalize_rendered_line(text, ctx.normalize_indent, ctx.tab_replacement)
});
push_code(ctx, line, &text);
}
}
@@ -611,6 +645,7 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, _chunk: &ChunkNode, span: VisibleSpan
ctx.source_lines,
span,
ctx.tab_replacement,
ctx.normalize_indent,
ctx.full_display_threshold,
ctx.preview_head_lines,
ctx.preview_tail_lines,
@@ -657,8 +692,12 @@ fn emit_chunk_subtree(
if options.between_top_level_definitions && depth == 0 && !options.is_first_top_level {
push_blank_meta(ctx);
}
let anchor_indent =
chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement);
let anchor_indent = chunk_body_anchor_indent(
ctx.source_lines,
chunk,
ctx.tab_replacement,
ctx.normalize_indent,
);
let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum);
let anchor_label = chunk_anchor_label(chunk, style);
push_meta(ctx, style.render(&anchor_indent, anchor_label, chunk.checksum.as_str()));
@@ -683,7 +722,12 @@ fn emit_chunk_subtree(
push_blank_meta(ctx);
}
if !chunk.path.is_empty() {
let anchor_indent = chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement);
let anchor_indent = chunk_body_anchor_indent(
ctx.source_lines,
chunk,
ctx.tab_replacement,
ctx.normalize_indent,
);
let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum);
let anchor_label = chunk_anchor_label(chunk, style);
push_meta(ctx, style.render(&anchor_indent, anchor_label, chunk.checksum.as_str()));
@@ -732,7 +776,12 @@ fn emit_chunk_subtree(
}
// Closing tag for chunks with children
if !chunk.path.is_empty() {
let anchor_indent = chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement);
let anchor_indent = chunk_body_anchor_indent(
ctx.source_lines,
chunk,
ctx.tab_replacement,
ctx.normalize_indent,
);
let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum);
let anchor_label = chunk_anchor_label(chunk, style);
push_meta(ctx, style.render_close(&anchor_indent, anchor_label, chunk.checksum.as_str()));
@@ -756,6 +805,7 @@ fn compute_num_width(
show_leaf_preview: bool,
source_lines: &[&str],
tab_replacement: &str,
normalize_indent: Option<(char, usize)>,
full_display_threshold: usize,
preview_head_lines: usize,
preview_tail_lines: usize,
@@ -782,6 +832,7 @@ fn compute_num_width(
show_leaf_preview,
source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -827,13 +878,17 @@ pub fn hunk_indent_for_chunk(
chunk_path: &str,
source: &str,
tab_replacement: &str,
normalize_indent: Option<(char, usize)>,
) -> String {
let source_lines: Vec<&str> = source.split('\n').collect();
let Some(chunk) = lookup.get(chunk_path) else {
return String::new();
};
let base = chunk_body_anchor_indent(&source_lines, chunk, tab_replacement);
format!("{base}{tab_replacement}")
let base = chunk_body_anchor_indent(&source_lines, chunk, tab_replacement, normalize_indent);
match normalize_indent {
Some(_) => format!("{base}\t"),
None => format!("{base}{tab_replacement}"),
}
}
/// Render a chunk tree with diff hunks inlined into their owning chunk blocks.
@@ -857,6 +912,9 @@ pub fn render_state_with_hunks(
let preview_head_lines = *PREVIEW_HEAD_LINES;
let preview_tail_lines = *PREVIEW_TAIL_LINES;
let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" ");
let normalize_indent = params.normalize_indent.unwrap_or(false).then(|| {
(detect_file_indent_char(state.source(), tree), detect_file_indent_step(tree) as usize)
});
let anchor_style = params.anchor_style.unwrap_or_default();
let focus: Option<HashMap<&str, ChunkFocusMode>> = params
.focused_paths
@@ -871,6 +929,7 @@ pub fn render_state_with_hunks(
params.show_leaf_preview,
&source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -884,6 +943,7 @@ pub fn render_state_with_hunks(
params.show_leaf_preview,
&source_lines,
tab_replacement,
normalize_indent,
full_display_threshold,
preview_head_lines,
preview_tail_lines,
@@ -904,6 +964,7 @@ pub fn render_state_with_hunks(
preview_head_lines,
preview_tail_lines,
tab_replacement,
normalize_indent,
focus,
inline_hunks,
};
+153 -83
View File
@@ -2,7 +2,7 @@ use std::{cmp::Ordering, collections::BTreeSet};
use crate::chunk::{
state::ChunkStateInner,
types::{ChunkNode, ChunkTree},
types::{ChunkNode, ChunkRegion, ChunkTree},
};
const CHUNK_NAME_PREFIXES: &[&str] =
@@ -10,26 +10,106 @@ const CHUNK_NAME_PREFIXES: &[&str] =
const CHECKSUM_ALPHABET: &str = "ZPMQVRWSNKTXJBYH";
pub struct ResolvedChunk<'a> {
pub chunk: &'a ChunkNode,
pub crc: Option<String>,
pub chunk: &'a ChunkNode,
pub crc: Option<String>,
pub region: ChunkRegion,
}
fn parse_region_name(value: &str) -> Option<ChunkRegion> {
match value.trim() {
"container" => Some(ChunkRegion::Container),
"prologue" => Some(ChunkRegion::Prologue),
"body" => Some(ChunkRegion::Body),
"epilogue" => Some(ChunkRegion::Epilogue),
_ => None,
}
}
pub fn split_region_suffix(selector: &str) -> (&str, Option<ChunkRegion>) {
let Some((prefix, suffix)) = selector.rsplit_once('@') else {
return (selector, None);
};
let Some(region) = parse_region_name(suffix.trim()) else {
return (selector, None);
};
(prefix.trim_end(), Some(region))
}
pub fn split_selector_crc_and_region(
selector: Option<&str>,
crc: Option<&str>,
region: Option<ChunkRegion>,
) -> Result<(Option<String>, Option<String>, ChunkRegion), String> {
let mut raw = selector
.map(str::trim)
.filter(|value| !matches!(*value, "" | "null" | "undefined"))
.unwrap_or_default()
.to_owned();
if let Some(index) = chunk_read_path_separator_index(&raw) {
raw = raw[index + 1..].to_owned();
}
let (without_region, parsed_region) = if raw.is_empty() {
(raw.as_str(), None)
} else {
let (prefix, parsed_region) = split_region_suffix(raw.as_str());
if parsed_region.is_some() {
(prefix, parsed_region)
} else if let Some((_, suffix)) = raw.rsplit_once('@') {
return Err(format!(
"Unknown chunk region \"{}\". Valid regions: container, prologue, body, epilogue.",
suffix.trim()
));
} else {
(raw.as_str(), None)
}
};
let mut selector_part = without_region.trim();
let embedded_crc = if let Some((prefix, suffix)) = selector_part.rsplit_once('#') {
if is_checksum_token(suffix.trim()) {
selector_part = prefix.trim_end();
sanitize_crc(Some(suffix))
} else {
None
}
} else if let Some(suffix) = selector_part.strip_prefix('#') {
if is_checksum_token(suffix.trim()) {
selector_part = "";
sanitize_crc(Some(suffix))
} else {
None
}
} else if is_checksum_token(selector_part) {
let cleaned = sanitize_crc(Some(selector_part));
selector_part = "";
cleaned
} else {
None
};
let cleaned_selector = if selector_part.is_empty() {
None
} else {
Some(selector_part.to_owned())
};
let cleaned_crc = sanitize_crc(crc).or(embedded_crc);
let region = region.or(parsed_region).unwrap_or(ChunkRegion::Container);
if let Some(cleaned_selector) = cleaned_selector.as_deref()
&& cleaned_crc.is_some()
&& looks_like_file_target(cleaned_selector)
{
return Ok((None, cleaned_crc, region));
}
Ok((cleaned_selector, cleaned_crc, region))
}
pub fn sanitize_chunk_selector(selector: Option<&str>) -> Option<String> {
let mut value = selector?.trim().to_owned();
if matches!(value.as_str(), "null" | "undefined") {
return None;
}
if let Some(index) = chunk_read_path_separator_index(&value) {
value = value[index + 1..].to_owned();
}
let value = strip_trailing_checksum(&value).trim();
if value.is_empty() {
None
} else {
Some(value.to_owned())
}
split_selector_crc_and_region(selector, None, None)
.ok()
.and_then(|(cleaned_selector, ..)| cleaned_selector)
}
pub fn sanitize_crc(crc: Option<&str>) -> Option<String> {
@@ -41,44 +121,12 @@ pub fn sanitize_crc(crc: Option<&str>) -> Option<String> {
}
}
pub fn split_selector_and_crc(
selector: Option<&str>,
crc: Option<&str>,
) -> (Option<String>, Option<String>) {
let cleaned_selector = sanitize_chunk_selector(selector);
let selector_crc = selector.and_then(extract_crc_token);
let cleaned_crc = sanitize_crc(crc).or(selector_crc);
if cleaned_selector.is_none()
&& let Some(selector) = selector
&& let Some(raw) = selector_after_read_path(selector)
&& let Some(raw_crc) = raw
.strip_prefix('#')
.or_else(|| is_checksum_token(raw).then_some(raw))
{
return (None, sanitize_crc(Some(raw_crc)).or(cleaned_crc));
}
if let Some(cleaned_selector) = cleaned_selector.as_deref()
&& cleaned_crc.is_some()
&& looks_like_file_target(cleaned_selector)
{
return (None, cleaned_crc);
}
if cleaned_selector.is_some() {
(cleaned_selector, cleaned_crc)
} else {
(None, cleaned_crc)
}
}
pub fn resolve_chunk_selector<'a>(
state: &'a ChunkStateInner,
selector: Option<&str>,
warnings: &mut Vec<String>,
) -> Result<&'a ChunkNode, String> {
let (cleaned_selector, cleaned_crc) = split_selector_and_crc(selector, None);
let (cleaned_selector, cleaned_crc, _) = split_selector_crc_and_region(selector, None, None)?;
resolve_chunk_selector_impl(state, cleaned_selector.as_deref(), cleaned_crc.as_deref(), warnings)
}
@@ -88,17 +136,18 @@ pub fn resolve_chunk_with_crc<'a>(
crc: Option<&str>,
warnings: &mut Vec<String>,
) -> Result<ResolvedChunk<'a>, String> {
let (cleaned_selector, cleaned_crc) = split_selector_and_crc(selector, crc);
let (cleaned_selector, cleaned_crc, region) =
split_selector_crc_and_region(selector, crc, None)?;
if cleaned_selector.is_none()
&& let Some(cleaned_crc) = cleaned_crc.clone()
{
let chunk = resolve_chunk_by_checksum(state, &cleaned_crc)?;
return Ok(ResolvedChunk { chunk, crc: Some(cleaned_crc) });
return Ok(ResolvedChunk { chunk, crc: Some(cleaned_crc), region });
}
let chunk = resolve_chunk_selector_impl(state, cleaned_selector.as_deref(), None, warnings)?;
Ok(ResolvedChunk { chunk, crc: cleaned_crc })
Ok(ResolvedChunk { chunk, crc: cleaned_crc, region })
}
pub fn resolve_chunk_by_checksum<'a>(
@@ -131,6 +180,54 @@ fn root_chunk(state: &ChunkStateInner) -> Result<&ChunkNode, String> {
.ok_or_else(|| "Chunk tree is missing the root chunk".to_owned())
}
pub const fn chunk_supports_region(chunk: &ChunkNode, region: ChunkRegion) -> bool {
match region {
ChunkRegion::Container => true,
ChunkRegion::Prologue | ChunkRegion::Body | ChunkRegion::Epilogue => {
chunk.prologue_end_byte.is_some() && chunk.epilogue_start_byte.is_some()
},
}
}
pub fn chunk_region_range(
chunk: &ChunkNode,
region: ChunkRegion,
) -> Result<(usize, usize), String> {
match region {
ChunkRegion::Container => Ok((chunk.start_byte as usize, chunk.end_byte as usize)),
ChunkRegion::Prologue => Ok((
chunk.start_byte as usize,
chunk
.prologue_end_byte
.ok_or_else(|| format!("Chunk \"{}\" does not support @prologue.", chunk.path))?
as usize,
)),
ChunkRegion::Body => Ok((
chunk
.prologue_end_byte
.ok_or_else(|| format!("Chunk \"{}\" does not support @body.", chunk.path))? as usize,
chunk
.epilogue_start_byte
.ok_or_else(|| format!("Chunk \"{}\" does not support @body.", chunk.path))? as usize,
)),
ChunkRegion::Epilogue => Ok((
chunk
.epilogue_start_byte
.ok_or_else(|| format!("Chunk \"{}\" does not support @epilogue.", chunk.path))?
as usize,
chunk.end_byte as usize,
)),
}
}
pub fn format_region_ref(chunk: &ChunkNode, region: ChunkRegion) -> String {
if chunk.path.is_empty() {
format!("<root>#{}@{}", chunk.checksum, region.as_str())
} else {
format!("{}#{}@{}", chunk.path, chunk.checksum, region.as_str())
}
}
fn resolve_chunk_selector_impl<'a>(
state: &'a ChunkStateInner,
selector: Option<&str>,
@@ -537,33 +634,6 @@ fn is_line_number_selector(selector: &str) -> bool {
!end.is_empty() && end.chars().all(|ch| ch.is_ascii_digit())
}
fn strip_trailing_checksum(value: &str) -> &str {
let Some((prefix, suffix)) = value.rsplit_once('#') else {
return value;
};
if is_checksum_token(suffix) {
prefix
} else {
value
}
}
fn extract_crc_token(value: &str) -> Option<String> {
let raw = selector_after_read_path(value)?;
let token = raw
.rsplit_once('#')
.map(|(_, suffix)| suffix)
.or_else(|| raw.strip_prefix('#'))
.or_else(|| is_checksum_token(raw).then_some(raw))?;
sanitize_crc(Some(token))
}
fn selector_after_read_path(value: &str) -> Option<&str> {
chunk_read_path_separator_index(value)
.map(|index| &value[index + 1..])
.or(Some(value))
}
fn is_checksum_token(value: &str) -> bool {
value.len() == 4
&& value
@@ -615,8 +685,8 @@ mod tests {
start_byte: 0,
end_byte: 0,
checksum_start_byte: 0,
body_start_byte: None,
body_end_byte: None,
prologue_end_byte: None,
epilogue_start_byte: None,
checksum: checksum.to_owned(),
error: false,
indent: 0,
+109 -18
View File
@@ -9,11 +9,15 @@ use regex::Regex;
use super::{
build_chunk_tree,
resolve::{resolve_chunk_selector, resolve_chunk_with_crc, split_selector_and_crc},
indent::{detect_file_indent_char, detect_file_indent_step, normalize_to_tabs},
resolve::{
chunk_region_range, chunk_supports_region, format_region_ref, resolve_chunk_selector,
resolve_chunk_with_crc, split_selector_crc_and_region,
},
};
use crate::chunk::types::{
ChunkInfo, ChunkNode, ChunkReadStatus, ChunkReadTarget, ChunkTree, EditParams, EditResult,
ReadRenderParams, ReadResult, RenderParams, VisibleLineRange,
ChunkInfo, ChunkNode, ChunkReadStatus, ChunkReadTarget, ChunkRegion, ChunkTree, EditParams,
EditResult, ReadRenderParams, ReadResult, RenderParams, VisibleLineRange,
};
const LINE_RANGE_SELECTOR_RE: &str = r"^L(\d+)(?:-L?(\d+))?$";
@@ -321,7 +325,19 @@ impl ChunkState {
/// errors.
#[napi(js_name = "renderRead")]
pub fn render_read(&self, params: ReadRenderParams) -> Result<ReadResult> {
let ParsedChunkReadPath { selector, crc } = parse_chunk_read_path(params.read_path.as_str());
let ParsedChunkReadPath { selector, crc, region } =
match parse_chunk_read_path(params.read_path.as_str()) {
Ok(parsed) => parsed,
Err(err) => {
return Ok(ReadResult {
text: format!("{}\n\n{}", params.display_path, err),
chunk: Some(ChunkReadTarget {
status: ChunkReadStatus::UnsupportedRegion,
selector: params.read_path.clone(),
}),
});
},
};
let visible_range = selector.as_deref().and_then(parse_visible_line_range);
let Some(root) = self.inner.root() else {
return Ok(ReadResult {
@@ -369,12 +385,16 @@ impl ChunkState {
anchor_style: params.anchor_style,
show_leaf_preview: true,
tab_replacement: params.tab_replacement,
normalize_indent: params.normalize_indent,
focused_paths: None,
});
return Ok(ReadResult { text: format!("{notice}\n\n{text}"), chunk: None });
}
if selector.as_deref().is_none_or(str::is_empty) && crc.is_none() {
if selector.as_deref().is_none_or(str::is_empty)
&& crc.is_none()
&& region == ChunkRegion::Container
{
return Ok(ReadResult {
text: self.render(RenderParams {
chunk_path: Some(root.path.clone()),
@@ -386,6 +406,7 @@ impl ChunkState {
anchor_style: params.anchor_style,
show_leaf_preview: true,
tab_replacement: params.tab_replacement,
normalize_indent: params.normalize_indent,
focused_paths: None,
}),
chunk: None,
@@ -395,9 +416,14 @@ impl ChunkState {
if selector.as_deref() == Some("?") {
let mut lines = vec![format!("{} chunks:", params.display_path)];
for chunk in self.inner.chunks().filter(|chunk| !chunk.path.is_empty()) {
let supported_regions = if chunk_supports_region(chunk, ChunkRegion::Body) {
"container, prologue, body, epilogue"
} else {
"container"
};
lines.push(format!(
" {}#{} L{}-L{}",
chunk.path, chunk.checksum, chunk.start_line, chunk.end_line
" {}#{} L{}-L{} regions: {}",
chunk.path, chunk.checksum, chunk.start_line, chunk.end_line, supported_regions
));
}
return Ok(ReadResult { text: lines.join("\n"), chunk: None });
@@ -420,6 +446,23 @@ impl ChunkState {
},
};
let chunk = resolved.chunk;
let selector_ref = format_region_ref(chunk, resolved.region);
if !chunk_supports_region(chunk, resolved.region) {
return Ok(ReadResult {
text: format!(
"{}:{}\n\nChunk \"{}\" does not support @{}.",
params.display_path,
chunk.path,
chunk.path,
resolved.region.as_str(),
),
chunk: Some(ChunkReadTarget {
status: ChunkReadStatus::UnsupportedRegion,
selector: selector_ref,
}),
});
}
if let Some(absolute_line_range) = params.absolute_line_range {
let req_start = absolute_line_range.start_line;
@@ -439,16 +482,65 @@ impl ChunkState {
),
chunk: Some(ChunkReadTarget {
status: ChunkReadStatus::Ok,
selector: chunk.path.clone(),
selector: selector_ref,
}),
});
}
}
if resolved.region != ChunkRegion::Container {
let masked_source = mask_chunk_display_source(self.inner.source(), self.inner.language());
let (start, end) = match chunk_region_range(chunk, resolved.region) {
Ok(range) => range,
Err(err) => {
return Ok(ReadResult {
text: format!("{}\n\n{}", params.display_path, err),
chunk: Some(ChunkReadTarget {
status: ChunkReadStatus::UnsupportedRegion,
selector: selector_ref,
}),
});
},
};
let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" ");
let normalize_indent = params.normalize_indent.unwrap_or(false).then(|| {
(
detect_file_indent_char(self.inner.source(), self.inner.tree()),
detect_file_indent_step(self.inner.tree()) as usize,
)
});
let region_text = masked_source
.get(start..end)
.unwrap_or_default()
.split('\n')
.map(|line| match normalize_indent {
Some((indent_char, indent_step)) => {
normalize_to_tabs(line, indent_char, indent_step)
},
None => line.replace('\t', tab_replacement),
})
.collect::<Vec<_>>()
.join("\n");
let text = if region_text.is_empty() {
format!("{selector_ref}\n\n[Empty @{} region]", resolved.region.as_str())
} else {
format!("{selector_ref}\n\n{region_text}")
};
return Ok(ReadResult {
text,
chunk: Some(ChunkReadTarget { status: ChunkReadStatus::Ok, selector: selector_ref }),
});
}
Ok(ReadResult {
text: self.render(RenderParams {
chunk_path: Some(chunk.path.clone()),
title: format!("{}:{}", params.display_path, chunk.path),
title: format!(
"{}:{}@{}",
params.display_path,
chunk.path,
resolved.region.as_str()
),
language_tag: params.language_tag.clone(),
visible_range: None,
render_children_only: false,
@@ -456,12 +548,10 @@ impl ChunkState {
anchor_style: params.anchor_style,
show_leaf_preview: true,
tab_replacement: params.tab_replacement,
normalize_indent: params.normalize_indent,
focused_paths: None,
}),
chunk: Some(ChunkReadTarget {
status: ChunkReadStatus::Ok,
selector: chunk.path.clone(),
}),
chunk: Some(ChunkReadTarget { status: ChunkReadStatus::Ok, selector: selector_ref }),
})
}
@@ -495,6 +585,7 @@ impl ChunkState {
struct ParsedChunkReadPath {
selector: Option<String>,
crc: Option<String>,
region: ChunkRegion,
}
fn normalize_language(language: &str) -> String {
@@ -522,11 +613,11 @@ fn chunk_read_path_separator_index(read_path: &str) -> Option<usize> {
read_path.find(':')
}
fn parse_chunk_read_path(read_path: &str) -> ParsedChunkReadPath {
let (selector, crc) = chunk_read_path_separator_index(read_path)
.map(|index| split_selector_and_crc(Some(&read_path[(index + 1)..]), None))
.unwrap_or_default();
ParsedChunkReadPath { selector, crc }
fn parse_chunk_read_path(read_path: &str) -> std::result::Result<ParsedChunkReadPath, String> {
let raw_selector =
chunk_read_path_separator_index(read_path).map(|index| &read_path[(index + 1)..]);
let (selector, crc, region) = split_selector_crc_and_region(raw_selector, None, None)?;
Ok(ParsedChunkReadPath { selector, crc, region })
}
fn parse_visible_line_range(selector: &str) -> Option<VisibleLineRange> {
+59 -25
View File
@@ -22,8 +22,12 @@ pub struct ChunkNode {
/// such as doc comments or attributes.
pub checksum_start_byte: u32,
pub body_start_byte: Option<u32>,
pub body_end_byte: Option<u32>,
/// End byte of the prologue region. `None` means the chunk only exposes
/// `@container`.
pub prologue_end_byte: Option<u32>,
/// Start byte of the epilogue region. `None` means the chunk only exposes
/// `@container`.
pub epilogue_start_byte: Option<u32>,
pub checksum: String,
pub error: bool,
@@ -77,34 +81,57 @@ pub enum ChunkReadStatus {
/// No chunk matched the requested selector.
#[napi(value = "not_found")]
NotFound,
/// Chunk matched but does not support the requested region.
#[napi(value = "unsupported_region")]
UnsupportedRegion,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[napi(string_enum)]
pub enum ChunkRegion {
#[napi(value = "container")]
Container,
#[napi(value = "prologue")]
Prologue,
#[napi(value = "body")]
Body,
#[napi(value = "epilogue")]
Epilogue,
}
impl ChunkRegion {
pub const fn as_str(self) -> &'static str {
match self {
Self::Container => "container",
Self::Prologue => "prologue",
Self::Body => "body",
Self::Epilogue => "epilogue",
}
}
}
/// Structural edit to apply relative to a chunk anchor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[napi(string_enum)]
pub enum ChunkEditOp {
/// Replace the chunk body, or a substring via `find`.
/// Replace the targeted region, or a substring via `find`.
#[napi(value = "replace")]
Replace,
/// Remove the chunk's source range.
/// Remove the targeted region.
#[napi(value = "delete")]
Delete,
/// Insert `content` as the last child of the target chunk.
#[napi(value = "append_child")]
AppendChild,
/// Insert `content` as the first child of the target chunk.
#[napi(value = "prepend_child")]
PrependChild,
/// Insert `content` after the target chunk's source range.
#[napi(value = "append_sibling")]
AppendSibling,
/// Insert `content` before the target chunk's source range.
#[napi(value = "prepend_sibling")]
PrependSibling,
/// Replace only the inner body of the chunk, preserving signature and
/// closing delimiter.
#[napi(value = "replace_body")]
ReplaceBody,
/// Insert `content` before the targeted region span.
#[napi(value = "before")]
Before,
/// Insert `content` after the targeted region span.
#[napi(value = "after")]
After,
/// Insert `content` at the start inside the targeted region.
#[napi(value = "prepend")]
Prepend,
/// Insert `content` at the end inside the targeted region.
#[napi(value = "append")]
Append,
}
impl ChunkEditOp {
@@ -112,11 +139,10 @@ impl ChunkEditOp {
match self {
Self::Replace => "replace",
Self::Delete => "delete",
Self::AppendChild => "append_child",
Self::PrependChild => "prepend_child",
Self::AppendSibling => "append_sibling",
Self::PrependSibling => "prepend_sibling",
Self::ReplaceBody => "replace_body",
Self::Before => "before",
Self::After => "after",
Self::Prepend => "prepend",
Self::Append => "append",
}
}
}
@@ -267,6 +293,9 @@ pub struct RenderParams {
/// Replace tab characters in displayed previews (e.g. two spaces).
#[napi(js_name = "tabReplacement")]
pub tab_replacement: Option<String>,
/// When true, normalize displayed indentation to canonical tabs.
#[napi(js_name = "normalizeIndent")]
pub normalize_indent: Option<bool>,
/// When set, restrict rendering to these chunks with their specified focus
/// modes. Everything not in this list is skipped.
@@ -300,6 +329,9 @@ pub struct ReadRenderParams {
/// Replace tabs in embedded previews.
#[napi(js_name = "tabReplacement")]
pub tab_replacement: Option<String>,
/// When true, normalize displayed indentation to canonical tabs.
#[napi(js_name = "normalizeIndent")]
pub normalize_indent: Option<bool>,
}
/// Rendered chunk text plus optional resolution metadata for the read request.
@@ -325,6 +357,8 @@ pub struct EditOperation {
/// Optional checksum anchor; falls back to `EditParams.defaultCrc` when
/// omitted.
pub crc: Option<String>,
/// Region to target. When omitted, defaults to `@container`.
pub region: Option<ChunkRegion>,
/// Replacement or inserted text (meaning depends on `op`).
pub content: Option<String>,
/// For scoped find/replace: literal substring to locate inside the target
+12
View File
@@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Simplified chunk edit operations: removed `append_child`, `prepend_child`, `append_sibling`, `prepend_sibling`, and `replace_body` ops in favor of unified `replace`, `before`, `after`, `prepend`, and `append` with region targeting (`@container`, `@prologue`, `@body`, `@epilogue`)
- Chunk edit `target` format changed: now accepts `selector#CRC@region` for mutations and `selector@region` for insertions; removed separate `crc` and `anchor` fields from edit operations
- Removed checksum requirement from insert operations (`before`, `after`, `prepend`, `append`); only `replace` requires `#CRC` suffix
### Added
@@ -25,6 +30,12 @@
### Changed
- Chunk edit tool documentation restructured: replaced operation-specific examples with region-based guidance and canonical indentation rules
- Chunk read documentation updated: selectors now support region syntax (e.g., `class_Foo.fn_bar#ABCD@body`) and canonical target listings show supported regions per chunk
- Chunk edit schema simplified: `target` description now documents region format; `op` and `content` descriptions clarified for region-aware operations
- Chunk edit streaming previews updated: labels now reflect region-aware operations (e.g., `append` instead of `append child`, `insert after` without anchor reference)
- Removed CRC parsing from `parseChunkSelector()` and `parseChunkReadPath()`: selectors no longer extract embedded checksums
- Chunk edit normalization simplified: no longer requires async checksum resolution or context-dependent operation mapping
- RPC mode now automatically disables session title generation by default; hosts can opt in with `PI_RPC_EMIT_TITLE=1` environment variable to receive title updates
- RPC mode now resets workflow-altering `todo.*`, `task.*`, and `async.*` settings to built-in defaults instead of inheriting user overrides
- RPC mode now disables automatic session title generation by default and suppresses `setTitle` extension UI requests unless hosts opt in with `PI_RPC_EMIT_TITLE=1`
@@ -61,6 +72,7 @@
### Fixed
- Chunk read output now correctly preserves embedded CRC in selectors (e.g., `class_Foo.fn_bar#ZZPM`) instead of stripping them during path parsing
- Chunk edit error messages now consistently report checksum mismatches with format `Checksum mismatch` instead of variable phrasing
- Chunk-mode read output now correctly displays scoped response trees showing only touched chunks and adjacent siblings, preventing unrelated distant chunks from appearing in responses
- DAP stopped event handling no longer blocks the message reader, preventing potential deadlocks during rapid event sequences
+35 -177
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import * as nodePath from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { StringEnum } from "@oh-my-pi/pi-coding-agent";
import {
ChunkAnchorStyle,
ChunkEditOp,
@@ -29,12 +29,11 @@ import type { EditToolDetails, LspBatchRequest } from "../renderer";
export type { ChunkReadTarget };
export type ChunkEditOperation =
| { op: "append_child"; sel?: string; crc?: string; content: string }
| { op: "prepend_child"; sel?: string; crc?: string; content: string }
| { op: "append_sibling"; sel?: string; crc?: string; content: string }
| { op: "prepend_sibling"; sel?: string; crc?: string; content: string }
| { op: "replace"; sel?: string; crc?: string; content: string }
| { op: "replace_body"; sel?: string; crc?: string; content: string };
| { op: "replace"; sel?: string; content: string }
| { op: "before"; sel?: string; content: string }
| { op: "after"; sel?: string; content: string }
| { op: "prepend"; sel?: string; content: string }
| { op: "append"; sel?: string; content: string };
type ChunkEditResult = {
diffSourceBefore: string;
@@ -49,7 +48,6 @@ type ChunkEditResult = {
export type ParsedChunkReadPath = {
filePath: string;
selector?: string;
crc?: string;
};
type ChunkCacheEntry = {
@@ -86,13 +84,14 @@ const chunkStateCache = new LRUCache<string, ChunkCacheEntry>({
max: readEnvInt("PI_CHUNK_CACHE_MAX_ENTRIES", 200),
});
const HASHLINE_NIBBLE_ALPHABET = "ZPMQVRWSNKTXJBYH";
const CHECKSUM_SUFFIX_RE = new RegExp(`^(.*?)(?:\\s+)?#([${HASHLINE_NIBBLE_ALPHABET}]{4})$`, "i");
export function invalidateChunkCache(filePath: string): void {
chunkStateCache.delete(filePath);
}
type ParsedChunkTarget = {
selector: string;
};
type ChunkSourceContext = {
resolvedPath: string;
sourceFile: BunFile;
@@ -122,19 +121,7 @@ function fileLanguageTag(filePath: string, language?: string): string | undefine
}
function resolveChunkTarget(target: string): ParsedChunkTarget {
const parsed = parseChunkSelector(target);
return {
selector: parsed.selector ?? target,
crc: parsed.crc,
};
}
function resolveChunkSiblingSelector(params: { selector: string; anchor: string | undefined; op: string }): string {
const { selector, anchor, op } = params;
if (!anchor) {
throw new Error(`'anchor' required for op=${op} on ${describeChunkTarget(selector)}.`);
}
return joinChunkPath(selector, anchor);
return { selector: target };
}
async function resolveChunkSourceContext(session: ToolSession, path: string): Promise<ChunkSourceContext> {
@@ -185,17 +172,10 @@ function chunkReadPathSeparatorIndex(readPath: string): number {
return readPath.indexOf(":");
}
export function parseChunkSelector(selector: string | undefined): { selector?: string; crc?: string } {
export function parseChunkSelector(selector: string | undefined): { selector?: string } {
if (!selector || selector.length === 0) {
return {};
}
const match = CHECKSUM_SUFFIX_RE.exec(selector);
if (!match) return { selector };
const normalizedSelector = match[1] ?? "";
const crc = match[2]?.toUpperCase();
if (normalizedSelector.length > 0) {
return { selector: normalizedSelector, crc };
}
return { selector };
}
@@ -208,7 +188,6 @@ export function parseChunkReadPath(readPath: string): ParsedChunkReadPath {
return {
filePath: readPath.slice(0, colonIndex),
selector: parsedSelector.selector,
crc: parsedSelector.crc,
};
}
@@ -254,6 +233,7 @@ export async function formatChunkedRead(params: {
? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine }
: undefined,
tabReplacement: " ",
normalizeIndent: true,
});
return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
}
@@ -276,24 +256,16 @@ function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperati
return {
op: ChunkEditOp.Replace,
sel: operation.sel,
crc: operation.crc,
content: operation.content,
};
case "append_child":
return { op: ChunkEditOp.AppendChild, sel: operation.sel, crc: operation.crc, content: operation.content };
case "prepend_child":
return { op: ChunkEditOp.PrependChild, sel: operation.sel, crc: operation.crc, content: operation.content };
case "append_sibling":
return { op: ChunkEditOp.AppendSibling, sel: operation.sel, crc: operation.crc, content: operation.content };
case "prepend_sibling":
return { op: ChunkEditOp.PrependSibling, sel: operation.sel, crc: operation.crc, content: operation.content };
case "replace_body":
return {
op: ChunkEditOp.ReplaceBody,
sel: operation.sel,
crc: operation.crc,
content: operation.content,
};
case "before":
return { op: ChunkEditOp.Before, sel: operation.sel, content: operation.content };
case "after":
return { op: ChunkEditOp.After, sel: operation.sel, content: operation.content };
case "prepend":
return { op: ChunkEditOp.Prepend, sel: operation.sel, content: operation.content };
case "append":
return { op: ChunkEditOp.Append, sel: operation.sel, content: operation.content };
default: {
const exhaustive: never = operation;
return exhaustive;
@@ -339,39 +311,18 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget {
return { status: ChunkReadStatus.NotFound, selector };
}
const CHUNK_OP_VALUES = [
"replace",
"replace_body",
"append",
"prepend",
"after",
"before",
"append_child",
"prepend_child",
"append_sibling",
"prepend_sibling",
] as const;
const CHUNK_OP_VALUES = ["replace", "after", "before", "prepend", "append"] as const;
export const chunkToolEditSchema = Type.Object({
target: Type.String({
description:
"Chunk path from read output, with #CRC suffix for mutations (e.g. 'class_X.fn_y#A14F'). Use parent path without #CRC for insert ops.",
"Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. @region defaults to @container. Valid regions: container, prologue, body, epilogue.",
}),
op: Type.Optional(
StringEnum(CHUNK_OP_VALUES, {
description:
"Edit op (default: replace). Use replace with empty content to remove a chunk. 'append'/'prepend' insert as last/first child. 'after'/'before' insert at sibling position; require 'anchor'.",
}),
),
op: Type.Optional(StringEnum(CHUNK_OP_VALUES)),
content: Type.String({
description:
'New content: required for append/prepend/after/before. For replace, use the full chunk body from read output, or "" to remove the chunk.',
description: "New content. Use \\t for indentation. Do NOT include the chunk's base padding.",
}),
anchor: Type.Optional(
Type.String({ description: "Named child to insert relative to (required for op=after/before)." }),
),
});
export const chunkEditParamsSchema = Type.Object(
{
path: Type.String({ description: "File path" }),
@@ -386,17 +337,6 @@ export const chunkEditParamsSchema = Type.Object(
export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
export type ChunkParams = Static<typeof chunkEditParamsSchema>;
type ParsedChunkTarget = {
selector: string;
crc?: string;
};
type ChunkExecutionContext = {
resolvedPath: string;
sourceExists: boolean;
chunkLanguage: string | undefined;
};
interface ExecuteChunkModeOptions {
session: ToolSession;
params: ChunkParams;
@@ -423,99 +363,21 @@ function parseChunkTarget(target: string): ParsedChunkTarget {
return resolveChunkTarget(target);
}
function joinChunkPath(parent: string, child: string): string {
if (child.length === 0) {
throw new Error("Sibling name cannot be empty.");
}
if (parent.length === 0 || child.startsWith(`${parent}.`)) {
return child;
}
return `${parent}.${child}`;
}
function describeChunkTarget(selector: string): string {
return selector.length > 0 ? `"${selector}"` : "root";
}
async function resolveRequiredChunkChecksum(params: {
op: string;
crc: string | undefined;
selector: string;
context: ChunkExecutionContext;
}): Promise<string> {
const { op, crc, selector, context } = params;
if (crc) return crc.toUpperCase();
if (selector.length > 0 && context.sourceExists) {
const resolved = await getChunkInfoForFile(context.resolvedPath, context.chunkLanguage, selector);
if (resolved) {
throw new Error(
`Checksum required for ${op} on ${describeChunkTarget(selector)}. ` +
`Re-read the chunk to get its checksum, then pass target: "${selector}#${resolved.checksum}".`,
);
}
throw new Error(`Chunk not found: "${selector}". Re-read the file to see available chunk paths.`);
}
throw new Error(
`Checksum required for ${op} on ${describeChunkTarget(selector)}. ` +
"Re-read the file first, then pass target with a #XXXX checksum suffix copied from the read output.",
);
}
async function normalizeChunkEditOperation(
edit: ChunkToolEdit,
context: ChunkExecutionContext,
): Promise<ChunkEditOperation> {
const { selector, crc } = parseChunkTarget(edit.target);
function normalizeChunkEditOperation(edit: ChunkToolEdit): ChunkEditOperation {
const { selector } = parseChunkTarget(edit.target);
const op = edit.op ?? "replace";
const content = edit.content;
switch (op) {
case "append":
case "append_child":
return { op: "append_child", sel: selector, content };
case "prepend":
case "prepend_child":
return { op: "prepend_child", sel: selector, content };
case "after":
case "append_sibling":
return {
op: "append_sibling",
sel: resolveChunkSiblingSelector({ selector, anchor: edit.anchor, op }),
content,
};
case "before":
case "prepend_sibling":
return {
op: "prepend_sibling",
sel: resolveChunkSiblingSelector({ selector, anchor: edit.anchor, op }),
content,
};
case "replace_body":
return {
op: "replace_body",
sel: selector,
crc: await resolveRequiredChunkChecksum({ op: "replace_body", crc, selector, context }),
content,
};
default:
return {
op: "replace",
sel: selector,
crc: await resolveRequiredChunkChecksum({ op: "replace", crc, selector, context }),
content,
};
}
return {
op,
sel: selector,
content,
};
}
async function normalizeChunkEditOperations(
edits: ChunkToolEdit[],
context: ChunkExecutionContext,
): Promise<ChunkEditOperation[]> {
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): ChunkEditOperation[] {
const operations: ChunkEditOperation[] = [];
for (const edit of edits) {
operations.push(await normalizeChunkEditOperation(edit, context));
operations.push(normalizeChunkEditOperation(edit));
}
return operations;
}
@@ -582,11 +444,7 @@ export async function executeChunkMode(
if (parentDir && parentDir !== ".") {
await fs.mkdir(parentDir, { recursive: true });
}
const normalizedOperations = await normalizeChunkEditOperations(edits, {
resolvedPath,
sourceExists,
chunkLanguage,
});
const normalizedOperations = normalizeChunkEditOperations(edits);
const chunkResult = applyChunkEdits({
source: rawContent,
+4 -8
View File
@@ -231,17 +231,13 @@ function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStream
switch (op) {
case "append":
case "append_child":
return { srcLabel: `\u2022 append child ${target}`, dst: contentLines };
return { srcLabel: `\u2022 append ${target}`, dst: contentLines };
case "prepend":
case "prepend_child":
return { srcLabel: `\u2022 prepend child ${target}`, dst: contentLines };
return { srcLabel: `\u2022 prepend ${target}`, dst: contentLines };
case "after":
case "append_sibling":
return { srcLabel: `\u2022 insert after ${target}/${edit.anchor ?? "?"}`, dst: contentLines };
return { srcLabel: `\u2022 insert after ${target}`, dst: contentLines };
case "before":
case "prepend_sibling":
return { srcLabel: `\u2022 insert before ${target}/${edit.anchor ?? "?"}`, dst: contentLines };
return { srcLabel: `\u2022 insert before ${target}`, dst: contentLines };
default:
return {
srcLabel: contentLines.length === 0 ? `\u2022 remove ${target}` : `\u2022 replace ${target}`,
@@ -1,21 +1,59 @@
Edits files via syntax-aware chunks. Run `read(path="file.ts")` first — the default read output shows anchors like `class_X.fn_y.if_2#CCCC`. Copy that exact `class_X.fn_y.if_2#CCCC` into `target`.
Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit target is a chunk selector, optionally qualified with a region.
<rules>
- **MUST** `read` first. NEVER invent chunk names or CRCs — copy them from the latest read output or edit response.
- `target` **MUST** be the **fully-qualified** path: `class_X.fn_y.if_2#CCCC`
- If the exact path is unclear, or your anchor style omits full paths, run `read(path="file", sel="?")` and copy a canonical target from that listing.
- `content` must match the full chunk region you are replacing (same span as read output), with correct inner indentation — except use `content: ""` to remove the chunk.
- Prefer `replace_body` when you are only changing a function/class implementation. It preserves the surrounding declaration shape and avoids accidentally dropping attached doc comments.
- Successful edits return refreshed anchors — use them for follow-ups, don't re-read just for new CRCs.
- **MUST** `read` first. Never invent chunk paths or CRCs. Copy them from the latest `read` output or edit response.
- `target` format:
- insertions: `chunk` or `chunk@region`
- replacements: `chunk#CRC` or `chunk#CRC@region`
- `@region` defaults to `@container`. Valid regions: `container`, `prologue`, `body`, `epilogue`.
- If the exact chunk path is unclear, or your anchor style omits full paths, run `read(path="file", sel="?")` and copy a selector from that listing. The listing also shows which regions each chunk supports.
- Use `\t` for indentation in `content`. Do **NOT** include the chunk's base indentation. Only indent relative to the chunk's opening level.
- `replace` requires the current CRC. Insertions do not.
- Successful edits return refreshed chunk anchors. Use the latest selectors/CRCs for follow-up edits.
</rules>
<ops>
|op|fields|effect|
|---|---|---|
|`replace`|`target#CRC`, `content`|rewrite or, with empty content, entire chunk|
|`replace_body`|`target#CRC`, `content`|rewrite only the inner body of the chunk, preserving signature and closing delimiter|
|`append_child` / `prepend_child`|`target`, `content`|insert as child of target|
|`append_sibling` / `prepend_sibling`|`target`, `anchor` (child name), `content`|insert as sibling of anchor|
<regions>
- `@container` — the full owned extent of the chunk. Default when `@region` is omitted.
- `@prologue` — attached trivia, header/signature, and opening delimiter.
- `@body` — the editable interior only.
- `@epilogue` — the closing delimiter or trailing owned trailer.
For file-root edits, `target` is the file header CRC alone (e.g. `"#VSKB"`).
Leaf chunks only support `@container`.
</regions>
<ops>
|op|target form|effect|
|---|---|---|
|`replace`|`chunk#CRC` or `chunk#CRC@region`|rewrite the addressed region (default: `@container`)|
|`before`|`chunk` or `chunk@region`|insert before the region span (default: `@container`)|
|`after`|`chunk` or `chunk@region`|insert after the region span (default: `@container`)|
|`prepend`|`chunk` or `chunk@region`|insert at the start inside the region (default: `@container`)|
|`append`|`chunk` or `chunk@region`|insert at the end inside the region (default: `@container`)|
</ops>
<examples>
- Replace only a function body without touching the closing brace:
- `target: "fn_main#ABCD@body"`
- `op: "replace"`
- `content: "\treturn compute();\n"`
- Insert a new top-level function after another top-level function:
- `target: "fn_prev"`
- `op: "after"`
- `content: "function next(): void {\n\twork();\n}\n"`
- Add a struct field:
- `target: "type_Server@body"`
- `op: "append"`
- `content: "\tport int\n"`
- Add a Go receiver method owned by the type, not a struct field:
- `target: "type_Server@container"`
- `op: "append"`
- `content: "func (s *Server) Stop() error {\n\treturn nil\n}\n"`
- Edit a doc comment or header block:
- `target: "fn_foo#WXYZ@prologue"`
- `op: "replace"`
- `content: "/**\n * Updated docs.\n */\nfunction foo() {"`
- Canonical indentation example:
- if a method body in a 4-space file should contain `return x;`, write `content: "\treturn x;\n"` for `@body.replace`
- do not write four leading spaces
- do not include the method's existing base indentation
</examples>
@@ -2,11 +2,11 @@ Reads files using syntax-aware chunks.
<instruction>
- `path` — file path or URL; may include `:selector` suffix
- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar`, `?`, `L50`, `L50-L120`, or `raw`
- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@body`, `?`, `L50`, `L50-L120`, or `raw`
- `timeout` — seconds, for URLs only
Each anchor `[full.chunk.path#CCCC]` in the default output is an exact chunk ID. Copy `full.chunk.path#CCCC` into the edit tool's `target` field.
If you need a canonical target list, or your anchor style omits full paths, run `read(path="file", sel="?")` and copy a path from that listing.
Each anchor `[full.chunk.path#CCCC]` in the default output identifies a chunk container. Use `full.chunk.path#CCCC` for `@container`, or add `@prologue`, `@body`, or `@epilogue` when targeting a specific region.
If you need a canonical target list, or your anchor style omits full paths, run `read(path="file", sel="?")`. That listing shows chunk paths plus the regions each chunk supports.
Line numbers in the gutter are absolute file line numbers.
Chunk trees: JS, TS, TSX, Python, Rust, Go. Others use blank-line fallback.
+114 -121
View File
@@ -31,7 +31,7 @@ describe("parseChunkReadPath", () => {
test("path with chunk checksum suffix normalizes selector", () => {
expect(parseChunkReadPath("file.ts:class_Foo.fn_bar#ZZPM")).toEqual({
filePath: "file.ts",
selector: "class_Foo.fn_bar",
selector: "class_Foo.fn_bar#ZZPM",
});
});
@@ -97,9 +97,31 @@ function getChecksum(source: string, chunkPath: string, language = "typescript")
return chunk.checksum;
}
function targetWithChecksum(
chunkPath: string,
checksum: string,
region: "container" | "prologue" | "body" | "epilogue" = "container",
): string {
return `${chunkPath}#${checksum}${region === "container" ? "" : `@${region}`}`;
}
function currentTarget(
source: string,
chunkPath: string,
language = "typescript",
region: "container" | "prologue" | "body" | "epilogue" = "container",
): string {
return targetWithChecksum(chunkPath, getChecksum(source, chunkPath, language), region);
}
function bodyTarget(chunkPath: string): string {
return `${chunkPath}@body`;
}
describe("applyChunkEdits", () => {
test("replace accepts a copied chunk header with checksum suffix", () => {
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
const originalChecksum = getChecksum(testSource, "class_Worker.fn_run");
const ac = { sel: targetWithChecksum("class_Worker.fn_run", originalChecksum) };
const result = edit([
{
op: "replace",
@@ -111,7 +133,7 @@ describe("applyChunkEdits", () => {
expect(result.diffSourceAfter).toContain("stop()");
expect(result.diffSourceAfter).not.toContain("run()");
const newChecksum = getChecksum(result.diffSourceAfter, "class_Worker.fn_stop");
expect(newChecksum).not.toBe(ac.crc);
expect(newChecksum).not.toBe(originalChecksum);
});
test("replace with wrong checksum throws with mismatch", () => {
@@ -119,19 +141,18 @@ describe("applyChunkEdits", () => {
edit([
{
op: "replace",
sel: "class_Worker.fn_run",
crc: "ZZZZ",
sel: targetWithChecksum("class_Worker.fn_run", "ZZZZ"),
content: "replacement",
},
]),
).toThrow(/Checksum mismatch/);
});
test("append_child on branch inserts after existing members", () => {
test("append on a class body inserts after existing members", () => {
const result = edit([
{
op: "append_child",
sel: "class_Worker",
op: "append",
sel: bodyTarget("class_Worker"),
content: `\tstatus(): string {\n\t\treturn "active";\n\t}`,
},
]);
@@ -144,12 +165,12 @@ describe("applyChunkEdits", () => {
expect(after).toContain("class Worker");
});
test("append_child on an empty container inserts inside the container", () => {
test("append on an empty container body inserts inside the container", () => {
const result = edit(
[
{
op: "append_child",
sel: "class_Empty",
op: "append",
sel: bodyTarget("class_Empty"),
content: "method(): void {}\n",
},
],
@@ -163,7 +184,7 @@ describe("applyChunkEdits", () => {
});
test("replace with empty content removes the target chunk", () => {
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
const ac = { sel: currentTarget(testSource, "class_Worker.fn_run") };
const result = edit([{ op: "replace", ...ac, content: "" }]);
expect(result.diffSourceAfter).not.toContain("run()");
@@ -177,8 +198,7 @@ describe("applyChunkEdits", () => {
[
{
op: "replace",
sel: "class_Worker.fn_restart",
crc: checksum,
sel: targetWithChecksum("class_Worker.fn_restart", checksum),
content: `\trestart(): void {\n\t\tshutdown();\n\t}`,
},
],
@@ -197,8 +217,7 @@ describe("applyChunkEdits", () => {
[
{
op: "replace",
sel: "class_Worker.fn_restart",
crc: checksum,
sel: targetWithChecksum("class_Worker.fn_restart", checksum),
content: `\t/** updated restart note */\n\trestart(): void {\n\t\tshutdown();\n\t}`,
},
],
@@ -221,14 +240,12 @@ describe("applyChunkEdits", () => {
operations: [
{
op: "replace",
sel: "class_Worker.constructor",
crc: ctorCrc,
sel: targetWithChecksum("class_Worker.constructor", ctorCrc),
content: `\tconstructor(name: string) {\n\t\tthis.name = name.trim();\n\t}`,
},
{
op: "replace",
sel: "class_Worker.fn_run",
crc: runCrc,
sel: targetWithChecksum("class_Worker.fn_run", runCrc),
content: `\trun(): void {\n\t\tconsole.log(this.name + "!");\n\t}`,
},
],
@@ -239,11 +256,11 @@ describe("applyChunkEdits", () => {
expect(result.diffSourceAfter).toMatch(/this\.name\s*\+\s*"!"/);
});
test("prepend_child on branch inserts before existing members", () => {
test("prepend on a class body inserts before existing members", () => {
const result = edit([
{
op: "prepend_child",
sel: "class_Worker",
op: "prepend",
sel: bodyTarget("class_Worker"),
content: `\tid = 0;`,
},
]);
@@ -258,7 +275,7 @@ describe("applyChunkEdits", () => {
});
describe("insertion boundaries", () => {
test("keeps prepend_child separated from the first existing TypeScript class member", () => {
test("keeps prepend separated from the first existing TypeScript class member", () => {
const source = `class Box<T> {\n value(): T {\n return this.current;\n }\n}\n`;
const result = applyEdit({
source,
@@ -266,8 +283,8 @@ describe("insertion boundaries", () => {
filePath: "/tmp/box.ts",
operations: [
{
op: "prepend_child",
sel: "class_Box",
op: "prepend",
sel: bodyTarget("class_Box"),
content: ` items(): T[] {\n return [];\n }`,
},
],
@@ -277,7 +294,7 @@ describe("insertion boundaries", () => {
expect(result.diffSourceAfter).not.toContain("}\n value(): T {");
});
test("keeps prepend_child separated from the first existing Rust impl member", () => {
test("keeps prepend separated from the first existing Rust impl member", () => {
const source = `impl Widget {\n fn old(&self) -> bool {\n true\n }\n}\n`;
const result = applyEdit({
source,
@@ -285,8 +302,8 @@ describe("insertion boundaries", () => {
filePath: "/tmp/widget.rs",
operations: [
{
op: "prepend_child",
sel: "impl_Widget",
op: "prepend",
sel: bodyTarget("impl_Widget"),
content: ` fn build(&self) -> bool {\n false\n }`,
},
],
@@ -296,7 +313,7 @@ describe("insertion boundaries", () => {
expect(result.diffSourceAfter).not.toContain("}\n fn old(&self) -> bool {");
});
test("keeps prepend_sibling separated before a Go top-level function", () => {
test("keeps before separated before a Go top-level function", () => {
const source = `package main\n\nfunc format() {}\n`;
const result = applyEdit({
source,
@@ -304,7 +321,7 @@ describe("insertion boundaries", () => {
filePath: "/tmp/format.go",
operations: [
{
op: "prepend_sibling",
op: "before",
sel: "fn_format",
content: "func formatLog() {}",
},
@@ -315,7 +332,7 @@ describe("insertion boundaries", () => {
expect(result.diffSourceAfter).not.toContain("func formatLog() {}\nfunc format() {}");
});
test("keeps append_sibling separated from the next Go top-level function", () => {
test("keeps after separated from the next Go top-level function", () => {
const source = `package main\n\nfunc first() {}\nfunc second() {}\n`;
const result = applyEdit({
source,
@@ -323,7 +340,7 @@ describe("insertion boundaries", () => {
filePath: "/tmp/functions.go",
operations: [
{
op: "append_sibling",
op: "after",
sel: "fn_first",
content: "func middle() {}",
},
@@ -334,7 +351,7 @@ describe("insertion boundaries", () => {
expect(result.diffSourceAfter).not.toContain("func middle() {}\nfunc second() {}");
});
test("append_child on a Go receiver type inserts after the last receiver method", () => {
test("append on a Go receiver type container inserts after the last receiver method", () => {
const source = `package main\n\ntype Server struct {}\n\nfunc (s *Server) Start() {}\nfunc (s *Server) Stop() {}\n`;
const result = applyEdit({
source,
@@ -342,8 +359,8 @@ describe("insertion boundaries", () => {
filePath: "/tmp/server.go",
operations: [
{
op: "append_child",
sel: "type_Server",
op: "append",
sel: "type_Server@container",
content: "func (s *Server) Restart() {}",
},
],
@@ -355,24 +372,21 @@ describe("insertion boundaries", () => {
expect(result.diffSourceAfter).not.toContain("type Server struct {\nfunc (s *Server) Restart() {}");
});
test("append_child on Go type_Server with struct fields still inserts file-scope func at column 0", () => {
test("append on Go type_Server container with struct fields still inserts file-scope func at column 0", () => {
const source = `package main
type Server struct {
Addr string
}
`;
const crc = ChunkState.parse(source, "go").chunk("type_Server")?.checksum;
expect(crc).toBeDefined();
const result = applyEdit({
source,
language: "go",
filePath: "/tmp/server.go",
operations: [
{
op: "append_child",
sel: "type_Server",
crc,
op: "append",
sel: "type_Server@container",
content: "func (s *Server) Ping() {}",
},
],
@@ -383,24 +397,21 @@ type Server struct {
expect(result.diffSourceAfter).not.toMatch(/Addr string\n[ \t]+func \(s \*Server\) Ping/);
});
test("append_child on Go type_Server keeps receiver method body indentation relative to column 0", () => {
test("append on Go type_Server container keeps receiver method body indentation relative to column 0", () => {
const source = `package main
type Server struct {
Addr string
}
`;
const crc = ChunkState.parse(source, "go").chunk("type_Server")?.checksum;
expect(crc).toBeDefined();
const result = applyEdit({
source,
language: "go",
filePath: "/tmp/server.go",
operations: [
{
op: "append_child",
sel: "type_Server",
crc,
op: "append",
sel: "type_Server@container",
content: "func (s *Server) LogCount() int {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn 0\n}",
},
],
@@ -412,11 +423,11 @@ type Server struct {
);
expect(result.diffSourceAfter).not.toContain("\n\tfunc (s *Server) LogCount() int {");
});
test("keeps append_child separated from the closing delimiter when adding the last child", () => {
test("keeps append separated from the closing delimiter when adding the last child", () => {
const result = edit([
{
op: "append_child",
sel: "class_Worker",
op: "append",
sel: bodyTarget("class_Worker"),
content: '\tstatus(): string {\n\t\treturn "active";\n\t}',
},
]);
@@ -438,7 +449,7 @@ type Server struct {
source,
language: "rust",
filePath: "/tmp/impl.rs",
operations: [{ op: "replace", sel: "impl_S.fn_b", crc, content: "" }],
operations: [{ op: "replace", sel: targetWithChecksum("impl_S.fn_b", crc), content: "" }],
});
expect(result.diffSourceAfter).toBe("impl S {\n fn a() {}\n\n}\n");
@@ -453,8 +464,7 @@ describe("edit safety invariants", () => {
const source = edit([
{
op: "replace",
sel: runChunkPath,
crc: staleChecksum,
sel: targetWithChecksum(runChunkPath, staleChecksum),
content: '\trun(): void {\n\t\tconsole.log("updated");\n\t}',
},
]).diffSourceAfter;
@@ -475,15 +485,14 @@ describe("edit safety invariants", () => {
[
{
op: "replace",
sel: runChunkPath,
crc: staleChecksum,
sel: targetWithChecksum(runChunkPath, staleChecksum),
content: '\trun(): void {\n\t\tconsole.log("again");\n\t}',
},
],
source,
);
}
return edit([{ op: "replace", sel: runChunkPath, crc: staleChecksum, content: "" }], source);
return edit([{ op: "replace", sel: targetWithChecksum(runChunkPath, staleChecksum), content: "" }], source);
};
expect(invoke).toThrow(new RegExp(`got "${staleChecksum}"`));
@@ -495,14 +504,12 @@ describe("edit safety invariants", () => {
const result = edit([
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: '\trun(): void {\n\t\tconsole.log("first");\n\t}',
},
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: '\trun(): void {\n\t\tconsole.log("second");\n\t}',
},
]);
@@ -514,15 +521,14 @@ describe("edit safety invariants", () => {
const checksum = getChecksum(testSource, runChunkPath);
const firstContent = '\trun(): void {\n\t\tconsole.log("first");\n\t}';
const afterFirst = edit([
{ op: "replace", sel: runChunkPath, crc: checksum, content: firstContent },
{ op: "replace", sel: targetWithChecksum(runChunkPath, checksum), content: firstContent },
]).diffSourceAfter;
const checksum2 = getChecksum(afterFirst, runChunkPath);
const result = edit([
{ op: "replace", sel: runChunkPath, crc: checksum, content: firstContent },
{ op: "replace", sel: targetWithChecksum(runChunkPath, checksum), content: firstContent },
{
op: "replace",
sel: runChunkPath,
crc: checksum2,
sel: targetWithChecksum(runChunkPath, checksum2),
content: '\trun(): void {\n\t\tconsole.log("second");\n\t}',
},
]);
@@ -536,14 +542,12 @@ describe("edit safety invariants", () => {
const result = edit([
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: '\trun(task = "default"): void {\n\t\tconsole.log(this.name);\n\t}',
},
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: '\trun(task = "default"): void {\n\t\tconsole.log(task);\n\t}',
},
]);
@@ -556,8 +560,7 @@ describe("edit safety invariants", () => {
const afterFirst = edit([
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: "\trun(): void {\n\t\tconsole.log(task);\n\t}",
},
]).diffSourceAfter;
@@ -565,14 +568,12 @@ describe("edit safety invariants", () => {
const result = edit([
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: "\trun(): void {\n\t\tconsole.log(task);\n\t}",
},
{
op: "replace",
sel: runChunkPath,
crc: checksum2,
sel: targetWithChecksum(runChunkPath, checksum2),
content: '\trun(task = "default"): void {\n\t\tconsole.log(task);\n\t}',
},
]);
@@ -586,14 +587,13 @@ describe("edit safety invariants", () => {
expect(() =>
edit([
{
op: "append_child",
sel: "class_Worker",
op: "append",
sel: bodyTarget("class_Worker"),
content: '\tstatus(): string {\n\t\treturn "active";\n\t}',
},
{
op: "replace",
sel: "class_Worker.fn_run",
crc: "ZZZZ",
sel: targetWithChecksum("class_Worker.fn_run", "ZZZZ"),
content: "",
},
]),
@@ -605,8 +605,7 @@ describe("edit safety invariants", () => {
const after = edit([
{
op: "replace",
sel: runChunkPath,
crc: getChecksum(testSource, runChunkPath),
sel: currentTarget(testSource, runChunkPath),
content: '\trun(): void {\n\t\tconsole.log("nearby");\n\t}',
},
]).diffSourceAfter;
@@ -621,8 +620,7 @@ describe("edit safety invariants", () => {
[
{
op: "replace",
sel: runChunkPath,
crc: checksum,
sel: targetWithChecksum(runChunkPath, checksum),
content: '\trun(): void {\n\t\tconsole.log("updated");\n\t}',
},
],
@@ -640,7 +638,7 @@ describe("edit safety invariants", () => {
describe("content prefix stripping", () => {
test("line-number prefixes are stripped from replacement content", () => {
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
const ac = { sel: currentTarget(testSource, "class_Worker.fn_run") };
const result = edit([
{
op: "replace",
@@ -654,7 +652,7 @@ describe("content prefix stripping", () => {
});
test("hashline prefixes are stripped from replacement content", () => {
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
const ac = { sel: currentTarget(testSource, "class_Worker.fn_run") };
const result = edit([
{
op: "replace",
@@ -679,7 +677,7 @@ describe("chunk path resolution errors", () => {
filePath: "/tmp/worker.ts",
operations: [
{
op: "prepend_sibling",
op: "before",
sel: "class_Worker.fn_ghost",
content: "\tghost(): void {}",
},
@@ -772,10 +770,10 @@ describe("formatChunkedRead", () => {
});
describe("leaf insert indentation", () => {
test("prepend_sibling on a nested method uses the method's indent level", () => {
test("before on a nested method uses the method's indent level", () => {
const result = edit([
{
op: "prepend_sibling",
op: "before",
sel: "class_Worker.fn_run",
content: "validate(): boolean {\n\treturn true;\n}",
},
@@ -789,10 +787,10 @@ describe("leaf insert indentation", () => {
expect(validatePos).toBeLessThan(runPos);
});
test("append_sibling on a nested method uses the method's indent level", () => {
test("after on a nested method uses the method's indent level", () => {
const result = edit([
{
op: "append_sibling",
op: "after",
sel: "class_Worker.fn_run",
content: 'stop(): void {\n\tconsole.log("stopped");\n}',
},
@@ -808,7 +806,7 @@ describe("leaf insert indentation", () => {
describe("replace last child formatting", () => {
test("replacing last method does not merge with closing brace", () => {
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
const ac = { sel: currentTarget(testSource, "class_Worker.fn_run") };
const result = edit([
{
op: "replace",
@@ -824,7 +822,7 @@ describe("replace last child formatting", () => {
});
test("replace dedents uniformly over-indented content so the chunk column is not applied twice", () => {
const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") };
const ac = { sel: currentTarget(testSource, "class_Worker.fn_run") };
const result = edit([
{
op: "replace",
@@ -978,7 +976,7 @@ describe("addressable member editing", () => {
const enumSource = `enum Status {\n Idle = "idle",\n Busy = "busy",\n}\n`;
test("replace accepts full-source edits on the parent enum container", () => {
const ac = { sel: "enum_Status", crc: getChecksum(enumSource, "enum_Status") };
const ac = { sel: currentTarget(enumSource, "enum_Status") };
const result = edit(
[
{
@@ -994,11 +992,11 @@ describe("addressable member editing", () => {
expect(result.diffSourceAfter).not.toContain('Busy = "busy"');
});
test("append_sibling inserts beside an individually addressable enum variant", () => {
test("after inserts beside an individually addressable enum variant", () => {
const result = edit(
[
{
op: "append_sibling",
op: "after",
sel: "enum_Status.variant_Idle",
content: 'Paused = "paused",',
},
@@ -1010,7 +1008,7 @@ describe("addressable member editing", () => {
});
test("replace with empty content removes an individually addressable enum variant", () => {
const busy = { sel: "enum_Status.variant_Busy", crc: getChecksum(enumSource, "enum_Status.variant_Busy") };
const busy = { sel: currentTarget(enumSource, "enum_Status.variant_Busy") };
const result = edit([{ op: "replace", ...busy, content: "" }], enumSource);
expect(result.diffSourceAfter).toContain('Idle = "idle"');
@@ -1027,7 +1025,7 @@ describe("Go receiver render ownership", () => {
filePath: "/tmp/server.go",
operations: [
{
op: "prepend_sibling",
op: "before",
sel: "type_Server.fn_Start",
content: "func DefaultServer() *Server {\n return &Server{}\n}",
},
@@ -1059,8 +1057,7 @@ describe("blank-line cleanup", () => {
[
{
op: "replace",
sel: "class_Worker.fn_restart",
crc: checksum,
sel: targetWithChecksum("class_Worker.fn_restart", checksum),
content: "",
},
],
@@ -1076,22 +1073,17 @@ describe("blank-line cleanup", () => {
// splice
// ═══════════════════════════════════════════════════════════════════════════
describe("prepend_child warnings", () => {
test("warns when comment-only prepend_child may merge into the next chunk", () => {
describe("prepend warnings", () => {
test("warns when comment-only body prepend may merge into the next chunk", () => {
const source = `package main\n\nimport "fmt"\n`;
const state = ChunkState.parse(source, "go");
const root = state.root();
if (!root) {
throw new Error("expected root chunk");
}
const result = applyChunkEdits({
source,
language: "go",
cwd: "/",
filePath: "main.go",
operations: [{ op: "prepend_child", sel: "", crc: root.checksum, content: "// AUTO-GENERATED\n" }],
operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }],
});
expect(result.warnings.some(w => w.includes("Comment-only prepend_child"))).toBe(true);
expect(result.warnings.some(w => w.includes("Comment-only @body.prepend"))).toBe(true);
});
});
@@ -1100,8 +1092,7 @@ describe("chunk selector auto-resolution", () => {
const result = edit([
{
op: "replace",
sel: "fn_run",
crc: getChecksum(testSource, "class_Worker.fn_run"),
sel: targetWithChecksum("fn_run", getChecksum(testSource, "class_Worker.fn_run")),
content: "run(): void {\n\tconsole.log(this.name);\n}",
},
]);
@@ -1114,8 +1105,7 @@ describe("chunk selector auto-resolution", () => {
const result = edit([
{
op: "replace",
sel: "run",
crc: getChecksum(testSource, "class_Worker.fn_run"),
sel: targetWithChecksum("run", getChecksum(testSource, "class_Worker.fn_run")),
content: "run(): void {\n\tconsole.log(this.name);\n}",
},
]);
@@ -1133,8 +1123,7 @@ describe("chunk selector auto-resolution", () => {
operations: [
{
op: "replace",
sel: "fn_run",
crc: getChecksum(source, "class_Foo.fn_run"),
sel: targetWithChecksum("fn_run", getChecksum(source, "class_Foo.fn_run")),
content: "",
},
],
@@ -1143,8 +1132,8 @@ describe("chunk selector auto-resolution", () => {
});
});
describe("prepend_child preamble guard", () => {
test("errors when comment-only prepend_child targets root with preamble", () => {
describe("prepend preamble guard", () => {
test("errors when comment-only body prepend targets root with preamble", () => {
// JS source with a leading comment block that becomes preamble
const source = `/**\n * License header\n */\nconst x = 1;\n`;
const state = ChunkState.parse(source, "javascript");
@@ -1153,17 +1142,15 @@ describe("prepend_child preamble guard", () => {
// Skip if parser doesn't produce preamble for this source
return;
}
const root = state.root();
if (!root) throw new Error("expected root chunk");
expect(() =>
applyChunkEdits({
source,
language: "javascript",
cwd: "/",
filePath: "index.js",
operations: [{ op: "prepend_child", sel: "", crc: root.checksum, content: "// AUTO-GENERATED\n" }],
operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }],
}),
).toThrow(/Comment-only prepend_child on root is not allowed when the file has a preamble/);
).toThrow(/Comment-only @body.prepend on root is not allowed when the file has a preamble/);
});
});
@@ -1203,7 +1190,13 @@ describe("tlaplus chunk rendering", () => {
language: "tlaplus",
cwd: "/tmp",
filePath: "/tmp/Spec.tla",
operations: [{ op: "replace", sel: initChunk.path, crc: initChunk.checksum, content: "Start == x = 0" }],
operations: [
{
op: "replace",
sel: targetWithChecksum(initChunk.path, initChunk.checksum),
content: "Start == x = 0",
},
],
});
expect(result.diffSourceAfter).toContain("Start == x = 0");
@@ -251,8 +251,7 @@ describe("chunk mode tools", () => {
operations: [
{
op: "replace",
sel: chunkPath,
crc: checksum,
sel: `${chunkPath}#${checksum}`,
content: buildHandleErrorMethod({ returnLine: " return err.message.toUpperCase() + total;" }),
},
],
+7 -1
View File
@@ -1,15 +1,20 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Replaced `ChunkEditOp` enum values — `AppendChild`, `PrependChild`, `AppendSibling`, `PrependSibling`, and `ReplaceBody` are now `Before`, `After`, `Prepend`, and `Append` with updated semantics for region-scoped operations
- Removed `ReplaceBody` operation — use `Replace` with `region: ChunkRegion.Body` to replace only chunk body content
- Moved package entry point from `src/index.ts` to `native/index.js` — consumers must update imports to use the new native module path
- Removed TypeScript source files from `src/` directory — all APIs now exported from auto-generated `native/index.js` with types in `native/index.d.ts`
- Changed enum exports to runtime objects — `const enum` values are now available at runtime via generated enum exports in `native/index.js`
### Added
- Added `ChunkRegion` enum with `Container`, `Prologue`, `Body`, and `Epilogue` values for targeting specific regions within chunks
- Added `region` parameter to `EditOperation` to specify which chunk region to target (defaults to `Container`)
- Added `UnsupportedRegion` status to `ChunkReadStatus` enum to indicate when a chunk does not support the requested region
- Added `normalizeIndent` parameter to `RenderParams` and `ReadRenderParams` to normalize displayed indentation to canonical tabs
- Added `ReplaceBody` chunk edit operation to replace only the inner body of a chunk while preserving signature and closing delimiter
- Added `ChunkFocusMode` enum with `Expanded`, `Collapsed`, and `Container` modes for controlling chunk participation in focus-scoped render passes
- Added `FocusedPath` interface to pair paths with focus modes for the N-API boundary
@@ -21,6 +26,7 @@
### Changed
- Updated `ChunkEditOp` documentation to reflect region-scoped semantics — operations now target specific regions rather than chunk structure positions
- Changed `ChunkEditOp.Replace` documentation to clarify substring replacement via `find` parameter instead of line-based replacement
- Changed `EditOperation` interface to use `find` parameter for scoped find/replace operations instead of `line` and `endLine` parameters
- Changed `EditParams` documentation to remove mention of scheduling reordering for line-scoped groups
+26 -16
View File
@@ -381,23 +381,18 @@ export declare enum ChunkAnchorStyle {
/** Structural edit to apply relative to a chunk anchor. */
export declare enum ChunkEditOp {
/** Replace the chunk body, or a substring via `find`. */
/** Replace the targeted region, or a substring via `find`. */
Replace = 'replace',
/** Remove the chunk's source range. */
/** Remove the targeted region. */
Delete = 'delete',
/** Insert `content` as the last child of the target chunk. */
AppendChild = 'append_child',
/** Insert `content` as the first child of the target chunk. */
PrependChild = 'prepend_child',
/** Insert `content` after the target chunk's source range. */
AppendSibling = 'append_sibling',
/** Insert `content` before the target chunk's source range. */
PrependSibling = 'prepend_sibling',
/**
* Replace only the inner body of the chunk, preserving signature and
* closing delimiter.
*/
ReplaceBody = 'replace_body'
/** Insert `content` before the targeted region span. */
Before = 'before',
/** Insert `content` after the targeted region span. */
After = 'after',
/** Insert `content` at the start inside the targeted region. */
Prepend = 'prepend',
/** Insert `content` at the end inside the targeted region. */
Append = 'append'
}
/** How a chunk participates in a focus-scoped render pass. */
@@ -434,7 +429,9 @@ export declare enum ChunkReadStatus {
/** Selector matched a chunk and content was produced. */
Ok = 'ok',
/** No chunk matched the requested selector. */
NotFound = 'not_found'
NotFound = 'not_found',
/** Chunk matched but does not support the requested region. */
UnsupportedRegion = 'unsupported_region'
}
/** Outcome of resolving which chunk was read for a `renderRead`-style request. */
@@ -445,6 +442,13 @@ export interface ChunkReadTarget {
selector: string
}
export declare enum ChunkRegion {
Container = 'container',
Prologue = 'prologue',
Body = 'body',
Epilogue = 'epilogue'
}
/** Clipboard image payload encoded as PNG bytes. */
export interface ClipboardImage {
/** PNG-encoded image bytes. */
@@ -495,6 +499,8 @@ export interface EditOperation {
* omitted.
*/
crc?: string
/** Region to target. When omitted, defaults to `@container`. */
region?: ChunkRegion
/** Replacement or inserted text (meaning depends on `op`). */
content?: string
/**
@@ -1134,6 +1140,8 @@ export interface ReadRenderParams {
absoluteLineRange?: VisibleLineRange
/** Replace tabs in embedded previews. */
tabReplacement?: string
/** When true, normalize displayed indentation to canonical tabs. */
normalizeIndent?: boolean
}
/** Rendered chunk text plus optional resolution metadata for the read request. */
@@ -1167,6 +1175,8 @@ export interface RenderParams {
showLeafPreview: boolean
/** Replace tab characters in displayed previews (e.g. two spaces). */
tabReplacement?: string
/** When true, normalize displayed indentation to canonical tabs. */
normalizeIndent?: boolean
/**
* When set, restrict rendering to these chunks with their specified focus
* modes. Everything not in this list is skipped.
+11 -5
View File
@@ -244,11 +244,10 @@ exports.ChunkAnchorStyle = {
exports.ChunkEditOp = {
Replace: 'replace',
Delete: 'delete',
AppendChild: 'append_child',
PrependChild: 'prepend_child',
AppendSibling: 'append_sibling',
PrependSibling: 'prepend_sibling',
ReplaceBody: 'replace_body',
Before: 'before',
After: 'after',
Prepend: 'prepend',
Append: 'append',
};
exports.ChunkFocusMode = {
Expanded: 'expanded',
@@ -258,6 +257,13 @@ exports.ChunkFocusMode = {
exports.ChunkReadStatus = {
Ok: 'ok',
NotFound: 'not_found',
UnsupportedRegion: 'unsupported_region',
};
exports.ChunkRegion = {
Container: 'container',
Prologue: 'prologue',
Body: 'body',
Epilogue: 'epilogue',
};
exports.Ellipsis = {
Unicode: 0,