1#![cfg_attr(test, allow(clippy::items_after_test_module))]
7
8use std::path::Path;
9
10use crate::config::Config;
11use crate::context::AppContext;
12use crate::error::AftError;
13use crate::format;
14use crate::parser::{detect_language, grammar_for, FileParser};
15
16pub fn line_col_to_byte(source: &str, line: u32, col: u32) -> usize {
24 let bytes = source.as_bytes();
25 let target_line = line as usize;
26 let mut current_line = 0usize;
27 let mut line_start = 0usize;
28
29 loop {
30 let mut line_end = line_start;
31 while line_end < bytes.len() && bytes[line_end] != b'\n' && bytes[line_end] != b'\r' {
32 line_end += 1;
33 }
34
35 if current_line == target_line {
36 return line_start + (col as usize).min(line_end.saturating_sub(line_start));
37 }
38
39 if line_end >= bytes.len() {
40 return source.len();
41 }
42
43 line_start = if bytes[line_end] == b'\r'
44 && line_end + 1 < bytes.len()
45 && bytes[line_end + 1] == b'\n'
46 {
47 line_end + 2
48 } else {
49 line_end + 1
50 };
51 current_line += 1;
52 }
53}
54
55pub fn replace_byte_range(
59 source: &str,
60 start: usize,
61 end: usize,
62 replacement: &str,
63) -> Result<String, AftError> {
64 if start > end {
65 return Err(AftError::InvalidRequest {
66 message: format!(
67 "invalid byte range [{}..{}): start must be <= end",
68 start, end
69 ),
70 });
71 }
72 if end > source.len() {
73 return Err(AftError::InvalidRequest {
74 message: format!(
75 "invalid byte range [{}..{}): end exceeds source length {}",
76 start,
77 end,
78 source.len()
79 ),
80 });
81 }
82 if !source.is_char_boundary(start) {
83 return Err(AftError::InvalidRequest {
84 message: format!(
85 "invalid byte range [{}..{}): start is not a char boundary",
86 start, end
87 ),
88 });
89 }
90 if !source.is_char_boundary(end) {
91 return Err(AftError::InvalidRequest {
92 message: format!(
93 "invalid byte range [{}..{}): end is not a char boundary",
94 start, end
95 ),
96 });
97 }
98
99 let mut result = String::with_capacity(
100 source.len().saturating_sub(end.saturating_sub(start)) + replacement.len(),
101 );
102 result.push_str(&source[..start]);
103 result.push_str(replacement);
104 result.push_str(&source[end..]);
105 Ok(result)
106}
107
108pub fn validate_syntax(path: &Path) -> Result<Option<bool>, AftError> {
113 let mut parser = FileParser::new();
114 match parser.parse(path) {
115 Ok((tree, _lang)) => Ok(Some(!tree.root_node().has_error())),
116 Err(AftError::InvalidRequest { .. }) => {
117 Ok(None)
119 }
120 Err(e) => Err(e),
121 }
122}
123
124pub fn validate_syntax_str(content: &str, path: &Path) -> Option<bool> {
130 let lang = detect_language(path)?;
131 let grammar = grammar_for(lang);
132 let mut parser = tree_sitter::Parser::new();
133 if parser.set_language(&grammar).is_err() {
134 return None;
135 }
136 let tree = parser.parse(content.as_bytes(), None)?;
137 Some(!tree.root_node().has_error())
138}
139
140pub fn wants_diff(params: &serde_json::Value) -> bool {
147 params
148 .get("include_diff")
149 .and_then(|v| v.as_bool())
150 .unwrap_or(false)
151 || wants_diff_content(params)
152}
153
154pub fn wants_diff_content(params: &serde_json::Value) -> bool {
161 params
162 .get("include_diff_content")
163 .and_then(|v| v.as_bool())
164 .unwrap_or(false)
165}
166
167pub fn wants_preview(params: &serde_json::Value) -> bool {
172 params
173 .get("preview")
174 .and_then(|v| v.as_bool())
175 .unwrap_or(false)
176}
177
178pub fn build_unified_diff(file: &str, before: &str, after: &str) -> String {
185 if before == after {
186 return format!(
187 "Index: {file}
188===================================================================
189--- {file}
190+++ {file}
191"
192 );
193 }
194
195 let text_diff = similar::TextDiff::from_lines(before, after);
196 let patch = text_diff.unified_diff().header(file, file).to_string();
197 format!(
198 "Index: {file}
199===================================================================
200{patch}"
201 )
202}
203
204pub fn attach_preview_diff(
206 result: &mut serde_json::Value,
207 params: &serde_json::Value,
208 file: &str,
209 before: &str,
210 after: &str,
211) {
212 result["preview"] = serde_json::json!(true);
213 result["diff"] = compute_diff_for_response(params, before, after);
214 result["preview_diff"] = serde_json::json!(build_unified_diff(file, before, after));
215}
216
217fn diff_counts(before: &str, after: &str) -> (usize, usize) {
218 use similar::ChangeTag;
219
220 let diff = similar::TextDiff::from_lines(before, after);
221 let mut additions = 0usize;
222 let mut deletions = 0usize;
223 for change in diff.iter_all_changes() {
224 match change.tag() {
225 ChangeTag::Insert => additions += 1,
226 ChangeTag::Delete => deletions += 1,
227 ChangeTag::Equal => {}
228 }
229 }
230 (additions, deletions)
231}
232
233pub fn compute_diff_counts(before: &str, after: &str) -> serde_json::Value {
237 let (additions, deletions) = diff_counts(before, after);
238 serde_json::json!({
239 "additions": additions,
240 "deletions": deletions,
241 })
242}
243
244pub fn compute_diff_for_response(
250 params: &serde_json::Value,
251 before: &str,
252 after: &str,
253) -> serde_json::Value {
254 if wants_diff_content(params) {
255 compute_diff_info(before, after)
256 } else {
257 compute_diff_counts(before, after)
258 }
259}
260
261pub fn compute_diff_info(before: &str, after: &str) -> serde_json::Value {
265 let (additions, deletions) = diff_counts(before, after);
266
267 let size_limit = 512 * 1024; if before.len() > size_limit || after.len() > size_limit {
270 serde_json::json!({
271 "additions": additions,
272 "deletions": deletions,
273 "truncated": true,
274 })
275 } else {
276 serde_json::json!({
277 "before": before,
278 "after": after,
279 "additions": additions,
280 "deletions": deletions,
281 })
282 }
283}
284pub fn auto_backup(
296 ctx: &AppContext,
297 session: &str,
298 path: &Path,
299 description: &str,
300 op_id: Option<&str>,
301) -> Result<Option<String>, AftError> {
302 if std::fs::symlink_metadata(path).is_err() {
303 return Ok(None);
304 }
305 let backup_id = {
306 let mut store = ctx.backup().lock();
307 store.snapshot_with_op(session, path, description, op_id)?
308 }; Ok(Some(backup_id))
310}
311
312pub struct ReformattedExcerpt {
317 pub text: String,
320 pub extensive: bool,
323}
324
325const REFORMATTED_EXCERPT_MAX_LINES: usize = 60;
326const REFORMATTED_EXCERPT_MAX_BYTES: usize = 4096;
327
328pub fn compute_reformatted_excerpt(
331 pre_format: &str,
332 post_format: &str,
333) -> Option<ReformattedExcerpt> {
334 if pre_format == post_format {
335 return None;
336 }
337
338 use similar::DiffTag;
339
340 let diff = similar::TextDiff::from_lines(pre_format, post_format);
341 let post_lines: Vec<&str> = post_format.lines().collect();
342 let mut collected: Vec<String> = Vec::new();
343 let mut last_post_idx: Option<usize> = None;
344
345 for group in diff.grouped_ops(2) {
346 let mut group_start: Option<usize> = None;
347 let mut group_end: Option<usize> = None;
348
349 for op in group {
350 let tag = op.tag();
351 if tag == DiffTag::Delete {
352 continue;
353 }
354 let new_range = op.new_range();
355 if new_range.is_empty() {
356 continue;
357 }
358 let start = new_range.start;
359 let end = new_range.end.saturating_sub(1);
360 group_start = Some(group_start.map_or(start, |s| s.min(start)));
361 group_end = Some(group_end.map_or(end, |e| e.max(end)));
362 }
363
364 let (Some(start), Some(end)) = (group_start, group_end) else {
365 continue;
366 };
367
368 if let Some(prev) = last_post_idx {
369 if start > prev + 1 {
370 collected.push("…".to_string());
371 }
372 }
373
374 for idx in start..=end {
375 if idx < post_lines.len() {
376 collected.push(post_lines[idx].to_string());
377 }
378 }
379 last_post_idx = Some(end);
380 }
381
382 let line_count = collected.len();
383 let byte_count: usize = collected.iter().map(|l| l.len() + 1).sum();
384 if line_count > REFORMATTED_EXCERPT_MAX_LINES || byte_count > REFORMATTED_EXCERPT_MAX_BYTES {
385 return Some(ReformattedExcerpt {
386 text: String::new(),
387 extensive: true,
388 });
389 }
390
391 Some(ReformattedExcerpt {
392 text: collected.join("\n"),
393 extensive: false,
394 })
395}
396
397pub struct WriteResult {
402 pub syntax_valid: Option<bool>,
404 pub formatted: bool,
406 pub format_skipped_reason: Option<String>,
410 pub validate_requested: bool,
412 pub validation_errors: Vec<format::ValidationError>,
414 pub validate_skipped_reason: Option<String>,
417 pub rolled_back: bool,
422 pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
431 pub reformatted_excerpt: Option<ReformattedExcerpt>,
433}
434
435pub fn format_validation_errors(errors: &[format::ValidationError]) -> String {
438 errors
439 .iter()
440 .map(|e| format!("line {}: {}", e.line, e.message))
441 .collect::<Vec<_>>()
442 .join("; ")
443}
444
445impl WriteResult {
446 pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
461 result["rolled_back"] = serde_json::json!(self.rolled_back);
462
463 let Some(outcome) = self.lsp_outcome.as_ref() else {
464 return;
465 };
466
467 result["lsp_diagnostics"] = serde_json::json!(outcome
468 .diagnostics
469 .iter()
470 .map(|d| {
471 serde_json::json!({
472 "file": d.file.display().to_string(),
473 "line": d.line,
474 "column": d.column,
475 "end_line": d.end_line,
476 "end_column": d.end_column,
477 "severity": d.severity.as_str(),
478 "message": d.message,
479 "code": d.code,
480 "source": d.source,
481 })
482 })
483 .collect::<Vec<_>>());
484
485 result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
486
487 if !outcome.pending_servers.is_empty() {
488 result["lsp_pending_servers"] = serde_json::json!(outcome
489 .pending_servers
490 .iter()
491 .map(|key| key.kind.id_str().to_string())
492 .collect::<Vec<_>>());
493 }
494 if !outcome.exited_servers.is_empty() {
495 result["lsp_exited_servers"] = serde_json::json!(outcome
496 .exited_servers
497 .iter()
498 .map(|key| key.kind.id_str().to_string())
499 .collect::<Vec<_>>());
500 }
501 }
502
503 pub fn append_reformatted_excerpt_to(&self, result: &mut serde_json::Value) {
505 if let Some(excerpt) = &self.reformatted_excerpt {
506 if excerpt.extensive {
507 result["reformatted"] = serde_json::json!({ "extensive": true });
508 } else {
509 result["reformatted"] = serde_json::json!({ "text": excerpt.text });
510 }
511 }
512 }
513}
514
515pub fn write_format_validate(
528 path: &Path,
529 content: &str,
530 config: &Config,
531 params: &serde_json::Value,
532) -> Result<WriteResult, AftError> {
533 let pre_write_content = if path.exists() {
534 std::fs::read_to_string(path).ok()
535 } else {
536 None
537 };
538 let was_syntax_valid = if pre_write_content.is_some() {
542 match validate_syntax(path) {
543 Ok(valid) => valid,
544 Err(_) => None,
545 }
546 } else {
547 None
548 };
549
550 std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
552 message: format!("failed to write file: {}", e),
553 })?;
554
555 let (formatted, format_skipped_reason) = format::auto_format(path, config);
557
558 let syntax_valid = match validate_syntax(path) {
560 Ok(sv) => sv,
561 Err(_) => None,
562 };
563 let rolled_back = if was_syntax_valid == Some(true) && syntax_valid == Some(false) {
564 if let Some(original) = pre_write_content.as_ref() {
565 std::fs::write(path, original).map_err(|e| AftError::InvalidRequest {
566 message: format!("failed to roll back invalid edit: {}", e),
567 })?;
568 true
569 } else {
570 false
571 }
572 } else {
573 false
574 };
575
576 let param_validate = params.get("validate").and_then(|v| v.as_str());
578 let config_validate = config.validate_on_edit.as_deref();
579 let validate_mode = param_validate.or(config_validate).unwrap_or("off");
581 let validate_requested = validate_mode == "full";
582 let (validation_errors, validate_skipped_reason) = if validate_requested {
583 format::validate_full(path, config)
584 } else {
585 (Vec::new(), None)
586 };
587
588 let reformatted_excerpt = if rolled_back {
589 None
590 } else {
591 std::fs::read_to_string(path)
592 .ok()
593 .and_then(|final_on_disk| compute_reformatted_excerpt(content, &final_on_disk))
594 };
595
596 Ok(WriteResult {
597 syntax_valid,
598 formatted,
599 format_skipped_reason,
600 validate_requested,
601 validation_errors,
602 validate_skipped_reason,
603 rolled_back,
604 lsp_outcome: None,
605 reformatted_excerpt,
606 })
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
616 fn line_col_to_byte_empty_string() {
617 assert_eq!(line_col_to_byte("", 0, 0), 0);
618 }
619
620 #[test]
621 fn line_col_to_byte_single_line() {
622 let source = "hello";
623 assert_eq!(line_col_to_byte(source, 0, 0), 0);
624 assert_eq!(line_col_to_byte(source, 0, 3), 3);
625 assert_eq!(line_col_to_byte(source, 0, 5), 5); }
627
628 #[test]
629 fn line_col_to_byte_multi_line() {
630 let source = "abc\ndef\nghi\n";
631 assert_eq!(line_col_to_byte(source, 0, 0), 0);
633 assert_eq!(line_col_to_byte(source, 0, 2), 2);
634 assert_eq!(line_col_to_byte(source, 1, 0), 4);
636 assert_eq!(line_col_to_byte(source, 1, 3), 7);
637 assert_eq!(line_col_to_byte(source, 2, 0), 8);
639 assert_eq!(line_col_to_byte(source, 2, 2), 10);
640 }
641
642 #[test]
643 fn line_col_to_byte_last_line_no_trailing_newline() {
644 let source = "abc\ndef";
645 assert_eq!(line_col_to_byte(source, 1, 0), 4);
647 assert_eq!(line_col_to_byte(source, 1, 3), 7); }
649
650 #[test]
651 fn line_col_to_byte_multi_byte_utf8() {
652 let source = "café\nbar";
654 assert_eq!(line_col_to_byte(source, 0, 0), 0);
656 assert_eq!(line_col_to_byte(source, 0, 5), 5); assert_eq!(line_col_to_byte(source, 1, 0), 6);
659 assert_eq!(line_col_to_byte(source, 1, 2), 8);
660 }
661
662 #[test]
663 fn line_col_to_byte_beyond_end() {
664 let source = "abc";
665 assert_eq!(line_col_to_byte(source, 5, 0), source.len());
667 }
668
669 #[test]
670 fn line_col_to_byte_col_clamped_to_line_length() {
671 let source = "ab\ncd";
672 assert_eq!(line_col_to_byte(source, 0, 10), 2);
674 }
675
676 #[test]
677 fn line_col_to_byte_crlf() {
678 let source = "abc\r\ndef\r\nghi\r\n";
679 assert_eq!(line_col_to_byte(source, 0, 0), 0);
680 assert_eq!(line_col_to_byte(source, 0, 10), 3);
681 assert_eq!(line_col_to_byte(source, 1, 0), 5);
682 assert_eq!(line_col_to_byte(source, 1, 3), 8);
683 assert_eq!(line_col_to_byte(source, 2, 0), 10);
684 }
685
686 #[test]
689 fn replace_byte_range_basic() {
690 let source = "hello world";
691 let result = replace_byte_range(source, 6, 11, "rust").unwrap();
692 assert_eq!(result, "hello rust");
693 }
694
695 #[test]
696 fn replace_byte_range_delete() {
697 let source = "hello world";
698 let result = replace_byte_range(source, 5, 11, "").unwrap();
699 assert_eq!(result, "hello");
700 }
701
702 #[test]
703 fn replace_byte_range_insert_at_same_position() {
704 let source = "helloworld";
705 let result = replace_byte_range(source, 5, 5, " ").unwrap();
706 assert_eq!(result, "hello world");
707 }
708
709 #[test]
710 fn replace_byte_range_replace_entire_string() {
711 let source = "old content";
712 let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
713 assert_eq!(result, "new content");
714 }
715
716 #[test]
717 fn compute_reformatted_excerpt_self_suppresses_when_unchanged() {
718 let s = "fn main() {\n let x = 1;\n}\n";
719 assert!(compute_reformatted_excerpt(s, s).is_none());
720 }
721
722 #[test]
723 fn compute_reformatted_excerpt_includes_post_format_text() {
724 let before = "fn main( ){ let x=1; }";
725 let after = "fn main() {\n let x = 1;\n}\n";
726 let excerpt = compute_reformatted_excerpt(before, after).expect("should diff");
727 assert!(!excerpt.extensive);
728 assert!(excerpt.text.contains("fn main()"));
729 assert!(excerpt.text.contains("let x = 1"));
730 }
731
732 #[test]
733 fn compute_reformatted_excerpt_extensive_when_over_line_cap() {
734 let before: String = (0..80).map(|i| format!("line{i} ugly\n")).collect();
735 let after: String = (0..80).map(|i| format!("line{i} neat\n")).collect();
736 let excerpt = compute_reformatted_excerpt(&before, &after).expect("should diff");
737 assert!(excerpt.extensive);
738 assert!(excerpt.text.is_empty());
739 }
740}