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(crate) fn validate_byte_range(source: &str, start: usize, end: usize) -> Result<(), AftError> {
56 if start > end {
57 return Err(AftError::InvalidRequest {
58 message: format!(
59 "invalid byte range [{}..{}): start must be <= end",
60 start, end
61 ),
62 });
63 }
64 if end > source.len() {
65 return Err(AftError::InvalidRequest {
66 message: format!(
67 "invalid byte range [{}..{}): end exceeds source length {}",
68 start,
69 end,
70 source.len()
71 ),
72 });
73 }
74 if !source.is_char_boundary(start) {
75 return Err(AftError::InvalidRequest {
76 message: format!(
77 "invalid byte range [{}..{}): start is not a char boundary",
78 start, end
79 ),
80 });
81 }
82 if !source.is_char_boundary(end) {
83 return Err(AftError::InvalidRequest {
84 message: format!(
85 "invalid byte range [{}..{}): end is not a char boundary",
86 start, end
87 ),
88 });
89 }
90 Ok(())
91}
92
93pub fn replace_byte_range(
97 source: &str,
98 start: usize,
99 end: usize,
100 replacement: &str,
101) -> Result<String, AftError> {
102 validate_byte_range(source, start, end)?;
103
104 let mut result = String::with_capacity(
105 source.len().saturating_sub(end.saturating_sub(start)) + replacement.len(),
106 );
107 result.push_str(&source[..start]);
108 result.push_str(replacement);
109 result.push_str(&source[end..]);
110 Ok(result)
111}
112
113pub fn validate_syntax(path: &Path) -> Result<Option<bool>, AftError> {
118 let mut parser = FileParser::new();
119 match parser.parse(path) {
120 Ok((tree, _lang)) => Ok(Some(!tree.root_node().has_error())),
121 Err(AftError::InvalidRequest { .. }) => {
122 Ok(None)
124 }
125 Err(e) => Err(e),
126 }
127}
128
129pub fn validate_syntax_str(content: &str, path: &Path) -> Option<bool> {
135 let lang = detect_language(path)?;
136 let grammar = grammar_for(lang);
137 let mut parser = tree_sitter::Parser::new();
138 if parser.set_language(&grammar).is_err() {
139 return None;
140 }
141 let tree = parser.parse(content.as_bytes(), None)?;
142 Some(!tree.root_node().has_error())
143}
144
145pub fn wants_diff(params: &serde_json::Value) -> bool {
152 params
153 .get("include_diff")
154 .and_then(|v| v.as_bool())
155 .unwrap_or(false)
156 || wants_diff_content(params)
157}
158
159pub fn wants_diff_content(params: &serde_json::Value) -> bool {
166 params
167 .get("include_diff_content")
168 .and_then(|v| v.as_bool())
169 .unwrap_or(false)
170}
171
172pub fn wants_preview(params: &serde_json::Value) -> bool {
177 params
178 .get("preview")
179 .and_then(|v| v.as_bool())
180 .unwrap_or(false)
181}
182
183pub fn build_unified_diff(file: &str, before: &str, after: &str) -> String {
190 if before == after {
191 return format!(
192 "Index: {file}
193===================================================================
194--- {file}
195+++ {file}
196"
197 );
198 }
199
200 let text_diff = similar::TextDiff::from_lines(before, after);
201 let patch = text_diff.unified_diff().header(file, file).to_string();
202 format!(
203 "Index: {file}
204===================================================================
205{patch}"
206 )
207}
208
209pub fn attach_preview_diff(
211 result: &mut serde_json::Value,
212 params: &serde_json::Value,
213 file: &str,
214 before: &str,
215 after: &str,
216) {
217 result["preview"] = serde_json::json!(true);
218 result["diff"] = compute_diff_for_response(params, before, after);
219 result["preview_diff"] = serde_json::json!(build_unified_diff(file, before, after));
220}
221
222fn diff_counts(before: &str, after: &str) -> (usize, usize) {
223 use similar::ChangeTag;
224
225 let diff = similar::TextDiff::from_lines(before, after);
226 let mut additions = 0usize;
227 let mut deletions = 0usize;
228 for change in diff.iter_all_changes() {
229 match change.tag() {
230 ChangeTag::Insert => additions += 1,
231 ChangeTag::Delete => deletions += 1,
232 ChangeTag::Equal => {}
233 }
234 }
235 (additions, deletions)
236}
237
238pub fn compute_diff_counts(before: &str, after: &str) -> serde_json::Value {
242 let (additions, deletions) = diff_counts(before, after);
243 serde_json::json!({
244 "additions": additions,
245 "deletions": deletions,
246 })
247}
248
249pub fn compute_diff_for_response(
255 params: &serde_json::Value,
256 before: &str,
257 after: &str,
258) -> serde_json::Value {
259 if wants_diff_content(params) {
260 compute_diff_info(before, after)
261 } else {
262 compute_diff_counts(before, after)
263 }
264}
265
266pub fn compute_diff_info(before: &str, after: &str) -> serde_json::Value {
270 let (additions, deletions) = diff_counts(before, after);
271
272 let size_limit = 512 * 1024; if before.len() > size_limit || after.len() > size_limit {
275 serde_json::json!({
276 "additions": additions,
277 "deletions": deletions,
278 "truncated": true,
279 })
280 } else {
281 serde_json::json!({
282 "before": before,
283 "after": after,
284 "additions": additions,
285 "deletions": deletions,
286 })
287 }
288}
289pub fn auto_backup(
301 ctx: &AppContext,
302 session: &str,
303 path: &Path,
304 description: &str,
305 op_id: Option<&str>,
306) -> Result<Option<String>, AftError> {
307 if std::fs::symlink_metadata(path).is_err() {
308 return Ok(None);
309 }
310 let backup_id = {
311 let mut store = ctx.backup().lock();
312 store.snapshot_with_op(session, path, description, op_id)?
313 }; Ok(backup_id)
315}
316
317pub(crate) fn auto_backup_from_capture(
320 ctx: &AppContext,
321 session: &str,
322 path: &Path,
323 description: &str,
324 op_id: Option<&str>,
325 capture: &crate::backup::CapturedRegularFile,
326) -> Result<Option<String>, AftError> {
327 let backup_id = {
328 let mut store = ctx.backup().lock();
329 store.snapshot_with_op_from_capture(session, path, description, op_id, capture)?
330 };
331 Ok(backup_id)
332}
333
334pub struct ReformattedExcerpt {
339 pub text: String,
342 pub extensive: bool,
345}
346
347const REFORMATTED_EXCERPT_MAX_LINES: usize = 60;
348const REFORMATTED_EXCERPT_MAX_BYTES: usize = 4096;
349
350pub fn compute_reformatted_excerpt(
353 pre_format: &str,
354 post_format: &str,
355) -> Option<ReformattedExcerpt> {
356 if pre_format == post_format {
357 return None;
358 }
359
360 use similar::DiffTag;
361
362 let diff = similar::TextDiff::from_lines(pre_format, post_format);
363 let post_lines: Vec<&str> = post_format.lines().collect();
364 let mut collected: Vec<String> = Vec::new();
365 let mut last_post_idx: Option<usize> = None;
366
367 for group in diff.grouped_ops(2) {
368 let mut group_start: Option<usize> = None;
369 let mut group_end: Option<usize> = None;
370
371 for op in group {
372 let tag = op.tag();
373 if tag == DiffTag::Delete {
374 continue;
375 }
376 let new_range = op.new_range();
377 if new_range.is_empty() {
378 continue;
379 }
380 let start = new_range.start;
381 let end = new_range.end.saturating_sub(1);
382 group_start = Some(group_start.map_or(start, |s| s.min(start)));
383 group_end = Some(group_end.map_or(end, |e| e.max(end)));
384 }
385
386 let (Some(start), Some(end)) = (group_start, group_end) else {
387 continue;
388 };
389
390 if let Some(prev) = last_post_idx {
391 if start > prev + 1 {
392 collected.push("…".to_string());
393 }
394 }
395
396 for idx in start..=end {
397 if idx < post_lines.len() {
398 collected.push(post_lines[idx].to_string());
399 }
400 }
401 last_post_idx = Some(end);
402 }
403
404 let line_count = collected.len();
405 let byte_count: usize = collected.iter().map(|l| l.len() + 1).sum();
406 if line_count > REFORMATTED_EXCERPT_MAX_LINES || byte_count > REFORMATTED_EXCERPT_MAX_BYTES {
407 return Some(ReformattedExcerpt {
408 text: String::new(),
409 extensive: true,
410 });
411 }
412
413 Some(ReformattedExcerpt {
414 text: collected.join("\n"),
415 extensive: false,
416 })
417}
418
419pub struct WriteResult {
424 pub syntax_valid: Option<bool>,
426 pub formatted: bool,
428 pub format_skipped_reason: Option<String>,
432 pub validate_requested: bool,
434 pub validation_errors: Vec<format::ValidationError>,
436 pub validate_skipped_reason: Option<String>,
439 pub rolled_back: bool,
444 pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
453 pub reformatted_excerpt: Option<ReformattedExcerpt>,
455}
456
457pub fn format_validation_errors(errors: &[format::ValidationError]) -> String {
460 errors
461 .iter()
462 .map(|e| format!("line {}: {}", e.line, e.message))
463 .collect::<Vec<_>>()
464 .join("; ")
465}
466
467impl WriteResult {
468 pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
483 result["rolled_back"] = serde_json::json!(self.rolled_back);
484
485 let Some(outcome) = self.lsp_outcome.as_ref() else {
486 return;
487 };
488
489 result["lsp_diagnostics"] = serde_json::json!(outcome
490 .diagnostics
491 .iter()
492 .map(|d| {
493 serde_json::json!({
494 "file": d.file.display().to_string(),
495 "line": d.line,
496 "column": d.column,
497 "end_line": d.end_line,
498 "end_column": d.end_column,
499 "severity": d.severity.as_str(),
500 "message": d.message,
501 "code": d.code,
502 "source": d.source,
503 })
504 })
505 .collect::<Vec<_>>());
506
507 result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
508
509 if !outcome.pending_servers.is_empty() {
510 result["lsp_pending_servers"] = serde_json::json!(outcome
511 .pending_servers
512 .iter()
513 .map(|key| key.kind.id_str().to_string())
514 .collect::<Vec<_>>());
515 }
516 if !outcome.exited_servers.is_empty() {
517 result["lsp_exited_servers"] = serde_json::json!(outcome
518 .exited_servers
519 .iter()
520 .map(|key| key.kind.id_str().to_string())
521 .collect::<Vec<_>>());
522 }
523 }
524
525 pub fn append_reformatted_excerpt_to(&self, result: &mut serde_json::Value) {
527 if let Some(excerpt) = &self.reformatted_excerpt {
528 if excerpt.extensive {
529 result["reformatted"] = serde_json::json!({ "extensive": true });
530 } else {
531 result["reformatted"] = serde_json::json!({ "text": excerpt.text });
532 }
533 }
534 }
535}
536
537pub fn write_format_validate(
550 path: &Path,
551 content: &str,
552 config: &Config,
553 params: &serde_json::Value,
554) -> Result<WriteResult, AftError> {
555 let pre_write_content = if path.exists() {
556 std::fs::read_to_string(path).ok()
557 } else {
558 None
559 };
560 let was_syntax_valid = if pre_write_content.is_some() {
564 match validate_syntax(path) {
565 Ok(valid) => valid,
566 Err(_) => None,
567 }
568 } else {
569 None
570 };
571
572 std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
574 message: format!("failed to write file: {}", e),
575 })?;
576
577 let (formatted, format_skipped_reason) = format::auto_format(path, config);
579
580 let syntax_valid = match validate_syntax(path) {
582 Ok(sv) => sv,
583 Err(_) => None,
584 };
585 let rolled_back = if was_syntax_valid == Some(true) && syntax_valid == Some(false) {
586 if let Some(original) = pre_write_content.as_ref() {
587 std::fs::write(path, original).map_err(|e| AftError::InvalidRequest {
588 message: format!("failed to roll back invalid edit: {}", e),
589 })?;
590 true
591 } else {
592 false
593 }
594 } else {
595 false
596 };
597
598 let param_validate = params.get("validate").and_then(|v| v.as_str());
600 let config_validate = config.validate_on_edit.as_deref();
601 let validate_mode = param_validate.or(config_validate).unwrap_or("off");
603 let validate_requested = validate_mode == "full";
604 let (validation_errors, validate_skipped_reason) = if validate_requested {
605 format::validate_full(path, config)
606 } else {
607 (Vec::new(), None)
608 };
609
610 let reformatted_excerpt = if rolled_back {
611 None
612 } else {
613 std::fs::read_to_string(path)
614 .ok()
615 .and_then(|final_on_disk| compute_reformatted_excerpt(content, &final_on_disk))
616 };
617
618 Ok(WriteResult {
619 syntax_valid,
620 formatted,
621 format_skipped_reason,
622 validate_requested,
623 validation_errors,
624 validate_skipped_reason,
625 rolled_back,
626 lsp_outcome: None,
627 reformatted_excerpt,
628 })
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
638 fn line_col_to_byte_empty_string() {
639 assert_eq!(line_col_to_byte("", 0, 0), 0);
640 }
641
642 #[test]
643 fn line_col_to_byte_single_line() {
644 let source = "hello";
645 assert_eq!(line_col_to_byte(source, 0, 0), 0);
646 assert_eq!(line_col_to_byte(source, 0, 3), 3);
647 assert_eq!(line_col_to_byte(source, 0, 5), 5); }
649
650 #[test]
651 fn line_col_to_byte_multi_line() {
652 let source = "abc\ndef\nghi\n";
653 assert_eq!(line_col_to_byte(source, 0, 0), 0);
655 assert_eq!(line_col_to_byte(source, 0, 2), 2);
656 assert_eq!(line_col_to_byte(source, 1, 0), 4);
658 assert_eq!(line_col_to_byte(source, 1, 3), 7);
659 assert_eq!(line_col_to_byte(source, 2, 0), 8);
661 assert_eq!(line_col_to_byte(source, 2, 2), 10);
662 }
663
664 #[test]
665 fn line_col_to_byte_last_line_no_trailing_newline() {
666 let source = "abc\ndef";
667 assert_eq!(line_col_to_byte(source, 1, 0), 4);
669 assert_eq!(line_col_to_byte(source, 1, 3), 7); }
671
672 #[test]
673 fn line_col_to_byte_multi_byte_utf8() {
674 let source = "café\nbar";
676 assert_eq!(line_col_to_byte(source, 0, 0), 0);
678 assert_eq!(line_col_to_byte(source, 0, 5), 5); assert_eq!(line_col_to_byte(source, 1, 0), 6);
681 assert_eq!(line_col_to_byte(source, 1, 2), 8);
682 }
683
684 #[test]
685 fn line_col_to_byte_beyond_end() {
686 let source = "abc";
687 assert_eq!(line_col_to_byte(source, 5, 0), source.len());
689 }
690
691 #[test]
692 fn line_col_to_byte_col_clamped_to_line_length() {
693 let source = "ab\ncd";
694 assert_eq!(line_col_to_byte(source, 0, 10), 2);
696 }
697
698 #[test]
699 fn line_col_to_byte_crlf() {
700 let source = "abc\r\ndef\r\nghi\r\n";
701 assert_eq!(line_col_to_byte(source, 0, 0), 0);
702 assert_eq!(line_col_to_byte(source, 0, 10), 3);
703 assert_eq!(line_col_to_byte(source, 1, 0), 5);
704 assert_eq!(line_col_to_byte(source, 1, 3), 8);
705 assert_eq!(line_col_to_byte(source, 2, 0), 10);
706 }
707
708 #[test]
711 fn replace_byte_range_basic() {
712 let source = "hello world";
713 let result = replace_byte_range(source, 6, 11, "rust").unwrap();
714 assert_eq!(result, "hello rust");
715 }
716
717 #[test]
718 fn replace_byte_range_delete() {
719 let source = "hello world";
720 let result = replace_byte_range(source, 5, 11, "").unwrap();
721 assert_eq!(result, "hello");
722 }
723
724 #[test]
725 fn replace_byte_range_insert_at_same_position() {
726 let source = "helloworld";
727 let result = replace_byte_range(source, 5, 5, " ").unwrap();
728 assert_eq!(result, "hello world");
729 }
730
731 #[test]
732 fn replace_byte_range_replace_entire_string() {
733 let source = "old content";
734 let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
735 assert_eq!(result, "new content");
736 }
737
738 #[test]
739 fn compute_reformatted_excerpt_self_suppresses_when_unchanged() {
740 let s = "fn main() {\n let x = 1;\n}\n";
741 assert!(compute_reformatted_excerpt(s, s).is_none());
742 }
743
744 #[test]
745 fn compute_reformatted_excerpt_includes_post_format_text() {
746 let before = "fn main( ){ let x=1; }";
747 let after = "fn main() {\n let x = 1;\n}\n";
748 let excerpt = compute_reformatted_excerpt(before, after).expect("should diff");
749 assert!(!excerpt.extensive);
750 assert!(excerpt.text.contains("fn main()"));
751 assert!(excerpt.text.contains("let x = 1"));
752 }
753
754 #[test]
755 fn compute_reformatted_excerpt_extensive_when_over_line_cap() {
756 let before: String = (0..80).map(|i| format!("line{i} ugly\n")).collect();
757 let after: String = (0..80).map(|i| format!("line{i} neat\n")).collect();
758 let excerpt = compute_reformatted_excerpt(&before, &after).expect("should diff");
759 assert!(excerpt.extensive);
760 assert!(excerpt.text.is_empty());
761 }
762
763 #[test]
774 fn validate_syntax_str_accepts_reference_to_variable_named_raw() {
775 let path = Path::new("lib.rs");
776 let src = "fn handle_hash(x: &u32) -> u32 { *x }\n\
777 fn main() {\n let raw = 5u32;\n let _ = handle_hash(&raw);\n}\n";
778 assert_eq!(validate_syntax_str(src, path), Some(true));
779 }
780
781 #[test]
784 fn validate_syntax_str_accepts_raw_borrow_operators() {
785 let path = Path::new("lib.rs");
786 let const_borrow = "fn main() {\n let x = 5u32;\n let _p = &raw const x;\n}\n";
787 let mut_borrow = "fn main() {\n let mut x = 5u32;\n let _p = &raw mut x;\n}\n";
788 assert_eq!(validate_syntax_str(const_borrow, path), Some(true));
789 assert_eq!(validate_syntax_str(mut_borrow, path), Some(true));
790 }
791}