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