diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs index 0b95ce022..1f15290d3 100644 --- a/crates/pi-natives/src/chunk/edit.rs +++ b/crates/pi-natives/src/chunk/edit.rs @@ -2,16 +2,16 @@ use std::{cmp::Ordering, path::Path}; use crate::chunk::{ indent::{ - detect_common_indent, detect_file_indent_char, detect_file_indent_step, - normalize_leading_whitespace_char, reindent_inserted_block, strip_content_prefixes, + detect_file_indent_char, detect_file_indent_step, normalize_leading_whitespace_char, + reindent_inserted_block, strip_content_prefixes, }, resolve::{ - resolve_exact_chunk_selector, resolve_exact_chunk_with_crc, sanitize_chunk_selector, - sanitize_crc, + resolve_chunk_selector, resolve_chunk_with_crc, sanitize_chunk_selector, sanitize_crc, }, state::{ChunkState, ChunkStateInner}, types::{ - ChunkAnchorStyle, ChunkEditOp, ChunkNode, EditOperation, EditParams, EditResult, RenderParams, + ChunkAnchorStyle, ChunkEditOp, ChunkFocusMode, ChunkNode, EditOperation, EditParams, + EditResult, FocusedPath, RenderParams, }, }; @@ -31,6 +31,19 @@ enum InsertPosition { LastChild, } +#[derive(Clone, Copy)] +enum LeadingTriviaFamily { + SlashLineComment, + BlockComment, + HashComment, + DashDashComment, + SemicolonComment, + PercentComment, + AtAttribute, + RustAttribute, + BracketAttribute, +} + #[derive(Clone, Copy)] struct InsertSpacing { blank_line_before: bool, @@ -45,11 +58,17 @@ struct InsertionPoint { pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result { let original_text = normalize_chunk_source(state.inner().source()); - let mut state = - rebuild_chunk_state(original_text.clone(), state.inner().language().to_string())?; + let initial_notebook_ctx = state.inner().notebook.clone(); + let mut state = rebuild_chunk_state( + original_text.clone(), + state.inner().language().to_string(), + initial_notebook_ctx.clone(), + )?; let file_indent_step = detect_file_indent_step(&state.tree) as usize; let file_indent_char = detect_file_indent_char(&state.source, &state.tree); let initial_parse_errors = state.tree.parse_errors; + let initial_chunk_paths: std::collections::HashSet = + state.tree.chunks.iter().map(|c| c.path.clone()).collect(); let mut touched_paths = Vec::new(); let mut warnings = Vec::new(); let mut last_scheduled: Option = None; @@ -63,7 +82,9 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result Result Result apply_delete( &mut state, @@ -139,6 +120,18 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result apply_replace_body( + &mut state, + &operation, + &scheduled, + current_default_selector, + current_default_crc.as_deref(), + file_indent_step, + file_indent_char, + &mut touched_paths, + &mut warnings, ), ChunkEditOp::AppendChild | ChunkEditOp::PrependChild @@ -167,7 +160,8 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result Result"); + if let Some(chunk) = scheduled.initial_chunk.as_ref() { + vec![format!( + "L{}-L{} parse error introduced while editing {} (chunk spans file lines {}-{})", + chunk.start_line, chunk.end_line, chunk_label, chunk.start_line, chunk.end_line + )] + } else { + vec![format!("Parse error introduced while editing {chunk_label}")] + } } else { Vec::new() } @@ -217,11 +218,43 @@ pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result, + warnings: &mut Vec, ) -> Result<(), String> { let anchor_selector = operation.sel.as_deref().or(default_selector); let crc = operation.crc.as_deref().or_else(|| { @@ -261,55 +295,51 @@ fn apply_replace( // When auto-accepted, strip CRC so resolution finds by path only (CRC is stale // from pre-batch). let resolve_crc = if batch_auto_accepted { None } else { crc }; - let resolved = resolve_exact_chunk_with_crc(state, anchor_selector, resolve_crc)?; + let resolved = resolve_chunk_with_crc(state, anchor_selector, resolve_crc, warnings)?; if !batch_auto_accepted { validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?; } let anchor = resolved.chunk.clone(); - if let Some(line) = operation.line { - let abs_end = operation.end_line.unwrap_or(line); - validate_line_range(&anchor, line, abs_end)?; - let offsets = line_offsets(&state.source); - let abs_beg = line; - let range_start = line_start_offset(&offsets, abs_beg, &state.source); - let range_end = line_end_offset(&offsets, abs_end, &state.source); - let replaced_range = state.source[range_start..range_end].to_owned(); - let target_indent = if is_zero_width_insert(abs_beg, abs_end) { - let ind_a = detect_common_indent( - &state.source[line_start_offset(&offsets, abs_end, &state.source) - ..line_end_offset(&offsets, abs_end, &state.source)], - ) - .prefix; - let b_line = abs_beg.min(state.tree.line_count); - let ind_b = detect_common_indent( - &state.source[line_start_offset(&offsets, b_line, &state.source) - ..line_end_offset(&offsets, b_line, &state.source)], - ) - .prefix; - if ind_a.len() >= ind_b.len() { - ind_a - } else { - ind_b - } - } else { - detect_common_indent(&replaced_range).prefix + // Scoped find/replace: locate a literal substring inside the chunk and replace + // it. + if let Some(find) = operation.find.as_deref() { + if find.is_empty() { + return Err(format!( + "find/replace on {}: 'find' cannot be empty. Omit 'find' for whole-chunk replace.", + describe_scheduled_operation(scheduled) + )); + } + + let chunk_start = anchor.start_byte as usize; + let chunk_end = anchor.end_byte as usize; + let chunk_source = &state.source[chunk_start..chunk_end]; + let mut matches = chunk_source.match_indices(find); + let Some((rel_offset, _)) = matches.next() else { + return Err(format!( + "find/replace on {}: 'find' text not found inside chunk. Re-read the file to confirm \ + current content, or use whole-chunk replace.", + anchor.path + )); }; - let content = operation.content.as_deref().unwrap_or_default(); - let mut replacement = normalize_inserted_content( - content, - &target_indent, - Some(file_indent_step), - file_indent_char, - ); - if !replacement.is_empty() && !replacement.ends_with('\n') && abs_end < state.tree.line_count - { - replacement.push('\n'); - } - state.source = replace_range_by_lines(&state.source, abs_beg, abs_end, &replacement); - if replacement.is_empty() { - state.source = cleanup_blank_line_artifacts_at_offset(&state.source, range_start); + if matches.next().is_some() { + let total = 2 + chunk_source.match_indices(find).skip(2).count(); + return Err(format!( + "find/replace on {}: 'find' is ambiguous ({} matches in chunk). Extend 'find' with \ + surrounding context so exactly one match remains, or use whole-chunk replace.", + anchor.path, total + )); } + + let replacement = operation.content.as_deref().unwrap_or_default(); + let abs_start = chunk_start + rel_offset; + let abs_end = abs_start + find.len(); + let mut new_source = + String::with_capacity(state.source.len() - find.len() + replacement.len()); + new_source.push_str(&state.source[..abs_start]); + new_source.push_str(replacement); + new_source.push_str(&state.source[abs_end..]); + state.source = new_source; touched_paths.push(anchor.path); return Ok(()); } @@ -318,6 +348,7 @@ fn apply_replace( let content = operation.content.as_deref().unwrap_or_default(); let mut replacement = normalize_inserted_content(content, &target_indent, Some(file_indent_step), file_indent_char); + replacement = preserve_attached_leading_trivia(state, &anchor, &replacement); if !replacement.is_empty() && !replacement.ends_with('\n') && anchor.end_line < state.tree.line_count @@ -335,13 +366,18 @@ fn apply_replace( Ok(()) } -fn apply_delete( +/// Replace only the inner body of a chunk, preserving the signature line(s) +/// and closing delimiter. +fn apply_replace_body( state: &mut ChunkStateInner, operation: &EditOperation, scheduled: &ScheduledEditOperation, default_selector: Option<&str>, default_crc: Option<&str>, + file_indent_step: usize, + file_indent_char: char, touched_paths: &mut Vec, + warnings: &mut Vec, ) -> Result<(), String> { let anchor_selector = operation.sel.as_deref().or(default_selector); let crc = operation.crc.as_deref().or_else(|| { @@ -354,7 +390,98 @@ fn apply_delete( let requires_checksum = operation.sel.is_some() || default_crc.is_some(); let batch_auto_accepted = ensure_batch_operation_target_current(scheduled, crc, touched_paths); let resolve_crc = if batch_auto_accepted { None } else { crc }; - let resolved = resolve_exact_chunk_with_crc(state, anchor_selector, resolve_crc)?; + let resolved = resolve_chunk_with_crc(state, anchor_selector, resolve_crc, warnings)?; + if !batch_auto_accepted { + validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?; + } + let anchor = resolved.chunk.clone(); + + let body_start = anchor.body_start_byte.ok_or_else(|| { + format!( + "replace_body on {}: chunk has no body range (not a function/method/class).", + anchor.path + ) + })? as usize; + let body_end = anchor.body_end_byte.ok_or_else(|| { + format!( + "replace_body on {}: chunk has no body range (not a function/method/class).", + anchor.path + ) + })? as usize; + + // Determine inner body boundaries. For brace-delimited bodies (most + // languages), we want to replace only between the opening `{` and + // closing `}`. For Python (colon + indented block), body_start is + // already the first statement. + let body_slice = &state.source[body_start..body_end]; + + let (inner_start, inner_end) = if let Some(open_brace) = body_slice.find('{') { + // Find the closing brace from the end. + let close_brace = body_slice.rfind('}').unwrap_or(body_slice.len()); + let abs_open = body_start + open_brace + 1; // after '{' + let abs_close = body_start + close_brace; // before '}' + // Skip the newline after '{' if present. + let start = if state.source.as_bytes().get(abs_open) == Some(&b'\n') { + abs_open + 1 + } else { + abs_open + }; + // Include trailing newline before '}' if the close is on its own line. + let end = if abs_close > 0 && state.source.as_bytes().get(abs_close - 1) == Some(&b'\n') { + abs_close - 1 + } else { + abs_close + }; + (start, end.max(start)) + } else { + // No braces (e.g. Python): replace the entire body range. + (body_start, body_end) + }; + + // Determine the target indent level for the body content. + let target_indent_level = anchor.indent as usize + file_indent_step; + let target_indent = + std::iter::repeat_n(file_indent_char, target_indent_level).collect::(); + let content = operation.content.as_deref().unwrap_or_default(); + let replacement = + normalize_inserted_content(content, &target_indent, Some(file_indent_step), file_indent_char); + + // Build the new source: [before inner] + replacement + newline + [after inner] + let mut new_source = String::with_capacity(state.source.len()); + new_source.push_str(&state.source[..inner_start]); + if !replacement.is_empty() { + new_source.push_str(&replacement); + if !replacement.ends_with('\n') { + new_source.push('\n'); + } + } + new_source.push_str(&state.source[inner_end..]); + state.source = new_source; + touched_paths.push(anchor.path); + Ok(()) +} + +fn apply_delete( + state: &mut ChunkStateInner, + operation: &EditOperation, + scheduled: &ScheduledEditOperation, + default_selector: Option<&str>, + default_crc: Option<&str>, + touched_paths: &mut Vec, + warnings: &mut Vec, +) -> Result<(), String> { + let anchor_selector = operation.sel.as_deref().or(default_selector); + let crc = operation.crc.as_deref().or_else(|| { + if operation.sel.is_none() { + default_crc + } else { + None + } + }); + let requires_checksum = operation.sel.is_some() || default_crc.is_some(); + let batch_auto_accepted = ensure_batch_operation_target_current(scheduled, crc, touched_paths); + let resolve_crc = if batch_auto_accepted { None } else { crc }; + let resolved = resolve_chunk_with_crc(state, anchor_selector, resolve_crc, warnings)?; if !batch_auto_accepted { validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?; } @@ -389,7 +516,7 @@ fn apply_insert( }); let batch_auto_accepted = ensure_batch_operation_target_current(scheduled, crc, touched_paths); let resolve_crc = if batch_auto_accepted { None } else { crc }; - let resolved = resolve_exact_chunk_with_crc(state, anchor_selector, resolve_crc)?; + let resolved = resolve_chunk_with_crc(state, anchor_selector, resolve_crc, warnings)?; if !batch_auto_accepted { validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), resolved.crc.is_some())?; } @@ -400,7 +527,7 @@ fn apply_insert( ChunkEditOp::PrependChild => InsertPosition::FirstChild, ChunkEditOp::AppendSibling => InsertPosition::After, ChunkEditOp::PrependSibling => InsertPosition::Before, - ChunkEditOp::Replace | ChunkEditOp::Delete => { + ChunkEditOp::Replace | ChunkEditOp::Delete | ChunkEditOp::ReplaceBody => { return Err("Internal error: insert position requested for non-insert op".to_owned()); }, }; @@ -479,10 +606,23 @@ fn normalize_chunk_source(text: &str) -> String { .replace('\r', "\n") } -fn rebuild_chunk_state(source: String, language: String) -> Result { - let tree = crate::chunk::build_chunk_tree(source.as_str(), language.as_str()) - .map_err(|err| err.to_string())?; - Ok(ChunkStateInner::new(source, language, tree)) +fn rebuild_chunk_state( + source: String, + language: String, + notebook: Option, +) -> Result { + let tree = if let Some(ctx) = ¬ebook { + crate::chunk::ast_ipynb::build_notebook_tree_from_virtual( + source.as_str(), + ctx.kernel_language.as_str(), + )? + } else { + crate::chunk::build_chunk_tree(source.as_str(), language.as_str()) + .map_err(|err| err.to_string())? + }; + let mut inner = ChunkStateInner::new(source, language, tree); + inner.notebook = notebook; + Ok(inner) } fn validate_batch_crc(chunk: &ChunkNode, crc: Option<&str>, required: bool) -> Result<(), String> { @@ -523,82 +663,6 @@ const fn chunk_path_opt(chunk: &ChunkNode) -> &str { } } -fn validate_line_range(anchor: &ChunkNode, line: u32, end_line: u32) -> Result<(), String> { - let c_start = anchor.start_line; - let c_end = anchor.end_line; - let chunk_name = chunk_path_opt(anchor); - if line < 1 { - return Err(format!( - "Line {line} is invalid for {chunk_name}; line and end_line are absolute file line \ - numbers (1-indexed). This chunk spans file lines {c_start}-{c_end}." - )); - } - if line > end_line.saturating_add(1) { - return Err(format!( - "Invalid line range L{line}-L{end_line} for {chunk_name}: use line ≤ end_line to replace \ - lines, or line = end_line + 1 for zero-width insertion." - )); - } - if line <= end_line { - if line < c_start || end_line > c_end { - return Err(format!( - "Line range L{line}-L{end_line} is outside {chunk_name} (chunk spans file lines \ - {c_start}-{c_end}). Use absolute line numbers from read output." - )); - } - return Ok(()); - } - - let before_chunk = end_line == c_start.saturating_sub(1) && line == c_start; - let inside_gap = c_start <= end_line && end_line < c_end && line == end_line + 1; - let after_chunk = end_line == c_end && line == c_end + 1; - if before_chunk || inside_gap || after_chunk { - return Ok(()); - } - - Err(format!( - "Invalid zero-width insert L{line}-L{end_line} for {chunk_name} (chunk spans file lines \ - {c_start}-{c_end}). Use end_line = {}, line = {c_start} to insert before the first chunk \ - line; end_line = k, line = k + 1 with {c_start} ≤ k < {c_end} between interior lines; \ - end_line = {c_end}, line = {} to insert after the last chunk line.", - c_start.saturating_sub(1), - c_end + 1 - )) -} - -const fn is_zero_width_insert(line: u32, end_line: u32) -> bool { - line == end_line + 1 -} - -const fn zero_width_insert_sort_key(anchor: &ChunkNode, end_line: u32, line: u32) -> u32 { - let c_start = anchor.start_line; - if end_line == c_start.saturating_sub(1) && line == c_start { - c_start - } else { - end_line - } -} - -fn line_scoped_sort_key(scheduled: &ScheduledEditOperation) -> u32 { - let operation = &scheduled.operation; - let Some(line) = operation.line else { - return 0; - }; - let Some(anchor) = scheduled.initial_chunk.as_ref() else { - return 0; - }; - let abs_end = operation.end_line.unwrap_or(line); - if is_zero_width_insert(line, abs_end) { - zero_width_insert_sort_key(anchor, abs_end, line) - } else { - line - } -} - -fn is_line_scoped(operation: &EditOperation) -> bool { - operation.op == ChunkEditOp::Replace && operation.line.is_some() -} - fn touches_chunk_path(touched_paths: &[String], selector: &str) -> bool { touched_paths.iter().any(|touched| { touched == selector @@ -660,6 +724,85 @@ fn normalize_inserted_content( normalized } +fn preserve_attached_leading_trivia( + state: &ChunkStateInner, + anchor: &ChunkNode, + replacement: &str, +) -> String { + if replacement.is_empty() { + return replacement.to_owned(); + } + + let trivia_start = anchor.start_byte as usize; + let trivia_end = anchor.checksum_start_byte as usize; + if trivia_end <= trivia_start { + return replacement.to_owned(); + } + + let leading_trivia = &state.source[trivia_start..trivia_end]; + if leading_trivia.trim().is_empty() + || replacement.starts_with(leading_trivia) + || replacement_supplies_leading_trivia(leading_trivia, replacement) + { + return replacement.to_owned(); + } + + let mut combined = String::with_capacity(leading_trivia.len() + replacement.len()); + combined.push_str(leading_trivia); + combined.push_str(replacement); + combined +} + +fn replacement_supplies_leading_trivia(leading_trivia: &str, replacement: &str) -> bool { + let Some(family) = detect_leading_trivia_family(leading_trivia) else { + return false; + }; + let Some(first_non_empty_line) = replacement.lines().find(|line| !line.trim().is_empty()) else { + return false; + }; + matches_leading_trivia_family(family, first_non_empty_line.trim_start()) +} + +fn detect_leading_trivia_family(text: &str) -> Option { + let first_non_empty_line = text.lines().find(|line| !line.trim().is_empty())?; + let trimmed = first_non_empty_line.trim_start(); + if trimmed.starts_with("//") { + Some(LeadingTriviaFamily::SlashLineComment) + } else if trimmed.starts_with("/*") || trimmed.starts_with('*') { + Some(LeadingTriviaFamily::BlockComment) + } else if trimmed.starts_with("#[") { + Some(LeadingTriviaFamily::RustAttribute) + } else if trimmed.starts_with('@') { + Some(LeadingTriviaFamily::AtAttribute) + } else if trimmed.starts_with('[') && trimmed.ends_with(']') { + Some(LeadingTriviaFamily::BracketAttribute) + } else if trimmed.starts_with("--") { + Some(LeadingTriviaFamily::DashDashComment) + } else if trimmed.starts_with(';') { + Some(LeadingTriviaFamily::SemicolonComment) + } else if trimmed.starts_with('%') { + Some(LeadingTriviaFamily::PercentComment) + } else if trimmed.starts_with('#') { + Some(LeadingTriviaFamily::HashComment) + } else { + None + } +} + +fn matches_leading_trivia_family(family: LeadingTriviaFamily, line: &str) -> bool { + match family { + LeadingTriviaFamily::SlashLineComment => line.starts_with("//"), + LeadingTriviaFamily::BlockComment => line.starts_with("/*") || line.starts_with('*'), + LeadingTriviaFamily::HashComment => line.starts_with('#') && !line.starts_with("#["), + LeadingTriviaFamily::DashDashComment => line.starts_with("--"), + LeadingTriviaFamily::SemicolonComment => line.starts_with(';'), + LeadingTriviaFamily::PercentComment => line.starts_with('%'), + LeadingTriviaFamily::AtAttribute => line.starts_with('@'), + LeadingTriviaFamily::RustAttribute => line.starts_with("#["), + LeadingTriviaFamily::BracketAttribute => line.starts_with('[') && line.ends_with(']'), + } +} + fn line_offsets(text: &str) -> Vec { let mut offsets = vec![0usize]; for (index, ch) in text.char_indices() { @@ -1000,13 +1143,21 @@ fn get_insertion_point_for_position( }) }, InsertPosition::FirstChild => { - if !anchor.path.is_empty() && anchor.leaf && !is_container_like_chunk(anchor) { + if !anchor.path.is_empty() + && anchor.leaf + && !is_container_like_chunk(anchor) + && !anchor.group + { return Err(format!("Cannot use prepend_child on leaf chunk {}", anchor.path)); } Ok(get_insertion_point(state, anchor, Ordering::Less, insertion_content)) }, InsertPosition::LastChild => { - if !anchor.path.is_empty() && anchor.leaf && !is_container_like_chunk(anchor) { + if !anchor.path.is_empty() + && anchor.leaf + && !is_container_like_chunk(anchor) + && !anchor.group + { return Err(format!("Cannot use append_child on leaf chunk {}", anchor.path)); } Ok(get_insertion_point(state, anchor, Ordering::Greater, insertion_content)) @@ -1050,6 +1201,52 @@ fn container_has_interior_content(state: &ChunkStateInner, anchor: &ChunkNode) - .any(|line| !line.trim().is_empty()) } +/// Returns true if a container's children should be separated by blank lines. +/// Root-level children (functions, classes) and containers with non-leaf +/// children (methods) want blank line spacing. Containers whose children are +/// all packed declarations (struct fields, enum variants) are tightly packed. +fn children_want_blank_line_spacing(state: &ChunkStateInner, anchor: &ChunkNode) -> bool { + // Root children are always top-level declarations, separated by blank lines. + if anchor.path.is_empty() { + return true; + } + // If the container has no chunk children, fall back to spaced (preserves + // existing behavior for containers with interior content but no parsed + // children). + if anchor.children.is_empty() { + return true; + } + // Packed children are declarations that belong tightly together without + // blank line separators: struct fields, enum variants, etc. + let all_packed = anchor.children.iter().all(|child_path| { + state + .tree + .chunks + .iter() + .any(|c| c.path == *child_path && is_packed_child(&c.name)) + }); + !all_packed +} + +/// Returns true if a chunk name indicates a packed (tightly-spaced) child. +/// These are declarations like struct fields and enum variants that don't +/// need blank line separators between them. +fn is_packed_child(name: &str) -> bool { + name.starts_with("field_") || name.starts_with("variant_") +} + +/// Returns true if sibling insertions around `anchor` should have blank line +/// spacing. Checks whether the anchor's parent container uses spaced or packed +/// layout. +fn is_spaced_sibling(state: &ChunkStateInner, anchor: &ChunkNode) -> bool { + let parent_path = anchor.parent_path.as_deref().unwrap_or(""); + if let Some(parent) = state.tree.chunks.iter().find(|c| c.path == parent_path) { + children_want_blank_line_spacing(state, parent) + } else { + true // Default to spaced if parent not found + } +} + fn compute_insert_spacing( state: &ChunkStateInner, anchor: &ChunkNode, @@ -1057,21 +1254,27 @@ fn compute_insert_spacing( ) -> InsertSpacing { let has_interior_content = container_has_interior_content(state, anchor); match pos { - InsertPosition::FirstChild => InsertSpacing { - blank_line_before: false, - blank_line_after: !anchor.children.is_empty() || has_interior_content, + InsertPosition::FirstChild => { + let spaced = children_want_blank_line_spacing(state, anchor); + InsertSpacing { + blank_line_before: false, + blank_line_after: spaced && (!anchor.children.is_empty() || has_interior_content), + } }, - InsertPosition::LastChild => InsertSpacing { - blank_line_before: !anchor.children.is_empty() || has_interior_content, - blank_line_after: false, + InsertPosition::LastChild => { + let spaced = children_want_blank_line_spacing(state, anchor); + InsertSpacing { + blank_line_before: spaced && (!anchor.children.is_empty() || has_interior_content), + blank_line_after: false, + } }, InsertPosition::Before => InsertSpacing { - blank_line_before: has_sibling_before(state, anchor), - blank_line_after: true, + blank_line_before: has_sibling_before(state, anchor) && is_spaced_sibling(state, anchor), + blank_line_after: is_spaced_sibling(state, anchor), }, InsertPosition::After => InsertSpacing { - blank_line_before: true, - blank_line_after: has_sibling_after(state, anchor), + blank_line_before: is_spaced_sibling(state, anchor), + blank_line_after: has_sibling_after(state, anchor) && is_spaced_sibling(state, anchor), }, } } @@ -1191,8 +1394,9 @@ fn display_path_for_file(file_path: &str, cwd: &str) -> String { /// A parsed unified diff hunk. struct DiffHunk { - header: String, - lines: Vec, + header: String, + lines: Vec, + new_start: u32, } /// Generate unified diff hunks between two texts using the `similar` crate. @@ -1225,51 +1429,163 @@ fn generate_diff_hunks(before: &str, after: &str, context: usize) -> Vec, + touched_paths: &[String], ) -> String { - let show_leaf_preview = state.language == "tlaplus"; - let tree_text = crate::chunk::render::render_state(state, &RenderParams { - chunk_path: Some(String::new()), - title: display_path.to_owned(), - language_tag: Some(state.language.clone()), - visible_range: None, - render_children_only: true, - omit_checksum: false, - anchor_style, - show_leaf_preview, - tab_replacement: Some(" ".to_owned()), - }); + use std::collections::HashMap; + let show_leaf_preview = state.language == "tlaplus"; + let focused_paths = compute_focus(state.tree(), touched_paths); let hunks = generate_diff_hunks(before, after, 0); - if hunks.is_empty() { + + // Map each hunk to the chunk that should display it. + // Walk from the deepest containing chunk upward until we find one that + // has children (and therefore a closing tag in the tree output). + let tree = state.tree(); + let tab_replacement = " "; + let lookup: HashMap<&str, &ChunkNode> = + tree.chunks.iter().map(|c| (c.path.as_str(), c)).collect(); + + let mut inline_hunks: HashMap> = HashMap::new(); + let mut orphan_hunks: Vec<&DiffHunk> = Vec::new(); + + for hunk in &hunks { + // Find the deepest chunk containing this hunk's new-file start line. + let owner = crate::chunk::render::find_hunk_owner_chunk(tree, &lookup, hunk.new_start); + match owner { + Some(chunk_path) => { + let indent = crate::chunk::render::hunk_indent_for_chunk( + &lookup, + chunk_path, + state.source(), + tab_replacement, + ); + let mut lines = Vec::with_capacity(hunk.lines.len() + 1); + lines.push(format!("{indent}{}", hunk.header)); + for line in &hunk.lines { + lines.push(format!("{indent}{line}")); + } + inline_hunks + .entry(chunk_path.to_owned()) + .or_default() + .push(crate::chunk::render::InlineHunk { lines }); + }, + None => orphan_hunks.push(hunk), + } + } + + let tree_text = crate::chunk::render::render_state_with_hunks( + state, + &RenderParams { + chunk_path: Some(String::new()), + title: display_path.to_owned(), + language_tag: Some(state.language.clone()), + visible_range: None, + render_children_only: true, + omit_checksum: false, + anchor_style, + show_leaf_preview, + tab_replacement: Some(tab_replacement.to_owned()), + focused_paths, + }, + inline_hunks, + ); + + if orphan_hunks.is_empty() { return tree_text; } - let diff_text = hunks - .into_iter() + // Append orphan hunks (not belonging to any named chunk) at the end. + let orphan_text = orphan_hunks + .iter() .flat_map(|hunk| { let mut lines = Vec::with_capacity(hunk.lines.len() + 1); - lines.push(hunk.header); - lines.extend(hunk.lines); + lines.push(hunk.header.clone()); + lines.extend(hunk.lines.iter().cloned()); lines }) .collect::>() .join("\n"); - format!("{tree_text}\n\n{diff_text}") + format!("{tree_text}\n\n{orphan_text}") +} + +/// Build a focus list that includes touched chunks as Expanded, their +/// immediate siblings as Collapsed, and all ancestors as Container. +/// Falls back to no focus (full render) when more than 20 chunks were touched. +fn compute_focus( + tree: &crate::chunk::types::ChunkTree, + touched: &[String], +) -> Option> { + use std::collections::HashMap; + + if touched.is_empty() || touched.len() > 20 { + return None; + } + + let lookup: HashMap<&str, &ChunkNode> = + tree.chunks.iter().map(|c| (c.path.as_str(), c)).collect(); + let mut focus: HashMap = HashMap::new(); + + for path in touched { + let Some(chunk) = lookup.get(path.as_str()) else { + continue; + }; + focus.insert(path.clone(), ChunkFocusMode::Expanded); + + // Ancestors -> Container (don't downgrade Expanded). + let mut current = chunk.parent_path.as_deref(); + while let Some(parent_path) = current { + focus + .entry(parent_path.to_string()) + .or_insert(ChunkFocusMode::Container); + current = lookup + .get(parent_path) + .and_then(|p| p.parent_path.as_deref()); + } + + // Immediate prev/next sibling -> Collapsed. + if let Some(parent_path) = chunk.parent_path.as_deref() + && let Some(parent) = lookup.get(parent_path) + && let Some(idx) = parent.children.iter().position(|p| p == path) + { + if idx > 0 { + focus + .entry(parent.children[idx - 1].clone()) + .or_insert(ChunkFocusMode::Collapsed); + } + if idx + 1 < parent.children.len() { + focus + .entry(parent.children[idx + 1].clone()) + .or_insert(ChunkFocusMode::Collapsed); + } + } + } + + // Root chunk must always be Container so the walk starts. + focus + .entry(String::new()) + .or_insert(ChunkFocusMode::Container); + + Some( + focus + .into_iter() + .map(|(path, mode)| FocusedPath { path, mode }) + .collect(), + ) } fn render_unchanged_response( @@ -1285,6 +1601,7 @@ fn render_unchanged_response( render_children_only: true, omit_checksum: false, anchor_style, + focused_paths: None, show_leaf_preview: true, tab_replacement: Some(" ".to_owned()), }) @@ -1308,12 +1625,11 @@ mod tests { let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { - op: ChunkEditOp::Replace, - sel: Some("fn_main".to_owned()), - crc: Some(chunk.checksum.clone()), - content: Some("fn main() {\n println!(\"new\");\n}".to_owned()), - line: None, - end_line: None, + op: ChunkEditOp::Replace, + sel: Some("fn_main".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("fn main() {\n println!(\"new\");\n}".to_owned()), + find: None, }], default_selector: None, default_crc: None, @@ -1336,32 +1652,21 @@ mod tests { } #[test] - fn edit_rejects_non_canonical_chunk_paths() { - let source = "class Worker { - run(): void { - console.log(this.name); - } -} -"; + fn edit_auto_resolves_unique_chunk_paths() { + let source = "class Worker {\n\trun(): void {\n\t\tconsole.log(this.name);\n\t}\n}\n"; let state = state_for(source, "typescript"); let chunk = state .inner() .chunk("class_Worker.fn_run") .expect("class_Worker.fn_run should exist"); - let err = apply_edits(&state, &EditParams { + let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { - op: ChunkEditOp::Replace, - sel: Some("run".to_owned()), - crc: Some(chunk.checksum.clone()), - content: Some( - "run(): void { - console.log(this.name); -}" - .to_owned(), - ), - line: None, - end_line: None, + op: ChunkEditOp::Replace, + sel: Some("run".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("run(): void {\n\tconsole.log(\"resolved\");\n}".to_owned()), + find: None, }], default_selector: None, default_crc: None, @@ -1369,58 +1674,107 @@ mod tests { cwd: ".".to_owned(), file_path: "test.ts".to_owned(), }) - .err() - .expect("edit should reject non-canonical selector"); + .expect("edit should resolve a unique fuzzy selector"); - assert!(err.contains("Chunk path not found: \"run\""), "{err}"); + assert!( + result.diff_after.contains("console.log(\"resolved\");"), + "expected updated body text, got {:?}", + result.diff_after + ); + assert!( + result.warnings.iter().any(|warning| warning + .contains("Auto-resolved chunk selector \"run\" to \"class_Worker.fn_run#")), + "expected auto-resolution warning, got {:?}", + result.warnings + ); } #[test] - fn selector_less_root_checksum_targets_file_root() { - let source = "# Contributing to uLua - -## Building and Testing - -Use just. - -## Code Style - -Use clang-format. -"; - let state = state_for(source, "markdown"); - let root = state.inner().chunk("").expect("root chunk should exist"); - let duplicate_count = state - .chunks() - .into_iter() - .filter(|chunk| chunk.checksum == root.checksum) - .count(); - assert!(duplicate_count > 1, "expected duplicate root checksum fixture"); + fn edit_auto_resolves_prefixed_function_names() { + let source = "function fuzzyMatch(): void {\n\tconsole.log(\"old\");\n}\n"; + let state = state_for(source, "typescript"); + let chunk = state + .inner() + .chunk("fn_fuzzyMatch") + .expect("fn_fuzzyMatch should exist"); let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { - op: ChunkEditOp::Replace, - sel: Some(String::new()), - crc: Some(root.checksum.clone()), - content: Some("# Updated guide".to_owned()), - line: Some(1), - end_line: Some(1), + op: ChunkEditOp::Replace, + sel: Some("fuzzyMatch".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some( + "function fuzzyMatch(): void {\n\tconsole.log(\"resolved\");\n}".to_owned(), + ), + find: None, }], default_selector: None, default_crc: None, anchor_style: None, cwd: ".".to_owned(), - file_path: "CONTRIBUTING.md".to_owned(), + file_path: "box.ts".to_owned(), }) - .expect("root checksum target should resolve the file root"); + .expect("edit should resolve a prefixed bare selector"); - assert!( - result.diff_after.starts_with( - "# Updated guide -" - ), - "{}", - result.diff_after - ); + assert!(result.diff_after.contains("console.log(\"resolved\");"), "{}", result.diff_after); + assert!(result.warnings.iter().any(|warning| { + warning.contains("Auto-resolved chunk selector \"fuzzyMatch\" to \"fn_fuzzyMatch#") + })); + } + + #[test] + fn edit_accepts_file_prefixed_checksum_targets() { + let source = "function main(): void {\n\tconsole.log(\"old\");\n}\n"; + let state = state_for(source, "typescript"); + let chunk = state + .inner() + .chunk("fn_main") + .expect("fn_main should exist"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("box.ts".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("function main(): void {\n\tconsole.log(\"normalized\");\n}".to_owned()), + find: None, + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "box.ts".to_owned(), + }) + .expect("edit should resolve file-prefixed checksum target"); + + assert!(result.diff_after.contains("console.log(\"normalized\");"), "{}", result.diff_after); + } + + #[test] + fn edit_rejects_line_number_targets_with_clear_error() { + let source = "function main(): void {\n\tconsole.log(\"old\");\n}\n"; + let state = state_for(source, "typescript"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("L2".to_owned()), + crc: None, + content: Some("function main(): void {\n\tconsole.log(\"new\");\n}".to_owned()), + find: None, + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "box.ts".to_owned(), + }); + let Err(err) = result else { + panic!("line-number selectors should be rejected"); + }; + + assert!(err.contains("Line-number targets are not supported in chunk mode"), "{err}"); + assert!(err.contains("fn_foo#ABCD"), "{err}"); } #[test] @@ -1434,12 +1788,11 @@ Use clang-format. let result = apply_edits(&state, &EditParams { operations: vec![EditOperation { - op: ChunkEditOp::Replace, - sel: Some("section_Top.section_Building".to_owned()), - crc: Some(chunk.checksum.clone()), - content: Some("## Building\n\nNew content.\n".to_owned()), - line: None, - end_line: None, + op: ChunkEditOp::Replace, + sel: Some("section_Top.section_Building".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("## Building\n\nNew content.\n".to_owned()), + find: None, }], default_selector: None, default_crc: None, @@ -1460,4 +1813,231 @@ Use clang-format. result.diff_after, ); } + + #[test] + fn find_replace_single_match() { + let source = "fn main() {\n println!(\"hello\");\n println!(\"world\");\n}\n"; + let state = state_for(source, "rust"); + let chunk = state.inner().chunk("fn_main").expect("fn_main"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("fn_main".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("warn!(\"hello\")".to_owned()), + find: Some("println!(\"hello\")".to_owned()), + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + }) + .expect("edit should apply"); + + assert!( + result.diff_after.contains("warn!(\"hello\")"), + "expected replacement, got {:?}", + result.diff_after + ); + assert!( + result.diff_after.contains("println!(\"world\")"), + "non-matched line must survive, got {:?}", + result.diff_after + ); + } + + #[test] + fn find_replace_not_found() { + let source = "fn main() {\n println!(\"hello\");\n}\n"; + let state = state_for(source, "rust"); + let chunk = state.inner().chunk("fn_main").expect("fn_main"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("fn_main".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("replacement".to_owned()), + find: Some("nonexistent text".to_owned()), + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + }); + + assert!(result.is_err(), "expected error for not-found find text"); + assert!( + result + .err() + .expect("err") + .contains("not found inside chunk"), + "error should mention not found" + ); + } + + #[test] + fn find_replace_ambiguous() { + let source = "fn main() {\n let a = 1;\n let b = 1;\n let c = 1;\n}\n"; + let state = state_for(source, "rust"); + let chunk = state.inner().chunk("fn_main").expect("fn_main"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("fn_main".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("2".to_owned()), + find: Some("= 1".to_owned()), + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + }); + + assert!(result.is_err(), "expected error for ambiguous find text"); + let err = result.err().expect("err"); + assert!(err.contains("ambiguous"), "error should mention ambiguous: {err}"); + assert!(err.contains("3 matches"), "error should report count: {err}"); + } + + #[test] + fn find_replace_empty_find_rejected() { + let source = "fn main() {\n println!(\"hello\");\n}\n"; + let state = state_for(source, "rust"); + let chunk = state.inner().chunk("fn_main").expect("fn_main"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("fn_main".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("replacement".to_owned()), + find: Some(String::new()), + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + }); + + assert!(result.is_err(), "expected error for empty find text"); + assert!(result.err().expect("err").contains("cannot be empty"), "error should mention empty"); + } + + #[test] + fn find_replace_respects_chunk_bounds() { + // 'hello' appears in fn_greet but NOT in fn_main. Searching fn_main should + // fail. + let source = "fn greet() {\n println!(\"hello\");\n}\n\nfn main() {\n greet();\n}\n"; + let state = state_for(source, "rust"); + let chunk = state.inner().chunk("fn_main").expect("fn_main"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("fn_main".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("goodbye".to_owned()), + find: Some("hello".to_owned()), + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.rs".to_owned(), + }); + + assert!(result.is_err(), "find outside target chunk should fail"); + assert!( + result + .err() + .expect("err") + .contains("not found inside chunk") + ); + } + + #[test] + fn focus_emits_only_touched_and_siblings() { + let source = "const a = 1;\n\nconst b = 2;\n\nconst c = 3;\n\nconst d = 4;\n\nconst e = 5;\n"; + let state = state_for(source, "typescript"); + let chunk = state.inner().chunk("var_c").expect("var_c"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::Replace, + sel: Some("var_c".to_owned()), + crc: Some(chunk.checksum.clone()), + content: Some("const c = 33;".to_owned()), + find: None, + }], + default_selector: None, + default_crc: None, + anchor_style: Some(ChunkAnchorStyle::Full), + cwd: ".".to_owned(), + file_path: "test.ts".to_owned(), + }) + .expect("edit should apply"); + + // Touched chunk and immediate siblings should appear; distant chunks should + // not contribute their bodies. + let response = &result.response_text; + assert!(response.contains("var_b"), "prev sibling should appear: {response}"); + assert!(response.contains("var_c"), "touched chunk should appear: {response}"); + assert!(response.contains("var_d"), "next sibling should appear: {response}"); + assert!( + !response.contains("const a"), + "distant chunk var_a body should not appear: {response}" + ); + assert!( + !response.contains("const e"), + "distant chunk var_e body should not appear: {response}" + ); + } + + #[test] + fn append_child_on_group_chunk_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"); + let stmts = state + .inner() + .tree + .chunks + .iter() + .find(|c| c.name.starts_with("stmts")) + .expect("stmts chunk should exist"); + assert!(stmts.group, "stmts chunk should be marked as group"); + + let result = apply_edits(&state, &EditParams { + operations: vec![EditOperation { + op: ChunkEditOp::AppendChild, + sel: Some(stmts.path.clone()), + crc: None, + content: Some("\nit(\"b\", () => {});".to_owned()), + find: None, + }], + default_selector: None, + default_crc: None, + anchor_style: None, + cwd: ".".to_owned(), + file_path: "test.ts".to_owned(), + }) + .expect("append_child on group chunk should succeed"); + + assert!( + result.diff_after.contains("it(\"b\""), + "appended content should appear in output, got: {}", + result.diff_after + ); + } } diff --git a/crates/pi-natives/src/chunk/indent.rs b/crates/pi-natives/src/chunk/indent.rs index 44c3996fb..36dfc5e01 100644 --- a/crates/pi-natives/src/chunk/indent.rs +++ b/crates/pi-natives/src/chunk/indent.rs @@ -3,38 +3,6 @@ use crate::chunk::types::ChunkTree; const DEFAULT_SPACE_INDENT_STEP: usize = 4; const MAX_REASONABLE_INDENT_STEP: usize = 8; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommonIndent { - pub prefix: String, - pub count: usize, -} - -pub fn detect_common_indent(text: &str) -> CommonIndent { - let mut min_count = usize::MAX; - let mut detected_char = None; - - for line in text.split('\n') { - if line.trim().is_empty() { - continue; - } - let whitespace = leading_whitespace(line); - if whitespace.is_empty() { - return CommonIndent { prefix: String::new(), count: 0 }; - } - if whitespace.len() < min_count { - min_count = whitespace.len(); - detected_char = whitespace.chars().next(); - } - } - - if min_count == usize::MAX { - return CommonIndent { prefix: String::new(), count: 0 }; - } - - let ch = detected_char.unwrap_or(' '); - CommonIndent { prefix: ch.to_string().repeat(min_count), count: min_count } -} - pub fn dedent_python_style(text: &str) -> String { let mut margin: Option<&str> = None; for line in text.split('\n') { @@ -512,10 +480,14 @@ mod tests { line_count: 1, start_byte: 0, end_byte: 0, + checksum_start_byte: 0, + body_start_byte: None, + body_end_byte: None, checksum: "ABCD".to_owned(), error: false, indent, indent_char: indent_char.to_owned(), + group: false, } } diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index 3f982867f..859c4330f 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -19,24 +19,40 @@ pub(crate) mod state; pub mod types; // Per-language classifiers +mod ast_astro; mod ast_bash_make_diff; mod ast_c_cpp_objc; mod ast_clojure; +mod ast_cmake; mod ast_csharp_java; mod ast_css; mod ast_data_formats; +mod ast_dockerfile; mod ast_elixir; +mod ast_erlang; mod ast_go; +mod ast_graphql; mod ast_haskell_scala; mod ast_html_xml; +mod ast_ini; +pub(crate) mod ast_ipynb; mod ast_js_ts; +mod ast_just; mod ast_markup; mod ast_misc; mod ast_nix_hcl; +mod ast_ocaml; +mod ast_perl; +mod ast_powershell; +mod ast_proto; mod ast_python; +mod ast_r; mod ast_ruby_lua; mod ast_rust; +mod ast_sql; +mod ast_svelte; mod ast_tlaplus; +mod ast_vue; use std::collections::HashMap; @@ -79,6 +95,15 @@ pub(crate) fn build_chunk_tree(source: &str, language: &str) -> Result Result ChunkTree { let mut chunks = vec![ChunkNode { - path: String::new(), - name: "root".to_string(), - leaf: false, - parent_path: None, - children: Vec::new(), - signature: None, - start_line: u32::from(total_lines != 0), - end_line: total_lines as u32, - line_count: total_lines as u32, - start_byte: 0, - end_byte: source.len() as u32, - checksum: checksum.clone(), - error: false, - indent: 0, - indent_char: String::new(), + path: String::new(), + name: "root".to_string(), + leaf: false, + parent_path: None, + children: Vec::new(), + signature: None, + start_line: u32::from(total_lines != 0), + end_line: total_lines as u32, + line_count: total_lines as u32, + start_byte: 0, + end_byte: source.len() as u32, + checksum_start_byte: 0, + body_start_byte: None, + body_end_byte: None, + checksum: checksum.clone(), + error: false, + indent: 0, + indent_char: String::new(), + group: false, }]; let line_starts = line_start_offsets(source); let mut root_children = Vec::new(); @@ -216,26 +249,30 @@ fn build_blank_line_tree( let end_byte = line_end_offset(source, &line_starts, end_line); root_children.push(name.clone()); chunks.push(ChunkNode { - path: name.clone(), - name: name.clone(), - leaf: true, - parent_path: Some(String::new()), - children: Vec::new(), - signature: None, - start_line: (start_line + 1) as u32, - end_line: (end_line + 1) as u32, - line_count: (end_line - start_line + 1) as u32, - start_byte: start_byte as u32, - end_byte: end_byte as u32, - checksum: chunk_checksum( + path: name.clone(), + name: name.clone(), + leaf: true, + parent_path: Some(String::new()), + children: Vec::new(), + signature: None, + start_line: (start_line + 1) as u32, + end_line: (end_line + 1) as u32, + line_count: (end_line - start_line + 1) as u32, + 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, + checksum: chunk_checksum( source .as_bytes() .get(start_byte..end_byte) .unwrap_or_default(), ), - error: false, - indent: 0, - indent_char: String::new(), + error: false, + indent: 0, + indent_char: String::new(), + group: false, }); start_line = end_line + 1; } @@ -281,6 +318,7 @@ 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 child_candidates = recurse .map(|recurse| { collect_children_for_context(recurse.node, recurse.context, source, classifier) @@ -321,10 +359,14 @@ fn build_chunk( line_count: line_count as u32, 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), checksum, error: candidate.error, indent, indent_char, + group: candidate.groupable, }); path } @@ -608,7 +650,7 @@ fn infer_fallback_block_name(first_line: &str, seen: &mut HashMap } } -fn line_start_offsets(source: &str) -> Vec { +pub(crate) fn line_start_offsets(source: &str) -> Vec { let mut starts = vec![0usize]; for (index, byte) in source.bytes().enumerate() { if byte == b'\n' { @@ -699,65 +741,19 @@ fn insert_preamble_chunk( line_count, start_byte, end_byte, + checksum_start_byte: start_byte, + body_start_byte: None, + body_end_byte: None, checksum, error: false, indent: 0, indent_char: String::new(), + group: false, }; chunks.push(preamble); root_children.insert(0, "preamble".to_string()); } -pub(crate) fn sort_chunk_children_by_position(chunks: &mut [ChunkNode]) { - let positions = chunks - .iter() - .map(|chunk| (chunk.path.clone(), (chunk.start_line, chunk.end_line))) - .collect::>(); - for chunk in chunks.iter_mut() { - chunk.children.sort_by(|left, right| { - let left_pos = positions.get(left).copied().unwrap_or((u32::MAX, u32::MAX)); - let right_pos = positions - .get(right) - .copied() - .unwrap_or((u32::MAX, u32::MAX)); - left_pos.cmp(&right_pos).then_with(|| left.cmp(right)) - }); - } -} - -pub(crate) fn rename_chunk_subtree( - chunks: &mut [ChunkNode], - old_prefix: &str, - new_prefix: &str, - new_parent_path: &str, -) { - let old_child_prefix = format!("{old_prefix}."); - for chunk in chunks.iter_mut() { - if chunk.path == old_prefix { - chunk.path = new_prefix.to_string(); - chunk.parent_path = Some(new_parent_path.to_string()); - } else if let Some(rest) = chunk.path.strip_prefix(old_child_prefix.as_str()) { - chunk.path = format!("{new_prefix}.{rest}"); - } - - if let Some(parent_path) = chunk.parent_path.as_mut() { - if parent_path == old_prefix { - *parent_path = new_prefix.to_string(); - } else if let Some(rest) = parent_path.strip_prefix(old_child_prefix.as_str()) { - *parent_path = format!("{new_prefix}.{rest}"); - } - } - - for child_path in &mut chunk.children { - if child_path == old_prefix { - *child_path = new_prefix.to_string(); - } else if let Some(rest) = child_path.strip_prefix(old_child_prefix.as_str()) { - *child_path = format!("{new_prefix}.{rest}"); - } - } - } -} - // ── Tests ──────────────────────────────────────────────────────────────── #[cfg(test)] @@ -806,22 +802,29 @@ mod tests { #[test] fn builds_structural_tree_for_each_supported_language() { let cases = [ + ("astro", "---\nconst title = \"Hello\";\n---\n

{title}

\n"), ("bash", "build() { echo ok; }\n"), ("c", "#include \nint main(void) { return 0; }\n"), + ("cmake", "cmake_minimum_required(VERSION 3.28)\nproject(App)\nfunction(run_it NAME)\n message(STATUS ${NAME})\nendfunction()\n"), ("cpp", "#include \nclass App {};\nint main() { return 0; }\n"), ("csharp", "using System;\nclass App { void Run() {} }\n"), ("clojure", "(ns demo.core)\n(defn greet [x] x)\n"), ("css", "@import \"a.css\";\n.app { color: red; }\n"), ("diff", "@@ -1,1 +1,1 @@\n-a\n+b\n"), + ("dockerfile", "FROM alpine AS base\nARG PORT=3000\nRUN echo hi\nCMD [\"sh\", \"-c\", \"echo ok\"]\n"), ("elixir", "defmodule App do\n def run(x) do\n x\n end\nend\n"), + ("erlang", "-module(app).\n-export([run/1]).\nrun(X) ->\n case X of\n ok -> ok;\n _ -> error\n end.\n"), ("go", "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"ok\") }\n"), + ("graphql", "type Query { hello: String }\nquery AppQuery { hello }\n"), ("handlebars", "{{#if ready}}
{{name}}
{{/if}}\n"), ("haskell", "module App where\nimport Data.List\nmain = putStrLn \"ok\"\n"), ("hcl", "locals { foo = 1 }\n"), ("html", "
ok
\n"), + ("ini", "[app]\nname=demo\nport=3000\n"), ("java", "import java.util.*;\nclass App { void run() {} }\n"), ("javascript", "import x from \"x\";\nexport function run() {}\n"), ("json", "{\"name\":\"app\",\"scripts\":{\"start\":\"bun\"}}\n"), + ("just", "set shell := [\"bash\", \"-cu\"]\nrun name:\n echo {{name}}\n"), ("julia", "module App\nfunction run(x)\n x\nend\nend\n"), ("kotlin", "package app\nclass App { fun run() {} }\n"), ("lua", "local function run(x) return x end\n"), @@ -832,25 +835,32 @@ mod tests { "objc", "#import \n@interface App : NSObject\n- (void)run;\n@end\n", ), + ("ocaml", "open Printf\nlet run x = x + 1\nmodule App = struct let value = 1 end\n"), ("odin", "package main\nmain :: proc() {}\n"), + ("perl", "package App;\nuse strict;\nsub run { return 1; }\n"), ("php", "let count = 0;\n{#if count}

{count}

{/if}\n"), ("swift", "import Foundation\nclass App { func run() {} }\n"), ("toml", "[package]\nname = \"app\"\n"), ( "tlaplus", - "---- MODULE Spec ----\nVARIABLE x\n\n(* --algorithm Demo\nvariables x = 0;\nbegin\n \ - Inc:\n x := x + 1;\nend algorithm; *)\n====\n", + "---- MODULE Spec ----\nVARIABLE x\n\n(* --algorithm Demo\nvariables x = 0;\nbegin\n Inc:\n x := x + 1;\nend algorithm; *)\n====\n", ), ("tsx", "export function App() { return
; }\n"), ("typescript", "export function run(): void {}\n"), ("verilog", "module app; endmodule\n"), + ("vue", "\n\n"), ("xml", "\n"), ("yaml", "apiVersion: v1\nmetadata:\n name: app\n"), ("zig", "const std = @import(\"std\");\npub fn main() void {}\n"), @@ -1417,124 +1427,6 @@ impl Config { ); } - #[test] - fn go_receiver_methods_attach_to_receiver_type() { - let source = r"package main - - type Server struct { - Addr string - } - - func (s *Server) Start() { - println(s.Addr) - } - - func (s Server) Stop() {} - "; - let tree = build_chunk_tree(source, "go").expect("tree should build"); - assert!( - tree - .root_children - .iter() - .any(|child| child == "type_Server"), - "expected type_Server in root children: {:?}", - tree.root_children - ); - assert!( - !tree - .root_children - .iter() - .any(|child| child == "fn_Start" || child == "fn_Stop"), - "receiver methods should not remain at root: {:?}", - tree.root_children - ); - let server = tree - .chunks - .iter() - .find(|c| c.path == "type_Server") - .expect("type_Server"); - assert!(!server.leaf); - assert!( - server - .children - .iter() - .any(|child| child == "type_Server.field_Addr"), - "expected type_Server.field_Addr in children: {:?}", - server.children - ); - assert!( - server - .children - .iter() - .any(|child| child == "type_Server.fn_Start"), - "expected type_Server.fn_Start in children: {:?}", - server.children - ); - assert!( - server - .children - .iter() - .any(|child| child == "type_Server.fn_Stop"), - "expected type_Server.fn_Stop in children: {:?}", - server.children - ); - let line_path = line_to_chunk_path(&tree, 7).expect("method line should resolve"); - assert_eq!(line_path, "type_Server.fn_Start"); - } - - #[test] - fn go_new_constructor_attaches_to_type_and_orders_by_line() { - let source = r"package main - -type Server struct { - Addr string -} - -func NewServer() *Server { - return &Server{} -} - -func (s *Server) GetAddress() string { - return s.Addr -} -"; - let tree = build_chunk_tree(source, "go").expect("tree should build"); - assert!( - !tree.root_children.iter().any(|c| c == "fn_NewServer"), - "constructor should not stay at root: {:?}", - tree.root_children - ); - let server = tree - .chunks - .iter() - .find(|c| c.path == "type_Server") - .expect("type_Server"); - assert!( - server - .children - .iter() - .any(|c| c == "type_Server.fn_NewServer"), - "expected type_Server.fn_NewServer in {:?}", - server.children - ); - let new_server = tree - .chunks - .iter() - .find(|c| c.path == "type_Server.fn_NewServer") - .expect("fn_NewServer chunk"); - let get_addr = tree - .chunks - .iter() - .find(|c| c.path == "type_Server.fn_GetAddress") - .expect("fn_GetAddress chunk"); - assert!( - new_server.start_line < get_addr.start_line, - "constructor line should sort before receiver method: new={} get={}", - new_server.start_line, - get_addr.start_line - ); - } - #[test] fn preamble_chunk_covers_leading_lines_before_first_item() { let source = "// header\n// second\n\nfn main() {}\n"; @@ -1822,12 +1714,12 @@ func (s *Server) Start() string { let before_method = before_tree .chunks .iter() - .find(|chunk| chunk.path == "type_Server.fn_Start") + .find(|chunk| chunk.path == "fn_Start") .expect("before method chunk"); let after_method = after_tree .chunks .iter() - .find(|chunk| chunk.path == "type_Server.fn_Start") + .find(|chunk| chunk.path == "fn_Start") .expect("after method chunk"); assert_eq!(before_struct.checksum, after_struct.checksum); assert_ne!(before_method.checksum, after_method.checksum); diff --git a/crates/pi-natives/src/chunk/render.rs b/crates/pi-natives/src/chunk/render.rs index b4c771667..47efdb2f1 100644 --- a/crates/pi-natives/src/chunk/render.rs +++ b/crates/pi-natives/src/chunk/render.rs @@ -3,13 +3,21 @@ use std::collections::HashMap; use crate::{ chunk::{ state::{ChunkStateInner, mask_chunk_display_source}, - types::{ChunkAnchorStyle, ChunkNode, ChunkTree, RenderParams, VisibleLineRange}, + types::{ + ChunkAnchorStyle, ChunkFocusMode, ChunkNode, ChunkTree, RenderParams, VisibleLineRange, + }, }, env_uint, }; type ChunkLookup<'a> = HashMap<&'a str, &'a ChunkNode>; +/// A pre-formatted diff hunk ready for inline display inside a chunk block. +pub struct InlineHunk { + /// Fully indented lines (header + diff lines) ready to push as meta lines. + pub lines: Vec, +} + env_uint! { // Configured full display threshold. static FULL_DISPLAY_THRESHOLD: usize = "PI_CHUNK_FULL_DISPLAY_THRESHOLD" or 80 => [1, usize::MAX]; @@ -36,6 +44,10 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { let preview_tail_lines = *PREVIEW_TAIL_LINES; let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" "); let anchor_style = params.anchor_style.unwrap_or_default(); + let focus: Option> = params + .focused_paths + .as_ref() + .map(|paths| paths.iter().map(|fp| (fp.path.as_str(), fp.mode)).collect()); let num_width = compute_num_width( tree, chunk, @@ -78,6 +90,8 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { preview_head_lines, preview_tail_lines, tab_replacement, + focus, + inline_hunks: HashMap::new(), }; push_line( @@ -97,8 +111,9 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { push_blank_meta(&mut ctx); if params.render_children_only { + let focus_ref = ctx.focus.as_ref(); let children = - visible_children_for_chunk(tree, chunk, &lookup, params.visible_range.as_ref()); + visible_children_for_chunk(tree, chunk, &lookup, params.visible_range.as_ref(), focus_ref); for (index, child) in children.iter().enumerate() { emit_chunk_subtree(&mut ctx, child, 0, ChunkSubtreeOptions { is_first_top_level: index == 0, @@ -108,7 +123,7 @@ pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { return ctx.out; } - if chunk.children.is_empty() { + if chunk.children.is_empty() && ctx.focus.is_none() { if params.show_leaf_preview && intersect_visible_span(chunk, params.visible_range.as_ref()).is_some() { @@ -202,6 +217,7 @@ fn visible_children_for_chunk<'a>( chunk: &'a ChunkNode, lookup: &ChunkLookup<'a>, visible_range: Option<&VisibleLineRange>, + focus: Option<&HashMap<&str, ChunkFocusMode>>, ) -> Vec<&'a ChunkNode> { let mut children = chunk .children @@ -212,6 +228,7 @@ fn visible_children_for_chunk<'a>( chunk_or_descendant_intersects_line_range(tree, child, lookup, range) }) }) + .filter(|child| focus.is_none_or(|map| map.contains_key(child.path.as_str()))) .collect::>(); children.sort_unstable_by_key(|child| child.start_line); children @@ -355,7 +372,7 @@ fn should_render_gap_line( if chunk.path.is_empty() { return true; } - let children = visible_children_for_chunk(tree, chunk, lookup, None); + let children = visible_children_for_chunk(tree, chunk, lookup, None, None); let has_out_of_span_child = children .iter() .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); @@ -381,7 +398,7 @@ fn for_each_rendered_source_line( preview_tail_lines: usize, visit: &mut impl FnMut(u32), ) { - let children = visible_children_for_chunk(tree, chunk, lookup, visible_range); + let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); let span = intersect_visible_span(chunk, visible_range); let has_kids = !children.is_empty(); @@ -490,7 +507,7 @@ fn compute_rendered_line_count( chunk.line_count as usize }; } - let children = visible_children_for_chunk(tree, chunk, lookup, visible_range); + let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); let has_out_of_span_child = children .iter() .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); @@ -531,6 +548,8 @@ struct RenderCtx<'a> { preview_head_lines: usize, preview_tail_lines: usize, tab_replacement: &'a str, + focus: Option>, + inline_hunks: HashMap>, } fn push_line(out: &mut String, line: String) { @@ -605,6 +624,17 @@ fn emit_leaf_body(ctx: &mut RenderCtx<'_>, _chunk: &ChunkNode, span: VisibleSpan } } +/// Emit any inline diff hunks mapped to the given chunk path. +fn emit_inline_hunks_for(ctx: &mut RenderCtx<'_>, chunk_path: &str) { + let lines: Vec = match ctx.inline_hunks.get(chunk_path) { + Some(hunks) => hunks.iter().flat_map(|h| h.lines.iter().cloned()).collect(), + None => return, + }; + for line in lines { + push_meta(ctx, line); + } +} + struct ChunkSubtreeOptions { is_first_top_level: bool, between_top_level_definitions: bool, @@ -616,7 +646,33 @@ fn emit_chunk_subtree( depth: usize, options: ChunkSubtreeOptions, ) { - let children = visible_children_for_chunk(ctx.tree, chunk, ctx.lookup, ctx.visible_range); + // Focus mode gate: skip unfocused chunks, collapse siblings, pass through + // containers and expanded. + if let Some(focus_map) = ctx.focus.as_ref() + && !chunk.path.is_empty() + { + match focus_map.get(chunk.path.as_str()) { + None => return, + Some(ChunkFocusMode::Collapsed) => { + 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 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())); + return; + }, + Some(ChunkFocusMode::Container | ChunkFocusMode::Expanded) => { + // fall through to normal rendering + }, + } + } + + let focus_ref = ctx.focus.as_ref(); + let children = + visible_children_for_chunk(ctx.tree, chunk, ctx.lookup, ctx.visible_range, focus_ref); let span = intersect_visible_span(chunk, ctx.visible_range); let has_kids = !children.is_empty(); @@ -638,15 +694,21 @@ fn emit_chunk_subtree( { emit_leaf_body(ctx, chunk, span); } - // Closing tag for single-line leaves is omitted (only multi-line chunks get - // them) return; } + + let is_container = ctx + .focus + .as_ref() + .and_then(|f| f.get(chunk.path.as_str())) + .copied() + == Some(ChunkFocusMode::Container); + if let Some(span) = span { let mut cursor = chunk.start_line; for child in children { let gap_end = child.start_line.saturating_sub(1); - if gap_end >= cursor { + if gap_end >= cursor && !is_container { for line in cursor..=gap_end { if line_in_file_scope(line, ctx.visible_range) && should_render_gap_line(ctx.tree, chunk, ctx.lookup, line) @@ -661,11 +723,15 @@ fn emit_chunk_subtree( }); cursor = cursor.max(child.end_line.saturating_add(1)); } - if cursor <= span.end { + if cursor <= span.end && !is_container { emit_line_gap(ctx, cursor, span.end); } - // Closing tag for multi-line chunks with children - if !chunk.path.is_empty() && chunk.line_count > 1 { + // Emit inline diff hunks before the closing tag. + if !chunk.path.is_empty() { + emit_inline_hunks_for(ctx, &chunk.path); + } + // 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 style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum); let anchor_label = chunk_anchor_label(chunk, style); @@ -700,7 +766,7 @@ fn compute_num_width( if render_children_only { return tree.line_count.to_string().len().max(1); } - let children = visible_children_for_chunk(tree, chunk, lookup, visible_range); + let children = visible_children_for_chunk(tree, chunk, lookup, visible_range, None); let has_out_of_span_child = children .iter() .any(|child| child.start_line < chunk.start_line || child.end_line > chunk.end_line); @@ -725,3 +791,169 @@ fn compute_num_width( ); max_line.to_string().len().max(1) } + +/// Find the chunk that should own a diff hunk for inline display. +/// +/// Walks from the deepest chunk containing `line` upward until it finds a +/// chunk with children (which will have a closing tag in the tree output). +/// Returns the chunk path, or `None` for file-root orphans. +pub fn find_hunk_owner_chunk<'a>( + tree: &'a ChunkTree, + lookup: &ChunkLookup<'a>, + line: u32, +) -> Option<&'a str> { + let deepest = line_to_containing_chunk(tree, line)?; + // If the deepest chunk has children, it gets a closing tag — use it. + if !deepest.children.is_empty() { + return Some(&deepest.path); + } + // Leaf: promote to parent (which has children and a closing tag). + if let Some(parent_path) = deepest.parent_path.as_deref() + && let Some(parent) = lookup.get(parent_path) + && !parent.path.is_empty() + { + return Some(&parent.path); + } + // Root-level leaf — no parent with a closing tag. + None +} + +/// Compute the indentation string for inline hunks placed inside a chunk. +/// +/// Uses the chunk's own body anchor indent plus one additional level, which +/// aligns hunks at the same depth as the chunk's child anchors. +pub fn hunk_indent_for_chunk( + lookup: &ChunkLookup<'_>, + chunk_path: &str, + source: &str, + tab_replacement: &str, +) -> 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}") +} + +/// Render a chunk tree with diff hunks inlined into their owning chunk blocks. +pub fn render_state_with_hunks( + state: &ChunkStateInner, + params: &RenderParams, + inline_hunks: HashMap>, +) -> String { + let tree = state.tree(); + let lookup = build_lookup(tree); + let chunk_path = params + .chunk_path + .as_deref() + .unwrap_or(tree.root_path.as_str()); + let Some(chunk) = get_chunk(&lookup, chunk_path) else { + return String::new(); + }; + let masked_source = mask_chunk_display_source(state.source(), state.language()); + let source_lines = masked_source.split('\n').collect::>(); + let full_display_threshold = *FULL_DISPLAY_THRESHOLD; + 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 anchor_style = params.anchor_style.unwrap_or_default(); + let focus: Option> = params + .focused_paths + .as_ref() + .map(|paths| paths.iter().map(|fp| (fp.path.as_str(), fp.mode)).collect()); + let num_width = compute_num_width( + tree, + chunk, + &lookup, + params.visible_range.as_ref(), + params.render_children_only, + params.show_leaf_preview, + &source_lines, + tab_replacement, + full_display_threshold, + preview_head_lines, + preview_tail_lines, + ); + let rendered_line_count = compute_rendered_line_count( + tree, + chunk, + &lookup, + params.visible_range.as_ref(), + params.render_children_only, + params.show_leaf_preview, + &source_lines, + tab_replacement, + full_display_threshold, + preview_head_lines, + preview_tail_lines, + ); + + let mut ctx = RenderCtx { + out: String::new(), + tree, + lookup: &lookup, + source_lines: &source_lines, + num_width, + visible_range: params.visible_range.as_ref(), + omit_checksum: params.omit_checksum, + anchor_style, + show_leaf_preview: params.show_leaf_preview, + last_was_blank_meta: false, + full_display_threshold, + preview_head_lines, + preview_tail_lines, + tab_replacement, + focus, + inline_hunks, + }; + + push_line( + &mut ctx.out, + format!( + "{}| {}", + " ".repeat(num_width), + format_header_meta( + params.title.as_str(), + rendered_line_count, + params.language_tag.as_deref(), + chunk.checksum.as_str(), + params.omit_checksum, + ) + ), + ); + push_blank_meta(&mut ctx); + + if params.render_children_only { + let focus_ref = ctx.focus.as_ref(); + let children = + visible_children_for_chunk(tree, chunk, &lookup, params.visible_range.as_ref(), focus_ref); + for (index, child) in children.iter().enumerate() { + emit_chunk_subtree(&mut ctx, child, 0, ChunkSubtreeOptions { + is_first_top_level: index == 0, + between_top_level_definitions: true, + }); + } + // Emit any hunks mapped to the root (empty path). + emit_inline_hunks_for(&mut ctx, ""); + return ctx.out; + } + + if chunk.children.is_empty() && ctx.focus.is_none() { + if params.show_leaf_preview + && intersect_visible_span(chunk, params.visible_range.as_ref()).is_some() + { + emit_chunk_subtree(&mut ctx, chunk, 0, ChunkSubtreeOptions { + is_first_top_level: true, + between_top_level_definitions: false, + }); + } + return ctx.out; + } + + emit_chunk_subtree(&mut ctx, chunk, 0, ChunkSubtreeOptions { + is_first_top_level: true, + between_top_level_definitions: false, + }); + ctx.out +} diff --git a/crates/pi-natives/src/chunk/resolve.rs b/crates/pi-natives/src/chunk/resolve.rs index d0531dd22..5fea2a64e 100644 --- a/crates/pi-natives/src/chunk/resolve.rs +++ b/crates/pi-natives/src/chunk/resolve.rs @@ -59,6 +59,13 @@ pub fn split_selector_and_crc( 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 { @@ -75,14 +82,6 @@ pub fn resolve_chunk_selector<'a>( resolve_chunk_selector_impl(state, cleaned_selector.as_deref(), cleaned_crc.as_deref(), warnings) } -pub fn resolve_exact_chunk_selector<'a>( - state: &'a ChunkStateInner, - selector: Option<&str>, -) -> Result<&'a ChunkNode, String> { - let (cleaned_selector, cleaned_crc) = split_selector_and_crc(selector, None); - resolve_chunk_selector_exact_impl(state, cleaned_selector.as_deref(), cleaned_crc.as_deref()) -} - pub fn resolve_chunk_with_crc<'a>( state: &'a ChunkStateInner, selector: Option<&str>, @@ -102,35 +101,6 @@ pub fn resolve_chunk_with_crc<'a>( Ok(ResolvedChunk { chunk, crc: cleaned_crc }) } -pub fn resolve_exact_chunk_with_crc<'a>( - state: &'a ChunkStateInner, - selector: Option<&str>, - crc: Option<&str>, -) -> Result, String> { - let (cleaned_selector, cleaned_crc) = split_selector_and_crc(selector, crc); - - if cleaned_selector.is_none() { - let chunk = root_chunk(state)?; - if let Some(cleaned_crc) = cleaned_crc.as_deref() { - if chunk.checksum != cleaned_crc { - return Err(format!( - "Root checksum \"{cleaned_crc}\" did not match the current file root. Re-read the \ - file and copy the root checksum from the header line." - )); - } - return Ok(ResolvedChunk { chunk, crc: Some(cleaned_crc.to_owned()) }); - } - return Ok(ResolvedChunk { chunk, crc: None }); - } - - let chunk = resolve_chunk_selector_exact_impl( - state, - cleaned_selector.as_deref(), - cleaned_crc.as_deref(), - )?; - Ok(ResolvedChunk { chunk, crc: cleaned_crc }) -} - pub fn resolve_chunk_by_checksum<'a>( state: &'a ChunkStateInner, crc: &str, @@ -148,11 +118,7 @@ pub fn resolve_chunk_by_checksum<'a>( matches.len(), matches .iter() - .map(|chunk| if chunk.path.is_empty() { - "" - } else { - chunk.path.as_str() - }) + .map(|chunk| format_node_ref(chunk)) .collect::>() .join(", "), )), @@ -175,6 +141,13 @@ fn resolve_chunk_selector_impl<'a>( return root_chunk(state); }; + if is_line_number_selector(cleaned) { + return Err(format!( + "Line-number targets are not supported in chunk mode. Use chunk paths like fn_foo#ABCD \ + instead of \"{cleaned}\"." + )); + } + if let Some(chunk) = state.chunk(cleaned) { return match_crc_filter(cleaned, vec![chunk], crc); } @@ -245,22 +218,6 @@ fn resolve_chunk_selector_impl<'a>( Err(build_not_found_error(state.tree(), cleaned)) } -fn resolve_chunk_selector_exact_impl<'a>( - state: &'a ChunkStateInner, - selector: Option<&str>, - crc: Option<&str>, -) -> Result<&'a ChunkNode, String> { - let Some(cleaned) = selector else { - return root_chunk(state); - }; - - let Some(chunk) = state.chunk(cleaned) else { - return Err(build_not_found_error(state.tree(), cleaned)); - }; - - match_crc_filter(cleaned, vec![chunk], crc) -} - fn match_crc_filter<'a>( cleaned: &str, matches: Vec<&'a ChunkNode>, @@ -269,20 +226,24 @@ fn match_crc_filter<'a>( let Some(cleaned_crc) = crc else { return Ok(matches[0]); }; - let filtered = filter_by_crc(matches, cleaned_crc); + let filtered = filter_by_crc(&matches, cleaned_crc); match filtered.len() { 1 => Ok(filtered[0]), - 0 => Err(format!( - "Chunk selector \"{cleaned}\" did not match checksum \"{cleaned_crc}\". Re-read the file \ - to get current checksums." - )), + 0 => { + let actual = matches + .iter() + .map(|chunk| format_node_ref(chunk)) + .collect::>() + .join(", "); + Err(format!("Stale checksum \"{cleaned_crc}\" for \"{cleaned}\". Current: {actual}.")) + }, _ => Err(format!( "Ambiguous chunk selector \"{cleaned}\" with checksum \"{cleaned_crc}\" matches {} \ chunks: {}. Use the full path from read output.", filtered.len(), filtered .iter() - .map(|chunk| chunk.path.as_str()) + .map(|chunk| format_node_ref(chunk)) .collect::>() .join(", "), )), @@ -298,11 +259,16 @@ fn resolve_matches<'a>( warning_label: &str, ) -> Result<&'a ChunkNode, String> { let matches = if let Some(cleaned_crc) = crc { - let filtered = filter_by_crc(matches, cleaned_crc); + let filtered = filter_by_crc(&matches, cleaned_crc); if filtered.is_empty() { + let actual = matches + .iter() + .map(|chunk| format_node_ref(chunk)) + .collect::>() + .join(", "); return Err(format!( - "{selector_label} \"{cleaned}\" did not match checksum \"{cleaned_crc}\". Re-read the \ - file to get current checksums." + "Stale checksum \"{cleaned_crc}\" for {selector_label} \"{cleaned}\". Current: \ + {actual}." )); } filtered @@ -320,10 +286,11 @@ fn resolve_matches<'a>( ) } -fn filter_by_crc<'a>(matches: Vec<&'a ChunkNode>, crc: &str) -> Vec<&'a ChunkNode> { +fn filter_by_crc<'a>(matches: &[&'a ChunkNode], crc: &str) -> Vec<&'a ChunkNode> { matches - .into_iter() + .iter() .filter(|chunk| chunk.checksum == crc) + .copied() .collect() } @@ -367,7 +334,7 @@ fn resolve_unique_chunks<'a>( 1 => { warnings.push(format!( "{warning_label} \"{cleaned}\" to \"{}\". Use the full path from read output.", - matches[0].path + format_node_ref(matches[0]) )); Ok(Some(matches[0])) }, @@ -377,7 +344,7 @@ fn resolve_unique_chunks<'a>( matches.len(), matches .iter() - .map(|chunk| chunk.path.as_str()) + .map(|chunk| format_node_ref(chunk)) .collect::>() .join(", "), )), @@ -406,6 +373,24 @@ fn strip_known_chunk_prefix(segment: &str) -> Option<&str> { .find_map(|prefix| segment.strip_prefix(prefix)) } +/// Format a chunk path with its CRC suffix, e.g. `fn_start#ABCD`. +fn format_chunk_ref(tree: &ChunkTree, path: &str) -> String { + if let Some(chunk) = find_chunk_by_path(tree, path) { + format!("{}#{}", path, chunk.checksum) + } else { + path.to_owned() + } +} + +/// Format a `ChunkNode` as `path#CRC`. +fn format_node_ref(chunk: &ChunkNode) -> String { + if chunk.path.is_empty() { + format!("#{}", chunk.checksum) + } else { + format!("{}#{}", chunk.path, chunk.checksum) + } +} + fn build_not_found_error(tree: &ChunkTree, cleaned: &str) -> String { let (direct_children_parent, direct_children, matched_empty_prefix) = matching_prefix_context(tree, cleaned); @@ -413,12 +398,17 @@ fn build_not_found_error(tree: &ChunkTree, cleaned: &str) -> String { .chunks .iter() .filter(|chunk| !chunk.path.is_empty() && !chunk.path.contains('.')) - .map(|chunk| chunk.path.as_str()) + .map(format_node_ref) .collect::>(); let similarity = suggest_chunk_paths(tree, cleaned, 8); let hint = if let Some(parent) = direct_children_parent { - format!(" Direct children of \"{parent}\": {}.", direct_children.join(", ")) + let children_with_crc = direct_children + .iter() + .map(|child| format_chunk_ref(tree, child)) + .collect::>() + .join(", "); + format!(" Direct children of \"{parent}\": {children_with_crc}.") } else if let Some(prefix) = matched_empty_prefix { if similarity.is_empty() { format!(" The prefix \"{prefix}\" exists but has no child chunks.") @@ -478,20 +468,20 @@ fn suggest_chunk_paths(tree: &ChunkTree, query: &str, limit: usize) -> Vec 0.1) + .map(|chunk| (&chunk.path, &chunk.checksum, chunk_path_similarity(query, &chunk.path))) + .filter(|(_, _, score)| *score > 0.1) .collect::>(); scored.sort_by(|left, right| { right - .1 - .partial_cmp(&left.1) + .2 + .partial_cmp(&left.2) .unwrap_or(Ordering::Equal) .then_with(|| left.0.cmp(right.0)) }); scored .into_iter() .take(limit) - .map(|(path, _)| path.to_owned()) + .map(|(path, checksum, _)| format!("{path}#{checksum}")) .collect() } @@ -522,6 +512,31 @@ fn chunk_path_similarity(query: &str, candidate: &str) -> f64 { } } +fn looks_like_file_target(selector: &str) -> bool { + if selector.contains('/') || selector.contains('\\') { + return true; + } + + let Some((base, ext)) = selector.rsplit_once('.') else { + return false; + }; + !base.is_empty() && !ext.is_empty() && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) +} + +fn is_line_number_selector(selector: &str) -> bool { + let Some(rest) = selector.strip_prefix('L') else { + return false; + }; + let Some((start, end)) = rest.split_once('-') else { + return !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()); + }; + if start.is_empty() || !start.chars().all(|ch| ch.is_ascii_digit()) { + return false; + } + let end = end.strip_prefix('L').unwrap_or(end); + !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; @@ -576,3 +591,92 @@ fn chunk_read_path_separator_index(value: &str) -> Option { }; Some(start) } + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk( + path: &str, + checksum: &str, + parent_path: Option<&str>, + children: Vec<&str>, + ) -> ChunkNode { + ChunkNode { + path: path.to_owned(), + name: path.rsplit('.').next().unwrap_or(path).to_owned(), + leaf: children.is_empty(), + parent_path: parent_path.map(str::to_owned), + children: children.into_iter().map(str::to_owned).collect(), + signature: None, + start_line: 1, + end_line: 1, + line_count: 1, + start_byte: 0, + end_byte: 0, + checksum_start_byte: 0, + body_start_byte: None, + body_end_byte: None, + checksum: checksum.to_owned(), + error: false, + indent: 0, + indent_char: " ".to_owned(), + group: false, + } + } + + fn state_for_resolution() -> ChunkStateInner { + ChunkStateInner::new(String::new(), "typescript".to_owned(), ChunkTree { + language: "typescript".to_owned(), + checksum: "ROOT".to_owned(), + line_count: 1, + parse_errors: 0, + fallback: false, + root_path: String::new(), + root_children: vec!["fn_handleTerraform".to_owned()], + chunks: vec![ + chunk("", "ROOT", None, vec!["fn_handleTerraform"]), + chunk("fn_handleTerraform", "HVJB", Some(""), vec!["fn_handleTerraform.try"]), + chunk("fn_handleTerraform.try", "RQPB", Some("fn_handleTerraform"), vec![ + "fn_handleTerraform.try.if_2", + ]), + chunk("fn_handleTerraform.try.if_2", "PKPV", Some("fn_handleTerraform.try"), vec![ + "fn_handleTerraform.try.if_2.loop", + ]), + chunk( + "fn_handleTerraform.try.if_2.loop", + "MZRS", + Some("fn_handleTerraform.try.if_2"), + vec!["fn_handleTerraform.try.if_2.loop.if_2"], + ), + chunk( + "fn_handleTerraform.try.if_2.loop.if_2", + "QKJY", + Some("fn_handleTerraform.try.if_2.loop"), + vec![], + ), + ], + }) + } + + #[test] + fn resolves_requested_chunk_selector_forms() { + let state = state_for_resolution(); + let selectors = [ + "fn_handleTerraform.try.if_2#PKPV", + "fn_handleTerraform.try.if_2", + "handleTerraform.try.if_2", + "if_2", + "if_2#PKPV", + "#PKPV", + "PKPV", + ]; + + for selector in selectors { + let mut warnings = Vec::new(); + let resolved = resolve_chunk_with_crc(&state, Some(selector), None, &mut warnings) + .unwrap_or_else(|err| panic!("selector {selector} should resolve: {err}")); + assert_eq!(resolved.chunk.path, "fn_handleTerraform.try.if_2"); + } + } +} diff --git a/crates/pi-natives/src/chunk/state.rs b/crates/pi-natives/src/chunk/state.rs index 22dd22330..dccf01a6a 100644 --- a/crates/pi-natives/src/chunk/state.rs +++ b/crates/pi-natives/src/chunk/state.rs @@ -34,6 +34,7 @@ pub struct ChunkStateInner { pub(crate) source: String, pub(crate) language: String, pub(crate) tree: ChunkTree, + pub(crate) notebook: Option, lookup: HashMap, checksum_lookup: HashMap>, leaf_lookup: HashMap>, @@ -43,6 +44,20 @@ pub struct ChunkStateInner { impl ChunkStateInner { pub(crate) fn parse(source: String, language: String) -> Result { let normalized_language = normalize_language(language.as_str()); + if normalized_language == "ipynb" { + let parsed = + crate::chunk::ast_ipynb::parse_notebook(&source).map_err(napi::Error::from_reason)?; + let kernel_lang = parsed.context.kernel_language.clone(); + let tree = crate::chunk::ast_ipynb::build_notebook_tree_from_virtual( + parsed.virtual_source.as_str(), + kernel_lang.as_str(), + ) + .map_err(napi::Error::from_reason)?; + let ctx = std::sync::Arc::new(parsed.context); + let mut inner = Self::new(parsed.virtual_source, normalized_language, tree); + inner.notebook = Some(ctx); + return Ok(inner); + } let tree = build_chunk_tree(source.as_str(), normalized_language.as_str())?; Ok(Self::new(source, normalized_language, tree)) } @@ -75,7 +90,16 @@ impl ChunkStateInner { .push(index); } } - Self { source, language, tree, lookup, checksum_lookup, leaf_lookup, suffix_lookup } + Self { + source, + language, + tree, + notebook: None, + lookup, + checksum_lookup, + leaf_lookup, + suffix_lookup, + } } pub(crate) const fn source(&self) -> &str { @@ -345,6 +369,7 @@ impl ChunkState { anchor_style: params.anchor_style, show_leaf_preview: true, tab_replacement: params.tab_replacement, + focused_paths: None, }); return Ok(ReadResult { text: format!("{notice}\n\n{text}"), chunk: None }); } @@ -361,6 +386,7 @@ impl ChunkState { anchor_style: params.anchor_style, show_leaf_preview: true, tab_replacement: params.tab_replacement, + focused_paths: None, }), chunk: None, }); @@ -430,6 +456,7 @@ impl ChunkState { anchor_style: params.anchor_style, show_leaf_preview: true, tab_replacement: params.tab_replacement, + focused_paths: None, }), chunk: Some(ChunkReadTarget { status: ChunkReadStatus::Ok, diff --git a/crates/pi-natives/src/chunk/types.rs b/crates/pi-natives/src/chunk/types.rs index 033c84699..f9d6fd4cf 100644 --- a/crates/pi-natives/src/chunk/types.rs +++ b/crates/pi-natives/src/chunk/types.rs @@ -6,21 +6,33 @@ use crate::chunk::state::ChunkState; #[derive(Clone)] pub struct ChunkNode { - pub path: String, - pub name: String, - pub leaf: bool, - pub parent_path: Option, - pub children: Vec, - pub signature: Option, - pub start_line: u32, - pub end_line: u32, - pub line_count: u32, - pub start_byte: u32, - pub end_byte: u32, + pub path: String, + pub name: String, + pub leaf: bool, + pub parent_path: Option, + pub children: Vec, + pub signature: Option, + pub start_line: u32, + pub end_line: u32, + pub line_count: u32, + pub start_byte: u32, + pub end_byte: u32, + /// Start byte of the semantic declaration used for checksums. This can be + /// later than `start_byte` when the chunk absorbs attached leading trivia + /// such as doc comments or attributes. + pub checksum_start_byte: u32, + + pub body_start_byte: Option, + pub body_end_byte: Option, + pub checksum: String, pub error: bool, pub indent: u32, pub indent_char: String, + /// True for group-candidate chunks (e.g. `stmts`, `imports`, `decls`) that + /// represent an ordered list of similar items. Append/prepend is valid on + /// these even when they are leaf nodes. + pub group: bool, } #[derive(Clone)] @@ -71,7 +83,7 @@ pub enum ChunkReadStatus { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[napi(string_enum)] pub enum ChunkEditOp { - /// Replace the chunk body, or a line range when `line`/`endLine` are set. + /// Replace the chunk body, or a substring via `find`. #[napi(value = "replace")] Replace, /// Remove the chunk's source range. @@ -89,6 +101,10 @@ pub enum ChunkEditOp { /// 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, } impl ChunkEditOp { @@ -100,6 +116,7 @@ impl ChunkEditOp { Self::PrependChild => "prepend_child", Self::AppendSibling => "append_sibling", Self::PrependSibling => "prepend_sibling", + Self::ReplaceBody => "replace_body", } } } @@ -195,6 +212,30 @@ impl ChunkAnchorStyle { } } +/// How a chunk participates in a focus-scoped render pass. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi(string_enum)] +pub enum ChunkFocusMode { + /// Emit full content and recurse normally. + #[napi(value = "expanded")] + Expanded, + /// Emit just the opening anchor; do not recurse or emit body. + #[napi(value = "collapsed")] + Collapsed, + /// Emit opening + closing anchors; recurse into focused children only. + /// Interior gap lines between children are suppressed. + #[napi(value = "container")] + Container, +} + +/// Path + focus mode pair for the N-API boundary (`HashMap` doesn't cross FFI). +#[derive(Clone, Debug)] +#[napi(object)] +pub struct FocusedPath { + pub path: String, + pub mode: ChunkFocusMode, +} + /// Options for `ChunkState.render`: which subtree to show and how anchors /// appear. #[derive(Clone)] @@ -226,6 +267,11 @@ pub struct RenderParams { /// Replace tab characters in displayed previews (e.g. two spaces). #[napi(js_name = "tabReplacement")] pub tab_replacement: Option, + + /// When set, restrict rendering to these chunks with their specified focus + /// modes. Everything not in this list is skipped. + #[napi(js_name = "focusedPaths")] + pub focused_paths: Option>, } /// Options for `ChunkState.renderRead`: selector path, display path, and @@ -272,28 +318,25 @@ pub struct ReadResult { #[napi(object)] pub struct EditOperation { /// Edit kind (replace, delete, insert relative to anchor). - pub op: ChunkEditOp, + pub op: ChunkEditOp, /// Chunk selector path; falls back to `EditParams.defaultSelector` when /// omitted. - pub sel: Option, + pub sel: Option, /// Optional checksum anchor; falls back to `EditParams.defaultCrc` when /// omitted. - pub crc: Option, + pub crc: Option, /// Replacement or inserted text (meaning depends on `op`). - pub content: Option, - /// For line-scoped `replace`, 1-based start line inside the target chunk. - pub line: Option, - /// For line-scoped `replace`, 1-based end line inside the chunk (defaults to - /// `line`). - #[napi(js_name = "endLine")] - pub end_line: Option, + pub content: Option, + /// For scoped find/replace: literal substring to locate inside the target + /// chunk. Must match exactly once. Pairs with `content` as the replacement. + pub find: Option, } /// Arguments for applying a batch of chunk edits to a file. #[derive(Clone)] #[napi(object)] pub struct EditParams { - /// Edits to apply in order (scheduling may reorder line-scoped groups). + /// Edits to apply in order. pub operations: Vec, /// Default chunk selector when an `EditOperation` omits `sel`. #[napi(js_name = "defaultSelector")] diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e7314ccbd..cae2c4634 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Deferred diagnostics support in LSP writethrough: `onDeferredDiagnostics` callback and `deferredSignal` in `WritethroughOptions` allow callers to receive diagnostics that arrive after the main 5-second timeout @@ -24,6 +23,9 @@ ### Changed +- Reorganized edit tool implementation from `patch/` to `edit/` directory structure with dedicated mode subdirectories (`edit/modes/chunk.ts`, `edit/modes/hashline.ts`, `edit/modes/patch.ts`, `edit/modes/replace.ts`) +- Updated package.json exports to use `./edit` path instead of `./patch` for edit tool and related utilities +- Chunk edit tool documentation simplified: removed line-based edit examples, clarified `target` format with full path and CRC suffix, added guidance for `replace_body` operation to preserve declarations - LSP diagnostics timeout reduced from 10 seconds to 5 seconds for faster feedback; slow diagnostics now fetch in background via deferred mechanism - Diagnostics action error messaging clarified: requires `file` parameter or `*` for workspace scope; improved guidance in error responses - Workspace symbols and reload actions now accept `*` to operate across all configured servers instead of requiring a file path @@ -54,6 +56,8 @@ ### Fixed +- 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 - Chunk-mode whole-chunk replaces now preserve attached leading comments and docblocks when replacement content starts at the declaration, preventing accidental comment loss during agent edits - Chunk edit error messages now consistently report checksum mismatches with the format `did not match checksum "XXXX"` instead of variable phrasing diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 6d48de530..ec81382c2 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -359,13 +359,17 @@ "types": "./src/modes/utils/*.ts", "import": "./src/modes/utils/*.ts" }, - "./patch": { - "types": "./src/patch/index.ts", - "import": "./src/patch/index.ts" + "./edit": { + "types": "./src/edit/index.ts", + "import": "./src/edit/index.ts" }, - "./patch/*": { - "types": "./src/patch/*.ts", - "import": "./src/patch/*.ts" + "./edit/*": { + "types": "./src/edit/*.ts", + "import": "./src/edit/*.ts" + }, + "./edit/modes/*": { + "types": "./src/edit/modes/*.ts", + "import": "./src/edit/modes/*.ts" }, "./plan-mode/*": { "types": "./src/plan-mode/*.ts", diff --git a/packages/coding-agent/src/cli/read-cli.ts b/packages/coding-agent/src/cli/read-cli.ts index 1905243a7..7d2bde50f 100644 --- a/packages/coding-agent/src/cli/read-cli.ts +++ b/packages/coding-agent/src/cli/read-cli.ts @@ -5,8 +5,8 @@ */ import * as path from "node:path"; import chalk from "chalk"; +import { formatChunkedRead, resolveAnchorStyle } from "../edit/modes/chunk"; import { getLanguageFromPath } from "../modes/theme/theme"; -import { formatChunkedRead, resolveAnchorStyle } from "../tools/chunk-tree"; export interface ReadCommandArgs { path: string; diff --git a/packages/coding-agent/src/commit/agentic/validation.ts b/packages/coding-agent/src/commit/agentic/validation.ts index 72715c063..78c89e14e 100644 --- a/packages/coding-agent/src/commit/agentic/validation.ts +++ b/packages/coding-agent/src/commit/agentic/validation.ts @@ -1,7 +1,7 @@ import { stripTypePrefix } from "../../commit/analysis/summary"; import { validateSummary } from "../../commit/analysis/validation"; import type { CommitType, ConventionalDetail } from "../../commit/types"; -import { normalizeUnicode } from "../../patch/normalize"; +import { normalizeUnicode } from "../../edit/normalize"; export const SUMMARY_MAX_CHARS = 72; export const MAX_DETAIL_ITEMS = 6; diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index f6fa9da50..6c5958445 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -3,7 +3,7 @@ import * as path from "node:path"; import { type ChunkAnchorStyle, formatAnchor } from "@oh-my-pi/pi-natives"; import { getProjectDir, getProjectPromptsDir, getPromptsDir, logger } from "@oh-my-pi/pi-utils"; import Handlebars from "handlebars"; -import { computeLineHash } from "../patch/hashline"; +import { computeLineHash } from "../edit/modes/hashline"; import { jtdToTypeScript } from "../tools/jtd-to-typescript"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; import { parseFrontmatter } from "../utils/frontmatter"; diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index bfafceb56..b62aad5de 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -19,8 +19,8 @@ import { YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-registry"; import { loadCapability } from "../discovery"; +import { type EditMode, normalizeEditMode } from "../edit"; import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme"; -import { type EditMode, normalizeEditMode } from "../patch"; import { AgentStorage } from "../session/agent-storage"; import { withFileLock } from "./file-lock"; import { diff --git a/packages/coding-agent/src/edit/diff.ts b/packages/coding-agent/src/edit/diff.ts new file mode 100644 index 000000000..e82316f4e --- /dev/null +++ b/packages/coding-agent/src/edit/diff.ts @@ -0,0 +1,818 @@ +/** + * Diff generation and replace-mode utilities for the edit tool. + * + * Provides diff string generation and the replace-mode edit logic + * used when not in patch mode. + */ +import { isEnoent } from "@oh-my-pi/pi-utils"; +import * as Diff from "diff"; +import { resolveToCwd } from "../tools/path-utils"; +import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace"; +import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; + +export interface DiffResult { + diff: string; + firstChangedLine: number | undefined; +} + +export interface DiffError { + error: string; +} + +export interface DiffHunk { + changeContext?: string; + oldStartLine?: number; + newStartLine?: number; + hasContextLines: boolean; + oldLines: string[]; + newLines: string[]; + isEndOfFile: boolean; +} + +export class ParseError extends Error { + constructor( + message: string, + readonly lineNumber?: number, + ) { + super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message); + this.name = "ParseError"; + } +} + +export class ApplyPatchError extends Error { + constructor(message: string) { + super(message); + this.name = "ApplyPatchError"; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Diff String Generation +// ═══════════════════════════════════════════════════════════════════════════ + +function countContentLines(content: string): number { + const lines = content.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") { + lines.pop(); + } + return Math.max(1, lines.length); +} + +function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string { + const padded = String(lineNum).padStart(width, " "); + return `${prefix}${padded}|${content}`; +} + +/** + * Generate a unified diff string with line numbers and context. + * Returns both the diff string and the first changed line number (in the new file). + */ +export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent)); + const lineNumWidth = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + + if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + + // Show the change + for (const line of raw) { + if (part.added) { + output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line)); + newLineNum++; + } else { + output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line)); + oldLineNum++; + } + } + lastWasChange = true; + } else { + // Context lines - only show a few before/after changes + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + + if (lastWasChange || nextPartIsChange) { + let linesToShow = raw; + let skipStart = 0; + let skipEnd = 0; + + if (!lastWasChange) { + // Show only last N lines as leading context + skipStart = Math.max(0, raw.length - contextLines); + linesToShow = raw.slice(skipStart); + } + + if (!nextPartIsChange && linesToShow.length > contextLines) { + // Show only first N lines as trailing context + skipEnd = linesToShow.length - contextLines; + linesToShow = linesToShow.slice(0, contextLines); + } + + // Add ellipsis if we skipped lines at start + if (skipStart > 0) { + output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "...")); + oldLineNum += skipStart; + newLineNum += skipStart; + } + + for (const line of linesToShow) { + output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line)); + oldLineNum++; + newLineNum++; + } + + // Add ellipsis if we skipped lines at end + if (skipEnd > 0) { + output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "...")); + oldLineNum += skipEnd; + newLineNum += skipEnd; + } + } else { + // Skip these context lines entirely + oldLineNum += raw.length; + newLineNum += raw.length; + } + + lastWasChange = false; + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Replace Mode Logic +// ═══════════════════════════════════════════════════════════════════════════ + +export interface ReplaceOptions { + /** Allow fuzzy matching */ + fuzzy: boolean; + /** Replace all occurrences */ + all: boolean; + /** Similarity threshold for fuzzy matching */ + threshold?: number; +} + +export interface ReplaceResult { + /** The new content after replacements */ + content: string; + /** Number of replacements made */ + count: number; +} + +/** + * Generate a unified diff string without file headers. + * Returns both the diff string and the first changed line number (in the new file). + */ +export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult { + const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines }); + const output: string[] = []; + let firstChangedLine: number | undefined; + const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent)); + const lineNumWidth = String(maxLineNum).length; + for (const hunk of patch.hunks) { + output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + let oldLine = hunk.oldStart; + let newLine = hunk.newStart; + for (const line of hunk.lines) { + if (line.startsWith("-")) { + if (firstChangedLine === undefined) firstChangedLine = newLine; + output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1))); + oldLine++; + continue; + } + if (line.startsWith("+")) { + if (firstChangedLine === undefined) firstChangedLine = newLine; + output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1))); + newLine++; + continue; + } + if (line.startsWith(" ")) { + output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1))); + oldLine++; + newLine++; + continue; + } + output.push(line); + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} + +const EOF_MARKER = "*** End of File"; +const CHANGE_CONTEXT_MARKER = "@@ "; +const EMPTY_CHANGE_CONTEXT_MARKER = "@@"; +const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/; +const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i; +const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i; +const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "]; +const DIFF_METADATA_PREFIXES = [ + "*** Update File:", + "*** Add File:", + "*** Delete File:", + "diff --git ", + "index ", + "--- ", + "+++ ", + "new file mode ", + "deleted file mode ", + "rename from ", + "rename to ", + "similarity index ", + "dissimilarity index ", + "old mode ", + "new mode ", +]; +const PATCH_WRAPPER_PREFIXES = ["*** Begin Patch", "*** End Patch"]; +const MAX_OCCURRENCE_PREVIEWS = 5; + +function isDiffContentLine(line: string): boolean { + const firstChar = line[0]; + if (firstChar === " ") return true; + if (firstChar === "+") { + return !line.startsWith("+++ "); + } + if (firstChar === "-") { + return !line.startsWith("--- "); + } + return false; +} + +function matchesTrimmedPrefix(line: string, prefixes: string[]): boolean { + return prefixes.some(prefix => line.startsWith(prefix)); +} + +function isPatchWrapperLine(line: string): boolean { + return line === "***" || matchesTrimmedPrefix(line, PATCH_WRAPPER_PREFIXES); +} + +function formatOccurrenceMatchError( + occurrences: number, + occurrencePreviews: string[] | undefined, + path?: string, +): string { + const previews = occurrencePreviews?.join("\n\n") ?? ""; + const moreMsg = + occurrences > MAX_OCCURRENCE_PREVIEWS ? ` (showing first ${MAX_OCCURRENCE_PREVIEWS} of ${occurrences})` : ""; + const pathSuffix = path ? ` in ${path}` : ""; + return `Found ${occurrences} occurrences${pathSuffix}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`; +} + +async function readFileTextForDiff(path: string, absolutePath: string): Promise { + try { + return await Bun.file(absolutePath).text(); + } catch (error) { + if (isEnoent(error)) { + throw new Error(`File not found: ${path}`); + } + throw error; + } +} + +export function normalizeDiff(diff: string): string { + let lines = diff.split("\n"); + + while (lines.length > 0) { + const lastLine = lines[lines.length - 1]; + if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) { + lines = lines.slice(0, -1); + } else { + break; + } + } + + if (lines[0] && isPatchWrapperLine(lines[0].trim())) { + lines = lines.slice(1); + } + if (lines.length > 0 && isPatchWrapperLine(lines[lines.length - 1]?.trim() ?? "")) { + lines = lines.slice(0, -1); + } + + lines = lines.filter(line => { + if (isDiffContentLine(line)) { + return true; + } + + return !matchesTrimmedPrefix(line.trim(), DIFF_METADATA_PREFIXES); + }); + + return lines.join("\n"); +} + +export function normalizeCreateContent(content: string): string { + const lines = content.split("\n"); + const nonEmptyLines = lines.filter(line => line.length > 0); + + if (nonEmptyLines.length > 0 && nonEmptyLines.every(line => line.startsWith("+ ") || line.startsWith("+"))) { + return lines + .map(line => { + if (line.startsWith("+ ")) return line.slice(2); + if (line.startsWith("+")) return line.slice(1); + return line; + }) + .join("\n"); + } + + return content; +} + +interface UnifiedHunkHeader { + oldStartLine: number; + oldLineCount: number; + newStartLine: number; + newLineCount: number; + changeContext?: string; +} + +function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined { + const match = line.match(UNIFIED_HUNK_HEADER_REGEX); + if (!match) return undefined; + + const oldStartLine = Number(match[1]); + const oldLineCount = match[2] ? Number(match[2]) : 1; + const newStartLine = Number(match[3]); + const newLineCount = match[4] ? Number(match[4]) : 1; + const changeContext = match[5]?.trim(); + + return { + oldStartLine, + oldLineCount, + newStartLine, + newLineCount, + changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined, + }; +} + +function isUnifiedDiffMetadataLine(line: string): boolean { + return matchesTrimmedPrefix( + line, + DIFF_METADATA_PREFIXES.filter(prefix => !prefix.startsWith("*** ")), + ); +} + +interface ParseHunkResult { + hunk: DiffHunk; + linesConsumed: number; +} + +function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult { + if (lines.length === 0) { + throw new ParseError("Diff does not contain any lines", lineNumber); + } + + const changeContexts: string[] = []; + let oldStartLine: number | undefined; + let newStartLine: number | undefined; + let startIndex: number; + + const headerLine = lines[0]; + const headerTrimmed = headerLine.trimEnd(); + const isHeaderLine = headerLine.startsWith("@@"); + const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined; + const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed); + + if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) { + startIndex = 1; + } else if (unifiedHeader) { + if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) { + throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber); + } + if (unifiedHeader.changeContext) { + changeContexts.push(unifiedHeader.changeContext); + } + oldStartLine = unifiedHeader.oldStartLine; + newStartLine = unifiedHeader.newStartLine; + startIndex = 1; + } else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) { + const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length); + const trimmedContextValue = contextValue.trim(); + const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, ""); + + const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX); + if (lineHintMatch) { + oldStartLine = Number(lineHintMatch[1]); + newStartLine = oldStartLine; + if (oldStartLine < 1) { + throw new ParseError("Line hint must be >= 1", lineNumber); + } + } else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) { + oldStartLine = 1; + newStartLine = 1; + } else if (trimmedContextValue.length > 0) { + changeContexts.push(contextValue); + } + startIndex = 1; + } else if (isHeaderLine) { + const contextValue = headerTrimmed.slice(2).trim(); + if (contextValue.length > 0) { + changeContexts.push(contextValue); + } + startIndex = 1; + } else { + if (!allowMissingContext) { + throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber); + } + startIndex = 0; + } + + if (oldStartLine !== undefined && oldStartLine < 1) { + throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber); + } + if (newStartLine !== undefined && newStartLine < 1) { + throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber); + } + + while (startIndex < lines.length) { + const nextLine = lines[startIndex]; + if (!nextLine.startsWith("@@")) { + break; + } + const trimmed = nextLine.trimEnd(); + if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) { + const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length); + if (nestedContext.trim().length > 0) { + changeContexts.push(nestedContext); + } + startIndex++; + } else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) { + startIndex++; + } else { + break; + } + } + + if (startIndex >= lines.length) { + throw new ParseError("Hunk does not contain any lines", lineNumber + 1); + } + + const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined; + + const hunk: DiffHunk = { + changeContext, + oldStartLine, + newStartLine, + hasContextLines: false, + oldLines: [], + newLines: [], + isEndOfFile: false, + }; + + let parsedLines = 0; + + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const nextLine = lines[i + 1]; + + if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) { + break; + } + + if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) { + if (parsedLines === 0) { + throw new ParseError("Hunk does not contain any lines", lineNumber + 1); + } + hunk.isEndOfFile = true; + parsedLines++; + break; + } + + if (trimmed === "..." || trimmed === "…") { + hunk.hasContextLines = true; + parsedLines++; + continue; + } + + const firstChar = line[0]; + + if (firstChar === undefined || firstChar === "") { + hunk.hasContextLines = true; + hunk.oldLines.push(""); + hunk.newLines.push(""); + } else if (firstChar === " ") { + hunk.hasContextLines = true; + hunk.oldLines.push(line.slice(1)); + hunk.newLines.push(line.slice(1)); + } else if (firstChar === "+") { + hunk.newLines.push(line.slice(1)); + } else if (firstChar === "-") { + hunk.oldLines.push(line.slice(1)); + } else if (!line.startsWith("@@")) { + hunk.hasContextLines = true; + hunk.oldLines.push(line); + hunk.newLines.push(line); + } else { + if (parsedLines === 0) { + throw new ParseError( + `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`, + lineNumber + 1, + ); + } + break; + } + parsedLines++; + } + + if (parsedLines === 0) { + throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex); + } + + stripLineNumberPrefixes(hunk); + return { hunk, linesConsumed: parsedLines + startIndex }; +} + +function stripLineNumberPrefixes(hunk: DiffHunk): void { + const allLines = [...hunk.oldLines, ...hunk.newLines].filter(line => line.trim().length > 0); + if (allLines.length < 2) return; + + const numberMatches = allLines + .map(line => line.match(/^\s*(\d{1,6})\s+(.+)$/u)) + .filter((match): match is RegExpMatchArray => match !== null); + + if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) { + return; + } + + const numbers = numberMatches.map(match => Number(match[1])); + let sequential = 0; + for (let i = 1; i < numbers.length; i++) { + if (numbers[i] === numbers[i - 1] + 1) { + sequential++; + } + } + + if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) { + return; + } + + const strip = (line: string): string => { + const match = line.match(/^\s*\d{1,6}\s+(.+)$/u); + return match ? match[1] : line; + }; + + hunk.oldLines = hunk.oldLines.map(strip); + hunk.newLines = hunk.newLines.map(strip); +} + +function countMultiFileMarkers(diff: string): number { + const counts = new Map(); + const paths = new Set(); + const lines = diff.split("\n"); + for (const line of lines) { + if (isDiffContentLine(line)) { + continue; + } + const trimmed = line.trim(); + for (const marker of MULTI_FILE_MARKERS) { + if (trimmed.startsWith(marker)) { + const filePath = extractMarkerPath(trimmed); + if (filePath) { + paths.add(filePath); + } + counts.set(marker, (counts.get(marker) ?? 0) + 1); + break; + } + } + } + if (paths.size > 0) { + return paths.size; + } + let maxCount = 0; + for (const count of counts.values()) { + if (count > maxCount) { + maxCount = count; + } + } + return maxCount; +} + +function extractMarkerPath(line: string): string | undefined { + if (line.startsWith("diff --git ")) { + const parts = line.split(/\s+/); + const candidate = parts[3] ?? parts[2]; + if (!candidate) return undefined; + return candidate.replace(/^(a|b)\//, ""); + } + if (line.startsWith("*** Update File:")) { + return line.slice("*** Update File:".length).trim(); + } + if (line.startsWith("*** Add File:")) { + return line.slice("*** Add File:".length).trim(); + } + if (line.startsWith("*** Delete File:")) { + return line.slice("*** Delete File:".length).trim(); + } + return undefined; +} + +export function parseDiffHunks(diff: string): DiffHunk[] { + const multiFileCount = countMultiFileMarkers(diff); + if (multiFileCount > 1) { + throw new ApplyPatchError( + `Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`, + ); + } + + const normalizedDiff = normalizeDiff(diff); + const lines = normalizedDiff.split("\n"); + const hunks: DiffHunk[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed === "") { + i++; + continue; + } + + const firstChar = line[0]; + const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-"; + if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) { + i++; + continue; + } + + if (trimmed.startsWith("@@") && lines.slice(i + 1).every(next => next.trim() === "")) { + break; + } + + const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true); + hunks.push(hunk); + i += linesConsumed; + } + + return hunks; +} + +/** + * Find and replace text in content using fuzzy matching. + */ +export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult { + if (oldText.length === 0) { + throw new Error("oldText must not be empty."); + } + const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD; + let normalizedContent = normalizeToLF(content); + const normalizedOldText = normalizeToLF(oldText); + const normalizedNewText = normalizeToLF(newText); + let count = 0; + + if (options.all) { + // Check for exact matches first + const exactCount = normalizedContent.split(normalizedOldText).length - 1; + if (exactCount > 0) { + return { + content: normalizedContent.split(normalizedOldText).join(normalizedNewText), + count: exactCount, + }; + } + + // No exact matches - try fuzzy matching iteratively + while (true) { + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: options.fuzzy, + threshold, + }); + + const shouldUseClosest = + options.fuzzy && + matchOutcome.closest && + matchOutcome.closest.confidence >= threshold && + (matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1); + const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined); + if (!match) { + break; + } + + const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText); + if (adjustedNewText === match.actualText) { + break; + } + normalizedContent = + normalizedContent.substring(0, match.startIndex) + + adjustedNewText + + normalizedContent.substring(match.startIndex + match.actualText.length); + count++; + } + + return { content: normalizedContent, count }; + } + + // Single replacement mode + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: options.fuzzy, + threshold, + }); + + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { + throw new Error(formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews)); + } + + if (!matchOutcome.match) { + return { content: normalizedContent, count: 0 }; + } + + const match = matchOutcome.match; + const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText); + normalizedContent = + normalizedContent.substring(0, match.startIndex) + + adjustedNewText + + normalizedContent.substring(match.startIndex + match.actualText.length); + + return { content: normalizedContent, count: 1 }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Preview/Diff Computation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Compute the diff for an edit operation without applying it. + * Used for preview rendering in the TUI before the tool executes. + */ +export async function computeEditDiff( + path: string, + oldText: string, + newText: string, + cwd: string, + fuzzy = true, + all = false, + threshold?: number, +): Promise { + if (oldText.length === 0) { + return { error: "oldText must not be empty." }; + } + const absolutePath = resolveToCwd(path, cwd); + + try { + let rawContent: string; + try { + rawContent = await readFileTextForDiff(path, absolutePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: message || `Unable to read ${path}` }; + } + + const { text: content } = stripBom(rawContent); + const normalizedContent = normalizeToLF(content); + const normalizedOldText = normalizeToLF(oldText); + const normalizedNewText = normalizeToLF(newText); + + const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { + fuzzy, + all, + threshold, + }); + + if (result.count === 0) { + // Get closest match for error message + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy: fuzzy, + threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD, + }); + + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { + return { + error: formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews, path), + }; + } + + return { + error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, { + allowFuzzy: fuzzy, + threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD, + fuzzyMatches: matchOutcome.fuzzyMatches, + }), + }; + } + + if (normalizedContent === result.content) { + return { + error: `No changes would be made to ${path}. The replacement produces identical content.`, + }; + } + + return generateDiffString(normalizedContent, result.content); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts new file mode 100644 index 000000000..ac503711d --- /dev/null +++ b/packages/coding-agent/src/edit/index.ts @@ -0,0 +1,308 @@ +import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import { renderPromptTemplate } from "../config/prompt-templates"; +import { + createLspWritethrough, + type FileDiagnosticsResult, + type WritethroughCallback, + type WritethroughDeferredHandle, + writethroughNoop, +} from "../lsp"; +import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" }; +import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" }; +import patchDescription from "../prompts/tools/patch.md" with { type: "text" }; +import replaceDescription from "../prompts/tools/replace.md" with { type: "text" }; +import type { ToolSession } from "../tools"; +import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode"; +import { + type ChunkParams, + chunkEditParamsSchema, + executeChunkMode, + isChunkParams, + resolveAnchorStyle, +} from "./modes/chunk"; +import { executeHashlineMode, type HashlineParams, hashlineEditParamsSchema, isHashlineParams } from "./modes/hashline"; +import { executePatchMode, isPatchParams, type PatchParams, patchEditSchema } from "./modes/patch"; +import { executeReplaceMode, isReplaceParams, type ReplaceParams, replaceEditSchema } from "./modes/replace"; +import { type EditToolDetails, getLspBatchRequest, type LspBatchRequest } from "./renderer"; + +export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode"; +export * from "./diff"; +export * from "./modes/chunk"; +export * from "./modes/hashline"; +export * from "./modes/patch"; +export * from "./modes/replace"; +export * from "./normalize"; +export * from "./renderer"; + +type TInput = + | typeof replaceEditSchema + | typeof patchEditSchema + | typeof hashlineEditParamsSchema + | typeof chunkEditParamsSchema; + +type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams; + +type ModeExecutionArgs = { + params: EditParams; + signal: AbortSignal | undefined; + batchRequest: LspBatchRequest | undefined; +}; + +type EditModeDefinition = { + description: (session: ToolSession) => string; + parameters: TInput; + invalidParamsMessage: string; + validate: (params: EditParams) => boolean; + execute: (tool: EditTool, args: ModeExecutionArgs) => Promise>; +}; + +function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined { + if (!rawEditMode || rawEditMode === "auto") { + return undefined; + } + + const editMode = normalizeEditMode(rawEditMode); + if (!editMode) { + throw new Error(`Invalid PI_EDIT_VARIANT: ${rawEditMode}`); + } + + return editMode; +} + +function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean { + switch (rawValue) { + case "true": + case "1": + return true; + case "false": + case "0": + return false; + case "auto": + return session.settings.get("edit.fuzzyMatch"); + default: + throw new Error(`Invalid PI_EDIT_FUZZY: ${rawValue}`); + } +} + +function resolveFuzzyThreshold(session: ToolSession, rawValue: string): number { + if (rawValue === "auto") { + return session.settings.get("edit.fuzzyThreshold"); + } + + const threshold = Number.parseFloat(rawValue); + if (Number.isNaN(threshold) || threshold < 0 || threshold > 1) { + throw new Error(`Invalid PI_EDIT_FUZZY_THRESHOLD: ${rawValue}`); + } + + return threshold; +} + +function createEditWritethrough(session: ToolSession): WritethroughCallback { + const enableLsp = session.enableLsp ?? true; + const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); + const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite"); + return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop; +} + +export class EditTool implements AgentTool { + readonly name = "edit"; + readonly label = "Edit"; + readonly nonAbortable = true; + readonly concurrency = "exclusive"; + readonly strict = true; + + readonly #allowFuzzy: boolean; + readonly #fuzzyThreshold: number; + readonly #writethrough: WritethroughCallback; + readonly #editMode?: EditMode; + readonly #pendingDeferredFetches = new Map(); + + constructor(private readonly session: ToolSession) { + const { + PI_EDIT_FUZZY: editFuzzy = "auto", + PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto", + PI_EDIT_VARIANT: envEditVariant = "auto", + } = Bun.env; + + this.#editMode = resolveConfiguredEditMode(envEditVariant); + this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy); + this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold); + this.#writethrough = createEditWritethrough(session); + } + + get mode(): EditMode { + if (this.#editMode) return this.#editMode; + return resolveEditMode(this.session); + } + + get description(): string { + return this.#getModeDefinition().description(this.session); + } + + get parameters(): TInput { + return this.#getModeDefinition().parameters; + } + + async execute( + _toolCallId: string, + params: ReplaceParams, + signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ): Promise>; + async execute( + _toolCallId: string, + params: PatchParams, + signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ): Promise>; + async execute( + _toolCallId: string, + params: HashlineParams, + signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ): Promise>; + async execute( + _toolCallId: string, + params: ChunkParams, + signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ): Promise>; + async execute( + _toolCallId: string, + params: EditParams, + signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ): Promise> { + const modeDefinition = this.#getModeDefinition(); + if (!modeDefinition.validate(params)) { + throw new Error(modeDefinition.invalidParamsMessage); + } + + return modeDefinition.execute(this, { + params, + signal, + batchRequest: getLspBatchRequest(context?.toolCall), + }); + } + + #getModeDefinition(): EditModeDefinition { + return { + chunk: { + description: (session: ToolSession) => + renderPromptTemplate(chunkEditDescription, { + anchorStyle: resolveAnchorStyle(session.settings), + }), + parameters: chunkEditParamsSchema, + invalidParamsMessage: "Invalid edit parameters for chunk mode.", + validate: isChunkParams, + async execute(tool: EditTool, args: ModeExecutionArgs) { + return executeChunkMode({ + session: tool.session, + params: args.params as ChunkParams, + signal: args.signal, + batchRequest: args.batchRequest, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path), + }); + }, + }, + patch: { + description: () => renderPromptTemplate(patchDescription), + parameters: patchEditSchema, + invalidParamsMessage: "Invalid edit parameters for patch mode.", + validate: isPatchParams, + async execute(tool: EditTool, args: ModeExecutionArgs) { + return executePatchMode({ + session: tool.session, + params: args.params as PatchParams, + signal: args.signal, + batchRequest: args.batchRequest, + allowFuzzy: tool.#allowFuzzy, + fuzzyThreshold: tool.#fuzzyThreshold, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path), + }); + }, + }, + hashline: { + description: () => renderPromptTemplate(hashlineDescription), + parameters: hashlineEditParamsSchema, + invalidParamsMessage: "Invalid edit parameters for hashline mode.", + validate: isHashlineParams, + async execute(tool: EditTool, args: ModeExecutionArgs) { + return executeHashlineMode({ + session: tool.session, + params: args.params as HashlineParams, + signal: args.signal, + batchRequest: args.batchRequest, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path), + }); + }, + }, + replace: { + description: () => renderPromptTemplate(replaceDescription), + parameters: replaceEditSchema, + invalidParamsMessage: "Invalid edit parameters for replace mode.", + validate: isReplaceParams, + async execute(tool: EditTool, args: ModeExecutionArgs) { + return executeReplaceMode({ + session: tool.session, + params: args.params as ReplaceParams, + signal: args.signal, + batchRequest: args.batchRequest, + allowFuzzy: tool.#allowFuzzy, + fuzzyThreshold: tool.#fuzzyThreshold, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: path => tool.#beginDeferredDiagnosticsForPath(path), + }); + }, + }, + }[this.mode]; + } + + #beginDeferredDiagnosticsForPath(path: string): WritethroughDeferredHandle { + const existingDeferred = this.#pendingDeferredFetches.get(path); + if (existingDeferred) { + existingDeferred.abort(); + this.#pendingDeferredFetches.delete(path); + } + + const deferredController = new AbortController(); + return { + onDeferredDiagnostics: (lateDiagnostics: FileDiagnosticsResult) => { + this.#pendingDeferredFetches.delete(path); + this.#injectLateDiagnostics(path, lateDiagnostics); + }, + signal: deferredController.signal, + finalize: (diagnostics: FileDiagnosticsResult | undefined) => { + if (!diagnostics) { + this.#pendingDeferredFetches.set(path, deferredController); + } else { + deferredController.abort(); + } + }, + }; + } + + #injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult): void { + const summary = diagnostics.summary ?? ""; + const lines = diagnostics.messages ?? []; + const body = [`Late LSP diagnostics for ${path} (arrived after the edit tool returned):`, summary, ...lines] + .filter(Boolean) + .join("\n"); + + this.session.queueDeferredMessage?.({ + role: "custom", + customType: "lsp-late-diagnostic", + content: body, + display: false, + timestamp: Date.now(), + }); + } +} diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts new file mode 100644 index 000000000..87ec11c40 --- /dev/null +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -0,0 +1,623 @@ +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 { + ChunkAnchorStyle, + ChunkEditOp, + type ChunkInfo, + ChunkReadStatus, + type ChunkReadTarget, + ChunkState, + type EditOperation as NativeEditOperation, +} from "@oh-my-pi/pi-natives"; +import { type Static, Type } from "@sinclair/typebox"; +import type { BunFile } from "bun"; +import { LRUCache } from "lru-cache"; +import type { Settings } from "../../config/settings"; +import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; +import { getLanguageFromPath } from "../../modes/theme/theme"; +import type { ToolSession } from "../../tools"; +import { checkAutoGeneratedFileContent } from "../../tools/auto-generated-guard"; +import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; +import { outputMeta } from "../../tools/output-meta"; +import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; +import { generateUnifiedDiffString } from "../diff"; +import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; +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 }; + +type ChunkEditResult = { + diffSourceBefore: string; + diffSourceAfter: string; + responseText: string; + changed: boolean; + parseValid: boolean; + touchedPaths: string[]; + warnings: string[]; +}; + +export type ParsedChunkReadPath = { + filePath: string; + selector?: string; + crc?: string; +}; + +type ChunkCacheEntry = { + mtimeMs: number; + size: number; + source: string; + state: ChunkState; +}; + +const validAnchorStyles: Record = { + full: ChunkAnchorStyle.Full, + kind: ChunkAnchorStyle.Kind, + bare: ChunkAnchorStyle.Bare, +}; + +export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle { + const envStyle = Bun.env.PI_ANCHOR_STYLE; + return ( + (envStyle && validAnchorStyles[envStyle]) || + (settings?.get("read.anchorstyle") as ChunkAnchorStyle | undefined) || + ChunkAnchorStyle.Full + ); +} + +const readEnvInt = (name: string, defaultValue: number): number => { + const value = Bun.env[name]; + if (!value) return defaultValue; + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) return defaultValue; + return parsed; +}; + +const chunkStateCache = new LRUCache({ + 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 ChunkSourceContext = { + resolvedPath: string; + sourceFile: BunFile; + sourceExists: boolean; + rawContent: string; + chunkLanguage: string | undefined; +}; + +function normalizeLanguage(language: string | undefined): string { + return language?.trim().toLowerCase() || ""; +} + +function normalizeChunkSource(text: string): string { + return normalizeToLF(stripBom(text).text); +} + +function displayPathForFile(filePath: string, cwd: string): string { + const relative = nodePath.relative(cwd, filePath).replace(/\\/g, "/"); + return relative && !relative.startsWith("..") ? relative : filePath.replace(/\\/g, "/"); +} + +function fileLanguageTag(filePath: string, language?: string): string | undefined { + const normalizedLanguage = normalizeLanguage(language); + if (normalizedLanguage.length > 0) return normalizedLanguage; + const ext = nodePath.extname(filePath).replace(/^\./, "").toLowerCase(); + return ext.length > 0 ? ext : undefined; +} + +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); +} + +async function resolveChunkSourceContext(session: ToolSession, path: string): Promise { + const resolvedPath = resolvePlanPath(session, path); + const sourceFile = Bun.file(resolvedPath); + const sourceExists = await sourceFile.exists(); + enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" }); + + let rawContent = ""; + if (sourceExists) { + rawContent = await sourceFile.text(); + await checkAutoGeneratedFileContent(rawContent, path); + } + + return { + resolvedPath, + sourceFile, + sourceExists, + rawContent, + chunkLanguage: getLanguageFromPath(resolvedPath), + }; +} + +function buildChunkEditResult(result: { + diffBefore: string; + diffAfter: string; + responseText: string; + changed: boolean; + parseValid: boolean; + touchedPaths: string[]; + warnings: string[]; +}): ChunkEditResult { + return { + diffSourceBefore: result.diffBefore, + diffSourceAfter: result.diffAfter, + responseText: result.responseText, + changed: result.changed, + parseValid: result.parseValid, + touchedPaths: result.touchedPaths, + warnings: result.warnings, + }; +} + +function chunkReadPathSeparatorIndex(readPath: string): number { + if (/^[a-zA-Z]:[/\\]/.test(readPath)) { + return readPath.indexOf(":", 2); + } + return readPath.indexOf(":"); +} + +export function parseChunkSelector(selector: string | undefined): { selector?: string; crc?: 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 }; +} + +export function parseChunkReadPath(readPath: string): ParsedChunkReadPath { + const colonIndex = chunkReadPathSeparatorIndex(readPath); + if (colonIndex === -1) { + return { filePath: readPath }; + } + const parsedSelector = parseChunkSelector(readPath.slice(colonIndex + 1) || undefined); + return { + filePath: readPath.slice(0, colonIndex), + selector: parsedSelector.selector, + crc: parsedSelector.crc, + }; +} + +export function isChunkReadablePath(readPath: string): boolean { + return parseChunkReadPath(readPath).selector !== undefined; +} + +export async function loadChunkStateForFile(filePath: string, language: string | undefined): Promise { + const file = Bun.file(filePath); + const stat = await file.stat(); + const cached = chunkStateCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached; + } + + const source = normalizeChunkSource(await file.text()); + const state = ChunkState.parse(source, normalizeLanguage(language)); + const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, state }; + chunkStateCache.set(filePath, entry); + return entry; +} + +export async function formatChunkedRead(params: { + filePath: string; + readPath: string; + cwd: string; + language?: string; + omitChecksum?: boolean; + anchorStyle?: ChunkAnchorStyle; + absoluteLineRange?: { startLine: number; endLine?: number }; +}): Promise<{ text: string; resolvedPath?: string; chunk?: ChunkReadTarget }> { + const { filePath, readPath, cwd, language, omitChecksum = false, anchorStyle, absoluteLineRange } = params; + const normalizedLanguage = normalizeLanguage(language); + const { state } = await loadChunkStateForFile(filePath, normalizedLanguage); + const displayPath = displayPathForFile(filePath, cwd); + const result = state.renderRead({ + readPath, + displayPath, + languageTag: fileLanguageTag(filePath, normalizedLanguage), + omitChecksum, + anchorStyle, + absoluteLineRange: absoluteLineRange + ? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine } + : undefined, + tabReplacement: " ", + }); + return { text: result.text, resolvedPath: filePath, chunk: result.chunk }; +} + +export async function formatChunkedGrepLine(params: { + filePath: string; + lineNumber: number; + line: string; + cwd: string; + language?: string; +}): Promise { + const { filePath, lineNumber, line, cwd, language } = params; + const { state } = await loadChunkStateForFile(filePath, language); + return state.formatGrepLine(displayPathForFile(filePath, cwd), lineNumber, line); +} + +function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperation { + switch (operation.op) { + case "replace": + 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, + }; + default: { + const exhaustive: never = operation; + return exhaustive; + } + } +} + +export function applyChunkEdits(params: { + source: string; + language?: string; + cwd: string; + filePath: string; + operations: ChunkEditOperation[]; + defaultSelector?: string; + defaultCrc?: string; + anchorStyle?: ChunkAnchorStyle; +}): ChunkEditResult { + const normalizedSource = normalizeChunkSource(params.source); + const nativeOperations = params.operations.map(toNativeEditOperation); + const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language)); + const result = state.applyEdits({ + operations: nativeOperations, + defaultSelector: params.defaultSelector, + defaultCrc: params.defaultCrc, + anchorStyle: params.anchorStyle, + cwd: params.cwd, + filePath: params.filePath, + }); + + return buildChunkEditResult(result); +} + +export async function getChunkInfoForFile( + filePath: string, + language: string | undefined, + chunkPath: string, +): Promise { + const { state } = await loadChunkStateForFile(filePath, language); + return state.chunk(chunkPath) ?? undefined; +} + +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; + +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.", + }), + 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'.", + }), + ), + 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.', + }), + 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" }), + edits: Type.Array(chunkToolEditSchema, { + description: "Chunk edits", + minItems: 1, + }), + }, + { additionalProperties: false }, +); + +export type ChunkToolEdit = Static; +export type ChunkParams = Static; + +type ParsedChunkTarget = { + selector: string; + crc?: string; +}; + +type ChunkExecutionContext = { + resolvedPath: string; + sourceExists: boolean; + chunkLanguage: string | undefined; +}; + +interface ExecuteChunkModeOptions { + session: ToolSession; + params: ChunkParams; + signal?: AbortSignal; + batchRequest?: LspBatchRequest; + writethrough: WritethroughCallback; + beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; +} + +export function isChunkParams(params: unknown): params is ChunkParams { + return ( + typeof params === "object" && + params !== null && + "edits" in params && + Array.isArray(params.edits) && + params.edits.length > 0 && + typeof params.edits[0] === "object" && + params.edits[0] !== null && + "target" in params.edits[0] + ); +} + +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 { + 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 { + const { selector, crc } = 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, + }; + } +} + +async function normalizeChunkEditOperations( + edits: ChunkToolEdit[], + context: ChunkExecutionContext, +): Promise { + const operations: ChunkEditOperation[] = []; + for (const edit of edits) { + operations.push(await normalizeChunkEditOperation(edit, context)); + } + return operations; +} + +async function writeChunkResult(params: { + result: ChunkEditResult; + resolvedPath: string; + sourceFile: BunFile; + sourceText: string; + sourceExists: boolean; + signal?: AbortSignal; + batchRequest?: LspBatchRequest; + writethrough: WritethroughCallback; + beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; +}): Promise> { + const { + result, + resolvedPath, + sourceFile, + sourceText, + sourceExists, + signal, + batchRequest, + writethrough, + beginDeferredDiagnosticsForPath, + } = params; + + const { bom, text } = stripBom(sourceText); + const originalEnding = detectLineEnding(text); + const finalContent = bom + restoreLineEndings(result.diffSourceAfter, originalEnding); + const diagnostics = await writethrough(resolvedPath, finalContent, signal, sourceFile, batchRequest, dst => + dst === resolvedPath ? beginDeferredDiagnosticsForPath(resolvedPath) : undefined, + ); + invalidateFsScanAfterWrite(resolvedPath); + + const diffResult = generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter); + const warningsBlock = result.warnings.length > 0 ? `\n\n${result.warnings.join("\n")}` : ""; + const meta = outputMeta() + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) + .get(); + + return { + content: [{ type: "text", text: `${result.responseText}${warningsBlock}` }], + details: { + diff: diffResult.diff, + firstChangedLine: diffResult.firstChangedLine, + diagnostics, + op: sourceExists ? "update" : "create", + meta, + }, + }; +} + +export async function executeChunkMode( + options: ExecuteChunkModeOptions, +): Promise> { + const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options; + const { path, edits } = params; + const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext( + session, + path, + ); + const parentDir = nodePath.dirname(resolvedPath); + if (parentDir && parentDir !== ".") { + await fs.mkdir(parentDir, { recursive: true }); + } + const normalizedOperations = await normalizeChunkEditOperations(edits, { + resolvedPath, + sourceExists, + chunkLanguage, + }); + + const chunkResult = applyChunkEdits({ + source: rawContent, + language: chunkLanguage, + cwd: session.cwd, + filePath: resolvedPath, + operations: normalizedOperations, + anchorStyle: resolveAnchorStyle(session.settings), + }); + + if (!chunkResult.changed) { + const responseText = `[No changes needed — content already matches.]\n\n${chunkResult.responseText}`; + return { + content: [{ type: "text", text: responseText }], + details: { + diff: "", + op: sourceExists ? "update" : "create", + meta: outputMeta().get(), + }, + }; + } + + return writeChunkResult({ + result: chunkResult, + resolvedPath, + sourceFile, + sourceText: rawContent, + sourceExists, + signal, + batchRequest, + writethrough, + beginDeferredDiagnosticsForPath, + }); +} diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/edit/modes/hashline.ts similarity index 52% rename from packages/coding-agent/src/patch/hashline.ts rename to packages/coding-agent/src/edit/modes/hashline.ts index f6be10019..6e84effb0 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/edit/modes/hashline.ts @@ -12,7 +12,32 @@ * Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`) */ -import type { HashMismatch } from "./types"; +import * as fs from "node:fs/promises"; +import * as nodePath from "node:path"; +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { type Static, Type } from "@sinclair/typebox"; +import type { BunFile } from "bun"; +import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; +import type { ToolSession } from "../../tools"; +import { checkAutoGeneratedFileContent } from "../../tools/auto-generated-guard"; +import { + invalidateFsScanAfterDelete, + invalidateFsScanAfterRename, + invalidateFsScanAfterWrite, +} from "../../tools/fs-cache-invalidation"; +import { outputMeta } from "../../tools/output-meta"; +import { resolveToCwd } from "../../tools/path-utils"; +import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; +import { generateDiffString } from "../diff"; +import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; +import type { EditToolDetails, LspBatchRequest } from "../renderer"; + +export interface HashMismatch { + line: number; + expected: string; + actual: string; +} export type Anchor = { line: number; hash: string }; export type HashlineEdit = @@ -86,6 +111,190 @@ export function formatHashLines(text: string, startLine = 1): string { .join("\n"); } +const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/; +const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/; +const DIFF_PLUS_RE = /^[+](?![+])/; + +type LinePrefixStats = { + nonEmpty: number; + hashPrefixCount: number; + diffPlusHashPrefixCount: number; + diffPlusCount: number; +}; + +function collectLinePrefixStats(lines: string[]): LinePrefixStats { + const stats: LinePrefixStats = { + nonEmpty: 0, + hashPrefixCount: 0, + diffPlusHashPrefixCount: 0, + diffPlusCount: 0, + }; + + for (const line of lines) { + if (line.length === 0) continue; + stats.nonEmpty++; + if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++; + if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++; + if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++; + } + + return stats; +} + +export function stripNewLinePrefixes(lines: string[]): string[] { + const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines); + if (nonEmpty === 0) return lines; + + const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty; + const stripPlus = + !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5; + if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines; + + return lines.map(line => { + if (stripHash) return line.replace(HASHLINE_PREFIX_RE, ""); + if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); + if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) { + return line.replace(HASHLINE_PREFIX_RE, ""); + } + return line; + }); +} + +export function stripHashlinePrefixes(lines: string[]): string[] { + const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines); + if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines; + return lines.map(line => line.replace(HASHLINE_PREFIX_RE, "")); +} + +const linesSchema = Type.Union([ + Type.Array(Type.String(), { description: "content (preferred format)" }), + Type.String(), + Type.Null(), +]); + +const locSchema = Type.Union( + [ + Type.Literal("append"), + Type.Literal("prepend"), + Type.Object({ append: Type.String({ description: "anchor" }) }), + Type.Object({ prepend: Type.String({ description: "anchor" }) }), + Type.Object({ + range: Type.Object({ + pos: Type.String({ description: "first line to edit (inclusive)" }), + end: Type.String({ description: "last line to edit (inclusive)" }), + }), + }), + ], + { description: "insert location" }, +); + +export const hashlineEditSchema = Type.Object( + { + loc: locSchema, + content: linesSchema, + }, + { additionalProperties: false }, +); + +export const hashlineEditParamsSchema = Type.Object( + { + path: Type.String({ description: "path" }), + edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), + delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })), + move: Type.Optional(Type.String({ description: "If set, move $path to $move" })), + }, + { additionalProperties: false }, +); + +export type HashlineToolEdit = Static; +export type HashlineParams = Static; + +interface ExecuteHashlineModeOptions { + session: ToolSession; + params: HashlineParams; + signal?: AbortSignal; + batchRequest?: LspBatchRequest; + writethrough: WritethroughCallback; + beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; +} + +export function hashlineParseText(edit: string[] | string | null): string[] { + if (edit === null) return []; + if (typeof edit === "string") { + const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit; + edit = normalizedEdit.replaceAll("\r", "").split("\n"); + } + return stripNewLinePrefixes(edit); +} + +export function isHashlineParams(params: unknown): params is HashlineParams { + return ( + typeof params === "object" && + params !== null && + "edits" in params && + Array.isArray(params.edits) && + (params.edits.length === 0 || + (typeof params.edits[0] === "object" && params.edits[0] !== null && "loc" in params.edits[0])) + ); +} + +function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] { + return edits.map(resolveEditAnchor); +} + +function tryParseTag(raw: string): Anchor | undefined { + try { + return parseTag(raw); + } catch { + return undefined; + } +} + +function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor { + const anchor = tryParseTag(raw); + if (!anchor) throw new Error(`${op} requires a valid anchor.`); + return anchor; +} + +function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } { + const pos = tryParseTag(range.pos); + const end = tryParseTag(range.end); + if (!pos || !end) throw new Error("range requires valid pos and end anchors."); + return { pos, end }; +} + +function resolveEditAnchor(edit: HashlineToolEdit): HashlineEdit { + const lines = hashlineParseText(edit.content); + const loc = edit.loc; + + if (loc === "append") { + return { op: "append_file", lines }; + } + + if (loc === "prepend") { + return { op: "prepend_file", lines }; + } + + if (typeof loc !== "object") { + throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`); + } + + if ("append" in loc) { + return { op: "append_at", pos: requireParsedAnchor(loc.append, "append"), lines }; + } + + if ("prepend" in loc) { + return { op: "prepend_at", pos: requireParsedAnchor(loc.prepend, "prepend"), lines }; + } + + if ("range" in loc) { + const { pos, end } = requireParsedRange(loc.range); + return { op: "replace_range", pos, end, lines }; + } + + throw new Error("Unknown loc shape. Expected append, prepend, or range."); +} + // ═══════════════════════════════════════════════════════════════════════════ // Hashline streaming formatter // ═══════════════════════════════════════════════════════════════════════════ @@ -99,6 +308,77 @@ export interface HashlineStreamOptions { maxChunkBytes?: number; } +interface ResolvedHashlineStreamOptions { + startLine: number; + maxChunkLines: number; + maxChunkBytes: number; +} + +type HashlineLineFormatter = (lineNumber: number, line: string) => string; + +interface HashlineChunkEmitter { + pushLine: (line: string) => string[]; + flush: () => string | undefined; +} + +function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions { + return { + startLine: options.startLine ?? 1, + maxChunkLines: options.maxChunkLines ?? 200, + maxChunkBytes: options.maxChunkBytes ?? 64 * 1024, + }; +} + +function createHashlineChunkEmitter( + options: ResolvedHashlineStreamOptions, + formatLine: HashlineLineFormatter, +): HashlineChunkEmitter { + let lineNumber = options.startLine; + let outLines: string[] = []; + let outBytes = 0; + + const flush = (): string | undefined => { + if (outLines.length === 0) return undefined; + const chunk = outLines.join("\n"); + outLines = []; + outBytes = 0; + return chunk; + }; + + const pushLine = (line: string): string[] => { + const formatted = formatLine(lineNumber, line); + lineNumber++; + + const chunksToYield: string[] = []; + const sepBytes = outLines.length === 0 ? 0 : 1; + const lineBytes = Buffer.byteLength(formatted, "utf-8"); + + if ( + outLines.length > 0 && + (outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes) + ) { + const flushed = flush(); + if (flushed) chunksToYield.push(flushed); + } + + outLines.push(formatted); + outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes; + + if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) { + const flushed = flush(); + if (flushed) chunksToYield.push(flushed); + } + + return chunksToYield; + }; + + return { pushLine, flush }; +} + +function formatHashlineStreamLine(lineNumber: number, line: string): string { + return `${formatLineTag(lineNumber, line)}:${line}`; +} + function isReadableStream(value: unknown): value is ReadableStream { return ( typeof value === "object" && @@ -132,52 +412,13 @@ export async function* streamHashLinesFromUtf8( source: ReadableStream | AsyncIterable, options: HashlineStreamOptions = {}, ): AsyncGenerator { - const startLine = options.startLine ?? 1; - const maxChunkLines = options.maxChunkLines ?? 200; - const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024; + const resolvedOptions = resolveHashlineStreamOptions(options); const decoder = new TextDecoder("utf-8"); const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source; - let lineNum = startLine; let pending = ""; let sawAnyText = false; let endedWithNewline = false; - let outLines: string[] = []; - let outBytes = 0; - - const flush = (): string | undefined => { - if (outLines.length === 0) return undefined; - const chunk = outLines.join("\n"); - outLines = []; - outBytes = 0; - return chunk; - }; - - const pushLine = (line: string): string[] => { - const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`; - lineNum++; - - const chunksToYield: string[] = []; - const sepBytes = outLines.length === 0 ? 0 : 1; // "\n" - const lineBytes = Buffer.byteLength(formatted, "utf-8"); - - if ( - outLines.length > 0 && - (outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes) - ) { - const flushed = flush(); - if (flushed) chunksToYield.push(flushed); - } - - outLines.push(formatted); - outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes; - - if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) { - const flushed = flush(); - if (flushed) chunksToYield.push(flushed); - } - - return chunksToYield; - }; + const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine); const consumeText = (text: string): string[] => { if (text.length === 0) return []; @@ -190,7 +431,7 @@ export async function* streamHashLinesFromUtf8( const line = pending.slice(0, idx); pending = pending.slice(idx + 1); endedWithNewline = true; - chunksToYield.push(...pushLine(line)); + chunksToYield.push(...emitter.pushLine(line)); } if (pending.length > 0) endedWithNewline = false; return chunksToYield; @@ -206,17 +447,17 @@ export async function* streamHashLinesFromUtf8( } if (!sawAnyText) { // Mirror `"".split("\n")` behavior: one empty line. - for (const out of pushLine("")) { + for (const out of emitter.pushLine("")) { yield out; } } else if (pending.length > 0 || endedWithNewline) { // Emit the final line (may be empty if the file ended with a newline). - for (const out of pushLine(pending)) { + for (const out of emitter.pushLine(pending)) { yield out; } } - const last = flush(); + const last = emitter.flush(); if (last) yield last; } @@ -229,72 +470,34 @@ export async function* streamHashLinesFromLines( lines: Iterable | AsyncIterable, options: HashlineStreamOptions = {}, ): AsyncGenerator { - const startLine = options.startLine ?? 1; - const maxChunkLines = options.maxChunkLines ?? 200; - const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024; - - let lineNum = startLine; - let outLines: string[] = []; - let outBytes = 0; + const resolvedOptions = resolveHashlineStreamOptions(options); + const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine); let sawAnyLine = false; - const flush = (): string | undefined => { - if (outLines.length === 0) return undefined; - const chunk = outLines.join("\n"); - outLines = []; - outBytes = 0; - return chunk; - }; - - const pushLine = (line: string): string[] => { - sawAnyLine = true; - const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`; - lineNum++; - - const chunksToYield: string[] = []; - const sepBytes = outLines.length === 0 ? 0 : 1; - const lineBytes = Buffer.byteLength(formatted, "utf-8"); - - if ( - outLines.length > 0 && - (outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes) - ) { - const flushed = flush(); - if (flushed) chunksToYield.push(flushed); - } - - outLines.push(formatted); - outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes; - - if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) { - const flushed = flush(); - if (flushed) chunksToYield.push(flushed); - } - - return chunksToYield; - }; const asyncIterator = (lines as AsyncIterable)[Symbol.asyncIterator]; if (typeof asyncIterator === "function") { for await (const line of lines as AsyncIterable) { - for (const out of pushLine(line)) { + sawAnyLine = true; + for (const out of emitter.pushLine(line)) { yield out; } } } else { for (const line of lines as Iterable) { - for (const out of pushLine(line)) { + sawAnyLine = true; + for (const out of emitter.pushLine(line)) { yield out; } } } if (!sawAnyLine) { // Mirror `"".split("\n")` behavior: one empty line. - for (const out of pushLine("")) { + for (const out of emitter.pushLine("")) { yield out; } } - const last = flush(); + const last = emitter.flush(); if (last) yield last; } @@ -457,6 +660,251 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn ); } } + +function runHashlinePreflightSanitizers(edits: HashlineEdit[], warnings: string[]): void { + maybeAutocorrectEscapedTabIndentation(edits, warnings); + maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings); +} + +function ensureHashlineEditHasContent(edit: HashlineEdit): void { + if (edit.lines.length === 0) { + edit.lines = [""]; + } +} + +function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines: string[], warnings: string[]): void { + let endLine: number; + switch (edit.op) { + case "replace_line": + endLine = edit.pos.line; + break; + case "replace_range": + endLine = edit.end.line; + break; + default: + return; + } + + if (edit.lines.length === 0) return; + const nextSurvivingIdx = endLine; + if (nextSurvivingIdx >= originalFileLines.length) return; + const nextSurvivingLine = originalFileLines[nextSurvivingIdx]; + const lastInsertedLine = edit.lines[edit.lines.length - 1]; + const trimmedNext = nextSurvivingLine.trim(); + const trimmedLast = lastInsertedLine.trim(); + if (trimmedLast.length > 0 && trimmedLast === trimmedNext) { + const tag = formatLineTag(endLine + 1, nextSurvivingLine); + warnings.push( + `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` + + `If you meant to replace the entire block, set \`end\` to ${tag} instead.`, + ); + } +} + +function dedupeHashlineEdits(edits: HashlineEdit[]): void { + const seenEditKeys = new Map(); + const dedupIndices = new Set(); + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; + let lineKey: string; + switch (edit.op) { + case "replace_line": + lineKey = `s:${edit.pos.line}`; + break; + case "replace_range": + lineKey = `r:${edit.pos.line}:${edit.end.line}`; + break; + case "append_at": + lineKey = `i:${edit.pos.line}`; + break; + case "prepend_at": + lineKey = `ib:${edit.pos.line}`; + break; + case "append_file": + lineKey = "ieof"; + break; + case "prepend_file": + lineKey = "ibef"; + break; + } + const dstKey = `${lineKey}:${edit.lines.join("\n")}`; + if (seenEditKeys.has(dstKey)) { + dedupIndices.add(i); + } else { + seenEditKeys.set(dstKey, i); + } + } + if (dedupIndices.size === 0) return; + for (let i = edits.length - 1; i >= 0; i--) { + if (dedupIndices.has(i)) edits.splice(i, 1); + } +} + +function getHashlineEditSortKey(edit: HashlineEdit, fileLineCount: number): { sortLine: number; precedence: number } { + switch (edit.op) { + case "replace_line": + return { sortLine: edit.pos.line, precedence: 0 }; + case "replace_range": + return { sortLine: edit.end.line, precedence: 0 }; + case "append_at": + return { sortLine: edit.pos.line, precedence: 1 }; + case "prepend_at": + return { sortLine: edit.pos.line, precedence: 2 }; + case "append_file": + return { sortLine: fileLineCount + 1, precedence: 1 }; + case "prepend_file": + return { sortLine: 0, precedence: 2 }; + } +} + +function applyHashlineEditToLines( + edit: HashlineEdit, + fileLines: string[], + originalFileLines: string[], + editIndex: number, + noopEdits: Array<{ editIndex: number; loc: string; current: string }>, + trackFirstChanged: (line: number) => void, +): void { + switch (edit.op) { + case "replace_line": { + const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line); + const newLines = edit.lines; + if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) { + noopEdits.push({ + editIndex, + loc: `${edit.pos.line}#${edit.pos.hash}`, + current: origLines.join("\n"), + }); + break; + } + fileLines.splice(edit.pos.line - 1, 1, ...newLines); + trackFirstChanged(edit.pos.line); + break; + } + case "replace_range": { + const count = edit.end.line - edit.pos.line + 1; + fileLines.splice(edit.pos.line - 1, count, ...edit.lines); + trackFirstChanged(edit.pos.line); + break; + } + case "append_at": { + const inserted = edit.lines; + if (inserted.length === 0) { + noopEdits.push({ + editIndex, + loc: `${edit.pos.line}#${edit.pos.hash}`, + current: originalFileLines[edit.pos.line - 1], + }); + break; + } + fileLines.splice(edit.pos.line, 0, ...inserted); + trackFirstChanged(edit.pos.line + 1); + break; + } + case "prepend_at": { + const inserted = edit.lines; + if (inserted.length === 0) { + noopEdits.push({ + editIndex, + loc: `${edit.pos.line}#${edit.pos.hash}`, + current: originalFileLines[edit.pos.line - 1], + }); + break; + } + fileLines.splice(edit.pos.line - 1, 0, ...inserted); + trackFirstChanged(edit.pos.line); + break; + } + case "append_file": { + const inserted = edit.lines; + if (inserted.length === 0) { + noopEdits.push({ editIndex, loc: "EOF", current: "" }); + break; + } + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...inserted); + trackFirstChanged(1); + } else { + fileLines.splice(fileLines.length, 0, ...inserted); + trackFirstChanged(fileLines.length - inserted.length + 1); + } + break; + } + case "prepend_file": { + const inserted = edit.lines; + if (inserted.length === 0) { + noopEdits.push({ editIndex, loc: "BOF", current: "" }); + break; + } + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...inserted); + } else { + fileLines.splice(0, 0, ...inserted); + } + trackFirstChanged(1); + break; + } + } +} + +function buildHashlineEditResult(params: { + fileLines: string[]; + firstChangedLine: number | undefined; + warnings: string[]; + noopEdits: Array<{ editIndex: number; loc: string; current: string }>; +}): { + lines: string; + firstChangedLine: number | undefined; + warnings?: string[]; + noopEdits?: Array<{ editIndex: number; loc: string; current: string }>; +} { + const { fileLines, firstChangedLine, warnings, noopEdits } = params; + return { + lines: fileLines.join("\n"), + firstChangedLine, + ...(warnings.length > 0 ? { warnings } : {}), + ...(noopEdits.length > 0 ? { noopEdits } : {}), + }; +} + +function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] { + const mismatches: HashMismatch[] = []; + for (const edit of edits) { + switch (edit.op) { + case "replace_line": + validateHashlineRef(edit.pos); + break; + case "replace_range": + validateHashlineRef(edit.pos); + validateHashlineRef(edit.end); + if (edit.pos.line > edit.end.line) { + throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`); + } + break; + case "append_at": + case "prepend_at": + validateHashlineRef(edit.pos); + ensureHashlineEditHasContent(edit); + break; + case "append_file": + case "prepend_file": + ensureHashlineEditHasContent(edit); + break; + } + } + return mismatches; + + function validateHashlineRef(ref: { line: number; hash: string }): void { + if (ref.line < 1 || ref.line > fileLines.length) { + throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`); + } + const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]); + if (actualHash === ref.hash) { + return; + } + mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); + } +} // ═══════════════════════════════════════════════════════════════════════════ // Edit Application // ═══════════════════════════════════════════════════════════════════════════ @@ -492,252 +940,28 @@ export function applyHashlineEdits( const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = []; const warnings: string[] = []; - // Pre-validate: collect all hash mismatches before mutating - const mismatches: HashMismatch[] = []; - function validateRef(ref: { line: number; hash: string }): boolean { - if (ref.line < 1 || ref.line > fileLines.length) { - throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`); - } - const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]); - if (actualHash === ref.hash) { - return true; - } - mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); - return false; - } - for (const edit of edits) { - switch (edit.op) { - case "replace_line": { - if (!validateRef(edit.pos)) continue; - break; - } - case "replace_range": { - const startValid = validateRef(edit.pos); - const endValid = validateRef(edit.end); - if (!startValid || !endValid) continue; - if (edit.pos.line > edit.end.line) { - throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`); - } - break; - } - case "append_at": - case "prepend_at": { - if (!validateRef(edit.pos)) continue; - if (edit.lines.length === 0) { - edit.lines = [""]; // insert an empty line - } - break; - } - case "append_file": - case "prepend_file": { - if (edit.lines.length === 0) { - edit.lines = [""]; // insert an empty line - } - break; - } - } - } + const mismatches = validateHashlineEditRefs(edits, fileLines); if (mismatches.length > 0) { throw new HashlineMismatchError(mismatches, fileLines); } - maybeAutocorrectEscapedTabIndentation(edits, warnings); - maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings); - - // Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line. - // This catches the common boundary-overreach pattern where the agent includes a closing delimiter - // in the replacement but sets `end` to the line before the delimiter, causing duplication. + runHashlinePreflightSanitizers(edits, warnings); for (const edit of edits) { - let endLine: number; - switch (edit.op) { - case "replace_line": - endLine = edit.pos.line; - break; - case "replace_range": - endLine = edit.end.line; - break; - default: - continue; - } - if (edit.lines.length === 0) continue; - const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end` - if (nextSurvivingIdx >= originalFileLines.length) continue; - const nextSurvivingLine = originalFileLines[nextSurvivingIdx]; - const lastInsertedLine = edit.lines[edit.lines.length - 1]; - const trimmedNext = nextSurvivingLine.trim(); - const trimmedLast = lastInsertedLine.trim(); - // Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation - if (trimmedLast.length > 0 && trimmedLast === trimmedNext) { - const tag = formatLineTag(endLine + 1, nextSurvivingLine); - warnings.push( - `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` + - `If you meant to replace the entire block, set \`end\` to ${tag} instead.`, - ); - } - } - // Deduplicate identical edits targeting the same line(s) - const seenEditKeys = new Map(); - const dedupIndices = new Set(); - for (let i = 0; i < edits.length; i++) { - const edit = edits[i]; - let lineKey: string; - switch (edit.op) { - case "replace_line": - lineKey = `s:${edit.pos.line}`; - break; - case "replace_range": - lineKey = `r:${edit.pos.line}:${edit.end.line}`; - break; - case "append_at": - lineKey = `i:${edit.pos.line}`; - break; - case "prepend_at": - lineKey = `ib:${edit.pos.line}`; - break; - case "append_file": - lineKey = "ieof"; - break; - case "prepend_file": - lineKey = "ibef"; - break; - } - const dstKey = `${lineKey}:${edit.lines.join("\n")}`; - if (seenEditKeys.has(dstKey)) { - dedupIndices.add(i); - } else { - seenEditKeys.set(dstKey, i); - } - } - if (dedupIndices.size > 0) { - for (let i = edits.length - 1; i >= 0; i--) { - if (dedupIndices.has(i)) edits.splice(i, 1); - } + collectBoundaryDuplicationWarning(edit, originalFileLines, warnings); } + dedupeHashlineEdits(edits); - // Compute sort key (descending) — bottom-up application - const annotated = edits.map((edit, idx) => { - let sortLine: number; - let precedence: number; - switch (edit.op) { - case "replace_line": - sortLine = edit.pos.line; - precedence = 0; - break; - case "replace_range": - sortLine = edit.end.line; - precedence = 0; - break; - case "append_at": - sortLine = edit.pos.line; - precedence = 1; - break; - case "prepend_at": - sortLine = edit.pos.line; - precedence = 2; - break; - case "append_file": - sortLine = fileLines.length + 1; - precedence = 1; - break; - case "prepend_file": - sortLine = 0; - precedence = 2; - break; - } - return { edit, idx, sortLine, precedence }; - }); + const annotated = edits + .map((edit, idx) => { + const { sortLine, precedence } = getHashlineEditSortKey(edit, fileLines.length); + return { edit, idx, sortLine, precedence }; + }) + .sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx); - annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx); - - // Apply edits bottom-up for (const { edit, idx } of annotated) { - switch (edit.op) { - case "replace_line": { - const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line); - const newLines = edit.lines; - if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) { - noopEdits.push({ - editIndex: idx, - loc: `${edit.pos.line}#${edit.pos.hash}`, - current: origLines.join("\n"), - }); - break; - } - fileLines.splice(edit.pos.line - 1, 1, ...newLines); - trackFirstChanged(edit.pos.line); - break; - } - case "replace_range": { - const count = edit.end.line - edit.pos.line + 1; - fileLines.splice(edit.pos.line - 1, count, ...edit.lines); - trackFirstChanged(edit.pos.line); - break; - } - case "append_at": { - const inserted = edit.lines; - if (inserted.length === 0) { - noopEdits.push({ - editIndex: idx, - loc: `${edit.pos.line}#${edit.pos.hash}`, - current: originalFileLines[edit.pos.line - 1], - }); - break; - } - fileLines.splice(edit.pos.line, 0, ...inserted); - trackFirstChanged(edit.pos.line + 1); - break; - } - case "prepend_at": { - const inserted = edit.lines; - if (inserted.length === 0) { - noopEdits.push({ - editIndex: idx, - loc: `${edit.pos.line}#${edit.pos.hash}`, - current: originalFileLines[edit.pos.line - 1], - }); - break; - } - fileLines.splice(edit.pos.line - 1, 0, ...inserted); - trackFirstChanged(edit.pos.line); - break; - } - case "append_file": { - const inserted = edit.lines; - if (inserted.length === 0) { - noopEdits.push({ editIndex: idx, loc: "EOF", current: "" }); - break; - } - if (fileLines.length === 1 && fileLines[0] === "") { - fileLines.splice(0, 1, ...inserted); - trackFirstChanged(1); - } else { - fileLines.splice(fileLines.length, 0, ...inserted); - trackFirstChanged(fileLines.length - inserted.length + 1); - } - break; - } - case "prepend_file": { - const inserted = edit.lines; - if (inserted.length === 0) { - noopEdits.push({ editIndex: idx, loc: "BOF", current: "" }); - break; - } - if (fileLines.length === 1 && fileLines[0] === "") { - fileLines.splice(0, 1, ...inserted); - } else { - fileLines.splice(0, 0, ...inserted); - } - trackFirstChanged(1); - break; - } - } + applyHashlineEditToLines(edit, fileLines, originalFileLines, idx, noopEdits, trackFirstChanged); } - return { - lines: fileLines.join("\n"), - firstChangedLine, - ...(warnings.length > 0 ? { warnings } : {}), - ...(noopEdits.length > 0 ? { noopEdits } : {}), - }; + return buildHashlineEditResult({ fileLines, firstChangedLine, warnings, noopEdits }); function trackFirstChanged(line: number): void { if (firstChangedLine === undefined || line < firstChangedLine) { @@ -962,3 +1186,218 @@ export function buildCompactHashlineDiffPreview( return { preview: out.join("\n"), addedLines, removedLines }; } + +export async function computeHashlineDiff( + input: { path: string; edits: HashlineEdit[]; move?: string }, + cwd: string, +): Promise< + | { + diff: string; + firstChangedLine: number | undefined; + } + | { + error: string; + } +> { + const { path, edits, move } = input; + const absolutePath = resolveToCwd(path, cwd); + const movePath = move ? resolveToCwd(move, cwd) : undefined; + const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0; + + try { + const file = Bun.file(absolutePath); + + if (movePath === absolutePath) { + return { error: "move path is the same as source path" }; + } + if (isMoveOnly) { + return { diff: "", firstChangedLine: undefined }; + } + + const rawContent = await readHashlineFileText(file, path); + + const { text: content } = stripBom(rawContent); + const normalizedContent = normalizeToLF(content); + const result = applyHashlineEdits(normalizedContent, edits); + if (normalizedContent === result.lines && !move) { + return { error: `No changes would be made to ${path}. The edits produce identical content.` }; + } + + return generateDiffString(normalizedContent, result.lines); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +async function readHashlineFileText(file: BunFile, path: string): Promise { + try { + return await file.text(); + } catch (error) { + if (isEnoent(error)) { + throw new Error(`File not found: ${path}`); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || `Unable to read ${path}`); + } +} + +export async function executeHashlineMode( + options: ExecuteHashlineModeOptions, +): Promise> { + const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options; + const { path, edits, delete: deleteFile, move } = params; + + enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move }); + + if (path.endsWith(".ipynb") && edits?.length > 0) { + throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); + } + + const absolutePath = resolvePlanPath(session, path); + const resolvedMove = move ? resolvePlanPath(session, move) : undefined; + if (resolvedMove === absolutePath) { + throw new Error("move path is the same as source path"); + } + + const sourceFile = Bun.file(absolutePath); + const sourceExists = await sourceFile.exists(); + const isMoveOnly = Boolean(resolvedMove) && edits.length === 0; + + if (deleteFile) { + if (sourceExists) { + await sourceFile.unlink(); + } + invalidateFsScanAfterDelete(absolutePath); + return { + content: [{ type: "text", text: `Deleted ${path}` }], + details: { + diff: "", + op: "delete", + meta: outputMeta().get(), + }, + }; + } + + if (isMoveOnly && resolvedMove) { + if (!sourceExists) { + throw new Error(`File not found: ${path}`); + } + const parentDir = nodePath.dirname(resolvedMove); + if (parentDir && parentDir !== ".") { + await fs.mkdir(parentDir, { recursive: true }); + } + await fs.rename(absolutePath, resolvedMove); + invalidateFsScanAfterRename(absolutePath, resolvedMove); + return { + content: [{ type: "text", text: `Moved ${path} to ${move}` }], + details: { + diff: "", + op: "update", + move, + meta: outputMeta().get(), + }, + }; + } + + if (!sourceExists) { + const lines: string[] = []; + for (const edit of edits) { + if (edit.loc === "append") { + lines.push(...hashlineParseText(edit.content)); + } else if (edit.loc === "prepend") { + lines.unshift(...hashlineParseText(edit.content)); + } else { + throw new Error(`File not found: ${path}`); + } + } + + await Bun.write(absolutePath, lines.join("\n")); + invalidateFsScanAfterWrite(absolutePath); + return { + content: [{ type: "text", text: `Created ${path}` }], + details: { + diff: "", + op: "create", + meta: outputMeta().get(), + }, + }; + } + + const anchorEdits = resolveEditAnchors(edits); + const rawContent = await sourceFile.text(); + await checkAutoGeneratedFileContent(rawContent, path); + + const { bom, text } = stripBom(rawContent); + const originalEnding = detectLineEnding(text); + const originalNormalized = normalizeToLF(text); + let normalizedText = originalNormalized; + + const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); + normalizedText = anchorResult.lines; + + const result = { + text: normalizedText, + firstChangedLine: anchorResult.firstChangedLine, + warnings: anchorResult.warnings, + noopEdits: anchorResult.noopEdits, + }; + if (originalNormalized === result.text && !move) { + let diagnostic = `No changes made to ${path}. The edits produced identical content.`; + if (result.noopEdits && result.noopEdits.length > 0) { + const details = result.noopEdits + .map( + edit => + `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${edit.current}`, + ) + .join("\n"); + diagnostic += `\n${details}`; + if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) { + const preview = result.noopEdits[0].current.trimEnd(); + if (preview.length > 0) { + diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`; + } + } + } + throw new Error(diagnostic); + } + + const writePath = resolvedMove ?? absolutePath; + const finalContent = bom + restoreLineEndings(result.text, originalEnding); + const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst => + dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined, + ); + if (resolvedMove && resolvedMove !== absolutePath) { + await sourceFile.unlink(); + invalidateFsScanAfterRename(absolutePath, resolvedMove); + } else { + invalidateFsScanAfterWrite(absolutePath); + } + + const diffResult = generateDiffString(originalNormalized, result.text); + const meta = outputMeta() + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) + .get(); + + const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`; + const preview = buildCompactHashlineDiffPreview(diffResult.diff); + const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`; + const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; + const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : ""; + + return { + content: [ + { + type: "text", + text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`, + }, + ], + details: { + diff: diffResult.diff, + firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, + diagnostics, + op: "update", + move, + meta, + }, + }; +} diff --git a/packages/coding-agent/src/patch/applicator.ts b/packages/coding-agent/src/edit/modes/patch.ts similarity index 78% rename from packages/coding-agent/src/patch/applicator.ts rename to packages/coding-agent/src/edit/modes/patch.ts index 54ae2d585..43224616c 100644 --- a/packages/coding-agent/src/patch/applicator.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -7,8 +7,33 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { resolveToCwd } from "../tools/path-utils"; -import { DEFAULT_FUZZY_THRESHOLD, findClosestSequenceMatch, findContextLine, findMatch, seekSequence } from "./fuzzy"; +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { StringEnum } from "@oh-my-pi/pi-ai"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { type Static, Type } from "@sinclair/typebox"; +import { + type FileDiagnosticsResult, + flushLspWritethroughBatch, + type WritethroughCallback, + type WritethroughDeferredHandle, +} from "../../lsp"; +import type { ToolSession } from "../../tools"; +import { checkAutoGeneratedFile } from "../../tools/auto-generated-guard"; +import { + invalidateFsScanAfterDelete, + invalidateFsScanAfterRename, + invalidateFsScanAfterWrite, +} from "../../tools/fs-cache-invalidation"; +import { outputMeta } from "../../tools/output-meta"; +import { resolveToCwd } from "../../tools/path-utils"; +import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; +import { + ApplyPatchError, + type DiffHunk, + generateUnifiedDiffString, + normalizeCreateContent, + parseDiffHunks, +} from "../diff"; import { adjustIndentation, convertLeadingTabsToSpaces, @@ -18,18 +43,56 @@ import { normalizeToLF, restoreLineEndings, stripBom, -} from "./normalize"; -import { normalizeCreateContent, parseHunks } from "./parser"; -import type { - ApplyPatchOptions, - ApplyPatchResult, - ContextLineResult, - DiffHunk, - FileSystem, - NormalizedPatchInput, - PatchInput, -} from "./types"; -import { ApplyPatchError, normalizePatchInput } from "./types"; +} from "../normalize"; +import type { EditToolDetails, LspBatchRequest } from "../renderer"; +import { + type ContextLineResult, + DEFAULT_FUZZY_THRESHOLD, + findClosestSequenceMatch, + findContextLine, + findMatch, + type SequenceSearchResult, + seekSequence, +} from "./replace"; + +export type Operation = "create" | "delete" | "update"; + +export interface PatchInput { + path: string; + op: Operation; + rename?: string; + diff?: string; +} + +export interface FileSystem { + exists(path: string): Promise; + read(path: string): Promise; + readBinary?: (path: string) => Promise; + write(path: string, content: string): Promise; + delete(path: string): Promise; + mkdir(path: string): Promise; +} + +interface FileChange { + type: Operation; + path: string; + newPath?: string; + oldContent?: string; + newContent?: string; +} + +export interface ApplyPatchResult { + change: FileChange; + warnings?: string[]; +} + +export interface ApplyPatchOptions { + cwd: string; + dryRun?: boolean; + fuzzyThreshold?: number; + allowFuzzy?: boolean; + fs?: FileSystem; +} // ═══════════════════════════════════════════════════════════════════════════ // Default File System @@ -38,14 +101,13 @@ import { ApplyPatchError, normalizePatchInput } from "./types"; /** Default filesystem implementation using Bun APIs */ export const defaultFileSystem: FileSystem = { async exists(path: string): Promise { - return fs.existsSync(path); + return Bun.file(path).exists(); }, async read(path: string): Promise { return Bun.file(path).text(); }, async readBinary(path: string): Promise { - const buffer = await Bun.file(path).arrayBuffer(); - return new Uint8Array(buffer); + return fs.promises.readFile(path); }, async write(path: string, content: string): Promise { await Bun.write(path, content); @@ -76,6 +138,71 @@ interface HunkVariant { kind: HunkVariantKind; } +function isBlankLine(line: string): boolean { + return line.trim().length === 0; +} + +function areEqualLines(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false; + } + return true; +} + +function areEqualTrimmedLines(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i].trim() !== right[i].trim()) return false; + } + return true; +} + +function getIndentChar(lines: string[]): string { + for (const line of lines) { + const ws = getLeadingWhitespace(line); + if (ws.length > 0) return ws[0]; + } + return " "; +} + +function collectIndentDeltas(oldLines: string[], actualLines: string[]): number[] { + const deltas: number[] = []; + const lineCount = Math.min(oldLines.length, actualLines.length); + for (let i = 0; i < lineCount; i++) { + const oldLine = oldLines[i]; + const actualLine = actualLines[i]; + if (isBlankLine(oldLine) || isBlankLine(actualLine)) continue; + deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine)); + } + return deltas; +} + +function applyIndentDelta(lines: string[], delta: number, indentChar: string): string[] { + return lines.map(line => { + if (isBlankLine(line)) return line; + if (delta > 0) return indentChar.repeat(delta) + line; + const toRemove = Math.min(-delta, countLeadingWhitespace(line)); + return line.slice(toRemove); + }); +} + +function canConvertTabsToSpaces(oldLines: string[], actualLines: string[], spacesPerTab: number): boolean { + const lineCount = Math.min(oldLines.length, actualLines.length); + for (let i = 0; i < lineCount; i++) { + const oldLine = oldLines[i]; + const actualLine = actualLines[i]; + if (isBlankLine(oldLine) || isBlankLine(actualLine)) continue; + const oldIndent = getLeadingWhitespace(oldLine); + const actualIndent = getLeadingWhitespace(actualLine); + if (oldIndent.length === 0) continue; + if (actualIndent.length !== oldIndent.length * spacesPerTab) { + return false; + } + } + return true; +} + // ═══════════════════════════════════════════════════════════════════════════ // Replacement Computation // ═══════════════════════════════════════════════════════════════════════════ @@ -87,42 +214,17 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n } // If pattern already matches actual exactly (including indentation), preserve agent's intended changes - if (patternLines.length === actualLines.length) { - let exactMatch = true; - for (let i = 0; i < patternLines.length; i++) { - if (patternLines[i] !== actualLines[i]) { - exactMatch = false; - break; - } - } - if (exactMatch) { - return newLines; - } + if (areEqualLines(patternLines, actualLines)) { + return newLines; } // If the patch is purely an indentation change (same trimmed content), apply exactly as specified - if (patternLines.length === newLines.length) { - let indentationOnly = true; - for (let i = 0; i < patternLines.length; i++) { - if (patternLines[i].trim() !== newLines[i].trim()) { - indentationOnly = false; - break; - } - } - if (indentationOnly) { - return newLines; - } + if (areEqualTrimmedLines(patternLines, newLines)) { + return newLines; } // Detect indent character from actual content - let indentChar = " "; - for (const line of actualLines) { - const ws = getLeadingWhitespace(line); - if (ws.length > 0) { - indentChar = ws[0]; - break; - } - } + const indentChar = getIndentChar(actualLines); let patternTabOnly = true; let actualSpaceOnly = true; @@ -171,9 +273,8 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n } } - if (consistent && ratio) { - const converted = convertLeadingTabsToSpaces(newLines.join("\n"), ratio).split("\n"); - return converted; + if (consistent && ratio && canConvertTabsToSpaces(patternLines, actualLines, ratio)) { + return convertLeadingTabsToSpaces(newLines.join("\n"), ratio).split("\n"); } } @@ -282,20 +383,8 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n patternMin = 0; } - let delta: number | undefined; - const deltas: number[] = []; - for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) { - const patternLine = patternLines[i]; - const actualLine = actualLines[i]; - if (patternLine.trim().length === 0 || actualLine.trim().length === 0) continue; - const pIndent = countLeadingWhitespace(patternLine); - const aIndent = countLeadingWhitespace(actualLine); - deltas.push(aIndent - pIndent); - } - - if (deltas.length > 0 && deltas.every(value => value === deltas[0])) { - delta = deltas[0]; - } + const deltas = collectIndentDeltas(patternLines, actualLines); + const delta = deltas.length > 0 && deltas.every(value => value === deltas[0]) ? deltas[0] : undefined; // Track which actual lines we've used to handle duplicate content correctly const usedActualLines = new Map(); // trimmed content -> count used @@ -328,11 +417,7 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n if (delta && delta !== 0) { const newIndent = countLeadingWhitespace(newLine); if (newIndent === patternMin) { - if (delta > 0) { - return indentChar.repeat(delta) + newLine; - } - const toRemove = Math.min(-delta, newIndent); - return newLine.slice(toRemove); + return applyIndentDelta([newLine], delta, indentChar)[0]; } } return newLine; @@ -737,7 +822,7 @@ function findSequenceWithHint( hintIndex: number | undefined, eof: boolean, allowFuzzy: boolean, -): import("./types").SequenceSearchResult { +): SequenceSearchResult { // Prefer content-based search starting from currentIndex const primaryResult = seekSequence(lines, pattern, currentIndex, eof, { allowFuzzy }); if ( @@ -910,6 +995,17 @@ function applyTrailingNewlinePolicy(content: string, hadFinalNewline: boolean): return content.replace(/\n+$/u, ""); } +async function readExistingPatchFile(fileSystem: FileSystem, absolutePath: string, path: string): Promise { + try { + return await fileSystem.read(absolutePath); + } catch (error) { + if (isEnoent(error)) { + throw new ApplyPatchError(`File not found: ${path}`); + } + throw error; + } +} + /** * Compute replacements needed to transform originalLines using the diff hunks. */ @@ -937,6 +1033,7 @@ function computeReplacements( } const lineHint = hunk.oldStartLine; const allowAggressiveFallbacks = hunk.changeContext !== undefined || lineHint !== undefined || hunk.isEndOfFile; + const fallbackVariants = filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks); if (lineHint !== undefined && hunk.changeContext === undefined && !hunk.hasContextLines) { lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1)); } @@ -1059,7 +1156,7 @@ function computeReplacements( } if (searchResult.index === undefined || (searchResult.matchCount ?? 0) > 1) { - for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) { + for (const variant of fallbackVariants) { if (variant.oldLines.length === 0) continue; const variantResult = findSequenceWithHint( originalLines, @@ -1079,7 +1176,7 @@ function computeReplacements( } if (searchResult.index === undefined && contextIndex !== undefined) { - for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) { + for (const variant of fallbackVariants) { if (variant.oldLines.length !== 1 || variant.newLines.length !== 1) continue; const removedLine = variant.oldLines[0]; const hasSharedDuplicate = hunk.newLines.some(line => line.trim() === removedLine.trim()); @@ -1178,23 +1275,8 @@ function computeReplacements( if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) { const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy }); if (secondMatch.index !== undefined) { - // Extract 3-line previews for each match - const formatPreview = (startIdx: number) => { - const contextLines = 2; - const maxLineLength = 80; - const start = Math.max(0, startIdx - contextLines); - const end = Math.min(originalLines.length, startIdx + contextLines + 1); - const lines = originalLines.slice(start, end); - return lines - .map((line, i) => { - const num = start + i + 1; - const truncated = line.length > maxLineLength ? `${line.slice(0, maxLineLength - 1)}…` : line; - return ` ${num} | ${truncated}`; - }) - .join("\n"); - }; - const preview1 = formatPreview(found); - const preview2 = formatPreview(secondMatch.index); + const preview1 = formatSequenceMatchPreview(originalLines, found); + const preview2 = formatSequenceMatchPreview(originalLines, secondMatch.index); throw new ApplyPatchError( `Found 2 occurrences in ${path}:\n\n${preview1}\n\n${preview2}\n\n` + `Add more context lines to disambiguate.`, @@ -1317,15 +1399,7 @@ function applyHunksToContent( } const content = newLines.join("\n"); - - // Preserve original trailing newline behavior - if (hadFinalNewline && !content.endsWith("\n")) { - return { content: `${content}\n`, warnings }; - } - if (!hadFinalNewline && content.endsWith("\n")) { - return { content: content.slice(0, -1), warnings }; - } - return { content, warnings }; + return { content: applyTrailingNewlinePolicy(content, hadFinalNewline), warnings }; } // ═══════════════════════════════════════════════════════════════════════════ @@ -1336,18 +1410,14 @@ function applyHunksToContent( * Apply a patch operation to the filesystem. */ export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise { - const normalized = normalizePatchInput(input); - return applyNormalizedPatch(normalized, options); + return applyNormalizedPatch(input, options); } /** * Apply a normalized patch operation to the filesystem. * @internal */ -async function applyNormalizedPatch( - input: NormalizedPatchInput, - options: ApplyPatchOptions, -): Promise { +async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOptions): Promise { const { cwd, dryRun = false, @@ -1358,6 +1428,7 @@ async function applyNormalizedPatch( const resolvePath = (p: string): string => resolveToCwd(p, cwd); const absolutePath = resolvePath(input.path); + const op = input.op ?? "update"; if (input.rename) { const destPath = resolvePath(input.rename); @@ -1367,7 +1438,7 @@ async function applyNormalizedPatch( } // Handle CREATE operation - if (input.op === "create") { + if (op === "create") { if (!input.diff) { throw new ApplyPatchError("Create operation requires diff (file content)"); } @@ -1393,12 +1464,8 @@ async function applyNormalizedPatch( } // Handle DELETE operation - if (input.op === "delete") { - if (!(await fs.exists(absolutePath))) { - throw new ApplyPatchError(`File not found: ${input.path}`); - } - - const oldContent = await fs.read(absolutePath); + if (op === "delete") { + const oldContent = await readExistingPatchFile(fs, absolutePath, input.path); if (!dryRun) { await fs.delete(absolutePath); } @@ -1417,11 +1484,7 @@ async function applyNormalizedPatch( throw new ApplyPatchError("Update operation requires diff (hunks)"); } - if (!(await fs.exists(absolutePath))) { - throw new ApplyPatchError(`File not found: ${input.path}`); - } - - const originalContent = await fs.read(absolutePath); + const originalContent = await readExistingPatchFile(fs, absolutePath, input.path); const { bom: bomFromText, text: strippedContent } = stripBom(originalContent); let bom = bomFromText; if (!bom && fs.readBinary) { @@ -1432,7 +1495,7 @@ async function applyNormalizedPatch( } const lineEnding = detectLineEnding(strippedContent); const normalizedContent = normalizeToLF(strippedContent); - const hunks = parseHunks(input.diff); + const hunks = parseDiffHunks(input.diff); if (hunks.length === 0) { throw new ApplyPatchError("Diff contains no hunks"); @@ -1480,3 +1543,243 @@ async function applyNormalizedPatch( export async function previewPatch(input: PatchInput, options: ApplyPatchOptions): Promise { return applyPatch(input, { ...options, dryRun: true }); } + +export async function computePatchDiff( + input: PatchInput, + cwd: string, + options?: { fuzzyThreshold?: number; allowFuzzy?: boolean }, +): Promise< + | { + diff: string; + firstChangedLine: number | undefined; + } + | { + error: string; + } +> { + try { + const result = await previewPatch(input, { + cwd, + fuzzyThreshold: options?.fuzzyThreshold, + allowFuzzy: options?.allowFuzzy, + }); + const oldContent = result.change.oldContent ?? ""; + const newContent = result.change.newContent ?? ""; + const normalizedOld = normalizeToLF(stripBom(oldContent).text); + const normalizedNew = normalizeToLF(stripBom(newContent).text); + if (!normalizedOld && !normalizedNew) { + return { diff: "", firstChangedLine: undefined }; + } + return generateUnifiedDiffString(normalizedOld, normalizedNew); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +export const patchEditSchema = Type.Object({ + path: Type.String({ description: "File path" }), + op: Type.Optional( + StringEnum(["create", "delete", "update"], { + description: "Operation (default: update)", + }), + ), + rename: Type.Optional(Type.String({ description: "New path for move" })), + diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), +}); + +export type PatchParams = Static; + +interface ExecutePatchModeOptions { + session: ToolSession; + params: PatchParams; + signal?: AbortSignal; + batchRequest?: LspBatchRequest; + allowFuzzy: boolean; + fuzzyThreshold: number; + writethrough: WritethroughCallback; + beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; +} + +export function isPatchParams(params: unknown): params is PatchParams { + if (typeof params !== "object" || params === null || !("path" in params)) { + return false; + } + return !("old_text" in params) && !("new_text" in params) && !("edits" in params); +} + +class LspFileSystem implements FileSystem { + #lastDiagnostics: FileDiagnosticsResult | undefined; + #fileCache: Record = {}; + + constructor( + private readonly writethrough: WritethroughCallback, + private readonly signal?: AbortSignal, + private readonly batchRequest?: LspBatchRequest, + private readonly deferredForPath?: (path: string) => WritethroughDeferredHandle, + ) {} + + #getFile(path: string): Bun.BunFile { + if (this.#fileCache[path]) { + return this.#fileCache[path]; + } + const file = Bun.file(path); + this.#fileCache[path] = file; + return file; + } + + async exists(path: string): Promise { + return this.#getFile(path).exists(); + } + + async read(path: string): Promise { + return this.#getFile(path).text(); + } + + async readBinary(path: string): Promise { + const bytes = await fs.promises.readFile(path); + return bytes; + } + + async write(path: string, content: string): Promise { + const file = this.#getFile(path); + const deferredForPath = this.deferredForPath; + const result = await this.writethrough( + path, + content, + this.signal, + file, + this.batchRequest, + deferredForPath ? (dst: string) => deferredForPath(dst) : undefined, + ); + if (result) { + this.#lastDiagnostics = result; + } + } + + async delete(path: string): Promise { + await this.#getFile(path).unlink(); + } + + async mkdir(path: string): Promise { + await fs.promises.mkdir(path, { recursive: true }); + } + + getDiagnostics(): FileDiagnosticsResult | undefined { + return this.#lastDiagnostics; + } +} + +function mergeDiagnosticsWithWarnings( + diagnostics: FileDiagnosticsResult | undefined, + warnings: string[], +): FileDiagnosticsResult | undefined { + if (warnings.length === 0) return diagnostics; + const warningMessages = warnings.map(warning => `patch: ${warning}`); + if (!diagnostics) { + return { + server: "patch", + messages: warningMessages, + summary: `Patch warnings: ${warnings.length}`, + errored: false, + }; + } + return { + ...diagnostics, + messages: [...warningMessages, ...diagnostics.messages], + summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`, + }; +} + +export async function executePatchMode( + options: ExecutePatchModeOptions, +): Promise> { + const { + session, + params, + signal, + batchRequest, + allowFuzzy, + fuzzyThreshold, + writethrough, + beginDeferredDiagnosticsForPath, + } = options; + const { path, op: rawOp, rename, diff } = params; + + const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; + + enforcePlanModeWrite(session, path, { op, move: rename }); + const resolvedPath = resolvePlanPath(session, path); + const resolvedRename = rename ? resolvePlanPath(session, rename) : undefined; + + if (path.endsWith(".ipynb")) { + throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); + } + if (rename?.endsWith(".ipynb")) { + throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); + } + + await checkAutoGeneratedFile(resolvedPath, path); + + const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff }; + const patchFileSystem = new LspFileSystem(writethrough, signal, batchRequest, beginDeferredDiagnosticsForPath); + const result = await applyPatch(input, { + cwd: session.cwd, + fs: patchFileSystem, + fuzzyThreshold, + allowFuzzy, + }); + + if (resolvedRename) { + invalidateFsScanAfterRename(resolvedPath, resolvedRename); + } else if (result.change.type === "delete") { + invalidateFsScanAfterDelete(resolvedPath); + } else { + invalidateFsScanAfterWrite(resolvedPath); + } + const effectiveRename = result.change.newPath ? rename : undefined; + + let diffResult: { diff: string; firstChangedLine: number | undefined } = { + diff: "", + firstChangedLine: undefined, + }; + if (result.change.type === "update" && result.change.oldContent && result.change.newContent) { + const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text); + const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text); + diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew); + } + + let resultText: string; + switch (result.change.type) { + case "create": + resultText = `Created ${path}`; + break; + case "delete": + resultText = `Deleted ${path}`; + break; + case "update": + resultText = effectiveRename ? `Updated and moved ${path} to ${effectiveRename}` : `Updated ${path}`; + break; + } + + let diagnostics = patchFileSystem.getDiagnostics(); + if (op === "delete" && batchRequest?.flush) { + const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, session.cwd, signal); + diagnostics ??= flushedDiagnostics; + } + const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, result.warnings ?? []); + const meta = outputMeta() + .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) + .get(); + + return { + content: [{ type: "text", text: resultText }], + details: { + diff: diffResult.diff, + firstChangedLine: diffResult.firstChangedLine, + diagnostics: mergedDiagnostics, + op, + move: effectiveRename, + meta, + }, + }; +} diff --git a/packages/coding-agent/src/patch/fuzzy.ts b/packages/coding-agent/src/edit/modes/replace.ts similarity index 55% rename from packages/coding-agent/src/patch/fuzzy.ts rename to packages/coding-agent/src/edit/modes/replace.ts index 7a274aef1..b81c359a1 100644 --- a/packages/coding-agent/src/patch/fuzzy.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -4,8 +4,152 @@ * Provides both character-level and line-level fuzzy matching with progressive * fallback strategies for finding text in files. */ -import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize"; -import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceMatchStrategy, SequenceSearchResult } from "./types"; +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { type Static, Type } from "@sinclair/typebox"; +import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; +import type { ToolSession } from "../../tools"; +import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; +import { outputMeta } from "../../tools/output-meta"; +import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; +import { generateDiffString, replaceText } from "../diff"; +import { + countLeadingWhitespace, + detectLineEnding, + normalizeForFuzzy, + normalizeToLF, + normalizeUnicode, + restoreLineEndings, + stripBom, +} from "../normalize"; +import type { EditToolDetails, LspBatchRequest } from "../renderer"; + +export interface FuzzyMatch { + actualText: string; + startIndex: number; + startLine: number; + confidence: number; +} + +export interface MatchOutcome { + match?: FuzzyMatch; + closest?: FuzzyMatch; + occurrences?: number; + occurrenceLines?: number[]; + occurrencePreviews?: string[]; + fuzzyMatches?: number; + dominantFuzzy?: boolean; +} + +export type SequenceMatchStrategy = + | "exact" + | "trim-trailing" + | "trim" + | "comment-prefix" + | "unicode" + | "prefix" + | "substring" + | "fuzzy" + | "fuzzy-dominant" + | "character"; + +export interface SequenceSearchResult { + index: number | undefined; + confidence: number; + matchCount?: number; + matchIndices?: number[]; + strategy?: SequenceMatchStrategy; +} + +export type ContextMatchStrategy = "exact" | "trim" | "unicode" | "prefix" | "substring" | "fuzzy"; + +export interface ContextLineResult { + index: number | undefined; + confidence: number; + matchCount?: number; + matchIndices?: number[]; + strategy?: ContextMatchStrategy; +} + +export class EditMatchError extends Error { + constructor( + readonly path: string, + readonly searchText: string, + readonly closest: FuzzyMatch | undefined, + readonly options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number }, + ) { + super(EditMatchError.formatMessage(path, searchText, closest, options)); + this.name = "EditMatchError"; + } + + static formatMessage( + path: string, + searchText: string, + closest: FuzzyMatch | undefined, + options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number }, + ): string { + if (!closest) { + return options.allowFuzzy + ? `Could not find a close enough match in ${path}.` + : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`; + } + + const similarity = Math.round(closest.confidence * 100); + const searchLines = searchText.split("\n"); + const actualLines = closest.actualText.split("\n"); + const { oldLine, newLine } = findFirstDifferentLine(searchLines, actualLines); + const thresholdPercent = Math.round(options.threshold * 100); + + const hint = options.allowFuzzy + ? options.fuzzyMatches && options.fuzzyMatches > 1 + ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.` + : `Closest match was below the ${thresholdPercent}% similarity threshold.` + : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches."; + + return [ + options.allowFuzzy + ? `Could not find a close enough match in ${path}.` + : `Could not find the exact text in ${path}.`, + ``, + `Closest match (${similarity}% similar) at line ${closest.startLine}:`, + ` - ${oldLine}`, + ` + ${newLine}`, + hint, + ].join("\n"); + } +} + +function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLine: string; newLine: string } { + const max = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < max; i++) { + const oldLine = oldLines[i] ?? ""; + const newLine = newLines[i] ?? ""; + if (oldLine !== newLine) { + return { oldLine, newLine }; + } + } + return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" }; +} + +function formatOccurrenceError(path: string, matchOutcome: MatchOutcome): string { + const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? ""; + const moreMsg = + matchOutcome.occurrences && matchOutcome.occurrences > MAX_RECORDED_MATCHES + ? ` (showing first ${MAX_RECORDED_MATCHES} of ${matchOutcome.occurrences})` + : ""; + return `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`; +} + +async function readReplaceFileContent(absolutePath: string, path: string): Promise { + try { + return await Bun.file(absolutePath).text(); + } catch (error) { + if (isEnoent(error)) { + throw new Error(`File not found: ${path}`); + } + throw error; + } +} // ═══════════════════════════════════════════════════════════════════════════ // Constants @@ -35,6 +179,135 @@ const OCCURRENCE_PREVIEW_CONTEXT = 5; /** Maximum line length for ambiguous match previews */ const OCCURRENCE_PREVIEW_MAX_LEN = 80; +/** Maximum number of match indices or previews to retain for diagnostics */ +const MAX_RECORDED_MATCHES = 5; + +/** Minimum confidence for a dominant fuzzy match to be auto-selected */ +const DOMINANT_FUZZY_MIN_CONFIDENCE = 0.97; + +/** Minimum score gap between the best and second-best fuzzy matches */ +const DOMINANT_FUZZY_DELTA = 0.08; + +interface IndexedMatches { + firstMatch: number | undefined; + matchCount: number; + matchIndices: number[]; +} + +interface PreviewWindowOptions { + context: number; + maxLen: number; +} + +function collectIndexedMatches( + start: number, + endInclusive: number, + predicate: (index: number) => boolean, +): IndexedMatches { + let firstMatch: number | undefined; + let matchCount = 0; + const matchIndices: number[] = []; + + for (let index = start; index <= endInclusive; index++) { + if (!predicate(index)) continue; + if (firstMatch === undefined) { + firstMatch = index; + } + matchCount++; + if (matchIndices.length < MAX_RECORDED_MATCHES) { + matchIndices.push(index); + } + } + + return { firstMatch, matchCount, matchIndices }; +} + +function toSingleMatchResult( + matches: IndexedMatches, + confidence: number, + strategy: TStrategy, +): { index: number; confidence: number; strategy: TStrategy } | undefined { + if (matches.firstMatch === undefined) { + return undefined; + } + return { + index: matches.firstMatch, + confidence, + strategy, + }; +} + +function toAmbiguousMatchResult( + matches: IndexedMatches, + confidence: number, + strategy: TStrategy, +): { index: number; confidence: number; matchCount: number; matchIndices: number[]; strategy: TStrategy } | undefined { + if (matches.firstMatch === undefined) { + return undefined; + } + return { + index: matches.firstMatch, + confidence, + matchCount: matches.matchCount, + matchIndices: matches.matchIndices, + strategy, + }; +} + +function formatPreviewWindow(lines: string[], centerIndex: number, options: PreviewWindowOptions): string { + const start = Math.max(0, centerIndex - options.context); + const end = Math.min(lines.length, centerIndex + options.context + 1); + return lines + .slice(start, end) + .map((line, index) => { + const num = start + index + 1; + const truncated = line.length > options.maxLen ? `${line.slice(0, options.maxLen - 1)}…` : line; + return ` ${num} | ${truncated}`; + }) + .join("\n"); +} + +function findExactMatchOutcome(content: string, target: string): MatchOutcome | undefined { + const exactIndex = content.indexOf(target); + if (exactIndex === -1) { + return undefined; + } + + const occurrences = content.split(target).length - 1; + if (occurrences > 1) { + const contentLines = content.split("\n"); + const occurrenceLines: number[] = []; + const occurrencePreviews: string[] = []; + let searchStart = 0; + + for (let i = 0; i < MAX_RECORDED_MATCHES; i++) { + const idx = content.indexOf(target, searchStart); + if (idx === -1) break; + const lineNumber = content.slice(0, idx).split("\n").length; + occurrenceLines.push(lineNumber); + occurrencePreviews.push( + formatPreviewWindow(contentLines, lineNumber - 1, { + context: OCCURRENCE_PREVIEW_CONTEXT, + maxLen: OCCURRENCE_PREVIEW_MAX_LEN, + }), + ); + searchStart = idx + 1; + } + + return { occurrences, occurrenceLines, occurrencePreviews }; + } + + const startLine = content.slice(0, exactIndex).split("\n").length; + return { + match: { + actualText: target, + startIndex: exactIndex, + startLine, + confidence: 1, + }, + }; +} + // ═══════════════════════════════════════════════════════════════════════════ // Core Algorithms // ═══════════════════════════════════════════════════════════════════════════ @@ -220,44 +493,9 @@ export function findMatch( return {}; } - // Try exact match first - const exactIndex = content.indexOf(target); - if (exactIndex !== -1) { - const occurrences = content.split(target).length - 1; - if (occurrences > 1) { - // Find line numbers and previews for each occurrence (up to 5) - const contentLines = content.split("\n"); - const occurrenceLines: number[] = []; - const occurrencePreviews: string[] = []; - let searchStart = 0; - for (let i = 0; i < 5; i++) { - const idx = content.indexOf(target, searchStart); - if (idx === -1) break; - const lineNumber = content.slice(0, idx).split("\n").length; - occurrenceLines.push(lineNumber); - const start = Math.max(0, lineNumber - 1 - OCCURRENCE_PREVIEW_CONTEXT); - const end = Math.min(contentLines.length, lineNumber + OCCURRENCE_PREVIEW_CONTEXT + 1); - const previewLines = contentLines.slice(start, end); - const preview = previewLines - .map((line, idx) => { - const num = start + idx + 1; - return ` ${num} | ${line.length > OCCURRENCE_PREVIEW_MAX_LEN ? `${line.slice(0, OCCURRENCE_PREVIEW_MAX_LEN - 1)}…` : line}`; - }) - .join("\n"); - occurrencePreviews.push(preview); - searchStart = idx + 1; - } - return { occurrences, occurrenceLines, occurrencePreviews }; - } - const startLine = content.slice(0, exactIndex).split("\n").length; - return { - match: { - actualText: target, - startIndex: exactIndex, - startLine, - confidence: 1, - }, - }; + const exactMatch = findExactMatchOutcome(content, target); + if (exactMatch) { + return exactMatch; } // Try fuzzy match @@ -272,12 +510,10 @@ export function findMatch( if (aboveThresholdCount === 1) { return { match: best, closest: best }; } - const dominantDelta = 0.08; - const dominantMin = 0.97; if ( aboveThresholdCount > 1 && - best.confidence >= dominantMin && - best.confidence - secondBestScore >= dominantDelta + best.confidence >= DOMINANT_FUZZY_MIN_CONFIDENCE && + best.confidence - secondBestScore >= DOMINANT_FUZZY_DELTA ) { return { match: best, closest: best, fuzzyMatches: aboveThresholdCount, dominantFuzzy: true }; } @@ -389,38 +625,31 @@ export function seekSequence( const maxStart = lines.length - pattern.length; const runExactPasses = (from: number, to: number): SequenceSearchResult | undefined => { - // Pass 1: Exact match - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, (a, b) => a === b)) { - return { index: i, confidence: 1.0, strategy: "exact" }; - } - } + const comparisonPasses: Array<{ + compare: (a: string, b: string) => boolean; + confidence: number; + strategy: SequenceMatchStrategy; + }> = [ + { compare: (a, b) => a === b, confidence: 1.0, strategy: "exact" }, + { compare: (a, b) => a.trimEnd() === b.trimEnd(), confidence: 0.99, strategy: "trim-trailing" }, + { compare: (a, b) => a.trim() === b.trim(), confidence: 0.98, strategy: "trim" }, + { + compare: (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b), + confidence: 0.975, + strategy: "comment-prefix", + }, + { + compare: (a, b) => normalizeUnicode(a) === normalizeUnicode(b), + confidence: 0.97, + strategy: "unicode", + }, + ]; - // Pass 2: Trailing whitespace stripped - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) { - return { index: i, confidence: 0.99, strategy: "trim-trailing" }; - } - } - - // Pass 3: Both leading and trailing whitespace stripped - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) { - return { index: i, confidence: 0.98, strategy: "trim" }; - } - } - - // Pass 3b: Comment-prefix normalized match - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) { - return { index: i, confidence: 0.975, strategy: "comment-prefix" }; - } - } - - // Pass 4: Normalize unicode punctuation - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) { - return { index: i, confidence: 0.97, strategy: "unicode" }; + for (const pass of comparisonPasses) { + const matches = collectIndexedMatches(from, to, i => matchesAt(lines, pattern, i, pass.compare)); + const result = toSingleMatchResult(matches, pass.confidence, pass.strategy); + if (result) { + return result; } } @@ -428,37 +657,20 @@ export function seekSequence( return undefined; } - // Pass 5: Partial line prefix match (track all matches for ambiguity detection) - { - let firstMatch: number | undefined; - let matchCount = 0; - const matchIndices: number[] = []; - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, lineStartsWithPattern)) { - if (firstMatch === undefined) firstMatch = i; - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - } - if (matchCount > 0) { - return { index: firstMatch, confidence: 0.965, matchCount, matchIndices, strategy: "prefix" }; - } - } + const partialPasses: Array<{ + compare: (line: string, patternLine: string) => boolean; + confidence: number; + strategy: SequenceMatchStrategy; + }> = [ + { compare: lineStartsWithPattern, confidence: 0.965, strategy: "prefix" }, + { compare: lineIncludesPattern, confidence: 0.94, strategy: "substring" }, + ]; - // Pass 6: Partial line substring match (track all matches for ambiguity detection) - { - let firstMatch: number | undefined; - let matchCount = 0; - const matchIndices: number[] = []; - for (let i = from; i <= to; i++) { - if (matchesAt(lines, pattern, i, lineIncludesPattern)) { - if (firstMatch === undefined) firstMatch = i; - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - } - if (matchCount > 0) { - return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" }; + for (const pass of partialPasses) { + const matches = collectIndexedMatches(from, to, i => matchesAt(lines, pattern, i, pass.compare)); + const result = toAmbiguousMatchResult(matches, pass.confidence, pass.strategy); + if (result) { + return result; } } @@ -482,34 +694,26 @@ export function seekSequence( } // Pass 7: Fuzzy matching - find best match above threshold - let bestIndex: number | undefined; let bestScore = 0; let secondBestScore = 0; - let matchCount = 0; - const matchIndices: number[] = []; + let bestIndex: number | undefined; + const fuzzyMatches: IndexedMatches = { + firstMatch: undefined, + matchCount: 0, + matchIndices: [], + }; - for (let i = searchStart; i <= maxStart; i++) { - const score = fuzzyScoreAt(lines, pattern, i); - if (score >= SEQUENCE_FUZZY_THRESHOLD) { - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - if (score > bestScore) { - secondBestScore = bestScore; - bestScore = score; - bestIndex = i; - } else if (score > secondBestScore) { - secondBestScore = score; - } - } - - // Also search from start if eof mode started from end - if (eof && searchStart > start) { - for (let i = start; i < searchStart; i++) { + const scoreFuzzyRange = (from: number, to: number): void => { + for (let i = from; i <= to; i++) { const score = fuzzyScoreAt(lines, pattern, i); if (score >= SEQUENCE_FUZZY_THRESHOLD) { - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); + if (fuzzyMatches.firstMatch === undefined) { + fuzzyMatches.firstMatch = i; + } + fuzzyMatches.matchCount++; + if (fuzzyMatches.matchIndices.length < MAX_RECORDED_MATCHES) { + fuzzyMatches.matchIndices.push(i); + } } if (score > bestScore) { secondBestScore = bestScore; @@ -519,21 +723,36 @@ export function seekSequence( secondBestScore = score; } } + }; + + scoreFuzzyRange(searchStart, maxStart); + + // Also search from start if eof mode started from end + if (eof && searchStart > start) { + scoreFuzzyRange(start, searchStart - 1); } if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) { - const dominantDelta = 0.08; - const dominantMin = 0.97; - if (matchCount > 1 && bestScore >= dominantMin && bestScore - secondBestScore >= dominantDelta) { + if ( + fuzzyMatches.matchCount > 1 && + bestScore >= DOMINANT_FUZZY_MIN_CONFIDENCE && + bestScore - secondBestScore >= DOMINANT_FUZZY_DELTA + ) { return { index: bestIndex, confidence: bestScore, matchCount: 1, - matchIndices, + matchIndices: fuzzyMatches.matchIndices, strategy: "fuzzy-dominant", }; } - return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" }; + return { + index: bestIndex, + confidence: bestScore, + matchCount: fuzzyMatches.matchCount, + matchIndices: fuzzyMatches.matchIndices, + strategy: "fuzzy", + }; } // Pass 8: Character-based fuzzy matching via findMatch @@ -620,56 +839,34 @@ export function findContextLine( const allowFuzzy = options?.allowFuzzy ?? true; const trimmedContext = context.trim(); - // Pass 1: Exact line match - { - let firstMatch: number | undefined; - let matchCount = 0; - const matchIndices: number[] = []; - for (let i = startFrom; i < lines.length; i++) { - if (lines[i] === context) { - if (firstMatch === undefined) firstMatch = i; - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - } - if (matchCount > 0) { - return { index: firstMatch, confidence: 1.0, matchCount, matchIndices, strategy: "exact" }; - } - } + const endIndex = lines.length - 1; + const exactPasses: Array<{ + confidence: number; + strategy: ContextMatchStrategy; + predicate: (index: number) => boolean; + }> = [ + { confidence: 1.0, strategy: "exact", predicate: i => lines[i] === context }, + { confidence: 0.99, strategy: "trim", predicate: i => lines[i].trim() === trimmedContext }, + ]; - // Pass 2: Trimmed match - { - let firstMatch: number | undefined; - let matchCount = 0; - const matchIndices: number[] = []; - for (let i = startFrom; i < lines.length; i++) { - if (lines[i].trim() === trimmedContext) { - if (firstMatch === undefined) firstMatch = i; - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - } - if (matchCount > 0) { - return { index: firstMatch, confidence: 0.99, matchCount, matchIndices, strategy: "trim" }; + for (const pass of exactPasses) { + const matches = collectIndexedMatches(startFrom, endIndex, pass.predicate); + const result = toAmbiguousMatchResult(matches, pass.confidence, pass.strategy); + if (result) { + return result; } } // Pass 3: Unicode normalization match const normalizedContext = normalizeUnicode(context); - { - let firstMatch: number | undefined; - let matchCount = 0; - const matchIndices: number[] = []; - for (let i = startFrom; i < lines.length; i++) { - if (normalizeUnicode(lines[i]) === normalizedContext) { - if (firstMatch === undefined) firstMatch = i; - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - } - if (matchCount > 0) { - return { index: firstMatch, confidence: 0.98, matchCount, matchIndices, strategy: "unicode" }; - } + const unicodeMatches = collectIndexedMatches( + startFrom, + endIndex, + i => normalizeUnicode(lines[i]) === normalizedContext, + ); + const unicodeResult = toAmbiguousMatchResult(unicodeMatches, 0.98, "unicode"); + if (unicodeResult) { + return unicodeResult; } if (!allowFuzzy) { @@ -679,19 +876,12 @@ export function findContextLine( // Pass 4: Prefix match (file line starts with context) const contextNorm = normalizeForFuzzy(context); if (contextNorm.length > 0) { - let firstMatch: number | undefined; - let matchCount = 0; - const matchIndices: number[] = []; - for (let i = startFrom; i < lines.length; i++) { - const lineNorm = normalizeForFuzzy(lines[i]); - if (lineNorm.startsWith(contextNorm)) { - if (firstMatch === undefined) firstMatch = i; - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); - } - } - if (matchCount > 0) { - return { index: firstMatch, confidence: 0.96, matchCount, matchIndices, strategy: "prefix" }; + const prefixMatches = collectIndexedMatches(startFrom, endIndex, i => + normalizeForFuzzy(lines[i]).startsWith(contextNorm), + ); + const prefixResult = toAmbiguousMatchResult(prefixMatches, 0.96, "prefix"); + if (prefixResult) { + return prefixResult; } } @@ -750,15 +940,23 @@ export function findContextLine( // Pass 6: Fuzzy match using similarity let bestIndex: number | undefined; let bestScore = 0; - let matchCount = 0; - const matchIndices: number[] = []; + const fuzzyMatches: IndexedMatches = { + firstMatch: undefined, + matchCount: 0, + matchIndices: [], + }; for (let i = startFrom; i < lines.length; i++) { const lineNorm = normalizeForFuzzy(lines[i]); const score = similarity(lineNorm, contextNorm); if (score >= CONTEXT_FUZZY_THRESHOLD) { - matchCount++; - if (matchIndices.length < 5) matchIndices.push(i); + if (fuzzyMatches.firstMatch === undefined) { + fuzzyMatches.firstMatch = i; + } + fuzzyMatches.matchCount++; + if (fuzzyMatches.matchIndices.length < MAX_RECORDED_MATCHES) { + fuzzyMatches.matchIndices.push(i); + } } if (score > bestScore) { bestScore = score; @@ -767,7 +965,13 @@ export function findContextLine( } if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) { - return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" }; + return { + index: bestIndex, + confidence: bestScore, + matchCount: fuzzyMatches.matchCount, + matchIndices: fuzzyMatches.matchIndices, + strategy: "fuzzy", + }; } if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) { @@ -782,3 +986,121 @@ export function findContextLine( return { index: undefined, confidence: bestScore }; } + +export const replaceEditSchema = Type.Object({ + path: Type.String({ description: "File path (relative or absolute)" }), + old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), + new_text: Type.String({ description: "Replacement text" }), + all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), +}); + +export type ReplaceParams = Static; + +interface ExecuteReplaceModeOptions { + session: ToolSession; + params: ReplaceParams; + signal?: AbortSignal; + batchRequest?: LspBatchRequest; + allowFuzzy: boolean; + fuzzyThreshold: number; + writethrough: WritethroughCallback; + beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; +} + +export function isReplaceParams(params: unknown): params is ReplaceParams { + return typeof params === "object" && params !== null && "old_text" in params && "new_text" in params; +} + +export async function executeReplaceMode( + options: ExecuteReplaceModeOptions, +): Promise> { + const { + session, + params, + signal, + batchRequest, + allowFuzzy, + fuzzyThreshold, + writethrough, + beginDeferredDiagnosticsForPath, + } = options; + const { path, old_text, new_text, all } = params; + + enforcePlanModeWrite(session, path); + + if (path.endsWith(".ipynb")) { + throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); + } + + if (old_text.length === 0) { + throw new Error("old_text must not be empty."); + } + + const absolutePath = resolvePlanPath(session, path); + const rawContent = await readReplaceFileContent(absolutePath, path); + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const normalizedOldText = normalizeToLF(old_text); + const normalizedNewText = normalizeToLF(new_text); + + const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { + fuzzy: allowFuzzy, + all: all ?? false, + threshold: fuzzyThreshold, + }); + + if (result.count === 0) { + const matchOutcome = findMatch(normalizedContent, normalizedOldText, { + allowFuzzy, + threshold: fuzzyThreshold, + }); + + if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { + throw new Error(formatOccurrenceError(path, matchOutcome)); + } + + throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { + allowFuzzy, + threshold: fuzzyThreshold, + fuzzyMatches: matchOutcome.fuzzyMatches, + }); + } + + if (normalizedContent === result.content) { + throw new Error( + `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, + ); + } + + const finalContent = bom + restoreLineEndings(result.content, originalEnding); + const diagnostics = await writethrough( + absolutePath, + finalContent, + signal, + Bun.file(absolutePath), + batchRequest, + dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined), + ); + invalidateFsScanAfterWrite(absolutePath); + + const diffResult = generateDiffString(normalizedContent, result.content); + const resultText = + result.count > 1 + ? `Successfully replaced ${result.count} occurrences in ${path}.` + : `Successfully replaced text in ${path}.`; + + const meta = outputMeta() + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) + .get(); + + return { + content: [{ type: "text", text: resultText }], + details: { + diff: diffResult.diff, + firstChangedLine: diffResult.firstChangedLine, + diagnostics, + meta, + }, + }; +} diff --git a/packages/coding-agent/src/patch/normalize.ts b/packages/coding-agent/src/edit/normalize.ts similarity index 79% rename from packages/coding-agent/src/patch/normalize.ts rename to packages/coding-agent/src/edit/normalize.ts index 90e637ed3..04c50c0bc 100644 --- a/packages/coding-agent/src/patch/normalize.ts +++ b/packages/coding-agent/src/edit/normalize.ts @@ -70,12 +70,16 @@ export function getLeadingWhitespace(line: string): string { return line.slice(0, countLeadingWhitespace(line)); } +function isNonEmptyLine(line: string): boolean { + return line.trim().length > 0; +} + /** Compute minimum indentation of non-empty lines */ export function minIndent(text: string): number { const lines = text.split("\n"); let min = Infinity; for (const line of lines) { - if (line.trim().length > 0) { + if (isNonEmptyLine(line)) { min = Math.min(min, countLeadingWhitespace(line)); } } @@ -107,9 +111,7 @@ function gcd(a: number, b: number): number { interface IndentProfile { lines: string[]; - indentStrings: string[]; indentCounts: number[]; - min: number; char: " " | "\t" | undefined; spaceOnly: boolean; tabOnly: boolean; @@ -120,9 +122,7 @@ interface IndentProfile { function buildIndentProfile(text: string): IndentProfile { const lines = text.split("\n"); - const indentStrings: string[] = []; const indentCounts: number[] = []; - let min = Infinity; let char: " " | "\t" | undefined; let spaceOnly = true; let tabOnly = true; @@ -131,12 +131,10 @@ function buildIndentProfile(text: string): IndentProfile { let unit = 0; for (const line of lines) { - if (line.trim().length === 0) continue; + if (!isNonEmptyLine(line)) continue; nonEmptyCount++; const indent = getLeadingWhitespace(line); - indentStrings.push(indent); indentCounts.push(indent.length); - min = Math.min(min, indent.length); if (indent.includes(" ")) { tabOnly = false; } @@ -156,10 +154,6 @@ function buildIndentProfile(text: string): IndentProfile { } } - if (min === Infinity) { - min = 0; - } - if (spaceOnly && nonEmptyCount > 0) { let current = 0; for (const count of indentCounts) { @@ -175,9 +169,7 @@ function buildIndentProfile(text: string): IndentProfile { return { lines, - indentStrings, indentCounts, - min, char, spaceOnly, tabOnly, @@ -246,6 +238,87 @@ export function normalizeForFuzzy(line: string): string { .replace(/[ \t]+/g, " "); } +function isIndentationOnlyRewrite(oldText: string, newText: string): boolean { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + if (oldLines.length !== newLines.length) { + return false; + } + for (let i = 0; i < oldLines.length; i++) { + if (oldLines[i].trim() !== newLines[i].trim()) { + return false; + } + } + return true; +} + +function maybeConvertTabIndentation( + oldProfile: IndentProfile, + actualProfile: IndentProfile, + newProfile: IndentProfile, + newText: string, +): string | undefined { + if (!actualProfile.spaceOnly || !oldProfile.tabOnly || !newProfile.tabOnly || actualProfile.unit <= 0) { + return undefined; + } + + const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length); + for (let i = 0; i < lineCount; i++) { + const oldLine = oldProfile.lines[i]; + const actualLine = actualProfile.lines[i]; + if (!isNonEmptyLine(oldLine) || !isNonEmptyLine(actualLine)) continue; + const oldIndent = getLeadingWhitespace(oldLine); + if (oldIndent.length === 0) continue; + const actualIndent = getLeadingWhitespace(actualLine); + if (actualIndent.length !== oldIndent.length * actualProfile.unit) { + return undefined; + } + } + + return convertLeadingTabsToSpaces(newText, actualProfile.unit); +} + +function computeUniformIndentDelta(oldProfile: IndentProfile, actualProfile: IndentProfile): number | undefined { + const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length); + const deltas: number[] = []; + for (let i = 0; i < lineCount; i++) { + const oldLine = oldProfile.lines[i]; + const actualLine = actualProfile.lines[i]; + if (!isNonEmptyLine(oldLine) || !isNonEmptyLine(actualLine)) continue; + deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine)); + } + + if (deltas.length === 0) { + return undefined; + } + + const delta = deltas[0]; + return deltas.every(value => value === delta) ? delta : undefined; +} + +function applyIndentDelta(text: string, delta: number, indentChar: string): string { + const adjusted = text.split("\n").map(line => { + if (!isNonEmptyLine(line)) { + return line; + } + if (delta > 0) { + return indentChar.repeat(delta) + line; + } + const toRemove = Math.min(-delta, countLeadingWhitespace(line)); + return line.slice(toRemove); + }); + + return adjusted.join("\n"); +} + +function hasNonEmptyIndentProfiles(...profiles: IndentProfile[]): boolean { + return profiles.every(profile => profile.nonEmptyCount > 0); +} + +function hasMixedIndentation(...profiles: IndentProfile[]): boolean { + return profiles.some(profile => profile.mixed); +} + // ═══════════════════════════════════════════════════════════════════════════ // Indentation Adjustment // ═══════════════════════════════════════════════════════════════════════════ @@ -264,73 +337,32 @@ export function adjustIndentation(oldText: string, actualText: string, newText: } // If the patch is purely an indentation change (same trimmed content), apply exactly as specified - const oldLines = oldText.split("\n"); - const newLines = newText.split("\n"); - if (oldLines.length === newLines.length) { - let indentationOnly = true; - for (let i = 0; i < oldLines.length; i++) { - if (oldLines[i].trim() !== newLines[i].trim()) { - indentationOnly = false; - break; - } - } - if (indentationOnly) { - return newText; - } + if (isIndentationOnlyRewrite(oldText, newText)) { + return newText; } const oldProfile = buildIndentProfile(oldText); const actualProfile = buildIndentProfile(actualText); const newProfile = buildIndentProfile(newText); - if (newProfile.nonEmptyCount === 0 || oldProfile.nonEmptyCount === 0 || actualProfile.nonEmptyCount === 0) { + if (!hasNonEmptyIndentProfiles(oldProfile, actualProfile, newProfile)) { return newText; } - if (oldProfile.mixed || actualProfile.mixed || newProfile.mixed) { + if (hasMixedIndentation(oldProfile, actualProfile, newProfile)) { return newText; } if (oldProfile.char && actualProfile.char && oldProfile.char !== actualProfile.char) { - if (actualProfile.spaceOnly && oldProfile.tabOnly && newProfile.tabOnly && actualProfile.unit > 0) { - let consistent = true; - const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length); - for (let i = 0; i < lineCount; i++) { - const oldLine = oldProfile.lines[i]; - const actualLine = actualProfile.lines[i]; - if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue; - const oldIndent = getLeadingWhitespace(oldLine); - const actualIndent = getLeadingWhitespace(actualLine); - if (oldIndent.length === 0) continue; - if (actualIndent.length !== oldIndent.length * actualProfile.unit) { - consistent = false; - break; - } - } - return consistent ? convertLeadingTabsToSpaces(newText, actualProfile.unit) : newText; + const converted = maybeConvertTabIndentation(oldProfile, actualProfile, newProfile, newText); + if (converted !== undefined) { + return converted; } return newText; } - const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length); - const deltas: number[] = []; - for (let i = 0; i < lineCount; i++) { - const oldLine = oldProfile.lines[i]; - const actualLine = actualProfile.lines[i]; - if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue; - deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine)); - } - - if (deltas.length === 0) { - return newText; - } - - const delta = deltas[0]; - if (!deltas.every(value => value === delta)) { - return newText; - } - - if (delta === 0) { + const delta = computeUniformIndentDelta(oldProfile, actualProfile); + if (delta === undefined || delta === 0) { return newText; } @@ -339,16 +371,5 @@ export function adjustIndentation(oldText: string, actualText: string, newText: } const indentChar = actualProfile.char ?? oldProfile.char ?? detectIndentChar(actualText); - const adjusted = newText.split("\n").map(line => { - if (line.trim().length === 0) { - return line; - } - if (delta > 0) { - return indentChar.repeat(delta) + line; - } - const toRemove = Math.min(-delta, countLeadingWhitespace(line)); - return line.slice(toRemove); - }); - - return adjusted.join("\n"); + return applyIndentDelta(newText, delta, indentChar); } diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/edit/renderer.ts similarity index 62% rename from packages/coding-agent/src/patch/shared.ts rename to packages/coding-agent/src/edit/renderer.ts index c16106714..418746c2e 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -1,5 +1,5 @@ /** - * Shared utilities for edit tool TUI rendering. + * Edit tool renderer and LSP batching helpers. */ import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; @@ -22,8 +22,10 @@ import { truncateDiffByHunk, } from "../tools/render-utils"; import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; -import type { ChunkToolEdit, HashlineToolEdit } from "./index"; -import type { DiffError, DiffResult, Operation } from "./types"; +import type { DiffError, DiffResult } from "./diff"; +import type { ChunkToolEdit } from "./modes/chunk"; +import type { HashlineToolEdit } from "./modes/hashline"; +import type { Operation } from "./modes/patch"; // ═══════════════════════════════════════════════════════════════════════════ // LSP Batching @@ -31,7 +33,12 @@ import type { DiffError, DiffResult, Operation } from "./types"; const LSP_BATCH_TOOLS = new Set(["edit", "write"]); -export function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined { +export interface LspBatchRequest { + id: string; + flush: boolean; +} + +export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBatchRequest | undefined { if (!toolCall) { return undefined; } @@ -96,12 +103,69 @@ export interface EditRenderContext { } const EDIT_STREAMING_PREVIEW_LINES = 12; +const CALL_TEXT_PREVIEW_LINES = 6; +const CALL_TEXT_PREVIEW_WIDTH = 80; +const STREAMING_EDIT_PREVIEW_WIDTH = 120; +const STREAMING_EDIT_PREVIEW_LIMIT = 4; +const STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT = 8; + +interface FormattedStreamingEdit { + srcLabel: string; + dst: string; +} function countLines(text: string): number { if (!text) return 0; return text.split("\n").length; } +function getOperationTitle(op: Operation | undefined): string { + return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit"; +} + +function formatEditPathDisplay( + rawPath: string, + uiTheme: Theme, + options?: { rename?: string; firstChangedLine?: number }, +): string { + let pathDisplay = rawPath ? uiTheme.fg("accent", shortenPath(rawPath)) : uiTheme.fg("toolOutput", "…"); + + if (options?.firstChangedLine) { + pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`); + } + + if (options?.rename) { + pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(options.rename))}`; + } + + return pathDisplay; +} + +function formatEditDescription( + rawPath: string, + uiTheme: Theme, + options?: { rename?: string; firstChangedLine?: number }, +): { language: string; description: string } { + const language = getLanguageFromPath(rawPath) ?? "text"; + const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language)); + return { + language, + description: `${icon} ${formatEditPathDisplay(rawPath, uiTheme, options)}`, + }; +} + +function renderPlainTextPreview(text: string, uiTheme: Theme): string { + const previewLines = text.split("\n"); + let preview = "\n\n"; + for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) { + preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`; + } + if (previewLines.length > CALL_TEXT_PREVIEW_LINES) { + preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`); + } + return preview.trimEnd(); +} + function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string { if (!diff) return ""; const lines = diff.split("\n"); @@ -117,110 +181,115 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe return text; } +function isChunkStreamingEdit(edit: Partial): edit is Partial { + return "target" in edit; +} + +function getStreamingEditContent(content: unknown): string { + if (Array.isArray(content)) { + return content.join("\n"); + } + return typeof content === "string" ? content : ""; +} + +function formatHashlineStreamingEdit(edit: Partial): FormattedStreamingEdit { + if (typeof edit !== "object" || !edit) { + return { srcLabel: "\u2022 (incomplete edit)", dst: "" }; + } + + const contentLines = getStreamingEditContent(edit.content); + const loc = edit.loc; + + if (loc === "append" || loc === "prepend") { + return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines }; + } + if (typeof loc === "object" && loc) { + if ("range" in loc && typeof loc.range === "object" && loc.range) { + return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines }; + } + if ("line" in loc) { + return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines }; + } + if ("append" in loc) { + return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines }; + } + if ("prepend" in loc) { + return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines }; + } + } + return { srcLabel: "\u2022 (unknown edit)", dst: contentLines }; +} + +function formatChunkStreamingEdit(edit: Partial): FormattedStreamingEdit { + if (typeof edit !== "object" || !edit) { + return { srcLabel: "\u2022 (incomplete edit)", dst: "" }; + } + + const contentLines = getStreamingEditContent(edit.content); + const target = edit.target ?? "?"; + const op = edit.op ?? "replace"; + + switch (op) { + case "append": + case "append_child": + return { srcLabel: `\u2022 append child ${target}`, dst: contentLines }; + case "prepend": + case "prepend_child": + return { srcLabel: `\u2022 prepend child ${target}`, dst: contentLines }; + case "after": + case "append_sibling": + return { srcLabel: `\u2022 insert after ${target}/${edit.anchor ?? "?"}`, dst: contentLines }; + case "before": + case "prepend_sibling": + return { srcLabel: `\u2022 insert before ${target}/${edit.anchor ?? "?"}`, dst: contentLines }; + default: + return { + srcLabel: contentLines.length === 0 ? `\u2022 remove ${target}` : `\u2022 replace ${target}`, + dst: contentLines, + }; + } +} + function formatStreamingHashlineEdits(edits: Partial[], uiTheme: Theme): string { - const MAX_EDITS = 4; - const MAX_DST_LINES = 8; let text = "\n\n"; // Detect whether these are chunk edits (target field) or hashline edits (loc field) - const isChunk = edits.length > 0 && "target" in edits[0]; + const isChunk = edits.length > 0 && isChunkStreamingEdit(edits[0]); const label = isChunk ? "chunk edit" : "hashline edit"; + const formatEdit = isChunk ? formatChunkStreamingEdit : formatHashlineStreamingEdit; text += uiTheme.fg("dim", `[${edits.length} ${label}${edits.length === 1 ? "" : "s"}]`); text += "\n"; let shownEdits = 0; let shownDstLines = 0; for (const edit of edits) { shownEdits++; - if (shownEdits > MAX_EDITS) break; - const formatted = isChunk - ? formatChunkEdit(edit as Partial) - : formatHashlineEdit(edit as Partial); - text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), 120)); + if (shownEdits > STREAMING_EDIT_PREVIEW_LIMIT) break; + const formatted = formatEdit(edit as never); + text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), STREAMING_EDIT_PREVIEW_WIDTH)); text += "\n"; if (formatted.dst === "") { - text += uiTheme.fg("dim", truncateToWidth(" (delete)", 120)); + text += uiTheme.fg("dim", truncateToWidth(" (delete)", STREAMING_EDIT_PREVIEW_WIDTH)); text += "\n"; continue; } for (const dstLine of formatted.dst.split("\n")) { shownDstLines++; - if (shownDstLines > MAX_DST_LINES) break; - text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), 120)); + if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break; + text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), STREAMING_EDIT_PREVIEW_WIDTH)); text += "\n"; } - if (shownDstLines > MAX_DST_LINES) break; + if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break; } - if (edits.length > MAX_EDITS) { - text += uiTheme.fg("dim", `\u2026 (${edits.length - MAX_EDITS} more edits)`); + if (edits.length > STREAMING_EDIT_PREVIEW_LIMIT) { + text += uiTheme.fg("dim", `\u2026 (${edits.length - STREAMING_EDIT_PREVIEW_LIMIT} more edits)`); } - if (shownDstLines > MAX_DST_LINES) { - text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - MAX_DST_LINES} more dst lines)`); + if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) { + text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT} more dst lines)`); } return text.trimEnd(); - - function formatHashlineEdit(edit: Partial): { srcLabel: string; dst: string } { - if (typeof edit !== "object" || !edit) { - return { srcLabel: "\u2022 (incomplete edit)", dst: "" }; - } - - const contentLines = Array.isArray(edit.content) ? (edit.content as string[]).join("\n") : ""; - const loc = edit.loc; - - if (loc === "append" || loc === "prepend") { - return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines }; - } - if (typeof loc === "object" && loc) { - if ("range" in loc && typeof loc.range === "object" && loc.range) { - return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines }; - } - if ("line" in loc) { - return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines }; - } - if ("append" in loc) { - return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines }; - } - if ("prepend" in loc) { - return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines }; - } - } - return { srcLabel: "\u2022 (unknown edit)", dst: contentLines }; - } - - function formatChunkEdit(edit: Partial): { srcLabel: string; dst: string } { - if (typeof edit !== "object" || !edit) { - return { srcLabel: "\u2022 (incomplete edit)", dst: "" }; - } - - const contentLines = Array.isArray(edit.content) - ? (edit.content as string[]).join("\n") - : typeof edit.content === "string" - ? edit.content - : ""; - const target = edit.target ?? "?"; - const op = edit.op ?? "replace"; - - switch (op) { - case "delete": - return { srcLabel: `\u2022 delete ${target}`, dst: "" }; - case "append": - return { srcLabel: `\u2022 append child ${target}`, dst: contentLines }; - case "prepend": - return { srcLabel: `\u2022 prepend child ${target}`, dst: contentLines }; - case "after": - return { srcLabel: `\u2022 insert after ${target}/${edit.anchor ?? "?"}`, dst: contentLines }; - case "before": - return { srcLabel: `\u2022 insert before ${target}/${edit.anchor ?? "?"}`, dst: contentLines }; - default: { - if (edit.line != null) { - const range = edit.end_line != null ? `${edit.line}\u2026${edit.end_line}` : `${edit.line}`; - return { srcLabel: `\u2022 replace ${target} L${range}`, dst: contentLines }; - } - return { srcLabel: `\u2022 replace ${target}`, dst: contentLines }; - } - } - } } + function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string { const icon = uiTheme.getLangIcon(language); if (lineCount !== null) { @@ -229,6 +298,25 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin return uiTheme.fg("dim", `${icon}`); } +function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme): string { + if (args.previewDiff) { + return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview"); + } + if (args.diff && args.op) { + return formatStreamingDiff(args.diff, rawPath, uiTheme); + } + if (args.edits && args.edits.length > 0) { + return formatStreamingHashlineEdits(args.edits, uiTheme); + } + if (args.diff) { + return renderPlainTextPreview(args.diff, uiTheme); + } + if (args.newText || args.patch) { + return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme); + } + return ""; +} + function renderDiffSection( diff: string, rawPath: string, @@ -293,50 +381,11 @@ export const editToolRenderer = { renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component { const rawPath = args.file_path || args.path || ""; - const filePath = shortenPath(rawPath); - const editLanguage = getLanguageFromPath(rawPath) ?? "text"; - const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage)); - let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); - - // Add arrow for move/rename operations - if (args.rename) { - pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(args.rename))}`; - } - - // Show operation type for patch mode - const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit"; + const { description } = formatEditDescription(rawPath, uiTheme, { rename: args.rename }); const spinner = options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : ""; - let text = `${formatTitle(opTitle, uiTheme)} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`; - - // Show streaming preview of diff/content - if (args.previewDiff) { - text += formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview"); - } else if (args.diff && args.op) { - text += formatStreamingDiff(args.diff, rawPath, uiTheme); - } else if (args.edits && args.edits.length > 0) { - text += formatStreamingHashlineEdits(args.edits, uiTheme); - } else if (args.diff) { - const previewLines = args.diff.split("\n"); - const maxLines = 6; - text += "\n\n"; - for (const line of previewLines.slice(0, maxLines)) { - text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`; - } - if (previewLines.length > maxLines) { - text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`); - } - } else if (args.newText || args.patch) { - const previewLines = (args.newText ?? args.patch ?? "").split("\n"); - const maxLines = 6; - text += "\n\n"; - for (const line of previewLines.slice(0, maxLines)) { - text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`; - } - if (previewLines.length > maxLines) { - text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`); - } - } + let text = `${formatTitle(getOperationTitle(args.op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`; + text += getCallPreview(args, rawPath, uiTheme); return new Text(text, 0, 0); }, @@ -348,18 +397,14 @@ export const editToolRenderer = { args?: EditRenderArgs, ): Component { const rawPath = args?.file_path || args?.path || ""; - const filePath = shortenPath(rawPath); - const editLanguage = getLanguageFromPath(rawPath) ?? "text"; - const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage)); - const op = args?.op || result.details?.op; const rename = args?.rename || result.details?.move; - const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit"; + const { language } = formatEditDescription(rawPath, uiTheme, { rename }); // Pre-compute metadata line (static across renders) const metadataLine = op !== "delete" - ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), editLanguage, uiTheme)}` + ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}` : ""; // Pre-compute error text (static) @@ -375,26 +420,17 @@ export const editToolRenderer = { const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; - // Build path display with line number - let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); const firstChangedLine = (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined); - if (firstChangedLine) { - pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`); - } - - // Add arrow for rename operations - if (rename) { - pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`; - } + const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine }); const header = renderStatusLine( { icon: result.isError ? "error" : "success", - title: opTitle, - description: `${editIcon} ${pathDisplay}`, + title: getOperationTitle(op), + description, }, uiTheme, ); diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 0823280df..4d8c6886a 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -28,11 +28,11 @@ import type { Static, TSchema } from "@sinclair/typebox"; import type { Rule } from "../../capability/rule"; import type { KeybindingsManager } from "../../config/keybindings"; import type { ModelRegistry } from "../../config/model-registry"; +import type { EditToolDetails } from "../../edit"; import type { BashResult } from "../../exec/bash-executor"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { PythonResult } from "../../ipy/executor"; import type { Theme } from "../../modes/theme/theme"; -import type { EditToolDetails } from "../../patch"; import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; import type { CustomMessage } from "../../session/messages"; import type { diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 2a6b2f120..acc629ace 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -9,9 +9,9 @@ import type { ImageContent, Message, Model, TextContent, ToolResultMessage } fro import type { Component, TUI } from "@oh-my-pi/pi-tui"; import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; +import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { Theme } from "../../modes/theme/theme"; -import type { EditToolDetails } from "../../patch"; import type { CompactionPreparation, CompactionResult } from "../../session/compaction"; import type { HookMessage } from "../../session/messages"; import type { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 2f7718e8f..f0fc7a132 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -16,6 +16,7 @@ export type * from "./config/prompt-templates"; export * from "./config/prompt-templates"; export type { RetrySettings, SkillsSettings } from "./config/settings"; export { Settings, settings } from "./config/settings"; +export * from "./edit/modes/hashline"; // Custom commands export type * from "./extensibility/custom-commands/types"; export type * from "./extensibility/custom-tools"; @@ -37,7 +38,6 @@ export * from "./modes"; export * from "./modes/components"; // Theme utilities for custom tools export * from "./modes/theme/theme"; -export * from "./patch/hashline"; // SDK for programmatic usage export * from "./sdk"; export * from "./session/agent-session"; diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 842008d03..a5ab9d30b 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -14,9 +14,9 @@ import { type TUI, } from "@oh-my-pi/pi-tui"; import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { computeEditDiff, computeHashlineDiff, computePatchDiff, type DiffError, type DiffResult } from "../../edit"; import type { Theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme"; -import { computeEditDiff, computeHashlineDiff, computePatchDiff, type DiffError, type DiffResult } from "../../patch"; import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash"; import { formatArgsInline, diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 24ca5f620..db872ac71 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1148,9 +1148,14 @@ const langMap: Record = { sh: "lang.shell", zsh: "lang.shell", fish: "lang.shell", + powershell: "lang.shell", + just: "lang.shell", shell: "lang.shell", html: "lang.html", htm: "lang.html", + astro: "lang.html", + vue: "lang.html", + svelte: "lang.html", css: "lang.css", scss: "lang.css", sass: "lang.css", @@ -2317,6 +2322,11 @@ export function getLanguageFromPath(filePath: string): string | undefined { ) { return "conf"; } + if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") { + return "dockerfile"; + } + if (baseName === "justfile") return "just"; + if (baseName === "cmakelists.txt") return "cmake"; const ext = filePath.split(".").pop()?.toLowerCase(); if (!ext) return undefined; @@ -2369,15 +2379,20 @@ export function getLanguageFromPath(filePath: string): string | undefined { tool: "bash", fish: "fish", ps1: "powershell", + psm1: "powershell", sql: "sql", html: "html", htm: "html", xhtml: "html", + astro: "astro", + vue: "vue", + svelte: "svelte", css: "css", scss: "scss", sass: "sass", less: "less", json: "json", + ipynb: "ipynb", hbs: "handlebars", hsb: "handlebars", handlebars: "handlebars", @@ -2395,12 +2410,16 @@ export function getLanguageFromPath(filePath: string): string | undefined { diff: "diff", patch: "diff", dockerfile: "dockerfile", + containerfile: "dockerfile", makefile: "make", + justfile: "just", mk: "make", mak: "make", cmake: "cmake", lua: "lua", jl: "julia", + pl: "perl", + pm: "perl", perl: "perl", r: "r", scala: "scala", diff --git a/packages/coding-agent/src/patch/diff.ts b/packages/coding-agent/src/patch/diff.ts deleted file mode 100644 index 4daf9f8f6..000000000 --- a/packages/coding-agent/src/patch/diff.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Diff generation and replace-mode utilities for the edit tool. - * - * Provides diff string generation and the replace-mode edit logic - * used when not in patch mode. - */ -import * as Diff from "diff"; -import { resolveToCwd } from "../tools/path-utils"; -import { previewPatch } from "./applicator"; -import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy"; -import type { HashlineEdit } from "./hashline"; -import { applyHashlineEdits } from "./hashline"; -import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; -import type { DiffError, DiffResult, PatchInput } from "./types"; -import { EditMatchError } from "./types"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Diff String Generation -// ═══════════════════════════════════════════════════════════════════════════ - -function countContentLines(content: string): number { - const lines = content.split("\n"); - if (lines.length > 1 && lines[lines.length - 1] === "") { - lines.pop(); - } - return Math.max(1, lines.length); -} - -function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string { - const padded = String(lineNum).padStart(width, " "); - return `${prefix}${padded}|${content}`; -} - -/** - * Generate a unified diff string with line numbers and context. - * Returns both the diff string and the first changed line number (in the new file). - */ -export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult { - const parts = Diff.diffLines(oldContent, newContent); - const output: string[] = []; - - const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent)); - const lineNumWidth = String(maxLineNum).length; - - let oldLineNum = 1; - let newLineNum = 1; - let lastWasChange = false; - let firstChangedLine: number | undefined; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const raw = part.value.split("\n"); - if (raw[raw.length - 1] === "") { - raw.pop(); - } - - if (part.added || part.removed) { - // Capture the first changed line (in the new file) - if (firstChangedLine === undefined) { - firstChangedLine = newLineNum; - } - - // Show the change - for (const line of raw) { - if (part.added) { - output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line)); - newLineNum++; - } else { - output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line)); - oldLineNum++; - } - } - lastWasChange = true; - } else { - // Context lines - only show a few before/after changes - const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); - - if (lastWasChange || nextPartIsChange) { - let linesToShow = raw; - let skipStart = 0; - let skipEnd = 0; - - if (!lastWasChange) { - // Show only last N lines as leading context - skipStart = Math.max(0, raw.length - contextLines); - linesToShow = raw.slice(skipStart); - } - - if (!nextPartIsChange && linesToShow.length > contextLines) { - // Show only first N lines as trailing context - skipEnd = linesToShow.length - contextLines; - linesToShow = linesToShow.slice(0, contextLines); - } - - // Add ellipsis if we skipped lines at start - if (skipStart > 0) { - output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "...")); - oldLineNum += skipStart; - newLineNum += skipStart; - } - - for (const line of linesToShow) { - output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line)); - oldLineNum++; - newLineNum++; - } - - // Add ellipsis if we skipped lines at end - if (skipEnd > 0) { - output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "...")); - oldLineNum += skipEnd; - newLineNum += skipEnd; - } - } else { - // Skip these context lines entirely - oldLineNum += raw.length; - newLineNum += raw.length; - } - - lastWasChange = false; - } - } - - return { diff: output.join("\n"), firstChangedLine }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Replace Mode Logic -// ═══════════════════════════════════════════════════════════════════════════ - -export interface ReplaceOptions { - /** Allow fuzzy matching */ - fuzzy: boolean; - /** Replace all occurrences */ - all: boolean; - /** Similarity threshold for fuzzy matching */ - threshold?: number; -} - -export interface ReplaceResult { - /** The new content after replacements */ - content: string; - /** Number of replacements made */ - count: number; -} - -/** - * Generate a unified diff string without file headers. - * Returns both the diff string and the first changed line number (in the new file). - */ -export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult { - const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines }); - const output: string[] = []; - let firstChangedLine: number | undefined; - const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent)); - const lineNumWidth = String(maxLineNum).length; - for (const hunk of patch.hunks) { - output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); - let oldLine = hunk.oldStart; - let newLine = hunk.newStart; - for (const line of hunk.lines) { - if (line.startsWith("-")) { - if (firstChangedLine === undefined) firstChangedLine = newLine; - output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1))); - oldLine++; - continue; - } - if (line.startsWith("+")) { - if (firstChangedLine === undefined) firstChangedLine = newLine; - output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1))); - newLine++; - continue; - } - if (line.startsWith(" ")) { - output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1))); - oldLine++; - newLine++; - continue; - } - output.push(line); - } - } - - return { diff: output.join("\n"), firstChangedLine }; -} - -/** - * Find and replace text in content using fuzzy matching. - */ -export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult { - if (oldText.length === 0) { - throw new Error("oldText must not be empty."); - } - const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD; - let normalizedContent = normalizeToLF(content); - const normalizedOldText = normalizeToLF(oldText); - const normalizedNewText = normalizeToLF(newText); - let count = 0; - - if (options.all) { - // Check for exact matches first - const exactCount = normalizedContent.split(normalizedOldText).length - 1; - if (exactCount > 0) { - return { - content: normalizedContent.split(normalizedOldText).join(normalizedNewText), - count: exactCount, - }; - } - - // No exact matches - try fuzzy matching iteratively - while (true) { - const matchOutcome = findMatch(normalizedContent, normalizedOldText, { - allowFuzzy: options.fuzzy, - threshold, - }); - - const shouldUseClosest = - options.fuzzy && - matchOutcome.closest && - matchOutcome.closest.confidence >= threshold && - (matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1); - const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined); - if (!match) { - break; - } - - const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText); - if (adjustedNewText === match.actualText) { - break; - } - normalizedContent = - normalizedContent.substring(0, match.startIndex) + - adjustedNewText + - normalizedContent.substring(match.startIndex + match.actualText.length); - count++; - } - - return { content: normalizedContent, count }; - } - - // Single replacement mode - const matchOutcome = findMatch(normalizedContent, normalizedOldText, { - allowFuzzy: options.fuzzy, - threshold, - }); - - if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { - const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? ""; - const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : ""; - throw new Error( - `Found ${matchOutcome.occurrences} occurrences${moreMsg}:\n\n${previews}\n\n` + - `Add more context lines to disambiguate.`, - ); - } - - if (!matchOutcome.match) { - return { content: normalizedContent, count: 0 }; - } - - const match = matchOutcome.match; - const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText); - normalizedContent = - normalizedContent.substring(0, match.startIndex) + - adjustedNewText + - normalizedContent.substring(match.startIndex + match.actualText.length); - - return { content: normalizedContent, count: 1 }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Preview/Diff Computation -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Compute the diff for an edit operation without applying it. - * Used for preview rendering in the TUI before the tool executes. - */ -export async function computeEditDiff( - path: string, - oldText: string, - newText: string, - cwd: string, - fuzzy = true, - all = false, - threshold?: number, -): Promise { - if (oldText.length === 0) { - return { error: "oldText must not be empty." }; - } - const absolutePath = resolveToCwd(path, cwd); - - try { - const file = Bun.file(absolutePath); - try { - if (!(await file.exists())) { - return { error: `File not found: ${path}` }; - } - } catch { - return { error: `File not found: ${path}` }; - } - - let rawContent: string; - try { - rawContent = await file.text(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { error: message || `Unable to read ${path}` }; - } - - const { text: content } = stripBom(rawContent); - const normalizedContent = normalizeToLF(content); - const normalizedOldText = normalizeToLF(oldText); - const normalizedNewText = normalizeToLF(newText); - - const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { - fuzzy, - all, - threshold, - }); - - if (result.count === 0) { - // Get closest match for error message - const matchOutcome = findMatch(normalizedContent, normalizedOldText, { - allowFuzzy: fuzzy, - threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD, - }); - - if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { - const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? ""; - const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : ""; - return { - error: `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`, - }; - } - - return { - error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy: fuzzy, - threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD, - fuzzyMatches: matchOutcome.fuzzyMatches, - }), - }; - } - - if (normalizedContent === result.content) { - return { - error: `No changes would be made to ${path}. The replacement produces identical content.`, - }; - } - - return generateDiffString(normalizedContent, result.content); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} - -/** - * Compute the diff for a patch operation without applying it. - * Used for preview rendering in the TUI before patch-mode edits execute. - */ -export async function computePatchDiff( - input: PatchInput, - cwd: string, - options?: { fuzzyThreshold?: number; allowFuzzy?: boolean }, -): Promise { - try { - const result = await previewPatch(input, { - cwd, - fuzzyThreshold: options?.fuzzyThreshold, - allowFuzzy: options?.allowFuzzy, - }); - const oldContent = result.change.oldContent ?? ""; - const newContent = result.change.newContent ?? ""; - const normalizedOld = normalizeToLF(stripBom(oldContent).text); - const normalizedNew = normalizeToLF(stripBom(newContent).text); - if (!normalizedOld && !normalizedNew) { - return { diff: "", firstChangedLine: undefined }; - } - return generateUnifiedDiffString(normalizedOld, normalizedNew); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} -/** - * Compute the diff for a hashline operation without applying it. - * Used for preview rendering in the TUI before hashline-mode edits execute. - */ -export async function computeHashlineDiff( - input: { path: string; edits: HashlineEdit[]; move?: string }, - cwd: string, -): Promise { - const { path, edits, move } = input; - const absolutePath = resolveToCwd(path, cwd); - const movePath = move ? resolveToCwd(move, cwd) : undefined; - const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0; - - try { - const file = Bun.file(absolutePath); - try { - if (!(await file.exists())) { - return { error: `File not found: ${path}` }; - } - } catch { - return { error: `File not found: ${path}` }; - } - - if (movePath === absolutePath) { - return { error: "move path is the same as source path" }; - } - if (isMoveOnly) { - return { diff: "", firstChangedLine: undefined }; - } - - let rawContent: string; - try { - rawContent = await file.text(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { error: message || `Unable to read ${path}` }; - } - - const { text: content } = stripBom(rawContent); - const normalizedContent = normalizeToLF(content); - const result = applyHashlineEdits(normalizedContent, edits); - if (normalizedContent === result.lines && !move) { - return { error: `No changes would be made to ${path}. The edits produce identical content.` }; - } - - return generateDiffString(normalizedContent, result.lines); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts deleted file mode 100644 index 460971e9e..000000000 --- a/packages/coding-agent/src/patch/index.ts +++ /dev/null @@ -1,1040 +0,0 @@ -/** - * Edit tool module. - * - * Supports four modes: - * - Replace mode (default): oldText/newText replacement with fuzzy matching - * - Patch mode: structured diff format with explicit operation type - * - Hashline mode: line-addressed edits using content hashes for integrity - * - Chunk mode: syntax-aware chunk-addressed edits using chunk checksums - * - * The mode is determined by the `edit.mode` setting. - */ -import * as fs from "node:fs/promises"; -import * as nodePath from "node:path"; -import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; -import { type Static, Type } from "@sinclair/typebox"; -import { renderPromptTemplate } from "../config/prompt-templates"; -import { - createLspWritethrough, - type FileDiagnosticsResult, - flushLspWritethroughBatch, - type WritethroughCallback, - writethroughNoop, -} from "../lsp"; -import { getLanguageFromPath } from "../modes/theme/theme"; -import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" }; -import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" }; -import patchDescription from "../prompts/tools/patch.md" with { type: "text" }; -import replaceDescription from "../prompts/tools/replace.md" with { type: "text" }; -import type { ToolSession } from "../tools"; -import { checkAutoGeneratedFile, checkAutoGeneratedFileContent } from "../tools/auto-generated-guard"; -import { applyChunkEdits, type ChunkEditOperation, getChunkInfoForFile, resolveAnchorStyle } from "../tools/chunk-tree"; -import { - invalidateFsScanAfterDelete, - invalidateFsScanAfterRename, - invalidateFsScanAfterWrite, -} from "../tools/fs-cache-invalidation"; -import { outputMeta } from "../tools/output-meta"; -import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"; -import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode"; -import { applyPatch } from "./applicator"; -import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff"; -import { findMatch } from "./fuzzy"; -import { - type Anchor, - applyHashlineEdits, - buildCompactHashlineDiffPreview, - type HashlineEdit, - parseTag, -} from "./hashline"; -import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; -import { stripNewLinePrefixes } from "./prefix-stripping"; -import { type EditToolDetails, getLspBatchRequest } from "./shared"; -// Internal imports -import type { FileSystem, Operation, PatchInput } from "./types"; -import { EditMatchError } from "./types"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Re-exports -// ═══════════════════════════════════════════════════════════════════════════ - -export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode"; -// Application -export { applyPatch, defaultFileSystem, previewPatch } from "./applicator"; -// Diff generation -export * from "./diff"; - -// Fuzzy matching -export * from "./fuzzy"; -// Hashline -export * from "./hashline"; -// Normalization -export * from "./normalize"; -// Parsing -export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } from "./parser"; -// Prefix stripping -export { stripHashlinePrefixes, stripNewLinePrefixes } from "./prefix-stripping"; -export type { EditRenderContext, EditToolDetails } from "./shared"; -// Rendering -export { editToolRenderer, getLspBatchRequest } from "./shared"; -export * from "./types"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Schemas -// ═══════════════════════════════════════════════════════════════════════════ - -const replaceEditSchema = Type.Object({ - path: Type.String({ description: "File path (relative or absolute)" }), - old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), - new_text: Type.String({ description: "Replacement text" }), - all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), -}); - -const patchEditSchema = Type.Object({ - path: Type.String({ description: "File path" }), - op: Type.Optional( - StringEnum(["create", "delete", "update"], { - description: "Operation (default: update)", - }), - ), - rename: Type.Optional(Type.String({ description: "New path for move" })), - diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), -}); - -const CHUNK_OP_VALUES = ["replace", "delete", "append", "prepend", "after", "before"] as const; - -const chunkToolEditSchema = Type.Object({ - target: Type.String({ - description: - "Chunk path from read output, with #CRC suffix for replace/delete (e.g. 'class_X.fn_y#A14F'). Use parent path without #CRC for insert ops.", - }), - op: Type.Optional( - StringEnum(CHUNK_OP_VALUES, { - description: - "Edit op (default: replace). 'delete' removes target. 'append'/'prepend' insert as last/first child. 'after'/'before' insert at sibling position; require 'anchor'.", - }), - ), - content: Type.Optional( - Type.String({ description: "New content (required for replace/append/prepend/after/before)." }), - ), - line: Type.Optional( - Type.Integer({ description: "Absolute file line for line-scoped replace (omit for whole-chunk replace)." }), - ), - end_line: Type.Optional(Type.Integer({ description: "End line (inclusive) for replacing a line range." })), - anchor: Type.Optional( - Type.String({ description: "Named child to insert relative to (required for op=after/before)." }), - ), -}); - -const chunkEditParamsSchema = Type.Object( - { - path: Type.String({ description: "File path" }), - edits: Type.Array(chunkToolEditSchema, { - description: "Chunk edits", - minItems: 1, - }), - }, - { additionalProperties: false }, -); - -export type ReplaceParams = Static; -export type PatchParams = Static; -export type ChunkToolEdit = Static; -export type ChunkParams = Static; - -export function hashlineParseText(edit: string[] | string | null): string[] { - if (edit === null) return []; - if (typeof edit === "string") { - const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit; - edit = normalizedEdit.replaceAll("\r", "").split("\n"); - } - return stripNewLinePrefixes(edit); -} - -function flattenContent(content: string | string[] | undefined): string { - if (content === undefined) return ""; - if (Array.isArray(content)) return content.join("\n"); - return content; -} - -type ParsedChunkTarget = { - selector: string; - crc?: string; -}; - -function parseChunkTarget(target: string): ParsedChunkTarget { - const hashIndex = target.lastIndexOf("#"); - if (hashIndex === -1 || hashIndex === target.length - 1) { - return { selector: target }; - } - return { - selector: target.slice(0, hashIndex), - crc: target.slice(hashIndex + 1).toUpperCase(), - }; -} - -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"; -} - -const linesSchema = Type.Union([ - Type.Array(Type.String(), { description: "content (preferred format)" }), - Type.String(), - Type.Null(), -]); - -const locSchema = Type.Union( - [ - Type.Literal("append"), - Type.Literal("prepend"), - Type.Object({ append: Type.String({ description: "anchor" }) }), - Type.Object({ prepend: Type.String({ description: "anchor" }) }), - Type.Object({ - range: Type.Object({ - pos: Type.String({ description: "first line to edit (inclusive)" }), - end: Type.String({ description: "last line to edit (inclusive)" }), - }), - }), - ], - { description: "insert location" }, -); - -const hashlineEditSchema = Type.Object( - { - loc: locSchema, - content: linesSchema, - }, - { additionalProperties: false }, -); - -const hashlineEditParamsSchema = Type.Object( - { - path: Type.String({ description: "path" }), - edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), - delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })), - move: Type.Optional(Type.String({ description: "If set, move $path to $move" })), - }, - { additionalProperties: false }, -); - -export type HashlineToolEdit = Static; -export type HashlineParams = Static; - -// ═══════════════════════════════════════════════════════════════════════════ -// Resilient anchor resolution -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Map loc/content tool-schema edits into typed HashlineEdit objects. - * - * Each edit entry has a `loc` (where to edit) and `content` (what to insert/replace). - * loc can be: - * - "append" / "prepend" — file-level insert - * - { append: anchor } / { prepend: anchor } — insert relative to anchor - * - { range: { pos, end } } — replace inclusive range - */ -function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] { - const result: HashlineEdit[] = []; - for (const edit of edits) { - const lines = hashlineParseText(edit.content); - const loc = edit.loc; - - if (loc === "append") { - result.push({ op: "append_file", lines }); - } else if (loc === "prepend") { - result.push({ op: "prepend_file", lines }); - } else if (typeof loc === "object") { - if ("append" in loc) { - const anchor = tryParseTag(loc.append); - if (!anchor) throw new Error("append requires a valid anchor."); - result.push({ op: "append_at", pos: anchor, lines }); - } else if ("prepend" in loc) { - const anchor = tryParseTag(loc.prepend); - if (!anchor) throw new Error("prepend requires a valid anchor."); - result.push({ op: "prepend_at", pos: anchor, lines }); - } else if ("range" in loc) { - const posAnchor = tryParseTag(loc.range.pos); - const endAnchor = tryParseTag(loc.range.end); - if (!posAnchor || !endAnchor) throw new Error("range requires valid pos and end anchors."); - result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines }); - } else { - throw new Error("Unknown loc shape. Expected append, prepend, or range."); - } - } else { - throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`); - } - } - return result; -} - -/** Parse a tag, returning undefined instead of throwing on garbage. */ -function tryParseTag(raw: string): Anchor | undefined { - try { - return parseTag(raw); - } catch { - return undefined; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// LSP FileSystem for patch mode -// ═══════════════════════════════════════════════════════════════════════════ - -class LspFileSystem implements FileSystem { - #lastDiagnostics: FileDiagnosticsResult | undefined; - #fileCache: Record = {}; - - constructor( - private readonly writethrough: ( - dst: string, - content: string, - signal?: AbortSignal, - file?: import("bun").BunFile, - batch?: { id: string; flush: boolean }, - ) => Promise, - private readonly signal?: AbortSignal, - private readonly batchRequest?: { id: string; flush: boolean }, - ) {} - - #getFile(path: string): Bun.BunFile { - if (this.#fileCache[path]) { - return this.#fileCache[path]; - } - const file = Bun.file(path); - this.#fileCache[path] = file; - return file; - } - - async exists(path: string): Promise { - return this.#getFile(path).exists(); - } - - async read(path: string): Promise { - return this.#getFile(path).text(); - } - - async readBinary(path: string): Promise { - const buffer = await this.#getFile(path).arrayBuffer(); - return new Uint8Array(buffer); - } - - async write(path: string, content: string): Promise { - const file = this.#getFile(path); - const result = await this.writethrough(path, content, this.signal, file, this.batchRequest); - if (result) { - this.#lastDiagnostics = result; - } - } - - async delete(path: string): Promise { - await this.#getFile(path).unlink(); - } - - async mkdir(path: string): Promise { - await fs.mkdir(path, { recursive: true }); - } - - getDiagnostics(): FileDiagnosticsResult | undefined { - return this.#lastDiagnostics; - } -} - -function mergeDiagnosticsWithWarnings( - diagnostics: FileDiagnosticsResult | undefined, - warnings: string[], -): FileDiagnosticsResult | undefined { - if (warnings.length === 0) return diagnostics; - const warningMessages = warnings.map(warning => `patch: ${warning}`); - if (!diagnostics) { - return { - server: "patch", - messages: warningMessages, - summary: `Patch warnings: ${warnings.length}`, - errored: false, - }; - } - return { - ...diagnostics, - messages: [...warningMessages, ...diagnostics.messages], - summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Tool Class -// ═══════════════════════════════════════════════════════════════════════════ - -type TInput = - | typeof replaceEditSchema - | typeof patchEditSchema - | typeof hashlineEditParamsSchema - | typeof chunkEditParamsSchema; - -function isHashlineParams( - params: ReplaceParams | PatchParams | HashlineParams | ChunkParams, -): params is HashlineParams { - return "edits" in params && Array.isArray(params.edits) && (params.edits.length === 0 || "loc" in params.edits[0]); -} - -function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams | ChunkParams): params is ReplaceParams { - return "old_text" in params && "new_text" in params; -} - -function isChunkParams(params: ReplaceParams | PatchParams | HashlineParams | ChunkParams): params is ChunkParams { - return "edits" in params && Array.isArray(params.edits) && params.edits.length > 0 && "target" in params.edits[0]; -} - -/** - * Edit tool implementation. - * - * Creates replace-mode, patch-mode, or hashline-mode behavior based on session settings. - */ -export class EditTool implements AgentTool { - readonly name = "edit"; - readonly label = "Edit"; - readonly nonAbortable = true; - readonly concurrency = "exclusive"; - readonly strict = true; - - readonly #allowFuzzy: boolean; - readonly #fuzzyThreshold: number; - readonly #writethrough: WritethroughCallback; - readonly #editMode?: EditMode | null; - - constructor(private readonly session: ToolSession) { - const { - PI_EDIT_FUZZY: editFuzzy = "auto", - PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto", - PI_EDIT_VARIANT: envEditVariant = "auto", - } = Bun.env; - - if (envEditVariant && envEditVariant !== "auto") { - const editMode = normalizeEditMode(envEditVariant); - if (!editMode) { - throw new Error(`Invalid PI_EDIT_VARIANT: ${envEditVariant}`); - } - this.#editMode = editMode; - } - - switch (editFuzzy) { - case "true": - case "1": - this.#allowFuzzy = true; - break; - case "false": - case "0": - this.#allowFuzzy = false; - break; - case "auto": - this.#allowFuzzy = session.settings.get("edit.fuzzyMatch"); - break; - default: - throw new Error(`Invalid PI_EDIT_FUZZY: ${editFuzzy}`); - } - switch (editFuzzyThreshold) { - case "auto": - this.#fuzzyThreshold = session.settings.get("edit.fuzzyThreshold"); - break; - default: - this.#fuzzyThreshold = parseFloat(editFuzzyThreshold); - if (Number.isNaN(this.#fuzzyThreshold) || this.#fuzzyThreshold < 0 || this.#fuzzyThreshold > 1) { - throw new Error(`Invalid PI_EDIT_FUZZY_THRESHOLD: ${editFuzzyThreshold}`); - } - break; - } - - const enableLsp = session.enableLsp ?? true; - const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); - const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite"); - this.#writethrough = enableLsp - ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) - : writethroughNoop; - } - - /** - * Determine edit mode dynamically based on current model. - * This is re-evaluated on each access so tool definitions stay current when model changes. - */ - get mode(): EditMode { - if (this.#editMode) return this.#editMode; - return resolveEditMode(this.session); - } - - /** - * Dynamic description based on current edit mode (which depends on current model). - */ - get description(): string { - switch (this.mode) { - case "chunk": - return renderPromptTemplate(chunkEditDescription, { - anchorStyle: resolveAnchorStyle(this.session.settings), - }); - case "patch": - return renderPromptTemplate(patchDescription); - case "hashline": - return renderPromptTemplate(hashlineDescription); - default: - return renderPromptTemplate(replaceDescription); - } - } - - /** - * Dynamic parameters schema based on current edit mode (which depends on current model). - */ - get parameters(): TInput { - switch (this.mode) { - case "chunk": - return chunkEditParamsSchema; - case "patch": - return patchEditSchema; - case "hashline": - return hashlineEditParamsSchema; - default: - return replaceEditSchema; - } - } - - async execute( - _toolCallId: string, - params: ReplaceParams | PatchParams | HashlineParams | ChunkParams, - signal?: AbortSignal, - _onUpdate?: AgentToolUpdateCallback, - context?: AgentToolContext, - ): Promise> { - const batchRequest = getLspBatchRequest(context?.toolCall); - - // ───────────────────────────────────────────────────────────────── - // Chunk mode execution - // ───────────────────────────────────────────────────────────────── - if (this.mode === "chunk") { - if (!isChunkParams(params)) { - throw new Error("Invalid edit parameters for chunk mode."); - } - - const { path, edits } = params; - const resolvedPath = resolvePlanPath(this.session, path); - const sourceExists = await Bun.file(resolvedPath).exists(); - enforcePlanModeWrite(this.session, path, { op: sourceExists ? "update" : "create" }); - - if (path.endsWith(".ipynb")) { - throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); - } - - let rawContent = ""; - if (sourceExists) { - rawContent = await Bun.file(resolvedPath).text(); - await checkAutoGeneratedFileContent(rawContent, path); - } - - const parentDir = nodePath.dirname(resolvedPath); - if (parentDir && parentDir !== ".") { - await fs.mkdir(parentDir, { recursive: true }); - } - - const chunkLanguage = getLanguageFromPath(resolvedPath); - const normalizedOperations: ChunkEditOperation[] = []; - - const assertChecksum = async (op: string, crc: string | undefined, selector: string): Promise => { - if (crc) return crc.toUpperCase(); - if (selector.length > 0 && sourceExists) { - const resolved = await getChunkInfoForFile(resolvedPath, 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.", - ); - }; - - for (const edit of edits) { - const { selector, crc } = parseChunkTarget(edit.target); - const op = edit.op ?? "replace"; - const hasContent = edit.content !== undefined; - const content = flattenContent(edit.content); - - switch (op) { - case "delete": { - if (hasContent || edit.line !== undefined || edit.end_line !== undefined) { - throw new Error( - `Delete edit on ${describeChunkTarget(selector)} cannot include content or line ranges.`, - ); - } - normalizedOperations.push({ - op: "delete", - sel: selector, - crc: await assertChecksum("delete", crc, selector), - }); - break; - } - - case "append": { - if (!hasContent) throw new Error(`Content required for append on ${describeChunkTarget(selector)}.`); - normalizedOperations.push({ op: "append_child", sel: selector, content }); - break; - } - case "prepend": { - if (!hasContent) throw new Error(`Content required for prepend on ${describeChunkTarget(selector)}.`); - normalizedOperations.push({ op: "prepend_child", sel: selector, content }); - break; - } - - case "after": { - if (!hasContent) - throw new Error(`Content required for after-insert on ${describeChunkTarget(selector)}.`); - if (!edit.anchor) - throw new Error(`'anchor' required for op=after on ${describeChunkTarget(selector)}.`); - normalizedOperations.push({ - op: "append_sibling", - sel: joinChunkPath(selector, edit.anchor), - content, - }); - break; - } - case "before": { - if (!hasContent) - throw new Error(`Content required for before-insert on ${describeChunkTarget(selector)}.`); - if (!edit.anchor) - throw new Error(`'anchor' required for op=before on ${describeChunkTarget(selector)}.`); - normalizedOperations.push({ - op: "prepend_sibling", - sel: joinChunkPath(selector, edit.anchor), - content, - }); - break; - } - default: { - if (!hasContent) { - throw new Error(`Content required for replace edit on ${describeChunkTarget(selector)}.`); - } - normalizedOperations.push({ - op: "replace", - sel: selector, - crc: await assertChecksum("replace", crc, selector), - content, - line: edit.line, - endLine: edit.end_line, - }); - break; - } - } - } - - const chunkResult = applyChunkEdits({ - source: rawContent, - language: chunkLanguage, - cwd: this.session.cwd, - filePath: resolvedPath, - operations: normalizedOperations, - anchorStyle: resolveAnchorStyle(this.session.settings), - }); - - if (!chunkResult.changed) { - const responseText = `[No changes needed — content already matches.]\n\n${chunkResult.responseText}`; - return { - content: [{ type: "text", text: responseText }], - details: { - diff: "", - op: sourceExists ? "update" : "create", - meta: outputMeta().get(), - }, - }; - } - - const { bom, text } = stripBom(rawContent); - const originalEnding = detectLineEnding(text); - const finalContent = bom + restoreLineEndings(chunkResult.diffSourceAfter, originalEnding); - const diagnostics = await this.#writethrough( - resolvedPath, - finalContent, - signal, - Bun.file(resolvedPath), - batchRequest, - ); - invalidateFsScanAfterWrite(resolvedPath); - - const diffResult = generateUnifiedDiffString(chunkResult.diffSourceBefore, chunkResult.diffSourceAfter); - const warningsBlock = chunkResult.warnings.length > 0 ? `\n\n${chunkResult.warnings.join("\n")}` : ""; - const meta = outputMeta() - .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) - .get(); - - return { - content: [{ type: "text", text: `${chunkResult.responseText}${warningsBlock}` }], - details: { - diff: diffResult.diff, - firstChangedLine: diffResult.firstChangedLine, - diagnostics, - op: sourceExists ? "update" : "create", - meta, - }, - }; - } - - // ───────────────────────────────────────────────────────────────── - // Hashline mode execution - // ───────────────────────────────────────────────────────────────── - if (this.mode === "hashline") { - if (!isHashlineParams(params)) { - throw new Error("Invalid edit parameters for hashline mode."); - } - - const { path, edits, delete: deleteFile, move } = params; - - enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", move }); - - if (path.endsWith(".ipynb") && edits?.length > 0) { - throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); - } - - const absolutePath = resolvePlanPath(this.session, path); - const resolvedMove = move ? resolvePlanPath(this.session, move) : undefined; - if (resolvedMove === absolutePath) { - throw new Error("move path is the same as source path"); - } - const sourceExists = await fs.exists(absolutePath); - const isMoveOnly = Boolean(resolvedMove) && edits.length === 0; - - if (deleteFile) { - if (sourceExists) { - await fs.unlink(absolutePath); - } - invalidateFsScanAfterDelete(absolutePath); - return { - content: [{ type: "text", text: `Deleted ${path}` }], - details: { - diff: "", - op: "delete", - meta: outputMeta().get(), - }, - }; - } - - if (isMoveOnly && resolvedMove) { - if (!sourceExists) { - throw new Error(`File not found: ${path}`); - } - const parentDir = nodePath.dirname(resolvedMove); - if (parentDir && parentDir !== ".") { - await fs.mkdir(parentDir, { recursive: true }); - } - // Preserve exact bytes for move-only operations, including binary files. - await fs.rename(absolutePath, resolvedMove); - invalidateFsScanAfterRename(absolutePath, resolvedMove); - return { - content: [{ type: "text", text: `Moved ${path} to ${move}` }], - details: { - diff: "", - op: "update", - move, - meta: outputMeta().get(), - }, - }; - } - - if (!sourceExists) { - const lines: string[] = []; - for (const edit of edits) { - // For file creation, only anchorless appends/prepends are valid - if (edit.loc === "append") { - lines.push(...hashlineParseText(edit.content)); - } else if (edit.loc === "prepend") { - lines.unshift(...hashlineParseText(edit.content)); - } else { - throw new Error(`File not found: ${path}`); - } - } - await fs.writeFile(absolutePath, lines.join("\n")); - return { - content: [{ type: "text", text: `Created ${path}` }], - details: { - diff: "", - op: "create", - meta: outputMeta().get(), - }, - }; - } - - const anchorEdits = resolveEditAnchors(edits); - - const rawContent = await fs.readFile(absolutePath, "utf-8"); - await checkAutoGeneratedFileContent(rawContent, path); - const { bom, text } = stripBom(rawContent); - const originalEnding = detectLineEnding(text); - const originalNormalized = normalizeToLF(text); - let normalizedText = originalNormalized; - - // Apply anchor-based edits first (replace, append_at, prepend_at) - const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); - normalizedText = anchorResult.lines; - - const result = { - text: normalizedText, - firstChangedLine: anchorResult.firstChangedLine, - warnings: anchorResult.warnings, - noopEdits: anchorResult.noopEdits, - }; - if (originalNormalized === result.text && !move) { - let diagnostic = `No changes made to ${path}. The edits produced identical content.`; - if (result.noopEdits && result.noopEdits.length > 0) { - const details = result.noopEdits - .map( - e => - `Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${e.current}`, - ) - .join("\n"); - diagnostic += `\n${details}`; - if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) { - const preview = result.noopEdits[0].current.trimEnd(); - if (preview.length > 0) { - diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`; - } - } - } - throw new Error(diagnostic); - } - - const finalContent = bom + restoreLineEndings(result.text, originalEnding); - const writePath = resolvedMove ?? absolutePath; - const diagnostics = await this.#writethrough( - writePath, - finalContent, - signal, - Bun.file(writePath), - batchRequest, - ); - if (resolvedMove && resolvedMove !== absolutePath) { - await fs.unlink(absolutePath); - invalidateFsScanAfterRename(absolutePath, resolvedMove); - } else { - invalidateFsScanAfterWrite(absolutePath); - } - const diffResult = generateDiffString(originalNormalized, result.text); - - const meta = outputMeta() - .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) - .get(); - - const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`; - const preview = buildCompactHashlineDiffPreview(diffResult.diff); - const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`; - const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; - const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : ""; - return { - content: [ - { - type: "text", - text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`, - }, - ], - details: { - diff: diffResult.diff, - firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, - diagnostics, - op: "update", - move, - meta, - }, - }; - } - - // ───────────────────────────────────────────────────────────────── - // Patch mode execution - // ───────────────────────────────────────────────────────────────── - if (this.mode === "patch") { - if (isHashlineParams(params) || isReplaceParams(params) || isChunkParams(params)) { - throw new Error("Invalid edit parameters for patch mode."); - } - - const { path, op: rawOp, rename, diff } = params; - - // Normalize unrecognized operations to "update" - const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; - - enforcePlanModeWrite(this.session, path, { op, move: rename }); - const resolvedPath = resolvePlanPath(this.session, path); - const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined; - - if (path.endsWith(".ipynb")) { - throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); - } - if (rename?.endsWith(".ipynb")) { - throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); - } - - await checkAutoGeneratedFile(resolvedPath, path); - - const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff }; - const fs = new LspFileSystem(this.#writethrough, signal, batchRequest); - const result = await applyPatch(input, { - cwd: this.session.cwd, - fs, - fuzzyThreshold: this.#fuzzyThreshold, - allowFuzzy: this.#allowFuzzy, - }); - if (resolvedRename) { - invalidateFsScanAfterRename(resolvedPath, resolvedRename); - } else if (result.change.type === "delete") { - invalidateFsScanAfterDelete(resolvedPath); - } else { - invalidateFsScanAfterWrite(resolvedPath); - } - const effRename = result.change.newPath ? rename : undefined; - - // Generate diff for display - let diffResult: { diff: string; firstChangedLine: number | undefined } = { - diff: "", - firstChangedLine: undefined, - }; - if (result.change.type === "update" && result.change.oldContent && result.change.newContent) { - const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text); - const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text); - diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew); - } - - let resultText: string; - switch (result.change.type) { - case "create": - resultText = `Created ${path}`; - break; - case "delete": - resultText = `Deleted ${path}`; - break; - case "update": - resultText = effRename ? `Updated and moved ${path} to ${effRename}` : `Updated ${path}`; - break; - } - - let diagnostics = fs.getDiagnostics(); - if (op === "delete" && batchRequest?.flush) { - const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); - diagnostics ??= flushedDiagnostics; - } - const patchWarnings = result.warnings ?? []; - const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); - - const meta = outputMeta() - .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) - .get(); - - return { - content: [{ type: "text", text: resultText }], - details: { - diff: diffResult.diff, - firstChangedLine: diffResult.firstChangedLine, - diagnostics: mergedDiagnostics, - op, - move: effRename, - meta, - }, - }; - } - - // ───────────────────────────────────────────────────────────────── - // Replace mode execution - // ───────────────────────────────────────────────────────────────── - if (!isReplaceParams(params)) { - throw new Error("Invalid edit parameters for replace mode."); - } - const { path, old_text, new_text, all } = params; - - enforcePlanModeWrite(this.session, path); - - if (path.endsWith(".ipynb")) { - throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); - } - - if (old_text.length === 0) { - throw new Error("old_text must not be empty."); - } - - const absolutePath = resolvePlanPath(this.session, path); - - if (!(await fs.exists(absolutePath))) { - throw new Error(`File not found: ${path}`); - } - - const rawContent = await fs.readFile(absolutePath, "utf-8"); - const { bom, text: content } = stripBom(rawContent); - const originalEnding = detectLineEnding(content); - const normalizedContent = normalizeToLF(content); - const normalizedOldText = normalizeToLF(old_text); - const normalizedNewText = normalizeToLF(new_text); - - const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { - fuzzy: this.#allowFuzzy, - all: all ?? false, - threshold: this.#fuzzyThreshold, - }); - - if (result.count === 0) { - // Get error details - const matchOutcome = findMatch(normalizedContent, normalizedOldText, { - allowFuzzy: this.#allowFuzzy, - threshold: this.#fuzzyThreshold, - }); - - if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { - const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? ""; - const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : ""; - throw new Error( - `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` + - `Add more context lines to disambiguate.`, - ); - } - - throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy: this.#allowFuzzy, - threshold: this.#fuzzyThreshold, - fuzzyMatches: matchOutcome.fuzzyMatches, - }); - } - - if (normalizedContent === result.content) { - throw new Error( - `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, - ); - } - - const finalContent = bom + restoreLineEndings(result.content, originalEnding); - const diagnostics = await this.#writethrough( - absolutePath, - finalContent, - signal, - Bun.file(absolutePath), - batchRequest, - ); - invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, result.content); - - const resultText = - result.count > 1 - ? `Successfully replaced ${result.count} occurrences in ${path}.` - : `Successfully replaced text in ${path}.`; - - const meta = outputMeta() - .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) - .get(); - - return { - content: [{ type: "text", text: resultText }], - details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta }, - }; - } -} diff --git a/packages/coding-agent/src/patch/parser.ts b/packages/coding-agent/src/patch/parser.ts deleted file mode 100644 index 602bf4830..000000000 --- a/packages/coding-agent/src/patch/parser.ts +++ /dev/null @@ -1,532 +0,0 @@ -/** - * Diff/patch parsing for the edit tool. - * - * Supports multiple input formats: - * - Simple +/- diffs - * - Unified diff format (@@ -X,Y +A,B @@) - * - Codex-style wrapped patches (*** Begin Patch / *** End Patch) - */ -import type { DiffHunk } from "./types"; -import { ApplyPatchError, ParseError } from "./types"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Constants -// ═══════════════════════════════════════════════════════════════════════════ - -const EOF_MARKER = "*** End of File"; -const CHANGE_CONTEXT_MARKER = "@@ "; -const EMPTY_CHANGE_CONTEXT_MARKER = "@@"; - -/** Regex to match unified diff hunk headers: @@ -OLD,COUNT +NEW,COUNT @@ optional-context */ -const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/; - -/** Regex to match @@ line/lines N or N-M pattern (model-generated line hints) */ -const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i; -const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i; - -/** - * Check if a line is a diff content line (context, addition, or removal). - * These should never be treated as metadata even if their content looks like it. - * Note: `--- ` and `+++ ` are metadata headers, not content lines. - */ -function isDiffContentLine(line: string): boolean { - const firstChar = line[0]; - if (firstChar === " ") return true; - if (firstChar === "+") { - // `+++ ` is metadata, single `+` followed by content is addition - return !line.startsWith("+++ "); - } - if (firstChar === "-") { - // `--- ` is metadata, single `-` followed by content is removal - return !line.startsWith("--- "); - } - return false; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Normalization -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Normalize a diff by stripping various wrapper formats and metadata. - * - * Handles: - * - `*** Begin Patch` / `*** End Patch` markers (partial or complete) - * - Codex file markers: `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** End of File` - * - Unified diff metadata: `diff --git`, `index`, `---`, `+++`, mode changes, rename markers - */ -export function normalizeDiff(diff: string): string { - let lines = diff.split("\n"); - - // Strip trailing truly empty lines (not diff content lines like " " which represent blank context) - while (lines.length > 0) { - const lastLine = lines[lines.length - 1]; - // Only strip if line is completely empty (no characters) OR - // if it's whitespace-only but NOT a diff content line (space prefix = context line) - if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) { - lines = lines.slice(0, -1); - } else { - break; - } - } - - // Layer 1: Strip *** Begin Patch / *** End Patch (may have only one or both) - if (lines[0]?.trim().startsWith("*** Begin Patch")) { - lines = lines.slice(1); - } - // Also strip bare *** at the beginning (model hallucination) - if (lines[0]?.trim() === "***") { - lines = lines.slice(1); - } - if (lines.length > 0 && lines[lines.length - 1]?.trim().startsWith("*** End Patch")) { - lines = lines.slice(0, -1); - } - // Also strip bare *** terminator (model hallucination) - if (lines.length > 0 && lines[lines.length - 1]?.trim() === "***") { - lines = lines.slice(0, -1); - } - - // Layer 2: Strip Codex-style file operation markers and unified diff metadata - // NOTE: Do NOT strip "*** End of File" - that's a valid marker within hunks, not a wrapper - // IMPORTANT: Only strip actual metadata lines, NOT diff content lines (starting with space, +, or -) - lines = lines.filter(line => { - // Preserve diff content lines even if their content looks like metadata - // Note: `--- ` and `+++ ` are metadata, not content lines - if (isDiffContentLine(line)) { - return true; - } - - const trimmed = line.trim(); - - // Codex file operation markers (these wrap multiple file changes) - if (trimmed.startsWith("*** Update File:")) return false; - if (trimmed.startsWith("*** Add File:")) return false; - if (trimmed.startsWith("*** Delete File:")) return false; - - // Unified diff metadata - if (trimmed.startsWith("diff --git ")) return false; - if (trimmed.startsWith("index ")) return false; - if (trimmed.startsWith("--- ")) return false; - if (trimmed.startsWith("+++ ")) return false; - if (trimmed.startsWith("new file mode ")) return false; - if (trimmed.startsWith("deleted file mode ")) return false; - if (trimmed.startsWith("rename from ")) return false; - if (trimmed.startsWith("rename to ")) return false; - if (trimmed.startsWith("similarity index ")) return false; - if (trimmed.startsWith("dissimilarity index ")) return false; - if (trimmed.startsWith("old mode ")) return false; - if (trimmed.startsWith("new mode ")) return false; - - return true; - }); - - return lines.join("\n"); -} - -/** - * Strip `+ ` prefix from file creation content if all non-empty lines have it. - * This handles diffs where file content is formatted as additions. - */ -export function normalizeCreateContent(content: string): string { - const lines = content.split("\n"); - const nonEmptyLines = lines.filter(l => l.length > 0); - - // Check if all non-empty lines start with "+ " or "+" - if (nonEmptyLines.length > 0 && nonEmptyLines.every(l => l.startsWith("+ ") || l.startsWith("+"))) { - return lines - .map(l => { - if (l.startsWith("+ ")) return l.slice(2); - if (l.startsWith("+")) return l.slice(1); - return l; - }) - .join("\n"); - } - - return content; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Header Parsing -// ═══════════════════════════════════════════════════════════════════════════ - -interface UnifiedHunkHeader { - oldStartLine: number; - oldLineCount: number; - newStartLine: number; - newLineCount: number; - changeContext?: string; -} - -function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined { - const match = line.match(UNIFIED_HUNK_HEADER_REGEX); - if (!match) return undefined; - - const oldStartLine = Number(match[1]); - const oldLineCount = match[2] ? Number(match[2]) : 1; - const newStartLine = Number(match[3]); - const newLineCount = match[4] ? Number(match[4]) : 1; - const changeContext = match[5]?.trim(); - - return { - oldStartLine, - oldLineCount, - newStartLine, - newLineCount, - changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined, - }; -} - -function isUnifiedDiffMetadataLine(line: string): boolean { - return ( - line.startsWith("diff --git ") || - line.startsWith("index ") || - line.startsWith("--- ") || - line.startsWith("+++ ") || - line.startsWith("new file mode ") || - line.startsWith("deleted file mode ") || - line.startsWith("rename from ") || - line.startsWith("rename to ") || - line.startsWith("similarity index ") || - line.startsWith("dissimilarity index ") || - line.startsWith("old mode ") || - line.startsWith("new mode ") - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Hunk Parsing -// ═══════════════════════════════════════════════════════════════════════════ - -interface ParseHunkResult { - hunk: DiffHunk; - linesConsumed: number; -} - -/** - * Parse a single hunk from lines starting at the current position. - * - * Handles several context formats: - * - Empty: `@@` (no context, match from current position) - * - Unified: `@@ -10,3 +10,3 @@` (line numbers as hints) - * - Context: `@@ function foo` (search for context line) - * - Line hint: `@@ line 125` (use line 125 as starting position) - * - Nested: `@@ class Foo\n@@ method` (hierarchical context search) - */ -function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult { - if (lines.length === 0) { - throw new ParseError("Diff does not contain any lines", lineNumber); - } - - const changeContexts: string[] = []; - let oldStartLine: number | undefined; - let newStartLine: number | undefined; - let startIndex: number; - - const headerLine = lines[0]; - const headerTrimmed = headerLine.trimEnd(); - const isHeaderLine = headerLine.startsWith("@@"); - const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined; - const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed); - - // Check for context marker - if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) { - startIndex = 1; - } else if (unifiedHeader) { - if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) { - throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber); - } - if (unifiedHeader.changeContext) { - changeContexts.push(unifiedHeader.changeContext); - } - oldStartLine = unifiedHeader.oldStartLine; - newStartLine = unifiedHeader.newStartLine; - startIndex = 1; - } else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) { - const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length); - const trimmedContextValue = contextValue.trim(); - const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, ""); - - const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX); - if (lineHintMatch) { - oldStartLine = Number(lineHintMatch[1]); - newStartLine = oldStartLine; - if (oldStartLine < 1) { - throw new ParseError("Line hint must be >= 1", lineNumber); - } - } else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) { - oldStartLine = 1; - newStartLine = 1; - } else if (trimmedContextValue.length > 0) { - changeContexts.push(contextValue); - } - startIndex = 1; - } else if (isHeaderLine) { - const contextValue = headerTrimmed.slice(2).trim(); - if (contextValue.length > 0) { - changeContexts.push(contextValue); - } - startIndex = 1; - } else { - if (!allowMissingContext) { - throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber); - } - startIndex = 0; - } - - if (oldStartLine !== undefined && oldStartLine < 1) { - throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber); - } - if (newStartLine !== undefined && newStartLine < 1) { - throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber); - } - - // Check for nested @@ anchors on subsequent lines - // Format: @@ class Foo - // @@ method - while (startIndex < lines.length) { - const nextLine = lines[startIndex]; - if (!nextLine.startsWith("@@")) { - break; - } - const trimmed = nextLine.trimEnd(); - - // Check if it's another @@ line (nested anchor) - if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) { - const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length); - if (nestedContext.trim().length > 0) { - changeContexts.push(nestedContext); - } - startIndex++; - } else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) { - // Empty @@ as separator - skip it - startIndex++; - } else { - // Not an @@ line, stop accumulating - break; - } - } - - if (startIndex >= lines.length) { - throw new ParseError("Hunk does not contain any lines", lineNumber + 1); - } - - // Combine contexts: if multiple, join with newline for hierarchical matching - const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined; - - const hunk: DiffHunk = { - changeContext, - oldStartLine, - newStartLine, - hasContextLines: false, - oldLines: [], - newLines: [], - isEndOfFile: false, - }; - - let parsedLines = 0; - - for (let i = startIndex; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - const nextLine = lines[i + 1]; - - if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) { - break; - } - - if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) { - if (parsedLines === 0) { - throw new ParseError("Hunk does not contain any lines", lineNumber + 1); - } - hunk.isEndOfFile = true; - parsedLines++; - break; - } - - if (trimmed === "..." || trimmed === "…") { - hunk.hasContextLines = true; - parsedLines++; - continue; - } - - const firstChar = line[0]; - - if (firstChar === undefined || firstChar === "") { - // Empty line - treat as context - hunk.hasContextLines = true; - hunk.oldLines.push(""); - hunk.newLines.push(""); - } else if (firstChar === " ") { - // Context line - hunk.hasContextLines = true; - hunk.oldLines.push(line.slice(1)); - hunk.newLines.push(line.slice(1)); - } else if (firstChar === "+") { - // Added line - hunk.newLines.push(line.slice(1)); - } else if (firstChar === "-") { - // Removed line - hunk.oldLines.push(line.slice(1)); - } else if (!line.startsWith("@@")) { - // Implicit context line (model omitted leading space) - hunk.hasContextLines = true; - hunk.oldLines.push(line); - hunk.newLines.push(line); - } else { - if (parsedLines === 0) { - throw new ParseError( - `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`, - lineNumber + 1, - ); - } - // Assume start of next hunk - break; - } - parsedLines++; - } - - if (parsedLines === 0) { - throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex); - } - - stripLineNumberPrefixes(hunk); - return { hunk, linesConsumed: parsedLines + startIndex }; -} - -function stripLineNumberPrefixes(hunk: DiffHunk): void { - const allLines = [...hunk.oldLines, ...hunk.newLines].filter(line => line.trim().length > 0); - if (allLines.length < 2) return; - - const numberMatches = allLines - .map(line => line.match(/^\s*(\d{1,6})\s+(.+)$/u)) - .filter((match): match is RegExpMatchArray => match !== null); - - if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) { - return; - } - - const numbers = numberMatches.map(match => Number(match[1])); - let sequential = 0; - for (let i = 1; i < numbers.length; i++) { - if (numbers[i] === numbers[i - 1] + 1) { - sequential++; - } - } - - if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) { - return; - } - - const strip = (line: string): string => { - const match = line.match(/^\s*\d{1,6}\s+(.+)$/u); - return match ? match[1] : line; - }; - - hunk.oldLines = hunk.oldLines.map(strip); - hunk.newLines = hunk.newLines.map(strip); -} - -/** Multi-file patch markers that indicate this is not a single-file patch */ -const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "]; - -/** - * Count multi-file markers in a diff. - * Returns the count of file-level markers found. - * Only counts lines that are actual metadata (not diff content lines). - */ -function countMultiFileMarkers(diff: string): number { - const counts = new Map(); - const paths = new Set(); - const lines = diff.split("\n"); - for (const line of lines) { - if (isDiffContentLine(line)) { - continue; - } - const trimmed = line.trim(); - for (const marker of MULTI_FILE_MARKERS) { - if (trimmed.startsWith(marker)) { - const path = extractMarkerPath(trimmed); - if (path) { - paths.add(path); - } - counts.set(marker, (counts.get(marker) ?? 0) + 1); - break; - } - } - } - if (paths.size > 0) { - return paths.size; - } - let maxCount = 0; - for (const count of counts.values()) { - if (count > maxCount) { - maxCount = count; - } - } - return maxCount; -} - -function extractMarkerPath(line: string): string | undefined { - if (line.startsWith("diff --git ")) { - const parts = line.split(/\s+/); - const candidate = parts[3] ?? parts[2]; - if (!candidate) return undefined; - return candidate.replace(/^(a|b)\//, ""); - } - if (line.startsWith("*** Update File:")) { - return line.slice("*** Update File:".length).trim(); - } - if (line.startsWith("*** Add File:")) { - return line.slice("*** Add File:".length).trim(); - } - if (line.startsWith("*** Delete File:")) { - return line.slice("*** Delete File:".length).trim(); - } - return undefined; -} - -/** - * Parse all diff hunks from a diff string. - */ -export function parseHunks(diff: string): DiffHunk[] { - const multiFileCount = countMultiFileMarkers(diff); - if (multiFileCount > 1) { - throw new ApplyPatchError( - `Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`, - ); - } - - const normalizedDiff = normalizeDiff(diff); - const lines = normalizedDiff.split("\n"); - const hunks: DiffHunk[] = []; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trim(); - - // Skip blank lines between hunks - if (trimmed === "") { - i++; - continue; - } - - // Skip unified diff metadata lines, but only if they're not diff content lines - const firstChar = line[0]; - const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-"; - if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) { - i++; - continue; - } - - if (trimmed.startsWith("@@") && lines.slice(i + 1).every(l => l.trim() === "")) { - break; - } - - const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true); - hunks.push(hunk); - i += linesConsumed; - } - - return hunks; -} diff --git a/packages/coding-agent/src/patch/prefix-stripping.ts b/packages/coding-agent/src/patch/prefix-stripping.ts deleted file mode 100644 index 0793c1376..000000000 --- a/packages/coding-agent/src/patch/prefix-stripping.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Pattern matching hashline display format prefixes: - * `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`. - * A plus-prefixed form appears in diff-like output and should be treated - * as hashline metadata too. - */ -const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/; -const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/; - -/** - * Pattern matching a unified-diff added-line `+` prefix (but not `++`). - * Does NOT match `-` to avoid corrupting Markdown list items. - */ -const DIFF_PLUS_RE = /^[+](?![+])/; - -/** - * Strip hashline display prefixes and diff `+` markers from replacement lines. - * - * Models frequently copy the `LINE#ID:` prefix from read output into their - * replacement content, or include unified-diff `+` prefixes. Both corrupt the - * output file. This strips them heuristically before application. - */ -export function stripNewLinePrefixes(lines: string[]): string[] { - let hashPrefixCount = 0; - let diffPlusHashPrefixCount = 0; - let diffPlusCount = 0; - let nonEmpty = 0; - for (const line of lines) { - if (line.length === 0) continue; - nonEmpty++; - if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++; - if (HASHLINE_PREFIX_PLUS_RE.test(line)) diffPlusHashPrefixCount++; - if (DIFF_PLUS_RE.test(line)) diffPlusCount++; - } - if (nonEmpty === 0) return lines; - - const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty; - const stripPlus = - !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5; - if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines; - - return lines.map(line => { - if (stripHash) return line.replace(HASHLINE_PREFIX_RE, ""); - if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); - if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) { - return line.replace(HASHLINE_PREFIX_RE, ""); - } - return line; - }); -} - -/** - * Strip hashline display prefixes only (no diff markers). - * - * Unlike {@link stripNewLinePrefixes} which also handles `+` diff markers, - * this only strips `LINE#ID:` / `#ID:` prefixes. - * - * Returns the original array reference when no stripping is needed. - */ -export function stripHashlinePrefixes(lines: string[]): string[] { - let hashPrefixCount = 0; - let nonEmpty = 0; - for (const line of lines) { - if (line.length === 0) continue; - nonEmpty++; - if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++; - } - if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines; - return lines.map(line => line.replace(HASHLINE_PREFIX_RE, "")); -} diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts deleted file mode 100644 index d612c3ff7..000000000 --- a/packages/coding-agent/src/patch/types.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Shared types for the edit tool module. - */ - -// ═══════════════════════════════════════════════════════════════════════════ -// File System Abstraction -// ═══════════════════════════════════════════════════════════════════════════ - -/** Abstraction for file system operations to support LSP writethrough */ -export interface FileSystem { - exists(path: string): Promise; - read(path: string): Promise; - readBinary?: (path: string) => Promise; - write(path: string, content: string): Promise; - delete(path: string): Promise; - mkdir(path: string): Promise; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Fuzzy Matching Types -// ═══════════════════════════════════════════════════════════════════════════ - -/** Result of a fuzzy match operation */ -export interface FuzzyMatch { - /** The actual text that was matched */ - actualText: string; - /** Character index where the match starts */ - startIndex: number; - /** Line number where the match starts (1-indexed) */ - startLine: number; - /** Confidence score (0-1, where 1 is exact match) */ - confidence: number; -} - -/** Outcome of attempting to find a match */ -export interface MatchOutcome { - /** The match if found with sufficient confidence */ - match?: FuzzyMatch; - /** The closest match found (may be below threshold) */ - closest?: FuzzyMatch; - /** Number of occurrences if multiple exact matches found */ - occurrences?: number; - /** Line numbers where occurrences were found (1-indexed) */ - occurrenceLines?: number[]; - /** Preview snippets for each occurrence (up to 5) */ - occurrencePreviews?: string[]; - /** Number of fuzzy matches above threshold */ - fuzzyMatches?: number; - /** True when a dominant fuzzy match was accepted despite multiple candidates */ - dominantFuzzy?: boolean; -} - -/** Result of a sequence search */ -export type SequenceMatchStrategy = - | "exact" - | "trim-trailing" - | "trim" - | "comment-prefix" - | "unicode" - | "prefix" - | "substring" - | "fuzzy" - | "fuzzy-dominant" - | "character"; - -export interface SequenceSearchResult { - /** Starting line index of the match (0-indexed) */ - index: number | undefined; - /** Confidence score (1.0 for exact match, lower for fuzzy) */ - confidence: number; - /** Number of matches at the same confidence level (for ambiguity detection) */ - matchCount?: number; - /** Sample of matching indices (0-indexed, up to a small limit) */ - matchIndices?: number[]; - /** Matching strategy used */ - strategy?: SequenceMatchStrategy; -} - -/** Result of a context line search */ -export type ContextMatchStrategy = "exact" | "trim" | "unicode" | "prefix" | "substring" | "fuzzy"; - -export interface ContextLineResult { - /** Index of the matching line (0-indexed) */ - index: number | undefined; - /** Confidence score (1.0 for exact match, lower for fuzzy) */ - confidence: number; - /** Number of matches at the same confidence level (for ambiguity detection) */ - matchCount?: number; - /** Sample of matching indices (0-indexed, up to a small limit) */ - matchIndices?: number[]; - /** Matching strategy used */ - strategy?: ContextMatchStrategy; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Patch Types -// ═══════════════════════════════════════════════════════════════════════════ - -export type Operation = "create" | "delete" | "update"; - -/** Input for a patch operation */ -export interface PatchInput { - /** File path (relative or absolute) */ - path: string; - /** Operation type */ - op: Operation; - /** New path for rename (update only) */ - rename?: string; - /** File content (create) or diff hunks (update) */ - diff?: string; -} - -/** Normalized patch input used internally by the applicator. */ -export interface NormalizedPatchInput { - path: string; - op: Operation; - rename?: string; - diff?: string; -} - -export function normalizePatchInput(input: PatchInput): NormalizedPatchInput { - return { - path: input.path, - op: input.op ?? "update", - rename: input.rename, - diff: input.diff, - }; -} - -/** A single hunk/chunk in a diff */ -export interface DiffHunk { - /** Context line to narrow down position (e.g., class/method definition) */ - changeContext?: string; - /** 1-based line hint from unified diff headers (old file) */ - oldStartLine?: number; - /** 1-based line hint from unified diff headers (new file) */ - newStartLine?: number; - /** True if the hunk contains context lines (space-prefixed) */ - hasContextLines: boolean; - /** Lines to be replaced (old content) */ - oldLines: string[]; - /** Lines to replace with (new content) */ - newLines: string[]; - /** If true, oldLines must occur at end of file */ - isEndOfFile: boolean; -} - -/** Describes a change made to a file */ -export interface FileChange { - type: Operation; - path: string; - newPath?: string; - oldContent?: string; - newContent?: string; -} - -/** Result of applying a patch */ -export interface ApplyPatchResult { - change: FileChange; - warnings?: string[]; -} - -/** Options for applying a patch */ -export interface ApplyPatchOptions { - /** Working directory for resolving relative paths */ - cwd: string; - /** Dry run - compute changes without writing */ - dryRun?: boolean; - /** Similarity threshold for fuzzy matching */ - fuzzyThreshold?: number; - /** Allow fuzzy/partial matching when applying hunks */ - allowFuzzy?: boolean; - /** File system abstraction (defaults to Bun-based implementation) */ - fs?: FileSystem; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Diff Generation Types -// ═══════════════════════════════════════════════════════════════════════════ - -/** Result of generating a diff */ -export interface DiffResult { - /** The unified diff string */ - diff: string; - /** Line number of the first change in the new file */ - firstChangedLine: number | undefined; -} - -/** Error from diff computation */ -export interface DiffError { - error: string; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Hashline Types -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Hashline edit operation/input types are schema-derived in `patch/index.ts` - * via `Static` and `Static`. - */ - -/** A single hash mismatch found during validation */ -export interface HashMismatch { - /** 1-indexed line number */ - line: number; - /** Hash the caller provided */ - expected: string; - /** Hash computed from the current file content */ - actual: string; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Error Classes -// ═══════════════════════════════════════════════════════════════════════════ - -export class ParseError extends Error { - constructor( - message: string, - public readonly lineNumber?: number, - ) { - super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message); - this.name = "ParseError"; - } -} - -export class ApplyPatchError extends Error { - constructor(message: string) { - super(message); - this.name = "ApplyPatchError"; - } -} - -export class EditMatchError extends Error { - constructor( - public readonly path: string, - public readonly searchText: string, - public readonly closest: FuzzyMatch | undefined, - public readonly options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number }, - ) { - super(EditMatchError.formatMessage(path, searchText, closest, options)); - this.name = "EditMatchError"; - } - - static formatMessage( - path: string, - searchText: string, - closest: FuzzyMatch | undefined, - options: { allowFuzzy: boolean; threshold: number; fuzzyMatches?: number }, - ): string { - if (!closest) { - return options.allowFuzzy - ? `Could not find a close enough match in ${path}.` - : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`; - } - - const similarity = Math.round(closest.confidence * 100); - const searchLines = searchText.split("\n"); - const actualLines = closest.actualText.split("\n"); - const { oldLine, newLine } = findFirstDifferentLine(searchLines, actualLines); - const thresholdPercent = Math.round(options.threshold * 100); - - const hint = options.allowFuzzy - ? options.fuzzyMatches && options.fuzzyMatches > 1 - ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.` - : `Closest match was below the ${thresholdPercent}% similarity threshold.` - : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches."; - - return [ - options.allowFuzzy - ? `Could not find a close enough match in ${path}.` - : `Could not find the exact text in ${path}.`, - ``, - `Closest match (${similarity}% similar) at line ${closest.startLine}:`, - ` - ${oldLine}`, - ` + ${newLine}`, - hint, - ].join("\n"); - } -} - -function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLine: string; newLine: string } { - const max = Math.max(oldLines.length, newLines.length); - for (let i = 0; i < max; i++) { - const oldLine = oldLines[i] ?? ""; - const newLine = newLines[i] ?? ""; - if (oldLine !== newLine) { - return { oldLine, newLine }; - } - } - return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" }; -} diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index d8e7c01e4..73b836601 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -1,38 +1,21 @@ -Edits files via syntax-aware chunks. Run `read(path="file.ts")` first — the default read output shows anchors like `[full.chunk.path#CRC]`, where `#CRC` is a 4-char hex checksum. Copy that exact `full.chunk.path#CRC` into `target`. +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`. - **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 (e.g. `class_X.fn_y.if_2`, not `if_2`), ending with `#CRC` for replace/delete. +- `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. -- Prefer `line`/`end_line` (absolute file line numbers from the read gutter) for small fixes over whole-chunk replace. -- `content` must include the destination block's inner indentation. +- `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. |op|fields|effect| |---|---|---| -|`replace` (default)|`target#CRC`, `content`, opt. `line`/`end_line`|rewrite chunk or a line range within it| -|`delete`|`target#CRC`|remove chunk| -|`append` / `prepend`|`target`, `content`|insert as last/first child of target| -|`after` / `before`|`target`, `anchor` (child name), `content`|insert at sibling position| +|`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| For file-root edits, `target` is the file header CRC alone (e.g. `"#VSKB"`). - - -Given read output: -``` - | server.ts·40L·ts·#VSKB -12| start(): void { - | {{anchor "fn_start" "HTST"}} -13| log("booting on " + this.port); -14| this.tryBind(); -15| } -``` - -Fix the typo on line 13: -```json -{"path":"server.ts","edits":[{"target":"{{sel "class_Server.fn_start"}}#HTST","line":13,"content":" warn(\"booting on \" + this.port);"}]} -``` - diff --git a/packages/coding-agent/src/prompts/tools/lsp.md b/packages/coding-agent/src/prompts/tools/lsp.md index a978ee0e7..5191ed028 100644 --- a/packages/coding-agent/src/prompts/tools/lsp.md +++ b/packages/coding-agent/src/prompts/tools/lsp.md @@ -1,21 +1,21 @@ Interacts with Language Server Protocol servers for code intelligence. -- `diagnostics`: Get errors/warnings for file, glob, or entire workspace (no file) +- `diagnostics`: Get errors/warnings for a file, a glob of files, or the entire workspace (`file: "*"`) - `definition`: Go to symbol definition → file path + position + 3-line source context - `type_definition`: Go to symbol type definition → file path + position + 3-line source context - `implementation`: Find concrete implementations → file path + position + 3-line source context - `references`: Find references → locations with 3-line source context (first 50), remaining location-only - `hover`: Get type info and documentation → type signature + docs -- `symbols`: List symbols in file, or search workspace (with query, no file) +- `symbols`: List symbols in a file, or search workspace with `file: "*"` and a `query` - `rename`: Rename symbol across codebase → preview or apply edits - `code_actions`: List available quick-fixes/refactors/import actions; apply one when `apply: true` and `query` matches title or index - `status`: Show active language servers -- `reload`: Restart the language server +- `reload`: Restart a specific server (via `file`) or all servers with `file: "*"` -- `file`: File path; for diagnostics it may be a glob pattern (e.g., `src/**/*.ts`) +- `file`: File path, glob pattern (e.g. `src/**/*.ts`), or `"*"` for workspace scope. Globs are expanded locally before dispatch. `"*"` routes `diagnostics`/`symbols`/`reload` to their workspace-wide form. - `line`: 1-indexed line number for position-based actions - `symbol`: Substring on the target line used to resolve column automatically - `occurrence`: 1-indexed match index when `symbol` appears multiple times on the same line @@ -28,6 +28,6 @@ Interacts with Language Server Protocol servers for code intelligence. - Requires running LSP server for target language - Some operations require file to be saved to disk -- Diagnostics glob mode samples up to 20 files per request to avoid long-running stalls on broad patterns +- Glob expansion samples up to 20 files per request; use `file: "*"` for broader coverage - When `symbol` is provided for position-based actions, missing symbols or out-of-bounds `occurrence` values return an explicit error instead of silently falling back diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md index 2c2574d6f..09a4f65b3 100644 --- a/packages/coding-agent/src/prompts/tools/read-chunk.md +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -7,7 +7,7 @@ Reads files using syntax-aware chunks. 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. -Line numbers in the gutter are absolute — use them for `line`/`end_line` in edits. +Line numbers in the gutter are absolute file line numbers. Chunk trees: JS, TS, TSX, Python, Rust, Go. Others use blank-line fallback. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 1d903ae86..ba6475637 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -63,6 +63,7 @@ import { } from "../config/model-resolver"; import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; +import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor"; import { exportSessionToHtml } from "../export/html"; import type { TtsrManager, TtsrMatchContext } from "../export/ttsr"; @@ -103,7 +104,6 @@ import { selectDiscoverableMCPToolNamesByServer, } from "../mcp/discoverable-tool-metadata"; import { getCurrentThemeName, theme } from "../modes/theme/theme"; -import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch"; import type { PlanModeState } from "../plan-mode/state"; import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" }; import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" }; @@ -2707,6 +2707,10 @@ export class AgentSession { }); } + queueDeferredMessage(message: CustomMessage): void { + this.#queueHiddenNextTurnMessage(message, true); + } + #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void { this.#pendingNextTurnMessages.push(message); if (!triggerTurn) return; diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index c7b6ee942..8feb3bc71 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -6,9 +6,9 @@ import { Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; +import { computeLineHash } from "../edit/modes/hashline"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; -import { computeLineHash } from "../patch/hashline"; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 83f287fd5..93809e8ba 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -6,9 +6,9 @@ import { Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; +import { computeLineHash } from "../edit/modes/hashline"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; -import { computeLineHash } from "../patch/hashline"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; diff --git a/packages/coding-agent/src/tools/chunk-tree.ts b/packages/coding-agent/src/tools/chunk-tree.ts deleted file mode 100644 index 008bf59fe..000000000 --- a/packages/coding-agent/src/tools/chunk-tree.ts +++ /dev/null @@ -1,271 +0,0 @@ -import * as path from "node:path"; -import { - ChunkAnchorStyle, - ChunkEditOp, - type ChunkInfo, - ChunkReadStatus, - type ChunkReadTarget, - ChunkState, - type EditOperation as NativeEditOperation, -} from "@oh-my-pi/pi-natives"; -import { LRUCache } from "lru-cache"; -import type { Settings } from "../config/settings"; -import { HASHLINE_NIBBLE_ALPHABET } from "../patch/hashline"; -import { normalizeToLF, stripBom } from "../patch/normalize"; - -export type { ChunkReadTarget }; - -const validAnchorStyles: Record = { - full: ChunkAnchorStyle.Full, - kind: ChunkAnchorStyle.Kind, - bare: ChunkAnchorStyle.Bare, -}; - -export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle { - const envStyle = Bun.env.PI_ANCHOR_STYLE; - return ( - (envStyle && validAnchorStyles[envStyle]) || - (settings?.get("read.anchorstyle") as ChunkAnchorStyle | undefined) || - ChunkAnchorStyle.Full - ); -} - -const readEnvInt = (name: string, defaultValue: number): number => { - const v = Bun.env[name]; - if (!v) return defaultValue; - const n = Number.parseInt(v, 10); - if (Number.isNaN(n) || n <= 0) return defaultValue; - return n; -}; - -const CACHE_MAX_ENTRIES = readEnvInt("PI_CHUNK_CACHE_MAX_ENTRIES", 200); -const CHECKSUM_SUFFIX_RE = new RegExp(`^(.*?)(?:\\s+)?#([${HASHLINE_NIBBLE_ALPHABET}]{4})$`, "i"); - -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; line?: number; endLine?: number } - | { op: "delete"; sel?: string; crc?: string }; - -export type ChunkEditResult = { - diffSourceBefore: string; - diffSourceAfter: string; - responseText: string; - changed: boolean; - parseValid: boolean; - touchedPaths: string[]; - warnings: string[]; -}; - -export type ParsedChunkReadPath = { - filePath: string; - selector?: string; - crc?: string; -}; - -type ChunkCacheEntry = { - mtimeMs: number; - size: number; - source: string; - state: ChunkState; -}; - -const chunkStateCache = new LRUCache({ - max: CACHE_MAX_ENTRIES, -}); - -export function invalidateChunkTreeCache(filePath: string): void { - chunkStateCache.delete(filePath); -} - -function normalizeLanguage(language: string | undefined): string { - return language?.trim().toLowerCase() || ""; -} - -function normalizeChunkSource(text: string): string { - return normalizeToLF(stripBom(text).text); -} - -function displayPathForFile(filePath: string, cwd: string): string { - const relative = path.relative(cwd, filePath).replace(/\\/g, "/"); - return relative && !relative.startsWith("..") ? relative : filePath.replace(/\\/g, "/"); -} - -function fileLanguageTag(filePath: string, language?: string): string | undefined { - const normalizedLanguage = normalizeLanguage(language); - if (normalizedLanguage.length > 0) return normalizedLanguage; - const ext = path.extname(filePath).replace(/^\./, "").toLowerCase(); - return ext.length > 0 ? ext : undefined; -} - -function chunkReadPathSeparatorIndex(readPath: string): number { - if (/^[a-zA-Z]:[/\\]/.test(readPath)) { - return readPath.indexOf(":", 2); - } - return readPath.indexOf(":"); -} - -export function parseChunkSelector(selector: string | undefined): { selector?: string; crc?: 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 the CRC was stripped from the path (e.g. "fn_foo#ABCD" → "fn_foo"), - // return both. If the path part is empty, the input was just a bare CRC - // token — keep the original as the selector. - if (normalizedSelector.length > 0) { - return { selector: normalizedSelector, crc }; - } - return { selector }; -} - -export function parseChunkReadPath(readPath: string): ParsedChunkReadPath { - const colonIndex = chunkReadPathSeparatorIndex(readPath); - if (colonIndex === -1) { - return { filePath: readPath }; - } - return { - filePath: readPath.slice(0, colonIndex), - selector: parseChunkSelector(readPath.slice(colonIndex + 1) || undefined).selector, - }; -} - -export function isChunkReadablePath(readPath: string): boolean { - return parseChunkReadPath(readPath).selector !== undefined; -} - -export async function loadChunkStateForFile(filePath: string, language: string | undefined): Promise { - const file = Bun.file(filePath); - const stat = await file.stat(); - const cached = chunkStateCache.get(filePath); - if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { - return cached; - } - - const source = normalizeChunkSource(await file.text()); - const state = ChunkState.parse(source, normalizeLanguage(language)); - const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, state }; - chunkStateCache.set(filePath, entry); - return entry; -} - -export async function formatChunkedRead(params: { - filePath: string; - readPath: string; - cwd: string; - language?: string; - omitChecksum?: boolean; - anchorStyle?: ChunkAnchorStyle; - absoluteLineRange?: { startLine: number; endLine?: number }; -}): Promise<{ text: string; resolvedPath?: string; chunk?: ChunkReadTarget }> { - const { filePath, readPath, cwd, language, omitChecksum = false, anchorStyle, absoluteLineRange } = params; - const normalizedLanguage = normalizeLanguage(language); - const { state } = await loadChunkStateForFile(filePath, normalizedLanguage); - const displayPath = displayPathForFile(filePath, cwd); - const result = state.renderRead({ - readPath, - displayPath, - languageTag: fileLanguageTag(filePath, normalizedLanguage), - omitChecksum, - anchorStyle, - absoluteLineRange: absoluteLineRange - ? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine } - : undefined, - tabReplacement: " ", - }); - return { text: result.text, resolvedPath: filePath, chunk: result.chunk }; -} - -export async function formatChunkedGrepLine(params: { - filePath: string; - lineNumber: number; - line: string; - cwd: string; - language?: string; -}): Promise { - const { filePath, lineNumber, line, cwd, language } = params; - const { state } = await loadChunkStateForFile(filePath, language); - return state.formatGrepLine(displayPathForFile(filePath, cwd), lineNumber, line); -} - -function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperation { - switch (operation.op) { - case "replace": - return { - op: ChunkEditOp.Replace, - sel: operation.sel, - crc: operation.crc, - content: operation.content, - line: operation.line, - endLine: operation.endLine, - }; - case "delete": - return { - op: ChunkEditOp.Delete, - sel: operation.sel, - crc: operation.crc, - }; - 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 }; - default: { - const exhaustive: never = operation; - return exhaustive; - } - } -} - -export function applyChunkEdits(params: { - source: string; - language?: string; - cwd: string; - filePath: string; - operations: ChunkEditOperation[]; - defaultSelector?: string; - defaultCrc?: string; - anchorStyle?: ChunkAnchorStyle; -}): ChunkEditResult { - const normalizedSource = normalizeChunkSource(params.source); - const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language)); - const result = state.applyEdits({ - operations: params.operations.map(toNativeEditOperation), - defaultSelector: params.defaultSelector, - defaultCrc: params.defaultCrc, - anchorStyle: params.anchorStyle, - cwd: params.cwd, - filePath: params.filePath, - }); - - return { - diffSourceBefore: result.diffBefore, - diffSourceAfter: result.diffAfter, - responseText: result.responseText, - changed: result.changed, - parseValid: result.parseValid, - touchedPaths: result.touchedPaths, - warnings: result.warnings, - }; -} - -export async function getChunkInfoForFile( - filePath: string, - language: string | undefined, - chunkPath: string, -): Promise { - const { state } = await loadChunkStateForFile(filePath, language); - return state.chunk(chunkPath) ?? undefined; -} - -export function missingChunkReadTarget(selector: string): ChunkReadTarget { - return { status: ChunkReadStatus.NotFound, selector }; -} diff --git a/packages/coding-agent/src/tools/fs-cache-invalidation.ts b/packages/coding-agent/src/tools/fs-cache-invalidation.ts index c65849801..13f541e48 100644 --- a/packages/coding-agent/src/tools/fs-cache-invalidation.ts +++ b/packages/coding-agent/src/tools/fs-cache-invalidation.ts @@ -1,12 +1,12 @@ import { invalidateFsScanCache } from "@oh-my-pi/pi-natives"; -import { invalidateChunkTreeCache } from "./chunk-tree"; +import { invalidateChunkCache } from "../edit/modes/chunk"; /** * Invalidate shared filesystem scan caches after a content write/update. */ export function invalidateFsScanAfterWrite(path: string): void { invalidateFsScanCache(path); - invalidateChunkTreeCache(path); + invalidateChunkCache(path); } /** @@ -14,7 +14,7 @@ export function invalidateFsScanAfterWrite(path: string): void { */ export function invalidateFsScanAfterDelete(path: string): void { invalidateFsScanCache(path); - invalidateChunkTreeCache(path); + invalidateChunkCache(path); } /** @@ -25,9 +25,9 @@ export function invalidateFsScanAfterDelete(path: string): void { */ export function invalidateFsScanAfterRename(oldPath: string, newPath: string): void { invalidateFsScanCache(oldPath); - invalidateChunkTreeCache(oldPath); + invalidateChunkCache(oldPath); if (newPath !== oldPath) { invalidateFsScanCache(newPath); - invalidateChunkTreeCache(newPath); + invalidateChunkCache(newPath); } } diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 886f9b7dd..950cffe7a 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -7,16 +7,16 @@ import { Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; +import { formatChunkedGrepLine } from "../edit/modes/chunk"; +import { computeLineHash } from "../edit/modes/hashline"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; -import { computeLineHash } from "../patch/hashline"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveEditMode } from "../utils/edit-mode"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; -import { formatChunkedGrepLine } from "./chunk-tree"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { combineSearchGlobs, diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 1a67ecad0..e4ba84d0d 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -4,13 +4,13 @@ import { $env, logger } from "@oh-my-pi/pi-utils"; import type { AsyncJobManager } from "../async"; import type { PromptTemplate } from "../config/prompt-templates"; import type { Settings } from "../config/settings"; +import { EditTool } from "../edit"; import type { Skill } from "../extensibility/skills"; import type { InternalUrlRouter } from "../internal-urls"; import { getPreludeDocs, warmPythonEnvironment } from "../ipy/executor"; import { checkPythonKernelAvailability } from "../ipy/kernel"; import { LspTool } from "../lsp"; import type { DiscoverableMCPSearchIndex, DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata"; -import { EditTool } from "../patch"; import type { PlanModeState } from "../plan-mode/state"; import type { CustomMessage } from "../session/messages"; import { TaskTool } from "../task"; @@ -57,10 +57,10 @@ import { WriteTool } from "./write"; // Exa MCP tools (22 tools) +export * from "../edit"; export * from "../exa"; export type * from "../exa/types"; export * from "../lsp"; -export * from "../patch"; export * from "../session/streaming-output"; export * from "../task"; export * from "../web/search"; @@ -184,6 +184,9 @@ export interface ToolSession { getCheckpointState?: () => CheckpointState | undefined; /** Set or clear active checkpoint state. */ setCheckpointState?: (state: CheckpointState | null) => void; + + /** Queue a hidden message to be injected at the next agent turn. */ + queueDeferredMessage?(message: CustomMessage): void; } type ToolFactory = (session: ToolSession) => Tool | null | Promise; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index d0033d971..fbbe8a0e0 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -8,11 +8,18 @@ import { Text } from "@oh-my-pi/pi-tui"; import { getRemoteDir, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; +import { + type ChunkReadTarget, + formatChunkedRead, + parseChunkReadPath, + parseChunkSelector, + resolveAnchorStyle, +} from "../edit/modes/chunk"; +import { computeLineHash } from "../edit/modes/hashline"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { parseInternalUrl } from "../internal-urls/parse"; import type { InternalUrl } from "../internal-urls/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; -import { computeLineHash } from "../patch/hashline"; import readDescription from "../prompts/tools/read.md" with { type: "text" }; import readChunkDescription from "../prompts/tools/read-chunk.md" with { type: "text" }; import type { ToolSession } from "../sdk"; @@ -37,13 +44,6 @@ import { import { convertFileWithMarkit } from "../utils/markit"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime"; import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader"; -import { - type ChunkReadTarget, - formatChunkedRead, - parseChunkReadPath, - parseChunkSelector, - resolveAnchorStyle, -} from "./chunk-tree"; import { executeReadUrl, isReadableUrlPath, @@ -66,18 +66,7 @@ function isProseLanguage(language: string | undefined): boolean { } // Document types converted to markdown via markit. -const CONVERTIBLE_EXTENSIONS = new Set([ - ".pdf", - ".doc", - ".docx", - ".ppt", - ".pptx", - ".xls", - ".xlsx", - ".rtf", - ".epub", - ".ipynb", -]); +const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]); // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep; diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index fba76be0d..09d98c513 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -4,10 +4,10 @@ * These provide rich visualization for tool calls and results in the TUI. */ import type { Component } from "@oh-my-pi/pi-tui"; +import { editToolRenderer } from "../edit"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { lspToolRenderer } from "../lsp/render"; import type { Theme } from "../modes/theme/theme"; -import { editToolRenderer } from "../patch"; import { taskToolRenderer } from "../task/render"; import { webSearchToolRenderer } from "../web/search/render"; import { askToolRenderer } from "./ask"; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 747973c7b..7b604be26 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -13,10 +13,10 @@ import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { unzipSync, zipSync } from "fflate"; import { renderPromptTemplate } from "../config/prompt-templates"; +import { stripHashlinePrefixes } from "../edit"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; -import { stripHashlinePrefixes } from "../patch"; import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index d2b5edfd9..b60a645e1 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -9,7 +9,7 @@ import * as fs from "node:fs/promises"; import path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { glob } from "@oh-my-pi/pi-natives"; -import { formatHashLines } from "../patch/hashline"; +import { formatHashLines } from "../edit/modes/hashline"; import type { FileMentionMessage } from "../session/messages"; import { DEFAULT_MAX_BYTES, diff --git a/packages/coding-agent/test/core/apply-patch-adverserial.test.ts b/packages/coding-agent/test/core/apply-patch-adverserial.test.ts index 97463f529..a29063130 100644 --- a/packages/coding-agent/test/core/apply-patch-adverserial.test.ts +++ b/packages/coding-agent/test/core/apply-patch-adverserial.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { ApplyPatchError, applyPatch } from "@oh-my-pi/pi-coding-agent/patch"; +import { ApplyPatchError, applyPatch } from "@oh-my-pi/pi-coding-agent/edit"; describe("applyPatch adversarial inputs", () => { let tempDir: string; diff --git a/packages/coding-agent/test/core/apply-patch-regression.test.ts b/packages/coding-agent/test/core/apply-patch-regression.test.ts index e8943ff2e..7482eb60a 100644 --- a/packages/coding-agent/test/core/apply-patch-regression.test.ts +++ b/packages/coding-agent/test/core/apply-patch-regression.test.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { applyPatch, findContextLine, seekSequence } from "@oh-my-pi/pi-coding-agent/patch"; +import { applyPatch, findContextLine, seekSequence } from "@oh-my-pi/pi-coding-agent/edit"; describe("regression: indentation adjustment for line-based replacements (2B)", () => { let tempDir: string; diff --git a/packages/coding-agent/test/core/apply-patch.test.ts b/packages/coding-agent/test/core/apply-patch.test.ts index 50d63d476..1e66d69b6 100644 --- a/packages/coding-agent/test/core/apply-patch.test.ts +++ b/packages/coding-agent/test/core/apply-patch.test.ts @@ -9,7 +9,7 @@ import { type PatchInput, parseDiffHunks, seekSequence, -} from "@oh-my-pi/pi-coding-agent/patch"; +} from "@oh-my-pi/pi-coding-agent/edit"; // ═══════════════════════════════════════════════════════════════════════════ // Legacy parser for test fixtures (*** Begin Patch format) diff --git a/packages/coding-agent/test/core/chunk-tree.test.ts b/packages/coding-agent/test/core/chunk-tree.test.ts index f52b2a96a..5c3169fd6 100644 --- a/packages/coding-agent/test/core/chunk-tree.test.ts +++ b/packages/coding-agent/test/core/chunk-tree.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { ChunkState } from "@oh-my-pi/pi-natives"; -import { applyChunkEdits, formatChunkedRead, parseChunkReadPath } from "../../src/tools/chunk-tree"; +import { applyChunkEdits, formatChunkedRead, parseChunkReadPath } from "../../src/edit/modes/chunk"; // ═══════════════════════════════════════════════════════════════════════════ // parseChunkReadPath @@ -124,7 +124,7 @@ describe("applyChunkEdits", () => { content: "replacement", }, ]), - ).toThrow(/did not match checksum/); + ).toThrow(/Checksum mismatch/); }); test("append_child on branch inserts after existing members", () => { @@ -162,14 +162,54 @@ describe("applyChunkEdits", () => { expect(result.diffSourceAfter).not.toEndWith("}\nmethod(): void {}\n"); }); - test("delete removes the target chunk", () => { + test("replace with empty content removes the target chunk", () => { const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - const result = edit([{ op: "delete", ...ac }]); + const result = edit([{ op: "replace", ...ac, content: "" }]); expect(result.diffSourceAfter).not.toContain("run()"); expect(result.diffSourceAfter).toContain("constructor"); }); + test("replace preserves attached doc comments when replacement starts at the declaration", () => { + const source = `class Worker {\n\t/** restart note */\n\trestart(): void {\n\t\tboot();\n\t}\n}\n`; + const checksum = getChecksum(source, "class_Worker.fn_restart"); + const result = edit( + [ + { + op: "replace", + sel: "class_Worker.fn_restart", + crc: checksum, + content: `\trestart(): void {\n\t\tshutdown();\n\t}`, + }, + ], + source, + ); + + expect(result.diffSourceAfter).toContain("\t/** restart note */\n\trestart(): void {"); + expect(result.diffSourceAfter).toContain("\t\tshutdown();"); + expect(result.diffSourceAfter).not.toContain("\t\tboot();"); + }); + + test("replace does not duplicate attached doc comments when replacement includes a new one", () => { + const source = `class Worker {\n\t/** restart note */\n\trestart(): void {\n\t\tboot();\n\t}\n}\n`; + const checksum = getChecksum(source, "class_Worker.fn_restart"); + const result = edit( + [ + { + op: "replace", + sel: "class_Worker.fn_restart", + crc: checksum, + content: `\t/** updated restart note */\n\trestart(): void {\n\t\tshutdown();\n\t}`, + }, + ], + source, + ); + + expect(result.diffSourceAfter).toContain("\t/** updated restart note */\n\trestart(): void {"); + expect(result.diffSourceAfter).not.toContain("/** restart note */"); + expect(result.diffSourceAfter.match(/updated restart note/g)).toHaveLength(1); + }); + test("sibling chunk crc from before the batch still validates after an unrelated sibling is replaced first", () => { const ctorCrc = getChecksum(testSource, "class_Worker.constructor"); const runCrc = getChecksum(testSource, "class_Worker.fn_run"); @@ -385,7 +425,7 @@ type Server struct { expect(result.diffSourceAfter).not.toContain("\t}\n\tstatus(): string {"); }); - test("delete of last impl method collapses extra whitespace-only lines before the closing brace", () => { + test("replace with empty content on last impl method collapses extra whitespace-only lines before the closing brace", () => { const source = `impl S { fn a() {} @@ -398,7 +438,7 @@ type Server struct { source, language: "rust", filePath: "/tmp/impl.rs", - operations: [{ op: "delete", sel: "impl_S.fn_b", crc }], + operations: [{ op: "replace", sel: "impl_S.fn_b", crc, content: "" }], }); expect(result.diffSourceAfter).toBe("impl S {\n fn a() {}\n\n}\n"); @@ -425,7 +465,7 @@ describe("edit safety invariants", () => { }; } - for (const operation of ["replace", "delete", "line-scoped replace"] as const) { + for (const operation of ["replace", "replace_empty"] as const) { test(`rejects stale checksum for ${operation} with current and provided checksums in the error`, () => { const { source, staleChecksum } = buildStaleRunFixture(); @@ -443,25 +483,10 @@ describe("edit safety invariants", () => { source, ); } - if (operation === "delete") { - return edit([{ op: "delete", sel: runChunkPath, crc: staleChecksum }], source); - } - return edit( - [ - { - op: "replace", - sel: runChunkPath, - crc: staleChecksum, - line: 7, - endLine: 7, - content: '\t\tconsole.log("again");', - }, - ], - source, - ); + return edit([{ op: "replace", sel: runChunkPath, crc: staleChecksum, content: "" }], source); }; - expect(invoke).toThrow(new RegExp(`did not match checksum "${staleChecksum}"`)); + expect(invoke).toThrow(new RegExp(`got "${staleChecksum}"`)); }); } @@ -506,41 +531,34 @@ describe("edit safety invariants", () => { expect(result.diffSourceAfter).not.toContain('console.log("first")'); }); - test("auto-accepts stale CRC for a second same-path splice in one batch", () => { + test("auto-accepts stale CRC when a second whole-chunk replace refines the same method", () => { const checksum = getChecksum(testSource, runChunkPath); const result = edit([ { op: "replace", sel: runChunkPath, crc: checksum, - line: 6, - endLine: 6, - content: '\trun(task = "default"): void {', + content: '\trun(task = "default"): void {\n\t\tconsole.log(this.name);\n\t}', }, { op: "replace", sel: runChunkPath, crc: checksum, - line: 7, - endLine: 7, - content: "\t\tconsole.log(task);", + content: '\trun(task = "default"): void {\n\t\tconsole.log(task);\n\t}', }, ]); expect(result.diffSourceAfter).toContain('run(task = "default")'); expect(result.diffSourceAfter).toContain("console.log(task)"); }); - test("applies two same-path splices in one batch when the second checksum matches the post-first state", () => { + test("second whole-chunk replace uses post-first checksum when refining signature and body", () => { const checksum = getChecksum(testSource, runChunkPath); - // Batch splices are applied bottom-up by absolute file line (higher line first). const afterFirst = edit([ { op: "replace", sel: runChunkPath, crc: checksum, - line: 7, - endLine: 7, - content: "\t\tconsole.log(task);", + content: "\trun(): void {\n\t\tconsole.log(task);\n\t}", }, ]).diffSourceAfter; const checksum2 = getChecksum(afterFirst, runChunkPath); @@ -549,22 +567,18 @@ describe("edit safety invariants", () => { op: "replace", sel: runChunkPath, crc: checksum, - line: 7, - endLine: 7, - content: "\t\tconsole.log(task);", + content: "\trun(): void {\n\t\tconsole.log(task);\n\t}", }, { op: "replace", sel: runChunkPath, crc: checksum2, - line: 6, - endLine: 6, - content: '\trun(task = "default"): void {', + content: '\trun(task = "default"): void {\n\t\tconsole.log(task);\n\t}', }, ]); expect(result.diffSourceAfter).toContain('run(task = "default"): void {'); - expect(result.diffSourceAfter).toContain("\t\tconsole.log(task);"); + expect(result.diffSourceAfter).toContain("\t\tconsole.log(task)"); expect(result.diffSourceAfter).not.toContain("console.log(this.name)"); }); @@ -577,12 +591,13 @@ describe("edit safety invariants", () => { content: '\tstatus(): string {\n\t\treturn "active";\n\t}', }, { - op: "delete", + op: "replace", sel: "class_Worker.fn_run", crc: "ZZZZ", + content: "", }, ]), - ).toThrow(/Edit operation 2\/2 failed.*did not match checksum/s); + ).toThrow(/Edit operation 2\/2 failed.*Checksum mismatch/s); }); test("keeps untouched sibling checksums stable after a nearby edit", () => { @@ -592,9 +607,7 @@ describe("edit safety invariants", () => { op: "replace", sel: runChunkPath, crc: getChecksum(testSource, runChunkPath), - line: 7, - endLine: 7, - content: '\t\tconsole.log("nearby");', + content: '\trun(): void {\n\t\tconsole.log("nearby");\n\t}', }, ]).diffSourceAfter; @@ -996,96 +1009,15 @@ describe("addressable member editing", () => { expect(result.diffSourceAfter).toContain(' Idle = "idle",\n\n Paused = "paused",\n\n Busy = "busy",'); }); - test("delete removes an individually addressable enum variant", () => { + 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 result = edit([{ op: "delete", ...busy }], enumSource); + const result = edit([{ op: "replace", ...busy, content: "" }], enumSource); expect(result.diffSourceAfter).toContain('Idle = "idle"'); expect(result.diffSourceAfter).not.toContain('Busy = "busy"'); }); }); -describe("zero-width splice (line insertion)", () => { - test("zero-width splice inserts before an absolute file line inside the chunk", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - const result = edit([ - { - op: "replace", - ...ac, - line: 7, - endLine: 6, - content: "if (!this.name) return;", - }, - ]); - - expect(result.diffSourceAfter).toContain("\t\tif (!this.name) return;\n\t\tconsole.log(this.name);"); - }); - - test("zero-width splice inserts after an absolute file line inside the chunk", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - const result = edit([ - { - op: "replace", - ...ac, - line: 8, - endLine: 7, - content: "trackRun();", - }, - ]); - - expect(result.diffSourceAfter).toContain("\t\tconsole.log(this.name);\n\t\ttrackRun();\n\t}"); - }); - - test("zero-width splice rejects gaps outside the chunk", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - expect(() => - edit([ - { - op: "replace", - ...ac, - line: 20, - endLine: 19, - content: "noop();", - }, - ]), - ).toThrow(/Invalid zero-width insert L20-L19/); - }); - test("zero-width splice after the chunk preserves the separator gap", () => { - const source = `class Worker {\n\trun(): void {\n\t\twork();\n\t}\n\n\tstop(): void {\n\t\tcleanup();\n\t}\n}\n`; - const checksum = getChecksum(source, "class_Worker.fn_run"); - const inserted = edit( - [ - { - op: "replace", - sel: "class_Worker.fn_run", - crc: checksum, - line: 5, - endLine: 4, - content: "// inserted", - }, - ], - source, - ); - - expect(inserted.diffSourceAfter).toContain("\t}\n\t// inserted\n\n\tstop(): void {"); - - const stopChecksum = getChecksum(inserted.diffSourceAfter, "class_Worker.fn_stop"); - const replaced = edit( - [ - { - op: "replace", - sel: "class_Worker.fn_stop", - crc: stopChecksum, - content: "\tstop(): void {\n\t\tshutdown();\n\t}", - }, - ], - inserted.diffSourceAfter, - ); - - expect(replaced.diffSourceAfter).toContain("\t// inserted\n\n\tstop(): void {"); - }); -}); - describe("Go receiver render ownership", () => { test("omits unrelated top-level siblings from grouped receiver output", () => { const source = `package main\n\ntype Server struct {\n Addr string\n}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n`; @@ -1121,26 +1053,6 @@ describe("blank-line cleanup", () => { } `; - test("splice deletion collapses triple newlines at the edit seam", () => { - const checksum = getChecksum(commentedSource, "class_Worker.fn_restart"); - const result = edit( - [ - { - op: "replace", - sel: "class_Worker.fn_restart", - crc: checksum, - line: 6, - endLine: 6, - content: "", - }, - ], - commentedSource, - ); - - expect(result.diffSourceAfter).toContain("\t}\n\n\trestart(): void {"); - expect(result.diffSourceAfter).not.toContain("\t}\n\n\n\trestart(): void {"); - }); - test("replace with empty content collapses triple newlines at the edit seam", () => { const checksum = getChecksum(commentedSource, "class_Worker.fn_restart"); const result = edit( @@ -1158,133 +1070,12 @@ describe("blank-line cleanup", () => { expect(result.diffSourceAfter).toContain("\t}\n\n}"); expect(result.diffSourceAfter).not.toContain("\t}\n\n\n}"); }); - - test("delete collapses triple newlines at the edit seam", () => { - const checksum = getChecksum(commentedSource, "class_Worker.fn_restart"); - const result = edit( - [ - { - op: "delete", - sel: "class_Worker.fn_restart", - crc: checksum, - }, - ], - commentedSource, - ); - - expect(result.diffSourceAfter).toContain("\t}\n\n}"); - expect(result.diffSourceAfter).not.toContain("\t}\n\n\n}"); - }); }); // ═══════════════════════════════════════════════════════════════════════════ // splice // ═══════════════════════════════════════════════════════════════════════════ -describe("splice", () => { - test("replaces a line subrange within a chunk using absolute file lines", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - // fn_run spans file lines 6-8; line 7 is console.log - const result = edit([ - { - op: "replace", - ...ac, - line: 7, - endLine: 7, - content: '\t\tconsole.log("updated");', - }, - ]); - - expect(result.diffSourceAfter).toContain('"updated"'); - expect(result.diffSourceAfter).not.toContain("console.log(this.name)"); - expect(result.diffSourceAfter).toContain("run(): void {"); - }); - - test("splice reindents zero-indented content against the replaced range", () => { - const source = `impl Widget {\n fn old(&self) -> bool {\n true\n }\n}\n`; - const checksum = getChecksum(source, "impl_Widget.fn_old", "rust"); - const result = applyEdit({ - source, - language: "rust", - filePath: "/tmp/widget.rs", - operations: [ - { - op: "replace", - sel: "impl_Widget.fn_old", - crc: checksum, - line: 3, - endLine: 4, - content: " false\n}", - }, - ], - }); - - expect(result.diffSourceAfter).toContain(" fn old(&self) -> bool {\n false\n }\n"); - expect(result.diffSourceAfter).not.toContain(" false"); - }); - - test("splice with wrong checksum throws", () => { - expect(() => - edit([ - { - op: "replace", - sel: "class_Worker.fn_run", - crc: "ZZZZ", - line: 7, - endLine: 7, - content: "replacement", - }, - ]), - ).toThrow(/did not match checksum/); - }); - - test("splice rejects reversed ranges that are not zero-width gaps", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - expect(() => - edit([ - { - op: "replace", - ...ac, - line: 4, - endLine: 2, - content: "replacement", - }, - ]), - ).toThrow(/Invalid line range L4-L2/); - }); - - test("splice rejects line 0 before edit application", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - expect(() => - edit([ - { - op: "replace", - ...ac, - line: 0, - endLine: 1, - content: "replacement", - }, - ]), - ).toThrow(/Line 0 is invalid/); - }); - - test("splice with out-of-range lines throws", () => { - const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; - // fn_run spans file lines 6-8; L1-L5 does not overlap - expect(() => - edit([ - { - op: "replace", - ...ac, - line: 1, - endLine: 5, - content: "replacement", - }, - ]), - ).toThrow(/outside/i); - }); -}); - describe("prepend_child warnings", () => { test("warns when comment-only prepend_child may merge into the next chunk", () => { const source = `package main\n\nimport "fmt"\n`; @@ -1304,31 +1095,51 @@ describe("prepend_child warnings", () => { }); }); -describe("chunk selector validation for edits", () => { - test("rejects suffix-only edit selectors", () => { - expect(() => - edit([ - { - op: "replace", - sel: "fn_run", - crc: getChecksum(testSource, "class_Worker.fn_run"), - content: `run(): void {\n\tconsole.log(this.name);\n}`, - }, - ]), - ).toThrow(/Chunk path not found: "fn_run"/); +describe("chunk selector auto-resolution", () => { + test("warns on suffix auto-resolution", () => { + const result = edit([ + { + op: "replace", + sel: "fn_run", + crc: getChecksum(testSource, "class_Worker.fn_run"), + content: "run(): void {\n\tconsole.log(this.name);\n}", + }, + ]); + expect( + result.warnings.some(w => w.includes('Auto-resolved chunk selector "fn_run" to "class_Worker.fn_run"')), + ).toBe(true); }); - test("rejects prefix-stripped edit selectors", () => { + test("warns on prefix auto-resolution", () => { + const result = edit([ + { + op: "replace", + sel: "run", + crc: getChecksum(testSource, "class_Worker.fn_run"), + content: "run(): void {\n\tconsole.log(this.name);\n}", + }, + ]); + expect(result.warnings.some(w => w.includes('Auto-resolved chunk selector "run" to "class_Worker.fn_run"'))).toBe( + true, + ); + }); + + test("errors on ambiguous suffix matches", () => { + const source = `class Foo {\n\trun(): void {}\n}\nclass Bar {\n\trun(): void {}\n}\n`; expect(() => - edit([ - { - op: "replace", - sel: "run", - crc: getChecksum(testSource, "class_Worker.fn_run"), - content: `run(): void {\n\tconsole.log(this.name);\n}`, - }, - ]), - ).toThrow(/Chunk path not found: "run"/); + applyEdit({ + source, + language: "typescript", + operations: [ + { + op: "replace", + sel: "fn_run", + crc: getChecksum(source, "class_Foo.fn_run"), + content: "", + }, + ], + }), + ).toThrow(/Ambiguous chunk selector "fn_run" matches 2 chunks/); }); }); @@ -1396,8 +1207,10 @@ describe("tlaplus chunk rendering", () => { }); expect(result.diffSourceAfter).toContain("Start == x = 0"); - expect(result.responseText).toContain("[mod_Spec.translation_12#"); - expect(result.responseText).toContain("\\* [translation hidden]"); + // The scoped response tree includes the touched chunk and adjacent siblings, + // but not distant ones like translation_12. The translation content must + // still be hidden from any chunk that IS visible. + expect(result.responseText).toContain("[mod_Spec.operator_Start#"); expect(result.responseText).not.toContain("Next == pc' = pc"); }); }); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 311ed743d..cc0c89d29 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -11,8 +11,8 @@ import { streamHashLinesFromUtf8, stripNewLinePrefixes, validateLineRef, -} from "@oh-my-pi/pi-coding-agent/patch"; -import { type Anchor, formatLineTag, type HashlineEdit } from "@oh-my-pi/pi-coding-agent/patch/hashline"; +} from "@oh-my-pi/pi-coding-agent/edit"; +import { type Anchor, formatLineTag, type HashlineEdit } from "@oh-my-pi/pi-coding-agent/edit/modes/hashline"; function makeTag(line: number, content: string): Anchor { return parseTag(formatLineTag(line, content)); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 7f5405576..a2e4aba8e 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -7,7 +7,7 @@ import { computeHashlineDiff, DEFAULT_FUZZY_THRESHOLD, findMatch, -} from "@oh-my-pi/pi-coding-agent/patch"; +} from "@oh-my-pi/pi-coding-agent/edit"; describe("findMatch", () => { describe("exact matching", () => { diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index f14c190f9..c59a70db5 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -6,7 +6,7 @@ import * as url from "node:url"; import * as zlib from "node:zlib"; import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { DEFAULT_BASH_INTERCEPTOR_RULES, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; +import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; diff --git a/packages/coding-agent/test/tools/chunk-mode.test.ts b/packages/coding-agent/test/tools/chunk-mode.test.ts index c52d63436..e2c27d279 100644 --- a/packages/coding-agent/test/tools/chunk-mode.test.ts +++ b/packages/coding-agent/test/tools/chunk-mode.test.ts @@ -3,15 +3,15 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; +import { HASHLINE_NIBBLE_ALPHABET } from "@oh-my-pi/pi-coding-agent/edit/modes/hashline"; import { getLanguageFromPath } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; -import { HASHLINE_NIBBLE_ALPHABET } from "@oh-my-pi/pi-coding-agent/patch/hashline"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { GrepTool } from "@oh-my-pi/pi-coding-agent/tools/grep"; import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; import { resolveFileDisplayMode } from "@oh-my-pi/pi-coding-agent/utils/file-display-mode"; import { ChunkReadStatus, ChunkState } from "@oh-my-pi/pi-natives"; -import { applyChunkEdits } from "../../src/tools/chunk-tree"; +import { applyChunkEdits } from "../../src/edit/modes/chunk"; function getText(result: { content: Array<{ type: string; text?: string }> }): string { return result.content @@ -43,6 +43,17 @@ function buildLargeTypescriptFixture(): string { return `class Server {\n private handleError(err: Error): string {\n let total = 0;\n${body}\n return err.message + total;\n }\n}\n\nfunction main(): void {\n console.log("boot");\n}\n`; } +function buildHandleErrorMethod(options: { totalInitLine?: string; returnLine?: string } = {}): string { + const body = Array.from({ length: 60 }, (_, index) => ` total += ${index};`).join("\n"); + const totalInit = options.totalInitLine ?? " let total = 0;"; + const ret = options.returnLine ?? " return err.message + total;"; + return ` private handleError(err: Error): string { +${totalInit} +${body} +${ret} + }`; +} + describe("chunk mode tools", () => { let tmpDir: string; let originalEditVariant: string | undefined; @@ -242,9 +253,7 @@ describe("chunk mode tools", () => { op: "replace", sel: chunkPath, crc: checksum, - line: 63, - endLine: 63, - content: "return err.message.toUpperCase() + total;", + content: buildHandleErrorMethod({ returnLine: " return err.message.toUpperCase() + total;" }), }, ], }).diffSourceAfter; @@ -255,11 +264,15 @@ describe("chunk mode tools", () => { edits: [ { target: `${chunkPath}#${checksum}`, - line: 63, - end_line: 63, - content: "return err.message.toUpperCase() + total;", + content: buildHandleErrorMethod({ returnLine: " return err.message.toUpperCase() + total;" }), + }, + { + target: `${chunkPath}#${checksum2}`, + content: buildHandleErrorMethod({ + totalInitLine: " let total = 1;", + returnLine: " return err.message.toUpperCase() + total;", + }), }, - { target: `${chunkPath}#${checksum2}`, line: 3, end_line: 3, content: "let total = 1;" }, ], } as never); @@ -304,48 +317,6 @@ describe("chunk mode tools", () => { expect(updatedSource).toContain('return "ok";'); }); - it("supports explicit absolute-line zero-width splices", async () => { - const filePath = path.join(tmpDir, "server.ts"); - const originalSource = buildLargeTypescriptFixture(); - await Bun.write(filePath, originalSource); - const session = createSession(tmpDir); - const editTool = new EditTool(session); - const chunkPath = "class_Server.fn_handleError"; - const fileText = await Bun.file(filePath).text(); - const checksum = getChunkChecksum(fileText, "typescript", chunkPath); - const afterInsertAfter = applyChunkEdits({ - source: fileText, - language: "typescript", - cwd: tmpDir, - filePath, - operations: [ - { - op: "replace", - sel: chunkPath, - crc: checksum, - line: 4, - endLine: 3, - content: "const end = Date.now();", - }, - ], - }).diffSourceAfter; - const checksum2 = getChunkChecksum(afterInsertAfter, "typescript", chunkPath); - - // Zero-width splices at larger `end` run first (bottom-up) so line numbers stay stable. - await editTool.execute("chunk-edit-insert-lines", { - path: filePath, - edits: [ - { target: `${chunkPath}#${checksum}`, line: 4, end_line: 3, content: "const end = Date.now();" }, - { target: `${chunkPath}#${checksum2}`, line: 3, end_line: 2, content: "const start = Date.now();" }, - ], - } as never); - - const updatedSource = await Bun.file(filePath).text(); - expect(updatedSource).toContain(" const start = Date.now();"); - expect(updatedSource).toContain(" const end = Date.now();"); - expect(updatedSource).toContain(" let total = 0;"); - }); - it("treats empty replace content as delete", async () => { const filePath = path.join(tmpDir, "server.ts"); const originalSource = buildLargeTypescriptFixture(); @@ -379,31 +350,6 @@ describe("chunk mode tools", () => { }); }); - it("rejects reversed splice ranges without changing the file", async () => { - const filePath = path.join(tmpDir, "server.ts"); - const originalSource = buildLargeTypescriptFixture(); - await Bun.write(filePath, originalSource); - const session = createSession(tmpDir); - const editTool = new EditTool(session); - const checksum = getChunkChecksum(originalSource, "typescript", "class_Server.fn_handleError"); - - await expect( - editTool.execute("chunk-edit-invalid-splice-range", { - path: filePath, - edits: [ - { - target: `class_Server.fn_handleError#${checksum}`, - line: 5, - end_line: 2, - content: " let total = 1;", - }, - ], - }), - ).rejects.toThrow(/Invalid line range L5-L2/); - - expect(await Bun.file(filePath).text()).toBe(originalSource); - }); - it("rolls back mixed-validity batches without changing the file", async () => { const filePath = path.join(tmpDir, "server.ts"); const originalSource = buildLargeTypescriptFixture(); @@ -416,7 +362,7 @@ describe("chunk mode tools", () => { path: filePath, edits: [ { target: "class_Server", op: "append", content: ' status(): string {\n return "ok";\n }' }, - { target: "class_Server.fn_handleError#ZZZZ", op: "delete" }, + { target: "class_Server.fn_handleError#ZZZZ", content: "" }, ], }), ).rejects.toThrow(/No changes were saved/); @@ -442,7 +388,7 @@ describe("chunk mode tools", () => { }, ], }), - ).rejects.toThrow(/Parse errors:[\s\S]*L\d+:C\d+/i); + ).rejects.toThrow(/Parse errors:[\s\S]*L\d+-L\d+.*parse error introduced/i); expect(await Bun.file(filePath).text()).toBe(originalSource); }); @@ -464,9 +410,7 @@ describe("chunk mode tools", () => { }, { target: `class_Server.fn_handleError#${checksum}`, - line: 3, - end_line: 3, - content: " return err.message.toUpperCase();", + content: " private handleError(err: Error): string {\n return err.message.toUpperCase();\n }", }, ], }); @@ -488,9 +432,7 @@ describe("chunk mode tools", () => { edits: [ { target: "class_Server.fn_handleError", - line: 3, - end_line: 3, - content: " let total = 1;", + content: buildHandleErrorMethod({ totalInitLine: " let total = 1;" }), }, ], }), @@ -498,7 +440,7 @@ describe("chunk mode tools", () => { expect(await Bun.file(filePath).text()).toBe(originalSource); }); - it("rejects non-canonical edit selectors", async () => { + it("auto-resolves chunk selectors with missing name prefixes", async () => { const filePath = path.join(tmpDir, "server.ts"); const originalSource = buildLargeTypescriptFixture(); await Bun.write(filePath, originalSource); @@ -506,49 +448,14 @@ describe("chunk mode tools", () => { const editTool = new EditTool(session); const checksum = getChunkChecksum(originalSource, "typescript", "fn_main"); - await expect( - editTool.execute("chunk-edit-prefix-resolve", { - path: filePath, - edits: [{ target: `main#${checksum}`, content: 'function main(): void {\n console.log("started");\n}\n' }], - }), - ).rejects.toThrow(/Chunk path not found: "main"/); - expect(await Bun.file(filePath).text()).toBe(originalSource); - }); - - it("resolves root-only checksum targets to the file root", async () => { - const filePath = path.join(tmpDir, "CONTRIBUTING.md"); - const source = `# Contributing to uLua - -## Building and Testing - -Use just. - -## Code Style - -Use clang-format. -`; - await Bun.write(filePath, source); - const session = createSession(tmpDir); - const editTool = new EditTool(session); - const language = getLanguageFromPath(filePath); - if (!language) { - throw new Error("expected markdown language"); - } - const state = ChunkState.parse(source, language); - const root = state.root(); - if (!root) { - throw new Error("expected root chunk"); - } - expect(state.chunks().filter(chunk => chunk.checksum === root.checksum).length).toBeGreaterThan(1); - - const _result = await editTool.execute("chunk-edit-root-checksum", { + // Use bare "main" instead of "fn_main" + const _result = await editTool.execute("chunk-edit-prefix-resolve", { path: filePath, - edits: [{ target: `#${root.checksum}`, line: 1, end_line: 1, content: "# Updated guide" }], + edits: [{ target: `main#${checksum}`, content: 'function main(): void {\n console.log("started");\n}\n' }], }); - const updatedSource = await Bun.file(filePath).text(); - expect(updatedSource.startsWith("# Updated guide\n")).toBe(true); - expect(updatedSource).toContain("## Building and Testing"); + expect(updatedSource).toContain('console.log("started")'); + expect(updatedSource).not.toContain('console.log("boot")'); }); it("preserves sibling headings when replacing a whole markdown section", async () => { @@ -602,4 +509,55 @@ Use clang-format. expect(updatedSource).toContain("Use `just verify`"); expect(updatedSource).not.toContain("cmake -S . -B build"); }); + + it("reads a Jupyter notebook as cell-based chunks in chunk mode", async () => { + const filePath = path.join(tmpDir, "analysis.ipynb"); + const notebook = JSON.stringify( + { + cells: [ + { + cell_type: "code", + source: ["def greet(name):\n", " return f'Hello {name}'\n"], + metadata: {}, + outputs: [], + execution_count: 1, + }, + { + cell_type: "markdown", + source: ["# Results\n", "Below are the results.\n"], + metadata: {}, + }, + { + cell_type: "code", + source: ["class Model:\n", " def train(self):\n", " pass\n"], + metadata: {}, + outputs: [], + execution_count: null, + }, + ], + metadata: { kernelspec: { language: "python" } }, + nbformat: 4, + nbformat_minor: 5, + }, + null, + " ", + ); + await Bun.write(filePath, notebook); + + // Read the notebook in chunk mode + const readTool = new ReadTool(createSession(tmpDir)); + const result = await readTool.execute("read-ipynb", { path: filePath }); + const text = getText(result); + + // Should show cell-level anchors + expect(text).toContain("cell_1"); + expect(text).toContain("cell_2"); + expect(text).toContain("cell_3"); + // Should show sub-chunks within code cells + expect(text).toContain("cell_1.fn_greet"); + expect(text).toContain("cell_3.class_Model"); + // Should show cell content + expect(text).toContain("def greet(name):"); + expect(text).toContain("# Results"); + }); }); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index a6d045815..d41586d24 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Moved package entry point from `src/index.ts` to `native/index.js` — consumers must update imports to use the new native module path @@ -9,6 +10,10 @@ ### Added +- 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 +- Added `focusedPaths` parameter to `RenderParams` to restrict rendering to specified chunks with their focus modes - Generated native module bindings in `native/index.js` and `native/index.d.ts` from napi-rs build output - Added `gen-enums.ts` script to extract and export runtime enum values from TypeScript const enums - Added `embedded-addon.js` for managing embedded native addon variants and metadata @@ -16,6 +21,9 @@ ### Changed +- 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 - Simplified native build pipeline by removing `--dev` flag support; debug builds no longer available through npm scripts - Updated native module loader to check `XDG_DATA_HOME` environment variable for native addon location before falling back to `~/.omp/natives` - Removed native binding validation function that checked for required exports at load time diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index c062dfbe6..df705df85 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -381,7 +381,7 @@ export declare enum ChunkAnchorStyle { /** Structural edit to apply relative to a chunk anchor. */ export declare enum ChunkEditOp { - /** Replace the chunk body, or a line range when `line`/`endLine` are set. */ + /** Replace the chunk body, or a substring via `find`. */ Replace = 'replace', /** Remove the chunk's source range. */ Delete = 'delete', @@ -392,7 +392,25 @@ export declare enum ChunkEditOp { /** Insert `content` after the target chunk's source range. */ AppendSibling = 'append_sibling', /** Insert `content` before the target chunk's source range. */ - PrependSibling = 'prepend_sibling' + PrependSibling = 'prepend_sibling', + /** + * Replace only the inner body of the chunk, preserving signature and + * closing delimiter. + */ + ReplaceBody = 'replace_body' +} + +/** How a chunk participates in a focus-scoped render pass. */ +export declare enum ChunkFocusMode { + /** Emit full content and recurse normally. */ + Expanded = 'expanded', + /** Emit just the opening anchor; do not recurse or emit body. */ + Collapsed = 'collapsed', + /** + * Emit opening + closing anchors; recurse into focused children only. + * Interior gap lines between children are suppressed. + */ + Container = 'container' } /** Summary of a single chunk node for tool output and navigation. */ @@ -479,18 +497,16 @@ export interface EditOperation { crc?: string /** Replacement or inserted text (meaning depends on `op`). */ content?: string - /** For line-scoped `replace`, 1-based start line inside the target chunk. */ - line?: number /** - * For line-scoped `replace`, 1-based end line inside the chunk (defaults to - * `line`). + * For scoped find/replace: literal substring to locate inside the target + * chunk. Must match exactly once. Pairs with `content` as the replacement. */ - endLine?: number + find?: string } /** Arguments for applying a batch of chunk edits to a file. */ export interface EditParams { - /** Edits to apply in order (scheduling may reorder line-scoped groups). */ + /** Edits to apply in order. */ operations: Array /** Default chunk selector when an `EditOperation` omits `sel`. */ defaultSelector?: string @@ -587,6 +603,12 @@ export declare enum FileType { Symlink = 3 } +/** Path + focus mode pair for the N-API boundary (`HashMap` doesn't cross FFI). */ +export interface FocusedPath { + path: string + mode: ChunkFocusMode +} + /** * Format one chunk anchor string for a node at `depth` using `style` and * optional checksum omission. @@ -1145,6 +1167,11 @@ export interface RenderParams { showLeafPreview: boolean /** Replace tab characters in displayed previews (e.g. two spaces). */ tabReplacement?: string + /** + * When set, restrict rendering to these chunks with their specified focus + * modes. Everything not in this list is skipped. + */ + focusedPaths?: Array } /** Sampling filter for resize operations. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 28b473109..e439cb85c 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -248,6 +248,12 @@ exports.ChunkEditOp = { PrependChild: 'prepend_child', AppendSibling: 'append_sibling', PrependSibling: 'prepend_sibling', + ReplaceBody: 'replace_body', +}; +exports.ChunkFocusMode = { + Expanded: 'expanded', + Collapsed: 'collapsed', + Container: 'container', }; exports.ChunkReadStatus = { Ok: 'ok',