wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What a replacement IS, in one place.
//!
//! The pattern flags used to mean two different things depending on where you
//! looked: names were matched with `regex` and case folding honoured, while
//! file CONTENTS went through a plain `str::replace` that ignored both. So
//! `--regex` and `--ignore-case` silently did nothing to the inside of a file,
//! and a preview built on either half would have disagreed with what the other
//! half wrote.
//!
//! Compiled once and used by the preview and by the write, so the diff a
//! reader agrees to is produced by the same code that carries it out. They
//! cannot drift, because there is only one of them.

use regex::{Regex, RegexBuilder};

/// One compiled replacement.
#[derive(Debug, Clone)]
enum Rule {
    /// A literal, case-sensitive replacement. The common case, and the only
    /// one that needs no regex engine at all.
    Literal { find: String, replace: String },
    /// Everything else: a regular expression, or a literal that has to fold
    /// case. A literal is escaped before compiling, so its metacharacters stay
    /// literal.
    Pattern {
        find: Regex,
        replace: String,
        /// Whether `$1` in the replacement refers to a capture. True only for
        /// a pattern the caller wrote as a regex — a literal replacement
        /// containing `$1` means those two characters.
        expand: bool,
    },
}

/// The replacements a refactor makes, compiled.
#[derive(Debug, Clone)]
pub struct Replacements {
    rules: Vec<Rule>,
}

impl Replacements {
    /// Compile `pairs`, refusing anything that cannot be compiled.
    ///
    /// A bad regular expression is an error here, before a single file is
    /// read — not a pattern that silently matches nothing across a whole
    /// tree and reports "no changes needed".
    pub fn compile(
        pairs: &[(String, String)],
        use_regex: bool,
        ignore_case: bool,
    ) -> Result<Self, String> {
        let mut rules = Vec::with_capacity(pairs.len());
        for (find, replace) in pairs {
            if find.is_empty() {
                return Err("a pattern to find cannot be empty".to_string());
            }
            if !use_regex && !ignore_case {
                rules.push(Rule::Literal {
                    find: find.clone(),
                    replace: replace.clone(),
                });
                continue;
            }
            let source = if use_regex {
                find.clone()
            } else {
                regex::escape(find)
            };
            let compiled = RegexBuilder::new(&source)
                .case_insensitive(ignore_case)
                // A pattern is allowed to span lines: a substitute can contain
                // newlines, so a search for one may need to as well.
                .multi_line(true)
                .dot_matches_new_line(false)
                .build()
                .map_err(|error| format!("pattern '{find}' is not a valid regular expression: {error}"))?;
            rules.push(Rule::Pattern {
                find: compiled,
                replace: replace.clone(),
                expand: use_regex,
            });
        }
        Ok(Self { rules })
    }

    /// The text with every replacement applied, in order.
    ///
    /// Each rule sees what the ones before it produced — the same order the
    /// command line applies a patterns file in, so a later pair can operate on
    /// an earlier pair's output deliberately.
    pub fn apply(&self, text: &str) -> String {
        let mut current = std::borrow::Cow::Borrowed(text);
        for rule in &self.rules {
            match rule {
                Rule::Literal { find, replace } => {
                    if current.contains(find.as_str()) {
                        current = std::borrow::Cow::Owned(current.replace(find.as_str(), replace));
                    }
                }
                Rule::Pattern {
                    find,
                    replace,
                    expand,
                } => {
                    let next = if *expand {
                        find.replace_all(&current, replace.as_str())
                    } else {
                        // `$1` in a literal replacement is two characters, not
                        // a capture reference.
                        find.replace_all(&current, regex::NoExpand(replace.as_str()))
                    };
                    if let std::borrow::Cow::Owned(owned) = next {
                        current = std::borrow::Cow::Owned(owned);
                    }
                }
            }
        }
        current.into_owned()
    }

    /// Whether applying these would change `text` at all.
    ///
    /// Answered by DOING it rather than by matching: a rule can match and
    /// replace a string with itself, and a rule later in the order can undo an
    /// earlier one. Either would report a change that the write would not
    /// make, and a preview listing files it does not touch is worse than one
    /// that takes a moment longer.
    pub fn changes(&self, text: &str) -> bool {
        self.apply(text) != text
    }

    /// How many replacements would actually be made.
    ///
    /// Counted rule by rule against the text that rule will see, so the number
    /// is what happens rather than what each rule would do alone.
    pub fn count(&self, text: &str) -> usize {
        let mut current = text.to_string();
        let mut total = 0;
        for rule in &self.rules {
            let (next, made) = match rule {
                Rule::Literal { find, replace } => {
                    let made = current.matches(find.as_str()).count();
                    (
                        if made > 0 {
                            current.replace(find.as_str(), replace)
                        } else {
                            current.clone()
                        },
                        made,
                    )
                }
                Rule::Pattern {
                    find,
                    replace,
                    expand,
                } => {
                    let made = find.find_iter(&current).count();
                    let next = if *expand {
                        find.replace_all(&current, replace.as_str()).into_owned()
                    } else {
                        find.replace_all(&current, regex::NoExpand(replace.as_str()))
                            .into_owned()
                    };
                    (next, made)
                }
            };
            total += made;
            current = next;
        }
        total
    }

    pub fn is_empty(&self) -> bool {
        self.rules.is_empty()
    }
}

#[cfg(test)]
#[path = "replacement_tests.rs"]
mod replacement_tests;