b60c0cc3ce
- Extracted tab width resolution from pi-natives to pi-utils with EditorConfig caching support. - Made tabWidth parameter required in native text functions (visibleWidth, truncateToWidth, wrapTextWithAnsi, sliceWithWidth, extractSegments). - Removed process-wide tab width state management from pi-natives text module. - Consolidated wrapper node promotion logic in chunk classification with abbreviated string representations. - Removed path-clean and pathdiff dependencies from pi-natives. - Updated all consuming packages to import tab width functions from pi-utils and pass explicit tabWidth parameters.
3959 lines
118 KiB
Rust
3959 lines
118 KiB
Rust
use std::{collections::HashMap, path::Path};
|
|
|
|
use crate::chunk::{
|
|
indent::{
|
|
dedent_python_style, denormalize_from_tabs, detect_file_indent_char, detect_file_indent_step,
|
|
indent_non_empty_lines, normalize_leading_whitespace_char, normalize_to_tabs,
|
|
reindent_inserted_block, strip_content_prefixes,
|
|
},
|
|
kind::ChunkKind,
|
|
resolve::{
|
|
ParsedSelector, chunk_region_range, resolve_chunk_selector, resolve_chunk_with_crc,
|
|
sanitize_chunk_selector, sanitize_crc, split_selector_crc_and_region,
|
|
},
|
|
state::{ChunkState, ChunkStateInner, ConflictMeta},
|
|
types::{
|
|
ChunkAnchorStyle, ChunkEditOp, ChunkFocusMode, ChunkNode, ChunkRegion, EditOperation,
|
|
EditParams, EditResult, FocusedPath, RenderParams,
|
|
},
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
struct ScheduledEditOperation {
|
|
operation: EditOperation,
|
|
original_index: usize,
|
|
requested_selector: Option<String>,
|
|
initial_chunk: Option<ChunkNode>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum InsertPosition {
|
|
Before,
|
|
After,
|
|
FirstChild,
|
|
LastChild,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct InsertSpacing {
|
|
blank_line_before: bool,
|
|
blank_line_after: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct InsertionPoint {
|
|
offset: usize,
|
|
indent: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ResolvedEditTarget {
|
|
chunk: ChunkNode,
|
|
region: Option<ChunkRegion>,
|
|
}
|
|
|
|
const NORMALIZED_TAB_REPLACEMENT: &str = " ";
|
|
const PRESERVED_TAB_REPLACEMENT: &str = "\t";
|
|
|
|
pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result<EditResult, String> {
|
|
let original_text = normalize_chunk_source(state.inner().source());
|
|
let initial_notebook_ctx = state.inner().notebook.clone();
|
|
let initial_conflict_meta = state.inner().conflict_meta.clone();
|
|
let mut state = rebuild_chunk_state(
|
|
original_text.clone(),
|
|
state.inner().language().to_string(),
|
|
initial_notebook_ctx.clone(),
|
|
initial_conflict_meta.clone(),
|
|
)?;
|
|
let file_indent_step = detect_file_indent_step(&state.source, &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<String> =
|
|
state.tree.chunks.iter().map(|c| c.path.clone()).collect();
|
|
let normalize_indent = params.normalize_indent.unwrap_or(true);
|
|
let mut touched_paths = Vec::new();
|
|
let mut warnings = Vec::new();
|
|
let mut last_scheduled: Option<ScheduledEditOperation> = None;
|
|
let initial_default_selector = params.default_selector.clone();
|
|
let initial_default_crc = params.default_crc.clone();
|
|
|
|
let mut scheduled_ops = Vec::with_capacity(params.operations.len());
|
|
for (original_index, operation) in params.operations.iter().cloned().enumerate() {
|
|
let selector = operation
|
|
.sel
|
|
.as_deref()
|
|
.or(initial_default_selector.as_deref());
|
|
let requested_selector = sanitize_chunk_selector(selector);
|
|
let initial_chunk = resolve_chunk_selector(&state, selector, &mut warnings)
|
|
.ok()
|
|
.cloned();
|
|
scheduled_ops.push(ScheduledEditOperation {
|
|
operation,
|
|
original_index,
|
|
requested_selector,
|
|
initial_chunk,
|
|
});
|
|
}
|
|
|
|
let execution_ops = scheduled_ops;
|
|
let current_default_selector = initial_default_selector.as_deref();
|
|
let mut current_default_crc = initial_default_crc;
|
|
let total_ops = params.operations.len();
|
|
|
|
for scheduled in execution_ops {
|
|
last_scheduled = Some(scheduled.clone());
|
|
let operation = normalize_operation_literals(&scheduled.operation);
|
|
let result = match operation.op {
|
|
ChunkEditOp::Replace => apply_replace(
|
|
&mut state,
|
|
&operation,
|
|
&scheduled,
|
|
current_default_selector,
|
|
current_default_crc.as_deref(),
|
|
file_indent_step,
|
|
file_indent_char,
|
|
normalize_indent,
|
|
&mut touched_paths,
|
|
&mut warnings,
|
|
),
|
|
ChunkEditOp::Delete => apply_delete(
|
|
&mut state,
|
|
&operation,
|
|
&scheduled,
|
|
current_default_selector,
|
|
current_default_crc.as_deref(),
|
|
&mut touched_paths,
|
|
&mut warnings,
|
|
),
|
|
ChunkEditOp::Before | ChunkEditOp::After | ChunkEditOp::Prepend | ChunkEditOp::Append => {
|
|
apply_insert(
|
|
&mut state,
|
|
&operation,
|
|
&scheduled,
|
|
current_default_selector,
|
|
current_default_crc.as_deref(),
|
|
file_indent_step,
|
|
file_indent_char,
|
|
normalize_indent,
|
|
&mut touched_paths,
|
|
&mut warnings,
|
|
)
|
|
},
|
|
};
|
|
|
|
if let Err(err) = result {
|
|
let display_path = display_path_for_file(¶ms.file_path, ¶ms.cwd);
|
|
let sel = operation.sel.as_deref().or(current_default_selector);
|
|
let context =
|
|
render_error_context(&state, sel, &display_path, params.anchor_style, normalize_indent);
|
|
return Err(format!(
|
|
"Edit operation {}/{} failed ({}): {}\nNo changes were saved. Fix the failing \
|
|
operation and retry the entire batch.{context}",
|
|
scheduled.original_index + 1,
|
|
total_ops,
|
|
describe_scheduled_operation(&scheduled),
|
|
err,
|
|
));
|
|
}
|
|
|
|
state = rebuild_chunk_state(
|
|
state.source.clone(),
|
|
state.language.clone(),
|
|
state.notebook.clone(),
|
|
state.conflict_meta.clone(),
|
|
)?;
|
|
if operation.sel.is_none() {
|
|
current_default_crc = None;
|
|
}
|
|
}
|
|
|
|
let parse_valid = state.tree.parse_errors <= initial_parse_errors;
|
|
if !parse_valid && initial_parse_errors == 0 {
|
|
let error_summaries = format_parse_error_summaries(&state);
|
|
let fallback_summary = if error_summaries.is_empty() {
|
|
if let Some(scheduled) = last_scheduled.as_ref() {
|
|
let chunk_label = scheduled
|
|
.initial_chunk
|
|
.as_ref()
|
|
.map(|c| c.path.as_str())
|
|
.or(scheduled.requested_selector.as_deref())
|
|
.unwrap_or("<unknown chunk>");
|
|
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()
|
|
}
|
|
} else {
|
|
error_summaries
|
|
};
|
|
let details = if fallback_summary.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
"\nParse errors:\n{}",
|
|
fallback_summary
|
|
.into_iter()
|
|
.map(|summary| format!("- {summary}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
};
|
|
let display_path = display_path_for_file(¶ms.file_path, ¶ms.cwd);
|
|
let sel = last_scheduled
|
|
.as_ref()
|
|
.and_then(|s| s.operation.sel.as_deref())
|
|
.or(initial_default_selector.as_deref());
|
|
let context =
|
|
render_error_context(&state, sel, &display_path, params.anchor_style, normalize_indent);
|
|
return Err(format!(
|
|
"Edit rejected: introduced {} parse error(s). The file was valid before the edit but is \
|
|
not after. Fix the content and retry.{details}{context}",
|
|
state.tree.parse_errors,
|
|
));
|
|
}
|
|
if !parse_valid {
|
|
warnings.push(format!(
|
|
"Edit introduced {} new parse error(s).",
|
|
state.tree.parse_errors.saturating_sub(initial_parse_errors)
|
|
));
|
|
}
|
|
|
|
let display_path = display_path_for_file(¶ms.file_path, ¶ms.cwd);
|
|
let changed_virtual = original_text != state.source;
|
|
|
|
// For notebooks, translate the virtual source back to JSON so the
|
|
// caller sees the actual ipynb file content in `diff_before`/`diff_after`.
|
|
// `initial_notebook_ctx` is the context captured at the very start of
|
|
// this call; it holds the pre-edit cell metadata. We use it to stamp
|
|
// the original JSON for `diff_before` and to produce the new JSON from
|
|
// the mutated virtual source for `diff_after`.
|
|
let (diff_before, diff_after) = if let Some(initial_ctx) = initial_notebook_ctx.as_ref() {
|
|
let before_json = crate::chunk::ast_ipynb::notebook_to_json(&original_text, initial_ctx)
|
|
.map_err(|err| format!("Failed to reconstruct pre-edit notebook JSON: {err}"))?;
|
|
let after_json = crate::chunk::ast_ipynb::notebook_to_json(&state.source, initial_ctx)
|
|
.map_err(|err| format!("Failed to serialize edited notebook JSON: {err}"))?;
|
|
(before_json, after_json)
|
|
} else {
|
|
let diff_before = if initial_conflict_meta.is_empty() {
|
|
original_text
|
|
} else {
|
|
crate::chunk::conflict::reconstruct_markers(&original_text, &initial_conflict_meta)
|
|
};
|
|
let diff_after = if state.conflict_meta.is_empty() {
|
|
state.source.clone()
|
|
} else {
|
|
crate::chunk::conflict::reconstruct_markers(&state.source, &state.conflict_meta)
|
|
};
|
|
(diff_before, diff_after)
|
|
};
|
|
let changed = diff_before != diff_after || changed_virtual;
|
|
if !state.conflict_meta.is_empty() {
|
|
let mut unresolved = state.conflict_meta.keys().cloned().collect::<Vec<_>>();
|
|
unresolved.sort();
|
|
warnings.push(format!(
|
|
"NOTICE: This file still has unresolved conflicts: {}.",
|
|
unresolved.join(", ")
|
|
));
|
|
}
|
|
// Newly-created chunks (e.g. inserted siblings that landed outside the anchor's
|
|
// parent subtree) are not reflected in `touched_paths` yet. Detect any chunk
|
|
// that did not exist in the pre-edit tree and include it so the scoped
|
|
// response tree actually shows the inserted content.
|
|
for chunk in &state.tree.chunks {
|
|
if !initial_chunk_paths.contains(&chunk.path) && !touched_paths.contains(&chunk.path) {
|
|
touched_paths.push(chunk.path.clone());
|
|
}
|
|
}
|
|
|
|
let response_text = if changed {
|
|
render_changed_hunks(
|
|
&state,
|
|
&display_path,
|
|
&diff_before,
|
|
&diff_after,
|
|
params.anchor_style,
|
|
&touched_paths,
|
|
normalize_indent,
|
|
)
|
|
} else {
|
|
render_unchanged_response(&state, &display_path, params.anchor_style, normalize_indent)
|
|
};
|
|
|
|
Ok(EditResult {
|
|
state: ChunkState::from_inner(state),
|
|
diff_before,
|
|
diff_after,
|
|
response_text,
|
|
changed,
|
|
parse_valid,
|
|
touched_paths,
|
|
warnings,
|
|
})
|
|
}
|
|
|
|
fn resolve_edit_target(
|
|
state: &ChunkStateInner,
|
|
operation: &EditOperation,
|
|
scheduled: &ScheduledEditOperation,
|
|
default_selector: Option<&str>,
|
|
default_crc: Option<&str>,
|
|
requires_checksum: bool,
|
|
touched_paths: &[String],
|
|
warnings: &mut Vec<String>,
|
|
) -> Result<ResolvedEditTarget, String> {
|
|
let 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 ParsedSelector { selector: cleaned_selector, crc: cleaned_crc, region: parsed_region } =
|
|
split_selector_crc_and_region(selector, crc, operation.region)?;
|
|
let batch_auto_accepted =
|
|
ensure_batch_operation_target_current(scheduled, cleaned_crc.as_deref(), touched_paths);
|
|
let resolve_crc = if batch_auto_accepted {
|
|
None
|
|
} else {
|
|
cleaned_crc.as_deref()
|
|
};
|
|
let resolved =
|
|
resolve_chunk_with_crc(state, cleaned_selector.as_deref(), resolve_crc, warnings)?;
|
|
let mut region = operation.region.or(parsed_region);
|
|
if !batch_auto_accepted {
|
|
validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?;
|
|
}
|
|
let chunk = resolved.chunk.clone();
|
|
if chunk.prologue_end_byte.is_none() || chunk.epilogue_start_byte.is_none() {
|
|
// @decl only depends on checksum_start_byte, not prologue/epilogue.
|
|
if region != Some(ChunkRegion::Decl) {
|
|
region = None;
|
|
}
|
|
}
|
|
|
|
Ok(ResolvedEditTarget { chunk, region })
|
|
}
|
|
|
|
fn apply_replace(
|
|
state: &mut ChunkStateInner,
|
|
operation: &EditOperation,
|
|
scheduled: &ScheduledEditOperation,
|
|
default_selector: Option<&str>,
|
|
default_crc: Option<&str>,
|
|
file_indent_step: usize,
|
|
file_indent_char: char,
|
|
normalize_indent: bool,
|
|
touched_paths: &mut Vec<String>,
|
|
warnings: &mut Vec<String>,
|
|
) -> Result<(), String> {
|
|
let target = resolve_edit_target(
|
|
state,
|
|
operation,
|
|
scheduled,
|
|
default_selector,
|
|
default_crc,
|
|
true,
|
|
touched_paths.as_slice(),
|
|
warnings,
|
|
)?;
|
|
let anchor = target.chunk;
|
|
if anchor.kind == ChunkKind::Theirs {
|
|
return Err(
|
|
"Virtual conflict branches cannot be replaced directly. Delete conflict.theirs to accept \
|
|
ours, delete conflict.ours to accept theirs, or replace the parent conflict chunk for a \
|
|
manual merge."
|
|
.to_owned(),
|
|
);
|
|
}
|
|
|
|
let (mut region_start, region_end) = match target.region {
|
|
None => (anchor.start_byte as usize, anchor.end_byte as usize),
|
|
Some(r) => chunk_region_range(&anchor, r),
|
|
};
|
|
if matches!(target.region, Some(ChunkRegion::Head)) {
|
|
region_start =
|
|
line_start_offset(&line_offsets(&state.source), anchor.start_line, &state.source);
|
|
}
|
|
|
|
// 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_source = &state.source[region_start..region_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
|
|
));
|
|
};
|
|
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 = region_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..]);
|
|
replace_source_and_adjust_conflicts(state, new_source, warnings);
|
|
touched_paths.push(anchor.path);
|
|
return Ok(());
|
|
}
|
|
|
|
let target_indent =
|
|
target_indent_for_region(state, &anchor, target.region, file_indent_char, file_indent_step);
|
|
|
|
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,
|
|
normalize_indent,
|
|
);
|
|
if target.region.is_none() {
|
|
if !replacement.is_empty()
|
|
&& !replacement.ends_with('\n')
|
|
&& anchor.end_line < state.tree.line_count
|
|
{
|
|
replacement.push('\n');
|
|
}
|
|
// If the chunk's range included a trailing blank line (common in
|
|
// markdown lists/paragraphs), preserve it so the replacement doesn't
|
|
// collapse into the next structural element.
|
|
let offsets = line_offsets(&state.source);
|
|
let last_line_text = state
|
|
.source
|
|
.split('\n')
|
|
.nth(anchor.end_line.saturating_sub(1) as usize)
|
|
.unwrap_or("");
|
|
if last_line_text.trim().is_empty()
|
|
&& !replacement.is_empty()
|
|
&& !replacement.ends_with("\n\n")
|
|
{
|
|
if !replacement.ends_with('\n') {
|
|
replacement.push('\n');
|
|
}
|
|
replacement.push('\n');
|
|
}
|
|
let range_start = line_start_offset(&offsets, anchor.start_line, &state.source);
|
|
let mut new_source =
|
|
replace_range_by_lines(&state.source, anchor.start_line, anchor.end_line, &replacement);
|
|
if replacement.is_empty() {
|
|
new_source = cleanup_blank_line_artifacts_at_offset(&new_source, range_start);
|
|
}
|
|
if anchor.kind == ChunkKind::Conflict {
|
|
state.conflict_meta.remove(anchor.path.as_str());
|
|
}
|
|
replace_source_and_adjust_conflicts(state, new_source, warnings);
|
|
} else {
|
|
// For prologue/epilogue replacements, ensure the replacement preserves
|
|
// the newline boundary so the body content isn't joined onto the same
|
|
// line as the replacement.
|
|
if matches!(target.region, Some(ChunkRegion::Head | ChunkRegion::Tail | ChunkRegion::Decl))
|
|
&& !replacement.is_empty()
|
|
&& !replacement.ends_with('\n')
|
|
&& state.source.as_bytes().get(region_end.saturating_sub(1)) == Some(&b'\n')
|
|
{
|
|
replacement.push('\n');
|
|
}
|
|
let new_source = replace_byte_range(&state.source, region_start, region_end, &replacement);
|
|
if anchor.kind == ChunkKind::Conflict {
|
|
state.conflict_meta.remove(anchor.path.as_str());
|
|
}
|
|
replace_source_and_adjust_conflicts(state, new_source, warnings);
|
|
}
|
|
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<String>,
|
|
warnings: &mut Vec<String>,
|
|
) -> Result<(), String> {
|
|
let target = resolve_edit_target(
|
|
state,
|
|
operation,
|
|
scheduled,
|
|
default_selector,
|
|
default_crc,
|
|
true,
|
|
touched_paths.as_slice(),
|
|
warnings,
|
|
)?;
|
|
let anchor = target.chunk;
|
|
if target.region.is_none() {
|
|
match anchor.kind {
|
|
ChunkKind::Ours => {
|
|
let Some(conflict_path) = anchor.parent_path.as_deref() else {
|
|
return Err("Conflict branch is missing its parent conflict chunk".to_owned());
|
|
};
|
|
let Some(conflict_meta) = state.conflict_meta.remove(conflict_path) else {
|
|
return Err(format!("Conflict metadata missing for {conflict_path}"));
|
|
};
|
|
let new_source = replace_byte_range(
|
|
&state.source,
|
|
conflict_meta.ours_start_byte,
|
|
conflict_meta.ours_end_byte,
|
|
conflict_meta.theirs_content.as_str(),
|
|
);
|
|
replace_source_and_adjust_conflicts(state, new_source, warnings);
|
|
touched_paths.push(conflict_path.to_owned());
|
|
return Ok(());
|
|
},
|
|
ChunkKind::Theirs => {
|
|
let Some(conflict_path) = anchor.parent_path.as_deref() else {
|
|
return Err("Conflict branch is missing its parent conflict chunk".to_owned());
|
|
};
|
|
state.conflict_meta.remove(conflict_path);
|
|
touched_paths.push(conflict_path.to_owned());
|
|
return Ok(());
|
|
},
|
|
ChunkKind::Conflict => {
|
|
state.conflict_meta.remove(anchor.path.as_str());
|
|
},
|
|
_ => {},
|
|
}
|
|
}
|
|
if anchor.kind == ChunkKind::Theirs {
|
|
return Err(
|
|
"Virtual conflict branches only support delete. Delete conflict.theirs to accept ours, \
|
|
delete conflict.ours to accept theirs, or replace the parent conflict chunk for a \
|
|
manual merge."
|
|
.to_owned(),
|
|
);
|
|
}
|
|
|
|
if let Some(r) = target.region {
|
|
let (range_start, range_end) = chunk_region_range(&anchor, r);
|
|
replace_source_and_adjust_conflicts(
|
|
state,
|
|
replace_byte_range(&state.source, range_start, range_end, ""),
|
|
warnings,
|
|
);
|
|
} else {
|
|
let offsets = line_offsets(&state.source);
|
|
let range_start = line_start_offset(&offsets, anchor.start_line, &state.source);
|
|
let new_source = cleanup_blank_line_artifacts_at_offset(
|
|
&replace_range_by_lines(&state.source, anchor.start_line, anchor.end_line, ""),
|
|
range_start,
|
|
);
|
|
replace_source_and_adjust_conflicts(state, new_source, warnings);
|
|
}
|
|
touched_paths.push(anchor.path);
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_insert(
|
|
state: &mut ChunkStateInner,
|
|
operation: &EditOperation,
|
|
scheduled: &ScheduledEditOperation,
|
|
default_selector: Option<&str>,
|
|
default_crc: Option<&str>,
|
|
file_indent_step: usize,
|
|
file_indent_char: char,
|
|
normalize_indent: bool,
|
|
touched_paths: &mut Vec<String>,
|
|
warnings: &mut Vec<String>,
|
|
) -> Result<(), String> {
|
|
let target = resolve_edit_target(
|
|
state,
|
|
operation,
|
|
scheduled,
|
|
default_selector,
|
|
default_crc,
|
|
false,
|
|
touched_paths.as_slice(),
|
|
warnings,
|
|
)?;
|
|
let anchor = target.chunk;
|
|
if anchor.kind == ChunkKind::Theirs {
|
|
return Err(
|
|
"Virtual conflict branches cannot be edited in place. Delete conflict.theirs to accept \
|
|
ours, delete conflict.ours to accept theirs, or replace the parent conflict chunk for a \
|
|
manual merge."
|
|
.to_owned(),
|
|
);
|
|
}
|
|
let (insertion, pos) = resolve_insertion_point(
|
|
state,
|
|
&anchor,
|
|
target.region,
|
|
operation.op,
|
|
operation.content.as_deref(),
|
|
file_indent_char,
|
|
file_indent_step,
|
|
)?;
|
|
let suppress_chunk_adjacency =
|
|
matches!(operation.op, ChunkEditOp::Prepend | ChunkEditOp::Append)
|
|
&& !(matches!(operation.op, ChunkEditOp::Append)
|
|
&& pos == InsertPosition::After
|
|
&& owned_container_end_line(state, &anchor) > anchor.end_line);
|
|
let spacing = compute_insert_spacing(state, &anchor, pos, suppress_chunk_adjacency);
|
|
let content = operation.content.as_deref().unwrap_or_default();
|
|
let mut replacement = normalize_inserted_content(
|
|
content,
|
|
&insertion.indent,
|
|
Some(file_indent_step),
|
|
file_indent_char,
|
|
normalize_indent,
|
|
);
|
|
replacement =
|
|
normalize_insertion_boundary_content(state, insertion.offset, &replacement, spacing);
|
|
|
|
if pos == InsertPosition::FirstChild {
|
|
let body = replacement.trim_matches('\n');
|
|
let comment_only = !body.is_empty()
|
|
&& body.lines().all(|line| {
|
|
let trimmed = line.trim();
|
|
trimmed.is_empty()
|
|
|| trimmed.starts_with("//")
|
|
|| trimmed.starts_with("///")
|
|
|| trimmed.starts_with('#')
|
|
|| trimmed.starts_with("/*")
|
|
});
|
|
if comment_only
|
|
&& anchor.path.is_empty()
|
|
&& anchor.children.iter().any(|child| child == "preamble")
|
|
{
|
|
return Err(
|
|
"Comment-only @body.prepend on root is not allowed when the file has a preamble \
|
|
chunk. Use replace on the preamble chunk instead."
|
|
.to_owned(),
|
|
);
|
|
}
|
|
if comment_only && !anchor.children.is_empty() {
|
|
warnings.push(
|
|
"Comment-only @body.prepend can merge into the following chunk's first line; it is \
|
|
not a separate named chunk."
|
|
.to_owned(),
|
|
);
|
|
}
|
|
}
|
|
|
|
replace_source_and_adjust_conflicts(
|
|
state,
|
|
insert_at_offset(&state.source, insertion.offset, &replacement),
|
|
warnings,
|
|
);
|
|
touched_paths.push(anchor.path);
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_operation_literals(operation: &EditOperation) -> EditOperation {
|
|
let mut operation = operation.clone();
|
|
if matches!(operation.sel.as_deref(), Some("null" | "undefined")) {
|
|
operation.sel = None;
|
|
}
|
|
if matches!(operation.crc.as_deref(), Some("null" | "undefined")) {
|
|
operation.crc = None;
|
|
}
|
|
operation
|
|
}
|
|
|
|
fn normalize_chunk_source(text: &str) -> String {
|
|
text
|
|
.strip_prefix('\u{feff}')
|
|
.unwrap_or(text)
|
|
.replace("\r\n", "\n")
|
|
.replace('\r', "\n")
|
|
}
|
|
|
|
fn rebuild_chunk_state(
|
|
source: String,
|
|
language: String,
|
|
notebook: Option<crate::chunk::ast_ipynb::SharedNotebookContext>,
|
|
conflict_meta: HashMap<String, ConflictMeta>,
|
|
) -> Result<ChunkStateInner, String> {
|
|
let mut 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 rebuilt_conflicts = if conflict_meta.is_empty() {
|
|
HashMap::new()
|
|
} else {
|
|
crate::chunk::conflict::reinject_conflict_chunks(&mut tree, source.as_str(), &conflict_meta)
|
|
};
|
|
let mut inner = ChunkStateInner::new(source, language, tree);
|
|
inner.notebook = notebook;
|
|
inner.conflict_meta = rebuilt_conflicts;
|
|
Ok(inner)
|
|
}
|
|
|
|
fn validate_batch_crc(chunk: &ChunkNode, crc: Option<&str>, required: bool) -> Result<(), String> {
|
|
if !required {
|
|
return Ok(());
|
|
}
|
|
validate_crc(chunk, crc)
|
|
}
|
|
|
|
fn validate_crc(chunk: &ChunkNode, crc: Option<&str>) -> Result<(), String> {
|
|
let cleaned = sanitize_crc(crc).ok_or_else(|| {
|
|
let selector = if chunk.path.is_empty() {
|
|
format!("#{}", chunk.checksum)
|
|
} else {
|
|
format!("{}#{}", chunk.path, chunk.checksum)
|
|
};
|
|
format!(
|
|
"Checksum required for {}. Re-read the chunk to get the current checksum, then include \
|
|
it in the selector. Hint: use target \"{}\" for container replacement, or append \
|
|
another region such as @body.",
|
|
chunk_path_opt(chunk),
|
|
selector
|
|
)
|
|
})?;
|
|
if chunk.checksum != cleaned {
|
|
return Err(format!(
|
|
"Checksum mismatch for {}: expected \"{}\", got \"{}\". The chunk content has changed \
|
|
since you last read it. Use the fresh checksum from the context below to retry.",
|
|
chunk_path_opt(chunk),
|
|
chunk.checksum,
|
|
cleaned
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
const fn chunk_path_opt(chunk: &ChunkNode) -> &str {
|
|
if chunk.path.is_empty() {
|
|
"root"
|
|
} else {
|
|
chunk.path.as_str()
|
|
}
|
|
}
|
|
|
|
fn touches_chunk_path(touched_paths: &[String], selector: &str) -> bool {
|
|
touched_paths.iter().any(|touched| {
|
|
touched == selector
|
|
|| touched.starts_with(&format!("{selector}."))
|
|
|| selector.starts_with(&format!("{touched}."))
|
|
})
|
|
}
|
|
|
|
/// Returns `true` when the CRC was auto-accepted (chunk was touched by an
|
|
/// earlier batch op and the model supplied the pre-batch CRC). The caller
|
|
/// should skip CRC validation in that case.
|
|
fn ensure_batch_operation_target_current(
|
|
scheduled: &ScheduledEditOperation,
|
|
crc: Option<&str>,
|
|
touched_paths: &[String],
|
|
) -> bool {
|
|
let Some(selector) = scheduled.requested_selector.as_deref() else {
|
|
return false;
|
|
};
|
|
let Some(initial_chunk) = scheduled.initial_chunk.as_ref() else {
|
|
return false;
|
|
};
|
|
let Some(cleaned_crc) = sanitize_crc(crc) else {
|
|
return false;
|
|
};
|
|
if !touches_chunk_path(touched_paths, selector) || cleaned_crc != initial_chunk.checksum {
|
|
return false;
|
|
}
|
|
// The chunk was touched by an earlier operation in this batch, and the model
|
|
// supplied the pre-batch CRC (which is all it could know). Auto-accept.
|
|
true
|
|
}
|
|
|
|
fn describe_scheduled_operation(scheduled: &ScheduledEditOperation) -> String {
|
|
let op = scheduled.operation.op.as_str();
|
|
if let Some(selector) = scheduled.requested_selector.as_deref() {
|
|
format!("{op} on \"{selector}\"")
|
|
} else {
|
|
op.to_owned()
|
|
}
|
|
}
|
|
|
|
fn replace_byte_range(source: &str, start: usize, end: usize, replacement: &str) -> String {
|
|
let mut new_source = String::with_capacity(
|
|
source
|
|
.len()
|
|
.saturating_sub(end.saturating_sub(start))
|
|
.saturating_add(replacement.len()),
|
|
);
|
|
new_source.push_str(&source[..start]);
|
|
new_source.push_str(replacement);
|
|
new_source.push_str(&source[end..]);
|
|
new_source
|
|
}
|
|
|
|
fn changed_span(before: &str, after: &str) -> (usize, usize, usize) {
|
|
let before_bytes = before.as_bytes();
|
|
let after_bytes = after.as_bytes();
|
|
let mut prefix = 0usize;
|
|
let max_prefix = before_bytes.len().min(after_bytes.len());
|
|
while prefix < max_prefix && before_bytes[prefix] == after_bytes[prefix] {
|
|
prefix += 1;
|
|
}
|
|
|
|
let mut before_suffix = before_bytes.len();
|
|
let mut after_suffix = after_bytes.len();
|
|
while before_suffix > prefix && after_suffix > prefix {
|
|
if before_bytes[before_suffix - 1] != after_bytes[after_suffix - 1] {
|
|
break;
|
|
}
|
|
before_suffix -= 1;
|
|
after_suffix -= 1;
|
|
}
|
|
|
|
(prefix, before_suffix, after_suffix)
|
|
}
|
|
|
|
const fn adjust_offset(offset: usize, delta: isize) -> usize {
|
|
if delta >= 0 {
|
|
offset.saturating_add(delta as usize)
|
|
} else {
|
|
offset.saturating_sub((-delta) as usize)
|
|
}
|
|
}
|
|
|
|
fn update_conflict_meta_after_source_change(
|
|
conflict_meta: &mut HashMap<String, ConflictMeta>,
|
|
before: &str,
|
|
after: &str,
|
|
warnings: &mut Vec<String>,
|
|
) {
|
|
let (change_start, before_end, after_end) = changed_span(before, after);
|
|
if change_start == before_end && change_start == after_end {
|
|
return;
|
|
}
|
|
|
|
let delta = (after_end.saturating_sub(change_start) as isize)
|
|
- (before_end.saturating_sub(change_start) as isize);
|
|
let mut removed = Vec::new();
|
|
|
|
for (path, meta) in conflict_meta.iter_mut() {
|
|
if before_end <= meta.ours_start_byte {
|
|
meta.ours_start_byte = adjust_offset(meta.ours_start_byte, delta);
|
|
meta.ours_end_byte = adjust_offset(meta.ours_end_byte, delta);
|
|
continue;
|
|
}
|
|
if change_start >= meta.ours_end_byte {
|
|
continue;
|
|
}
|
|
if change_start >= meta.ours_start_byte && before_end <= meta.ours_end_byte {
|
|
meta.ours_end_byte = adjust_offset(meta.ours_end_byte, delta);
|
|
continue;
|
|
}
|
|
|
|
removed.push(path.clone());
|
|
}
|
|
|
|
for path in removed {
|
|
conflict_meta.remove(path.as_str());
|
|
warnings.push(format!(
|
|
"Conflict {path} no longer maps cleanly after a surrounding edit, so it was marked as \
|
|
resolved."
|
|
));
|
|
}
|
|
}
|
|
|
|
fn replace_source_and_adjust_conflicts(
|
|
state: &mut ChunkStateInner,
|
|
new_source: String,
|
|
warnings: &mut Vec<String>,
|
|
) {
|
|
let before = state.source.clone();
|
|
update_conflict_meta_after_source_change(
|
|
&mut state.conflict_meta,
|
|
before.as_str(),
|
|
new_source.as_str(),
|
|
warnings,
|
|
);
|
|
state.source = new_source;
|
|
}
|
|
|
|
fn target_indent_for_region(
|
|
state: &ChunkStateInner,
|
|
anchor: &ChunkNode,
|
|
region: Option<ChunkRegion>,
|
|
file_indent_char: char,
|
|
file_indent_step: usize,
|
|
) -> String {
|
|
match region {
|
|
None | Some(ChunkRegion::Head | ChunkRegion::Tail | ChunkRegion::Decl) => {
|
|
anchor.indent_char.repeat(anchor.indent as usize)
|
|
},
|
|
Some(ChunkRegion::Body) => {
|
|
compute_insert_indent(state, anchor, true, file_indent_char, file_indent_step)
|
|
},
|
|
}
|
|
}
|
|
|
|
fn normalize_inserted_content(
|
|
content: &str,
|
|
target_indent: &str,
|
|
file_indent_step: Option<usize>,
|
|
file_indent_char: char,
|
|
normalize_indent: bool,
|
|
) -> String {
|
|
let mut normalized = normalize_chunk_source(content);
|
|
normalized = strip_content_prefixes(&normalized);
|
|
if !normalize_indent {
|
|
let dedented = dedent_python_style(&normalized);
|
|
return indent_non_empty_lines(&dedented, target_indent);
|
|
}
|
|
normalized = normalized
|
|
.split('\n')
|
|
.map(|line| denormalize_from_tabs(line, file_indent_char, file_indent_step.unwrap_or(1)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
if target_indent.is_empty() {
|
|
// Even at indent level 0, normalize the content's indent character
|
|
// to match the file's convention (e.g. LLM sends spaces for a tab file).
|
|
normalized =
|
|
normalize_leading_whitespace_char(&normalized, file_indent_char, file_indent_step);
|
|
} else {
|
|
normalized = reindent_inserted_block(&normalized, target_indent, file_indent_step);
|
|
}
|
|
normalized
|
|
}
|
|
|
|
fn line_offsets(text: &str) -> Vec<usize> {
|
|
let mut offsets = vec![0usize];
|
|
for (index, ch) in text.char_indices() {
|
|
if ch == '\n' {
|
|
offsets.push(index + 1);
|
|
}
|
|
}
|
|
offsets
|
|
}
|
|
|
|
fn line_start_offset(offsets: &[usize], line: u32, text: &str) -> usize {
|
|
if line <= 1 {
|
|
0
|
|
} else {
|
|
offsets
|
|
.get((line - 1) as usize)
|
|
.copied()
|
|
.unwrap_or(text.len())
|
|
}
|
|
}
|
|
|
|
fn line_end_offset(offsets: &[usize], line: u32, text: &str) -> usize {
|
|
offsets.get(line as usize).copied().unwrap_or(text.len())
|
|
}
|
|
|
|
fn replace_range_by_lines(text: &str, start_line: u32, end_line: u32, replacement: &str) -> String {
|
|
let offsets = line_offsets(text);
|
|
let start_offset = line_start_offset(&offsets, start_line, text);
|
|
let end_offset = line_end_offset(&offsets, end_line, text);
|
|
format!("{}{}{}", &text[..start_offset], replacement, &text[end_offset..])
|
|
}
|
|
|
|
fn insert_at_offset(text: &str, offset: usize, content: &str) -> String {
|
|
format!("{}{}{}", &text[..offset], content, &text[offset..])
|
|
}
|
|
|
|
fn cleanup_blank_line_artifacts_at_offset(text: &str, offset: usize) -> String {
|
|
let mut run_start = offset.min(text.len());
|
|
while run_start > 0 && text.as_bytes()[run_start - 1] == b'\n' {
|
|
run_start -= 1;
|
|
}
|
|
|
|
let mut run_end = offset.min(text.len());
|
|
while run_end < text.len() && text.as_bytes()[run_end] == b'\n' {
|
|
run_end += 1;
|
|
}
|
|
|
|
let newline_run = &text[run_start..run_end];
|
|
if !newline_run.contains("\n\n") {
|
|
return text.to_owned();
|
|
}
|
|
|
|
let after_run = &text[run_end..];
|
|
let before_run = &text[..run_start];
|
|
let trailing_line = before_run.rsplit('\n').next().unwrap_or("");
|
|
let after_starts_with_close = after_run
|
|
.trim_start_matches([' ', '\t'])
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
|
|
let trailing_trimmed = trailing_line.trim_matches([' ', '\t']);
|
|
let trailing_is_only_close = trailing_trimmed.chars().count() == 1
|
|
&& trailing_trimmed
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
|
|
let between_adjacent_closing = trailing_is_only_close && after_starts_with_close;
|
|
|
|
if after_starts_with_close {
|
|
if newline_run.contains("\n\n\n") {
|
|
return format!("{}{}{}", before_run, collapse_newline_runs(newline_run, 2), after_run);
|
|
}
|
|
if between_adjacent_closing {
|
|
return text.to_owned();
|
|
}
|
|
return format!("{before_run}\n{after_run}");
|
|
}
|
|
if !newline_run.contains("\n\n\n") {
|
|
return text.to_owned();
|
|
}
|
|
format!("{}{}{}", before_run, collapse_newline_runs(newline_run, 2), after_run)
|
|
}
|
|
|
|
fn collapse_newline_runs(run: &str, max_newlines: usize) -> String {
|
|
let mut out = String::with_capacity(run.len());
|
|
let mut newline_count = 0usize;
|
|
for ch in run.chars() {
|
|
if ch == '\n' {
|
|
newline_count += 1;
|
|
if newline_count <= max_newlines {
|
|
out.push(ch);
|
|
}
|
|
} else {
|
|
newline_count = 0;
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn chunk_slice(text: &str, chunk: &ChunkNode) -> String {
|
|
if chunk.line_count == 0 {
|
|
return String::new();
|
|
}
|
|
text
|
|
.split('\n')
|
|
.skip(chunk.start_line.saturating_sub(1) as usize)
|
|
.take((chunk.end_line - chunk.start_line + 1) as usize)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
const fn is_container_like_chunk(chunk: &ChunkNode) -> bool {
|
|
let traits = chunk.kind.traits();
|
|
!chunk.leaf
|
|
|| traits.container
|
|
|| traits.has_addressable_members
|
|
|| traits.always_preserve_children
|
|
}
|
|
|
|
fn go_receiver_belongs_to_type(source: &str, chunk: &ChunkNode, type_name: &str) -> bool {
|
|
let header = source[chunk.start_byte as usize..chunk.end_byte as usize]
|
|
.lines()
|
|
.next()
|
|
.unwrap_or_default()
|
|
.trim_start();
|
|
header.starts_with("func ")
|
|
&& (header.contains(&format!(" {type_name})")) || header.contains(&format!("*{type_name})")))
|
|
}
|
|
|
|
fn owned_container_end_line(state: &ChunkStateInner, anchor: &ChunkNode) -> u32 {
|
|
if state.language != "go" || anchor.kind != ChunkKind::Type {
|
|
return anchor.end_line;
|
|
}
|
|
|
|
let type_name = anchor
|
|
.identifier
|
|
.as_deref()
|
|
.unwrap_or_else(|| anchor.kind.prefix());
|
|
let mut owned_end_line = anchor.end_line;
|
|
let mut top_level_chunks = state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.filter(|chunk| chunk.parent_path.as_deref() == Some(""))
|
|
.collect::<Vec<_>>();
|
|
top_level_chunks.sort_by_key(|chunk| chunk.start_line);
|
|
|
|
let Some(start_index) = top_level_chunks
|
|
.iter()
|
|
.position(|chunk| chunk.path == anchor.path)
|
|
else {
|
|
return anchor.end_line;
|
|
};
|
|
|
|
for chunk in top_level_chunks.into_iter().skip(start_index + 1) {
|
|
if chunk.start_line < owned_end_line {
|
|
continue;
|
|
}
|
|
if chunk.kind == ChunkKind::Function
|
|
&& go_receiver_belongs_to_type(&state.source, chunk, type_name)
|
|
{
|
|
owned_end_line = chunk.end_line;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
owned_end_line
|
|
}
|
|
|
|
fn before_chunk_insertion_point(state: &ChunkStateInner, anchor: &ChunkNode) -> InsertionPoint {
|
|
if anchor.path.is_empty() {
|
|
return InsertionPoint { offset: 0, indent: String::new() };
|
|
}
|
|
let offsets = line_offsets(&state.source);
|
|
InsertionPoint {
|
|
offset: line_start_offset(&offsets, anchor.start_line, &state.source),
|
|
indent: anchor.indent_char.repeat(anchor.indent as usize),
|
|
}
|
|
}
|
|
|
|
fn after_chunk_insertion_point(state: &ChunkStateInner, anchor: &ChunkNode) -> InsertionPoint {
|
|
if anchor.path.is_empty() {
|
|
return InsertionPoint { offset: state.source.len(), indent: String::new() };
|
|
}
|
|
let offsets = line_offsets(&state.source);
|
|
let end_line = owned_container_end_line(state, anchor);
|
|
InsertionPoint {
|
|
offset: line_end_offset(&offsets, end_line, &state.source),
|
|
indent: anchor.indent_char.repeat(anchor.indent as usize),
|
|
}
|
|
}
|
|
|
|
fn body_insertion_point(
|
|
state: &ChunkStateInner,
|
|
anchor: &ChunkNode,
|
|
at_end: bool,
|
|
file_indent_char: char,
|
|
file_indent_step: usize,
|
|
) -> InsertionPoint {
|
|
let offsets = line_offsets(&state.source);
|
|
let indent = compute_insert_indent(state, anchor, true, file_indent_char, file_indent_step);
|
|
if at_end {
|
|
if let Some(last_child_path) = anchor.children.last()
|
|
&& let Some(last_child) = state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|chunk| &chunk.path == last_child_path)
|
|
{
|
|
let child_indent = if last_child.indent_char.is_empty() {
|
|
indent
|
|
} else {
|
|
last_child.indent_char.repeat(last_child.indent as usize)
|
|
};
|
|
return InsertionPoint {
|
|
offset: line_end_offset(&offsets, last_child.end_line, &state.source),
|
|
indent: child_indent,
|
|
};
|
|
}
|
|
let (_, body_end) = chunk_region_range(anchor, ChunkRegion::Body);
|
|
return InsertionPoint { offset: body_end, indent };
|
|
}
|
|
|
|
if let Some(first_child_path) = anchor.children.first()
|
|
&& let Some(first_child) = state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|chunk| &chunk.path == first_child_path)
|
|
{
|
|
return InsertionPoint {
|
|
offset: line_start_offset(&offsets, first_child.start_line, &state.source),
|
|
indent,
|
|
};
|
|
}
|
|
let (body_start, _) = chunk_region_range(anchor, ChunkRegion::Body);
|
|
InsertionPoint { offset: body_start, indent }
|
|
}
|
|
|
|
fn resolve_insertion_point(
|
|
state: &ChunkStateInner,
|
|
anchor: &ChunkNode,
|
|
region: Option<ChunkRegion>,
|
|
op: ChunkEditOp,
|
|
_file_content: Option<&str>,
|
|
file_indent_char: char,
|
|
file_indent_step: usize,
|
|
) -> Result<(InsertionPoint, InsertPosition), String> {
|
|
match (region, op) {
|
|
// Before chunk boundary
|
|
(
|
|
None | Some(ChunkRegion::Head | ChunkRegion::Decl),
|
|
ChunkEditOp::Before | ChunkEditOp::Prepend,
|
|
) => Ok((before_chunk_insertion_point(state, anchor), InsertPosition::Before)),
|
|
// After chunk boundary
|
|
(
|
|
None | Some(ChunkRegion::Tail | ChunkRegion::Decl),
|
|
ChunkEditOp::After | ChunkEditOp::Append,
|
|
) => Ok((after_chunk_insertion_point(state, anchor), InsertPosition::After)),
|
|
// Inner first-child position
|
|
(Some(ChunkRegion::Body), ChunkEditOp::Before | ChunkEditOp::Prepend)
|
|
| (Some(ChunkRegion::Head), ChunkEditOp::After | ChunkEditOp::Append) => Ok((
|
|
body_insertion_point(state, anchor, false, file_indent_char, file_indent_step),
|
|
InsertPosition::FirstChild,
|
|
)),
|
|
// Inner last-child position
|
|
(Some(ChunkRegion::Body), ChunkEditOp::After | ChunkEditOp::Append)
|
|
| (Some(ChunkRegion::Tail), ChunkEditOp::Before | ChunkEditOp::Prepend) => Ok((
|
|
body_insertion_point(state, anchor, true, file_indent_char, file_indent_step),
|
|
InsertPosition::LastChild,
|
|
)),
|
|
(_, ChunkEditOp::Replace | ChunkEditOp::Delete) => {
|
|
Err("Internal error: insertion point requested for non-insert op".to_owned())
|
|
},
|
|
}
|
|
}
|
|
|
|
fn indent_prefix_for_level(
|
|
anchor: &ChunkNode,
|
|
file_indent_char: char,
|
|
file_indent_step: usize,
|
|
extra_levels: usize,
|
|
) -> String {
|
|
let step = file_indent_step.max(1);
|
|
let indent_char = if matches!(file_indent_char, ' ' | '\t') {
|
|
file_indent_char
|
|
} else {
|
|
anchor.indent_char.chars().next().unwrap_or(' ')
|
|
};
|
|
if indent_char == '\t' {
|
|
return "\t".repeat(anchor.indent as usize + extra_levels);
|
|
}
|
|
let indent_levels = (anchor.indent as usize / step).saturating_add(extra_levels);
|
|
" ".repeat(step * indent_levels)
|
|
}
|
|
|
|
fn compute_insert_indent(
|
|
state: &ChunkStateInner,
|
|
anchor: &ChunkNode,
|
|
inside: bool,
|
|
file_indent_char: char,
|
|
file_indent_step: usize,
|
|
) -> String {
|
|
if !inside || anchor.path.is_empty() {
|
|
return String::new();
|
|
}
|
|
if let Some(first_child_path) = anchor.children.first()
|
|
&& let Some(first_child) = state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|chunk| &chunk.path == first_child_path)
|
|
{
|
|
let indent_char = if first_child.indent_char.is_empty() {
|
|
if anchor.indent_char.is_empty() {
|
|
"\t"
|
|
} else {
|
|
anchor.indent_char.as_str()
|
|
}
|
|
} else {
|
|
first_child.indent_char.as_str()
|
|
};
|
|
return indent_char.repeat(first_child.indent as usize);
|
|
}
|
|
|
|
for line in chunk_slice(&state.source, anchor).split('\n').skip(1) {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let prefix_len = line.len() - line.trim_start_matches([' ', '\t']).len();
|
|
if prefix_len > 0 {
|
|
return line[..prefix_len].to_owned();
|
|
}
|
|
break;
|
|
}
|
|
|
|
let indent_char = if anchor.indent_char.is_empty() {
|
|
file_indent_char.to_string()
|
|
} else {
|
|
anchor.indent_char.clone()
|
|
};
|
|
if indent_char == "\t" {
|
|
"\t".repeat(anchor.indent as usize + 1)
|
|
} else {
|
|
indent_prefix_for_level(anchor, file_indent_char, file_indent_step, 1)
|
|
}
|
|
}
|
|
|
|
fn sibling_index(state: &ChunkStateInner, anchor: &ChunkNode) -> Option<(usize, usize)> {
|
|
let parent_path = anchor.parent_path.as_deref().unwrap_or("");
|
|
let parent = state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|chunk| chunk.path == parent_path)?;
|
|
let index = parent
|
|
.children
|
|
.iter()
|
|
.position(|child| child == &anchor.path)?;
|
|
Some((index, parent.children.len()))
|
|
}
|
|
|
|
fn has_sibling_before(state: &ChunkStateInner, anchor: &ChunkNode) -> bool {
|
|
sibling_index(state, anchor).is_some_and(|(index, _)| index > 0)
|
|
}
|
|
|
|
fn has_sibling_after(state: &ChunkStateInner, anchor: &ChunkNode) -> bool {
|
|
sibling_index(state, anchor).is_some_and(|(index, total)| index + 1 < total)
|
|
}
|
|
|
|
fn container_has_interior_content(state: &ChunkStateInner, anchor: &ChunkNode) -> bool {
|
|
if !is_container_like_chunk(anchor) {
|
|
return false;
|
|
}
|
|
chunk_slice(&state.source, anchor)
|
|
.split('\n')
|
|
.skip(1)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.skip(1)
|
|
.any(|line| !line.trim().is_empty())
|
|
}
|
|
|
|
fn visible_child_chunks<'a>(
|
|
state: &'a ChunkStateInner,
|
|
anchor: &'a ChunkNode,
|
|
) -> Vec<&'a ChunkNode> {
|
|
anchor
|
|
.children
|
|
.iter()
|
|
.filter_map(|child_path| {
|
|
state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|chunk| chunk.path == *child_path)
|
|
})
|
|
.filter(|child| child.kind != ChunkKind::Chunk)
|
|
.collect()
|
|
}
|
|
|
|
fn sibling_gap_has_blank_line(
|
|
state: &ChunkStateInner,
|
|
left: &ChunkNode,
|
|
right: &ChunkNode,
|
|
) -> bool {
|
|
right.start_line > owned_container_end_line(state, left) + 1
|
|
}
|
|
|
|
/// 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 {
|
|
if anchor.path.is_empty() {
|
|
let root_children = visible_child_chunks(state, anchor);
|
|
if root_children.len() >= 2 {
|
|
return root_children
|
|
.windows(2)
|
|
.any(|pair| sibling_gap_has_blank_line(state, pair[0], pair[1]));
|
|
}
|
|
return true;
|
|
}
|
|
if anchor.children.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
let visible_children = visible_child_chunks(state, anchor);
|
|
if visible_children.is_empty() {
|
|
return true;
|
|
}
|
|
if visible_children.len() >= 2 {
|
|
return visible_children
|
|
.windows(2)
|
|
.any(|pair| sibling_gap_has_blank_line(state, pair[0], pair[1]));
|
|
}
|
|
|
|
let all_packed = visible_children
|
|
.iter()
|
|
.all(|child| child.kind.traits().packed);
|
|
!all_packed
|
|
}
|
|
|
|
/// 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,
|
|
pos: InsertPosition,
|
|
is_prepend_or_append: bool,
|
|
) -> InsertSpacing {
|
|
let has_interior_content = container_has_interior_content(state, anchor);
|
|
let markdown_block_spacing = state.language == "markdown";
|
|
match pos {
|
|
InsertPosition::FirstChild => {
|
|
let spaced = markdown_block_spacing || 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 => {
|
|
let spaced = markdown_block_spacing || children_want_blank_line_spacing(state, anchor);
|
|
InsertSpacing {
|
|
blank_line_before: spaced && (!anchor.children.is_empty() || has_interior_content),
|
|
blank_line_after: markdown_block_spacing && has_sibling_after(state, anchor),
|
|
}
|
|
},
|
|
InsertPosition::Before => {
|
|
let spaced = markdown_block_spacing || is_spaced_sibling(state, anchor);
|
|
InsertSpacing {
|
|
blank_line_before: has_sibling_before(state, anchor) && spaced,
|
|
// When the op is `prepend` (container.prepend), omit the trailing
|
|
// blank line so the content stays adjacent to the chunk and gets
|
|
// absorbed as leading trivia on tree rebuild.
|
|
blank_line_after: !is_prepend_or_append && spaced,
|
|
}
|
|
},
|
|
InsertPosition::After => {
|
|
let spaced = markdown_block_spacing || is_spaced_sibling(state, anchor);
|
|
InsertSpacing {
|
|
// When the op is `append` (container.append), omit the leading
|
|
// blank line so the content stays adjacent to the chunk.
|
|
blank_line_before: !is_prepend_or_append && spaced,
|
|
blank_line_after: has_sibling_after(state, anchor) && spaced,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn count_trailing_newlines_before_offset(text: &str, offset: usize) -> usize {
|
|
let mut count = 0usize;
|
|
let bytes = text.as_bytes();
|
|
let mut index = offset;
|
|
while index > 0 && bytes[index - 1] == b'\n' {
|
|
count += 1;
|
|
index -= 1;
|
|
}
|
|
count
|
|
}
|
|
|
|
fn count_leading_newlines_after_offset(text: &str, offset: usize) -> usize {
|
|
let mut count = 0usize;
|
|
let bytes = text.as_bytes();
|
|
let mut index = offset;
|
|
while index < bytes.len() && bytes[index] == b'\n' {
|
|
count += 1;
|
|
index += 1;
|
|
}
|
|
count
|
|
}
|
|
|
|
fn normalize_insertion_boundary_content(
|
|
state: &ChunkStateInner,
|
|
offset: usize,
|
|
content: &str,
|
|
spacing: InsertSpacing,
|
|
) -> String {
|
|
let trimmed = content.trim_matches('\n');
|
|
if trimmed.is_empty() {
|
|
return content.to_owned();
|
|
}
|
|
|
|
let prev_char = if offset > 0 {
|
|
state
|
|
.source
|
|
.as_bytes()
|
|
.get(offset - 1)
|
|
.copied()
|
|
.map(char::from)
|
|
} else {
|
|
None
|
|
};
|
|
let next_char = state.source.as_bytes().get(offset).copied().map(char::from);
|
|
let prefix_newlines = if spacing.blank_line_before {
|
|
2usize.saturating_sub(count_trailing_newlines_before_offset(&state.source, offset))
|
|
} else {
|
|
usize::from(prev_char.is_some() && prev_char != Some('\n'))
|
|
};
|
|
let suffix_newlines = if spacing.blank_line_after {
|
|
2usize.saturating_sub(count_leading_newlines_after_offset(&state.source, offset))
|
|
} else {
|
|
usize::from(next_char.is_some() && next_char != Some('\n'))
|
|
};
|
|
|
|
format!("{}{}{}", "\n".repeat(prefix_newlines), trimmed, "\n".repeat(suffix_newlines))
|
|
}
|
|
|
|
fn line_column_at_offset(text: &str, offset: usize) -> (usize, usize) {
|
|
let offsets = line_offsets(text);
|
|
let mut low = 0usize;
|
|
let mut high = offsets.len().saturating_sub(1);
|
|
while low <= high {
|
|
let mid = usize::midpoint(low, high);
|
|
let start = offsets[mid];
|
|
let next = offsets.get(mid + 1).copied().unwrap_or(text.len() + 1);
|
|
if offset < start {
|
|
if mid == 0 {
|
|
break;
|
|
}
|
|
high = mid - 1;
|
|
continue;
|
|
}
|
|
if offset >= next {
|
|
low = mid + 1;
|
|
continue;
|
|
}
|
|
return (mid + 1, offset - start + 1);
|
|
}
|
|
(offsets.len(), 1)
|
|
}
|
|
|
|
fn format_parse_error_summaries(state: &ChunkStateInner) -> Vec<String> {
|
|
state
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.filter(|chunk| chunk.error)
|
|
.take(3)
|
|
.map(|chunk| {
|
|
let (line, column) = line_column_at_offset(&state.source, chunk.start_byte as usize);
|
|
match chunk
|
|
.signature
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
Some(snippet) => format!("L{line}:C{column} unexpected syntax near {snippet:?}"),
|
|
None => format!("L{line}:C{column} unexpected syntax"),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn display_path_for_file(file_path: &str, cwd: &str) -> String {
|
|
let file = Path::new(file_path);
|
|
let cwd = Path::new(cwd);
|
|
match file.strip_prefix(cwd) {
|
|
Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
|
|
Err(_) => file.to_string_lossy().replace('\\', "/"),
|
|
}
|
|
}
|
|
|
|
/// A parsed unified diff hunk.
|
|
struct DiffHunk {
|
|
header: String,
|
|
lines: Vec<String>,
|
|
new_start: u32,
|
|
}
|
|
|
|
/// Normalize the content part of a diff hunk line (after the +/-/space prefix)
|
|
/// so that its indentation matches the chunk tree display format.
|
|
fn render_hunk_line(
|
|
line: &str,
|
|
normalize_indent: bool,
|
|
indent_char: char,
|
|
indent_step: usize,
|
|
) -> String {
|
|
if !normalize_indent {
|
|
return line.to_owned();
|
|
}
|
|
if line.is_empty() {
|
|
return line.to_owned();
|
|
}
|
|
let first = line.as_bytes()[0];
|
|
if matches!(first, b'+' | b'-' | b' ') {
|
|
let prefix = &line[..1];
|
|
let content = &line[1..];
|
|
let normalized = normalize_to_tabs(content, indent_char, indent_step);
|
|
format!("{prefix}{normalized}")
|
|
} else {
|
|
line.to_owned()
|
|
}
|
|
}
|
|
|
|
/// Generate unified diff hunks between two texts using the `similar` crate.
|
|
fn generate_diff_hunks(before: &str, after: &str, context: usize) -> Vec<DiffHunk> {
|
|
use similar::{ChangeTag, TextDiff};
|
|
|
|
let diff = TextDiff::from_lines(before, after);
|
|
let mut hunks = Vec::new();
|
|
|
|
for group in diff.grouped_ops(context) {
|
|
let mut hunk_lines = Vec::new();
|
|
|
|
let first = &group[0];
|
|
let last = &group[group.len() - 1];
|
|
let old_start = first.old_range().start + 1;
|
|
let old_len = last.old_range().end - first.old_range().start;
|
|
let new_start = first.new_range().start + 1;
|
|
let new_len = last.new_range().end - first.new_range().start;
|
|
|
|
let header = format!("@@ -{old_start},{old_len} +{new_start},{new_len} @@");
|
|
|
|
for op in &group {
|
|
for change in diff.iter_changes(op) {
|
|
let line = change.value().trim_end_matches('\n');
|
|
match change.tag() {
|
|
ChangeTag::Equal => hunk_lines.push(format!(" {line}")),
|
|
ChangeTag::Delete => hunk_lines.push(format!("-{line}")),
|
|
ChangeTag::Insert => hunk_lines.push(format!("+{line}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
hunks.push(DiffHunk { header, lines: hunk_lines, new_start: new_start as u32 });
|
|
}
|
|
|
|
hunks
|
|
}
|
|
|
|
/// Render the response text for a changed file, combining the current chunked
|
|
/// tree view with inline diff hunks placed inside the owning chunk blocks.
|
|
fn render_changed_hunks(
|
|
state: &ChunkStateInner,
|
|
display_path: &str,
|
|
before: &str,
|
|
after: &str,
|
|
anchor_style: Option<ChunkAnchorStyle>,
|
|
touched_paths: &[String],
|
|
normalize_indent: bool,
|
|
) -> String {
|
|
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);
|
|
|
|
// 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 = if normalize_indent {
|
|
NORMALIZED_TAB_REPLACEMENT
|
|
} else {
|
|
PRESERVED_TAB_REPLACEMENT
|
|
};
|
|
let file_indent_char = detect_file_indent_char(state.source(), tree);
|
|
let file_indent_step = detect_file_indent_step(state.source(), tree) as usize;
|
|
let lookup: HashMap<&str, &ChunkNode> =
|
|
tree.chunks.iter().map(|c| (c.path.as_str(), c)).collect();
|
|
let render_indent = normalize_indent.then_some((file_indent_char, file_indent_step));
|
|
|
|
let mut inline_hunks: HashMap<String, Vec<crate::chunk::render::InlineHunk>> = 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,
|
|
render_indent,
|
|
);
|
|
let mut lines = Vec::with_capacity(hunk.lines.len() + 1);
|
|
lines.push(format!("{indent}{}", hunk.header));
|
|
for line in &hunk.lines {
|
|
let normalized =
|
|
render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step);
|
|
lines.push(format!("{indent}{normalized}"));
|
|
}
|
|
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()),
|
|
normalize_indent: Some(normalize_indent),
|
|
focused_paths,
|
|
},
|
|
inline_hunks,
|
|
);
|
|
|
|
if orphan_hunks.is_empty() {
|
|
return tree_text;
|
|
}
|
|
|
|
// 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.clone());
|
|
lines.extend(hunk.lines.iter().map(|line| {
|
|
render_hunk_line(line, normalize_indent, file_indent_char, file_indent_step)
|
|
}));
|
|
lines
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
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<Vec<FocusedPath>> {
|
|
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<String, ChunkFocusMode> = 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(),
|
|
)
|
|
}
|
|
|
|
/// Render a focused chunk view to append to error messages. Resolves the
|
|
/// selector ignoring CRC so the agent sees fresh anchors without a re-read.
|
|
fn render_error_context(
|
|
state: &ChunkStateInner,
|
|
selector: Option<&str>,
|
|
display_path: &str,
|
|
anchor_style: Option<ChunkAnchorStyle>,
|
|
normalize_indent: bool,
|
|
) -> String {
|
|
let Ok(ParsedSelector { selector: clean_path, .. }) =
|
|
split_selector_crc_and_region(selector, None, None)
|
|
else {
|
|
return String::new();
|
|
};
|
|
let mut ignored = Vec::new();
|
|
let Ok(chunk) = resolve_chunk_selector(state, clean_path.as_deref(), &mut ignored) else {
|
|
return String::new();
|
|
};
|
|
let focused_paths = compute_focus(state.tree(), std::slice::from_ref(&chunk.path));
|
|
let tab_replacement = if normalize_indent {
|
|
NORMALIZED_TAB_REPLACEMENT
|
|
} else {
|
|
PRESERVED_TAB_REPLACEMENT
|
|
};
|
|
let rendered = 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: true,
|
|
tab_replacement: Some(tab_replacement.to_owned()),
|
|
normalize_indent: Some(normalize_indent),
|
|
focused_paths,
|
|
});
|
|
format!("\n\nFresh content:\n{rendered}")
|
|
}
|
|
|
|
fn render_unchanged_response(
|
|
state: &ChunkStateInner,
|
|
display_path: &str,
|
|
anchor_style: Option<ChunkAnchorStyle>,
|
|
normalize_indent: bool,
|
|
) -> String {
|
|
let tab_replacement = if normalize_indent {
|
|
NORMALIZED_TAB_REPLACEMENT
|
|
} else {
|
|
PRESERVED_TAB_REPLACEMENT
|
|
};
|
|
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,
|
|
focused_paths: None,
|
|
show_leaf_preview: true,
|
|
tab_replacement: Some(tab_replacement.to_owned()),
|
|
normalize_indent: Some(normalize_indent),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::chunk::build_chunk_tree;
|
|
|
|
fn state_for(source: &str, language: &str) -> ChunkState {
|
|
let tree = build_chunk_tree(source, language).expect("tree should build");
|
|
ChunkState::from_inner(ChunkStateInner::new(source.to_owned(), language.to_owned(), tree))
|
|
}
|
|
|
|
fn parsed_state_for(source: &str, language: &str) -> ChunkState {
|
|
ChunkState::parse(source.to_owned(), language.to_owned()).expect("state should parse")
|
|
}
|
|
|
|
fn apply_single_edit(
|
|
state: &ChunkState,
|
|
file_path: &str,
|
|
operation: EditOperation,
|
|
) -> EditResult {
|
|
apply_edits(state, &EditParams {
|
|
operations: vec![operation],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: file_path.to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should apply")
|
|
}
|
|
|
|
#[test]
|
|
fn root_level_replace_preserves_space_indentation() {
|
|
let source = "fn main() {\n println!(\"old\");\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()),
|
|
region: None,
|
|
content: Some("fn main() {\n println!(\"new\");\n}".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.rs".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should apply");
|
|
|
|
assert!(
|
|
result.diff_after.contains("println!(\"new\");"),
|
|
"expected updated body text, got {:?}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result.diff_after.contains("\n\tprintln!(\"new\");\n"),
|
|
"expected no tab-indented body, got {:?}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_hunks_use_normalized_indentation() {
|
|
// Source uses 4-space indentation with nested children, so the tree
|
|
// can detect indent_char=' ' and indent_step=4. The diff hunk lines
|
|
// in the response should use tab-normalized indentation (matching the
|
|
// read tool's output) instead of the raw file indentation.
|
|
let source = "class Foo {\n value: number = 0;\n\n increment(): void {\n \
|
|
this.value += 1;\n }\n\n decrement(): void {\n this.value -= \
|
|
1;\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("class_Foo.fn_increm")
|
|
.expect("fn_increm");
|
|
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("class_Foo.fn_increm".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: Some(ChunkRegion::Body),
|
|
content: Some("this.value += 2;\n".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.ts".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should apply");
|
|
|
|
// The response text should contain diff hunks with tab-normalized
|
|
// indentation, not the raw 4-space (two levels = 8-space) indentation.
|
|
assert!(
|
|
result.response_text.contains("-\t\tthis.value += 1;"),
|
|
"diff hunk removed line should use tab-normalized indent. Response:\n{}",
|
|
result.response_text
|
|
);
|
|
assert!(
|
|
result.response_text.contains("+\t\tthis.value += 2;"),
|
|
"diff hunk added line should use tab-normalized indent. Response:\n{}",
|
|
result.response_text
|
|
);
|
|
// Should NOT contain the raw 8-space-indented diff lines.
|
|
assert!(
|
|
!result.response_text.contains("- this.value += 1;"),
|
|
"should not have raw space-indented diff lines. Response:\n{}",
|
|
result.response_text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_chunk_replace_does_not_duplicate_attributes() {
|
|
let source = "#[napi]\nfn close() {\n old();\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk = state.inner().chunk("fn_close").expect("fn_close");
|
|
// The chunk range should include the #[napi] attribute.
|
|
assert_eq!(chunk.start_line, 1, "chunk should start at the attribute line");
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("fn_close".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: None,
|
|
content: Some("/// doc\n#[napi]\nfn close() {\n new();\n}".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
let occurrences = result.diff_after.matches("#[napi]").count();
|
|
assert_eq!(
|
|
occurrences, 1,
|
|
"expected exactly one #[napi] attribute, got {occurrences}. Full text:\n{}",
|
|
result.diff_after
|
|
);
|
|
assert!(result.diff_after.contains("new()"), "replacement body should be present");
|
|
}
|
|
|
|
#[test]
|
|
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 result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("run".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: None,
|
|
content: Some("run(): void {\n\tconsole.log(\"resolved\");\n}".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.ts".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should resolve a unique fuzzy selector");
|
|
|
|
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 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_fuzzyM")
|
|
.expect("fn_fuzzyM should exist");
|
|
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("fuzzyM".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: None,
|
|
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: "box.ts".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should resolve a prefixed bare selector");
|
|
|
|
assert!(result.diff_after.contains("console.log(\"resolved\");"), "{}", result.diff_after);
|
|
assert!(result.warnings.iter().any(|warning| {
|
|
warning.contains("Auto-resolved chunk selector \"fuzzyM\" to \"fn_fuzzyM#")
|
|
}));
|
|
}
|
|
|
|
#[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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should resolve file-prefixed checksum target");
|
|
|
|
assert!(result.diff_after.contains("console.log(\"normalized\");"), "{}", result.diff_after);
|
|
}
|
|
|
|
#[test]
|
|
fn line_number_selector_auto_resolves_to_containing_chunk() {
|
|
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");
|
|
|
|
// L2 falls inside fn_main — should auto-resolve and apply the edit.
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("L2#{}", chunk.checksum)),
|
|
crc: None,
|
|
region: 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(),
|
|
normalize_indent: None,
|
|
});
|
|
|
|
let result = result.expect("line-number selector should auto-resolve");
|
|
assert!(
|
|
result
|
|
.warnings
|
|
.iter()
|
|
.any(|w| w.contains("Auto-resolved line target")),
|
|
"should warn about auto-resolution: {:?}",
|
|
result.warnings
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("\"new\""),
|
|
"edit should have applied: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn line_number_outside_any_chunk_returns_error() {
|
|
let source = "function main(): void {\n\tconsole.log(\"old\");\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
|
|
// L999 is way beyond the file — should fail.
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("L999".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("// hello".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "box.ts".to_owned(),
|
|
normalize_indent: None,
|
|
});
|
|
|
|
assert!(result.is_err(), "line outside any chunk should fail");
|
|
let err = result.err().unwrap();
|
|
assert!(err.contains("does not fall inside any chunk"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_section_replace_preserves_next_sibling_heading() {
|
|
let source = "# Top\n\n## Building\n\nOld content.\n\n## Code Style\n\n- style one\n";
|
|
let state = state_for(source, "markdown");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("sect_Top.sect_Buildi")
|
|
.expect("sect_Buildi");
|
|
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("sect_Top.sect_Buildi".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: None,
|
|
content: Some("## Building\n\nNew content.\n".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.md".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("replace should succeed");
|
|
|
|
assert!(
|
|
result.diff_after.contains("## Code Style"),
|
|
"next sibling heading must survive section replace, got:\n{}",
|
|
result.diff_after,
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("New content."),
|
|
"replacement content must be present, got:\n{}",
|
|
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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
})
|
|
.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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
});
|
|
|
|
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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
});
|
|
|
|
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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
});
|
|
|
|
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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
});
|
|
|
|
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()),
|
|
region: None,
|
|
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(),
|
|
normalize_indent: None,
|
|
})
|
|
.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_on_group_chunk_container_inserts_at_end() {
|
|
// A file with only a `stmts` group chunk (e.g. a describe() call in a test
|
|
// file). Appending to it should insert content at the end of the statement
|
|
// list.
|
|
let source = "import { describe } from \"bun:test\";\n\ndescribe(\"suite\", () => \
|
|
{\n\tit(\"a\", () => {});\n});\n";
|
|
let state = state_for(source, "typescript");
|
|
let stmts = state
|
|
.inner()
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|c| {
|
|
c.path
|
|
.rsplit('.')
|
|
.next()
|
|
.is_some_and(|leaf| leaf.starts_with("stmts"))
|
|
})
|
|
.expect("stmts chunk should exist");
|
|
assert!(stmts.group, "stmts chunk should be marked as group");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some(stmts.path.clone()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\nit(\"b\", () => {});".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("it(\"b\""),
|
|
"appended content should appear in output, got: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn replace_body_preserves_typescript_closing_brace_indentation() {
|
|
let source = "function main() {\n work();\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
let chunk = state.inner().chunk("fn_main").expect("fn_main");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\treturn next();\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(result.diff_after, "function main() {\n return next();\n}\n");
|
|
}
|
|
|
|
#[test]
|
|
fn replace_body_preserves_rust_closing_brace_indentation() {
|
|
let source = "fn main() {\n println!(\"old\");\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk = state.inner().chunk("fn_main").expect("fn_main");
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\tprintln!(\"new\");\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(result.diff_after, "fn main() {\n println!(\"new\");\n}\n");
|
|
}
|
|
|
|
#[test]
|
|
fn replace_body_preserves_go_closing_brace_indentation() {
|
|
let source = "func main() {\n work()\n}\n";
|
|
let state = state_for(source, "go");
|
|
let chunk = state.inner().chunk("fn_main").expect("fn_main");
|
|
|
|
let result = apply_single_edit(&state, "test.go", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\treturn\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(result.diff_after, "func main() {\n return\n}\n");
|
|
}
|
|
|
|
#[test]
|
|
fn three_space_body_replace_denormalizes_tabs_back_to_file_style() {
|
|
let source = "def run():\n return 1\n";
|
|
let state = state_for(source, "python");
|
|
let chunk = state.inner().chunk("fn_run").expect("fn_run");
|
|
|
|
let result = apply_single_edit(&state, "test.py", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("fn_run#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\treturn 2\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(result.diff_after, "def run():\n return 2\n");
|
|
}
|
|
|
|
#[test]
|
|
fn after_targets_chunk_directly_for_top_level_sibling_insertion() {
|
|
let source = "function alpha(): void {\n\twork();\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::After,
|
|
sel: Some("fn_alpha".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("function beta(): void {\n\twork();\n}\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(result.diff_after.contains("function alpha(): void"), "{}", result.diff_after);
|
|
assert!(result.diff_after.contains("function beta(): void"), "{}", result.diff_after);
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.find("function alpha(): void")
|
|
.expect("alpha")
|
|
< result
|
|
.diff_after
|
|
.find("function beta(): void")
|
|
.expect("beta")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn go_body_and_container_append_are_not_interchangeable() {
|
|
let source = "package main\n\ntype Server struct {\n Addr string\n}\n\nfunc (s *Server) \
|
|
Start() {\n work()\n}\n";
|
|
|
|
let body_state = state_for(source, "go");
|
|
let body_result = apply_single_edit(&body_state, "test.go", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some("type_Server@body".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\tPort int\n".to_owned()),
|
|
find: None,
|
|
});
|
|
assert!(
|
|
body_result
|
|
.diff_after
|
|
.contains("Addr string\n Port int\n}"),
|
|
"{}",
|
|
body_result.diff_after
|
|
);
|
|
assert!(
|
|
!body_result.diff_after.contains("func (s *Server) Port"),
|
|
"{}",
|
|
body_result.diff_after
|
|
);
|
|
|
|
let container_state = state_for(source, "go");
|
|
let container_result = apply_single_edit(&container_state, "test.go", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some("type_Server".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("func (s *Server) Stop() {\n\twork()\n}\n".to_owned()),
|
|
find: None,
|
|
});
|
|
assert!(
|
|
container_result
|
|
.diff_after
|
|
.contains("func (s *Server) Start()"),
|
|
"{}",
|
|
container_result.diff_after
|
|
);
|
|
assert!(
|
|
container_result
|
|
.diff_after
|
|
.contains("func (s *Server) Stop()"),
|
|
"{}",
|
|
container_result.diff_after
|
|
);
|
|
assert!(
|
|
container_result
|
|
.diff_after
|
|
.find("func (s *Server) Start()")
|
|
.expect("start")
|
|
< container_result
|
|
.diff_after
|
|
.find("func (s *Server) Stop()")
|
|
.expect("stop")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn go_type_container_append_after_receiver_methods_preserves_sibling_spacing() {
|
|
let source = "package main\n\ntype Server struct {}\n\nfunc (s *Server) Start() {}\nfunc (s \
|
|
*Server) Stop() {}\n";
|
|
let state = state_for(source, "go");
|
|
|
|
let result = apply_single_edit(&state, "test.go", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some("type_Server".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("func (s *Server) Restart() {}".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains("func (s *Server) Stop() {}\n\nfunc (s *Server) Restart() {}"),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn packed_toml_table_after_inserts_stay_tightly_packed() {
|
|
let source = "[dependencies]\nanyhow.workspace = true\nbytes.workspace = \
|
|
true\nserde.workspace = true\nsolar-interface.workspace = \
|
|
true\nparking_lot.workspace = true\nsolar-sema.workspace = \
|
|
true\ntokio.workspace = true\ntracing.workspace = true\n";
|
|
let state = state_for(source, "toml");
|
|
|
|
let result = apply_single_edit(&state, "Cargo.toml", EditOperation {
|
|
op: ChunkEditOp::After,
|
|
sel: Some("table_depend.key_parkin".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("rayon.workspace = true\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains(
|
|
"parking_lot.workspace = true\nrayon.workspace = true\nsolar-sema.workspace = true"
|
|
),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result
|
|
.diff_after
|
|
.contains("parking_lot.workspace = true\n\nrayon.workspace = true"),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result
|
|
.diff_after
|
|
.contains("rayon.workspace = true\n\nsolar-sema.workspace = true"),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn packed_top_level_typescript_variables_stay_tightly_packed() {
|
|
let source = "const a = 1;\nconst b = 2;\nconst c = 3;\n";
|
|
let state = state_for(source, "typescript");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::After,
|
|
sel: Some("var_b".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("const bb = 22;\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains("const b = 2;\nconst bb = 22;\nconst c = 3;"),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result.diff_after.contains("const b = 2;\n\nconst bb = 22;"),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result.diff_after.contains("const bb = 22;\n\nconst c = 3;"),
|
|
"{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn crc_mismatch_error_includes_fresh_chunk_context() {
|
|
let source = "class Foo {\n bar() {\n return 1;\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("class_Foo.fn_bar#ZZZZ".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("baz() { return 2; }".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: Some(ChunkAnchorStyle::Full),
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.ts".to_owned(),
|
|
normalize_indent: None,
|
|
});
|
|
let err = result.err().expect("should fail with stale CRC");
|
|
|
|
assert!(err.contains("Fresh content:"), "error should include fresh content: {err}");
|
|
assert!(err.contains("fn_bar"), "error should show the chunk with fresh anchor: {err}");
|
|
assert!(err.contains("class_Foo"), "error should show ancestor context: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn prologue_replace_preserves_newline_before_body() {
|
|
let source = "/// Old doc.\nfn main() {\n work();\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk = state.inner().chunk("fn_main").expect("fn_main");
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("fn_main#{}@head", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("/// New doc.\nfn main() {".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
// The body should NOT be joined onto the prologue line.
|
|
assert!(
|
|
!result.diff_after.contains("{ work"),
|
|
"prologue replace should not join body onto same line: {}",
|
|
result.diff_after
|
|
);
|
|
assert_eq!(result.diff_after, "/// New doc.\nfn main() {\n work();\n}\n",);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_table_pipes_preserved_in_replace() {
|
|
let new_table = "| Header A | Header B |\n| --- | --- |\n| cell A | cell B |\n";
|
|
|
|
// Simulate what normalize_inserted_content does to table content.
|
|
let result = super::normalize_inserted_content(new_table, "", None, ' ', true);
|
|
|
|
assert!(result.contains("| Header A"), "table pipes should not be stripped: {result}");
|
|
}
|
|
|
|
#[test]
|
|
fn container_prepend_creates_addressable_chunk() {
|
|
// Prepending without a @region inserts before the chunk. After tree rebuild,
|
|
// the inserted content should be addressable (either absorbed as trivia
|
|
// or as a new preamble/chunk), not orphaned.
|
|
let source = "const a = 1;\n\nstruct Config {\n host: String,\n}\n";
|
|
let state = state_for(source, "rust");
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Prepend,
|
|
sel: Some("struct_Config".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("// Config documentation\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
// The comment should exist in the output.
|
|
assert!(
|
|
result.diff_after.contains("// Config documentation"),
|
|
"prepended content should be in the file: {}",
|
|
result.diff_after
|
|
);
|
|
|
|
// Re-parse and check that every non-empty line is covered by some chunk.
|
|
let new_state = state_for(&result.diff_after, "rust");
|
|
let tree = new_state.inner().tree();
|
|
let lines: Vec<&str> = result.diff_after.split('\n').collect();
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let line_num = (i + 1) as u32;
|
|
let covered = tree
|
|
.chunks
|
|
.iter()
|
|
.any(|c| !c.path.is_empty() && c.start_line <= line_num && c.end_line >= line_num);
|
|
assert!(
|
|
covered,
|
|
"line {} ({:?}) should be covered by a chunk, but isn't. Chunks: {:?}",
|
|
line_num,
|
|
line,
|
|
tree
|
|
.chunks
|
|
.iter()
|
|
.filter(|c| !c.path.is_empty())
|
|
.map(|c| format!("{}:L{}-L{}", c.path, c.start_line, c.end_line))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn leaf_chunk_supports_body_region_read() {
|
|
// A small method (under LEAF_THRESHOLD) should still have region boundaries
|
|
// if it has a body delimiter.
|
|
let source = "class Foo {\n bar() {\n return 1;\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
let fn_bar = state.inner().chunk("class_Foo.fn_bar").expect("fn_bar");
|
|
assert!(fn_bar.leaf, "fn_bar should be a leaf chunk");
|
|
assert!(fn_bar.prologue_end_byte.is_some(), "leaf fn_bar should have prologue_end_byte set");
|
|
assert!(
|
|
fn_bar.epilogue_start_byte.is_some(),
|
|
"leaf fn_bar should have epilogue_start_byte set"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_body_replace_preserves_correct_indentation_4space() {
|
|
// 4-space file: method body at 2 levels of indent.
|
|
let source = "class Server {\n start() {\n work();\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("class_Server.fn_start")
|
|
.expect("fn_start");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\treturn 42;\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(
|
|
result.diff_after, "class Server {\n start() {\n return 42;\n }\n}\n",
|
|
"4-space: nested body replace should produce correct 2-level indent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_body_replace_preserves_correct_indentation_2space() {
|
|
// 2-space file: method body at 2 levels of indent.
|
|
let source = "class Server {\n start() {\n work();\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("class_Server.fn_start")
|
|
.expect("fn_start");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\treturn 42;\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(
|
|
result.diff_after, "class Server {\n start() {\n return 42;\n }\n}\n",
|
|
"2-space: nested body replace should produce correct 2-level indent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_body_replace_with_excess_tabs_corrected() {
|
|
// Agent accidentally includes base padding (2 tabs instead of 1).
|
|
// Correction mechanism should strip common indent and produce correct output.
|
|
let source = "class Server {\n start() {\n work();\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("class_Server.fn_start")
|
|
.expect("fn_start");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\t\tif (x) {\n\t\t\ty();\n\t\t}\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert_eq!(
|
|
result.diff_after,
|
|
"class Server {\n start() {\n if (x) {\n y();\n }\n }\n}\n",
|
|
"2-space: excess tabs should be corrected via dedent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn body_append_inserts_inside_class() {
|
|
// Appending to @body of a class should insert inside the body,
|
|
// not after the closing brace.
|
|
let source = "class Foo {\n bar() {\n return 1;\n }\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some("class_Foo@body".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("baz() {\n\treturn 2;\n}\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("baz()"),
|
|
"appended method should appear: {}",
|
|
result.diff_after
|
|
);
|
|
// baz should appear BEFORE the final closing brace
|
|
let baz_pos = result.diff_after.find("baz()").unwrap();
|
|
let last_brace = result.diff_after.rfind('}').unwrap();
|
|
assert!(
|
|
baz_pos < last_brace,
|
|
"baz() at {baz_pos} should be before last '}}' at {last_brace}: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn body_prepend_inserts_after_opening_brace() {
|
|
// Prepending to @body of an enum should insert after the opening brace,
|
|
// not before doc comments.
|
|
let source = "/** My enum. */\nenum Color {\n Red,\n Green,\n Blue,\n}\n";
|
|
let state = state_for(source, "typescript");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Prepend,
|
|
sel: Some("enum_Color@body".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("White,\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("White"),
|
|
"prepended variant should appear: {}",
|
|
result.diff_after
|
|
);
|
|
// White should appear AFTER the opening brace, before Red
|
|
let white_pos = result.diff_after.find("White").unwrap();
|
|
let red_pos = result.diff_after.find("Red").unwrap();
|
|
let doc_pos = result.diff_after.find("/** My enum.").unwrap();
|
|
assert!(white_pos > doc_pos, "White should be after doc comment: {}", result.diff_after);
|
|
assert!(white_pos < red_pos, "White should be before Red: {}", result.diff_after);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_list_replace_preserves_trailing_blank_line() {
|
|
let source = "# Title\n\n- item 1\n- item 2\n\n## Next\n";
|
|
let state = state_for(source, "markdown");
|
|
let list = state
|
|
.inner()
|
|
.tree
|
|
.chunks
|
|
.iter()
|
|
.find(|c| {
|
|
c.path
|
|
.rsplit('.')
|
|
.next()
|
|
.is_some_and(|leaf| leaf.starts_with("list"))
|
|
})
|
|
.expect("list chunk");
|
|
|
|
let result = apply_single_edit(&state, "test.md", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("{}#{}", list.path, list.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("- new 1\n- new 2\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
// The blank line between the list and ## Next must be preserved.
|
|
assert!(
|
|
result.diff_after.contains("- new 2\n\n## Next"),
|
|
"blank line between list and heading should be preserved: {:?}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_after_preserves_blank_line_before_next_section() {
|
|
let source = "# Title\n\n## Alpha\n\nalpha body\n\n## Beta\n\nbeta body\n";
|
|
let state = state_for(source, "markdown");
|
|
let section = state
|
|
.inner()
|
|
.chunk("sect_Title.sect_Alpha")
|
|
.expect("alpha section");
|
|
|
|
let result = apply_single_edit(&state, "test.md", EditOperation {
|
|
op: ChunkEditOp::After,
|
|
sel: Some(format!("{}#{}", section.path, section.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("## Inserted\n\ninserted body\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains("## Inserted\n\ninserted body\n\n## Beta"),
|
|
"blank line between inserted section and next heading should be preserved: {:?}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_body_append_preserves_blank_line_before_next_section() {
|
|
let source = "# Title\n\n## Alpha\n\nalpha body\n\n## Beta\n\nbeta body\n";
|
|
let state = state_for(source, "markdown");
|
|
let section = state
|
|
.inner()
|
|
.chunk("sect_Title.sect_Alpha")
|
|
.expect("alpha section");
|
|
|
|
let result = apply_single_edit(&state, "test.md", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some(format!("{}#{}@body", section.path, section.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("\nextra paragraph\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains("alpha body\n\n extra paragraph\n\n## Beta"),
|
|
"blank line between appended body content and next heading should be preserved: {:?}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rust_trait_members_are_addressable() {
|
|
let source = "trait Handler {\n fn handle(&self, req: &str) -> String;\n fn \
|
|
name(&self) -> &str;\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let tree = state.inner().tree();
|
|
|
|
let trait_chunk = tree
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.path == "trait_Handle")
|
|
.expect("trait_Handle should exist");
|
|
|
|
// Trait members should be listed as children even when they're
|
|
// single-line signatures (not collapsed as trivial).
|
|
assert!(
|
|
!trait_chunk.children.is_empty(),
|
|
"trait_Handle should have children, got leaf. Chunks: {:?}",
|
|
tree.chunks.iter().map(|c| &c.path).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn python_body_append_preserves_indentation() {
|
|
let source = "class Server:\n def __init__(self):\n self.x = 1\n\n def \
|
|
start(self):\n pass\n";
|
|
let state = state_for(source, "python");
|
|
|
|
let result = apply_single_edit(&state, "test.py", EditOperation {
|
|
op: ChunkEditOp::Append,
|
|
sel: Some("class_Server@body".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("def stop(self):\n\tpass\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
// The appended method should be at 4-space indent (class member level),
|
|
// with its body at 8-space indent.
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains(" def stop(self):\n pass"),
|
|
"appended method should have correct Python indentation: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn body_region_on_leaf_without_delimiters_falls_back_to_full_chunk() {
|
|
let source = "enum LogLevel {\n Debug,\n Info,\n Warn,\n Fatal,\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("enum_LogLev.vrnt_Info")
|
|
.expect("vrnt_Info should exist");
|
|
assert!(chunk.prologue_end_byte.is_none(), "leaf variant should not have prologue_end_byte");
|
|
|
|
for region_name in ["body", "head", "tail"] {
|
|
let sel = format!("enum_LogLev.vrnt_Info#{}@{}", chunk.checksum, region_name);
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(sel),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("Error,".to_owned()),
|
|
find: None,
|
|
}],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.rs".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("leaf region should fall back to full chunk");
|
|
|
|
assert!(
|
|
result.diff_after.contains("Debug,\n Error,\n Warn,"),
|
|
"@{region_name} should replace the full leaf chunk, got: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn rust_impl_method_head_replace_no_body_duplication() {
|
|
let source = concat!(
|
|
"struct Server {
|
|
",
|
|
" running: bool,
|
|
",
|
|
"}
|
|
",
|
|
"
|
|
",
|
|
"impl Server {
|
|
",
|
|
" /// Starts the server.
|
|
",
|
|
" pub fn start(&mut self) {
|
|
",
|
|
" self.running = true;
|
|
",
|
|
" println!(\"started\");
|
|
",
|
|
" }
|
|
",
|
|
"}
|
|
",
|
|
);
|
|
let state = state_for(source, "rust");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("impl_Server.fn_start")
|
|
.expect("impl_Server.fn_start should exist");
|
|
assert!(
|
|
chunk.prologue_end_byte.is_some(),
|
|
"fn_start should have prologue_end_byte, got: start_byte={}, end_byte={}, \
|
|
prologue_end_byte={:?}, epilogue_start_byte={:?}",
|
|
chunk.start_byte,
|
|
chunk.end_byte,
|
|
chunk.prologue_end_byte,
|
|
chunk.epilogue_start_byte,
|
|
);
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("impl_Server.fn_start#{}@head", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some(
|
|
" /// Initializes and starts the server.
|
|
pub fn start(&mut self) {"
|
|
.to_owned(),
|
|
),
|
|
find: None,
|
|
});
|
|
|
|
let body_count = result.diff_after.matches("self.running = true;").count();
|
|
assert_eq!(
|
|
body_count, 1,
|
|
"body should appear exactly once after @head replace, got {} occurrences in:
|
|
{}",
|
|
body_count, result.diff_after
|
|
);
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains("/// Initializes and starts the server."),
|
|
"new doc comment should be in output:
|
|
{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result.diff_after.contains("/// Starts the server."),
|
|
"old doc comment should be removed:
|
|
{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typescript_class_method_head_replace_no_body_duplication() {
|
|
let source = concat!(
|
|
"class Server {
|
|
",
|
|
" /** Starts the server. */
|
|
",
|
|
" start() {
|
|
",
|
|
" this.running = true;
|
|
",
|
|
" console.log(\"started\");
|
|
",
|
|
" }
|
|
",
|
|
"}
|
|
",
|
|
);
|
|
let state = state_for(source, "typescript");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("class_Server.fn_start")
|
|
.expect("class_Server.fn_start should exist");
|
|
assert!(chunk.prologue_end_byte.is_some(), "fn_start should have prologue_end_byte");
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some(
|
|
" /** Initializes the server. */
|
|
start() {"
|
|
.to_owned(),
|
|
),
|
|
find: None,
|
|
});
|
|
|
|
let body_count = result.diff_after.matches("this.running = true;").count();
|
|
assert_eq!(
|
|
body_count, 1,
|
|
"body should appear exactly once after @head replace, got {} occurrences in:
|
|
{}",
|
|
body_count, result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("/** Initializes the server. */"),
|
|
"new doc comment should be in output:
|
|
{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn python_body_replace_does_not_corrupt_surrounding_code() {
|
|
let source =
|
|
"import os\n\ndef main():\n x = 1\n print(x)\n\ndef helper():\n return 42\n";
|
|
let state = state_for(source, "python");
|
|
let chunk = state.inner().chunk("fn_main").expect("fn_main");
|
|
|
|
let result = apply_single_edit(&state, "test.py", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("y = 2\nprint(y)\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("import os"),
|
|
"imports should survive body replace: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("def main"),
|
|
"function head should survive body replace: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("y = 2"),
|
|
"replacement body should appear: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("def helper"),
|
|
"sibling function should survive body replace: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("return 42"),
|
|
"sibling function body should survive: {}",
|
|
result.diff_after
|
|
);
|
|
// Imports should remain at column 0, not indented
|
|
assert!(
|
|
result.diff_after.starts_with("import os"),
|
|
"import should be at column 0: {:?}",
|
|
&result.diff_after[..40.min(result.diff_after.len())]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn python_head_replace_does_not_orphan_body() {
|
|
let source = "class Server:\n def start(self) -> None:\n self.running = True\n";
|
|
let state = state_for(source, "python");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("class_Server.fn_start")
|
|
.expect("fn_start");
|
|
|
|
let result = apply_single_edit(&state, "test.py", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("def begin(self) -> None:\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("def begin"),
|
|
"replaced head should appear: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("self.running = True"),
|
|
"body should survive head replace: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn python_body_prepend_has_correct_indentation() {
|
|
let source = "def main():\n x = 1\n print(x)\n";
|
|
let state = state_for(source, "python");
|
|
|
|
let result = apply_single_edit(&state, "test.py", EditOperation {
|
|
op: ChunkEditOp::Prepend,
|
|
sel: Some("fn_main@body".to_owned()),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("y = 0\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("y = 0"),
|
|
"prepended content should appear: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("x = 1"),
|
|
"existing body should survive: {}",
|
|
result.diff_after
|
|
);
|
|
// The prepended content should be at the body indent level
|
|
assert!(
|
|
result.diff_after.contains(" y = 0"),
|
|
"prepended content should be at body indent: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn python_class_body_replace_preserves_structure() {
|
|
let source =
|
|
"class Server:\n def start(self):\n pass\n\n def stop(self):\n pass\n";
|
|
let state = state_for(source, "python");
|
|
let chunk = state.inner().chunk("class_Server").expect("class_Server");
|
|
|
|
let result = apply_single_edit(&state, "test.py", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("class_Server#{}@body", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some("def run(self):\n\tpass\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result.diff_after.contains("class Server:"),
|
|
"class header should survive body replace: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("def run(self)"),
|
|
"replaced body should appear: {}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result.diff_after.contains("def start"),
|
|
"old body should be replaced: {}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_chunk_replace_includes_leading_trivia_in_range() {
|
|
// Whole-chunk replace covers the full range including absorbed leading
|
|
// trivia (comments, attributes). If the replacement omits the trivia,
|
|
// it gets dropped — the read output shows the trivia as part of the
|
|
// chunk so the LLM knows to include it.
|
|
let source = "#[cfg(test)]\nmod tests {\n\tuse super::*;\n\n\t#[test]\n\tfn my_test() \
|
|
{\n\t\told();\n\t}\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("mod_tests.fn_my_tes")
|
|
.expect("mod_tests.fn_my_tes should exist");
|
|
|
|
// Verify the chunk absorbs the #[test] attribute as leading trivia.
|
|
assert!(
|
|
chunk.start_byte < chunk.checksum_start_byte,
|
|
"chunk should have absorbed leading trivia (start_byte {} < checksum_start_byte {})",
|
|
chunk.start_byte,
|
|
chunk.checksum_start_byte
|
|
);
|
|
|
|
// Replace the function WITHOUT including #[test] in the content.
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("mod_tests.fn_my_tes".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: None,
|
|
content: Some("fn my_test() {\n\tnew();\n}".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
// #[test] is dropped because the replacement didn't include it.
|
|
assert!(
|
|
!result.diff_after.contains("#[test]"),
|
|
"#[test] should be dropped when omitted from replacement. Full text:\n{}",
|
|
result.diff_after
|
|
);
|
|
|
|
// Verify the read output shows #[test] as part of the chunk's content
|
|
// so the LLM can see it needs to be included.
|
|
let read_output = crate::chunk::render::render_state(state.inner(), &RenderParams {
|
|
chunk_path: Some(String::new()),
|
|
title: "test.rs".to_owned(),
|
|
language_tag: Some("rust".to_owned()),
|
|
visible_range: None,
|
|
render_children_only: true,
|
|
omit_checksum: true,
|
|
anchor_style: Some(ChunkAnchorStyle::Full),
|
|
show_leaf_preview: true,
|
|
tab_replacement: Some(" ".to_owned()),
|
|
normalize_indent: Some(true),
|
|
focused_paths: None,
|
|
});
|
|
println!("=== READ OUTPUT ===\n{read_output}\n=== END ===");
|
|
assert!(
|
|
read_output.contains("#[test]"),
|
|
"read output must show #[test] as part of the chunk. Output:\n{read_output}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decl_region_replaces_without_leading_trivia() {
|
|
// @decl covers checksum_start_byte → end_byte: the declaration itself
|
|
// without leading trivia. Leading comments/attributes are preserved.
|
|
let source = "#[cfg(test)]\nmod tests {\n\tuse super::*;\n\n\t#[test]\n\tfn my_test() \
|
|
{\n\t\told();\n\t}\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("mod_tests.fn_my_tes")
|
|
.expect("fn_my_tes should exist");
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("mod_tests.fn_my_tes".to_owned()),
|
|
crc: Some(chunk.checksum.clone()),
|
|
region: Some(ChunkRegion::Decl),
|
|
content: Some("fn my_test() {\n\tnew();\n}".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
// #[test] should be preserved — @decl doesn't cover leading trivia.
|
|
assert!(
|
|
result.diff_after.contains("#[test]"),
|
|
"#[test] should be preserved with @decl. Full text:\n{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("new()"),
|
|
"replacement body should appear. Full text:\n{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_chunk_replace_shows_diff_hunks_after_attribute_restoration() {
|
|
// Bug 2: After a first edit drops #[test] (bug 1), a follow-up edit that
|
|
// adds it back should show diff hunks in the response text.
|
|
// Uses a module with multiple functions and a batch of two replacements
|
|
// to match the real-world scenario.
|
|
let source = "\
|
|
#[cfg(test)]\nmod tests {\n\tuse super::*;\n\n\tfn test_alpha() {\n\t\told_alpha();\n\t}\n\n\tfn \
|
|
test_middle() {\n\t\tmiddle();\n\t}\n\n\tfn test_beta() \
|
|
{\n\t\told_beta();\n\t}\n}\n";
|
|
let state = state_for(source, "rust");
|
|
let chunk_a = state
|
|
.inner()
|
|
.chunk("mod_tests.fn_test_a")
|
|
.expect("fn_test_a should exist");
|
|
let chunk_b = state
|
|
.inner()
|
|
.chunk("mod_tests.fn_test_b")
|
|
.expect("fn_test_b should exist");
|
|
|
|
// Batch replace: add #[test] to both functions.
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![
|
|
EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("mod_tests.fn_test_a".to_owned()),
|
|
crc: Some(chunk_a.checksum.clone()),
|
|
region: None,
|
|
content: Some("#[test]\nfn test_alpha() {\n\tnew_alpha();\n}".to_owned()),
|
|
find: None,
|
|
},
|
|
EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("mod_tests.fn_test_b".to_owned()),
|
|
crc: Some(chunk_b.checksum.clone()),
|
|
region: None,
|
|
content: Some("#[test]\nfn test_beta() {\n\tnew_beta();\n}".to_owned()),
|
|
find: None,
|
|
},
|
|
],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.rs".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should apply");
|
|
|
|
assert!(result.changed, "edit should be detected as a change");
|
|
assert!(
|
|
result.diff_after.contains("#[test]"),
|
|
"#[test] should be in the result. Full text:\n{}",
|
|
result.diff_after
|
|
);
|
|
// The response text should contain diff hunks (@@) showing the changes.
|
|
assert!(
|
|
result.response_text.contains("@@"),
|
|
"response should include diff hunks showing the changes. Response:\n{}",
|
|
result.response_text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_hunks_shown_for_non_leaf_function_replacement() {
|
|
// Bug 2 (realistic): When replacing functions that have children
|
|
// (sub-chunks like stmts, let bindings), the diff hunks should still
|
|
// appear in the response. Mirrors the real-world scenario where only
|
|
// #[test] is added and the function body stays identical.
|
|
let source =
|
|
"\
|
|
#[cfg(test)]\nmod tests {\n\tuse super::*;\n\n\tfn test_alpha() {\n\t\tlet mut config = \
|
|
base_config();\n\t\tconfig.enabled = Some(false);\n\t\tconfig.max_items = \
|
|
Some(10);\n\n\t\tlet Err(error) = build_options(&config) else {\n\t\t\tpanic!(\"should \
|
|
fail\");\n\t\t};\n\t\tassert_error_contains(&error, \"cannot be \
|
|
combined\");\n\t}\n\n\tfn test_middle() {\n\t\tmiddle();\n\t}\n\n\tfn test_beta() \
|
|
{\n\t\tlet mut config = base_config();\n\t\tconfig.enabled = \
|
|
Some(true);\n\t\tconfig.max_size = Some(0);\n\n\t\tlet Err(error) = \
|
|
build_options(&config) else {\n\t\t\tpanic!(\"must be \
|
|
positive\");\n\t\t};\n\t\tassert_error_contains(&error, \"must be positive\");\n\t}\n}\n";
|
|
let state = state_for(source, "rust");
|
|
|
|
// Verify the functions have children (sub-chunks).
|
|
let chunk_a = state
|
|
.inner()
|
|
.chunk("mod_tests.fn_test_a")
|
|
.expect("fn_test_a should exist");
|
|
assert!(
|
|
!chunk_a.children.is_empty(),
|
|
"fn_test_a should have children (sub-chunks), got: {:?}",
|
|
chunk_a.children
|
|
);
|
|
let chunk_b = state
|
|
.inner()
|
|
.chunk("mod_tests.fn_test_b")
|
|
.expect("fn_test_b should exist");
|
|
assert!(
|
|
!chunk_b.children.is_empty(),
|
|
"fn_test_b should have children (sub-chunks), got: {:?}",
|
|
chunk_b.children
|
|
);
|
|
|
|
// Replace both functions: only adding #[test], body is identical.
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![
|
|
EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("mod_tests.fn_test_a".to_owned()),
|
|
crc: Some(chunk_a.checksum.clone()),
|
|
region: None,
|
|
content: Some(
|
|
"#[test]\nfn test_alpha() {\n\tlet mut config = \
|
|
base_config();\n\tconfig.enabled = Some(false);\n\tconfig.max_items = \
|
|
Some(10);\n\n\tlet Err(error) = build_options(&config) else \
|
|
{\n\t\tpanic!(\"should fail\");\n\t};\n\tassert_error_contains(&error, \
|
|
\"cannot be combined\");\n}"
|
|
.to_owned(),
|
|
),
|
|
find: None,
|
|
},
|
|
EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some("mod_tests.fn_test_b".to_owned()),
|
|
crc: Some(chunk_b.checksum.clone()),
|
|
region: None,
|
|
content: Some(
|
|
"#[test]\nfn test_beta() {\n\tlet mut config = base_config();\n\tconfig.enabled \
|
|
= Some(true);\n\tconfig.max_size = Some(0);\n\n\tlet Err(error) = \
|
|
build_options(&config) else {\n\t\tpanic!(\"must be \
|
|
positive\");\n\t};\n\tassert_error_contains(&error, \"must be positive\");\n}"
|
|
.to_owned(),
|
|
),
|
|
find: None,
|
|
},
|
|
],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.rs".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("edit should apply");
|
|
|
|
assert!(result.changed, "edit should be detected as a change");
|
|
assert!(result.diff_before != result.diff_after, "diff_before and diff_after should differ");
|
|
// Count actual diff hunks.
|
|
let hunks = super::generate_diff_hunks(&result.diff_before, &result.diff_after, 0);
|
|
assert!(
|
|
!hunks.is_empty(),
|
|
"generate_diff_hunks should produce non-empty hunks.\ndiff_before:\n{}\ndiff_after:\n{}",
|
|
result.diff_before,
|
|
result.diff_after,
|
|
);
|
|
// The response text should contain diff hunks (@@) showing the changes.
|
|
assert!(
|
|
result.response_text.contains("@@"),
|
|
"response should include diff hunks showing the changes.\nhunks: {}\nResponse:\n{}",
|
|
hunks.len(),
|
|
result.response_text,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conflicted_reads_render_conflict_children_and_both_sides() {
|
|
let source = "\
|
|
function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>>>> topic\n}\n";
|
|
let state = parsed_state_for(source, "typescript");
|
|
assert!(state.has_conflicts());
|
|
assert_eq!(state.conflict_count(), 1);
|
|
|
|
let conflict = state
|
|
.inner()
|
|
.chunks()
|
|
.find(|chunk| chunk.kind == ChunkKind::Conflict)
|
|
.expect("conflict chunk should exist")
|
|
.clone();
|
|
let rendered = state
|
|
.render_read(crate::chunk::types::ReadRenderParams {
|
|
read_path: String::new(),
|
|
display_path: "test.ts".to_owned(),
|
|
language_tag: Some("ts".to_owned()),
|
|
omit_checksum: false,
|
|
anchor_style: Some(ChunkAnchorStyle::Full),
|
|
absolute_line_range: None,
|
|
tab_replacement: Some(" ".to_owned()),
|
|
normalize_indent: Some(true),
|
|
})
|
|
.expect("render should succeed");
|
|
|
|
assert!(rendered.text.contains(conflict.path.as_str()));
|
|
assert!(
|
|
rendered
|
|
.text
|
|
.contains(format!("{}.ours", conflict.path).as_str())
|
|
);
|
|
assert!(
|
|
rendered
|
|
.text
|
|
.contains(format!("{}.theirs", conflict.path).as_str())
|
|
);
|
|
assert!(rendered.text.contains("return bar();"));
|
|
assert!(rendered.text.contains("return baz();"));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_ours_accepts_theirs() {
|
|
let source = "\
|
|
function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>>>> topic\n}\n";
|
|
let state = parsed_state_for(source, "typescript");
|
|
let ours = state
|
|
.inner()
|
|
.chunks()
|
|
.find(|chunk| chunk.kind == ChunkKind::Ours)
|
|
.expect("ours chunk should exist")
|
|
.clone();
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Delete,
|
|
sel: Some(ours.path.clone()),
|
|
crc: Some(ours.checksum),
|
|
region: None,
|
|
content: None,
|
|
find: None,
|
|
});
|
|
|
|
assert!(!result.state.has_conflicts());
|
|
assert!(result.diff_before.contains("<<<<<<< HEAD"));
|
|
assert!(result.diff_after.contains("return baz();"));
|
|
assert!(!result.diff_after.contains("<<<<<<<"));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_theirs_accepts_ours() {
|
|
let source = "\
|
|
function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>>>> topic\n}\n";
|
|
let state = parsed_state_for(source, "typescript");
|
|
let theirs = state
|
|
.inner()
|
|
.chunks()
|
|
.find(|chunk| chunk.kind == ChunkKind::Theirs)
|
|
.expect("theirs chunk should exist")
|
|
.clone();
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Delete,
|
|
sel: Some(theirs.path.clone()),
|
|
crc: Some(theirs.checksum),
|
|
region: None,
|
|
content: None,
|
|
find: None,
|
|
});
|
|
|
|
assert!(!result.state.has_conflicts());
|
|
assert!(result.diff_after.contains("return bar();"));
|
|
assert!(!result.diff_after.contains("<<<<<<<"));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_conflict_manually_merges_and_clears_metadata() {
|
|
let source = "\
|
|
function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>>>> topic\n}\n";
|
|
let state = parsed_state_for(source, "typescript");
|
|
let conflict = state
|
|
.inner()
|
|
.chunks()
|
|
.find(|chunk| chunk.kind == ChunkKind::Conflict)
|
|
.expect("conflict chunk should exist")
|
|
.clone();
|
|
|
|
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(conflict.path.clone()),
|
|
crc: Some(conflict.checksum),
|
|
region: None,
|
|
content: Some("\treturn qux();\n".to_owned()),
|
|
find: None,
|
|
});
|
|
|
|
assert!(!result.state.has_conflicts());
|
|
assert!(result.diff_after.contains("return qux();"));
|
|
assert!(!result.diff_after.contains("<<<<<<<"));
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_conflicts_survive_rebuilds_within_a_batch() {
|
|
let source = "\
|
|
function foo() {\n<<<<<<< HEAD\n\treturn bar();\n=======\n\treturn baz();\n>>>>>>> topic\n}\n";
|
|
let state = parsed_state_for(source, "typescript");
|
|
let conflict = state
|
|
.inner()
|
|
.chunks()
|
|
.find(|chunk| chunk.kind == ChunkKind::Conflict)
|
|
.expect("conflict chunk should exist")
|
|
.clone();
|
|
let ours = state
|
|
.inner()
|
|
.chunk(format!("{}.ours", conflict.path).as_str())
|
|
.expect("ours child should exist")
|
|
.clone();
|
|
let theirs = state
|
|
.inner()
|
|
.chunk(format!("{}.theirs", conflict.path).as_str())
|
|
.expect("theirs child should exist")
|
|
.clone();
|
|
|
|
let result = apply_edits(&state, &EditParams {
|
|
operations: vec![
|
|
EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(ours.path.clone()),
|
|
crc: Some(ours.checksum),
|
|
region: None,
|
|
content: Some("\treturn bar(1);\n".to_owned()),
|
|
find: None,
|
|
},
|
|
EditOperation {
|
|
op: ChunkEditOp::Delete,
|
|
sel: Some(theirs.path.clone()),
|
|
crc: Some(theirs.checksum),
|
|
region: None,
|
|
content: None,
|
|
find: None,
|
|
},
|
|
],
|
|
default_selector: None,
|
|
default_crc: None,
|
|
anchor_style: None,
|
|
cwd: ".".to_owned(),
|
|
file_path: "test.ts".to_owned(),
|
|
normalize_indent: None,
|
|
})
|
|
.expect("batch edit should apply");
|
|
|
|
assert!(!result.state.has_conflicts());
|
|
assert!(result.diff_after.contains("return bar(1);"));
|
|
assert!(!result.diff_after.contains("<<<<<<<"));
|
|
}
|
|
|
|
#[test]
|
|
fn exported_decorated_class_is_addressable() {
|
|
let source = concat!(
|
|
"function sealed(target: any) {}\n",
|
|
"\n",
|
|
"@sealed\n",
|
|
"export class Server {\n",
|
|
" start(): void {\n",
|
|
" console.log(\"starting\");\n",
|
|
" }\n",
|
|
" stop(): void {\n",
|
|
" console.log(\"stopping\");\n",
|
|
" }\n",
|
|
"}\n",
|
|
"\n",
|
|
"function formatLog(msg: string): string {\n",
|
|
" return `[LOG] ${msg}`;\n",
|
|
"}\n",
|
|
);
|
|
let state = state_for(source, "typescript");
|
|
let tree = state.inner().tree();
|
|
|
|
let class_chunk = tree
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.path == "class_Server")
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"class_Server should be in the chunk tree. Available chunks: {:?}",
|
|
tree.chunks.iter().map(|c| &c.path).collect::<Vec<_>>()
|
|
)
|
|
});
|
|
assert!(!class_chunk.children.is_empty(), "class_Server should have child methods");
|
|
|
|
let start = state.inner().chunk("class_Server.fn_start");
|
|
assert!(start.is_some(), "class_Server.fn_start should exist");
|
|
let stop = state.inner().chunk("class_Server.fn_stop");
|
|
assert!(stop.is_some(), "class_Server.fn_stop should exist");
|
|
}
|
|
|
|
#[test]
|
|
fn head_replace_on_nested_rust_fn_uniform_indent() {
|
|
let source = concat!(
|
|
"pub struct Server {\n",
|
|
"\thost: String,\n",
|
|
"\tport: u16,\n",
|
|
"}\n",
|
|
"\n",
|
|
"impl Server {\n",
|
|
"\tpub fn address(&self) -> String {\n",
|
|
"\t\tformat!(\"{}:{}\", self.host, self.port)\n",
|
|
"\t}\n",
|
|
"}\n",
|
|
);
|
|
let state = state_for(source, "rust");
|
|
let chunk = state
|
|
.inner()
|
|
.chunk("impl_Server.fn_addres")
|
|
.expect("impl_Server.fn_addres should exist");
|
|
|
|
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
|
op: ChunkEditOp::Replace,
|
|
sel: Some(format!("impl_Server.fn_addres#{}@head", chunk.checksum)),
|
|
crc: None,
|
|
region: None,
|
|
content: Some(
|
|
"/// Returns the server address.\n#[must_use]\npub fn address(&self) -> String {\n"
|
|
.to_owned(),
|
|
),
|
|
find: None,
|
|
});
|
|
|
|
assert!(
|
|
result
|
|
.diff_after
|
|
.contains("\t/// Returns the server address."),
|
|
"doc comment should be at 1-tab indent, got:\n{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("\t#[must_use]"),
|
|
"attribute should be at 1-tab indent, got:\n{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
result.diff_after.contains("\tpub fn address"),
|
|
"signature should be at 1-tab indent, got:\n{}",
|
|
result.diff_after
|
|
);
|
|
assert!(
|
|
!result.diff_after.contains("\t\t///"),
|
|
"doc comment must NOT be double-indented, got:\n{}",
|
|
result.diff_after
|
|
);
|
|
}
|
|
}
|