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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+563
-438
File diff suppressed because it is too large
Load Diff
@@ -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 )";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;" }),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+26
-16
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user