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
6#![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
16/// Convert 0-indexed line/col to a byte offset within `source`.
17///
18/// Tree-sitter columns are byte-indexed within the line, so `col` is a byte
19/// offset from the start of the line (not a character offset).
20///
21/// Scans raw bytes so both LF and CRLF line endings are counted correctly.
22/// Returns `source.len()` if line is beyond the end of the file.
23pub 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
55/// Replace bytes in `[start..end)` with `replacement`.
56///
57/// Returns an error if the range is invalid or does not align to UTF-8 char boundaries.
58pub fn replace_byte_range(
59    source: &str,
60    start: usize,
61    end: usize,
62    replacement: &str,
63) -> Result<String, AftError> {
64    if start > end {
65        return Err(AftError::InvalidRequest {
66            message: format!(
67                "invalid byte range [{}..{}): start must be <= end",
68                start, end
69            ),
70        });
71    }
72    if end > source.len() {
73        return Err(AftError::InvalidRequest {
74            message: format!(
75                "invalid byte range [{}..{}): end exceeds source length {}",
76                start,
77                end,
78                source.len()
79            ),
80        });
81    }
82    if !source.is_char_boundary(start) {
83        return Err(AftError::InvalidRequest {
84            message: format!(
85                "invalid byte range [{}..{}): start is not a char boundary",
86                start, end
87            ),
88        });
89    }
90    if !source.is_char_boundary(end) {
91        return Err(AftError::InvalidRequest {
92            message: format!(
93                "invalid byte range [{}..{}): end is not a char boundary",
94                start, end
95            ),
96        });
97    }
98
99    let mut result = String::with_capacity(
100        source.len().saturating_sub(end.saturating_sub(start)) + replacement.len(),
101    );
102    result.push_str(&source[..start]);
103    result.push_str(replacement);
104    result.push_str(&source[end..]);
105    Ok(result)
106}
107
108/// Validate syntax of a file using a fresh FileParser (D023).
109///
110/// Returns `Ok(Some(true))` if syntax is valid, `Ok(Some(false))` if there are
111/// parse errors, and `Ok(None)` if the language is unsupported.
112pub fn validate_syntax(path: &Path) -> Result<Option<bool>, AftError> {
113    let mut parser = FileParser::new();
114    match parser.parse(path) {
115        Ok((tree, _lang)) => Ok(Some(!tree.root_node().has_error())),
116        Err(AftError::InvalidRequest { .. }) => {
117            // Unsupported language — not an error, just can't validate
118            Ok(None)
119        }
120        Err(e) => Err(e),
121    }
122}
123
124/// Validate syntax of an in-memory string without touching disk.
125///
126/// Uses `detect_language(path)` + `grammar_for(lang)` + `parser.parse()`
127/// to validate syntax of a proposed content string. Returns `None` for
128/// unsupported languages, `Some(true)` for valid, `Some(false)` for invalid.
129pub fn validate_syntax_str(content: &str, path: &Path) -> Option<bool> {
130    let lang = detect_language(path)?;
131    let grammar = grammar_for(lang);
132    let mut parser = tree_sitter::Parser::new();
133    if parser.set_language(&grammar).is_err() {
134        return None;
135    }
136    let tree = parser.parse(content.as_bytes(), None)?;
137    Some(!tree.root_node().has_error())
138}
139
140/// Check if the caller requested diff info in the response.
141///
142/// `include_diff` yields a compact counts-only diff (`additions`/`deletions`),
143/// which is what agent-facing/raw consumers should use — the payload does not
144/// scale with file size. Full before/after content requires the separate
145/// `include_diff_content` flag (UI metadata only); see [`wants_diff_content`].
146pub fn wants_diff(params: &serde_json::Value) -> bool {
147    params
148        .get("include_diff")
149        .and_then(|v| v.as_bool())
150        .unwrap_or(false)
151        || wants_diff_content(params)
152}
153
154/// Check if the caller requested the full before/after file contents in the
155/// diff. This is for UI rendering only (e.g. the OpenCode/Pi plugins building a
156/// diff view in tool metadata) and is deliberately NOT the default: full
157/// content makes the response scale with file size, not edit size, which floods
158/// agent context on large files. Agent-facing/raw consumers should pass
159/// `include_diff` (counts only) instead.
160pub fn wants_diff_content(params: &serde_json::Value) -> bool {
161    params
162        .get("include_diff_content")
163        .and_then(|v| v.as_bool())
164        .unwrap_or(false)
165}
166
167/// Check whether the caller requested an internal, side-effect-free preview.
168///
169/// This is deliberately a wire-only flag used by host integrations before they
170/// ask for edit approval. It is not exposed in any agent-facing tool schema.
171pub fn wants_preview(params: &serde_json::Value) -> bool {
172    params
173        .get("preview")
174        .and_then(|v| v.as_bool())
175        .unwrap_or(false)
176}
177
178/// Build the unified-diff string used by internal permission previews.
179///
180/// The OpenCode approval prompt expects a plain unified-diff string in
181/// `metadata.diff`. Host wrappers may alternatively consume the `diff.before` /
182/// `diff.after` response fields and render their own patch, but returning the
183/// ready-to-display string keeps the bridge contract self-contained.
184pub fn build_unified_diff(file: &str, before: &str, after: &str) -> String {
185    if before == after {
186        return format!(
187            "Index: {file}
188===================================================================
189--- {file}
190+++ {file}
191"
192        );
193    }
194
195    let text_diff = similar::TextDiff::from_lines(before, after);
196    let patch = text_diff.unified_diff().header(file, file).to_string();
197    format!(
198        "Index: {file}
199===================================================================
200{patch}"
201    )
202}
203
204/// Attach the standard preview diff fields to a command response payload.
205pub fn attach_preview_diff(
206    result: &mut serde_json::Value,
207    params: &serde_json::Value,
208    file: &str,
209    before: &str,
210    after: &str,
211) {
212    result["preview"] = serde_json::json!(true);
213    result["diff"] = compute_diff_for_response(params, before, after);
214    result["preview_diff"] = serde_json::json!(build_unified_diff(file, before, after));
215}
216
217fn diff_counts(before: &str, after: &str) -> (usize, usize) {
218    use similar::ChangeTag;
219
220    let diff = similar::TextDiff::from_lines(before, after);
221    let mut additions = 0usize;
222    let mut deletions = 0usize;
223    for change in diff.iter_all_changes() {
224        match change.tag() {
225            ChangeTag::Insert => additions += 1,
226            ChangeTag::Delete => deletions += 1,
227            ChangeTag::Equal => {}
228        }
229    }
230    (additions, deletions)
231}
232
233/// Compute compact diff counts (additions/deletions) without echoing any file
234/// content. This is the agent-facing default — the payload is constant-size
235/// regardless of how large the edited file is.
236pub fn compute_diff_counts(before: &str, after: &str) -> serde_json::Value {
237    let (additions, deletions) = diff_counts(before, after);
238    serde_json::json!({
239        "additions": additions,
240        "deletions": deletions,
241    })
242}
243
244/// Pick the right diff shape for a response based on request flags.
245///
246/// Default (`include_diff`): compact counts only — constant-size payload that
247/// never floods agent context. Full before/after content is returned only when
248/// the caller explicitly opts in with `include_diff_content` (UI metadata path).
249pub fn compute_diff_for_response(
250    params: &serde_json::Value,
251    before: &str,
252    after: &str,
253) -> serde_json::Value {
254    if wants_diff_content(params) {
255        compute_diff_info(before, after)
256    } else {
257        compute_diff_counts(before, after)
258    }
259}
260
261/// Compute diff info between before/after content for UI metadata.
262/// Returns a JSON value with before, after, additions, deletions.
263/// For files >512KB, omits full content and returns only counts.
264pub fn compute_diff_info(before: &str, after: &str) -> serde_json::Value {
265    let (additions, deletions) = diff_counts(before, after);
266
267    // For large files, skip sending full content to avoid bloating JSON
268    let size_limit = 512 * 1024; // 512KB
269    if before.len() > size_limit || after.len() > size_limit {
270        serde_json::json!({
271            "additions": additions,
272            "deletions": deletions,
273            "truncated": true,
274        })
275    } else {
276        serde_json::json!({
277            "before": before,
278            "after": after,
279            "additions": additions,
280            "deletions": deletions,
281        })
282    }
283}
284/// Snapshot the file into the backup store before mutation, scoped to a session.
285///
286/// Returns `Ok(Some(backup_id))` if the file existed and was backed up,
287/// `Ok(None)` if the file doesn't exist (new file creation).
288///
289/// The `session` argument is the request-level session namespace (see
290/// [`crate::protocol::RawRequest::session`]). Snapshots created by one session
291/// are not visible from another, which is what keeps undo state isolated in
292/// a shared-bridge setup (issue #14).
293///
294/// Drops the RefCell borrow before returning (D029).
295pub fn auto_backup(
296    ctx: &AppContext,
297    session: &str,
298    path: &Path,
299    description: &str,
300    op_id: Option<&str>,
301) -> Result<Option<String>, AftError> {
302    if std::fs::symlink_metadata(path).is_err() {
303        return Ok(None);
304    }
305    let backup_id = {
306        let mut store = ctx.backup().lock();
307        store.snapshot_with_op(session, path, description, op_id)?
308    }; // borrow dropped here
309    Ok(backup_id)
310}
311
312/// Post-format excerpt of the region(s) the formatter reflowed, so the agent
313/// can re-anchor its next edit on the real on-disk text instead of the text it
314/// submitted. `None` on WriteResult when the formatter did not change the
315/// applied edit (self-suppressing).
316pub struct ReformattedExcerpt {
317    /// Post-format text of the changed region(s), with ~2 lines of context,
318    /// capped. Empty when `extensive` is true.
319    pub text: String,
320    /// True when the reflow exceeded the cap (whole-file reformat etc.) — too
321    /// large to inline; the agent should re-read the file before re-anchoring.
322    pub extensive: bool,
323}
324
325const REFORMATTED_EXCERPT_MAX_LINES: usize = 60;
326const REFORMATTED_EXCERPT_MAX_BYTES: usize = 4096;
327
328/// Compute a bounded post-format excerpt when `pre_format` (agent-applied edit)
329/// differs from `post_format` (on-disk after formatting).
330pub fn compute_reformatted_excerpt(
331    pre_format: &str,
332    post_format: &str,
333) -> Option<ReformattedExcerpt> {
334    if pre_format == post_format {
335        return None;
336    }
337
338    use similar::DiffTag;
339
340    let diff = similar::TextDiff::from_lines(pre_format, post_format);
341    let post_lines: Vec<&str> = post_format.lines().collect();
342    let mut collected: Vec<String> = Vec::new();
343    let mut last_post_idx: Option<usize> = None;
344
345    for group in diff.grouped_ops(2) {
346        let mut group_start: Option<usize> = None;
347        let mut group_end: Option<usize> = None;
348
349        for op in group {
350            let tag = op.tag();
351            if tag == DiffTag::Delete {
352                continue;
353            }
354            let new_range = op.new_range();
355            if new_range.is_empty() {
356                continue;
357            }
358            let start = new_range.start;
359            let end = new_range.end.saturating_sub(1);
360            group_start = Some(group_start.map_or(start, |s| s.min(start)));
361            group_end = Some(group_end.map_or(end, |e| e.max(end)));
362        }
363
364        let (Some(start), Some(end)) = (group_start, group_end) else {
365            continue;
366        };
367
368        if let Some(prev) = last_post_idx {
369            if start > prev + 1 {
370                collected.push("…".to_string());
371            }
372        }
373
374        for idx in start..=end {
375            if idx < post_lines.len() {
376                collected.push(post_lines[idx].to_string());
377            }
378        }
379        last_post_idx = Some(end);
380    }
381
382    let line_count = collected.len();
383    let byte_count: usize = collected.iter().map(|l| l.len() + 1).sum();
384    if line_count > REFORMATTED_EXCERPT_MAX_LINES || byte_count > REFORMATTED_EXCERPT_MAX_BYTES {
385        return Some(ReformattedExcerpt {
386            text: String::new(),
387            extensive: true,
388        });
389    }
390
391    Some(ReformattedExcerpt {
392        text: collected.join("\n"),
393        extensive: false,
394    })
395}
396
397/// Result of the write → format → validate pipeline.
398///
399/// Returned by `write_format_validate` to give callers a single struct
400/// with all post-write signals for the response JSON.
401pub struct WriteResult {
402    /// Whether tree-sitter syntax validation passed. `None` if unsupported language.
403    pub syntax_valid: Option<bool>,
404    /// Whether the file was auto-formatted.
405    pub formatted: bool,
406    /// Why formatting was skipped, if it was. Values: "unsupported_language",
407    /// "no_formatter_configured", "formatter_not_installed", "formatter_excluded_path",
408    /// "timeout", "error".
409    pub format_skipped_reason: Option<String>,
410    /// Whether full validation was requested (controls whether validation_errors is included in response).
411    pub validate_requested: bool,
412    /// Structured type-checker errors (only populated when validate:"full" is requested).
413    pub validation_errors: Vec<format::ValidationError>,
414    /// Why validation was skipped, if it was. Values: "unsupported_language",
415    /// "no_checker_configured", "checker_not_installed", "timeout", "error".
416    pub validate_skipped_reason: Option<String>,
417    /// True when the write+format+validate pipeline detected post-write
418    /// invalid syntax against a previously-valid file and restored the
419    /// pre-write content. The on-disk file is the original; `syntax_valid`
420    /// reports the would-have-been-written status (Some(false)).
421    pub rolled_back: bool,
422    /// Per-edit LSP diagnostics outcome (v0.17.3). Carries the verified-fresh
423    /// diagnostics PLUS per-server status (pending/exited) so the response
424    /// can report `complete: bool` honestly.
425    ///
426    /// `None` means the caller didn't request diagnostics OR the request
427    /// was a fire-and-forget notify (no wait). `Some(outcome)` always
428    /// reports diagnostics from servers that proved freshness against the
429    /// post-edit document version.
430    pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
431    /// Post-format excerpt when the formatter reflowed the applied edit.
432    pub reformatted_excerpt: Option<ReformattedExcerpt>,
433}
434
435/// Render structured validation errors as a compact `line N: message` list for
436/// an error message. Used by the refactor handlers when a write was rolled back.
437pub fn format_validation_errors(errors: &[format::ValidationError]) -> String {
438    errors
439        .iter()
440        .map(|e| format!("line {}: {}", e.line, e.message))
441        .collect::<Vec<_>>()
442        .join("; ")
443}
444
445impl WriteResult {
446    /// Append LSP diagnostics + per-server status to a response JSON
447    /// object.
448    ///
449    /// v0.17.3 honest-reporting contract: when diagnostics were requested
450    /// (`lsp_outcome.is_some()`), this ALWAYS emits `lsp_diagnostics: [...]`
451    /// (even if empty) plus `lsp_complete: bool`, `lsp_pending_servers`,
452    /// and `lsp_exited_servers`. Empty `lsp_diagnostics` no longer means
453    /// "the field disappeared" — it means "we waited and got an explicit
454    /// fresh-but-clean result, OR every expected server is in the pending/
455    /// exited list (check `lsp_complete`)."
456    ///
457    /// When diagnostics were NOT requested (`lsp_outcome.is_none()`),
458    /// nothing is added — keeps the no-LSP edit path's response shape
459    /// unchanged.
460    pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
461        result["rolled_back"] = serde_json::json!(self.rolled_back);
462
463        let Some(outcome) = self.lsp_outcome.as_ref() else {
464            return;
465        };
466
467        result["lsp_diagnostics"] = serde_json::json!(outcome
468            .diagnostics
469            .iter()
470            .map(|d| {
471                serde_json::json!({
472                    "file": d.file.display().to_string(),
473                    "line": d.line,
474                    "column": d.column,
475                    "end_line": d.end_line,
476                    "end_column": d.end_column,
477                    "severity": d.severity.as_str(),
478                    "message": d.message,
479                    "code": d.code,
480                    "source": d.source,
481                })
482            })
483            .collect::<Vec<_>>());
484
485        result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
486
487        if !outcome.pending_servers.is_empty() {
488            result["lsp_pending_servers"] = serde_json::json!(outcome
489                .pending_servers
490                .iter()
491                .map(|key| key.kind.id_str().to_string())
492                .collect::<Vec<_>>());
493        }
494        if !outcome.exited_servers.is_empty() {
495            result["lsp_exited_servers"] = serde_json::json!(outcome
496                .exited_servers
497                .iter()
498                .map(|key| key.kind.id_str().to_string())
499                .collect::<Vec<_>>());
500        }
501    }
502
503    /// Append post-format reflow excerpt when the formatter changed the applied edit.
504    pub fn append_reformatted_excerpt_to(&self, result: &mut serde_json::Value) {
505        if let Some(excerpt) = &self.reformatted_excerpt {
506            if excerpt.extensive {
507                result["reformatted"] = serde_json::json!({ "extensive": true });
508            } else {
509                result["reformatted"] = serde_json::json!({ "text": excerpt.text });
510            }
511        }
512    }
513}
514
515/// Write content to disk, auto-format, then validate syntax.
516///
517/// This is the shared tail for all mutation commands. The pipeline order is:
518/// 1. `fs::write` — persist content
519/// 2. `auto_format` — run the project formatter (reads the written file, writes back)
520/// 3. `validate_syntax` — parse the (potentially formatted) file
521/// 4. `validate_full` — run type checker if requested by params or config
522///
523/// The `params` argument carries the original request parameters. When it
524/// contains `"validate": "full"`, or config sets `validate_on_edit: "full"`,
525/// the project's type checker is invoked after syntax validation and the
526/// results are included in `WriteResult`.
527pub fn write_format_validate(
528    path: &Path,
529    content: &str,
530    config: &Config,
531    params: &serde_json::Value,
532) -> Result<WriteResult, AftError> {
533    let pre_write_content = if path.exists() {
534        std::fs::read_to_string(path).ok()
535    } else {
536        None
537    };
538    // Existing clean files are protected from invalid mutations. New files have
539    // no safe prior content to restore, so their pre-write validity remains None
540    // and invalid syntax is reported without rollback.
541    let was_syntax_valid = if pre_write_content.is_some() {
542        match validate_syntax(path) {
543            Ok(valid) => valid,
544            Err(_) => None,
545        }
546    } else {
547        None
548    };
549
550    // Step 1: Write
551    std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
552        message: format!("failed to write file: {}", e),
553    })?;
554
555    // Step 2: Format (before validate so we validate the formatted content)
556    let (formatted, format_skipped_reason) = format::auto_format(path, config);
557
558    // Step 3: Validate syntax
559    let syntax_valid = match validate_syntax(path) {
560        Ok(sv) => sv,
561        Err(_) => None,
562    };
563    let rolled_back = if was_syntax_valid == Some(true) && syntax_valid == Some(false) {
564        if let Some(original) = pre_write_content.as_ref() {
565            std::fs::write(path, original).map_err(|e| AftError::InvalidRequest {
566                message: format!("failed to roll back invalid edit: {}", e),
567            })?;
568            true
569        } else {
570            false
571        }
572    } else {
573        false
574    };
575
576    // Step 4: Full validation (type checker) — only when requested
577    let param_validate = params.get("validate").and_then(|v| v.as_str());
578    let config_validate = config.validate_on_edit.as_deref();
579    // Explicit param overrides config. Valid values: "syntax" | "full" | "off".
580    let validate_mode = param_validate.or(config_validate).unwrap_or("off");
581    let validate_requested = validate_mode == "full";
582    let (validation_errors, validate_skipped_reason) = if validate_requested {
583        format::validate_full(path, config)
584    } else {
585        (Vec::new(), None)
586    };
587
588    let reformatted_excerpt = if rolled_back {
589        None
590    } else {
591        std::fs::read_to_string(path)
592            .ok()
593            .and_then(|final_on_disk| compute_reformatted_excerpt(content, &final_on_disk))
594    };
595
596    Ok(WriteResult {
597        syntax_valid,
598        formatted,
599        format_skipped_reason,
600        validate_requested,
601        validation_errors,
602        validate_skipped_reason,
603        rolled_back,
604        lsp_outcome: None,
605        reformatted_excerpt,
606    })
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    // --- line_col_to_byte ---
614
615    #[test]
616    fn line_col_to_byte_empty_string() {
617        assert_eq!(line_col_to_byte("", 0, 0), 0);
618    }
619
620    #[test]
621    fn line_col_to_byte_single_line() {
622        let source = "hello";
623        assert_eq!(line_col_to_byte(source, 0, 0), 0);
624        assert_eq!(line_col_to_byte(source, 0, 3), 3);
625        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of line
626    }
627
628    #[test]
629    fn line_col_to_byte_multi_line() {
630        let source = "abc\ndef\nghi\n";
631        // line 0: "abc" at bytes 0..3, newline at 3
632        assert_eq!(line_col_to_byte(source, 0, 0), 0);
633        assert_eq!(line_col_to_byte(source, 0, 2), 2);
634        // line 1: "def" at bytes 4..7, newline at 7
635        assert_eq!(line_col_to_byte(source, 1, 0), 4);
636        assert_eq!(line_col_to_byte(source, 1, 3), 7);
637        // line 2: "ghi" at bytes 8..11, newline at 11
638        assert_eq!(line_col_to_byte(source, 2, 0), 8);
639        assert_eq!(line_col_to_byte(source, 2, 2), 10);
640    }
641
642    #[test]
643    fn line_col_to_byte_last_line_no_trailing_newline() {
644        let source = "abc\ndef";
645        // line 1: "def" at bytes 4..7, no trailing newline
646        assert_eq!(line_col_to_byte(source, 1, 0), 4);
647        assert_eq!(line_col_to_byte(source, 1, 3), 7); // end
648    }
649
650    #[test]
651    fn line_col_to_byte_multi_byte_utf8() {
652        // "é" is 2 bytes in UTF-8
653        let source = "café\nbar";
654        // line 0: "café" is 5 bytes (c=1, a=1, f=1, é=2)
655        assert_eq!(line_col_to_byte(source, 0, 0), 0);
656        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of "café"
657                                                       // line 1: "bar" starts at byte 6
658        assert_eq!(line_col_to_byte(source, 1, 0), 6);
659        assert_eq!(line_col_to_byte(source, 1, 2), 8);
660    }
661
662    #[test]
663    fn line_col_to_byte_beyond_end() {
664        let source = "abc";
665        // Line beyond file returns source.len()
666        assert_eq!(line_col_to_byte(source, 5, 0), source.len());
667    }
668
669    #[test]
670    fn line_col_to_byte_col_clamped_to_line_length() {
671        let source = "ab\ncd";
672        // col=10 on a 2-char line should clamp to 2
673        assert_eq!(line_col_to_byte(source, 0, 10), 2);
674    }
675
676    #[test]
677    fn line_col_to_byte_crlf() {
678        let source = "abc\r\ndef\r\nghi\r\n";
679        assert_eq!(line_col_to_byte(source, 0, 0), 0);
680        assert_eq!(line_col_to_byte(source, 0, 10), 3);
681        assert_eq!(line_col_to_byte(source, 1, 0), 5);
682        assert_eq!(line_col_to_byte(source, 1, 3), 8);
683        assert_eq!(line_col_to_byte(source, 2, 0), 10);
684    }
685
686    // --- replace_byte_range ---
687
688    #[test]
689    fn replace_byte_range_basic() {
690        let source = "hello world";
691        let result = replace_byte_range(source, 6, 11, "rust").unwrap();
692        assert_eq!(result, "hello rust");
693    }
694
695    #[test]
696    fn replace_byte_range_delete() {
697        let source = "hello world";
698        let result = replace_byte_range(source, 5, 11, "").unwrap();
699        assert_eq!(result, "hello");
700    }
701
702    #[test]
703    fn replace_byte_range_insert_at_same_position() {
704        let source = "helloworld";
705        let result = replace_byte_range(source, 5, 5, " ").unwrap();
706        assert_eq!(result, "hello world");
707    }
708
709    #[test]
710    fn replace_byte_range_replace_entire_string() {
711        let source = "old content";
712        let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
713        assert_eq!(result, "new content");
714    }
715
716    #[test]
717    fn compute_reformatted_excerpt_self_suppresses_when_unchanged() {
718        let s = "fn main() {\n    let x = 1;\n}\n";
719        assert!(compute_reformatted_excerpt(s, s).is_none());
720    }
721
722    #[test]
723    fn compute_reformatted_excerpt_includes_post_format_text() {
724        let before = "fn  main( ){  let   x=1;  }";
725        let after = "fn main() {\n    let x = 1;\n}\n";
726        let excerpt = compute_reformatted_excerpt(before, after).expect("should diff");
727        assert!(!excerpt.extensive);
728        assert!(excerpt.text.contains("fn main()"));
729        assert!(excerpt.text.contains("let x = 1"));
730    }
731
732    #[test]
733    fn compute_reformatted_excerpt_extensive_when_over_line_cap() {
734        let before: String = (0..80).map(|i| format!("line{i} ugly\n")).collect();
735        let after: String = (0..80).map(|i| format!("line{i} neat\n")).collect();
736        let excerpt = compute_reformatted_excerpt(&before, &after).expect("should diff");
737        assert!(excerpt.extensive);
738        assert!(excerpt.text.is_empty());
739    }
740
741    // --- validate_syntax_str: `&raw` must not be a false syntax error ---
742
743    /// `&raw` where `raw` is an ordinary variable is valid Rust (a reference to
744    /// the binding `raw`). `raw` is only a contextual keyword in the raw-borrow
745    /// operators `&raw const` / `&raw mut`. tree-sitter-rust before 0.24.2
746    /// mis-parsed a bare `&raw` as the start of a raw-borrow and emitted an
747    /// ERROR node, so `validate_syntax_str` returned `Some(false)` and the edit
748    /// pipeline rolled back a correct edit. This pins the fixed grammar
749    /// behavior: a grammar downgrade that reintroduces the false positive fails
750    /// here instead of silently discarding users' edits.
751    #[test]
752    fn validate_syntax_str_accepts_reference_to_variable_named_raw() {
753        let path = Path::new("lib.rs");
754        let src = "fn handle_hash(x: &u32) -> u32 { *x }\n\
755                   fn main() {\n    let raw = 5u32;\n    let _ = handle_hash(&raw);\n}\n";
756        assert_eq!(validate_syntax_str(src, path), Some(true));
757    }
758
759    /// The genuine raw-borrow operators must still parse cleanly (guard against
760    /// a "fix" that loosens the grammar the wrong way).
761    #[test]
762    fn validate_syntax_str_accepts_raw_borrow_operators() {
763        let path = Path::new("lib.rs");
764        let const_borrow = "fn main() {\n    let x = 5u32;\n    let _p = &raw const x;\n}\n";
765        let mut_borrow = "fn main() {\n    let mut x = 5u32;\n    let _p = &raw mut x;\n}\n";
766        assert_eq!(validate_syntax_str(const_borrow, path), Some(true));
767        assert_eq!(validate_syntax_str(mut_borrow, path), Some(true));
768    }
769}