replaxe 0.1.1

A command-line tool to replace text in files with easy patterns
use std::{
    collections::BTreeSet,
    fmt::{Display, Formatter},
};

use anyhow::{anyhow, Result};
use crossterm::style::{self, Attribute, Color, Stylize};

#[derive(Debug, PartialEq, Eq)]
pub struct Matches {
    text: String,
    matches: Vec<Match>,
    is_replaced: bool,
}

#[derive(Debug, PartialEq, Eq)]
struct Match {
    points: Vec<usize>,
}

impl Matches {
    pub fn new(text: impl Into<String>, matcher: &str, wildcard: &str) -> Result<Self> {
        let text = text.into();

        if matcher.is_empty() {
            return Err(anyhow!("matcher cannot be empty"));
        }

        let matcher = matcher.split(wildcard).collect::<Vec<_>>();
        if matcher.iter().any(|x| x.is_empty()) {
            return Err(anyhow!(
                "wildcards have to be enclosed in non-empty strings"
            ));
        }
        let mut i = 0;
        let mut matches = Vec::new();

        while i < text.len() {
            if let Some(m) = Self::try_match(&text, &matcher, i) {
                i = *m.points.last().unwrap();
                matches.push(m);
            } else {
                break;
            }
        }

        Ok(Self {
            text,
            matches,
            is_replaced: false,
        })
    }

    fn try_match(text: &str, matcher: &[&str], mut i: usize) -> Option<Match> {
        let mut points = Vec::with_capacity(matcher.len() * 2);

        for &matcher in matcher {
            if let Some(index) = text[i..].find(matcher) {
                let begin = i + index;
                let end = begin + matcher.len();
                points.push(begin);
                points.push(end);
                i = end;
            } else {
                return None;
            }
        }

        Some(Match { points })
    }

    pub fn replace(
        &self,
        replace: &str,
        wildcard: &str,
        reorder: impl IntoIterator<Item = usize> + Clone,
    ) -> Result<Self> {
        let replacer = replace.split(wildcard).collect::<Vec<_>>();
        let mut result = String::new();
        let mut i = 0;
        let mut new_matches = Vec::with_capacity(self.matches.len());

        for m in &self.matches {
            let mut new_match = Vec::with_capacity(m.points.len());
            let mut reorder = reorder.clone().into_iter();
            let mut seen = BTreeSet::new();
            result.push_str(&self.text[i..m.points[0]]);
            new_match.push(result.len());

            for (i, &r) in replacer.iter().enumerate() {
                result.push_str(r);
                new_match.push(result.len());
                if i == replacer.len() - 1 {
                    break;
                }
                let index = if let Some(index) = reorder.next() {
                    index
                } else {
                    'index: {
                        let mut last = 0;
                        for &v in &seen {
                            if v > last {
                                break 'index last;
                            }
                            last = v + 1;
                        }
                        seen.last().copied().map(|x| x + 1).unwrap_or(0)
                    }
                };
                seen.insert(index);
                let (Some(&begin), Some(&end)) =
                    (m.points.get(index * 2 + 1), m.points.get(index * 2 + 2))
                else {
                    return Err(anyhow!("not enough wildcard matches for replace pattern"));
                };
                result.push_str(&self.text[begin..end]);
                new_match.push(result.len());
            }

            new_matches.push(Match { points: new_match });
            i = *m.points.last().unwrap();
        }

        result.push_str(&self.text[i..]);
        Ok(Matches {
            text: result,
            matches: new_matches,
            is_replaced: true,
        })
    }

    pub fn text(&self) -> &str {
        &self.text
    }

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

impl Display for Matches {
    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
        let line_breaks = get_line_breaks(&self.text);
        for (n, m) in self.matches.iter().enumerate() {
            let begin = m.points[0];
            let end = m.points[m.points.len() - 1];
            let begin_line = get_line_number(&line_breaks, begin);
            let end_line = get_line_number(&line_breaks, end);
            let begin_col = begin - line_breaks[begin_line];
            let end_col = end - line_breaks[end_line];
            if begin_line == end_line {
                writeln!(
                    f,
                    "Ln {} from Col {} to Col {}:",
                    begin_line + 1,
                    begin_col + 1,
                    end_col
                )?;
            } else {
                writeln!(
                    f,
                    "from (Ln {}, Col {}) to (Ln {}, Col {}):",
                    begin_line + 1,
                    begin_col + 1,
                    end_line + 1,
                    end_col
                )?;
            }

            let max_line_width = ((end_line + 1) as f64).log10() as usize + 1;

            let mut points = m.points.iter().copied().peekable();
            let mut status = 0;
            for line in begin_line..=end_line {
                write!(
                    f,
                    "{}{:>max_line_width$}{} ",
                    style::SetForegroundColor(Color::Yellow),
                    line + 1,
                    style::SetForegroundColor(Color::Reset),
                )?;

                let mut current = line_breaks[line];
                let line_end = line_breaks
                    .get(line + 1)
                    .copied()
                    .unwrap_or(self.text.len());
                let mut first_loop = true;
                while current < line_end {
                    let end = match points.peek().copied() {
                        Some(end) => {
                            if first_loop {
                                first_loop = false;
                            } else {
                                status = match status {
                                    0 => 1,
                                    1 => 2,
                                    _ => 1,
                                };
                            }
                            if end < line_end {
                                points.next();
                                end
                            } else {
                                line_end
                            }
                        }
                        None => {
                            status = 0;
                            line_end
                        }
                    };

                    let content = &self.text[current..end].trim_end_matches(['\n', '\r']);

                    let content = match status {
                        1 => {
                            if self.is_replaced {
                                content.green()
                            } else {
                                content.red()
                            }
                        }
                        2 => content.magenta().underlined(),
                        _ => content.attribute(Attribute::Dim),
                    };

                    write!(f, "{}", content)?;
                    current = end;
                }
                writeln!(f)?;
            }
            if n < self.matches.len() - 1 {
                writeln!(f)?;
            }
        }
        Ok(())
    }
}

fn get_line_breaks(text: &str) -> Vec<usize> {
    let mut breaks = Vec::new();
    for line in text.lines() {
        let offset = line.as_ptr() as usize - text.as_ptr() as usize;
        breaks.push(offset);
    }
    breaks
}

fn get_line_number(line_breaks: &[usize], index: usize) -> usize {
    match line_breaks.binary_search(&index) {
        Ok(n) => n,
        Err(n) => n - 1,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_document() {
        let text = "hello world, hero!";
        let matcher = "he*o";
        let wildcard = "*";
        let document = Matches::new(text, matcher, wildcard).unwrap();
        assert_eq!(
            document.matches,
            vec![
                Match {
                    points: vec![0, 2, 4, 5]
                },
                Match {
                    points: vec![13, 15, 16, 17]
                }
            ]
        );
    }

    #[test]
    fn test_get_line_breaks() {
        let text = "hello\nworld\r\n, hero!";
        let line_breaks = get_line_breaks(text);
        assert_eq!(line_breaks, vec![0, 6, 13]);
        assert_eq!(get_line_number(&line_breaks, 0), 0);
        assert_eq!(get_line_number(&line_breaks, 5), 0);
        assert_eq!(get_line_number(&line_breaks, 6), 1);
        assert_eq!(get_line_number(&line_breaks, 12), 1);
        assert_eq!(get_line_number(&line_breaks, 13), 2);
    }
}