about summary refs log tree commit diff
diff options
context:
space:
mode:
authorJason Newcomb <jsnewcomb@pm.me>2025-04-14 13:40:05 -0400
committerJason Newcomb <jsnewcomb@pm.me>2025-05-12 17:07:52 -0400
commit98cb92f3235c4c963d1c17e8a833f37091b5b487 (patch)
tree83a2f29a83d13fd2655c7f8645db07b0828104a8
parent3fe5fb296786e9396c1e91280360d9f029367b44 (diff)
clippy_dev: Reuse buffers when updating files and don't write unchanged files in `clippy_dev`
-rw-r--r--clippy_dev/src/main.rs4
-rw-r--r--clippy_dev/src/release.rs26
-rw-r--r--clippy_dev/src/rename_lint.rs17
-rw-r--r--clippy_dev/src/sync.rs31
-rw-r--r--clippy_dev/src/update_lints.rs206
-rw-r--r--clippy_dev/src/utils.rs207
6 files changed, 252 insertions, 239 deletions
diff --git a/clippy_dev/src/main.rs b/clippy_dev/src/main.rs
index 565416572a9..73248d72d04 100644
--- a/clippy_dev/src/main.rs
+++ b/clippy_dev/src/main.rs
@@ -30,10 +30,8 @@ fn main() {
         DevCommand::UpdateLints { print_only, check } => {
             if print_only {
                 update_lints::print_lints();
-            } else if check {
-                update_lints::update(utils::UpdateMode::Check);
             } else {
-                update_lints::update(utils::UpdateMode::Change);
+                update_lints::update(utils::UpdateMode::from_check(check));
             }
         },
         DevCommand::NewLint {
diff --git a/clippy_dev/src/release.rs b/clippy_dev/src/release.rs
index 34f81e10a39..d3b1a7ff320 100644
--- a/clippy_dev/src/release.rs
+++ b/clippy_dev/src/release.rs
@@ -1,8 +1,7 @@
+use crate::utils::{FileUpdater, Version, update_text_region_fn};
 use std::fmt::Write;
 
-use crate::utils::{UpdateMode, Version, replace_region_in_file};
-
-const CARGO_TOML_FILES: [&str; 4] = [
+static CARGO_TOML_FILES: &[&str] = &[
     "clippy_config/Cargo.toml",
     "clippy_lints/Cargo.toml",
     "clippy_utils/Cargo.toml",
@@ -11,15 +10,18 @@ const CARGO_TOML_FILES: [&str; 4] = [
 
 pub fn bump_version(mut version: Version) {
     version.minor += 1;
-    for &file in &CARGO_TOML_FILES {
-        replace_region_in_file(
-            UpdateMode::Change,
-            file.as_ref(),
-            "# begin autogenerated version\n",
-            "# end autogenerated version",
-            |res| {
-                writeln!(res, "version = \"{}\"", version.toml_display()).unwrap();
-            },
+
+    let mut updater = FileUpdater::default();
+    for file in CARGO_TOML_FILES {
+        updater.update_file(
+            file,
+            &mut update_text_region_fn(
+                "# begin autogenerated version\n",
+                "# end autogenerated version",
+                |dst| {
+                    writeln!(dst, "version = \"{}\"", version.toml_display()).unwrap();
+                },
+            ),
         );
     }
 }
diff --git a/clippy_dev/src/rename_lint.rs b/clippy_dev/src/rename_lint.rs
index 045e1f83e13..25db95a4489 100644
--- a/clippy_dev/src/rename_lint.rs
+++ b/clippy_dev/src/rename_lint.rs
@@ -1,8 +1,8 @@
 use crate::update_lints::{
-    RenamedLint, clippy_lints_src_files, gather_all, gen_renamed_lints_test, generate_lint_files,
+    RenamedLint, clippy_lints_src_files, gather_all, gen_renamed_lints_test_fn, generate_lint_files,
 };
 use crate::utils::{
-    UpdateMode, Version, insert_at_marker, replace_ident_like, rewrite_file, try_rename_file, write_file,
+    FileUpdater, UpdateMode, Version, insert_at_marker, replace_ident_like, rewrite_file, try_rename_file,
 };
 use std::ffi::OsStr;
 use std::path::Path;
@@ -32,6 +32,7 @@ pub fn rename(clippy_version: Version, old_name: &str, new_name: &str, uplift: b
         panic!("`{new_name}` should not contain the `{prefix}` prefix");
     }
 
+    let mut updater = FileUpdater::default();
     let (mut lints, deprecated_lints, mut renamed_lints) = gather_all();
     let mut old_lint_index = None;
     let mut found_new_name = false;
@@ -72,8 +73,8 @@ pub fn rename(clippy_version: Version, old_name: &str, new_name: &str, uplift: b
             && name != Some(OsStr::new("rename.rs"))
             && name != Some(OsStr::new("deprecated_lints.rs"))
     }) {
-        rewrite_file(file.path(), |s| {
-            replace_ident_like(s, &[(&lint.old_name, &lint.new_name)])
+        updater.update_file(file.path(), &mut |_, src, dst| {
+            replace_ident_like(&[(&lint.old_name, &lint.new_name)], src, dst)
         });
     }
 
@@ -101,12 +102,12 @@ pub fn rename(clippy_version: Version, old_name: &str, new_name: &str, uplift: b
     });
 
     if uplift {
-        write_file(Path::new("tests/ui/rename.rs"), &gen_renamed_lints_test(&renamed_lints));
+        updater.update_file("tests/ui/rename.rs", &mut gen_renamed_lints_test_fn(&renamed_lints));
         println!(
             "`{old_name}` has be uplifted. All the code inside `clippy_lints` related to it needs to be removed manually."
         );
     } else if found_new_name {
-        write_file(Path::new("tests/ui/rename.rs"), &gen_renamed_lints_test(&renamed_lints));
+        updater.update_file("tests/ui/rename.rs", &mut gen_renamed_lints_test_fn(&renamed_lints));
         println!(
             "`{new_name}` is already defined. The old linting code inside `clippy_lints` needs to be updated/removed manually."
         );
@@ -173,7 +174,9 @@ pub fn rename(clippy_version: Version, old_name: &str, new_name: &str, uplift: b
                 .to_str()
                 .is_none_or(|x| x["clippy_lints/src/".len()..] != *"deprecated_lints.rs")
             {
-                rewrite_file(file.path(), |s| replace_ident_like(s, replacements));
+                updater.update_file(file.path(), &mut |_, src, dst| {
+                    replace_ident_like(replacements, src, dst)
+                });
             }
         }
 
diff --git a/clippy_dev/src/sync.rs b/clippy_dev/src/sync.rs
index a6b65e561c2..c699b0d7b95 100644
--- a/clippy_dev/src/sync.rs
+++ b/clippy_dev/src/sync.rs
@@ -1,33 +1,18 @@
-use std::fmt::Write;
-use std::path::Path;
-
+use crate::utils::{FileUpdater, update_text_region_fn};
 use chrono::offset::Utc;
-
-use crate::utils::{UpdateMode, replace_region_in_file};
+use std::fmt::Write;
 
 pub fn update_nightly() {
-    // Update rust-toolchain nightly version
     let date = Utc::now().format("%Y-%m-%d").to_string();
-    replace_region_in_file(
-        UpdateMode::Change,
-        Path::new("rust-toolchain.toml"),
+    let update = &mut update_text_region_fn(
         "# begin autogenerated nightly\n",
         "# end autogenerated nightly",
-        |res| {
-            writeln!(res, "channel = \"nightly-{date}\"").unwrap();
+        |dst| {
+            writeln!(dst, "channel = \"nightly-{date}\"").unwrap();
         },
     );
 
-    // Update clippy_utils nightly version
-    replace_region_in_file(
-        UpdateMode::Change,
-        Path::new("clippy_utils/README.md"),
-        "<!-- begin autogenerated nightly -->\n",
-        "<!-- end autogenerated nightly -->",
-        |res| {
-            writeln!(res, "```").unwrap();
-            writeln!(res, "nightly-{date}").unwrap();
-            writeln!(res, "```").unwrap();
-        },
-    );
+    let mut updater = FileUpdater::default();
+    updater.update_file("rust-toolchain.toml", update);
+    updater.update_file("clippy_utils/README.md", update);
 }
diff --git a/clippy_dev/src/update_lints.rs b/clippy_dev/src/update_lints.rs
index 2f450b7d9ea..ad995f5e4c2 100644
--- a/clippy_dev/src/update_lints.rs
+++ b/clippy_dev/src/update_lints.rs
@@ -1,10 +1,10 @@
-use crate::utils::{UpdateMode, exit_with_failure, replace_region_in_file};
+use crate::utils::{FileUpdater, UpdateMode, UpdateStatus, update_text_region_fn};
 use itertools::Itertools;
 use rustc_lexer::{LiteralKind, TokenKind, tokenize};
 use rustc_literal_escaper::{Mode, unescape_unicode};
 use std::collections::{HashMap, HashSet};
 use std::ffi::OsStr;
-use std::fmt::{self, Write};
+use std::fmt::Write;
 use std::fs;
 use std::ops::Range;
 use std::path::Path;
@@ -33,74 +33,77 @@ pub fn update(update_mode: UpdateMode) {
 pub fn generate_lint_files(
     update_mode: UpdateMode,
     lints: &[Lint],
-    deprecated_lints: &[DeprecatedLint],
-    renamed_lints: &[RenamedLint],
+    deprecated: &[DeprecatedLint],
+    renamed: &[RenamedLint],
 ) {
     let mut lints = lints.to_owned();
-    lints.sort_by_key(|lint| lint.name.clone());
-
-    replace_region_in_file(
-        update_mode,
-        Path::new("README.md"),
-        "[There are over ",
-        " lints included in this crate!]",
-        |res| {
-            write!(res, "{}", round_to_fifty(lints.len())).unwrap();
-        },
-    );
-
-    replace_region_in_file(
-        update_mode,
-        Path::new("book/src/README.md"),
-        "[There are over ",
-        " lints included in this crate!]",
-        |res| {
-            write!(res, "{}", round_to_fifty(lints.len())).unwrap();
-        },
-    );
-
-    replace_region_in_file(
-        update_mode,
-        Path::new("CHANGELOG.md"),
-        "<!-- begin autogenerated links to lint list -->\n",
-        "<!-- end autogenerated links to lint list -->",
-        |res| {
-            for lint in lints
-                .iter()
-                .map(|l| &*l.name)
-                .chain(deprecated_lints.iter().filter_map(|l| l.name.strip_prefix("clippy::")))
-                .chain(renamed_lints.iter().filter_map(|l| l.old_name.strip_prefix("clippy::")))
-                .sorted()
-            {
-                writeln!(res, "[`{lint}`]: {DOCS_LINK}#{lint}").unwrap();
-            }
-        },
-    );
-
-    // This has to be in lib.rs, otherwise rustfmt doesn't work
-    replace_region_in_file(
-        update_mode,
-        Path::new("clippy_lints/src/lib.rs"),
-        "// begin lints modules, do not remove this comment, it’s used in `update_lints`\n",
-        "// end lints modules, do not remove this comment, it’s used in `update_lints`",
-        |res| {
-            for lint_mod in lints.iter().map(|l| &l.module).unique().sorted() {
-                writeln!(res, "mod {lint_mod};").unwrap();
-            }
-        },
-    );
-
-    process_file(
-        "clippy_lints/src/declared_lints.rs",
+    lints.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
+    FileUpdater::default().update_files_checked(
+        "cargo dev update_lints",
         update_mode,
-        &gen_declared_lints(lints.iter()),
+        &mut [
+            (
+                "README.md",
+                &mut update_text_region_fn("[There are over ", " lints included in this crate!]", |dst| {
+                    write!(dst, "{}", round_to_fifty(lints.len())).unwrap();
+                }),
+            ),
+            (
+                "book/src/README.md",
+                &mut update_text_region_fn("[There are over ", " lints included in this crate!]", |dst| {
+                    write!(dst, "{}", round_to_fifty(lints.len())).unwrap();
+                }),
+            ),
+            (
+                "CHANGELOG.md",
+                &mut update_text_region_fn(
+                    "<!-- begin autogenerated links to lint list -->\n",
+                    "<!-- end autogenerated links to lint list -->",
+                    |dst| {
+                        for lint in lints
+                            .iter()
+                            .map(|l| &*l.name)
+                            .chain(deprecated.iter().filter_map(|l| l.name.strip_prefix("clippy::")))
+                            .chain(renamed.iter().filter_map(|l| l.old_name.strip_prefix("clippy::")))
+                            .sorted()
+                        {
+                            writeln!(dst, "[`{lint}`]: {DOCS_LINK}#{lint}").unwrap();
+                        }
+                    },
+                ),
+            ),
+            (
+                "clippy_lints/src/lib.rs",
+                &mut update_text_region_fn(
+                    "// begin lints modules, do not remove this comment, it’s used in `update_lints`\n",
+                    "// end lints modules, do not remove this comment, it’s used in `update_lints`",
+                    |dst| {
+                        for lint_mod in lints.iter().map(|l| &l.module).sorted().dedup() {
+                            writeln!(dst, "mod {lint_mod};").unwrap();
+                        }
+                    },
+                ),
+            ),
+            ("clippy_lints/src/declared_lints.rs", &mut |_, src, dst| {
+                dst.push_str(GENERATED_FILE_COMMENT);
+                dst.push_str("pub static LINTS: &[&crate::LintInfo] = &[\n");
+                for (module_name, lint_name) in lints.iter().map(|l| (&l.module, l.name.to_uppercase())).sorted() {
+                    writeln!(dst, "    crate::{module_name}::{lint_name}_INFO,").unwrap();
+                }
+                dst.push_str("];\n");
+                UpdateStatus::from_changed(src != dst)
+            }),
+            ("tests/ui/deprecated.rs", &mut |_, src, dst| {
+                dst.push_str(GENERATED_FILE_COMMENT);
+                for lint in deprecated {
+                    writeln!(dst, "#![warn({})] //~ ERROR: lint `{}`", lint.name, lint.name).unwrap();
+                }
+                dst.push_str("\nfn main() {}\n");
+                UpdateStatus::from_changed(src != dst)
+            }),
+            ("tests/ui/rename.rs", &mut gen_renamed_lints_test_fn(renamed)),
+        ],
     );
-
-    let content = gen_deprecated_lints_test(deprecated_lints);
-    process_file("tests/ui/deprecated.rs", update_mode, &content);
-
-    let content = gen_renamed_lints_test(renamed_lints);
-    process_file("tests/ui/rename.rs", update_mode, &content);
 }
 
 pub fn print_lints() {
@@ -125,19 +128,6 @@ fn round_to_fifty(count: usize) -> usize {
     count / 50 * 50
 }
 
-fn process_file(path: impl AsRef<Path>, update_mode: UpdateMode, content: &str) {
-    if update_mode == UpdateMode::Check {
-        let old_content =
-            fs::read_to_string(&path).unwrap_or_else(|e| panic!("Cannot read from {}: {e}", path.as_ref().display()));
-        if content != old_content {
-            exit_with_failure();
-        }
-    } else {
-        fs::write(&path, content.as_bytes())
-            .unwrap_or_else(|e| panic!("Cannot write to {}: {e}", path.as_ref().display()));
-    }
-}
-
 /// Lint data parsed from the Clippy source code.
 #[derive(Clone, PartialEq, Eq, Debug)]
 pub struct Lint {
@@ -194,51 +184,25 @@ impl RenamedLint {
     }
 }
 
-/// Generates the code for registering lints
-#[must_use]
-fn gen_declared_lints<'a>(lints: impl Iterator<Item = &'a Lint>) -> String {
-    let mut details: Vec<_> = lints.map(|l| (&l.module, l.name.to_uppercase())).collect();
-    details.sort_unstable();
-
-    let mut output = GENERATED_FILE_COMMENT.to_string();
-    output.push_str("pub static LINTS: &[&crate::LintInfo] = &[\n");
-
-    for (module_name, lint_name) in details {
-        let _: fmt::Result = writeln!(output, "    crate::{module_name}::{lint_name}_INFO,");
-    }
-    output.push_str("];\n");
-
-    output
-}
-
-fn gen_deprecated_lints_test(lints: &[DeprecatedLint]) -> String {
-    let mut res: String = GENERATED_FILE_COMMENT.into();
-    for lint in lints {
-        writeln!(res, "#![warn({})] //~ ERROR: lint `{}`", lint.name, lint.name).unwrap();
-    }
-    res.push_str("\nfn main() {}\n");
-    res
-}
-
-#[must_use]
-pub fn gen_renamed_lints_test(lints: &[RenamedLint]) -> String {
-    let mut seen_lints = HashSet::new();
-    let mut res: String = GENERATED_FILE_COMMENT.into();
-
-    res.push_str("#![allow(clippy::duplicated_attributes)]\n");
-    for lint in lints {
-        if seen_lints.insert(&lint.new_name) {
-            writeln!(res, "#![allow({})]", lint.new_name).unwrap();
+pub fn gen_renamed_lints_test_fn(lints: &[RenamedLint]) -> impl Fn(&Path, &str, &mut String) -> UpdateStatus {
+    move |_, src, dst| {
+        let mut seen_lints = HashSet::new();
+        dst.push_str(GENERATED_FILE_COMMENT);
+        dst.push_str("#![allow(clippy::duplicated_attributes)]\n");
+        for lint in lints {
+            if seen_lints.insert(&lint.new_name) {
+                writeln!(dst, "#![allow({})]", lint.new_name).unwrap();
+            }
         }
-    }
-    seen_lints.clear();
-    for lint in lints {
-        if seen_lints.insert(&lint.old_name) {
-            writeln!(res, "#![warn({})] //~ ERROR: lint `{}`", lint.old_name, lint.old_name).unwrap();
+        seen_lints.clear();
+        for lint in lints {
+            if seen_lints.insert(&lint.old_name) {
+                writeln!(dst, "#![warn({})] //~ ERROR: lint `{}`", lint.old_name, lint.old_name).unwrap();
+            }
         }
+        dst.push_str("\nfn main() {}\n");
+        UpdateStatus::from_changed(src != dst)
     }
-    res.push_str("\nfn main() {}\n");
-    res
 }
 
 /// Gathers all lints defined in `clippy_lints/src`
diff --git a/clippy_dev/src/utils.rs b/clippy_dev/src/utils.rs
index 38a839c16a7..aaacdcf65c1 100644
--- a/clippy_dev/src/utils.rs
+++ b/clippy_dev/src/utils.rs
@@ -209,78 +209,145 @@ pub fn exit_if_err(status: io::Result<ExitStatus>) {
     }
 }
 
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy)]
+pub enum UpdateStatus {
+    Unchanged,
+    Changed,
+}
+impl UpdateStatus {
+    #[must_use]
+    pub fn from_changed(value: bool) -> Self {
+        if value { Self::Changed } else { Self::Unchanged }
+    }
+
+    #[must_use]
+    pub fn is_changed(self) -> bool {
+        matches!(self, Self::Changed)
+    }
+}
+
+#[derive(Clone, Copy)]
 pub enum UpdateMode {
-    Check,
     Change,
+    Check,
+}
+impl UpdateMode {
+    #[must_use]
+    pub fn from_check(check: bool) -> Self {
+        if check { Self::Check } else { Self::Change }
+    }
 }
 
-pub(crate) fn exit_with_failure() {
-    println!(
-        "Not all lints defined properly. \
-                 Please run `cargo dev update_lints` to make sure all lints are defined properly."
-    );
-    process::exit(1);
+#[derive(Default)]
+pub struct FileUpdater {
+    src_buf: String,
+    dst_buf: String,
 }
+impl FileUpdater {
+    fn update_file_checked_inner(
+        &mut self,
+        tool: &str,
+        mode: UpdateMode,
+        path: &Path,
+        update: &mut dyn FnMut(&Path, &str, &mut String) -> UpdateStatus,
+    ) {
+        let mut file = File::open(path, OpenOptions::new().read(true).write(true));
+        file.read_to_cleared_string(&mut self.src_buf);
+        self.dst_buf.clear();
+        match (mode, update(path, &self.src_buf, &mut self.dst_buf)) {
+            (UpdateMode::Check, UpdateStatus::Changed) => {
+                eprintln!(
+                    "the contents of `{}` are out of date\nplease run `{tool}` to update",
+                    path.display()
+                );
+                process::exit(1);
+            },
+            (UpdateMode::Change, UpdateStatus::Changed) => file.replace_contents(self.dst_buf.as_bytes()),
+            (UpdateMode::Check | UpdateMode::Change, UpdateStatus::Unchanged) => {},
+        }
+    }
 
-/// Replaces a region in a file delimited by two lines matching regexes.
-///
-/// `path` is the relative path to the file on which you want to perform the replacement.
-///
-/// See `replace_region_in_text` for documentation of the other options.
-///
-/// # Panics
-///
-/// Panics if the path could not read or then written
-pub(crate) fn replace_region_in_file(
-    update_mode: UpdateMode,
-    path: &Path,
-    start: &str,
-    end: &str,
-    write_replacement: impl FnMut(&mut String),
-) {
-    let contents = fs::read_to_string(path).unwrap_or_else(|e| panic!("Cannot read from `{}`: {e}", path.display()));
-    let new_contents = match replace_region_in_text(&contents, start, end, write_replacement) {
-        Ok(x) => x,
-        Err(delim) => panic!("Couldn't find `{delim}` in file `{}`", path.display()),
-    };
+    fn update_file_inner(&mut self, path: &Path, update: &mut dyn FnMut(&Path, &str, &mut String) -> UpdateStatus) {
+        let mut file = File::open(path, OpenOptions::new().read(true).write(true));
+        file.read_to_cleared_string(&mut self.src_buf);
+        self.dst_buf.clear();
+        if update(path, &self.src_buf, &mut self.dst_buf).is_changed() {
+            file.replace_contents(self.dst_buf.as_bytes());
+        }
+    }
 
-    match update_mode {
-        UpdateMode::Check if contents != new_contents => exit_with_failure(),
-        UpdateMode::Check => (),
-        UpdateMode::Change => {
-            if let Err(e) = fs::write(path, new_contents.as_bytes()) {
-                panic!("Cannot write to `{}`: {e}", path.display());
-            }
-        },
+    pub fn update_file_checked(
+        &mut self,
+        tool: &str,
+        mode: UpdateMode,
+        path: impl AsRef<Path>,
+        update: &mut dyn FnMut(&Path, &str, &mut String) -> UpdateStatus,
+    ) {
+        self.update_file_checked_inner(tool, mode, path.as_ref(), update);
+    }
+
+    #[expect(clippy::type_complexity)]
+    pub fn update_files_checked(
+        &mut self,
+        tool: &str,
+        mode: UpdateMode,
+        files: &mut [(
+            impl AsRef<Path>,
+            &mut dyn FnMut(&Path, &str, &mut String) -> UpdateStatus,
+        )],
+    ) {
+        for (path, update) in files {
+            self.update_file_checked_inner(tool, mode, path.as_ref(), update);
+        }
+    }
+
+    pub fn update_file(
+        &mut self,
+        path: impl AsRef<Path>,
+        update: &mut dyn FnMut(&Path, &str, &mut String) -> UpdateStatus,
+    ) {
+        self.update_file_inner(path.as_ref(), update);
     }
 }
 
 /// Replaces a region in a text delimited by two strings. Returns the new text if both delimiters
 /// were found, or the missing delimiter if not.
-pub(crate) fn replace_region_in_text<'a>(
-    text: &str,
-    start: &'a str,
-    end: &'a str,
-    mut write_replacement: impl FnMut(&mut String),
-) -> Result<String, &'a str> {
-    let (text_start, rest) = text.split_once(start).ok_or(start)?;
-    let (_, text_end) = rest.split_once(end).ok_or(end)?;
-
-    let mut res = String::with_capacity(text.len() + 4096);
-    res.push_str(text_start);
-    res.push_str(start);
-    write_replacement(&mut res);
-    res.push_str(end);
-    res.push_str(text_end);
-
-    Ok(res)
+pub fn update_text_region(
+    path: &Path,
+    start: &str,
+    end: &str,
+    src: &str,
+    dst: &mut String,
+    insert: &mut impl FnMut(&mut String),
+) -> UpdateStatus {
+    let Some((src_start, src_end)) = src.split_once(start) else {
+        panic!("`{}` does not contain `{start}`", path.display());
+    };
+    let Some((replaced_text, src_end)) = src_end.split_once(end) else {
+        panic!("`{}` does not contain `{end}`", path.display());
+    };
+    dst.push_str(src_start);
+    dst.push_str(start);
+    let new_start = dst.len();
+    insert(dst);
+    let changed = dst[new_start..] != *replaced_text;
+    dst.push_str(end);
+    dst.push_str(src_end);
+    UpdateStatus::from_changed(changed)
+}
+
+pub fn update_text_region_fn(
+    start: &str,
+    end: &str,
+    mut insert: impl FnMut(&mut String),
+) -> impl FnMut(&Path, &str, &mut String) -> UpdateStatus {
+    move |path, src, dst| update_text_region(path, start, end, src, dst, &mut insert)
 }
 
 /// Replace substrings if they aren't bordered by identifier characters. Returns `None` if there
 /// were no replacements.
 #[must_use]
-pub fn replace_ident_like(contents: &str, replacements: &[(&str, &str)]) -> Option<String> {
+pub fn replace_ident_like(replacements: &[(&str, &str)], src: &str, dst: &mut String) -> UpdateStatus {
     fn is_ident_char(c: u8) -> bool {
         matches!(c, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')
     }
@@ -290,26 +357,20 @@ pub fn replace_ident_like(contents: &str, replacements: &[(&str, &str)]) -> Opti
         .build(replacements.iter().map(|&(x, _)| x.as_bytes()))
         .unwrap();
 
-    let mut result = String::with_capacity(contents.len() + 1024);
     let mut pos = 0;
-    let mut edited = false;
-    for m in searcher.find_iter(contents) {
-        let (old, new) = replacements[m.pattern()];
-        result.push_str(&contents[pos..m.start()]);
-        result.push_str(
-            if !is_ident_char(contents.as_bytes().get(m.start().wrapping_sub(1)).copied().unwrap_or(0))
-                && !is_ident_char(contents.as_bytes().get(m.end()).copied().unwrap_or(0))
-            {
-                edited = true;
-                new
-            } else {
-                old
-            },
-        );
-        pos = m.end();
+    let mut changed = false;
+    for m in searcher.find_iter(src) {
+        if !is_ident_char(src.as_bytes().get(m.start().wrapping_sub(1)).copied().unwrap_or(0))
+            && !is_ident_char(src.as_bytes().get(m.end()).copied().unwrap_or(0))
+        {
+            dst.push_str(&src[pos..m.start()]);
+            dst.push_str(replacements[m.pattern()].1);
+            pos = m.end();
+            changed = true;
+        }
     }
-    result.push_str(&contents[pos..]);
-    edited.then_some(result)
+    dst.push_str(&src[pos..]);
+    UpdateStatus::from_changed(changed)
 }
 
 #[expect(clippy::must_use_candidate)]