diff --git a/crates/pi-natives/src/chunk/classify.rs b/crates/pi-natives/src/chunk/classify.rs index d7074a4e3..b97ea881c 100644 --- a/crates/pi-natives/src/chunk/classify.rs +++ b/crates/pi-natives/src/chunk/classify.rs @@ -10,6 +10,7 @@ use super::{ ChunkContext, NameStyle, RawChunkCandidate, extract_identifier, is_absorbable_attribute, is_trivia_node, make_candidate, named_children, recurse_self, resolve_recurse, resolve_value_container, sanitize_node_kind, signature_for_node, + try_promote_call_with_callback, }, defaults, kind::ChunkKind, @@ -227,14 +228,27 @@ pub fn classify_with_defaults<'tree>( return make_candidate(node, ChunkKind::Error, None, NameStyle::Error, None, None, source); } - match context { + let candidate = match context { ChunkContext::Root => classify_with_tables(classifier, context, node, source) .unwrap_or_else(|| defaults::classify_root_default(node, source)), ChunkContext::ClassBody => classify_with_tables(classifier, context, node, source) .unwrap_or_else(|| defaults::classify_class_default(node, source)), ChunkContext::FunctionBody => classify_with_tables(classifier, context, node, source) .unwrap_or_else(|| defaults::classify_function_default(node, source)), + }; + + // If the classifier produced a groupable leaf (no recurse), try to + // promote call-with-trailing-callback patterns into named container + // chunks. This handles `describe(...)`, `t.Run(...)`, etc. across + // all languages without per-language opt-in. + if candidate.recurse.is_none() + && candidate.groupable + && let Some(promoted) = try_promote_call_with_callback(node, source) + { + return promoted; } + + candidate } pub fn first_wrapper_content_child<'tree>( diff --git a/crates/pi-natives/src/chunk/common.rs b/crates/pi-natives/src/chunk/common.rs index fa91378d6..d414bdb9c 100644 --- a/crates/pi-natives/src/chunk/common.rs +++ b/crates/pi-natives/src/chunk/common.rs @@ -422,6 +422,28 @@ pub fn recurse_value_container(node: Node<'_>) -> Option> { resolve_value_container(node) } +/// Try to promote a node that wraps a call expression with a trailing +/// callback/block argument. Returns a named chunk candidate with `recurse` +/// pointing into the callback body. +/// +/// This is language-agnostic: it uses structural shape detection to find +/// call-with-callback patterns in any language (JS `describe(...)`, Go +/// `t.Run(...)`, Rust `tokio::spawn(async { ... })`, etc.). +pub fn try_promote_call_with_callback<'tree>( + node: Node<'tree>, + source: &str, +) -> Option> { + let (func_node, body) = shape::trailing_callback_body(node)?; + + // Extract a name from the call target (e.g. `describe`, `describe.serial`, + // `app.use`). Sanitize the raw source text (dots become underscores). + let name = sanitize_identifier(node_text(source, func_node.start_byte(), func_node.end_byte())); + + let recurse = Some(RecurseSpec { node: body, context: ChunkContext::FunctionBody }); + + Some(make_kind_chunk(node, ChunkKind::Expression, name, source, recurse)) +} + // ── Identifier extraction ──────────────────────────────────────────────── pub fn extract_identifier(node: Node<'_>, source: &str) -> Option { diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index b0a29b646..020b21215 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -332,10 +332,7 @@ fn resolve_edit_target( } let chunk = resolved.chunk.clone(); if chunk.prologue_end_byte.is_none() || chunk.epilogue_start_byte.is_none() { - // @decl only depends on checksum_start_byte, not prologue/epilogue. - if region != Some(ChunkRegion::Decl) { - region = None; - } + region = None; } Ok(ResolvedEditTarget { chunk, region }) @@ -470,13 +467,9 @@ fn apply_replace( } replace_source_and_adjust_conflicts(state, new_source, warnings); } else { - // For prologue/epilogue replacements, ensure the replacement preserves - // the newline boundary so the body content isn't joined onto the same - // line as the replacement. - if matches!( - target.region, - Some(ChunkRegion::Head | ChunkRegion::Body | ChunkRegion::Tail | ChunkRegion::Decl) - ) && !replacement.is_empty() + // Preserve the region's trailing newline boundary so the next line stays + // structurally separate after a head/body replacement. + if !replacement.is_empty() && !replacement.ends_with('\n') && state.source.as_bytes().get(region_end.saturating_sub(1)) == Some(&b'\n') { @@ -633,29 +626,21 @@ fn apply_insert( if pos == InsertPosition::FirstChild { let body = replacement.trim_matches('\n'); - let comment_only = !body.is_empty() - && body.lines().all(|line| { - let trimmed = line.trim(); - trimmed.is_empty() - || trimmed.starts_with("//") - || trimmed.starts_with("///") - || trimmed.starts_with('#') - || trimmed.starts_with("/*") - }); + let comment_only = !body.is_empty() && body.lines().all(|line| is_comment_only_line(line.trim())); if comment_only && anchor.path.is_empty() && anchor.children.iter().any(|child| child == "preamble") { return Err( - "Comment-only @body.prepend on root is not allowed when the file has a preamble \ - chunk. Use replace on the preamble chunk instead." + "Comment-only ~.prepend on root is not allowed when the file has a preamble chunk. \ + Use replace on the preamble chunk instead." .to_owned(), ); } if comment_only && !anchor.children.is_empty() { warnings.push( - "Comment-only @body.prepend can merge into the following chunk's first line; it is \ - not a separate named chunk." + "Comment-only ~.prepend can merge into the following chunk's first line; it is not a \ + separate named chunk." .to_owned(), ); } @@ -732,7 +717,7 @@ fn validate_crc(chunk: &ChunkNode, crc: Option<&str>) -> Result<(), String> { format!( "Checksum required for {}. Re-read the chunk to get the current checksum, then include \ it in the selector. Hint: use target \"{}\" for container replacement, or append \ - another region such as @body.", + another region such as ~.", chunk_path_opt(chunk), selector ) @@ -799,6 +784,33 @@ fn describe_scheduled_operation(scheduled: &ScheduledEditOperation) -> String { } } +/// Return `true` when `line` (already trimmed) is empty or a line comment in +/// any of the languages this tool edits. +/// +/// Distinguishes shell/Python `# comment` (hash followed by whitespace, `!`, +/// or end-of-line) from TS/JS `#private` field declarations and Rust `#[attr]` +/// attributes, which all start with `#` but are not comments. +fn is_comment_only_line(line: &str) -> bool { + if line.is_empty() { + return true; + } + // C-family single-line and block comments. `//` covers `///` doc comments. + if line.starts_with("//") || line.starts_with("/*") { + return true; + } + // Hash-family comments (shell, Python, YAML, TOML, Nix, make, ...). + // A bare `#`, shebang `#!`, or `#` followed by whitespace is a comment. + // `#[attr]` (Rust), `#![attr]` (Rust inner), and `#foo` (TS private field) + // are **not** comments. + if let Some(rest) = line.strip_prefix('#') { + return rest.is_empty() + || rest.starts_with('!') && !rest.starts_with("![") + || rest.starts_with(' ') + || rest.starts_with('\t'); + } + false +} + fn replace_byte_range(source: &str, start: usize, end: usize, replacement: &str) -> String { let mut new_source = String::with_capacity( source @@ -906,9 +918,7 @@ fn target_indent_for_region( file_indent_step: usize, ) -> String { match region { - None | Some(ChunkRegion::Head | ChunkRegion::Tail | ChunkRegion::Decl) => { - anchor.indent_char.repeat(anchor.indent as usize) - }, + None | Some(ChunkRegion::Head) => anchor.indent_char.repeat(anchor.indent as usize), Some(ChunkRegion::Body) => { compute_insert_indent(state, anchor, true, file_indent_char, file_indent_step) }, @@ -1196,15 +1206,13 @@ fn resolve_insertion_point( ) -> Result<(InsertionPoint, InsertPosition), String> { match (region, op) { // Before chunk boundary - ( - None | Some(ChunkRegion::Head | ChunkRegion::Decl), - ChunkEditOp::Before | ChunkEditOp::Prepend, - ) => Ok((before_chunk_insertion_point(state, anchor), InsertPosition::Before)), + (None, ChunkEditOp::Before | ChunkEditOp::Prepend) => { + Ok((before_chunk_insertion_point(state, anchor), InsertPosition::Before)) + }, // After chunk boundary - ( - None | Some(ChunkRegion::Tail | ChunkRegion::Decl), - ChunkEditOp::After | ChunkEditOp::Append, - ) => Ok((after_chunk_insertion_point(state, anchor), InsertPosition::After)), + (None, ChunkEditOp::After | ChunkEditOp::Append) => { + Ok((after_chunk_insertion_point(state, anchor), InsertPosition::After)) + }, // Inner first-child position (Some(ChunkRegion::Body), ChunkEditOp::Before | ChunkEditOp::Prepend) | (Some(ChunkRegion::Head), ChunkEditOp::After | ChunkEditOp::Append) => Ok(( @@ -1213,7 +1221,7 @@ fn resolve_insertion_point( )), // Inner last-child position (Some(ChunkRegion::Body), ChunkEditOp::After | ChunkEditOp::Append) - | (Some(ChunkRegion::Tail), ChunkEditOp::Before | ChunkEditOp::Prepend) => Ok(( + | (Some(ChunkRegion::Head), ChunkEditOp::Before | ChunkEditOp::Prepend) => Ok(( body_insertion_point(state, anchor, true, file_indent_char, file_indent_step), InsertPosition::LastChild, )), @@ -1271,7 +1279,7 @@ fn compute_insert_indent( return indent_char.repeat(first_child.indent as usize); } - // Scan only the @body region (between prologue and epilogue), not the full + // Scan only the ~ region (between prologue and epilogue), not the full // chunk. This avoids picking up the closing delimiter's indent for // empty/sparse bodies. let (body_start, body_end) = chunk_region_range(anchor, ChunkRegion::Body); @@ -2425,24 +2433,16 @@ mod tests { } #[test] - fn append_on_group_chunk_container_inserts_at_end() { - // A file with only a `stmts` group chunk (e.g. a describe() call in a test - // file). Appending to it should insert content at the end of the statement - // list. - let source = "import { describe } from \"bun:test\";\n\ndescribe(\"suite\", () => \ - {\n\tit(\"a\", () => {});\n});\n"; - let state = state_for(source, "typescript"); + fn append_on_root_stmts_group_inserts_after_grouped_statements() { + // This fixture exposes the root-level `stmts` group. Appending to that + // group should place content after the grouped top-level statements. + // Use plain expression statements (no trailing callback) so they stay + // as groupable stmts rather than being promoted to named expr chunks. + let source = "import { foo } from \"bar\";\n\nconsole.log(\"a\");\nconsole.log(\"b\");\n"; + let state = parsed_state_for(source, "typescript"); let stmts = state .inner() - .tree - .chunks - .iter() - .find(|c| { - c.path - .rsplit('.') - .next() - .is_some_and(|leaf| leaf.starts_with("stmts")) - }) + .chunk("stmts") .expect("stmts chunk should exist"); assert!(stmts.group, "stmts chunk should be marked as group"); @@ -2451,15 +2451,22 @@ mod tests { sel: Some(stmts.path.clone()), crc: None, region: None, - content: Some("\nit(\"b\", () => {});".to_owned()), + content: Some("\nconsole.log(\"c\");".to_owned()), find: None, }); assert!( - result.diff_after.contains("it(\"b\""), + result.diff_after.contains("log(\"c\""), "appended content should appear in output, got: {}", result.diff_after ); + assert!( + result + .diff_after + .contains("log(\"b\");\nconsole.log(\"c\");"), + "appended statement should land after the grouped top-level statements, got: {}", + result.diff_after + ); } #[test] @@ -2470,7 +2477,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@body", chunk.checksum)), + sel: Some(format!("fn_main#{}~", chunk.checksum)), crc: None, region: None, content: Some("\treturn next();\n".to_owned()), @@ -2488,7 +2495,7 @@ mod tests { let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@body", chunk.checksum)), + sel: Some(format!("fn_main#{}~", chunk.checksum)), crc: None, region: None, content: Some("\tprintln!(\"new\");\n".to_owned()), @@ -2506,7 +2513,7 @@ mod tests { let result = apply_single_edit(&state, "test.go", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@body", chunk.checksum)), + sel: Some(format!("fn_main#{}~", chunk.checksum)), crc: None, region: None, content: Some("\treturn\n".to_owned()), @@ -2524,7 +2531,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_run#{}@body", chunk.checksum)), + sel: Some(format!("fn_run#{}~", chunk.checksum)), crc: None, region: None, content: Some("\treturn 2\n".to_owned()), @@ -2570,7 +2577,7 @@ mod tests { let body_state = state_for(source, "go"); let body_result = apply_single_edit(&body_state, "test.go", EditOperation { op: ChunkEditOp::Append, - sel: Some("type_Server@body".to_owned()), + sel: Some("type_Server~".to_owned()), crc: None, region: None, content: Some("\tPort int\n".to_owned()), @@ -2757,7 +2764,7 @@ mod tests { let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@head", chunk.checksum)), + sel: Some(format!("fn_main#{}^", chunk.checksum)), crc: None, region: None, content: Some("/// New doc.\nfn main() {".to_owned()), @@ -2862,7 +2869,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}~", chunk.checksum)), crc: None, region: None, content: Some("\treturn 42;\n".to_owned()), @@ -2887,7 +2894,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}~", chunk.checksum)), crc: None, region: None, content: Some("\treturn 42;\n".to_owned()), @@ -2913,7 +2920,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}~", chunk.checksum)), crc: None, region: None, content: Some("\t\tif (x) {\n\t\t\ty();\n\t\t}\n".to_owned()), @@ -2929,14 +2936,14 @@ mod tests { #[test] fn body_append_inserts_inside_class() { - // Appending to @body of a class should insert inside the body, + // Appending to ~ of a class should insert inside the body, // not after the closing brace. let source = "class Foo {\n bar() {\n return 1;\n }\n}\n"; let state = state_for(source, "typescript"); let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Append, - sel: Some("class_Foo@body".to_owned()), + sel: Some("class_Foo~".to_owned()), crc: None, region: None, content: Some("baz() {\n\treturn 2;\n}\n".to_owned()), @@ -2960,14 +2967,14 @@ mod tests { #[test] fn body_prepend_inserts_after_opening_brace() { - // Prepending to @body of an enum should insert after the opening brace, + // Prepending to ~ of an enum should insert after the opening brace, // not before doc comments. let source = "/** My enum. */\nenum Color {\n Red,\n Green,\n Blue,\n}\n"; let state = state_for(source, "typescript"); let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Prepend, - sel: Some("enum_Color@body".to_owned()), + sel: Some("enum_Color~".to_owned()), crc: None, region: None, content: Some("White,\n".to_owned()), @@ -3059,7 +3066,7 @@ mod tests { let result = apply_single_edit(&state, "test.md", EditOperation { op: ChunkEditOp::Append, - sel: Some(format!("{}#{}@body", section.path, section.checksum)), + sel: Some(format!("{}#{}~", section.path, section.checksum)), crc: None, region: None, content: Some("\nextra paragraph\n".to_owned()), @@ -3105,7 +3112,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Append, - sel: Some("class_Server@body".to_owned()), + sel: Some("class_Server~".to_owned()), crc: None, region: None, content: Some("def stop(self):\n\tpass\n".to_owned()), @@ -3133,8 +3140,8 @@ mod tests { .expect("vrnt_Info should exist"); assert!(chunk.prologue_end_byte.is_none(), "leaf variant should not have prologue_end_byte"); - for region_name in ["body", "head", "tail"] { - let sel = format!("enum_LogLev.vrnt_Info#{}@{}", chunk.checksum, region_name); + for region_suffix in ["~", "^"] { + let sel = format!("enum_LogLev.vrnt_Info#{}{}", chunk.checksum, region_suffix); let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { op: ChunkEditOp::Replace, @@ -3155,7 +3162,7 @@ mod tests { assert!( result.diff_after.contains("Debug,\n Error,\n Warn,"), - "@{region_name} should replace the full leaf chunk, got: {}", + "{region_suffix} should replace the full leaf chunk, got: {}", result.diff_after ); } @@ -3203,7 +3210,7 @@ mod tests { let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("impl_Server.fn_start#{}@head", chunk.checksum)), + sel: Some(format!("impl_Server.fn_start#{}^", chunk.checksum)), crc: None, region: None, content: Some( @@ -3217,7 +3224,7 @@ mod tests { let body_count = result.diff_after.matches("self.running = true;").count(); assert_eq!( body_count, 1, - "body should appear exactly once after @head replace, got {} occurrences in: + "body should appear exactly once after ^ replace, got {} occurrences in: {}", body_count, result.diff_after ); @@ -3264,7 +3271,7 @@ mod tests { let result = apply_single_edit(&state, "test.ts", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}^", chunk.checksum)), crc: None, region: None, content: Some( @@ -3278,7 +3285,7 @@ mod tests { let body_count = result.diff_after.matches("this.running = true;").count(); assert_eq!( body_count, 1, - "body should appear exactly once after @head replace, got {} occurrences in: + "body should appear exactly once after ^ replace, got {} occurrences in: {}", body_count, result.diff_after ); @@ -3299,7 +3306,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("fn_main#{}@body", chunk.checksum)), + sel: Some(format!("fn_main#{}~", chunk.checksum)), crc: None, region: None, content: Some("y = 2\nprint(y)\n".to_owned()), @@ -3350,7 +3357,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)), + sel: Some(format!("class_Server.fn_start#{}^", chunk.checksum)), crc: None, region: None, content: Some("def begin(self) -> None:\n".to_owned()), @@ -3376,7 +3383,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Prepend, - sel: Some("fn_main@body".to_owned()), + sel: Some("fn_main~".to_owned()), crc: None, region: None, content: Some("y = 0\n".to_owned()), @@ -3410,7 +3417,7 @@ mod tests { let result = apply_single_edit(&state, "test.py", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("class_Server#{}@body", chunk.checksum)), + sel: Some(format!("class_Server#{}~", chunk.checksum)), crc: None, region: None, content: Some("def run(self):\n\tpass\n".to_owned()), @@ -3495,40 +3502,6 @@ mod tests { ); } - #[test] - fn decl_region_replaces_without_leading_trivia() { - // @decl covers checksum_start_byte → end_byte: the declaration itself - // without leading trivia. Leading comments/attributes are preserved. - let source = "#[cfg(test)]\nmod tests {\n\tuse super::*;\n\n\t#[test]\n\tfn my_test() \ - {\n\t\told();\n\t}\n}\n"; - let state = state_for(source, "rust"); - let chunk = state - .inner() - .chunk("mod_tests.fn_my_tes") - .expect("fn_my_tes should exist"); - - let result = apply_single_edit(&state, "test.rs", EditOperation { - op: ChunkEditOp::Replace, - sel: Some("mod_tests.fn_my_tes".to_owned()), - crc: Some(chunk.checksum.clone()), - region: Some(ChunkRegion::Decl), - content: Some("fn my_test() {\n\tnew();\n}".to_owned()), - find: None, - }); - - // #[test] should be preserved — @decl doesn't cover leading trivia. - assert!( - result.diff_after.contains("#[test]"), - "#[test] should be preserved with @decl. Full text:\n{}", - result.diff_after - ); - assert!( - result.diff_after.contains("new()"), - "replacement body should appear. Full text:\n{}", - result.diff_after - ); - } - #[test] fn whole_chunk_replace_shows_diff_hunks_after_attribute_restoration() { // Bug 2: After a first edit drops #[test] (bug 1), a follow-up edit that @@ -3930,7 +3903,7 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> let result = apply_single_edit(&state, "test.rs", EditOperation { op: ChunkEditOp::Replace, - sel: Some(format!("impl_Server.fn_addres#{}@head", chunk.checksum)), + sel: Some(format!("impl_Server.fn_addres#{}^", chunk.checksum)), crc: None, region: None, content: Some( @@ -4074,4 +4047,31 @@ function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>> "expected closing brace on own line, got:\n{new_source}" ); } + + #[test] + fn is_comment_only_line_distinguishes_hash_comment_from_private_field_and_attribute() { + // Shell/Python-style comments are treated as comments. + assert!(is_comment_only_line("")); + assert!(is_comment_only_line("# shell comment")); + assert!(is_comment_only_line("#\tpython-style tab after hash")); + assert!(is_comment_only_line("#")); + assert!(is_comment_only_line("#!/usr/bin/env bash")); + assert!(is_comment_only_line("// line comment")); + assert!(is_comment_only_line("/// doc comment")); + assert!(is_comment_only_line("/* block comment start")); + + // TypeScript / JavaScript private fields are NOT comments. + assert!(!is_comment_only_line("#config: Config;")); + assert!(!is_comment_only_line("#running = false;")); + assert!(!is_comment_only_line("#_internal: number = 0;")); + + // Rust attributes and inner attributes are NOT comments. + assert!(!is_comment_only_line("#[napi]")); + assert!(!is_comment_only_line("#[derive(Debug)]")); + assert!(!is_comment_only_line("#![deny(warnings)]")); + + // Plain code lines are not comments. + assert!(!is_comment_only_line("let x = 1;")); + assert!(!is_comment_only_line("return 0;")); + } } diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index 655f6af35..eee902c6c 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -343,8 +343,8 @@ fn build_chunk( // trailing newline that logically terminates the last body line. // When this happens and the source byte at chunk_end is indeed a // newline, extend the chunk's end_byte to match so that: - // - @body covers complete lines including their trailing newline - // - @tail remains a valid (empty) region + // - ~ covers complete lines including their trailing newline + // - ^ and ~ are the only supported sub-chunk regions if epi_start > chunk_end && epi_start <= source.len() && source.as_bytes().get(chunk_end) == Some(&b'\n') @@ -1087,17 +1087,17 @@ mod tests { .find(|c| c.path == "key_server") .expect("key_server"); - // @head should contain "server:" but not the nested keys. + // ^ should contain "server:" but not the nested keys. let (head_s, head_e) = chunk_region_range(server, ChunkRegion::Head); let head = &source[head_s..head_e]; - assert!(head.contains("server"), "@head should contain the key, got {head:?}"); - assert!(!head.contains("host"), "@head should not contain value content, got {head:?}"); + assert!(head.contains("server"), "^ should contain the key, got {head:?}"); + assert!(!head.contains("host"), "^ should not contain value content, got {head:?}"); - // @body should contain the nested keys but not "server:". + // ~ should contain the nested keys but not "server:". let (body_s, body_e) = chunk_region_range(server, ChunkRegion::Body); let body = &source[body_s..body_e]; - assert!(body.contains("host"), "@body should contain nested keys, got {body:?}"); - assert!(!body.contains("server"), "@body should not contain the key header, got {body:?}"); + assert!(body.contains("host"), "~ should contain nested keys, got {body:?}"); + assert!(!body.contains("server"), "~ should not contain the key header, got {body:?}"); } #[test] @@ -1218,6 +1218,71 @@ function main(): void {{ assert!(line_path.starts_with("class_Bla.fn_onEven")); } + #[test] + fn call_with_trailing_callback_promotes_to_named_expression() { + // Test that `describe(...)` / `it(...)` patterns with trailing callback + // arguments are promoted to named expression chunks with children, + // rather than being flat groupable stmts leaves. + let source = "import { describe, it } from \"bun:test\";\n\ndescribe(\"suite\", () => \ + {\n\tit(\"does a\", () => {\n\t\texpect(1).toBe(1);\n\t});\n\n\tit(\"does \ + b\", () => {\n\t\texpect(2).toBe(2);\n\t});\n});\n"; + let tree = build_chunk_tree(source, "typescript").expect("tree should build"); + + // describe(...) should be promoted to a named expr chunk, not grouped into + // stmts. + let describe_chunk = tree + .chunks + .iter() + .find(|c| c.path == "expr_descri") + .expect("describe should be a named chunk"); + assert!(!describe_chunk.leaf, "describe chunk should have children (not a leaf)"); + assert!(!describe_chunk.group, "describe chunk should not be groupable"); + + // The nested calls inside should stay addressable under the promoted parent. + let it_chunks = tree + .chunks + .iter() + .filter(|c| c.path.starts_with("expr_descri.expr")) + .count(); + assert_eq!( + it_chunks, + 2, + "nested calls under describe() should stay addressable; chunks: {:?}", + tree.chunks.iter().map(|c| &c.path).collect::>() + ); + } + + #[test] + fn call_with_trailing_callback_works_for_member_expressions() { + // Test member expression calls like `describe.serial(...)` or `app.use(...)`. + let source = "describe.serial(\"ordered\", () => {\n\tit(\"first\", () => \ + {\n\t\texpect(true).toBe(true);\n\t});\n});\n"; + let tree = build_chunk_tree(source, "typescript").expect("tree should build"); + + let describe_chunk = tree + .chunks + .iter() + .find(|c| c.path == "expr_descri") + .expect("describe.serial should be a named chunk"); + assert!(!describe_chunk.group, "describe.serial chunk should not be groupable"); + } + + #[test] + fn call_without_callback_stays_grouped() { + // Plain call expressions without trailing callbacks should remain as + // groupable stmts, not promoted. + let source = "console.log(\"a\");\nconsole.log(\"b\");\n"; + let tree = build_chunk_tree(source, "typescript").expect("tree should build"); + + let stmts_chunk = tree + .chunks + .iter() + .find(|c| c.path == "stmts") + .expect("plain calls should be grouped into stmts"); + assert!(stmts_chunk.group, "stmts should be a group"); + assert!(stmts_chunk.leaf, "stmts with no callback should be a leaf"); + } + #[test] fn surfaces_error_chunks() { let source = r"class Broken { @@ -1928,7 +1993,7 @@ impl Config { .expect("state should parse"); let result = state .render_read(ReadRenderParams { - read_path: "sample.ts:fn_run@body".to_string(), + read_path: "sample.ts:fn_run~".to_string(), display_path: "sample.ts".to_string(), language_tag: Some("ts".to_string()), omit_checksum: false, @@ -1965,7 +2030,7 @@ impl Config { ChunkState::parse(source.to_string(), "python".to_string()).expect("state should parse"); let result = state .render_read(ReadRenderParams { - read_path: "test.py:class_Server.fn_addres@head".to_string(), + read_path: "test.py:class_Server.fn_addres^".to_string(), display_path: "test.py".to_string(), language_tag: Some("py".to_string()), omit_checksum: false, @@ -2469,18 +2534,14 @@ end let (head_s, head_e) = chunk_region_range(fn_chunk, ChunkRegion::Head); let head = &source[head_s..head_e]; - assert!(head.contains("def run():"), "fn @head should contain def signature, got {head:?}"); - assert!(!head.contains("return"), "fn @head should not contain body, got {head:?}"); + assert!(head.contains("def run():"), "fn ^ should contain def signature, got {head:?}"); + assert!(!head.contains("return"), "fn ^ should not contain body, got {head:?}"); let (body_s, body_e) = chunk_region_range(fn_chunk, ChunkRegion::Body); let body = &source[body_s..body_e]; - assert!(body.contains("return 1"), "fn @body should contain body, got {body:?}"); - assert!(!body.contains("def run"), "fn @body should not contain head, got {body:?}"); - assert!(body.ends_with('\n'), "fn @body should end with newline, got {body:?}"); - - let (tail_s, tail_e) = chunk_region_range(fn_chunk, ChunkRegion::Tail); - assert!(tail_e >= tail_s, "tail range must not be inverted"); - assert_eq!(tail_e - tail_s, 0, "fn @tail should be empty for Python"); + assert!(body.contains("return 1"), "fn ~ should contain body, got {body:?}"); + assert!(!body.contains("def run"), "fn ~ should not contain head, got {body:?}"); + assert!(body.ends_with('\n'), "fn ~ should end with newline, got {body:?}"); } #[test] @@ -2499,21 +2560,14 @@ end let (head_s, head_e) = chunk_region_range(class_chunk, ChunkRegion::Head); let head = &source[head_s..head_e]; - assert!(head.contains("class Server:"), "class @head should contain class def, got {head:?}"); - assert!(!head.contains("def start"), "class @head should not contain methods, got {head:?}"); + assert!(head.contains("class Server:"), "class ^ should contain class def, got {head:?}"); + assert!(!head.contains("def start"), "class ^ should not contain methods, got {head:?}"); let (body_s, body_e) = chunk_region_range(class_chunk, ChunkRegion::Body); let body = &source[body_s..body_e]; - assert!(body.contains("def start"), "class @body should contain methods, got {body:?}"); - assert!(body.contains("def stop"), "class @body should contain all methods, got {body:?}"); - assert!( - !body.contains("class Server"), - "class @body should not contain header, got {body:?}" - ); - - let (tail_s, tail_e) = chunk_region_range(class_chunk, ChunkRegion::Tail); - assert!(tail_e >= tail_s, "tail range must not be inverted"); - assert_eq!(tail_e - tail_s, 0, "class @tail should be empty for Python"); + assert!(body.contains("def start"), "class ~ should contain methods, got {body:?}"); + assert!(body.contains("def stop"), "class ~ should contain all methods, got {body:?}"); + assert!(!body.contains("class Server"), "class ~ should not contain header, got {body:?}"); } #[test] @@ -2531,19 +2585,15 @@ end let (head_s, head_e) = chunk_region_range(fn_chunk, ChunkRegion::Head); let head = &source[head_s..head_e]; - assert!(head.contains("@property"), "@head should include decorator, got {head:?}"); - assert!(head.contains("def address"), "@head should include def, got {head:?}"); - assert!(!head.contains("return"), "@head should not include body, got {head:?}"); + assert!(head.contains("@property"), "^ should include decorator, got {head:?}"); + assert!(head.contains("def address"), "^ should include def, got {head:?}"); + assert!(!head.contains("return"), "^ should not include body, got {head:?}"); let (body_s, body_e) = chunk_region_range(fn_chunk, ChunkRegion::Body); let body = &source[body_s..body_e]; - assert!(body.contains("return self._addr"), "@body should contain return, got {body:?}"); - assert!(!body.contains("@property"), "@body should not contain decorator, got {body:?}"); - assert!(!body.contains("def address"), "@body should not contain def, got {body:?}"); - - let (tail_s, tail_e) = chunk_region_range(fn_chunk, ChunkRegion::Tail); - assert!(tail_e >= tail_s, "tail range must not be inverted"); - assert_eq!(tail_e - tail_s, 0, "fn @tail should be empty for Python"); + assert!(body.contains("return self._addr"), "~ should contain return, got {body:?}"); + assert!(!body.contains("@property"), "~ should not contain decorator, got {body:?}"); + assert!(!body.contains("def address"), "~ should not contain def, got {body:?}"); } #[test] @@ -2618,29 +2668,20 @@ end let fn_body = &source[fn_body_s..fn_body_e]; assert!( fn_body.contains("if self.running"), - "fn_start @body should contain body, got {fn_body:?}" + "fn_start ~ should contain body, got {fn_body:?}" ); assert!( fn_body.contains("self.running = True"), - "fn_start @body should contain all lines, got {fn_body:?}" + "fn_start ~ should contain all lines, got {fn_body:?}" ); assert!( !fn_body.contains("def start"), - "fn_start @body should not include head, got {fn_body:?}" + "fn_start ~ should not include head, got {fn_body:?}" ); assert!( !fn_body.contains("@property"), - "fn_start @body should not leak into next method, got {fn_body:?}" + "fn_start ~ should not leak into next method, got {fn_body:?}" ); - - // Verify all tail regions are empty - for chunk in &tree.chunks { - if chunk.path.is_empty() { - continue; - } - let (ts, te) = chunk_region_range(chunk, ChunkRegion::Tail); - assert!(te >= ts, "tail of {:?} must not be inverted: start={ts} end={te}", chunk.path); - } } #[test] diff --git a/crates/pi-natives/src/chunk/resolve.rs b/crates/pi-natives/src/chunk/resolve.rs index 9fdc2075d..2f8bb29ac 100644 --- a/crates/pi-natives/src/chunk/resolve.rs +++ b/crates/pi-natives/src/chunk/resolve.rs @@ -14,29 +14,26 @@ pub struct ResolvedChunk<'a> { fn parse_region_name(value: &str) -> Option { match value.trim() { - "head" => Some(ChunkRegion::Head), - "body" => Some(ChunkRegion::Body), - "tail" => Some(ChunkRegion::Tail), - "decl" => Some(ChunkRegion::Decl), + "^" => Some(ChunkRegion::Head), + "~" => Some(ChunkRegion::Body), _ => None, } } fn is_known_region_name(value: &str) -> bool { - matches!(value.trim(), "head" | "body" | "tail" | "decl") + matches!(value.trim(), "^" | "~") } -/// Split a trailing `@region` suffix from a selector. Returns the selector -/// prefix, and `Some(region)` if a region was specified. The outer `Option` -/// indicates whether an `@` was found. pub fn split_region_suffix(selector: &str) -> (&str, bool, Option) { - let Some((prefix, suffix)) = selector.rsplit_once('@') else { + let Some(suffix) = selector.chars().last() else { return (selector, false, None); }; - if !is_known_region_name(suffix.trim()) { + let suffix = suffix.to_string(); + if !is_known_region_name(&suffix) { return (selector, false, None); } - (prefix.trim_end(), true, parse_region_name(suffix.trim())) + let prefix = &selector[..selector.len() - suffix.len()]; + (prefix.trim_end(), true, parse_region_name(&suffix)) } pub struct ParsedSelector { @@ -67,8 +64,7 @@ pub fn split_selector_crc_and_region( (prefix, parsed_region) } else if let Some((_, suffix)) = raw.rsplit_once('@') { return Err(format!( - "Unknown chunk region \"{}\". Valid regions: head, body, tail (or omit for the full \ - chunk).", + "Unknown chunk region \"{}\". Valid regions: ^, ~ (or omit for the full chunk).", suffix.trim() )); } else { @@ -204,13 +200,11 @@ pub fn chunk_region_range(chunk: &ChunkNode, region: ChunkRegion) -> (usize, usi match region { ChunkRegion::Head => (start, pro_end), ChunkRegion::Body => (pro_end, epi_start), - ChunkRegion::Tail => (epi_start, end), - ChunkRegion::Decl => ((chunk.checksum_start_byte as usize).clamp(start, end), end), } } pub fn format_region_ref(chunk: &ChunkNode, region: Option) -> String { - let suffix = region.map_or(String::new(), |r| format!("@{}", r.as_str())); + let suffix = region.map_or(String::new(), |r| r.as_str().to_owned()); if chunk.path.is_empty() { format!("#{}{suffix}", chunk.checksum) } else { diff --git a/crates/pi-natives/src/chunk/shape.rs b/crates/pi-natives/src/chunk/shape.rs index 0d5c88d34..8c97c2653 100644 --- a/crates/pi-natives/src/chunk/shape.rs +++ b/crates/pi-natives/src/chunk/shape.rs @@ -209,12 +209,76 @@ fn kind_looks_like_comment(kind: &str) -> bool { kind == "comment" || kind.contains("comment") } +/// Detect a call-with-trailing-callback pattern inside a node. +/// +/// Returns `(call_text_node, body_node)` where `call_text_node` is the function +/// being called (for name extraction) and `body_node` is the block body of the +/// trailing callback argument. +/// +/// This handles patterns like: +/// - JS/TS: `describe('x', () => { ... })`, `app.use(handler)` +/// - Go: `t.Run("x", func(t *testing.T) { ... })` +/// - Rust: `tokio::spawn(async { ... })` +/// - Ruby: `describe 'x' do ... end` (via `do_block`) +/// +/// Only matches when the last argument has a structural block body, so +/// expression-body arrows and simple value arguments are not promoted. +pub fn trailing_callback_body(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> { + // Look through the node's children for a call-like node. + let call = local_named_children(node) + .into_iter() + .find(|c| is_call_like(*c))?; + + // Find the arguments / parameter list. + let args_node = call + .child_by_field_name("arguments") + .or_else(|| call.child_by_field_name("args")) + .or_else(|| call.child_by_field_name("block"))?; + + // Get the last named child of the arguments list. + let last_arg = local_named_children(args_node).into_iter().last()?; + + // Try to find a structural body inside the last argument. + let body = recurse_target(last_arg)?; + + // Extract the call target node for name purposes. + let func_node = call + .child_by_field_name("function") + .or_else(|| call.child_by_field_name("method")) + .or_else(|| call.child_by_field_name("name")) + .unwrap_or(call); + + Some((func_node, body)) +} + +/// Returns `true` if the node looks like a function/method call. +fn is_call_like(node: Node<'_>) -> bool { + let kind = node.kind(); + // Explicit known call kinds. + if matches!( + kind, + "call_expression" + | "call" + | "function_call" + | "method_call" + | "method_call_expression" + | "invocation_expression" + ) { + return true; + } + // Heuristic: has an `arguments` field. + node.child_by_field_name("arguments").is_some() +} + #[cfg(test)] mod tests { use ast_grep_core::tree_sitter::LanguageExt; use tree_sitter::{Node, Parser}; - use super::{identifier_node, is_root_wrapper_node, recurse_target, signature_end_byte}; + use super::{ + identifier_node, is_root_wrapper_node, recurse_target, signature_end_byte, + trailing_callback_body, + }; use crate::language::SupportLang; fn parse_tree_with_language( @@ -271,4 +335,20 @@ mod tests { let node = find_named_node(tree.root_node(), "block_node").expect("block_node"); assert!(is_root_wrapper_node(node), "block_node should be treated as a structural wrapper"); } + + #[test] + fn js_expression_statement_with_callback_detects_body() { + let source = "describe(\"suite\", () => {\n\tit(\"a\", () => {});\n});\n"; + let (_schema_language, tree) = parse_tree_with_language(source, SupportLang::TypeScript); + let expr_stmt = + find_named_node(tree.root_node(), "expression_statement").expect("expression_statement"); + let (func_node, body) = + trailing_callback_body(expr_stmt).expect("should detect trailing callback body"); + assert_eq!(body.kind(), "statement_block"); + assert!( + matches!(func_node.kind(), "identifier" | "member_expression"), + "func_node should be an identifier or member_expression, got {}", + func_node.kind() + ); + } } diff --git a/crates/pi-natives/src/chunk/types.rs b/crates/pi-natives/src/chunk/types.rs index e74e536e3..d1606d5cb 100644 --- a/crates/pi-natives/src/chunk/types.rs +++ b/crates/pi-natives/src/chunk/types.rs @@ -91,25 +91,17 @@ pub enum ChunkReadStatus { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[napi(string_enum)] pub enum ChunkRegion { - #[napi(value = "head")] + #[napi(value = "^")] Head, - #[napi(value = "body")] + #[napi(value = "~")] Body, - #[napi(value = "tail")] - Tail, - /// The semantic declaration without leading trivia (comments, attributes). - /// Spans from `checksum_start_byte` to `end_byte`. - #[napi(value = "decl")] - Decl, } impl ChunkRegion { pub const fn as_str(self) -> &'static str { match self { - Self::Head => "head", - Self::Body => "body", - Self::Tail => "tail", - Self::Decl => "decl", + Self::Head => "^", + Self::Body => "~", } } } diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 10a8cd11c..6acf5b42b 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -556,12 +556,12 @@ describe("Tool argument coercion", () => { id: "call-heal-esc-1", name: "heal-esc-1", arguments: { - edits: '[{"target": "fn_foo#ABCD@body", "content": "return 1;\\n"}\\n]', + edits: '[{"target": "fn_foo#ABCD~", "content": "return 1;\\n"}\\n]', }, }; const result = validateToolArguments(tool, toolCall); - expect(result.edits).toEqual([{ target: "fn_foo#ABCD@body", content: "return 1;\n" }]); + expect(result.edits).toEqual([{ target: "fn_foo#ABCD~", content: "return 1;\n" }]); }); it("heals stringified array with trailing junk after balanced container", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 930c7bbdd..45192a904 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## [Unreleased] + ### Changed +- Updated chunk selector syntax from `@region` format to `~` (body) and `^` (head) suffixes for more concise region targeting +- Simplified chunk edit documentation to use new `~` and `^` region syntax instead of `@head`, `@body`, `@tail`, `@decl` keywords - Replaced internal `raceAbort` function with imported `raceWithAbort` utility from pi-utils - Refactored cleanup timer to use async iterator pattern with `timers.setInterval` instead of `setInterval` - Made `#cleanupIdleSessions` synchronous and moved async cleanup loop logic to new `#runCleanupLoop` method diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index 82eb353b1..9b8a45ba4 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -332,7 +332,7 @@ export const chunkToolEditSchema = Type.Object({ op: StringEnum(CHUNK_OP_VALUES), sel: Type.String({ description: - "Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, body, tail, decl.", + "Chunk selector. Use 'path~' or 'path^' for insertions, 'path#CRC~' or 'path#CRC^' for replace, or omit the suffix to target the full chunk.", }), content: Type.String({ description: diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index 1b993e676..1b7d812fd 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -3,25 +3,25 @@ Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit - **MUST** `read` first. Never invent chunk paths or CRCs. Copy them from the latest `read` output or edit response. - `sel` format: - - insertions: `chunk` or `chunk@region` - - replacements: `chunk#CRC` or `chunk#CRC@region` -- Without a `@region` it defaults to the entire chunk including leading trivia. Valid regions: `head`, `body`, `tail`, `decl`. + - insertions: `chunk`, `chunk~`, or `chunk^` + - replacements: `chunk#CRC`, `chunk#CRC~`, or `chunk#CRC^` +- Without a suffix it defaults to the entire chunk including leading trivia. `~` targets the body, `^` targets the head. - If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing. {{#if chunkAutoIndent}} -- Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `@body` of a method, write the body starting at column 0: +- Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `~` of a method, write the body starting at column 0: ``` content: "if (x) {\n\treturn true;\n}" ``` The tool adds the correct base indent automatically. Never manually pad with the chunk's own indentation. {{else}} - Match the file's literal tabs/spaces in `content`. Do not convert indentation to canonical `\t`. -- Write content at indent-level 0 relative to the target region. For example, to replace `@body` of a method, write: +- Write content at indent-level 0 relative to the target region. For example, to replace `~` of a method, write: ``` content: "if (x) {\n return true;\n}" ``` The tool adds the correct base indent automatically, then preserves the tabs/spaces you used inside the snippet. Never manually pad with the chunk's own indentation. {{/if}} -- `@region` only works on container chunks (classes, functions, impl blocks, sections). Do **not** use `@head`/`@body`/`@tail` on leaf chunks (enum variants, fields, single statements) — use the whole chunk instead. +- Region suffixes only work on container chunks (classes, functions, impl blocks, sections). Do **not** use `^` or `~` on leaf chunks (enum variants, fields, single statements) — use the whole chunk instead. - `replace` requires the current CRC. Insertions do not. - **CRCs change after every edit.** Always use the selectors/CRCs from the most recent `read` or edit response. Never reuse a CRC from a previous edit. @@ -29,42 +29,38 @@ Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit You **MUST** use the narrowest region that covers your change. Replacing without a region replaces the **entire chunk including leading comments, decorators, and attributes** — omitting them from `content` deletes them. -**`replace` is total, not surgical.** The `content` you supply becomes the *complete* new content for the targeted region. Everything in the original region that you omit from `content` is deleted. Before replacing `@body` on any chunk, verify the chunk does not contain children you intend to keep. If a chunk spans hundreds of lines and your change touches only a few, target a specific child chunk — not the parent. +**`replace` is total, not surgical.** The `content` you supply becomes the *complete* new content for the targeted region. Everything in the original region that you omit from `content` is deleted. Before replacing `~` on any chunk, verify the chunk does not contain children you intend to keep. If a chunk spans hundreds of lines and your change touches only a few, target a specific child chunk — not the parent. -**Group chunks (`stmts_*`, `imports_*`, `decls_*`) are containers.** They hold many sibling items (test functions, import statements, declarations). Replacing `@body` on a group chunk replaces **all** of its children. To edit one item inside a group, target that item's own chunk path. If no child chunk exists, use the specific child's chunk selector from `read` output — do not replace the parent group. +**Group chunks (`stmts_*`, `imports_*`, `decls_*`) are containers.** They hold many sibling items (test functions, import statements, declarations). Replacing `~` on a group chunk replaces **all** of its children. To edit one item inside a group, target that item's own chunk path. If no child chunk exists, use the specific child's chunk selector from `read` output — do not replace the parent group. Given a chunk like: ``` -/// doc comment <-- leading trivia (comment, decorator, attribute) +/// doc comment <-- leading trivia #[attr] <-- leading trivia fn foo(x: i32) { <-- signature + opening delimiter body(); <-- body } <-- closing delimiter ``` -|Region|Covers|Use when| -|---|---|---| -|`@body`|interior only|most edits — signature and delimiters are preserved| -|`@head`|leading trivia + signature + opening delimiter|changing signature, decorators, attributes, or doc comments| -|`@tail`|closing delimiter only|rarely needed| -|`@decl`|`@head` minus leading trivia|changing signature without touching decorators/attributes| -|*(no region)*|entire chunk (`@head` + `@body` + `@tail`)|full replacement| +Append `~` to target the body, `^` to target the head (trivia + signature), or nothing for the whole chunk: +- `fn_foo#CRC~` — body only. **Use for most edits.** On leaf chunks, falls back to whole chunk. +- `fn_foo#CRC^` — head (decorators, attributes, doc comments, signature, opening delimiter). +- `fn_foo#CRC` — entire chunk including leading trivia. +- `chunk~` + `append`/`prepend` inserts *inside* the container. `chunk` + `append`/`prepend` inserts *outside*. -- On leaf chunks (fields, variants, single-line items), `@body` falls back to the whole chunk. -- `@decl` excludes decorators/attributes/doc-comments. Including them in `@decl` content **duplicates** them. Use `@head` instead. -- `chunk@body` + `append`/`prepend` inserts *inside* the container. `chunk` + `append`/`prepend` inserts *outside* it. +**Note on leading trivia:** whether a decorator/doc comment belongs to `^` depends on the parser. In Rust and Python, attributes and decorators are attached to the function chunk, so `^` covers them. In TypeScript/JavaScript, a `@decorator` + `/** jsdoc */` block immediately above a method often surfaces as a **separate sibling chunk** (shown as `chunk#CRC` in the `?` listing) rather than as part of the function's `^`. If you need to rewrite a decorator, check the `?` listing for a sibling `chunk#CRC` directly above your target. |op|sel|effect| |---|---|---| -|`replace`|`chunk#CRC(@region)?`|rewrite the addressed region| -|`before`|`chunk(@region)?`|insert before the region span| -|`after`|`chunk(@region)?`|insert after the region span| -|`prepend`|`chunk(@region)?`|insert at the start inside the region| -|`append`|`chunk(@region)?`|insert at the end inside the region| +|`replace`|`chunk#CRC`, `chunk#CRC~`, or `chunk#CRC^`|rewrite the addressed region| +|`before`|`chunk`, `chunk~`, or `chunk^`|insert before the region span| +|`after`|`chunk`, `chunk~`, or `chunk^`|insert after the region span| +|`prepend`|`chunk`, `chunk~`, or `chunk^`|insert at the start inside the region| +|`append`|`chunk`, `chunk~`, or `chunk^`|insert at the end inside the region| @@ -138,9 +134,9 @@ function makeCounter(start: number): Counter { } ``` -**Replace a method body** (`@body`): +**Replace a method body** (`~`): ``` -{ "sel": "class_Counter.fn_increment#NQWY@body", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" } +{ "sel": "class_Counter.fn_increment#NQWY~", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" } ``` Result — only the body changes, signature and braces are kept: ``` @@ -150,9 +146,9 @@ Result — only the body changes, signature and braces are kept: } ``` -**Replace a function header** (`@head` — signature and doc comment): +**Replace a function header** (`^` — signature and doc comment): ``` -{ "sel": "fn_createCounter#PQQY@head", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" } +{ "sel": "fn_createCounter#PQQY^", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" } ``` Result — adds a doc comment and updates the signature, body untouched: ``` @@ -198,9 +194,9 @@ function isActive(s: Status): boolean { function createCounter(initial: number): Counter { ``` -**Prepend inside a container** (`@body` + `prepend`): +**Prepend inside a container** (`~` + `prepend`): ``` -{ "sel": "class_Counter@body", "op": "prepend", "content": "label: string = 'default';\n\n" } +{ "sel": "class_Counter~", "op": "prepend", "content": "label: string = 'default';\n\n" } ``` Result — a new field is added at the top of the class body, before existing members: ``` @@ -210,12 +206,12 @@ class Counter { value: number = 0; ``` -**Append inside a container** (`@body` + `append`): +**Append inside a container** (`~` + `append`): ~~~json {{#if chunkAutoIndent}} -{ "sel": "class_Counter@body", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" } +{ "sel": "class_Counter~", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" } {{else}} -{ "sel": "class_Counter@body", "op": "append", "content": "\nreset(): void {\n this.value = 0;\n}\n" } +{ "sel": "class_Counter~", "op": "append", "content": "\nreset(): void {\n this.value = 0;\n}\n" } {{/if}} ~~~ Result — a new method is added at the end of the class body, before the closing `}`: @@ -242,8 +238,8 @@ Result — the method is removed from the class. - Match the file's real indentation characters in your snippet. The tool preserves your literal tabs/spaces after adding the target region's base indent. {{/if}} - Do NOT include the chunk's base indentation — only indent relative to the region's opening level. - - For `@body` of a function: write at column 0, e.g. `"return x;\n"`. The tool adds the correct base indent. - - For `@head`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`. + - For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in, and the tool adds the method's base indent to both. + - For `^`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`. {{#if chunkAutoIndent}} - For a top-level item: start at zero indent. Write `"function foo() {\n\treturn 1;\n}\n"`. {{else}} diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md index 013b908a6..a1d600a27 100644 --- a/packages/coding-agent/src/prompts/tools/read-chunk.md +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -2,7 +2,7 @@ Reads files using syntax-aware chunks. - `path` — file path or URL; may include `:selector` suffix -- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@body`, `?`, `L50`, `L50-L120`, or `raw` +- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD~`, `?`, `L50`, `L50-L120`, or `raw` - `timeout` — seconds, for URLs only Each opening anchor `[< full.chunk.path#CCCC ]` in the default output identifies a chunk. Use `full.chunk.path#CCCC` as-is to read truncated chunks. diff --git a/packages/coding-agent/test/core/chunk-tree.test.ts b/packages/coding-agent/test/core/chunk-tree.test.ts index b1208afb9..67e034183 100644 --- a/packages/coding-agent/test/core/chunk-tree.test.ts +++ b/packages/coding-agent/test/core/chunk-tree.test.ts @@ -97,21 +97,16 @@ function getChecksum(source: string, chunkPath: string, language = "typescript") return chunk.checksum; } -function targetWithChecksum(chunkPath: string, checksum: string, region?: "head" | "body" | "tail"): string { - return `${chunkPath}#${checksum}${region ? `@${region}` : ""}`; +function targetWithChecksum(chunkPath: string, checksum: string, region?: "^" | "~"): string { + return `${chunkPath}#${checksum}${region ?? ""}`; } -function currentTarget( - source: string, - chunkPath: string, - language = "typescript", - region?: "head" | "body" | "tail", -): string { +function currentTarget(source: string, chunkPath: string, language = "typescript", region?: "^" | "~"): string { return targetWithChecksum(chunkPath, getChecksum(source, chunkPath, language), region); } function bodyTarget(chunkPath: string): string { - return `${chunkPath}@body`; + return `${chunkPath}~`; } describe("applyChunkEdits", () => { @@ -1065,9 +1060,9 @@ describe("prepend warnings", () => { language: "go", cwd: "/", filePath: "main.go", - operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }], + operations: [{ op: "prepend", sel: "~", content: "// AUTO-GENERATED\n" }], }); - expect(result.warnings.some(w => w.includes("Comment-only @body.prepend"))).toBe(true); + expect(result.warnings.some(w => w.includes("Comment-only ~.prepend"))).toBe(true); }); }); @@ -1128,9 +1123,9 @@ describe("prepend preamble guard", () => { language: "javascript", cwd: "/", filePath: "index.js", - operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }], + operations: [{ op: "prepend", sel: "~", content: "// AUTO-GENERATED\n" }], }), - ).toThrow(/Comment-only @body.prepend on root is not allowed when the file has a preamble/); + ).toThrow(/Comment-only ~.prepend on root is not allowed when the file has a preamble/); }); }); diff --git a/packages/coding-agent/test/tools/chunk-mode-regressions.test.ts b/packages/coding-agent/test/tools/chunk-mode-regressions.test.ts index d6dd36b82..414c3ab3d 100644 --- a/packages/coding-agent/test/tools/chunk-mode-regressions.test.ts +++ b/packages/coding-agent/test/tools/chunk-mode-regressions.test.ts @@ -77,7 +77,7 @@ describe("chunk mode regression coverage", () => { path: filePath, edits: [ { - sel: `${selector}@body`, + sel: `${selector}~`, op: "replace", content: 'let greeting = format!("Hello, {name}");\nprintln!("{greeting}");\ngreeting\n', }, @@ -116,7 +116,7 @@ describe("chunk mode regression coverage", () => { expect(updatedSource).not.toContain("## Inserted\n\ninserted body\n## Beta"); }); - it("preserves the blank line before the next markdown section on @body.append", async () => { + it("preserves the blank line before the next markdown section on ~.append", async () => { const filePath = path.join(tmpDir, "spacing-append.md"); await Bun.write(filePath, "# Title\n\n## Alpha\n\nalpha body\n\n## Beta\n\nbeta body\n"); const session = createSession(tmpDir); @@ -130,7 +130,7 @@ describe("chunk mode regression coverage", () => { path: filePath, edits: [ { - sel: `${selector}@body`, + sel: `${selector}~`, op: "append", content: "\nextra paragraph\n", }, diff --git a/packages/coding-agent/test/tools/chunk-mode.test.ts b/packages/coding-agent/test/tools/chunk-mode.test.ts index b9197eb4d..3264023d5 100644 --- a/packages/coding-agent/test/tools/chunk-mode.test.ts +++ b/packages/coding-agent/test/tools/chunk-mode.test.ts @@ -300,7 +300,7 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - sel: `class_Server.fn_handle#${checksum}@body`, + sel: `class_Server.fn_handle#${checksum}~`, op: "replace", content: 'if (flag) {\n\tconsole.log("tabbed");\n}\n', }, @@ -392,7 +392,7 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { - sel: "class_Server@body", + sel: "class_Server~", op: "append", content: 'status(): string {\n return "ok";\n}\n', }, diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 7bb4039c4..4fefd45db 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -447,14 +447,8 @@ export interface ChunkReadTarget { } export declare enum ChunkRegion { - Head = 'head', - Body = 'body', - Tail = 'tail', - /** - * The semantic declaration without leading trivia (comments, attributes). - * Spans from `checksum_start_byte` to `end_byte`. - */ - Decl = 'decl' + Head = '^', + Body = '~' } /** Clipboard image payload encoded as PNG bytes. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 4c481dcd1..865465b7c 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -266,10 +266,8 @@ module.exports.ChunkReadStatus = { UnsupportedRegion: 'unsupported_region', }; module.exports.ChunkRegion = { - Head: 'head', - Body: 'body', - Tail: 'tail', - Decl: 'decl', + Head: '^', + Body: '~', }; module.exports.Ellipsis = { Unicode: 0,