Skip to main content

aft/
edit.rs

1//! Shared edit engine: byte-offset conversion, content replacement,
2//! syntax validation, and auto-backup orchestration.
3//!
4//! Used by `write`, `edit_symbol`, `edit_match`, and `batch` commands.
5
6use std::path::Path;
7
8use crate::config::Config;
9use crate::context::AppContext;
10use crate::error::AftError;
11use crate::format;
12use crate::parser::{detect_language, grammar_for, FileParser};
13
14/// Convert 0-indexed line/col to a byte offset within `source`.
15///
16/// Tree-sitter columns are byte-indexed within the line, so `col` is a byte
17/// offset from the start of the line (not a character offset).
18///
19/// Returns `source.len()` if line is beyond the end of the file.
20pub fn line_col_to_byte(source: &str, line: u32, col: u32) -> usize {
21    let mut byte = 0;
22    for (i, l) in source.lines().enumerate() {
23        if i == line as usize {
24            return byte + (col as usize).min(l.len());
25        }
26        byte += l.len() + 1; // +1 for the newline character
27    }
28    // If we run out of lines, clamp to source length.
29    source.len()
30}
31
32/// Replace bytes in `[start..end)` with `replacement`.
33///
34/// Panics if `start > end` or indices are out of bounds for the source.
35pub fn replace_byte_range(source: &str, start: usize, end: usize, replacement: &str) -> String {
36    let mut result = String::with_capacity(source.len() - (end - start) + replacement.len());
37    result.push_str(&source[..start]);
38    result.push_str(replacement);
39    result.push_str(&source[end..]);
40    result
41}
42
43/// Validate syntax of a file using a fresh FileParser (D023).
44///
45/// Returns `Ok(Some(true))` if syntax is valid, `Ok(Some(false))` if there are
46/// parse errors, and `Ok(None)` if the language is unsupported.
47pub fn validate_syntax(path: &Path) -> Result<Option<bool>, AftError> {
48    let mut parser = FileParser::new();
49    match parser.parse(path) {
50        Ok((tree, _lang)) => Ok(Some(!tree.root_node().has_error())),
51        Err(AftError::InvalidRequest { .. }) => {
52            // Unsupported language — not an error, just can't validate
53            Ok(None)
54        }
55        Err(e) => Err(e),
56    }
57}
58
59/// Validate syntax of an in-memory string without touching disk.
60///
61/// Uses `detect_language(path)` + `grammar_for(lang)` + `parser.parse()`
62/// to validate syntax of a proposed content string. Returns `None` for
63/// unsupported languages, `Some(true)` for valid, `Some(false)` for invalid.
64pub fn validate_syntax_str(content: &str, path: &Path) -> Option<bool> {
65    let lang = detect_language(path)?;
66    let grammar = grammar_for(lang);
67    let mut parser = tree_sitter::Parser::new();
68    if parser.set_language(&grammar).is_err() {
69        return None;
70    }
71    let tree = parser.parse(content.as_bytes(), None)?;
72    Some(!tree.root_node().has_error())
73}
74
75/// Result of a dry-run diff computation.
76pub struct DryRunResult {
77    /// Unified diff between original and proposed content.
78    pub diff: String,
79    /// Whether the proposed content has valid syntax. `None` for unsupported languages.
80    pub syntax_valid: Option<bool>,
81}
82
83/// Compute a unified diff between original and proposed content, plus syntax validation.
84///
85/// Returns a standard unified diff with `a/` and `b/` path prefixes and 3 lines of context.
86/// Also validates syntax of the proposed content via tree-sitter.
87pub fn dry_run_diff(original: &str, proposed: &str, path: &Path) -> DryRunResult {
88    let display_path = path.display().to_string();
89    let text_diff = similar::TextDiff::from_lines(original, proposed);
90    let diff = text_diff
91        .unified_diff()
92        .context_radius(3)
93        .header(
94            &format!("a/{}", display_path),
95            &format!("b/{}", display_path),
96        )
97        .to_string();
98    let syntax_valid = validate_syntax_str(proposed, path);
99    DryRunResult { diff, syntax_valid }
100}
101
102/// Extract the `dry_run` boolean from request params.
103///
104/// Returns `true` if `params["dry_run"]` is `true`, `false` otherwise.
105pub fn is_dry_run(params: &serde_json::Value) -> bool {
106    params
107        .get("dry_run")
108        .and_then(|v| v.as_bool())
109        .unwrap_or(false)
110}
111
112/// Check if the caller requested diff info in the response.
113pub fn wants_diff(params: &serde_json::Value) -> bool {
114    params
115        .get("include_diff")
116        .and_then(|v| v.as_bool())
117        .unwrap_or(false)
118}
119
120/// Compute diff info between before/after content for UI metadata.
121/// Returns a JSON value with before, after, additions, deletions.
122/// For files >512KB, omits full content and returns only counts.
123pub fn compute_diff_info(before: &str, after: &str) -> serde_json::Value {
124    use similar::ChangeTag;
125
126    let diff = similar::TextDiff::from_lines(before, after);
127    let mut additions = 0usize;
128    let mut deletions = 0usize;
129    for change in diff.iter_all_changes() {
130        match change.tag() {
131            ChangeTag::Insert => additions += 1,
132            ChangeTag::Delete => deletions += 1,
133            ChangeTag::Equal => {}
134        }
135    }
136
137    // For large files, skip sending full content to avoid bloating JSON
138    let size_limit = 512 * 1024; // 512KB
139    if before.len() > size_limit || after.len() > size_limit {
140        serde_json::json!({
141            "additions": additions,
142            "deletions": deletions,
143            "truncated": true,
144        })
145    } else {
146        serde_json::json!({
147            "before": before,
148            "after": after,
149            "additions": additions,
150            "deletions": deletions,
151        })
152    }
153}
154/// Snapshot the file into the backup store before mutation.
155///
156/// Returns `Ok(Some(backup_id))` if the file existed and was backed up,
157/// `Ok(None)` if the file doesn't exist (new file creation).
158///
159/// Drops the RefCell borrow before returning (D029).
160pub fn auto_backup(
161    ctx: &AppContext,
162    path: &Path,
163    description: &str,
164) -> Result<Option<String>, AftError> {
165    if !path.exists() {
166        return Ok(None);
167    }
168    let backup_id = {
169        let mut store = ctx.backup().borrow_mut();
170        store.snapshot(path, description)?
171    }; // borrow dropped here
172    Ok(Some(backup_id))
173}
174
175/// Result of the write → format → validate pipeline.
176///
177/// Returned by `write_format_validate` to give callers a single struct
178/// with all post-write signals for the response JSON.
179pub struct WriteResult {
180    /// Whether tree-sitter syntax validation passed. `None` if unsupported language.
181    pub syntax_valid: Option<bool>,
182    /// Whether the file was auto-formatted.
183    pub formatted: bool,
184    /// Why formatting was skipped, if it was. Values: "not_found", "timeout", "error", "unsupported_language".
185    pub format_skipped_reason: Option<String>,
186    /// Whether full validation was requested (controls whether validation_errors is included in response).
187    pub validate_requested: bool,
188    /// Structured type-checker errors (only populated when validate:"full" is requested).
189    pub validation_errors: Vec<format::ValidationError>,
190    /// Why validation was skipped, if it was. Values: "not_found", "timeout", "error", "unsupported_language".
191    pub validate_skipped_reason: Option<String>,
192    /// LSP diagnostics for the edited file. Only populated when `diagnostics: true` is
193    /// passed in the edit request AND a language server is available.
194    pub lsp_diagnostics: Vec<crate::lsp::diagnostics::StoredDiagnostic>,
195}
196
197impl WriteResult {
198    /// Append LSP diagnostics to a response JSON object.
199    /// Only adds the field when diagnostics were requested and collected.
200    pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
201        if !self.lsp_diagnostics.is_empty() {
202            result["lsp_diagnostics"] = serde_json::json!(self
203                .lsp_diagnostics
204                .iter()
205                .map(|d| {
206                    serde_json::json!({
207                        "file": d.file.display().to_string(),
208                        "line": d.line,
209                        "column": d.column,
210                        "end_line": d.end_line,
211                        "end_column": d.end_column,
212                        "severity": d.severity.as_str(),
213                        "message": d.message,
214                        "code": d.code,
215                        "source": d.source,
216                    })
217                })
218                .collect::<Vec<_>>());
219        }
220    }
221}
222
223/// Write content to disk, auto-format, then validate syntax.
224///
225/// This is the shared tail for all mutation commands. The pipeline order is:
226/// 1. `fs::write` — persist content
227/// 2. `auto_format` — run the project formatter (reads the written file, writes back)
228/// 3. `validate_syntax` — parse the (potentially formatted) file
229/// 4. `validate_full` — run type checker if `params.validate == "full"`
230///
231/// The `params` argument carries the original request parameters. When it
232/// contains `"validate": "full"`, the project's type checker is invoked after
233/// syntax validation and the results are included in `WriteResult`.
234pub fn write_format_validate(
235    path: &Path,
236    content: &str,
237    config: &Config,
238    params: &serde_json::Value,
239) -> Result<WriteResult, AftError> {
240    // Step 1: Write
241    std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
242        message: format!("failed to write file: {}", e),
243    })?;
244
245    // Step 2: Format (before validate so we validate the formatted content)
246    let (formatted, format_skipped_reason) = format::auto_format(path, config);
247
248    // Step 3: Validate syntax
249    let syntax_valid = match validate_syntax(path) {
250        Ok(sv) => sv,
251        Err(_) => None,
252    };
253
254    // Step 4: Full validation (type checker) — only when requested
255    let validate_requested = params.get("validate").and_then(|v| v.as_str()) == Some("full");
256    let (validation_errors, validate_skipped_reason) = if validate_requested {
257        format::validate_full(path, config)
258    } else {
259        (Vec::new(), None)
260    };
261
262    Ok(WriteResult {
263        syntax_valid,
264        formatted,
265        format_skipped_reason,
266        validate_requested,
267        validation_errors,
268        validate_skipped_reason,
269        lsp_diagnostics: Vec::new(),
270    })
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    // --- line_col_to_byte ---
278
279    #[test]
280    fn line_col_to_byte_empty_string() {
281        assert_eq!(line_col_to_byte("", 0, 0), 0);
282    }
283
284    #[test]
285    fn line_col_to_byte_single_line() {
286        let source = "hello";
287        assert_eq!(line_col_to_byte(source, 0, 0), 0);
288        assert_eq!(line_col_to_byte(source, 0, 3), 3);
289        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of line
290    }
291
292    #[test]
293    fn line_col_to_byte_multi_line() {
294        let source = "abc\ndef\nghi\n";
295        // line 0: "abc" at bytes 0..3, newline at 3
296        assert_eq!(line_col_to_byte(source, 0, 0), 0);
297        assert_eq!(line_col_to_byte(source, 0, 2), 2);
298        // line 1: "def" at bytes 4..7, newline at 7
299        assert_eq!(line_col_to_byte(source, 1, 0), 4);
300        assert_eq!(line_col_to_byte(source, 1, 3), 7);
301        // line 2: "ghi" at bytes 8..11, newline at 11
302        assert_eq!(line_col_to_byte(source, 2, 0), 8);
303        assert_eq!(line_col_to_byte(source, 2, 2), 10);
304    }
305
306    #[test]
307    fn line_col_to_byte_last_line_no_trailing_newline() {
308        let source = "abc\ndef";
309        // line 1: "def" at bytes 4..7, no trailing newline
310        assert_eq!(line_col_to_byte(source, 1, 0), 4);
311        assert_eq!(line_col_to_byte(source, 1, 3), 7); // end
312    }
313
314    #[test]
315    fn line_col_to_byte_multi_byte_utf8() {
316        // "é" is 2 bytes in UTF-8
317        let source = "café\nbar";
318        // line 0: "café" is 5 bytes (c=1, a=1, f=1, é=2)
319        assert_eq!(line_col_to_byte(source, 0, 0), 0);
320        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of "café"
321                                                       // line 1: "bar" starts at byte 6
322        assert_eq!(line_col_to_byte(source, 1, 0), 6);
323        assert_eq!(line_col_to_byte(source, 1, 2), 8);
324    }
325
326    #[test]
327    fn line_col_to_byte_beyond_end() {
328        let source = "abc";
329        // Line beyond file returns source.len()
330        assert_eq!(line_col_to_byte(source, 5, 0), source.len());
331    }
332
333    #[test]
334    fn line_col_to_byte_col_clamped_to_line_length() {
335        let source = "ab\ncd";
336        // col=10 on a 2-char line should clamp to 2
337        assert_eq!(line_col_to_byte(source, 0, 10), 2);
338    }
339
340    // --- replace_byte_range ---
341
342    #[test]
343    fn replace_byte_range_basic() {
344        let source = "hello world";
345        let result = replace_byte_range(source, 6, 11, "rust");
346        assert_eq!(result, "hello rust");
347    }
348
349    #[test]
350    fn replace_byte_range_delete() {
351        let source = "hello world";
352        let result = replace_byte_range(source, 5, 11, "");
353        assert_eq!(result, "hello");
354    }
355
356    #[test]
357    fn replace_byte_range_insert_at_same_position() {
358        let source = "helloworld";
359        let result = replace_byte_range(source, 5, 5, " ");
360        assert_eq!(result, "hello world");
361    }
362
363    #[test]
364    fn replace_byte_range_replace_entire_string() {
365        let source = "old content";
366        let result = replace_byte_range(source, 0, source.len(), "new content");
367        assert_eq!(result, "new content");
368    }
369}
370
371/// Format an already-written file (no re-write) without re-writing or validating.
372/// Returns Ok(true) if formatting was applied, Ok(false) if skipped.
373pub fn write_format_only(path: &Path, config: &Config) -> Result<bool, AftError> {
374    use crate::format::detect_formatter;
375    let lang = match crate::parser::detect_language(path) {
376        Some(l) => l,
377        None => return Ok(false),
378    };
379    let formatter = detect_formatter(path, lang, config);
380    if let Some((cmd, args)) = formatter {
381        let status = std::process::Command::new(&cmd)
382            .args(&args)
383            .arg(path)
384            .stdout(std::process::Stdio::null())
385            .stderr(std::process::Stdio::null())
386            .status();
387        match status {
388            Ok(s) if s.success() => Ok(true),
389            _ => Ok(false),
390        }
391    } else {
392        Ok(false)
393    }
394}