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/// Snapshot the file into the backup store before mutation.
113///
114/// Returns `Ok(Some(backup_id))` if the file existed and was backed up,
115/// `Ok(None)` if the file doesn't exist (new file creation).
116///
117/// Drops the RefCell borrow before returning (D029).
118pub fn auto_backup(
119    ctx: &AppContext,
120    path: &Path,
121    description: &str,
122) -> Result<Option<String>, AftError> {
123    if !path.exists() {
124        return Ok(None);
125    }
126    let backup_id = {
127        let mut store = ctx.backup().borrow_mut();
128        store.snapshot(path, description)?
129    }; // borrow dropped here
130    Ok(Some(backup_id))
131}
132
133/// Result of the write → format → validate pipeline.
134///
135/// Returned by `write_format_validate` to give callers a single struct
136/// with all post-write signals for the response JSON.
137pub struct WriteResult {
138    /// Whether tree-sitter syntax validation passed. `None` if unsupported language.
139    pub syntax_valid: Option<bool>,
140    /// Whether the file was auto-formatted.
141    pub formatted: bool,
142    /// Why formatting was skipped, if it was. Values: "not_found", "timeout", "error", "unsupported_language".
143    pub format_skipped_reason: Option<String>,
144    /// Whether full validation was requested (controls whether validation_errors is included in response).
145    pub validate_requested: bool,
146    /// Structured type-checker errors (only populated when validate:"full" is requested).
147    pub validation_errors: Vec<format::ValidationError>,
148    /// Why validation was skipped, if it was. Values: "not_found", "timeout", "error", "unsupported_language".
149    pub validate_skipped_reason: Option<String>,
150    /// LSP diagnostics for the edited file. Only populated when `diagnostics: true` is
151    /// passed in the edit request AND a language server is available.
152    pub lsp_diagnostics: Vec<crate::lsp::diagnostics::StoredDiagnostic>,
153}
154
155impl WriteResult {
156    /// Append LSP diagnostics to a response JSON object.
157    /// Only adds the field when diagnostics were requested and collected.
158    pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
159        if !self.lsp_diagnostics.is_empty() {
160            result["lsp_diagnostics"] = serde_json::json!(self
161                .lsp_diagnostics
162                .iter()
163                .map(|d| {
164                    serde_json::json!({
165                        "file": d.file.display().to_string(),
166                        "line": d.line,
167                        "column": d.column,
168                        "end_line": d.end_line,
169                        "end_column": d.end_column,
170                        "severity": d.severity.as_str(),
171                        "message": d.message,
172                        "code": d.code,
173                        "source": d.source,
174                    })
175                })
176                .collect::<Vec<_>>());
177        }
178    }
179}
180
181/// Write content to disk, auto-format, then validate syntax.
182///
183/// This is the shared tail for all mutation commands. The pipeline order is:
184/// 1. `fs::write` — persist content
185/// 2. `auto_format` — run the project formatter (reads the written file, writes back)
186/// 3. `validate_syntax` — parse the (potentially formatted) file
187/// 4. `validate_full` — run type checker if `params.validate == "full"`
188///
189/// The `params` argument carries the original request parameters. When it
190/// contains `"validate": "full"`, the project's type checker is invoked after
191/// syntax validation and the results are included in `WriteResult`.
192pub fn write_format_validate(
193    path: &Path,
194    content: &str,
195    config: &Config,
196    params: &serde_json::Value,
197) -> Result<WriteResult, AftError> {
198    // Step 1: Write
199    std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
200        message: format!("failed to write file: {}", e),
201    })?;
202
203    // Step 2: Format (before validate so we validate the formatted content)
204    let (formatted, format_skipped_reason) = format::auto_format(path, config);
205
206    // Step 3: Validate syntax
207    let syntax_valid = match validate_syntax(path) {
208        Ok(sv) => sv,
209        Err(_) => None,
210    };
211
212    // Step 4: Full validation (type checker) — only when requested
213    let validate_requested = params.get("validate").and_then(|v| v.as_str()) == Some("full");
214    let (validation_errors, validate_skipped_reason) = if validate_requested {
215        format::validate_full(path, config)
216    } else {
217        (Vec::new(), None)
218    };
219
220    Ok(WriteResult {
221        syntax_valid,
222        formatted,
223        format_skipped_reason,
224        validate_requested,
225        validation_errors,
226        validate_skipped_reason,
227        lsp_diagnostics: Vec::new(),
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    // --- line_col_to_byte ---
236
237    #[test]
238    fn line_col_to_byte_empty_string() {
239        assert_eq!(line_col_to_byte("", 0, 0), 0);
240    }
241
242    #[test]
243    fn line_col_to_byte_single_line() {
244        let source = "hello";
245        assert_eq!(line_col_to_byte(source, 0, 0), 0);
246        assert_eq!(line_col_to_byte(source, 0, 3), 3);
247        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of line
248    }
249
250    #[test]
251    fn line_col_to_byte_multi_line() {
252        let source = "abc\ndef\nghi\n";
253        // line 0: "abc" at bytes 0..3, newline at 3
254        assert_eq!(line_col_to_byte(source, 0, 0), 0);
255        assert_eq!(line_col_to_byte(source, 0, 2), 2);
256        // line 1: "def" at bytes 4..7, newline at 7
257        assert_eq!(line_col_to_byte(source, 1, 0), 4);
258        assert_eq!(line_col_to_byte(source, 1, 3), 7);
259        // line 2: "ghi" at bytes 8..11, newline at 11
260        assert_eq!(line_col_to_byte(source, 2, 0), 8);
261        assert_eq!(line_col_to_byte(source, 2, 2), 10);
262    }
263
264    #[test]
265    fn line_col_to_byte_last_line_no_trailing_newline() {
266        let source = "abc\ndef";
267        // line 1: "def" at bytes 4..7, no trailing newline
268        assert_eq!(line_col_to_byte(source, 1, 0), 4);
269        assert_eq!(line_col_to_byte(source, 1, 3), 7); // end
270    }
271
272    #[test]
273    fn line_col_to_byte_multi_byte_utf8() {
274        // "é" is 2 bytes in UTF-8
275        let source = "café\nbar";
276        // line 0: "café" is 5 bytes (c=1, a=1, f=1, é=2)
277        assert_eq!(line_col_to_byte(source, 0, 0), 0);
278        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of "café"
279                                                       // line 1: "bar" starts at byte 6
280        assert_eq!(line_col_to_byte(source, 1, 0), 6);
281        assert_eq!(line_col_to_byte(source, 1, 2), 8);
282    }
283
284    #[test]
285    fn line_col_to_byte_beyond_end() {
286        let source = "abc";
287        // Line beyond file returns source.len()
288        assert_eq!(line_col_to_byte(source, 5, 0), source.len());
289    }
290
291    #[test]
292    fn line_col_to_byte_col_clamped_to_line_length() {
293        let source = "ab\ncd";
294        // col=10 on a 2-char line should clamp to 2
295        assert_eq!(line_col_to_byte(source, 0, 10), 2);
296    }
297
298    // --- replace_byte_range ---
299
300    #[test]
301    fn replace_byte_range_basic() {
302        let source = "hello world";
303        let result = replace_byte_range(source, 6, 11, "rust");
304        assert_eq!(result, "hello rust");
305    }
306
307    #[test]
308    fn replace_byte_range_delete() {
309        let source = "hello world";
310        let result = replace_byte_range(source, 5, 11, "");
311        assert_eq!(result, "hello");
312    }
313
314    #[test]
315    fn replace_byte_range_insert_at_same_position() {
316        let source = "helloworld";
317        let result = replace_byte_range(source, 5, 5, " ");
318        assert_eq!(result, "hello world");
319    }
320
321    #[test]
322    fn replace_byte_range_replace_entire_string() {
323        let source = "old content";
324        let result = replace_byte_range(source, 0, source.len(), "new content");
325        assert_eq!(result, "new content");
326    }
327}