Skip to main content

rig_tools/
edit.rs

1//! The `edit` tool: several replacements in one file per call, applied in
2//! order, each to the text the ones before it left, and all of them or
3//! none. A byte-order mark and CRLF line endings are kept. Text that does
4//! not match exactly may still match line by line once trailing
5//! whitespace and typographic quotes, dashes and spaces are evened out, but
6//! only where exactly one place in the file matches. The result names the
7//! lines that now hold the changed text and shows them, with a few lines
8//! around each, as the file now holds them; a failure says what to send
9//! instead, with the closest text to an `old_text` that was not found.
10
11use std::borrow::Cow;
12use std::fmt::Write as _;
13use std::ops::Range;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use rig_core::completion::ToolAnnotations;
18use rig_core::tool::{
19    FileChange, FileState, FilesChanged, HunkLine, Scope, Tool, ToolContext, ToolExecutionError,
20    Touches, args_schema, content_hash,
21};
22use schemars::JsonSchema;
23use serde::Deserialize;
24
25use crate::fs::{diff, io_error};
26use crate::workspace::{Local, Workspace, text_error, text_of};
27use crate::{Seen, blocking, clip};
28
29/// Most matches an ambiguity error lists by line, and most changed places
30/// a reply lists.
31const MAX_LISTED: usize = 8;
32/// Most lines of the closest text a not-found error shows, and the most
33/// characters of each.
34const MAX_CLOSEST: usize = 20;
35const MAX_CLOSEST_CHARS: usize = 300;
36
37/// Replaces text in a file. Publishes the file it changed
38/// ([`FilesChanged`]): its [resolved](Workspace) path, and the
39/// whole diff.
40#[derive(Clone, Debug)]
41pub struct Edit {
42    /// Where the file is edited.
43    pub workspace: Arc<dyn Workspace>,
44    /// The directory a relative path is edited in; `None` is the
45    /// workspace's working directory.
46    pub base: Option<PathBuf>,
47    /// Whether a path that resolves outside `base` is refused, before
48    /// anything is read.
49    pub confine: bool,
50    /// The files [`Read`](crate::Read) showed and edits left, shared with
51    /// it: an `old_text` not found asks for a new read only of a file that
52    /// changed since, and an edit of a file seen whole is made from that
53    /// text, in one [`write_if`](Workspace::write_if).
54    pub seen: Seen,
55}
56
57impl Default for Edit {
58    /// Edits this machine's files, from this process's working directory.
59    fn default() -> Self {
60        Self {
61            workspace: Arc::new(Local),
62            base: None,
63            confine: false,
64            seen: Seen::default(),
65        }
66    }
67}
68
69/// Arguments of [`Edit`].
70#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct EditArgs {
73    /// The file to edit.
74    pub path: String,
75    /// The replacements, in order: each matches the text the ones before
76    /// it left.
77    #[schemars(length(min = 1))]
78    pub edits: Vec<Replacement>,
79}
80
81impl EditArgs {
82    /// Make `edits` to `path`.
83    pub fn new(path: impl Into<String>, edits: impl IntoIterator<Item = Replacement>) -> Self {
84        Self {
85            path: path.into(),
86            edits: edits.into_iter().collect(),
87        }
88    }
89}
90
91// One replacement of an `edit` call; not a doc comment, which the model would see.
92#[allow(missing_docs)]
93#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct Replacement {
96    /// The text to replace, copied from the file.
97    pub old_text: String,
98    /// The replacement.
99    pub new_text: String,
100    /// Replace every match instead of exactly one.
101    #[serde(default)]
102    pub replace_all: bool,
103}
104
105impl Replacement {
106    /// Replace the one match of `old_text` with `new_text`.
107    pub fn new(old_text: impl Into<String>, new_text: impl Into<String>) -> Self {
108        Self {
109            old_text: old_text.into(),
110            new_text: new_text.into(),
111            replace_all: false,
112        }
113    }
114
115    /// The same replacement, of every match.
116    pub fn all(mut self) -> Self {
117        self.replace_all = true;
118        self
119    }
120}
121
122impl Tool for Edit {
123    const NAME: &'static str = "edit";
124    type Args = EditArgs;
125    type Output = String;
126    type Error = ToolExecutionError;
127
128    fn description(&self) -> String {
129        "Replace text in one file. Each entry of `edits` replaces `old_text` with `new_text`. \
130         Copy each `old_text` from the file as `read` showed it, with just enough lines around \
131         the change to match one place, or set `replace_all` to replace every exact match. \
132         Text with no exact match still matches line by line once trailing whitespace and \
133         typographic quotes, dashes and spaces are evened out, but only where exactly one place \
134         matches that way, and the result says so. Make all changes to one file in one call, \
135         one entry per change: the entries apply in order, each to the text the ones before it \
136         left, and all of them or none. Returns the lines that now hold the changed text, and \
137         those lines and a few around each as the file now holds them, so there is no need to \
138         read it again."
139            .to_owned()
140    }
141
142    fn parameters(&self) -> serde_json::Value {
143        args_schema::<EditArgs>()
144    }
145
146    fn annotations(&self) -> ToolAnnotations {
147        ToolAnnotations::default()
148            .with_destructive(true)
149            .with_idempotent(false)
150            .with_open_world(false)
151    }
152
153    fn footprint(&self, args: &EditArgs) -> Touches {
154        Touches::Writes(Scope::Paths(vec![
155            self.workspace.resolve(self.base.as_deref(), &args.path),
156        ]))
157    }
158
159    async fn call(
160        &self,
161        context: &mut ToolContext,
162        args: EditArgs,
163    ) -> Result<String, ToolExecutionError> {
164        let path = self
165            .workspace
166            .place(self.base.as_deref(), self.confine, &args.path)?;
167        let workspace = self.workspace.clone();
168        let seen = self.seen.clone();
169        let (output, change) = blocking(move || edit(&*workspace, &seen, path, args)).await?;
170        context.insert_result(FilesChanged(vec![change]))?;
171        Ok(output)
172    }
173}
174
175fn edit(
176    workspace: &dyn Workspace,
177    seen: &Seen,
178    resolved: PathBuf,
179    args: EditArgs,
180) -> Result<(String, FileChange), ToolExecutionError> {
181    let path = resolved.to_string_lossy();
182    // From the text seen last, the edit is one write, made only while the
183    // file still holds that text; else it is made from what the file holds.
184    let mut held = None;
185    if let Some((hash, text)) = seen.text(&resolved)
186        && let Ok(applied) = apply_to(&path, &text, &args.edits, false)
187    {
188        let now = workspace
189            .write_if(&resolved, applied.text.as_bytes(), &hash)
190            .map_err(|error| {
191                seen.forget(&resolved);
192                text_error(&path, error)
193            })?;
194        match now {
195            None => return Ok(reply(seen, &resolved, &args, applied)),
196            Some(now) => held = Some(text_of(now).map_err(|error| text_error(&path, error))?),
197        }
198    }
199    let raw = match held {
200        Some(raw) => raw,
201        None => workspace.read_text(&resolved)?,
202    };
203    let changed = seen
204        .hash(&resolved)
205        .is_some_and(|hash| hash != content_hash(raw.as_bytes()));
206    let applied = apply_to(&path, &raw, &args.edits, changed)?;
207    workspace
208        .write(&resolved, applied.text.as_bytes())
209        .map_err(|error| {
210            seen.forget(&resolved);
211            io_error(&path, error)
212        })?;
213    Ok(reply(seen, &resolved, &args, applied))
214}
215
216/// What an edit that wrote `applied` to `resolved` answers and publishes,
217/// noting the text it left as seen.
218fn reply(seen: &Seen, resolved: &Path, args: &EditArgs, applied: Applied) -> (String, FileChange) {
219    seen.saw_whole(resolved, &applied.text);
220    let places = places(&applied.change);
221    let at = changed_at(&places, &applied.change, applied.replaced, args.edits.len());
222    let mut out = format!("Edited {}: {at}.", args.path);
223    for note in applied.notes {
224        let _ = write!(out, "\nNote: {note}");
225    }
226    out.push_str(&around(&applied.text, &places));
227    (out, applied.change)
228}
229
230/// Lines shown before and after each changed place in an edit's reply.
231const CONTEXT: usize = 3;
232/// Most lines of the file an edit's reply shows, and most of one place;
233/// a longer place shows its first and last lines.
234const MAX_AROUND: usize = 60;
235const MAX_PLACE: usize = 30;
236
237/// The lines of `text`, the file after an edit, at and around each of
238/// `places`, as they are now: each run of them under a `[lines A-B]` line,
239/// with long lines cut, so the reply shows the result without a new read.
240fn around(text: &str, places: &[Place]) -> String {
241    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
242    let lines: Vec<&str> = text.lines().collect();
243    let mut runs: Vec<(usize, usize)> = Vec::new();
244    for place in places {
245        let (first, last) = match *place {
246            Place::Lines(first, last) => (first, last),
247            Place::Removed(at) => (at, at.saturating_sub(1)),
248        };
249        let first = first.saturating_sub(CONTEXT).max(1);
250        let last = (last + CONTEXT).min(lines.len());
251        match runs.last_mut() {
252            Some((_, end)) if first <= *end + 1 => *end = (*end).max(last),
253            _ if first <= last => runs.push((first, last)),
254            _ => {}
255        }
256    }
257    let mut out = String::new();
258    let mut shown = 0;
259    for (index, &(first, last)) in runs.iter().enumerate() {
260        if shown >= MAX_AROUND {
261            let _ = write!(
262                out,
263                "\n[{} more changed places not shown; read them to see them]",
264                runs.len() - index
265            );
266            break;
267        }
268        let _ = write!(out, "\n[lines {first}-{last}]");
269        let count = last - first + 1;
270        let half = MAX_PLACE / 2;
271        for (number, line) in (first..=last).zip(lines.iter().skip(first - 1)) {
272            if count > MAX_PLACE && number == first + half {
273                let _ = write!(out, "\n[… {} lines]", count - 2 * half);
274            }
275            if count > MAX_PLACE && number >= first + half && number <= last - half {
276                continue;
277            }
278            out.push('\n');
279            out.push_str(clip(line, MAX_CLOSEST_CHARS));
280            shown += 1;
281        }
282    }
283    out
284}
285
286/// Where `change` left the changed text, its `places`, as an `edit` reply
287/// says it: how many places were replaced when that is not one per edit
288/// (`replace_all`), each place by its lines in the file after the change
289/// (a place that only removed lines by the line now where they were), and
290/// how many lines the file grew or shrank by.
291fn changed_at(places: &[Place], change: &FileChange, replaced: usize, edits: usize) -> String {
292    let mut out = String::new();
293    if replaced != edits {
294        let _ = write!(out, "{replaced} replacements, ");
295    }
296    out.push_str(match places {
297        [Place::Removed(_)] => "line ",
298        [Place::Lines(first, last)] if first == last => "line ",
299        // Each removal names its own line.
300        [Place::Removed(_), ..] => "",
301        _ => "lines ",
302    });
303    let listed: Vec<String> = places
304        .iter()
305        .take(MAX_LISTED)
306        .map(|place| match *place {
307            Place::Lines(first, last) if first == last => first.to_string(),
308            Place::Lines(first, last) => format!("{first}-{last}"),
309            Place::Removed(at) if places.len() == 1 => at.to_string(),
310            Place::Removed(at) => format!("line {at}"),
311        })
312        .collect();
313    out.push_str(&listed.join(", "));
314    let more = places.len().saturating_sub(MAX_LISTED);
315    if more > 0 {
316        let _ = write!(out, ", and {more} more");
317    }
318    let shift = change.added as i64 - change.removed as i64;
319    if shift != 0 {
320        let noun = if shift.abs() == 1 { "line" } else { "lines" };
321        let _ = write!(out, " ({shift:+} {noun})");
322    }
323    out
324}
325
326/// A place a change changed, by its lines in the file after it.
327#[derive(Clone, Copy, Debug, PartialEq, Eq)]
328enum Place {
329    /// It added or rewrote these lines, both ends included.
330    Lines(usize, usize),
331    /// It only removed lines, where this line now is.
332    Removed(usize),
333}
334
335/// Each run of changed lines in `change`'s hunks, in order.
336fn places(change: &FileChange) -> Vec<Place> {
337    let mut places = Vec::new();
338    for hunk in &change.hunks {
339        let kept = |line: &HunkLine| matches!(line, HunkLine::Same(_));
340        let mut runs = hunk.lines.chunk_by(|a, b| kept(a) == kept(b)).peekable();
341        // The line of the file after the change that the next run starts at.
342        let mut at = hunk.new.start;
343        while let Some(run) = runs.next() {
344            if run.first().is_some_and(kept) {
345                at += run.len();
346                continue;
347            }
348            let added = run.iter().filter(|line| line.sign() == '+').count();
349            places.push(match added {
350                // Hunks keep the lines after a change, so a run that ends
351                // its hunk ends the file: lines removed there are at its last.
352                0 if runs.peek().is_none() => Place::Removed(at.saturating_sub(1).max(1)),
353                0 => Place::Removed(at),
354                added => Place::Lines(at, at + added - 1),
355            });
356            at += added;
357        }
358    }
359    places
360}
361
362/// What [`apply`] makes of a file's text.
363#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct Applied {
365    /// The edited text, with the byte-order mark and line endings the file
366    /// had: what [`Edit`] writes.
367    pub text: String,
368    /// What the edit does to the file, with its hunks, the hash of the text
369    /// it was made to and that of [`text`](Self::text).
370    pub change: FileChange,
371    /// How many places were replaced.
372    pub replaced: usize,
373    /// How each edit that matched only loosely was matched, for the model.
374    pub notes: Vec<String>,
375}
376
377/// Makes `edits` to `text`, the whole text of the file at `path`, exactly
378/// as [`Edit`] does, without touching the file: each edit is matched,
379/// exactly or loosely, against the text the edits before it left, and all
380/// are applied or none. A byte-order mark and CRLF line endings are kept.
381/// `path` names the file in the [`FileChange`] and in an error.
382///
383/// ```
384/// use rig_tools::{Replacement, edit::apply};
385///
386/// let applied = apply("a.txt", "one\r\ntwo\r\n", &[Replacement::new("two", "2")])?;
387/// assert_eq!(applied.text, "one\r\n2\r\n");
388/// assert_eq!((applied.change.added, applied.change.removed), (1, 1));
389/// let missed = apply("a.txt", "one\n", &[Replacement::new("three", "3")]);
390/// assert!(missed.is_err_and(|error| error.to_string().contains("`a.txt` was not changed")));
391/// # Ok::<(), rig_core::tool::ToolExecutionError>(())
392/// ```
393///
394/// # Errors
395///
396/// When an edit matches nowhere or more than once, or the edits change
397/// nothing: the error says what to send instead.
398pub fn apply(path: &str, text: &str, edits: &[Replacement]) -> Result<Applied, ToolExecutionError> {
399    apply_to(path, text, edits, false)
400}
401
402/// [`apply`], `changed` when the file changed since it was last read: an
403/// `old_text` not found then asks for a new read.
404fn apply_to(
405    path: &str,
406    text: &str,
407    edits: &[Replacement],
408    changed: bool,
409) -> Result<Applied, ToolExecutionError> {
410    if edits.is_empty() {
411        return Err(ToolExecutionError::invalid_args(format!(
412            "`edits` is empty, so `{path}` was not changed; give at least one \
413             {{\"old_text\", \"new_text\"}}"
414        )));
415    }
416    let whole = text;
417    let file = Decoded::new(text);
418    let text: &str = &file.text;
419    let count = edits.len();
420    let many = count > 1;
421    // The text as the edits applied so far left it.
422    let mut edited = text.to_owned();
423    let mut applied: Vec<usize> = Vec::new();
424    let mut replaced = 0;
425    let mut notes = Vec::new();
426    // Every edit is checked, so one reply names all that need fixing.
427    let mut failed: Vec<String> = Vec::new();
428    // The closest text to each `old_text` not found, shown after them.
429    let mut closest: Vec<String> = Vec::new();
430    for (index, replacement) in edits.iter().enumerate() {
431        let label = if many {
432            format!("edits[{index}]: ")
433        } else {
434            String::new()
435        };
436        let old = lf(&replacement.old_text);
437        let new = lf(&replacement.new_text);
438        if old.is_empty() {
439            failed.push(format!(
440                "{label}`old_text` is empty; to add text, replace a nearby line with itself plus the new text"
441            ));
442            continue;
443        }
444        if old == new {
445            failed.push(format!(
446                "{label}`old_text` and `new_text` are the same, so it changes nothing; drop it or fix `new_text`"
447            ));
448            continue;
449        }
450        match locate(&edited, &old, replacement.replace_all) {
451            Err(Miss::NotFound) => {
452                if let Some(earlier) = changed_by(text, edits, &applied, &old, replacement) {
453                    failed.push(format!(
454                        "{label}`old_text` was in the file, but edits[{earlier}], applied before \
455                         it, changed that text; match the text as edits[{earlier}] leaves it, or \
456                         make this change in edits[{earlier}]"
457                    ));
458                    continue;
459                }
460                let near = (!changed).then(|| nearest(&edited, &old)).flatten();
461                failed.push(format!(
462                    "{label}{}",
463                    not_found(path, changed, near.is_some())
464                ));
465                if let Some(near) = near {
466                    let whose = if many {
467                        format!("of edits[{index}] ")
468                    } else {
469                        String::new()
470                    };
471                    closest.push(format!(
472                        "The closest text to the `old_text` {whose}is {}:\n{}",
473                        near.lines, near.text
474                    ));
475                }
476            }
477            Err(miss) => failed.push(format!("{label}{}", miss.explain())),
478            Ok(found) => {
479                if let Some(note) = found.note {
480                    notes.push(format!("{label}{note}"));
481                }
482                replaced += found.spans.len();
483                edited = splice(&edited, &found.spans, &new);
484                applied.push(index);
485            }
486        }
487    }
488    if !failed.is_empty() {
489        let mut why = batch_failure(path, count, &failed);
490        for near in closest {
491            let _ = write!(why, "\n{near}");
492        }
493        return Err(ToolExecutionError::invalid_args(why));
494    }
495    if edited == text {
496        return Err(refused(
497            path,
498            "the edits leave the file as it was; check `new_text`".to_owned(),
499        ));
500    }
501    let change = diff(path, Some(text), Some(&edited))
502        .with_before_text(text)
503        .with_before(FileState::of(whole.as_bytes()));
504    let encoded = file.encode(edited);
505    Ok(Applied {
506        change: change.with_after(FileState::of(encoded.as_bytes())),
507        text: encoded,
508        replaced,
509        notes,
510    })
511}
512
513/// `text` with each of `spans`, in order and apart, replaced by `new`.
514fn splice(text: &str, spans: &[Range<usize>], new: &str) -> String {
515    let mut out = String::with_capacity(text.len());
516    let mut at = 0;
517    for span in spans {
518        out.push_str(text.get(at..span.start).unwrap_or_default());
519        out.push_str(new);
520        at = span.end;
521    }
522    out.push_str(text.get(at..).unwrap_or_default());
523    out
524}
525
526/// Which of the edits `applied` before it, made in order to `text`, took
527/// away the text `old` of `replacement` matched there; `None` when it
528/// matched nowhere to begin with. Only worked out for a refusal.
529fn changed_by(
530    text: &str,
531    edits: &[Replacement],
532    applied: &[usize],
533    old: &str,
534    replacement: &Replacement,
535) -> Option<usize> {
536    let mut before = text.to_owned();
537    for &index in applied {
538        if locate(&before, old, replacement.replace_all).is_err() {
539            return None;
540        }
541        let earlier = edits.get(index)?;
542        let found = locate(&before, &lf(&earlier.old_text), earlier.replace_all).ok()?;
543        before = splice(&before, &found.spans, &lf(&earlier.new_text));
544        if locate(&before, old, replacement.replace_all).is_err() {
545            return Some(index);
546        }
547    }
548    None
549}
550
551/// The file's text with LF line endings and no byte-order mark, and how to
552/// put both back.
553struct Decoded<'a> {
554    text: Cow<'a, str>,
555    bom: bool,
556    crlf: bool,
557}
558
559impl<'a> Decoded<'a> {
560    fn new(raw: &'a str) -> Self {
561        let (bom, body) = match raw.strip_prefix('\u{feff}') {
562            Some(body) => (true, body),
563            None => (false, raw),
564        };
565        // Only a file whose every line ends in CRLF is converted; a mixed
566        // one is edited as it is, and loose matching ignores its `\r`s.
567        let newlines = body.matches('\n').count();
568        let crlf = newlines > 0 && body.matches("\r\n").count() == newlines;
569        let text = if crlf {
570            Cow::Owned(body.replace("\r\n", "\n"))
571        } else {
572            Cow::Borrowed(body)
573        };
574        Self { text, bom, crlf }
575    }
576
577    /// `text` with the file's line endings and byte-order mark.
578    fn encode(&self, text: String) -> String {
579        let body = if self.crlf {
580            text.replace('\n', "\r\n")
581        } else {
582            text
583        };
584        if self.bom {
585            format!("\u{feff}{body}")
586        } else {
587            body
588        }
589    }
590}
591
592/// `text` with CRLF line endings made LF, as the file is matched.
593fn lf(text: &str) -> String {
594    text.replace("\r\n", "\n")
595}
596
597/// A model-visible refusal: nothing was written to `path`.
598fn refused(path: &str, why: String) -> ToolExecutionError {
599    ToolExecutionError::invalid_args(format!(
600        "{why}. `{path}` was not changed; fix the call and send all of its edits again."
601    ))
602}
603
604/// The refusal for edits of `path` that failed: each one's reason, and, in
605/// a batch, that the edits that matched were not applied either and must
606/// be sent again with the fixed ones.
607fn batch_failure(path: &str, count: usize, failed: &[String]) -> String {
608    let [why] = failed else {
609        return format!(
610            "{} of the {count} edits failed:\n{}.\nNo edit was applied, so `{path}` was not \
611             changed. Send all {count} edits again, with these fixed.",
612            failed.len(),
613            failed.join(".\n"),
614        );
615    };
616    if count == 1 {
617        return format!("{why}. `{path}` was not changed; fix the edit and send it again.");
618    }
619    format!(
620        "{why}. No edit was applied, so `{path}` was not changed. Send all {count} edits again, \
621         with this one fixed."
622    )
623}
624
625/// The 1-based line holding byte `at` of `text`.
626fn line_of(text: &str, at: usize) -> usize {
627    text.get(..at)
628        .map_or(0, |before| before.matches('\n').count())
629        + 1
630}
631
632/// Where an edit's `old_text` matched.
633struct Found {
634    spans: Vec<Range<usize>>,
635    /// How the match was loose, if it was.
636    note: Option<String>,
637}
638
639/// Why an edit's `old_text` did not match once.
640enum Miss {
641    /// It matches nowhere.
642    NotFound,
643    /// It matches at these 1-based lines, `loose` when only after evening
644    /// out whitespace or punctuation.
645    Ambiguous { lines: Vec<usize>, loose: bool },
646}
647
648impl Miss {
649    /// What went wrong and what to send instead.
650    fn explain(&self) -> String {
651        match self {
652            Miss::Ambiguous { lines, loose } => {
653                let listed: Vec<String> = lines
654                    .iter()
655                    .take(MAX_LISTED)
656                    .map(ToString::to_string)
657                    .collect();
658                let more = lines.len().saturating_sub(MAX_LISTED);
659                let more = if more > 0 {
660                    format!(" and {more} more")
661                } else {
662                    String::new()
663                };
664                format!(
665                    "`old_text` {}matches {} places, starting at lines {}{more}; add the lines \
666                     around the one to change so it matches once, or set `replace_all` to \
667                     change every one",
668                    if *loose {
669                        "does not match exactly, and loosely "
670                    } else {
671                        ""
672                    },
673                    lines.len(),
674                    listed.join(", ")
675                )
676            }
677            Miss::NotFound => not_found("the file", false, false),
678        }
679    }
680}
681
682/// Why an `old_text` matched nowhere in `path`, which `changed` since it
683/// was last read, and what to do: read it again when it changed, else copy
684/// the text from the closest place when there is one (`near`).
685fn not_found(path: &str, changed: bool, near: bool) -> String {
686    let what = "`old_text` was not found, not even ignoring trailing whitespace";
687    if changed {
688        format!(
689            "{what}: `{path}` changed since it was last read, so read it again and copy \
690             `old_text` from what it holds now"
691        )
692    } else if near {
693        format!("{what}; copy it from the closest text in the file, shown below")
694    } else {
695        format!("{what}, and nothing in the file is close to it; check the path and the text")
696    }
697}
698
699/// The place in a text closest to an `old_text` that matched nowhere.
700struct Near {
701    /// Its lines, as `line 3` or `lines 3-5`.
702    lines: String,
703    /// Its text, as the file holds it, with long lines cut.
704    text: String,
705}
706
707/// The run of `text`'s lines most like `old`'s, as many as `old` has: each
708/// line compared by how much of its start and end match once evened out as
709/// a loose match is. `None` when no run is even half alike.
710fn nearest(text: &str, old: &str) -> Option<Near> {
711    let file = Lines::new(text);
712    let count = file.spans.len();
713    let wanted: Vec<String> = old
714        .strip_suffix('\n')
715        .unwrap_or(old)
716        .split('\n')
717        .map(loose_key)
718        .collect();
719    let size = wanted.len().min(count).max(1);
720    let keys: Vec<String> = (0..count)
721        .map(|index| loose_key(file.line(index)))
722        .collect();
723    // Only the first lines of a long `old_text` are compared, so a large
724    // file is searched quickly.
725    let compared = size.min(MAX_CLOSEST);
726    let mut best: Option<(f64, usize)> = None;
727    for start in 0..=count.saturating_sub(size) {
728        let score: f64 = wanted
729            .iter()
730            .zip(keys.iter().skip(start))
731            .take(compared)
732            .map(|(want, have)| alike(want, have))
733            .sum::<f64>()
734            / compared as f64;
735        if best.is_none_or(|(most, _)| score > most) {
736            best = Some((score, start));
737        }
738    }
739    let (score, start) = best?;
740    if score < 0.5 {
741        return None;
742    }
743    let shown: Vec<String> = (start..start + size)
744        .take(MAX_CLOSEST)
745        .map(|index| clip(file.line(index), MAX_CLOSEST_CHARS).to_owned())
746        .collect();
747    let mut text = shown.join("\n");
748    if size > MAX_CLOSEST {
749        let _ = write!(text, "\n[… {} more lines]", size - MAX_CLOSEST);
750    }
751    let lines = match size {
752        1 => format!("line {}", start + 1),
753        _ => format!("lines {}-{}", start + 1, start + size),
754    };
755    Some(Near { lines, text })
756}
757
758/// How alike two lines are, from 0 to 1: the characters their starts and
759/// ends share, over the longer one's.
760fn alike(one: &str, other: &str) -> f64 {
761    let (one, other): (Vec<char>, Vec<char>) = (one.chars().collect(), other.chars().collect());
762    let longest = one.len().max(other.len());
763    if longest == 0 {
764        return 1.0;
765    }
766    let start = one.iter().zip(&other).take_while(|(a, b)| a == b).count();
767    let end = one
768        .iter()
769        .rev()
770        .zip(other.iter().rev())
771        .take(one.len().min(other.len()) - start)
772        .take_while(|(a, b)| a == b)
773        .count();
774    (start + end) as f64 / longest as f64
775}
776
777/// Finds `old` in `text`: exactly, else line by line with trailing
778/// whitespace and typographic punctuation evened out. A loose match must be
779/// the only one; `replace_all` takes exact matches only.
780fn locate(text: &str, old: &str, replace_all: bool) -> Result<Found, Miss> {
781    let exact: Vec<Range<usize>> = text
782        .match_indices(old)
783        .map(|(start, matched)| start..start + matched.len())
784        .collect();
785    match exact.len() {
786        0 => {}
787        count if count == 1 || replace_all => {
788            return Ok(Found {
789                spans: exact,
790                note: None,
791            });
792        }
793        _ => {
794            return Err(Miss::Ambiguous {
795                lines: exact.iter().map(|span| line_of(text, span.start)).collect(),
796                loose: false,
797            });
798        }
799    }
800    if replace_all {
801        return Err(Miss::NotFound);
802    }
803    let lines = Lines::new(text);
804    let wanted: Vec<&str> = old.strip_suffix('\n').unwrap_or(old).split('\n').collect();
805    let start = match lines.windows(&wanted).as_slice() {
806        [] => return Err(Miss::NotFound),
807        [start] => *start,
808        starts => {
809            return Err(Miss::Ambiguous {
810                lines: starts.iter().map(|start| start + 1).collect(),
811                loose: true,
812            });
813        }
814    };
815    Ok(Found {
816        spans: vec![lines.span(start, wanted.len(), old.ends_with('\n'))],
817        note: Some(
818            "`old_text` matched only after evening out trailing whitespace and quote, dash or \
819             space characters; copy text exactly next time"
820                .to_owned(),
821        ),
822    })
823}
824
825/// `line` as loosely compared: without trailing whitespace, and with
826/// typographic quotes, dashes and spaces made ASCII.
827fn loose_key(line: &str) -> String {
828    line.trim_end().chars().map(plain).collect()
829}
830
831/// The ASCII form of a typographic quote, dash or space.
832fn plain(c: char) -> char {
833    match c {
834        '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
835        '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
836        '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
837        '\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
838        c => c,
839    }
840}
841
842/// The lines of a text, with where each starts and ends.
843struct Lines<'a> {
844    text: &'a str,
845    /// For each line, its byte range without and with its `\n`.
846    spans: Vec<(Range<usize>, usize)>,
847}
848
849impl<'a> Lines<'a> {
850    fn new(text: &'a str) -> Self {
851        let mut spans = Vec::new();
852        let mut start = 0;
853        for line in text.split_inclusive('\n') {
854            let end = start + line.len();
855            let content = end - usize::from(line.ends_with('\n'));
856            spans.push((start..content, end));
857            start = end;
858        }
859        Self { text, spans }
860    }
861
862    fn line(&self, index: usize) -> &'a str {
863        self.spans
864            .get(index)
865            .and_then(|(content, _)| self.text.get(content.clone()))
866            .unwrap_or_default()
867    }
868
869    /// The 0-based first lines of each run of lines that loosely equals
870    /// `wanted`.
871    fn windows(&self, wanted: &[&str]) -> Vec<usize> {
872        let keys: Vec<String> = (0..self.spans.len())
873            .map(|index| loose_key(self.line(index)))
874            .collect();
875        let wanted: Vec<String> = wanted.iter().map(|line| loose_key(line)).collect();
876        if wanted.iter().all(String::is_empty) {
877            return Vec::new();
878        }
879        keys.windows(wanted.len().max(1))
880            .enumerate()
881            .filter(|(_, window)| *window == wanted.as_slice())
882            .map(|(start, _)| start)
883            .collect()
884    }
885
886    /// The bytes of `count` lines from `start`, with the last line's `\n`
887    /// when `whole_lines`.
888    fn span(&self, start: usize, count: usize, whole_lines: bool) -> Range<usize> {
889        let first = self
890            .spans
891            .get(start)
892            .map_or(self.text.len(), |(content, _)| content.start);
893        let last = self.spans.get(start + count - 1);
894        let end = match last {
895            Some((_, with_newline)) if whole_lines => *with_newline,
896            Some((content, _)) => content.end,
897            None => self.text.len(),
898        };
899        first..end
900    }
901}
902
903#[cfg(test)]
904mod tests;