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(backup_id)
310}
311
312pub(crate) fn auto_backup_from_capture(
315 ctx: &AppContext,
316 session: &str,
317 path: &Path,
318 description: &str,
319 op_id: Option<&str>,
320 capture: &crate::backup::CapturedRegularFile,
321) -> Result<Option<String>, AftError> {
322 let backup_id = {
323 let mut store = ctx.backup().lock();
324 store.snapshot_with_op_from_capture(session, path, description, op_id, capture)?
325 };
326 Ok(backup_id)
327}
328
329pub struct ReformattedExcerpt {
334 pub text: String,
337 pub extensive: bool,
340}
341
342const REFORMATTED_EXCERPT_MAX_LINES: usize = 60;
343const REFORMATTED_EXCERPT_MAX_BYTES: usize = 4096;
344
345pub fn compute_reformatted_excerpt(
348 pre_format: &str,
349 post_format: &str,
350) -> Option<ReformattedExcerpt> {
351 if pre_format == post_format {
352 return None;
353 }
354
355 use similar::DiffTag;
356
357 let diff = similar::TextDiff::from_lines(pre_format, post_format);
358 let post_lines: Vec<&str> = post_format.lines().collect();
359 let mut collected: Vec<String> = Vec::new();
360 let mut last_post_idx: Option<usize> = None;
361
362 for group in diff.grouped_ops(2) {
363 let mut group_start: Option<usize> = None;
364 let mut group_end: Option<usize> = None;
365
366 for op in group {
367 let tag = op.tag();
368 if tag == DiffTag::Delete {
369 continue;
370 }
371 let new_range = op.new_range();
372 if new_range.is_empty() {
373 continue;
374 }
375 let start = new_range.start;
376 let end = new_range.end.saturating_sub(1);
377 group_start = Some(group_start.map_or(start, |s| s.min(start)));
378 group_end = Some(group_end.map_or(end, |e| e.max(end)));
379 }
380
381 let (Some(start), Some(end)) = (group_start, group_end) else {
382 continue;
383 };
384
385 if let Some(prev) = last_post_idx {
386 if start > prev + 1 {
387 collected.push("…".to_string());
388 }
389 }
390
391 for idx in start..=end {
392 if idx < post_lines.len() {
393 collected.push(post_lines[idx].to_string());
394 }
395 }
396 last_post_idx = Some(end);
397 }
398
399 let line_count = collected.len();
400 let byte_count: usize = collected.iter().map(|l| l.len() + 1).sum();
401 if line_count > REFORMATTED_EXCERPT_MAX_LINES || byte_count > REFORMATTED_EXCERPT_MAX_BYTES {
402 return Some(ReformattedExcerpt {
403 text: String::new(),
404 extensive: true,
405 });
406 }
407
408 Some(ReformattedExcerpt {
409 text: collected.join("\n"),
410 extensive: false,
411 })
412}
413
414pub struct WriteResult {
419 pub syntax_valid: Option<bool>,
421 pub formatted: bool,
423 pub format_skipped_reason: Option<String>,
427 pub validate_requested: bool,
429 pub validation_errors: Vec<format::ValidationError>,
431 pub validate_skipped_reason: Option<String>,
434 pub rolled_back: bool,
439 pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
448 pub reformatted_excerpt: Option<ReformattedExcerpt>,
450}
451
452pub fn format_validation_errors(errors: &[format::ValidationError]) -> String {
455 errors
456 .iter()
457 .map(|e| format!("line {}: {}", e.line, e.message))
458 .collect::<Vec<_>>()
459 .join("; ")
460}
461
462impl WriteResult {
463 pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
478 result["rolled_back"] = serde_json::json!(self.rolled_back);
479
480 let Some(outcome) = self.lsp_outcome.as_ref() else {
481 return;
482 };
483
484 result["lsp_diagnostics"] = serde_json::json!(outcome
485 .diagnostics
486 .iter()
487 .map(|d| {
488 serde_json::json!({
489 "file": d.file.display().to_string(),
490 "line": d.line,
491 "column": d.column,
492 "end_line": d.end_line,
493 "end_column": d.end_column,
494 "severity": d.severity.as_str(),
495 "message": d.message,
496 "code": d.code,
497 "source": d.source,
498 })
499 })
500 .collect::<Vec<_>>());
501
502 result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
503
504 if !outcome.pending_servers.is_empty() {
505 result["lsp_pending_servers"] = serde_json::json!(outcome
506 .pending_servers
507 .iter()
508 .map(|key| key.kind.id_str().to_string())
509 .collect::<Vec<_>>());
510 }
511 if !outcome.exited_servers.is_empty() {
512 result["lsp_exited_servers"] = serde_json::json!(outcome
513 .exited_servers
514 .iter()
515 .map(|key| key.kind.id_str().to_string())
516 .collect::<Vec<_>>());
517 }
518 }
519
520 pub fn append_reformatted_excerpt_to(&self, result: &mut serde_json::Value) {
522 if let Some(excerpt) = &self.reformatted_excerpt {
523 if excerpt.extensive {
524 result["reformatted"] = serde_json::json!({ "extensive": true });
525 } else {
526 result["reformatted"] = serde_json::json!({ "text": excerpt.text });
527 }
528 }
529 }
530}
531
532pub fn write_format_validate(
545 path: &Path,
546 content: &str,
547 config: &Config,
548 params: &serde_json::Value,
549) -> Result<WriteResult, AftError> {
550 let pre_write_content = if path.exists() {
551 std::fs::read_to_string(path).ok()
552 } else {
553 None
554 };
555 let was_syntax_valid = if pre_write_content.is_some() {
559 match validate_syntax(path) {
560 Ok(valid) => valid,
561 Err(_) => None,
562 }
563 } else {
564 None
565 };
566
567 std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
569 message: format!("failed to write file: {}", e),
570 })?;
571
572 let (formatted, format_skipped_reason) = format::auto_format(path, config);
574
575 let syntax_valid = match validate_syntax(path) {
577 Ok(sv) => sv,
578 Err(_) => None,
579 };
580 let rolled_back = if was_syntax_valid == Some(true) && syntax_valid == Some(false) {
581 if let Some(original) = pre_write_content.as_ref() {
582 std::fs::write(path, original).map_err(|e| AftError::InvalidRequest {
583 message: format!("failed to roll back invalid edit: {}", e),
584 })?;
585 true
586 } else {
587 false
588 }
589 } else {
590 false
591 };
592
593 let param_validate = params.get("validate").and_then(|v| v.as_str());
595 let config_validate = config.validate_on_edit.as_deref();
596 let validate_mode = param_validate.or(config_validate).unwrap_or("off");
598 let validate_requested = validate_mode == "full";
599 let (validation_errors, validate_skipped_reason) = if validate_requested {
600 format::validate_full(path, config)
601 } else {
602 (Vec::new(), None)
603 };
604
605 let reformatted_excerpt = if rolled_back {
606 None
607 } else {
608 std::fs::read_to_string(path)
609 .ok()
610 .and_then(|final_on_disk| compute_reformatted_excerpt(content, &final_on_disk))
611 };
612
613 Ok(WriteResult {
614 syntax_valid,
615 formatted,
616 format_skipped_reason,
617 validate_requested,
618 validation_errors,
619 validate_skipped_reason,
620 rolled_back,
621 lsp_outcome: None,
622 reformatted_excerpt,
623 })
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
633 fn line_col_to_byte_empty_string() {
634 assert_eq!(line_col_to_byte("", 0, 0), 0);
635 }
636
637 #[test]
638 fn line_col_to_byte_single_line() {
639 let source = "hello";
640 assert_eq!(line_col_to_byte(source, 0, 0), 0);
641 assert_eq!(line_col_to_byte(source, 0, 3), 3);
642 assert_eq!(line_col_to_byte(source, 0, 5), 5); }
644
645 #[test]
646 fn line_col_to_byte_multi_line() {
647 let source = "abc\ndef\nghi\n";
648 assert_eq!(line_col_to_byte(source, 0, 0), 0);
650 assert_eq!(line_col_to_byte(source, 0, 2), 2);
651 assert_eq!(line_col_to_byte(source, 1, 0), 4);
653 assert_eq!(line_col_to_byte(source, 1, 3), 7);
654 assert_eq!(line_col_to_byte(source, 2, 0), 8);
656 assert_eq!(line_col_to_byte(source, 2, 2), 10);
657 }
658
659 #[test]
660 fn line_col_to_byte_last_line_no_trailing_newline() {
661 let source = "abc\ndef";
662 assert_eq!(line_col_to_byte(source, 1, 0), 4);
664 assert_eq!(line_col_to_byte(source, 1, 3), 7); }
666
667 #[test]
668 fn line_col_to_byte_multi_byte_utf8() {
669 let source = "café\nbar";
671 assert_eq!(line_col_to_byte(source, 0, 0), 0);
673 assert_eq!(line_col_to_byte(source, 0, 5), 5); assert_eq!(line_col_to_byte(source, 1, 0), 6);
676 assert_eq!(line_col_to_byte(source, 1, 2), 8);
677 }
678
679 #[test]
680 fn line_col_to_byte_beyond_end() {
681 let source = "abc";
682 assert_eq!(line_col_to_byte(source, 5, 0), source.len());
684 }
685
686 #[test]
687 fn line_col_to_byte_col_clamped_to_line_length() {
688 let source = "ab\ncd";
689 assert_eq!(line_col_to_byte(source, 0, 10), 2);
691 }
692
693 #[test]
694 fn line_col_to_byte_crlf() {
695 let source = "abc\r\ndef\r\nghi\r\n";
696 assert_eq!(line_col_to_byte(source, 0, 0), 0);
697 assert_eq!(line_col_to_byte(source, 0, 10), 3);
698 assert_eq!(line_col_to_byte(source, 1, 0), 5);
699 assert_eq!(line_col_to_byte(source, 1, 3), 8);
700 assert_eq!(line_col_to_byte(source, 2, 0), 10);
701 }
702
703 #[test]
706 fn replace_byte_range_basic() {
707 let source = "hello world";
708 let result = replace_byte_range(source, 6, 11, "rust").unwrap();
709 assert_eq!(result, "hello rust");
710 }
711
712 #[test]
713 fn replace_byte_range_delete() {
714 let source = "hello world";
715 let result = replace_byte_range(source, 5, 11, "").unwrap();
716 assert_eq!(result, "hello");
717 }
718
719 #[test]
720 fn replace_byte_range_insert_at_same_position() {
721 let source = "helloworld";
722 let result = replace_byte_range(source, 5, 5, " ").unwrap();
723 assert_eq!(result, "hello world");
724 }
725
726 #[test]
727 fn replace_byte_range_replace_entire_string() {
728 let source = "old content";
729 let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
730 assert_eq!(result, "new content");
731 }
732
733 #[test]
734 fn compute_reformatted_excerpt_self_suppresses_when_unchanged() {
735 let s = "fn main() {\n let x = 1;\n}\n";
736 assert!(compute_reformatted_excerpt(s, s).is_none());
737 }
738
739 #[test]
740 fn compute_reformatted_excerpt_includes_post_format_text() {
741 let before = "fn main( ){ let x=1; }";
742 let after = "fn main() {\n let x = 1;\n}\n";
743 let excerpt = compute_reformatted_excerpt(before, after).expect("should diff");
744 assert!(!excerpt.extensive);
745 assert!(excerpt.text.contains("fn main()"));
746 assert!(excerpt.text.contains("let x = 1"));
747 }
748
749 #[test]
750 fn compute_reformatted_excerpt_extensive_when_over_line_cap() {
751 let before: String = (0..80).map(|i| format!("line{i} ugly\n")).collect();
752 let after: String = (0..80).map(|i| format!("line{i} neat\n")).collect();
753 let excerpt = compute_reformatted_excerpt(&before, &after).expect("should diff");
754 assert!(excerpt.extensive);
755 assert!(excerpt.text.is_empty());
756 }
757
758 #[test]
769 fn validate_syntax_str_accepts_reference_to_variable_named_raw() {
770 let path = Path::new("lib.rs");
771 let src = "fn handle_hash(x: &u32) -> u32 { *x }\n\
772 fn main() {\n let raw = 5u32;\n let _ = handle_hash(&raw);\n}\n";
773 assert_eq!(validate_syntax_str(src, path), Some(true));
774 }
775
776 #[test]
779 fn validate_syntax_str_accepts_raw_borrow_operators() {
780 let path = Path::new("lib.rs");
781 let const_borrow = "fn main() {\n let x = 5u32;\n let _p = &raw const x;\n}\n";
782 let mut_borrow = "fn main() {\n let mut x = 5u32;\n let _p = &raw mut x;\n}\n";
783 assert_eq!(validate_syntax_str(const_borrow, path), Some(true));
784 assert_eq!(validate_syntax_str(mut_borrow, path), Some(true));
785 }
786}