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