use std::path::Path;
use crate::context::AppContext;
use crate::edit::{self, line_col_to_byte};
use crate::protocol::{RawRequest, Response};
struct ResolvedEdit {
byte_start: usize,
byte_end: usize,
replacement: String,
}
pub fn handle_batch(req: &RawRequest, ctx: &AppContext) -> Response {
let file = match req.params.get("file").and_then(|v| v.as_str()) {
Some(f) => f,
None => {
return Response::error(
&req.id,
"invalid_request",
"batch: missing required param 'file'",
);
}
};
let edits = match req.params.get("edits").and_then(|v| v.as_array()) {
Some(e) => e,
None => {
return Response::error(
&req.id,
"invalid_request",
"batch: missing required param 'edits' (expected array)",
);
}
};
if edits.is_empty() {
return Response::error(
&req.id,
"invalid_request",
"batch: 'edits' array must not be empty",
);
}
let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Err(resp) => return resp,
};
if !path.exists() {
return Response::error(
&req.id,
"file_not_found",
format!("file not found: {}", file),
);
}
let source = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
}
};
let mut resolved: Vec<ResolvedEdit> = Vec::with_capacity(edits.len());
for (i, edit_val) in edits.iter().enumerate() {
match resolve_edit(&source, edit_val, i, &req.id) {
Ok(r) => resolved.push(r),
Err(resp) => return resp,
}
}
let backup_id = match edit::auto_backup(ctx, req.session(), &path, "batch: pre-batch backup") {
Ok(id) => id,
Err(e) => {
return Response::error(&req.id, e.code(), e.to_string());
}
};
resolved.sort_by(|a, b| b.byte_start.cmp(&a.byte_start));
for i in 0..resolved.len().saturating_sub(1) {
let higher = &resolved[i];
let lower = &resolved[i + 1];
if lower.byte_end > higher.byte_start {
return Response::error(
&req.id,
"overlapping_edits",
format!(
"batch: edits overlap — edit at bytes [{}..{}) overlaps with edit at bytes [{}..{})",
lower.byte_start, lower.byte_end, higher.byte_start, higher.byte_end
),
);
}
}
let mut content = source.clone();
for r in &resolved {
content = match edit::replace_byte_range(&content, r.byte_start, r.byte_end, &r.replacement)
{
Ok(updated) => updated,
Err(e) => {
return Response::error(&req.id, e.code(), e.to_string());
}
};
}
let mut write_result =
match edit::write_format_validate(&path, &content, &ctx.config(), &req.params) {
Ok(r) => r,
Err(e) => {
return Response::error(&req.id, e.code(), e.to_string());
}
};
if let Ok(final_content) = std::fs::read_to_string(&path) {
write_result.lsp_outcome = ctx.lsp_post_write(&path, &final_content, &req.params);
}
log::debug!("batch: {} edits in {}", edits.len(), file);
let syntax_valid = write_result.syntax_valid.unwrap_or(true);
let mut result = serde_json::json!({
"file": file,
"edits_applied": edits.len(),
"syntax_valid": syntax_valid,
"formatted": write_result.formatted,
});
if let Some(ref reason) = write_result.format_skipped_reason {
result["format_skipped_reason"] = serde_json::json!(reason);
}
if write_result.validate_requested {
result["validation_errors"] = serde_json::json!(write_result.validation_errors);
}
if let Some(ref reason) = write_result.validate_skipped_reason {
result["validate_skipped_reason"] = serde_json::json!(reason);
}
if let Some(ref id) = backup_id {
result["backup_id"] = serde_json::json!(id);
}
write_result.append_lsp_diagnostics_to(&mut result);
Response::success(&req.id, result)
}
fn resolve_edit(
source: &str,
edit_val: &serde_json::Value,
index: usize,
req_id: &str,
) -> Result<ResolvedEdit, Response> {
let match_str = edit_val
.get("match")
.or_else(|| edit_val.get("oldString"))
.and_then(|v| v.as_str());
if let Some(match_str) = match_str {
let replacement = edit_val
.get("replacement")
.or_else(|| edit_val.get("newString"))
.and_then(|v| v.as_str())
.unwrap_or("");
let fuzzy_matches = crate::fuzzy_match::find_all_fuzzy(source, match_str);
if fuzzy_matches.is_empty() {
return Err(Response::error(
req_id,
"batch_edit_failed",
format!(
"batch: edit[{}] match '{}' not found in file",
index, match_str
),
));
}
if fuzzy_matches[0].pass > 1 {
log::debug!(
"batch: edit[{}] fuzzy match (pass {}) for '{}'",
index,
fuzzy_matches[0].pass,
match_str
);
}
if fuzzy_matches.len() > 1 {
if let Some(occ) = edit_val.get("occurrence").and_then(|v| v.as_u64()) {
let occ = occ as usize;
if occ >= fuzzy_matches.len() {
return Err(Response::error(
req_id,
"batch_edit_failed",
format!(
"batch: edit[{}] occurrence {} out of range (found {} occurrences)",
index,
occ,
fuzzy_matches.len()
),
));
}
let m = &fuzzy_matches[occ];
return Ok(ResolvedEdit {
byte_start: m.byte_start,
byte_end: m.byte_start + m.byte_len,
replacement: replacement.to_string(),
});
}
return Err(Response::error(
req_id,
"batch_edit_failed",
format!(
"batch: edit[{}] match '{}' is ambiguous ({} occurrences, expected 1). Use 'occurrence' field (0-indexed) to select which one.",
index,
match_str,
fuzzy_matches.len()
),
));
}
let m = &fuzzy_matches[0];
Ok(ResolvedEdit {
byte_start: m.byte_start,
byte_end: m.byte_start + m.byte_len,
replacement: replacement.to_string(),
})
} else if edit_val.get("line_start").is_some() {
let line_start_1based = edit_val
.get("line_start")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.ok_or_else(|| {
Response::error(
req_id,
"invalid_request",
format!(
"batch: edit[{}] 'line_start' must be a positive integer (1-based)",
index
),
)
})?;
if line_start_1based == 0 {
return Err(Response::error(
req_id,
"invalid_request",
format!("batch: edit[{}] 'line_start' must be >= 1 (1-based)", index),
));
}
let line_start = line_start_1based - 1;
let line_end_1based = edit_val
.get("line_end")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.ok_or_else(|| {
Response::error(
req_id,
"invalid_request",
format!(
"batch: edit[{}] 'line_end' must be a positive integer (1-based)",
index
),
)
})?;
if line_end_1based == 0 {
return Err(Response::error(
req_id,
"invalid_request",
format!("batch: edit[{}] 'line_end' must be >= 1 (1-based)", index),
));
}
let line_end = line_end_1based - 1;
let content = edit_val
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("");
let lines: Vec<&str> = source.lines().collect();
let total_lines = lines.len();
if line_start > total_lines {
return Err(Response::error(
req_id,
"batch_edit_failed",
format!(
"batch: edit[{}] line_start {} out of range (file has {} lines)",
index, line_start_1based, total_lines
),
));
}
if line_start == total_lines {
let byte_pos = source.len();
let mut replacement_str = content.to_string();
if !source.ends_with('\n') && !replacement_str.starts_with('\n') {
replacement_str.insert(0, '\n');
}
if !replacement_str.ends_with('\n') {
replacement_str.push('\n');
}
return Ok(ResolvedEdit {
byte_start: byte_pos,
byte_end: byte_pos,
replacement: replacement_str,
});
}
if line_start > line_end + 1 {
return Err(Response::error(
req_id,
"invalid_request",
format!(
"batch: edit[{}] line_start {} > line_end {}",
index, line_start_1based, line_end_1based
),
));
}
if line_start == line_end + 1
|| (line_end == 0
&& line_start == 0
&& edit_val
.get("line_end")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
== Some(0)
&& line_start > line_end)
{
let byte_pos = line_col_to_byte(source, line_start as u32, 0);
let mut replacement_str = content.to_string();
if !replacement_str.ends_with('\n') {
replacement_str.push('\n');
}
return Ok(ResolvedEdit {
byte_start: byte_pos,
byte_end: byte_pos,
replacement: replacement_str,
});
}
let line_end = if line_end >= total_lines {
total_lines - 1
} else {
line_end
};
let byte_start = line_col_to_byte(source, line_start as u32, 0);
let byte_end = line_col_to_byte(source, line_end.saturating_add(1) as u32, 0);
let mut replacement_str = content.to_string();
if !replacement_str.is_empty() {
let range_has_trailing_nl = byte_end > 0
&& byte_end <= source.len()
&& source.as_bytes()[byte_end - 1] == b'\n';
if range_has_trailing_nl && !replacement_str.ends_with('\n') {
replacement_str.push('\n');
}
}
Ok(ResolvedEdit {
byte_start,
byte_end,
replacement: replacement_str,
})
} else {
Err(Response::error(
req_id,
"invalid_request",
format!(
"batch: edit[{}] must have either 'match' or 'line_start'/'line_end'",
index
),
))
}
}