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