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 fn attach_backup_skipped_reason(
321 result: &mut serde_json::Value,
322 ctx: &AppContext,
323 session: &str,
324 op_id: &str,
325 path: Option<&Path>,
326) {
327 let reason = ctx
328 .backup()
329 .lock()
330 .skipped_reason_for_operation(session, op_id, path);
331 if let (Some(object), Some(reason)) = (result.as_object_mut(), reason) {
332 object.insert(
333 "backup_skipped_reason".to_string(),
334 serde_json::Value::String(reason.as_str().to_string()),
335 );
336 }
337}
338
339pub(crate) fn auto_backup_from_capture(
340 ctx: &AppContext,
341 session: &str,
342 path: &Path,
343 description: &str,
344 op_id: Option<&str>,
345 capture: &crate::backup::CapturedRegularFile,
346) -> Result<Option<String>, AftError> {
347 let backup_id = {
348 let mut store = ctx.backup().lock();
349 store.snapshot_with_op_from_capture(session, path, description, op_id, capture)?
350 };
351 Ok(backup_id)
352}
353
354pub struct ReformattedExcerpt {
359 pub text: String,
362 pub extensive: bool,
365}
366
367const REFORMATTED_EXCERPT_MAX_LINES: usize = 60;
368const REFORMATTED_EXCERPT_MAX_BYTES: usize = 4096;
369
370pub fn compute_reformatted_excerpt(
373 pre_format: &str,
374 post_format: &str,
375) -> Option<ReformattedExcerpt> {
376 if pre_format == post_format {
377 return None;
378 }
379
380 use similar::DiffTag;
381
382 let diff = similar::TextDiff::from_lines(pre_format, post_format);
383 let post_lines: Vec<&str> = post_format.lines().collect();
384 let mut collected: Vec<String> = Vec::new();
385 let mut last_post_idx: Option<usize> = None;
386
387 for group in diff.grouped_ops(2) {
388 let mut group_start: Option<usize> = None;
389 let mut group_end: Option<usize> = None;
390
391 for op in group {
392 let tag = op.tag();
393 if tag == DiffTag::Delete {
394 continue;
395 }
396 let new_range = op.new_range();
397 if new_range.is_empty() {
398 continue;
399 }
400 let start = new_range.start;
401 let end = new_range.end.saturating_sub(1);
402 group_start = Some(group_start.map_or(start, |s| s.min(start)));
403 group_end = Some(group_end.map_or(end, |e| e.max(end)));
404 }
405
406 let (Some(start), Some(end)) = (group_start, group_end) else {
407 continue;
408 };
409
410 if let Some(prev) = last_post_idx {
411 if start > prev + 1 {
412 collected.push("…".to_string());
413 }
414 }
415
416 for idx in start..=end {
417 if idx < post_lines.len() {
418 collected.push(post_lines[idx].to_string());
419 }
420 }
421 last_post_idx = Some(end);
422 }
423
424 let line_count = collected.len();
425 let byte_count: usize = collected.iter().map(|l| l.len() + 1).sum();
426 if line_count > REFORMATTED_EXCERPT_MAX_LINES || byte_count > REFORMATTED_EXCERPT_MAX_BYTES {
427 return Some(ReformattedExcerpt {
428 text: String::new(),
429 extensive: true,
430 });
431 }
432
433 Some(ReformattedExcerpt {
434 text: collected.join("\n"),
435 extensive: false,
436 })
437}
438
439pub struct WriteResult {
444 pub syntax_valid: Option<bool>,
446 pub formatted: bool,
448 pub format_skipped_reason: Option<String>,
452 pub validate_requested: bool,
454 pub validation_errors: Vec<format::ValidationError>,
456 pub validate_skipped_reason: Option<String>,
459 pub rolled_back: bool,
464 pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
473 pub reformatted_excerpt: Option<ReformattedExcerpt>,
475}
476
477pub fn format_validation_errors(errors: &[format::ValidationError]) -> String {
480 errors
481 .iter()
482 .map(|e| format!("line {}: {}", e.line, e.message))
483 .collect::<Vec<_>>()
484 .join("; ")
485}
486
487impl WriteResult {
488 pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
503 result["rolled_back"] = serde_json::json!(self.rolled_back);
504
505 let Some(outcome) = self.lsp_outcome.as_ref() else {
506 return;
507 };
508
509 result["lsp_diagnostics"] = serde_json::json!(outcome
510 .diagnostics
511 .iter()
512 .map(|d| {
513 serde_json::json!({
514 "file": d.file.display().to_string(),
515 "line": d.line,
516 "column": d.column,
517 "end_line": d.end_line,
518 "end_column": d.end_column,
519 "severity": d.severity.as_str(),
520 "message": d.message,
521 "code": d.code,
522 "source": d.source,
523 })
524 })
525 .collect::<Vec<_>>());
526
527 result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
528
529 if !outcome.pending_servers.is_empty() {
530 result["lsp_pending_servers"] = serde_json::json!(outcome
531 .pending_servers
532 .iter()
533 .map(|key| key.kind.id_str().to_string())
534 .collect::<Vec<_>>());
535 }
536 if !outcome.exited_servers.is_empty() {
537 result["lsp_exited_servers"] = serde_json::json!(outcome
538 .exited_servers
539 .iter()
540 .map(|key| key.kind.id_str().to_string())
541 .collect::<Vec<_>>());
542 }
543 }
544
545 pub fn append_reformatted_excerpt_to(&self, result: &mut serde_json::Value) {
547 if let Some(excerpt) = &self.reformatted_excerpt {
548 if excerpt.extensive {
549 result["reformatted"] = serde_json::json!({ "extensive": true });
550 } else {
551 result["reformatted"] = serde_json::json!({ "text": excerpt.text });
552 }
553 }
554 }
555}
556
557pub fn write_format_validate(
570 path: &Path,
571 content: &str,
572 config: &Config,
573 params: &serde_json::Value,
574) -> Result<WriteResult, AftError> {
575 let pre_write_content = if path.exists() {
576 std::fs::read_to_string(path).ok()
577 } else {
578 None
579 };
580 let was_syntax_valid = if pre_write_content.is_some() {
584 match validate_syntax(path) {
585 Ok(valid) => valid,
586 Err(_) => None,
587 }
588 } else {
589 None
590 };
591
592 std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
594 message: format!("failed to write file: {}", e),
595 })?;
596
597 let (formatted, format_skipped_reason) = format::auto_format(path, config);
599
600 let syntax_valid = match validate_syntax(path) {
602 Ok(sv) => sv,
603 Err(_) => None,
604 };
605 let rolled_back = if was_syntax_valid == Some(true) && syntax_valid == Some(false) {
606 if let Some(original) = pre_write_content.as_ref() {
607 std::fs::write(path, original).map_err(|e| AftError::InvalidRequest {
608 message: format!("failed to roll back invalid edit: {}", e),
609 })?;
610 true
611 } else {
612 false
613 }
614 } else {
615 false
616 };
617
618 let param_validate = params.get("validate").and_then(|v| v.as_str());
620 let config_validate = config.validate_on_edit.as_deref();
621 let validate_mode = param_validate.or(config_validate).unwrap_or("off");
623 let validate_requested = validate_mode == "full";
624 let (validation_errors, validate_skipped_reason) = if validate_requested {
625 format::validate_full(path, config)
626 } else {
627 (Vec::new(), None)
628 };
629
630 let reformatted_excerpt = if rolled_back {
631 None
632 } else {
633 std::fs::read_to_string(path)
634 .ok()
635 .and_then(|final_on_disk| compute_reformatted_excerpt(content, &final_on_disk))
636 };
637
638 Ok(WriteResult {
639 syntax_valid,
640 formatted,
641 format_skipped_reason,
642 validate_requested,
643 validation_errors,
644 validate_skipped_reason,
645 rolled_back,
646 lsp_outcome: None,
647 reformatted_excerpt,
648 })
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
658 fn line_col_to_byte_empty_string() {
659 assert_eq!(line_col_to_byte("", 0, 0), 0);
660 }
661
662 #[test]
663 fn line_col_to_byte_single_line() {
664 let source = "hello";
665 assert_eq!(line_col_to_byte(source, 0, 0), 0);
666 assert_eq!(line_col_to_byte(source, 0, 3), 3);
667 assert_eq!(line_col_to_byte(source, 0, 5), 5); }
669
670 #[test]
671 fn line_col_to_byte_multi_line() {
672 let source = "abc\ndef\nghi\n";
673 assert_eq!(line_col_to_byte(source, 0, 0), 0);
675 assert_eq!(line_col_to_byte(source, 0, 2), 2);
676 assert_eq!(line_col_to_byte(source, 1, 0), 4);
678 assert_eq!(line_col_to_byte(source, 1, 3), 7);
679 assert_eq!(line_col_to_byte(source, 2, 0), 8);
681 assert_eq!(line_col_to_byte(source, 2, 2), 10);
682 }
683
684 #[test]
685 fn line_col_to_byte_last_line_no_trailing_newline() {
686 let source = "abc\ndef";
687 assert_eq!(line_col_to_byte(source, 1, 0), 4);
689 assert_eq!(line_col_to_byte(source, 1, 3), 7); }
691
692 #[test]
693 fn line_col_to_byte_multi_byte_utf8() {
694 let source = "café\nbar";
696 assert_eq!(line_col_to_byte(source, 0, 0), 0);
698 assert_eq!(line_col_to_byte(source, 0, 5), 5); assert_eq!(line_col_to_byte(source, 1, 0), 6);
701 assert_eq!(line_col_to_byte(source, 1, 2), 8);
702 }
703
704 #[test]
705 fn line_col_to_byte_beyond_end() {
706 let source = "abc";
707 assert_eq!(line_col_to_byte(source, 5, 0), source.len());
709 }
710
711 #[test]
712 fn line_col_to_byte_col_clamped_to_line_length() {
713 let source = "ab\ncd";
714 assert_eq!(line_col_to_byte(source, 0, 10), 2);
716 }
717
718 #[test]
719 fn line_col_to_byte_crlf() {
720 let source = "abc\r\ndef\r\nghi\r\n";
721 assert_eq!(line_col_to_byte(source, 0, 0), 0);
722 assert_eq!(line_col_to_byte(source, 0, 10), 3);
723 assert_eq!(line_col_to_byte(source, 1, 0), 5);
724 assert_eq!(line_col_to_byte(source, 1, 3), 8);
725 assert_eq!(line_col_to_byte(source, 2, 0), 10);
726 }
727
728 #[test]
731 fn replace_byte_range_basic() {
732 let source = "hello world";
733 let result = replace_byte_range(source, 6, 11, "rust").unwrap();
734 assert_eq!(result, "hello rust");
735 }
736
737 #[test]
738 fn replace_byte_range_delete() {
739 let source = "hello world";
740 let result = replace_byte_range(source, 5, 11, "").unwrap();
741 assert_eq!(result, "hello");
742 }
743
744 #[test]
745 fn replace_byte_range_insert_at_same_position() {
746 let source = "helloworld";
747 let result = replace_byte_range(source, 5, 5, " ").unwrap();
748 assert_eq!(result, "hello world");
749 }
750
751 #[test]
752 fn replace_byte_range_replace_entire_string() {
753 let source = "old content";
754 let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
755 assert_eq!(result, "new content");
756 }
757
758 #[test]
759 fn compute_reformatted_excerpt_self_suppresses_when_unchanged() {
760 let s = "fn main() {\n let x = 1;\n}\n";
761 assert!(compute_reformatted_excerpt(s, s).is_none());
762 }
763
764 #[test]
765 fn compute_reformatted_excerpt_includes_post_format_text() {
766 let before = "fn main( ){ let x=1; }";
767 let after = "fn main() {\n let x = 1;\n}\n";
768 let excerpt = compute_reformatted_excerpt(before, after).expect("should diff");
769 assert!(!excerpt.extensive);
770 assert!(excerpt.text.contains("fn main()"));
771 assert!(excerpt.text.contains("let x = 1"));
772 }
773
774 #[test]
775 fn compute_reformatted_excerpt_extensive_when_over_line_cap() {
776 let before: String = (0..80).map(|i| format!("line{i} ugly\n")).collect();
777 let after: String = (0..80).map(|i| format!("line{i} neat\n")).collect();
778 let excerpt = compute_reformatted_excerpt(&before, &after).expect("should diff");
779 assert!(excerpt.extensive);
780 assert!(excerpt.text.is_empty());
781 }
782
783 #[test]
794 fn validate_syntax_str_accepts_reference_to_variable_named_raw() {
795 let path = Path::new("lib.rs");
796 let src = "fn handle_hash(x: &u32) -> u32 { *x }\n\
797 fn main() {\n let raw = 5u32;\n let _ = handle_hash(&raw);\n}\n";
798 assert_eq!(validate_syntax_str(src, path), Some(true));
799 }
800
801 #[test]
804 fn validate_syntax_str_accepts_raw_borrow_operators() {
805 let path = Path::new("lib.rs");
806 let const_borrow = "fn main() {\n let x = 5u32;\n let _p = &raw const x;\n}\n";
807 let mut_borrow = "fn main() {\n let mut x = 5u32;\n let _p = &raw mut x;\n}\n";
808 assert_eq!(validate_syntax_str(const_borrow, path), Some(true));
809 assert_eq!(validate_syntax_str(mut_borrow, path), Some(true));
810 }
811}