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/// Persist a regular-file capture that was already freshness-checked while
313/// creating the operation's rollback checkpoint.
314pub(crate) fn auto_backup_from_capture(
315    ctx: &AppContext,
316    session: &str,
317    path: &Path,
318    description: &str,
319    op_id: Option<&str>,
320    capture: &crate::backup::CapturedRegularFile,
321) -> Result<Option<String>, AftError> {
322    let backup_id = {
323        let mut store = ctx.backup().lock();
324        store.snapshot_with_op_from_capture(session, path, description, op_id, capture)?
325    };
326    Ok(backup_id)
327}
328
329/// Post-format excerpt of the region(s) the formatter reflowed, so the agent
330/// can re-anchor its next edit on the real on-disk text instead of the text it
331/// submitted. `None` on WriteResult when the formatter did not change the
332/// applied edit (self-suppressing).
333pub struct ReformattedExcerpt {
334    /// Post-format text of the changed region(s), with ~2 lines of context,
335    /// capped. Empty when `extensive` is true.
336    pub text: String,
337    /// True when the reflow exceeded the cap (whole-file reformat etc.) — too
338    /// large to inline; the agent should re-read the file before re-anchoring.
339    pub extensive: bool,
340}
341
342const REFORMATTED_EXCERPT_MAX_LINES: usize = 60;
343const REFORMATTED_EXCERPT_MAX_BYTES: usize = 4096;
344
345/// Compute a bounded post-format excerpt when `pre_format` (agent-applied edit)
346/// differs from `post_format` (on-disk after formatting).
347pub fn compute_reformatted_excerpt(
348    pre_format: &str,
349    post_format: &str,
350) -> Option<ReformattedExcerpt> {
351    if pre_format == post_format {
352        return None;
353    }
354
355    use similar::DiffTag;
356
357    let diff = similar::TextDiff::from_lines(pre_format, post_format);
358    let post_lines: Vec<&str> = post_format.lines().collect();
359    let mut collected: Vec<String> = Vec::new();
360    let mut last_post_idx: Option<usize> = None;
361
362    for group in diff.grouped_ops(2) {
363        let mut group_start: Option<usize> = None;
364        let mut group_end: Option<usize> = None;
365
366        for op in group {
367            let tag = op.tag();
368            if tag == DiffTag::Delete {
369                continue;
370            }
371            let new_range = op.new_range();
372            if new_range.is_empty() {
373                continue;
374            }
375            let start = new_range.start;
376            let end = new_range.end.saturating_sub(1);
377            group_start = Some(group_start.map_or(start, |s| s.min(start)));
378            group_end = Some(group_end.map_or(end, |e| e.max(end)));
379        }
380
381        let (Some(start), Some(end)) = (group_start, group_end) else {
382            continue;
383        };
384
385        if let Some(prev) = last_post_idx {
386            if start > prev + 1 {
387                collected.push("…".to_string());
388            }
389        }
390
391        for idx in start..=end {
392            if idx < post_lines.len() {
393                collected.push(post_lines[idx].to_string());
394            }
395        }
396        last_post_idx = Some(end);
397    }
398
399    let line_count = collected.len();
400    let byte_count: usize = collected.iter().map(|l| l.len() + 1).sum();
401    if line_count > REFORMATTED_EXCERPT_MAX_LINES || byte_count > REFORMATTED_EXCERPT_MAX_BYTES {
402        return Some(ReformattedExcerpt {
403            text: String::new(),
404            extensive: true,
405        });
406    }
407
408    Some(ReformattedExcerpt {
409        text: collected.join("\n"),
410        extensive: false,
411    })
412}
413
414/// Result of the write → format → validate pipeline.
415///
416/// Returned by `write_format_validate` to give callers a single struct
417/// with all post-write signals for the response JSON.
418pub struct WriteResult {
419    /// Whether tree-sitter syntax validation passed. `None` if unsupported language.
420    pub syntax_valid: Option<bool>,
421    /// Whether the file was auto-formatted.
422    pub formatted: bool,
423    /// Why formatting was skipped, if it was. Values: "unsupported_language",
424    /// "no_formatter_configured", "formatter_not_installed", "formatter_excluded_path",
425    /// "timeout", "error".
426    pub format_skipped_reason: Option<String>,
427    /// Whether full validation was requested (controls whether validation_errors is included in response).
428    pub validate_requested: bool,
429    /// Structured type-checker errors (only populated when validate:"full" is requested).
430    pub validation_errors: Vec<format::ValidationError>,
431    /// Why validation was skipped, if it was. Values: "unsupported_language",
432    /// "no_checker_configured", "checker_not_installed", "timeout", "error".
433    pub validate_skipped_reason: Option<String>,
434    /// True when the write+format+validate pipeline detected post-write
435    /// invalid syntax against a previously-valid file and restored the
436    /// pre-write content. The on-disk file is the original; `syntax_valid`
437    /// reports the would-have-been-written status (Some(false)).
438    pub rolled_back: bool,
439    /// Per-edit LSP diagnostics outcome (v0.17.3). Carries the verified-fresh
440    /// diagnostics PLUS per-server status (pending/exited) so the response
441    /// can report `complete: bool` honestly.
442    ///
443    /// `None` means the caller didn't request diagnostics OR the request
444    /// was a fire-and-forget notify (no wait). `Some(outcome)` always
445    /// reports diagnostics from servers that proved freshness against the
446    /// post-edit document version.
447    pub lsp_outcome: Option<crate::lsp::manager::PostEditWaitOutcome>,
448    /// Post-format excerpt when the formatter reflowed the applied edit.
449    pub reformatted_excerpt: Option<ReformattedExcerpt>,
450}
451
452/// Render structured validation errors as a compact `line N: message` list for
453/// an error message. Used by the refactor handlers when a write was rolled back.
454pub fn format_validation_errors(errors: &[format::ValidationError]) -> String {
455    errors
456        .iter()
457        .map(|e| format!("line {}: {}", e.line, e.message))
458        .collect::<Vec<_>>()
459        .join("; ")
460}
461
462impl WriteResult {
463    /// Append LSP diagnostics + per-server status to a response JSON
464    /// object.
465    ///
466    /// v0.17.3 honest-reporting contract: when diagnostics were requested
467    /// (`lsp_outcome.is_some()`), this ALWAYS emits `lsp_diagnostics: [...]`
468    /// (even if empty) plus `lsp_complete: bool`, `lsp_pending_servers`,
469    /// and `lsp_exited_servers`. Empty `lsp_diagnostics` no longer means
470    /// "the field disappeared" — it means "we waited and got an explicit
471    /// fresh-but-clean result, OR every expected server is in the pending/
472    /// exited list (check `lsp_complete`)."
473    ///
474    /// When diagnostics were NOT requested (`lsp_outcome.is_none()`),
475    /// nothing is added — keeps the no-LSP edit path's response shape
476    /// unchanged.
477    pub fn append_lsp_diagnostics_to(&self, result: &mut serde_json::Value) {
478        result["rolled_back"] = serde_json::json!(self.rolled_back);
479
480        let Some(outcome) = self.lsp_outcome.as_ref() else {
481            return;
482        };
483
484        result["lsp_diagnostics"] = serde_json::json!(outcome
485            .diagnostics
486            .iter()
487            .map(|d| {
488                serde_json::json!({
489                    "file": d.file.display().to_string(),
490                    "line": d.line,
491                    "column": d.column,
492                    "end_line": d.end_line,
493                    "end_column": d.end_column,
494                    "severity": d.severity.as_str(),
495                    "message": d.message,
496                    "code": d.code,
497                    "source": d.source,
498                })
499            })
500            .collect::<Vec<_>>());
501
502        result["lsp_complete"] = serde_json::Value::Bool(outcome.complete());
503
504        if !outcome.pending_servers.is_empty() {
505            result["lsp_pending_servers"] = serde_json::json!(outcome
506                .pending_servers
507                .iter()
508                .map(|key| key.kind.id_str().to_string())
509                .collect::<Vec<_>>());
510        }
511        if !outcome.exited_servers.is_empty() {
512            result["lsp_exited_servers"] = serde_json::json!(outcome
513                .exited_servers
514                .iter()
515                .map(|key| key.kind.id_str().to_string())
516                .collect::<Vec<_>>());
517        }
518    }
519
520    /// Append post-format reflow excerpt when the formatter changed the applied edit.
521    pub fn append_reformatted_excerpt_to(&self, result: &mut serde_json::Value) {
522        if let Some(excerpt) = &self.reformatted_excerpt {
523            if excerpt.extensive {
524                result["reformatted"] = serde_json::json!({ "extensive": true });
525            } else {
526                result["reformatted"] = serde_json::json!({ "text": excerpt.text });
527            }
528        }
529    }
530}
531
532/// Write content to disk, auto-format, then validate syntax.
533///
534/// This is the shared tail for all mutation commands. The pipeline order is:
535/// 1. `fs::write` — persist content
536/// 2. `auto_format` — run the project formatter (reads the written file, writes back)
537/// 3. `validate_syntax` — parse the (potentially formatted) file
538/// 4. `validate_full` — run type checker if requested by params or config
539///
540/// The `params` argument carries the original request parameters. When it
541/// contains `"validate": "full"`, or config sets `validate_on_edit: "full"`,
542/// the project's type checker is invoked after syntax validation and the
543/// results are included in `WriteResult`.
544pub fn write_format_validate(
545    path: &Path,
546    content: &str,
547    config: &Config,
548    params: &serde_json::Value,
549) -> Result<WriteResult, AftError> {
550    let pre_write_content = if path.exists() {
551        std::fs::read_to_string(path).ok()
552    } else {
553        None
554    };
555    // Existing clean files are protected from invalid mutations. New files have
556    // no safe prior content to restore, so their pre-write validity remains None
557    // and invalid syntax is reported without rollback.
558    let was_syntax_valid = if pre_write_content.is_some() {
559        match validate_syntax(path) {
560            Ok(valid) => valid,
561            Err(_) => None,
562        }
563    } else {
564        None
565    };
566
567    // Step 1: Write
568    std::fs::write(path, content).map_err(|e| AftError::InvalidRequest {
569        message: format!("failed to write file: {}", e),
570    })?;
571
572    // Step 2: Format (before validate so we validate the formatted content)
573    let (formatted, format_skipped_reason) = format::auto_format(path, config);
574
575    // Step 3: Validate syntax
576    let syntax_valid = match validate_syntax(path) {
577        Ok(sv) => sv,
578        Err(_) => None,
579    };
580    let rolled_back = if was_syntax_valid == Some(true) && syntax_valid == Some(false) {
581        if let Some(original) = pre_write_content.as_ref() {
582            std::fs::write(path, original).map_err(|e| AftError::InvalidRequest {
583                message: format!("failed to roll back invalid edit: {}", e),
584            })?;
585            true
586        } else {
587            false
588        }
589    } else {
590        false
591    };
592
593    // Step 4: Full validation (type checker) — only when requested
594    let param_validate = params.get("validate").and_then(|v| v.as_str());
595    let config_validate = config.validate_on_edit.as_deref();
596    // Explicit param overrides config. Valid values: "syntax" | "full" | "off".
597    let validate_mode = param_validate.or(config_validate).unwrap_or("off");
598    let validate_requested = validate_mode == "full";
599    let (validation_errors, validate_skipped_reason) = if validate_requested {
600        format::validate_full(path, config)
601    } else {
602        (Vec::new(), None)
603    };
604
605    let reformatted_excerpt = if rolled_back {
606        None
607    } else {
608        std::fs::read_to_string(path)
609            .ok()
610            .and_then(|final_on_disk| compute_reformatted_excerpt(content, &final_on_disk))
611    };
612
613    Ok(WriteResult {
614        syntax_valid,
615        formatted,
616        format_skipped_reason,
617        validate_requested,
618        validation_errors,
619        validate_skipped_reason,
620        rolled_back,
621        lsp_outcome: None,
622        reformatted_excerpt,
623    })
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    // --- line_col_to_byte ---
631
632    #[test]
633    fn line_col_to_byte_empty_string() {
634        assert_eq!(line_col_to_byte("", 0, 0), 0);
635    }
636
637    #[test]
638    fn line_col_to_byte_single_line() {
639        let source = "hello";
640        assert_eq!(line_col_to_byte(source, 0, 0), 0);
641        assert_eq!(line_col_to_byte(source, 0, 3), 3);
642        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of line
643    }
644
645    #[test]
646    fn line_col_to_byte_multi_line() {
647        let source = "abc\ndef\nghi\n";
648        // line 0: "abc" at bytes 0..3, newline at 3
649        assert_eq!(line_col_to_byte(source, 0, 0), 0);
650        assert_eq!(line_col_to_byte(source, 0, 2), 2);
651        // line 1: "def" at bytes 4..7, newline at 7
652        assert_eq!(line_col_to_byte(source, 1, 0), 4);
653        assert_eq!(line_col_to_byte(source, 1, 3), 7);
654        // line 2: "ghi" at bytes 8..11, newline at 11
655        assert_eq!(line_col_to_byte(source, 2, 0), 8);
656        assert_eq!(line_col_to_byte(source, 2, 2), 10);
657    }
658
659    #[test]
660    fn line_col_to_byte_last_line_no_trailing_newline() {
661        let source = "abc\ndef";
662        // line 1: "def" at bytes 4..7, no trailing newline
663        assert_eq!(line_col_to_byte(source, 1, 0), 4);
664        assert_eq!(line_col_to_byte(source, 1, 3), 7); // end
665    }
666
667    #[test]
668    fn line_col_to_byte_multi_byte_utf8() {
669        // "é" is 2 bytes in UTF-8
670        let source = "café\nbar";
671        // line 0: "café" is 5 bytes (c=1, a=1, f=1, é=2)
672        assert_eq!(line_col_to_byte(source, 0, 0), 0);
673        assert_eq!(line_col_to_byte(source, 0, 5), 5); // end of "café"
674                                                       // line 1: "bar" starts at byte 6
675        assert_eq!(line_col_to_byte(source, 1, 0), 6);
676        assert_eq!(line_col_to_byte(source, 1, 2), 8);
677    }
678
679    #[test]
680    fn line_col_to_byte_beyond_end() {
681        let source = "abc";
682        // Line beyond file returns source.len()
683        assert_eq!(line_col_to_byte(source, 5, 0), source.len());
684    }
685
686    #[test]
687    fn line_col_to_byte_col_clamped_to_line_length() {
688        let source = "ab\ncd";
689        // col=10 on a 2-char line should clamp to 2
690        assert_eq!(line_col_to_byte(source, 0, 10), 2);
691    }
692
693    #[test]
694    fn line_col_to_byte_crlf() {
695        let source = "abc\r\ndef\r\nghi\r\n";
696        assert_eq!(line_col_to_byte(source, 0, 0), 0);
697        assert_eq!(line_col_to_byte(source, 0, 10), 3);
698        assert_eq!(line_col_to_byte(source, 1, 0), 5);
699        assert_eq!(line_col_to_byte(source, 1, 3), 8);
700        assert_eq!(line_col_to_byte(source, 2, 0), 10);
701    }
702
703    // --- replace_byte_range ---
704
705    #[test]
706    fn replace_byte_range_basic() {
707        let source = "hello world";
708        let result = replace_byte_range(source, 6, 11, "rust").unwrap();
709        assert_eq!(result, "hello rust");
710    }
711
712    #[test]
713    fn replace_byte_range_delete() {
714        let source = "hello world";
715        let result = replace_byte_range(source, 5, 11, "").unwrap();
716        assert_eq!(result, "hello");
717    }
718
719    #[test]
720    fn replace_byte_range_insert_at_same_position() {
721        let source = "helloworld";
722        let result = replace_byte_range(source, 5, 5, " ").unwrap();
723        assert_eq!(result, "hello world");
724    }
725
726    #[test]
727    fn replace_byte_range_replace_entire_string() {
728        let source = "old content";
729        let result = replace_byte_range(source, 0, source.len(), "new content").unwrap();
730        assert_eq!(result, "new content");
731    }
732
733    #[test]
734    fn compute_reformatted_excerpt_self_suppresses_when_unchanged() {
735        let s = "fn main() {\n    let x = 1;\n}\n";
736        assert!(compute_reformatted_excerpt(s, s).is_none());
737    }
738
739    #[test]
740    fn compute_reformatted_excerpt_includes_post_format_text() {
741        let before = "fn  main( ){  let   x=1;  }";
742        let after = "fn main() {\n    let x = 1;\n}\n";
743        let excerpt = compute_reformatted_excerpt(before, after).expect("should diff");
744        assert!(!excerpt.extensive);
745        assert!(excerpt.text.contains("fn main()"));
746        assert!(excerpt.text.contains("let x = 1"));
747    }
748
749    #[test]
750    fn compute_reformatted_excerpt_extensive_when_over_line_cap() {
751        let before: String = (0..80).map(|i| format!("line{i} ugly\n")).collect();
752        let after: String = (0..80).map(|i| format!("line{i} neat\n")).collect();
753        let excerpt = compute_reformatted_excerpt(&before, &after).expect("should diff");
754        assert!(excerpt.extensive);
755        assert!(excerpt.text.is_empty());
756    }
757
758    // --- validate_syntax_str: `&raw` must not be a false syntax error ---
759
760    /// `&raw` where `raw` is an ordinary variable is valid Rust (a reference to
761    /// the binding `raw`). `raw` is only a contextual keyword in the raw-borrow
762    /// operators `&raw const` / `&raw mut`. tree-sitter-rust before 0.24.2
763    /// mis-parsed a bare `&raw` as the start of a raw-borrow and emitted an
764    /// ERROR node, so `validate_syntax_str` returned `Some(false)` and the edit
765    /// pipeline rolled back a correct edit. This pins the fixed grammar
766    /// behavior: a grammar downgrade that reintroduces the false positive fails
767    /// here instead of silently discarding users' edits.
768    #[test]
769    fn validate_syntax_str_accepts_reference_to_variable_named_raw() {
770        let path = Path::new("lib.rs");
771        let src = "fn handle_hash(x: &u32) -> u32 { *x }\n\
772                   fn main() {\n    let raw = 5u32;\n    let _ = handle_hash(&raw);\n}\n";
773        assert_eq!(validate_syntax_str(src, path), Some(true));
774    }
775
776    /// The genuine raw-borrow operators must still parse cleanly (guard against
777    /// a "fix" that loosens the grammar the wrong way).
778    #[test]
779    fn validate_syntax_str_accepts_raw_borrow_operators() {
780        let path = Path::new("lib.rs");
781        let const_borrow = "fn main() {\n    let x = 5u32;\n    let _p = &raw const x;\n}\n";
782        let mut_borrow = "fn main() {\n    let mut x = 5u32;\n    let _p = &raw mut x;\n}\n";
783        assert_eq!(validate_syntax_str(const_borrow, path), Some(true));
784        assert_eq!(validate_syntax_str(mut_borrow, path), Some(true));
785    }
786}