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/// Scans raw bytes so both LF and CRLF line endings are counted correctly.
20/// Returns `source.len()` if line is beyond the end of the file.
21pub fn line_col_to_byte(source: &str, line: u32, col: u32) -> usize {
22    let bytes = source.as_bytes();
23    let target_line = line as usize;
24    let mut current_line = 0usize;
25    let mut line_start = 0usize;
26
27    loop {
28        let mut line_end = line_start;
29        while line_end < bytes.len() && bytes[line_end] != b'\n' && bytes[line_end] != b'\r' {
30            line_end += 1;
31        }
32
33        if current_line == target_line {
34            return line_start + (col as usize).min(line_end.saturating_sub(line_start));
35        }
36
37        if line_end >= bytes.len() {
38            return source.len();
39        }
40
41        line_start = if bytes[line_end] == b'\r'
42            && line_end + 1 < bytes.len()
43            && bytes[line_end + 1] == b'\n'
44        {
45            line_end + 2
46        } else {
47            line_end + 1
48        };
49        current_line += 1;
50    }
51}
52
53/// Replace bytes in `[start..end)` with `replacement`.
54///
55/// Returns an error if the range is invalid or does not align to UTF-8 char boundaries.
56pub fn replace_byte_range(
57    source: &str,
58    start: usize,
59    end: usize,
60    replacement: &str,
61) -> Result<String, AftError> {
62    if start > end {
63        return Err(AftError::InvalidRequest {
64            message: format!(
65                "invalid byte range [{}..{}): start must be <= end",
66                start, end
67            ),
68        });
69    }
70    if end > source.len() {
71        return Err(AftError::InvalidRequest {
72            message: format!(
73                "invalid byte range [{}..{}): end exceeds source length {}",
74                start,
75                end,
76                source.len()
77            ),
78        });
79    }
80    if !source.is_char_boundary(start) {
81        return Err(AftError::InvalidRequest {
82            message: format!(
83                "invalid byte range [{}..{}): start is not a char boundary",
84                start, end
85            ),
86        });
87    }
88    if !source.is_char_boundary(end) {
89        return Err(AftError::InvalidRequest {
90            message: format!(
91                "invalid byte range [{}..{}): end is not a char boundary",
92                start, end
93            ),
94        });
95    }
96
97    let mut result = String::with_capacity(
98        source.len().saturating_sub(end.saturating_sub(start)) + replacement.len(),
99    );
100    result.push_str(&source[..start]);
101    result.push_str(replacement);
102    result.push_str(&source[end..]);
103    Ok(result)
104}
105
106/// Validate syntax of a file using a fresh FileParser (D023).
107///
108/// Returns `Ok(Some(true))` if syntax is valid, `Ok(Some(false))` if there are
109/// parse errors, and `Ok(None)` if the language is unsupported.
110pub fn validate_syntax(path: &Path) -> Result<Option<bool>, AftError> {
111    let mut parser = FileParser::new();
112    match parser.parse(path) {
113        Ok((tree, _lang)) => Ok(Some(!tree.root_node().has_error())),
114        Err(AftError::InvalidRequest { .. }) => {
115            // Unsupported language — not an error, just can't validate
116            Ok(None)
117        }
118        Err(e) => Err(e),
119    }
120}
121
122/// Validate syntax of an in-memory string without touching disk.
123///
124/// Uses `detect_language(path)` + `grammar_for(lang)` + `parser.parse()`
125/// to validate syntax of a proposed content string. Returns `None` for
126/// unsupported languages, `Some(true)` for valid, `Some(false)` for invalid.
127pub fn validate_syntax_str(content: &str, path: &Path) -> Option<bool> {
128    let lang = detect_language(path)?;
129    let grammar = grammar_for(lang);
130    let mut parser = tree_sitter::Parser::new();
131    if parser.set_language(&grammar).is_err() {
132        return None;
133    }
134    let tree = parser.parse(content.as_bytes(), None)?;
135    Some(!tree.root_node().has_error())
136}
137
138/// Result of a dry-run diff computation.
139pub struct DryRunResult {
140    /// Unified diff between original and proposed content.
141    pub diff: String,
142    /// Whether the proposed content has valid syntax. `None` for unsupported languages.
143    pub syntax_valid: Option<bool>,
144}
145
146/// Compute a unified diff between original and proposed content, plus syntax validation.
147///
148/// Returns a standard unified diff with `a/` and `b/` path prefixes and 3 lines of context.
149/// Also validates syntax of the proposed content via tree-sitter.
150pub fn dry_run_diff(original: &str, proposed: &str, path: &Path) -> DryRunResult {
151    let display_path = path.display().to_string();
152    let text_diff = similar::TextDiff::from_lines(original, proposed);
153    let diff = text_diff
154        .unified_diff()
155        .context_radius(3)
156        .header(
157            &format!("a/{}", display_path),
158            &format!("b/{}", display_path),
159        )
160        .to_string();
161    let syntax_valid = validate_syntax_str(proposed, path);
162    DryRunResult { diff, syntax_valid }
163}
164
165/// Extract the `dry_run` boolean from request params.
166///
167/// Returns `true` if `params["dry_run"]` is `true`, `false` otherwise.
168pub fn is_dry_run(params: &serde_json::Value) -> bool {
169    params
170        .get("dry_run")
171        .and_then(|v| v.as_bool())
172        .unwrap_or(false)
173}
174
175/// Check if the caller requested diff info in the response.
176pub fn wants_diff(params: &serde_json::Value) -> bool {
177    params
178        .get("include_diff")
179        .and_then(|v| v.as_bool())
180        .unwrap_or(false)
181}
182
183/// Compute diff info between before/after content for UI metadata.
184/// Returns a JSON value with before, after, additions, deletions.
185/// For files >512KB, omits full content and returns only counts.
186pub fn compute_diff_info(before: &str, after: &str) -> serde_json::Value {
187    use similar::ChangeTag;
188
189    let diff = similar::TextDiff::from_lines(before, after);
190    let mut additions = 0usize;
191    let mut deletions = 0usize;
192    for change in diff.iter_all_changes() {
193        match change.tag() {
194            ChangeTag::Insert => additions += 1,
195            ChangeTag::Delete => deletions += 1,
196            ChangeTag::Equal => {}
197        }
198    }
199
200    // For large files, skip sending full content to avoid bloating JSON
201    let size_limit = 512 * 1024; // 512KB
202    if before.len() > size_limit || after.len() > size_limit {
203        serde_json::json!({
204            "additions": additions,
205            "deletions": deletions,
206            "truncated": true,
207        })
208    } else {
209        serde_json::json!({
210            "before": before,
211            "after": after,
212            "additions": additions,
213            "deletions": deletions,
214        })
215    }
216}
217/// Snapshot the file into the backup store before mutation, scoped to a session.
218///
219/// Returns `Ok(Some(backup_id))` if the file existed and was backed up,
220/// `Ok(None)` if the file doesn't exist (new file creation).
221///
222/// The `session` argument is the request-level session namespace (see
223/// [`crate::protocol::RawRequest::session`]). Snapshots created by one session
224/// are not visible from another, which is what keeps undo state isolated in
225/// a shared-bridge setup (issue #14).
226///
227/// Drops the RefCell borrow before returning (D029).
228pub fn auto_backup(
229    ctx: &AppContext,
230    session: &str,
231    path: &Path,
232    description: &str,
233) -> Result<Option<String>, AftError> {
234    if !path.exists() {
235        return Ok(None);
236    }
237    let backup_id = {
238        let mut store = ctx.backup().borrow_mut();
239        store.snapshot(session, path, description)?
240    }; // borrow dropped here
241    Ok(Some(backup_id))
242}
243
244/// Result of the write → format → validate pipeline.
245///
246/// Returned by `write_format_validate` to give callers a single struct
247/// with all post-write signals for the response JSON.
248pub struct WriteResult {
249    /// Whether tree-sitter syntax validation passed. `None` if unsupported language.
250    pub syntax_valid: Option<bool>,
251    /// Whether the file was auto-formatted.
252    pub formatted: bool,
253    /// Why formatting was skipped, if it was. Values: "unsupported_language",
254    /// "no_formatter_configured", "formatter_not_installed", "timeout", "error".
255    pub format_skipped_reason: Option<String>,
256    /// Whether full validation was requested (controls whether validation_errors is included in response).
257    pub validate_requested: bool,
258    /// Structured type-checker errors (only populated when validate:"full" is requested).
259    pub validation_errors: Vec<format::ValidationError>,
260    /// Why validation was skipped, if it was. Values: "unsupported_language",
261    /// "no_checker_configured", "checker_not_installed", "timeout", "error".
262    pub validate_skipped_reason: Option<String>,
263    /// Per-edit LSP diagnostics outcome (v0.17.3). Carries the verified-fresh
264    /// diagnostics PLUS per-server status (pending/exited) so the response
265    /// can report `complete: bool` honestly.
266    ///
267    /// `None` means the caller didn't request diagnostics OR the request
268    /// was a fire-and-forget notify (no wait). `Some(outcome)` always
269    /// reports diagnostics from servers that proved freshness against the
270    /// post-edit document version.
271    pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
272}
273
274impl WriteResult {
275    /// Append LSP diagnostics + per-server status to a response JSON
276    /// object.
277    ///
278    /// v0.17.3 honest-reporting contract: when diagnostics were requested
279    /// (`lsp_outcome.is_some()`), this ALWAYS emits `lsp_diagnostics: [...]`
280    /// (even if empty) plus `lsp_complete: bool`, `lsp_pending_servers`,
281    /// and `lsp_exited_servers`. Empty `lsp_diagnostics` no longer means
282    /// "the field disappeared" — it means "we waited and got an explicit
283    /// fresh-but-clean result, OR every expected server is in the pending/
284    /// exited list (check `lsp_complete`)."
285    ///
286    /// When diagnostics were NOT requested (`lsp_outcome.is_none()`),
287    /// nothing is added — keeps the no-LSP edit path's response shape
288    /// unchanged.
289    pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
290        let Some(outcome) = self.lsp_outcome.as_ref() else {
291            return;
292        };
293
294        result["lsp_diagnostics"] = serde_json::json!(outcome
295            .diagnostics
296            .iter()
297            .map(|d| {
298                serde_json::json!({
299                    "file": d.file.display().to_string(),
300                    "line": d.line,
301                    "column": d.column,
302                    "end_line": d.end_line,
303                    "end_column": d.end_column,
304                    "severity": d.severity.as_str(),
305                    "message": d.message,
306                    "code": d.code,
307                    "source": d.source,
308                })
309            })
310            .collect::<Vec<_>>());
311
312        result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
313
314        if !outcome.pending_servers.is_empty() {
315            result["lsp_pending_servers"] = serde_json::json!(outcome
316                .pending_servers
317                .iter()
318                .map(|key| key.kind.id_str().to_string())
319                .collect::<Vec<_>>());
320        }
321        if !outcome.exited_servers.is_empty() {
322            result["lsp_exited_servers"] = serde_json::json!(outcome
323                .exited_servers
324                .iter()
325                .map(|key| key.kind.id_str().to_string())
326                .collect::<Vec<_>>());
327        }
328    }
329}
330
331/// Write content to disk, auto-format, then validate syntax.
332///
333/// This is the shared tail for all mutation commands. The pipeline order is:
334/// 1. `fs::write` — persist content
335/// 2. `auto_format` — run the project formatter (reads the written file, writes back)
336/// 3. `validate_syntax` — parse the (potentially formatted) file
337/// 4. `validate_full` — run type checker if requested by params or config
338///
339/// The `params` argument carries the original request parameters. When it
340/// contains `"validate": "full"`, or config sets `validate_on_edit: "full"`,
341/// the project's type checker is invoked after syntax validation and the
342/// results are included in `WriteResult`.
343pub fn write_format_validate(
344    path: &Path,
345    content: &str,
346    config: &Config,
347    params: &serde_json::Value,
348) -> Result<WriteResult, AftError> {
349    // Step 1: Write
350    std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
351        message: format!("failed to write file: {}", e),
352    })?;
353
354    // Step 2: Format (before validate so we validate the formatted content)
355    let (formatted, format_skipped_reason) = format::auto_format(path, config);
356
357    // Step 3: Validate syntax
358    let syntax_valid = match validate_syntax(path) {
359        Ok(sv) => sv,
360        Err(_) => None,
361    };
362
363    // Step 4: Full validation (type checker) — only when requested
364    let param_validate = params.get("validate").and_then(|v| v.as_str());
365    let config_validate = config.validate_on_edit.as_deref();
366    // Explicit param overrides config. Valid values: "syntax" | "full" | "off".
367    let validate_mode = param_validate.or(config_validate).unwrap_or("off");
368    let validate_requested = validate_mode == "full";
369    let (validation_errors, validate_skipped_reason) = if validate_requested {
370        format::validate_full(path, config)
371    } else {
372        (Vec::new(), None)
373    };
374
375    Ok(WriteResult {
376        syntax_valid,
377        formatted,
378        format_skipped_reason,
379        validate_requested,
380        validation_errors,
381        validate_skipped_reason,
382        lsp_outcome: None,
383    })
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    // --- line_col_to_byte ---
391
392    #[test]
393    fn line_col_to_byte_empty_string() {
394        assert_eq!(line_col_to_byte("", 0, 0), 0);
395    }
396
397    #[test]
398    fn line_col_to_byte_single_line() {
399        let source = "hello";
400        assert_eq!(line_col_to_byte(source, 0, 0), 0);
401        assert_eq!(line_col_to_byte(source, 0, 3), 3);
402        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of line
403    }
404
405    #[test]
406    fn line_col_to_byte_multi_line() {
407        let source = "abc\ndef\nghi\n";
408        // line 0: "abc" at bytes 0..3, newline at 3
409        assert_eq!(line_col_to_byte(source, 0, 0), 0);
410        assert_eq!(line_col_to_byte(source, 0, 2), 2);
411        // line 1: "def" at bytes 4..7, newline at 7
412        assert_eq!(line_col_to_byte(source, 1, 0), 4);
413        assert_eq!(line_col_to_byte(source, 1, 3), 7);
414        // line 2: "ghi" at bytes 8..11, newline at 11
415        assert_eq!(line_col_to_byte(source, 2, 0), 8);
416        assert_eq!(line_col_to_byte(source, 2, 2), 10);
417    }
418
419    #[test]
420    fn line_col_to_byte_last_line_no_trailing_newline() {
421        let source = "abc\ndef";
422        // line 1: "def" at bytes 4..7, no trailing newline
423        assert_eq!(line_col_to_byte(source, 1, 0), 4);
424        assert_eq!(line_col_to_byte(source, 1, 3), 7); // end
425    }
426
427    #[test]
428    fn line_col_to_byte_multi_byte_utf8() {
429        // "é" is 2 bytes in UTF-8
430        let source = "café\nbar";
431        // line 0: "café" is 5 bytes (c=1, a=1, f=1, é=2)
432        assert_eq!(line_col_to_byte(source, 0, 0), 0);
433        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of "café"
434                                                       // line 1: "bar" starts at byte 6
435        assert_eq!(line_col_to_byte(source, 1, 0), 6);
436        assert_eq!(line_col_to_byte(source, 1, 2), 8);
437    }
438
439    #[test]
440    fn line_col_to_byte_beyond_end() {
441        let source = "abc";
442        // Line beyond file returns source.len()
443        assert_eq!(line_col_to_byte(source, 5, 0), source.len());
444    }
445
446    #[test]
447    fn line_col_to_byte_col_clamped_to_line_length() {
448        let source = "ab\ncd";
449        // col=10 on a 2-char line should clamp to 2
450        assert_eq!(line_col_to_byte(source, 0, 10), 2);
451    }
452
453    #[test]
454    fn line_col_to_byte_crlf() {
455        let source = "abc\r\ndef\r\nghi\r\n";
456        assert_eq!(line_col_to_byte(source, 0, 0), 0);
457        assert_eq!(line_col_to_byte(source, 0, 10), 3);
458        assert_eq!(line_col_to_byte(source, 1, 0), 5);
459        assert_eq!(line_col_to_byte(source, 1, 3), 8);
460        assert_eq!(line_col_to_byte(source, 2, 0), 10);
461    }
462
463    // --- replace_byte_range ---
464
465    #[test]
466    fn replace_byte_range_basic() {
467        let source = "hello world";
468        let result = replace_byte_range(source, 6, 11, "rust").unwrap();
469        assert_eq!(result, "hello rust");
470    }
471
472    #[test]
473    fn replace_byte_range_delete() {
474        let source = "hello world";
475        let result = replace_byte_range(source, 5, 11, "").unwrap();
476        assert_eq!(result, "hello");
477    }
478
479    #[test]
480    fn replace_byte_range_insert_at_same_position() {
481        let source = "helloworld";
482        let result = replace_byte_range(source, 5, 5, " ").unwrap();
483        assert_eq!(result, "hello world");
484    }
485
486    #[test]
487    fn replace_byte_range_replace_entire_string() {
488        let source = "old content";
489        let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
490        assert_eq!(result, "new content");
491    }
492}
493
494/// Format an already-written file (no re-write) without re-writing or validating.
495/// Returns Ok(true) if formatting was applied, Ok(false) if skipped.
496pub fn write_format_only(path: &Path, config: &Config) -> Result<bool, AftError> {
497    use crate::format::detect_formatter;
498    let lang = match crate::parser::detect_language(path) {
499        Some(l) => l,
500        None => return Ok(false),
501    };
502    let formatter = detect_formatter(path, lang, config);
503    if let Some((cmd, args)) = formatter {
504        let status = std::process::Command::new(&cmd)
505            .args(&args)
506            .arg(path)
507            .stdout(std::process::Stdio::null())
508            .stderr(std::process::Stdio::null())
509            .status();
510        match status {
511            Ok(s) if s.success() => Ok(true),
512            _ => Ok(false),
513        }
514    } else {
515        Ok(false)
516    }
517}