From f64c0d9694cd8fd0eb3f178d46a7bc62dae76115 Mon Sep 17 00:00:00 2001 From: Alisher Galiev Date: Mon, 24 Mar 2025 15:13:53 +0500 Subject: [PATCH] Add counters to bulk rename function --- Cargo.lock | 1 + yazi-actor/Cargo.toml | 21 +- yazi-actor/src/mgr/bulk_rename.rs | 40 +- .../src/mgr/bulk_rename/counters/ansi.rs | 64 ++ .../src/mgr/bulk_rename/counters/cyrillic.rs | 104 +++ .../src/mgr/bulk_rename/counters/digit.rs | 33 + .../src/mgr/bulk_rename/counters/geneal.rs | 99 +++ .../src/mgr/bulk_rename/counters/mod.rs | 218 ++++++ .../src/mgr/bulk_rename/counters/roman.rs | 183 +++++ .../src/mgr/bulk_rename/counters/test.rs | 177 +++++ .../mgr/bulk_rename/filename_template/mod.rs | 693 ++++++++++++++++++ .../mgr/bulk_rename/filename_template/test.rs | 312 ++++++++ .../src/mgr/bulk_rename/name_generator/mod.rs | 232 ++++++ .../mgr/bulk_rename/name_generator/tests.rs | 133 ++++ 14 files changed, 2291 insertions(+), 19 deletions(-) create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/ansi.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/digit.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/geneal.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/mod.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/roman.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/counters/test.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/filename_template/test.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs create mode 100644 yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 11da35007..078013c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4595,6 +4595,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "unicode-width 0.2.0", "yazi-binding", "yazi-boot", "yazi-config", diff --git a/yazi-actor/Cargo.toml b/yazi-actor/Cargo.toml index b23fdc8a8..132f2d102 100644 --- a/yazi-actor/Cargo.toml +++ b/yazi-actor/Cargo.toml @@ -30,16 +30,17 @@ yazi-watcher = { path = "../yazi-watcher", version = "25.9.15" } yazi-widgets = { path = "../yazi-widgets", version = "25.9.15" } # External dependencies -anyhow = { workspace = true } -crossterm = { workspace = true } -futures = { workspace = true } -hashbrown = { workspace = true } -mlua = { workspace = true } -paste = { workspace = true } -scopeguard = { workspace = true } -tokio = { workspace = true } -tokio-stream = { workspace = true } -tracing = { workspace = true } +anyhow = { workspace = true } +crossterm = { workspace = true } +futures = { workspace = true } +hashbrown = { workspace = true } +mlua = { workspace = true } +paste = { workspace = true } +scopeguard = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tracing = { workspace = true } +unicode-width = { workspace = true } [target."cfg(unix)".dependencies] libc = { workspace = true } diff --git a/yazi-actor/src/mgr/bulk_rename.rs b/yazi-actor/src/mgr/bulk_rename.rs index 8d5290283..a77b23eef 100644 --- a/yazi-actor/src/mgr/bulk_rename.rs +++ b/yazi-actor/src/mgr/bulk_rename.rs @@ -19,6 +19,12 @@ use crate::{Actor, Ctx}; pub struct BulkRename; +mod counters; +mod filename_template; +mod name_generator; + +use name_generator::generate_names; + impl Actor for BulkRename { type Options = VoidOpt; @@ -60,15 +66,8 @@ impl Actor for BulkRename { defer!(AppProxy::resume()); AppProxy::stop().await; - let new: Vec<_> = Local - .read_to_string(&tmp) - .await? - .lines() - .take(old.len()) - .enumerate() - .map(|(i, s)| Tuple::new(i, s)) - .collect(); - + let new_names = Local.read_to_string(&tmp).await?; + let new = Self::parse_new_names(&new_names, old.len()).await?; Self::r#do(root, old, new, selected).await }); succ!(); @@ -76,6 +75,29 @@ impl Actor for BulkRename { } impl BulkRename { + /// Reads a number of lines from a string, attempting to parse them as either + /// fixed filenames or counter-based templates. + /// + /// The number of expected lines should match `expected_count`. + /// If parsing fails, displays all errors to the user and waits for ENTER + /// before returning an error. + async fn parse_new_names(new_names: &str, expected_count: usize) -> Result> { + match generate_names(&mut new_names.lines().take(expected_count)) { + Ok(paths) => Ok(paths), + Err(errors) => { + // Show all parse errors in TTY, then return an error + terminal_clear(TTY.writer())?; + let err = format! {"Found errors in the filenames:\n\n{errors}\nPress ENTER to exit"}; + execute!(TTY.writer(), Print(err),)?; + // Wait for user input + TTY.reader().read_exact(&mut [0])?; + + // Return an error to skip further rename + Err(anyhow::anyhow!("Parsing errors in rename lines")) + } + } + } + async fn r#do( root: usize, old: Vec, diff --git a/yazi-actor/src/mgr/bulk_rename/counters/ansi.rs b/yazi-actor/src/mgr/bulk_rename/counters/ansi.rs new file mode 100644 index 000000000..aa441112e --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/ansi.rs @@ -0,0 +1,64 @@ +//! This module provides functionality for managing ANSI letter counters for both +//! uppercase and lowercase letters, following Excel's alphabetic counter style. + +use super::{CounterFormatter, LOWERCASE, UPPERCASE, write_number_as_letters_gen}; +use std::fmt; + +/// A helper structure for generating uppercase ANSI letters (e.g., A, B, ..., AA, AB). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnsiUpper; + +/// A helper structure for generating lowercase ANSI letters (e.g., a, b, ..., aa, ab). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnsiLower; + +impl_counter_formatter! { AnsiUpper, UPPERCASE } +impl_counter_formatter! { AnsiLower, LOWERCASE } + +/// Converts ANSI letters (e.g., "A", "Z", "AA") to their corresponding numeric values. +/// The conversion follows Excel's alphabetic counter rules: 'A' = 1, 'B' = 2, ..., +/// 'Z' = 26, 'AA' = 27, etc. +/// +/// The `UPPERCASE` constant determines whether the string should be validated +/// as uppercase or lowercase. +/// +/// # Returns +/// +/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`. +#[inline] +fn convert_letters_to_number(value: &str) -> Option { + if value.is_empty() { + return None; + } + + if UPPERCASE { + if !value.chars().all(|c| c.is_ascii_uppercase()) { + return None; + } + } else if !value.chars().all(|c| c.is_ascii_lowercase()) { + return None; + } + + let result = value.chars().rev().enumerate().fold(0_u32, |acc, (i, c)| { + acc + ((c as u32) - (if UPPERCASE { 'A' } else { 'a' } as u32) + 1) * 26_u32.pow(i as u32) + }); + + Some(result) +} + +/// Writes the numeric value as ANSI letters (e.g., 1 → "A", 27 → "AA") into the provided buffer. +/// +/// # Arguments +/// +/// * `num` - The numeric value to convert. +/// * `width` - The minimum width of the generated string, padded with zeros if necessary. +/// * `buf` - The buffer to write the resulting string into. +#[inline] +fn write_number_as_letters( + num: u32, + width: usize, + buf: &mut impl fmt::Write, +) -> fmt::Result { + let base = if UPPERCASE { b'A' } else { b'a' }; + write_number_as_letters_gen(num, width, 26, |r| (base + r as u8) as char, buf) +} diff --git a/yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs b/yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs new file mode 100644 index 000000000..194b38be9 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs @@ -0,0 +1,104 @@ +//! This module provides functionality for managing Cyrillic letter counters for both +//! uppercase and lowercase letters, following Excel's alphabetic counter style. + +use super::{CounterFormatter, LOWERCASE, UPPERCASE, write_number_as_letters_gen}; +use std::fmt; + +/// An array of uppercase Cyrillic letters used for indexing and mapping. +/// This array includes all uppercase Cyrillic letters excluding 'Ё', 'Й', 'Ъ', 'Ы', 'Ь'. +const UPPERCASE_CYRILLIC: [char; 28] = [ + 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', + 'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ', 'Э', 'Ю', 'Я', +]; + +/// An array of lowercase Cyrillic letters used for indexing and mapping. +/// This array includes all lowercase Cyrillic letters excluding 'ё', 'й', 'ъ', 'ы', 'ь'. +const LOWERCASE_CYRILLIC: [char; 28] = [ + 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', + 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'э', 'ю', 'я', +]; + +/// A helper structure for generating uppercase Cyrillic letters (e.g., А, Б, В, ..., АА, АБ), +/// while excluding 'Ё', 'Й', 'Ъ', 'Ы' and 'Ь'. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CyrillicUpper; + +/// A helper structure for generating lowercase Cyrillic letters (e.g., а, б, в, ..., аа, аб), +/// while excluding 'ё', 'й', 'ъ', 'ы' and 'ь'. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CyrillicLower; + +impl_counter_formatter! { CyrillicUpper, UPPERCASE } +impl_counter_formatter! { CyrillicLower, LOWERCASE } + +/// Converts Cyrillic letters (e.g., "Б", "В", "БА") to their corresponding numeric values. +/// The conversion follows Excel's alphabetic counter rules: 'А' = 1, 'Б' = 2, ..., +/// 'Я' = 28, 'АА' = 29, etc. +/// +/// The `UPPERCASE` constant determines whether the string should be validated +/// as uppercase or lowercase. +/// +/// # Returns +/// +/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`. +#[inline] +fn convert_letters_to_number(value: &str) -> Option { + if invalid_string::(value) { + return None; + } + let lookup = if UPPERCASE { &UPPERCASE_CYRILLIC } else { &LOWERCASE_CYRILLIC }; + + let result = value.chars().rev().enumerate().fold(0_u32, |acc, (i, c)| { + if let Some(index) = lookup.iter().position(|&x| x == c) { + acc + (index as u32 + 1) * 28_u32.pow(i as u32) + } else { + acc + } + }); + Some(result) +} + +/// Writes the numeric value as Cyrillic letters (e.g., 1 → "А", 28 → "Я") into the provided buffer. +/// +/// # Arguments +/// +/// * `num` - The numeric value to convert. +/// * `width` - The minimum width of the generated string, padded with zeros if necessary. +/// * `buf` - The buffer to write the resulting string into. +#[inline] +fn write_number_as_letters( + num: u32, + width: usize, + buf: &mut impl fmt::Write, +) -> fmt::Result { + let lookup = if UPPERCASE { &UPPERCASE_CYRILLIC } else { &LOWERCASE_CYRILLIC }; + + write_number_as_letters_gen(num, width, 28, |remainder| lookup[remainder as usize], buf) +} + +/// Checks if a string is non-empty and consists only of valid uppercase or +/// lowercase Cyrillic letters, excluding 'Ё', 'Й', 'Ъ', 'Ы', and 'Ь' +/// ('ё', 'й', 'ъ', 'ы' and 'ь'). +/// +/// The `UPPERCASE` constant determines whether to check uppercase or lowercase letters. +/// +/// # Returns +/// +/// Returns `true` if the string is invalid; otherwise, returns `false`. +#[inline] +fn invalid_string(str: &str) -> bool { + if str.is_empty() { + return true; + } + if UPPERCASE { + !str.chars().all(|c| { + // ('А'..='Я') == ('\u{0410}'..='\u{042F}') + ('\u{0410}'..='\u{042F}').contains(&c) && !matches!(c, 'Ё' | 'Й' | 'Ъ' | 'Ы' | 'Ь') + }) + } else { + !str.chars().all(|c| { + // ('а'..='я') == ('\u{0430}'..='\u{044F}') + ('\u{0430}'..='\u{044F}').contains(&c) && !matches!(c, 'ё' | 'й' | 'ъ' | 'ы' | 'ь') + }) + } +} diff --git a/yazi-actor/src/mgr/bulk_rename/counters/digit.rs b/yazi-actor/src/mgr/bulk_rename/counters/digit.rs new file mode 100644 index 000000000..fc14c9e1c --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/digit.rs @@ -0,0 +1,33 @@ +//! This module provides functionality for managing Arabic numeral counters. + +use super::CounterFormatter; +use std::fmt; + +/// A helper structure for generating numeric values (e.g., 1, 2, ..., 999 or 001, 002). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Digits; + +impl CounterFormatter for Digits { + /// Formats a value as a zero-padded string and writes it to a buffer. + /// + /// # Arguments + /// + /// * `value` - The numeric value to format. + /// * `width` - The minimum width of the output string. + /// * `buf` - A mutable reference to a buffer. + #[inline] + fn value_to_buffer( + self, + value: u32, + width: usize, + buf: &mut impl fmt::Write, + ) -> Result<(), fmt::Error> { + write!(buf, "{value:0>width$}") + } + + /// Parses a zero-padded numeric string into a `u32` value. + #[inline] + fn string_to_value(self, value: &str) -> Option { + value.parse().ok() + } +} diff --git a/yazi-actor/src/mgr/bulk_rename/counters/geneal.rs b/yazi-actor/src/mgr/bulk_rename/counters/geneal.rs new file mode 100644 index 000000000..adbe7b7d4 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/geneal.rs @@ -0,0 +1,99 @@ +use std::fmt; + +/// This macro generates an implementation of CounterFormatter for a given +/// counter helper type. +/// +/// # Arguments +/// +/// * `$type` - The target helper struct (e.g., `AnsiUpper`). +/// * `$case` - A boolean constant determining whether the counter uppercase or lowercase. +macro_rules! impl_counter_formatter { + ($type:ty, $case:expr) => { + impl CounterFormatter for $type { + #[inline] + fn value_to_buffer(self, value: u32, width: usize, buf: &mut impl fmt::Write) -> fmt::Result { + write_number_as_letters::<{ $case }>(value, width, buf) + } + + #[inline] + fn string_to_value(self, value: &str) -> Option { + convert_letters_to_number::<{ $case }>(value) + } + } + }; +} + +/// Converts a given numeric value into an alphabetic representation following a base-N numbering system, +/// similar to Excel-style column labels (e.g., 1 → A, 2 → B, ..., 26 → Z, 27 → AA, etc.). +/// +/// This function generalizes the process for different alphabets by allowing a customizable base (`alphabet_len`) +/// and a transformation function (`convert_fn`) that maps remainder values to characters. +/// +/// # Arguments +/// +/// * `num` - The numeric value to be converted. Since alphabetic numbering systems start from 1 +/// (e.g., A = 1, B = 2), it should be non-zero value. +/// +/// * `width` - The minimum width of the output string. If necessary, the result will be left-padded with '0'. +/// +/// * `alphabet_len` - The base of the numbering system (e.g., 26 for Latin, 28 for Cyrillic, etc.). +/// +/// * `convert_fn` - A closure that converts a remainder (`u32`) into a corresponding character. +/// - The `remainder` represents the remainder of division by `alphabet_len` (i.e., `num % alphabet_len`). +/// - The closure should map this remainder to a specific character in the corresponding alphabet +/// (e.g., `b'A' + remainder as u8`). +/// +/// * `buf` - A mutable reference to a `fmt::Write` buffer where the result is written. +#[inline] +pub(super) fn write_number_as_letters_gen( + mut num: u32, + width: usize, + alphabet_len: u32, + mut convert_fn: impl FnMut(u32) -> char, + buf: &mut impl fmt::Write, +) -> fmt::Result { + if num == 0 { + return Ok(()); + } + + let mut stack_buf = ['0'; 10]; + let mut written_len = 0; + + for char in &mut stack_buf { + if num == 0 { + break; + } + let remainder = (num - 1) % alphabet_len; + *char = convert_fn(remainder); + num = (num - remainder - 1) / alphabet_len; + written_len += 1; + } + + if num > 0 { + let mut vec_buf = Vec::with_capacity(20); + vec_buf.extend_from_slice(&stack_buf); + + while num > 0 { + let remainder = (num - 1) % alphabet_len; + vec_buf.push(convert_fn(remainder)); + num = (num - remainder - 1) / alphabet_len; + written_len += 1; + } + + for _ in vec_buf.len()..width { + buf.write_char('0')?; + } + for &c in vec_buf.iter().rev() { + buf.write_char(c)?; + } + } else { + for _ in written_len..width { + buf.write_char('0')?; + } + for &c in stack_buf[..written_len].iter().rev() { + buf.write_char(c)?; + } + } + + Ok(()) +} diff --git a/yazi-actor/src/mgr/bulk_rename/counters/mod.rs b/yazi-actor/src/mgr/bulk_rename/counters/mod.rs new file mode 100644 index 000000000..937edbfbb --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/mod.rs @@ -0,0 +1,218 @@ +//! This module provides functionality for creating and managing various formats +//! of counters. +//! +//! Counters are used to generate sequences of values based on different +//! alphabets and numeral systems, including ANSI, Cyrillic, and Roman letters, +//! as well as digits. +//! +//! # Overview +//! +//! The module defines traits and structures for different formats of counters, +//! including: +//! +//! - uppercase and lowercase ANSI letters; +//! - uppercase and lowercase Cyrillic letters; +//! - numeric counter; +//! - uppercase and lowercase Roman numerals. +//! +//! The `CharacterCounter` structure provides a unified interface for handling +//! these different formats of counters. + +use super::filename_template::CounterBuilder; +use std::fmt; + +#[cfg(test)] +mod test; + +#[macro_use] +mod geneal; +mod ansi; +mod cyrillic; +mod digit; +mod roman; + +const UPPERCASE: bool = true; +const LOWERCASE: bool = false; + +pub use ansi::{AnsiLower, AnsiUpper}; +pub use cyrillic::{CyrillicLower, CyrillicUpper}; +pub use digit::Digits; +use geneal::write_number_as_letters_gen; +pub use roman::{RomanLower, RomanUpper}; + +/// Defines common behavior for counters that generate sequential values. +pub trait Counter { + /// Writes the current value to the provided buffer. + fn write_value(&self, buf: &mut impl fmt::Write) -> fmt::Result; + + /// Advances the counter to the next value in the sequence. + fn advance(&mut self); + + /// Resets the counter to its initial value. + #[allow(dead_code)] + fn restart(&mut self); +} + +pub trait CounterFormatter: Copy { + /// Formats a value as a zero-padded string and writes it to a buffer. + /// + /// # Arguments + /// + /// * `value` - The numeric value to format. + /// * `width` - The minimum width of the output string. + /// * `buf` - A mutable reference to a buffer. + fn value_to_buffer( + self, + value: u32, + width: usize, + buf: &mut impl fmt::Write, + ) -> Result<(), fmt::Error>; + + /// Parses a zero-padded numeric string into a `u32` value. + fn string_to_value(self, value: &str) -> Option; +} + +/// Enum representing different formats of character-based counters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CounterFormat { + /// Numeric values (1, 2, ..., 999). + Digits(Digits), + + /// Uppercase ANSI letters (A, B, C, ..., AA, AB, ...). + AnsiUpper(AnsiUpper), + + /// Lowercase ANSI letters (a, b, c, ..., aa, ab, ...). + AnsiLower(AnsiLower), + + /// Uppercase Roman numerals (I, II, III, IV, V, ...). + RomanUpper(RomanUpper), + + /// Lowercase Roman numerals (i, ii, iii, iv, v, ...). + RomanLower(RomanLower), + + /// Uppercase Cyrillic letters (А, Б, В, ..., АА, АБ, ...). + CyrillicUpper(CyrillicUpper), + + /// Lowercase Cyrillic letters (а, б, в, ..., аа, аб, ...). + CyrillicLower(CyrillicLower), +} + +impl Default for CounterFormat { + fn default() -> Self { + CounterFormat::Digits(Digits) + } +} + +impl CounterFormatter for CounterFormat { + fn value_to_buffer( + self, + value: u32, + width: usize, + buf: &mut impl fmt::Write, + ) -> Result<(), fmt::Error> { + match self { + CounterFormat::Digits(fmt) => fmt.value_to_buffer(value, width, buf), + CounterFormat::AnsiUpper(fmt) => fmt.value_to_buffer(value, width, buf), + CounterFormat::AnsiLower(fmt) => fmt.value_to_buffer(value, width, buf), + CounterFormat::RomanUpper(fmt) => fmt.value_to_buffer(value, width, buf), + CounterFormat::RomanLower(fmt) => fmt.value_to_buffer(value, width, buf), + CounterFormat::CyrillicUpper(fmt) => fmt.value_to_buffer(value, width, buf), + CounterFormat::CyrillicLower(fmt) => fmt.value_to_buffer(value, width, buf), + } + } + + fn string_to_value(self, value: &str) -> Option { + match self { + CounterFormat::Digits(fmt) => fmt.string_to_value(value), + CounterFormat::AnsiUpper(fmt) => fmt.string_to_value(value), + CounterFormat::AnsiLower(fmt) => fmt.string_to_value(value), + CounterFormat::RomanUpper(fmt) => fmt.string_to_value(value), + CounterFormat::RomanLower(fmt) => fmt.string_to_value(value), + CounterFormat::CyrillicUpper(fmt) => fmt.string_to_value(value), + CounterFormat::CyrillicLower(fmt) => fmt.string_to_value(value), + } + } +} + +impl fmt::Display for CounterFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CounterFormat::Digits(_) => write!(f, "Numeric Digits"), + CounterFormat::AnsiUpper(_) => write!(f, "ANSI Uppercase Letters"), + CounterFormat::AnsiLower(_) => write!(f, "ANSI Lowercase Letters"), + CounterFormat::RomanUpper(_) => write!(f, "Roman Uppercase Numerals"), + CounterFormat::RomanLower(_) => write!(f, "Roman Lowercase Numerals"), + CounterFormat::CyrillicUpper(_) => write!(f, "Cyrillic Uppercase Letters"), + CounterFormat::CyrillicLower(_) => write!(f, "Cyrillic Lowercase Letters"), + } + } +} + +/// Represents a character-based counter. Provides a unified interface for +/// handling different formats of counters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CharacterCounter { + /// The format of counter (e.g., ANSI, Cyrillic, Roman, digits). + format: CounterFormat, + + /// The initial numeric value of the counter, used to reset the counter. + start: u32, + + /// The current numeric value of the counter. + state: u32, + + /// The increment step size when advancing the counter. + step: u32, + + /// The minimum width of the generated string, padded with leading zeros. + width: usize, +} + +impl CharacterCounter { + /// Creates a new `CharacterCounter` instance. + /// + /// # Arguments + /// + /// * `format` - the format of counter (e.g., ANSI, Cyrillic, Roman, digits). + /// * `start` - the initial numeric value of the counter. + /// * `step` - the increment step size when advancing the counter. + /// * `width` - the minimum width of the generated string, padded with leading zeros. + pub fn new(format: CounterFormat, start: u32, step: u32, width: usize) -> Self { + Self { format, start, state: start, step, width } + } + + /// Updates the `CharacterCounter` instance with the parameters set in + /// builder. + pub fn update_from(&mut self, builder: CounterBuilder) { + if self.format != builder.format() { + self.format = builder.format(); + } + + if let Some(start) = builder.start() { + self.start = start; + self.state = start; + } + + if let Some(step) = builder.step() { + self.step = step; + } + + if let Some(width) = builder.width() { + self.width = width; + } + } +} + +impl Counter for CharacterCounter { + fn write_value(&self, buf: &mut impl fmt::Write) -> fmt::Result { + self.format.value_to_buffer(self.state, self.width, buf) + } + + fn advance(&mut self) { + self.state += self.step; + } + + fn restart(&mut self) { + self.state = self.start; + } +} diff --git a/yazi-actor/src/mgr/bulk_rename/counters/roman.rs b/yazi-actor/src/mgr/bulk_rename/counters/roman.rs new file mode 100644 index 000000000..1f96e7297 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/roman.rs @@ -0,0 +1,183 @@ +//! This module provides functionality for managing Roman numeral counters +//! for both uppercase and lowercase Roman numerals. + +use super::{CounterFormatter, LOWERCASE, UPPERCASE}; +use std::fmt; + +/// A lookup table for uppercase Roman numerals and their values. +const UPPERCASE_ROMAN_NUMERALS: [(&str, u32); 13] = [ + ("M", 1000), + ("CM", 900), + ("D", 500), + ("CD", 400), + ("C", 100), + ("XC", 90), + ("L", 50), + ("XL", 40), + ("X", 10), + ("IX", 9), + ("V", 5), + ("IV", 4), + ("I", 1), +]; + +/// A lookup table for lowercase Roman numerals and their values. +const LOWERCASE_ROMAN_NUMERALS: [(&str, u32); 13] = [ + ("m", 1000), + ("cm", 900), + ("d", 500), + ("cd", 400), + ("c", 100), + ("xc", 90), + ("l", 50), + ("xl", 40), + ("x", 10), + ("ix", 9), + ("v", 5), + ("iv", 4), + ("i", 1), +]; + +/// A helper structure for generating uppercase Roman numerals (e.g., I, II, III, IV, V, ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RomanUpper; + +/// A helper structure for generating lowercase Roman numerals (e.g., i, ii, iii, iv, v, ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RomanLower; + +impl_counter_formatter! { RomanUpper, UPPERCASE } +impl_counter_formatter! { RomanLower, LOWERCASE } + +/// Converts Roman numerals (e.g. I, II, III) to their corresponding numeric values. +/// +/// The `UPPERCASE` constant determines whether the string should be validated +/// as uppercase or lowercase. +/// +/// # Returns +/// +/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`. +#[inline] +fn convert_letters_to_number(start: &str) -> Option { + if invalid_string::(start) { + return None; + }; + let roman_numerals = + if UPPERCASE { &UPPERCASE_ROMAN_NUMERALS } else { &LOWERCASE_ROMAN_NUMERALS }; + + let mut num = 0; + let mut i = 0; + + while i < start.len() { + if i + 1 < start.len() { + if let Some(&(_, value)) = roman_numerals.iter().find(|&&(s, _)| s == &start[i..=i + 1]) { + num += value; + i += 2; + continue; + } + } + if let Some(&(_, value)) = roman_numerals.iter().find(|&&(s, _)| s == &start[i..=i]) { + num += value; + i += 1; + } else { + return None; + } + } + + Some(num) +} + +/// Writes the numeric value as Roman numerals (e.g., 1 → "I", 4 → "IV") into the +/// provided buffer. +/// +/// # Arguments +/// +/// * `num` - The numeric value to convert. +/// * `width` - The minimum width of the generated string, padded with zeros if necessary. +/// * `buf` - The buffer to write the resulting string into. +#[inline] +fn write_number_as_letters( + mut num: u32, + width: usize, + buf: &mut impl fmt::Write, +) -> fmt::Result { + if num == 0 { + return Ok(()); + } + + let roman_numerals = + if UPPERCASE { &UPPERCASE_ROMAN_NUMERALS } else { &LOWERCASE_ROMAN_NUMERALS }; + + let mut stack_buf = ['0'; 10]; + let mut length = 0; + + let mut iter = roman_numerals.iter().peekable(); + 'outer: while let Some(&&(roman, value)) = iter.peek() { + 'inner: loop { + if num < value { + break 'inner; + } + let final_length = length + roman.len(); + if final_length > stack_buf.len() { + break 'outer; + } + for (char_ref, char) in stack_buf[length..final_length].iter_mut().zip(roman.chars()) { + *char_ref = char + } + num -= value; + length += roman.len(); + } + iter.next(); + } + + if num > 0 { + let mut vec_buf = Vec::with_capacity(20); + vec_buf.extend_from_slice(&stack_buf[..length]); + + for &(roman, value) in iter { + while num >= value { + vec_buf.extend(roman.chars()); + num -= value; + length += roman.len(); + } + } + + for _ in vec_buf.len()..width { + buf.write_char('0')?; + } + for &c in vec_buf.iter() { + buf.write_char(c)?; + } + } else { + for _ in length..width { + buf.write_char('0')?; + } + for &c in stack_buf[..length].iter() { + buf.write_char(c)?; + } + } + + Ok(()) +} + +/// Checks if a string is non-empty and consists only of valid +/// uppercase or lowercase Roman numerals. +/// +/// The `UPPERCASE` constant determines whether to check uppercase or lowercase letters. +/// +/// # Returns +/// +/// Returns `true` if the string is invalid; otherwise, returns `false`. +#[inline] +fn invalid_string(str: &str) -> bool { + if str.is_empty() { + return true; + } + let valid_chars = if UPPERCASE { + ['M', 'D', 'C', 'L', 'X', 'V', 'I'] + } else { + ['m', 'd', 'c', 'l', 'x', 'v', 'i'] + }; + + !str.chars().all(|c| valid_chars.contains(&c)) +} diff --git a/yazi-actor/src/mgr/bulk_rename/counters/test.rs b/yazi-actor/src/mgr/bulk_rename/counters/test.rs new file mode 100644 index 000000000..1da252f02 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/counters/test.rs @@ -0,0 +1,177 @@ +use super::*; + +const DIGITS_VALUES: [&str; 100] = [ + "000", "001", "002", "003", "004", "005", "006", "007", "008", "009", "010", "011", "012", "013", + "014", "015", "016", "017", "018", "019", "020", "021", "022", "023", "024", "025", "026", "027", + "028", "029", "030", "031", "032", "033", "034", "035", "036", "037", "038", "039", "040", "041", + "042", "043", "044", "045", "046", "047", "048", "049", "050", "051", "052", "053", "054", "055", + "056", "057", "058", "059", "060", "061", "062", "063", "064", "065", "066", "067", "068", "069", + "070", "071", "072", "073", "074", "075", "076", "077", "078", "079", "080", "081", "082", "083", + "084", "085", "086", "087", "088", "089", "090", "091", "092", "093", "094", "095", "096", "097", + "098", "099", +]; + +#[test] +fn test_digits_advance_100_iterations() { + let mut buf = String::new(); + let counter = Digits; + for (idx, &expected_value) in DIGITS_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32, 3, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in DIGITS_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32)); + } +} + +const UPPERCASE_ANSI_VALUES: [&str; 100] = [ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", + "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", + "AK", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ", + "BA", "BB", "BC", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BK", "BL", "BM", "BN", "BO", "BP", + "BQ", "BR", "BS", "BT", "BU", "BV", "BW", "BX", "BY", "BZ", "CA", "CB", "CC", "CD", "CE", "CF", + "CG", "CH", "CI", "CJ", "CK", "CL", "CM", "CN", "CO", "CP", "CQ", "CR", "CS", "CT", "CU", "CV", +]; + +const LOWERCASE_ANSI_VALUES: [&str; 100] = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", + "t", "u", "v", "w", "x", "y", "z", "aa", "ab", "ac", "ad", "ae", "af", "ag", "ah", "ai", "aj", + "ak", "al", "am", "an", "ao", "ap", "aq", "ar", "as", "at", "au", "av", "aw", "ax", "ay", "az", + "ba", "bb", "bc", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bk", "bl", "bm", "bn", "bo", "bp", + "bq", "br", "bs", "bt", "bu", "bv", "bw", "bx", "by", "bz", "ca", "cb", "cc", "cd", "ce", "cf", + "cg", "ch", "ci", "cj", "ck", "cl", "cm", "cn", "co", "cp", "cq", "cr", "cs", "ct", "cu", "cv", +]; + +#[test] +fn test_ansi_upper_advance_100_iterations() { + let mut buf = String::new(); + let counter = AnsiUpper; + for (idx, &expected_value) in UPPERCASE_ANSI_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in UPPERCASE_ANSI_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1)); + } +} + +#[test] +fn test_ansi_lower_advance_100_iterations() { + let mut buf = String::new(); + let counter = AnsiLower; + for (idx, &expected_value) in LOWERCASE_ANSI_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in LOWERCASE_ANSI_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1)); + } +} + +const UPPERCASE_ROMAN_VALUES: [&str; 100] = [ + "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", + "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", + "XXVIII", "XXIX", "XXX", "XXXI", "XXXII", "XXXIII", "XXXIV", "XXXV", "XXXVI", "XXXVII", + "XXXVIII", "XXXIX", "XL", "XLI", "XLII", "XLIII", "XLIV", "XLV", "XLVI", "XLVII", "XLVIII", + "XLIX", "L", "LI", "LII", "LIII", "LIV", "LV", "LVI", "LVII", "LVIII", "LIX", "LX", "LXI", + "LXII", "LXIII", "LXIV", "LXV", "LXVI", "LXVII", "LXVIII", "LXIX", "LXX", "LXXI", "LXXII", + "LXXIII", "LXXIV", "LXXV", "LXXVI", "LXXVII", "LXXVIII", "LXXIX", "LXXX", "LXXXI", "LXXXII", + "LXXXIII", "LXXXIV", "LXXXV", "LXXXVI", "LXXXVII", "LXXXVIII", "LXXXIX", "XC", "XCI", "XCII", + "XCIII", "XCIV", "XCV", "XCVI", "XCVII", "XCVIII", "XCIX", "C", +]; + +const LOWERCASE_ROMAN_VALUES: [&str; 100] = [ + "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii", "xiii", "xiv", "xv", + "xvi", "xvii", "xviii", "xix", "xx", "xxi", "xxii", "xxiii", "xxiv", "xxv", "xxvi", "xxvii", + "xxviii", "xxix", "xxx", "xxxi", "xxxii", "xxxiii", "xxxiv", "xxxv", "xxxvi", "xxxvii", + "xxxviii", "xxxix", "xl", "xli", "xlii", "xliii", "xliv", "xlv", "xlvi", "xlvii", "xlviii", + "xlix", "l", "li", "lii", "liii", "liv", "lv", "lvi", "lvii", "lviii", "lix", "lx", "lxi", + "lxii", "lxiii", "lxiv", "lxv", "lxvi", "lxvii", "lxviii", "lxix", "lxx", "lxxi", "lxxii", + "lxxiii", "lxxiv", "lxxv", "lxxvi", "lxxvii", "lxxviii", "lxxix", "lxxx", "lxxxi", "lxxxii", + "lxxxiii", "lxxxiv", "lxxxv", "lxxxvi", "lxxxvii", "lxxxviii", "lxxxix", "xc", "xci", "xcii", + "xciii", "xciv", "xcv", "xcvi", "xcvii", "xcviii", "xcix", "c", +]; + +#[test] +fn test_roman_upper_advance_100_iterations() { + let mut buf = String::new(); + let counter = RomanUpper; + for (idx, &expected_value) in UPPERCASE_ROMAN_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in UPPERCASE_ROMAN_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1)); + } +} + +#[test] +fn test_roman_lower_advance_100_iterations() { + let mut buf = String::new(); + let counter = RomanLower; + for (idx, &expected_value) in LOWERCASE_ROMAN_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in LOWERCASE_ROMAN_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1)); + } +} + +const UPPERCASE_CYRILLIC_VALUES: [&str; 100] = [ + "А", "Б", "В", "Г", "Д", "Е", "Ж", "З", "И", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У", + "Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Э", "Ю", "Я", "АА", "АБ", "АВ", "АГ", "АД", "АЕ", "АЖ", "АЗ", + "АИ", "АК", "АЛ", "АМ", "АН", "АО", "АП", "АР", "АС", "АТ", "АУ", "АФ", "АХ", "АЦ", "АЧ", "АШ", + "АЩ", "АЭ", "АЮ", "АЯ", "БА", "ББ", "БВ", "БГ", "БД", "БЕ", "БЖ", "БЗ", "БИ", "БК", "БЛ", "БМ", + "БН", "БО", "БП", "БР", "БС", "БТ", "БУ", "БФ", "БХ", "БЦ", "БЧ", "БШ", "БЩ", "БЭ", "БЮ", "БЯ", + "ВА", "ВБ", "ВВ", "ВГ", "ВД", "ВЕ", "ВЖ", "ВЗ", "ВИ", "ВК", "ВЛ", "ВМ", "ВН", "ВО", "ВП", "ВР", +]; + +const LOWERCASE_CYRILLIC_VALUES: [&str; 100] = [ + "а", "б", "в", "г", "д", "е", "ж", "з", "и", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у", + "ф", "х", "ц", "ч", "ш", "щ", "э", "ю", "я", "аа", "аб", "ав", "аг", "ад", "ае", "аж", "аз", + "аи", "ак", "ал", "ам", "ан", "ао", "ап", "ар", "ас", "ат", "ау", "аф", "ах", "ац", "ач", "аш", + "ащ", "аэ", "аю", "ая", "ба", "бб", "бв", "бг", "бд", "бе", "бж", "бз", "би", "бк", "бл", "бм", + "бн", "бо", "бп", "бр", "бс", "бт", "бу", "бф", "бх", "бц", "бч", "бш", "бщ", "бэ", "бю", "бя", + "ва", "вб", "вв", "вг", "вд", "ве", "вж", "вз", "ви", "вк", "вл", "вм", "вн", "во", "вп", "вр", +]; + +#[test] +fn test_cyrillic_upper_advance_100_iterations() { + let mut buf = String::new(); + let counter = CyrillicUpper; + for (idx, &expected_value) in UPPERCASE_CYRILLIC_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in UPPERCASE_CYRILLIC_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1)); + } +} + +#[test] +fn test_cyrillic_lower_advance_100_iterations() { + let mut buf = String::new(); + let counter = CyrillicLower; + for (idx, &expected_value) in LOWERCASE_CYRILLIC_VALUES.iter().enumerate() { + let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf); + assert_eq!(expected_value, &buf); + buf.clear(); + } + + for (idx, &expected_value) in LOWERCASE_CYRILLIC_VALUES.iter().enumerate() { + assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1)); + } +} diff --git a/yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs b/yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs new file mode 100644 index 000000000..2186ce5e5 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs @@ -0,0 +1,693 @@ +use std::{ + error::Error, + fmt, + num::{IntErrorKind, NonZero, ParseIntError}, + ops::Range, + str::FromStr, + str::Split, +}; +use unicode_width::UnicodeWidthStr; + +use super::counters::{ + AnsiLower, AnsiUpper, CharacterCounter, CounterFormat, CounterFormatter, CyrillicLower, + CyrillicUpper, Digits, RomanLower, RomanUpper, +}; + +#[cfg(test)] +mod test; +#[cfg(test)] +use super::counters::Counter; + +/// A byte range within a string. +pub type Span = Range; + +impl<'a> TryFrom<&'a str> for ParsedLine<'a> { + type Error = ParseError<'a>; + + fn try_from(input: &'a str) -> Result { + match Template::parse(input) { + Ok(template) => Ok(ParsedLine::Countable(template)), + Err(error) => match error { + TemplateError::NotCounter => Ok(ParsedLine::Fixed(input)), + TemplateError::Parse(error) => Err(error), + }, + } + } +} + +/// A parsed input line representing either plain text or a template +/// combining static text fragments and counters. +pub enum ParsedLine<'a> { + /// A variant used when the input line contains no counters. + /// + /// This avoids allocating a `Vec` for `TemplatePart` when no dynamic parts are present. + Fixed(&'a str), + + /// A variant used when the input line contains a template with + /// static text fragments and counters. + Countable(Template<'a>), +} + +/// A template (pattern) that combines static text fragments and counters. +/// +/// This structure holds a list of parts, where each part is either plain text +/// or a placeholder for a dynamically generated counter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Template<'a> { + /// A sequence of parts, each of which is either static text or a `CounterBuilder`. + parts: Vec>, + + /// Number of `CounterBuilder` parts contained in `parts`. + /// + /// Guaranteed to be non-zero for type safety: if no counters are found, + /// we return `ParsedLine::Fixed` instead of creating a `Template`. + counter_count: NonZero, +} + +/// Represents the elements of a countable template (pattern). +/// +/// A `TemplatePart` can be either static text or a `CounterBuilder`, which +/// allows for the deferred creation or updating of a counter when needed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplatePart<'a> { + /// Static text within the template. + Text(&'a str), + + /// A `CounterBuilder` that can be used to create or modify a counter. + CounterBuilder(CounterBuilder), +} + +impl Template<'_> { + /// Returns the number of CounterBuilders within [`Template`] + pub fn counter_count(&self) -> usize { + self.counter_count.get() + } + + /// Returns the number of CounterBuilders within [`Template`] + pub fn parts(&self) -> &Vec> { + &self.parts + } + + /// Parses a string into a `Template`, identifying static text and counter + /// placeholders. + /// + /// Parses the input text, replacing countable elements with `CounterBuilder` + /// instances. Returns `Err(TemplateError)` if no counters are found or + /// parsing fails. + /// + /// # Counter Pattern Format + /// + /// Format: `%{,,,}` + /// + /// * ``: A single character indicating the counter type: + /// + /// - `N`, `n`, `D`, `d` → Numeric digits. + /// - `A` → Uppercase ANSI letters. + /// - `a` → Lowercase ANSI letters. + /// - `R` → Uppercase Roman numerals. + /// - `r` → Lowercase Roman numerals. + /// - `C` → Uppercase Cyrillic letters. + /// - `c` → Lowercase Cyrillic letters. + /// + /// * `` (optional): Initial value, either as a number (e.g., + /// `1`, `2`, etc.) or a value corresponding to the counter type: + /// + /// - `A`, `B`, `AA` (ANSI uppercase) + /// - `a`, `b`, `aa` (ANSI lowercase) + /// - `I`, `II`, `III` (Roman uppercase) + /// - `i`, `ii`, `iii` (Roman lowercase) + /// - `А`, `Б`, `АБ` (Cyrillic uppercase) + /// - `а`, `б`, `аб` (Cyrillic lowercase) + /// - `_` for unspecified. + /// + /// - `` (optional): Step size, integer (e.g., `2`) or `_` for + /// unspecified. + /// + /// - `` (optional): Minimum width with zero-padding. Integer + /// (e.g., `3`) or `_` for unspecified. + /// + /// Optional parameters must be specified sequentially: `` is + /// required if `` or `` are used, either explicitly + /// (e.g., `1`) or with `_`. Omitting earlier parameters with commas (e.g., + /// `%{N,,2}` or `%{N,,,4}`) is invalid. Use `%{N,_,2}` or `%{N,_,_,4}` + /// instead. Defaults (`1` for unset values) apply in + /// `CounterBuilder::build()`. + /// + /// ## Escaping `%{` + /// + /// To include a literal `%{` in the output without interpreting it as a counter, + /// escape it by writing `%%{`. The leading `%%` will be interpreted as a single + /// `%` followed by a literal `{`. For example: + /// + /// - `file_%%{name}` → `file_%{name}` + /// + /// # Examples (given as CounterBuilder fields) + /// + /// - `%{N,1}` → start=1, step=None, width=None + /// - `%{N,1,3}` → start=1, step=3, width=None + /// - `%{N,_,2}` → start=None, step=2, width=None + /// - `%{N,_,_,4}` → start=None, step=None, width=4 + /// + /// # Errors + /// + /// - `TemplateError::NotCounter`: No counters found. + /// - `TemplateError::Parse`: Invalid parameters (e.g., `%{N,,2}`). + fn parse(input: &str) -> Result, TemplateError<'_>> { + let mut chars = input.char_indices(); + let mut parts = Vec::new(); + let mut parsed_start_byte_idx = 0; + let mut counter_count = 0; + + while let Some((count_start_byte_idx, char)) = chars.next() { + if char == '%' { + let mut percent_count = 1; + while let Some((current_byte_idx, char)) = chars.next() { + match char { + '%' => percent_count += 1, + '{' => { + // If we have sequence of percent sign (`%%{` or `%%%{` or `%%%%{` and so on) + // so there is escaping of `%{` + if percent_count > 1 { + // current_byte_idx points to the start of '{', so we need to subtract 2 for point + // to the star '%%{' + if current_byte_idx - parsed_start_byte_idx > 0 { + let before_match = &input[parsed_start_byte_idx..current_byte_idx - 2]; + parts.push(TemplatePart::Text(before_match)); + } + + parts.push(TemplatePart::Text("%{")); + + // current_byte_idx points to the start of '{', so we need to add the length of '{' + parsed_start_byte_idx = current_byte_idx + 1; + break; + } else { + let mut counter_end_found = false; + for (count_end_byte_idx, char) in chars.by_ref() { + if char == '}' { + counter_end_found = true; + + // counter starts from `count_start_byte_idx + length of %{` till `count_end_byte_idx` + let span = count_start_byte_idx + 2..count_end_byte_idx; + let builder = Self::parse_counter(span, input)?; + + if count_start_byte_idx - parsed_start_byte_idx > 0 { + let before_match = &input[parsed_start_byte_idx..count_start_byte_idx]; + parts.push(TemplatePart::Text(before_match)); + } + parts.push(TemplatePart::CounterBuilder(builder)); + parsed_start_byte_idx = count_end_byte_idx + 1; + counter_count += 1; + break; + } + } + if !counter_end_found { + return Err(TemplateError::Parse(ParseError { + input, + span: input.len()..input.len(), + reason: "Unclosed delimiter", + expected: Some("}"), + found: None, + })); + } + break; + } + } + _ => break, + } + } + } + } + + // If no countable elements were found, return an error. + if counter_count == 0 { + Err(TemplateError::NotCounter) + } else { + // Add any remaining text after the last match. + if parsed_start_byte_idx < input.len() { + let after_last_match = &input[parsed_start_byte_idx..]; + parts.push(TemplatePart::Text(after_last_match)); + } + Ok(Template { + parts, + // SAFETY: We checked that counter_count is not equal to zero + counter_count: unsafe { NonZero::::new_unchecked(counter_count) }, + }) + } + } + + /// Parses a counter parameters from a substring and creates a `CounterBuilder`. + /// + /// Takes a `span` range within the `input` string, extracts a counter parameters in the format + /// `[,,,]`, and returns a configured + /// `CounterBuilder`. Returns `ParseError` if parsing fails. + /// + /// See `Template::parse` for more information + /// + /// # Arguments + /// + /// * `span` - The range of the counter parameters within `input`. + /// * `input` - The full input string. + fn parse_counter(span: Span, input: &str) -> Result> { + let Range { start, end } = span; + let mut iter = Parts::new(span, input); + let (format_span, format) = iter.next().unwrap_or((start..end, "")); + + let builder = CounterBuilder::default() + .try_set_format(input, format_span, format)? + .try_set_start(input, iter.next())? + .try_set_step(input, iter.next())? + .try_set_width(input, iter.next())?; + + if let Some((format_span, _)) = iter.next() { + return Err(ParseError { + input, + span: format_span.start - 1..end, + reason: "Extra arguments", + expected: Some("no additional arguments"), + found: None, + }); + } + + Ok(builder) + } +} + +/// Enum representing errors that can occur when parsing countable +/// [Template]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateError<'a> { + /// Error indicating that no counter was found in the pattern. + NotCounter, + /// Error indicating invalid input for a counter configuration. + Parse(ParseError<'a>), +} + +impl<'a> From> for TemplateError<'a> { + fn from(err: ParseError<'a>) -> Self { + TemplateError::Parse(err) + } +} + +/// Represents an error encountered while parsing a counter +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError<'a> { + /// The full input string where the error occurred. + pub input: &'a str, + + /// The byte range in the input text where the error occurred. + pub span: Span, + + /// A brief description of what went wrong. + pub reason: &'static str, + + /// An optional hint about the expected input. + pub expected: Option<&'static str>, + + /// An optional string showing what was actually found. + pub found: Option<&'a str>, +} + +impl fmt::Display for ParseError<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Range { start, end } = self.span; + write!(f, "Error: {}", self.reason)?; + + if let Some(expected) = &self.expected { + write!(f, ". Expected: '{expected}'")?; + } + if let Some(found) = &self.found { + write!(f, ", found: '{found}'")?; + } + + writeln!(f, "\n\n{}", self.input)?; + + let offset = UnicodeWidthStr::width(&self.input[..start]); + let length = UnicodeWidthStr::width(&self.input[start..end]).max(1); + + writeln!(f, "{:>offset$}{:^>length$}", "", "", offset = offset, length = length) + } +} + +impl Error for ParseError<'_> {} + +/// An iterator over comma-separated segments of a string slice, returning +/// both the segment and its byte range (`Span`) relative to the original full +/// input string. +/// +/// This is used for parsing parameter lists such as `%{N,_,2,3}` where each +/// value needs to be associated with its exact location in the original string +/// for precise error reporting. +pub struct Parts<'a> { + parts: Split<'a, char>, + current_idx: usize, +} + +impl<'a> Parts<'a> { + /// Creates a new `Parts` iterator over the portion of `input` defined by `span`. + /// + /// The `span` defines the byte range into the original string, and the + /// returned segments will report their positions relative to that original input. + pub fn new(span: Span, input: &'a str) -> Self { + let current_idx = span.start; + let parts = &input[span]; + + Self { parts: parts.split(','), current_idx } + } +} + +impl<'a> Iterator for Parts<'a> { + /// An item representing a single comma-separated segment and its byte range. + /// + /// - `Span`: the byte range of the segment in the original input string, + /// used for precise error reporting. + /// + /// - `&'a str`: the actual content of the segment. + type Item = (Span, &'a str); + + fn next(&mut self) -> Option { + let part = self.parts.next()?; + + let next = Some((self.current_idx..self.current_idx + part.len(), part)); + self.current_idx += part.len() + 1; + next + } +} + +/// A builder for constructing `CharacterCounter` instances. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CounterBuilder { + /// The format of counter to create. + format: CounterFormat, + + /// The initial counter value. + start: Option, + + /// The step size for incrementing the counter. + step: Option, + + /// The minimum output width, zero-padded if needed. + width: Option, +} + +impl CounterBuilder { + /// Creates a new `CounterBuilder` instance with default values + #[inline] + #[allow(dead_code)] + pub fn new() -> Self { + CounterBuilder::default() + } + + /// Returns the selected counter format. + #[inline] + pub fn format(&self) -> CounterFormat { + self.format + } + + /// Returns the initial value of the counter. + #[inline] + pub fn start(&self) -> Option { + self.start + } + + /// Returns the step size for advancing the counter. + #[inline] + pub fn step(&self) -> Option { + self.step + } + + /// Returns the minimum width of the generated output. + #[inline] + pub fn width(&self) -> Option { + self.width + } + + /// Parses and sets the counter format from a single-character string. + /// + /// This method extracts and validates a counter format from a potentially + /// whitespace-padded string. If parsing fails, it returns a [`ParseError`] + /// with the span adjusted to exclude leading and trailing whitespace. + /// + /// # Supported Characters + /// + /// - `'D'`, `'d'`, `'N'`, `'n'` → [`Digits`] + /// - `'A'` → [`AnsiUpper`] + /// - `'a'` → [`AnsiLower`] + /// - `'R'` → [`RomanUpper`] + /// - `'r'` → [`RomanLower`] + /// - `'C'` → [`CyrillicUpper`] + /// - `'c'` → [`CyrillicLower`] + /// + /// # Arguments + /// + /// * `input` - The full input string + /// + /// * `span` - The range of the counter format within `input`. + /// + /// * `format` - A single-character string (possibly with surrounding whitespaces) + /// representing the counter format. + #[inline] + pub fn try_set_format<'a>( + mut self, + input: &'a str, + span: Span, + format: &'a str, + ) -> Result> { + let Range { start, end } = span; + let (trim_span, trimmed) = trim_with_range(start..end, format); + let format = match trimmed { + "D" | "d" | "N" | "n" => CounterFormat::Digits(Digits), + "A" => CounterFormat::AnsiUpper(AnsiUpper), + "a" => CounterFormat::AnsiLower(AnsiLower), + "R" => CounterFormat::RomanUpper(RomanUpper), + "r" => CounterFormat::RomanLower(RomanLower), + "C" => CounterFormat::CyrillicUpper(CyrillicUpper), + "c" => CounterFormat::CyrillicLower(CyrillicLower), + "" => { + return Err(ParseError { + input, + span: start..end, + reason: "Empty counter kind", + expected: Some("one of D, d, N, n, A, a, R, r, C, c"), + found: None, + }); + } + other => { + return Err(ParseError { + input, + span: trim_span, + reason: "Unexpected counter kind", + expected: Some("one of D, d, N, n, A, a, R, r, C, c"), + found: Some(other), + }); + } + }; + + self.format = format; + Ok(self) + } + + /// Parses and sets the start value for the counter from an optional, + /// possibly whitespace-padded string. + /// + /// The value is first interpreted using the current counter format + /// (e.g. digits, letters, Roman numerals). If that fails, it is parsed + /// as an integer. An underscore (`_`) means "unspecified" and is ignored. + /// + /// Leading and trailing whitespace is excluded from the error span if parsing fails. + /// + /// # Supported Formats + /// + /// - Digits: `1`, `2`, `100` + /// - ANSI Uppercase: `A`, `B`, `AA` + /// - ANSI Lowercase: `a`, `b`, `aa` + /// - Roman Uppercase: `I`, `II`, `III` + /// - Roman Lowercase: `i`, `ii`, `iii` + /// - Cyrillic Uppercase: `А`, `Б`, `АБ` + /// - Cyrillic Lowercase: `а`, `б`, `аб` + /// + /// # Arguments + /// + /// * `input` - The full input string + /// + /// * `start` – Optional `(Span, &str)` pair representing the value and + /// its position in the original input. + #[inline] + pub fn try_set_start<'a>( + mut self, + input: &'a str, + start: Option<(Span, &'a str)>, + ) -> Result> { + self.start = Self::parse_field(input, start, |trimmed| self.format.string_to_value(trimmed))?; + Ok(self) + } + + /// Parses and sets the step size for the counter from an optional, + /// possibly whitespace-padded string. + /// + /// The value is parsed as an integer. An underscore (`_`) means + /// "unspecified" and is ignored. + /// + /// Leading and trailing whitespace is excluded from the error span if parsing fails. + /// + /// # Arguments + /// + /// * `input` - The full input string + /// + /// * `step` – Optional `(Span, &str)` pair representing the value and + /// its position in the original input. + #[inline] + pub fn try_set_step<'a>( + mut self, + input: &'a str, + step: Option<(Span, &'a str)>, + ) -> Result> { + self.step = Self::parse_field(input, step, |_| None)?; + Ok(self) + } + + /// Parses and sets the minimum output width for the counter from an optional, + /// possibly whitespace-padded string. + /// + /// The value is parsed as an integer. An underscore (`_`) means + /// "unspecified" and is ignored. + /// + /// Leading and trailing whitespace is excluded from the error span if parsing fails. + /// + /// # Arguments + /// + /// * `input` - The full input string + /// + /// * `width` – Optional `(Span, &str)` pair representing the value and + /// its position in the original input. + #[inline] + pub fn try_set_width<'a>( + mut self, + input: &'a str, + width: Option<(Span, &'a str)>, + ) -> Result> { + self.width = Self::parse_field(input, width, |_| None)?; + Ok(self) + } + + /// Parses an optional `(Span, &str)` into a typed value with optional custom logic. + /// + /// Steps: + /// + /// 1. Trim leading/trailing whitespace. + /// 2. If the result is `_`, return `Ok(None)`. + /// 3. If `custom_parse` returns `Some(val)`, use it. + /// 4. Otherwise, parse using [`FromStr`] for `T`. + /// + /// On failure, returns a [`ParseError`] with a span pointing to the trimmed region + /// (or the full span if the string is empty). + /// + /// # Type Parameters + /// + /// - `T`: The target type, requiring [`FromStr`], [`IntError`], and [`Error`]. + /// + /// # Arguments + /// + /// - `input`: The full input string + /// - `field_data`: Optional `(Span, &str)` (position in the original input text and + /// the content). + /// - `custom_parse`: A fallback parser tried before numeric parsing. + #[inline] + fn parse_field<'a, T>( + input: &'a str, + field_data: Option<(Span, &'a str)>, + custom_parse: impl FnOnce(&str) -> Option, + ) -> Result, ParseError<'a>> + where + T: FromStr, + T::Err: IntError + Error, + { + let Some((original_span, content)) = field_data else { + return Ok(None); + }; + + let (mut trim_span, trimmed) = trim_with_range(original_span.clone(), content); + + if trimmed == "_" { + return Ok(None); + } + + if let Some(val) = custom_parse(trimmed) { + return Ok(Some(val)); + } + + match trimmed.parse::() { + Ok(v) => Ok(Some(v)), + Err(err) => { + let reason = match err.kind() { + IntErrorKind::Empty => { + trim_span = original_span; + "Cannot parse integer from empty string" + } + IntErrorKind::InvalidDigit => "Invalid digit found in string", + IntErrorKind::PosOverflow => "Number too large", + IntErrorKind::NegOverflow => "Number too small", + _ => "Failed to parse integer", + }; + Err(ParseError { + input, + span: trim_span, + reason, + expected: Some("digit"), + found: Some(trimmed), + }) + } + } + } + + /// Builds and returns a `CharacterCounter` instance based on the parameters + /// set in this builder. + /// + /// If any of these parameters are not set, default values are used: + /// - `start`: 1 + /// - `step`: 1 + /// - `width`: 1 + #[inline] + pub fn build(self) -> CharacterCounter { + CharacterCounter::new( + self.format, + self.start.unwrap_or(1), + self.step.unwrap_or(1), + self.width.unwrap_or(1), + ) + } +} + +/// Trims leading whitespace from `str` and returns the trimmed slice +/// along with an updated `Span` reflecting the new start position. +/// +/// If the string is entirely whitespace, returns an empty slice +/// and a zero-length span at the original `span.start`. +/// +/// # Arguments +/// +/// - `span`: The original byte range in the input text. +/// - `str`: The substring to trim. +fn trim_with_range(span: Span, str: &str) -> (Span, &str) { + let Some(mut start) = str.find(|c: char| !c.is_whitespace()) else { + return (span.start..span.start, ""); + }; + + start += span.start; + let str = str.trim(); + + (start..start + str.len(), str) +} + +/// A lightweight trait for accessing [`IntErrorKind`] in generic +/// number-parsing logic, without depending directly on `ParseIntError`. +trait IntError { + /// Returns the specific kind of integer parse error. + fn kind(&self) -> &IntErrorKind; +} + +impl IntError for ParseIntError { + #[inline] + fn kind(&self) -> &IntErrorKind { + self.kind() + } +} diff --git a/yazi-actor/src/mgr/bulk_rename/filename_template/test.rs b/yazi-actor/src/mgr/bulk_rename/filename_template/test.rs new file mode 100644 index 000000000..7c2060522 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/filename_template/test.rs @@ -0,0 +1,312 @@ +use super::*; + +#[test] +fn test_new_creates_default_builder() { + let builder = CounterBuilder::new(); + assert_eq!(builder.format(), CounterFormat::Digits(Digits)); + assert_eq!(builder.start(), None); + assert_eq!(builder.step(), None); + assert_eq!(builder.width(), None); +} + +#[test] +fn test_try_set_format() { + let builder = CounterBuilder::new(); + + // Test all supported formats + let formats = [ + ("D", " D ", CounterFormat::Digits(Digits)), + ("d", " d ", CounterFormat::Digits(Digits)), + ("N", " N ", CounterFormat::Digits(Digits)), + ("n", " n ", CounterFormat::Digits(Digits)), + ("A", " A ", CounterFormat::AnsiUpper(AnsiUpper)), + ("a", " a ", CounterFormat::AnsiLower(AnsiLower)), + ("R", " R ", CounterFormat::RomanUpper(RomanUpper)), + ("r", " r ", CounterFormat::RomanLower(RomanLower)), + ("C", " C ", CounterFormat::CyrillicUpper(CyrillicUpper)), + ("c", " c ", CounterFormat::CyrillicLower(CyrillicLower)), + ]; + + for (without_space, with_space, expected) in formats.iter() { + let result = builder.try_set_format(without_space, 0..1, without_space); + assert_eq!(result.unwrap().format(), *expected,); + let result = builder.try_set_format(with_space, 0..5, with_space); + assert_eq!(result.unwrap().format(), *expected,); + } + + let result = builder.try_set_format("", 0..0, ""); + let error = result.unwrap_err(); + assert_eq!(error.reason, "Empty counter kind"); + assert_eq!(error.span, 0..0); + assert_eq!(error.expected, Some("one of D, d, N, n, A, a, R, r, C, c")); + assert_eq!(error.found, None); + + let result = + CounterBuilder::new().try_set_format(" Ü-Wagen as examplé ", 2..27, " Ü-Wagen as examplé "); + let error = result.unwrap_err(); + assert_eq!(error.reason, "Unexpected counter kind"); + assert_eq!(error.span, 4..25); + assert_eq!(error.expected, Some("one of D, d, N, n, A, a, R, r, C, c")); + assert_eq!(error.found, Some("Ü-Wagen as examplé")); +} + +#[test] +fn test_try_set_start() { + let builder = CounterBuilder::new(); + let result = builder.try_set_start("n,5", Some((2..3, "5"))).unwrap(); + assert_eq!(result.start(), Some(5)); + + let formats = [ + ("D", "25", 25), + ("d", "25", 25), + ("N", "25", 25), + ("n", "25", 25), + ("A", "AB", 28), + ("a", "ab", 28), + ("R", "IV", 4), + ("r", "iv", 4), + ("C", "АБ", 30), + ("c", "аб", 30), + ]; + + for (format, start, expected) in formats.iter() { + let builder = builder.try_set_format(format, 0..1, format).unwrap(); + let result = builder.try_set_start(format, Some((2..4, start))).unwrap(); + assert_eq!(result.start(), Some(*expected)); + } + + let result = builder.try_set_start("_", Some((2..3, "_"))).unwrap(); + assert_eq!(result.start(), None); + + let result = builder.try_set_start(" 5 ", Some((0..5, " 5 "))).unwrap(); + assert_eq!(result.start(), Some(5)); + + let result = + builder.try_set_start(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé "))); + let error = result.unwrap_err(); + assert_eq!(error.span, 4..25); + assert_eq!(error.expected, Some("digit")); + assert_eq!(error.found, Some("Ü-Wagen as examplé")); +} + +#[test] +fn test_try_set_step() { + let builder = CounterBuilder::new(); + let result = builder.try_set_step("5", Some((2..3, "5"))).unwrap(); + assert_eq!(result.step(), Some(5)); + + let result = builder.try_set_step("_", Some((2..3, "_"))).unwrap(); + assert_eq!(result.step(), None); + + let result = builder.try_set_step(" 5 ", Some((0..5, " 5 "))).unwrap(); + assert_eq!(result.step(), Some(5)); + + let result = + builder.try_set_step(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé "))); + let error = result.unwrap_err(); + assert_eq!(error.span, 4..25); + assert_eq!(error.expected, Some("digit")); + assert_eq!(error.found, Some("Ü-Wagen as examplé")); +} + +#[test] +fn test_try_set_width() { + let builder = CounterBuilder::new(); + let result = builder.try_set_width("5", Some((2..3, "5"))).unwrap(); + assert_eq!(result.width(), Some(5)); + + let result = builder.try_set_width("_", Some((2..3, "_"))).unwrap(); + assert_eq!(result.width(), None); + + let result = builder.try_set_width(" 5 ", Some((0..5, " 5 "))).unwrap(); + assert_eq!(result.width(), Some(5)); + + let result = + builder.try_set_width(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé "))); + let error = result.unwrap_err(); + assert_eq!(error.span, 4..25); + assert_eq!(error.expected, Some("digit")); + assert_eq!(error.found, Some("Ü-Wagen as examplé")); +} + +#[test] +fn test_build_with_all_parameters() { + let builder = CounterBuilder::new() + .try_set_format("N,10,2,3", 0..1, "N") + .unwrap() + .try_set_start("N,10,2,3", Some((2..4, "10"))) + .unwrap() + .try_set_step("N,10,2,3", Some((5..6, "2"))) + .unwrap() + .try_set_width("N,10,2,3", Some((7..8, "3"))) + .unwrap(); + let counter = builder.build(); + let mut buf = String::new(); + counter.write_value(&mut buf).unwrap(); + assert_eq!(buf, "010"); // Digits format, width 3 +} + +#[test] +fn test_template_parse_no_counters() { + match Template::parse("plain text without counters") { + Err(TemplateError::NotCounter) => {} + _ => panic!("Expected NotCounter error"), + } +} + +#[test] +fn test_template_parse_counters() { + use super::{CounterFormat as CF, TemplatePart as TP}; + + // test includes escaped counters %%{R,3,4,5} + let inputs: [(&str, CF, CF, CF, CF, CF, Option, Option, Option); 8] = [ + ( + "Ü-%%{R,3,4,5}_%{D}_examplé_%{d}_你好_%{N}_слово_%{n}_word_%{A}.txt", + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::AnsiUpper(AnsiUpper), + None, + None, + None, + ), + ( + "Ü-%%{R,3,4,5}_%{a}_examplé_%{R}_你好_%{r}_слово_%{C}_word_%{c}.txt", + CF::AnsiLower(AnsiLower), + CF::RomanUpper(RomanUpper), + CF::RomanLower(RomanLower), + CF::CyrillicUpper(CyrillicUpper), + CF::CyrillicLower(CyrillicLower), + None, + None, + None, + ), + ( + "Ü-%%{R,3,4,5}_%{D,3}_examplé_%{d,3}_你好_%{N,3}_слово_%{n,3}_word_%{A,3}.txt", + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::AnsiUpper(AnsiUpper), + Some(3), + None, + None, + ), + ( + "Ü-%%{R,3,4,5}_%{a,c}_examplé_%{R,3}_你好_%{r,iii}_слово_%{C,3}_word_%{c,в}.txt", + CF::AnsiLower(AnsiLower), + CF::RomanUpper(RomanUpper), + CF::RomanLower(RomanLower), + CF::CyrillicUpper(CyrillicUpper), + CF::CyrillicLower(CyrillicLower), + Some(3), + None, + None, + ), + ( + "Ü-%%{R,3,4,5}_%{D,3,4}_examplé_%{d,3,4}_你好_%{N,3,4}_слово_%{n,3,4}_word_%{A,3,4}.txt", + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::AnsiUpper(AnsiUpper), + Some(3), + Some(4), + None, + ), + ( + "Ü-%%{R,3,4,5}_%{a,c,14}_examplé_%{R,3,14}_你好_%{r,iii,14}_слово_%{C,3,14}_word_%{c,в,14}.txt", + CF::AnsiLower(AnsiLower), + CF::RomanUpper(RomanUpper), + CF::RomanLower(RomanLower), + CF::CyrillicUpper(CyrillicUpper), + CF::CyrillicLower(CyrillicLower), + Some(3), + Some(14), + None, + ), + ( + "Ü-%%{R,3,4,5}_%{D,3,4,55}_examplé_%{d,3,4,55}_你好_%{N,3,4,55}_слово_%{n,3,4,55}_word_%{A,3,4,55}.txt", + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::Digits(Digits), + CF::AnsiUpper(AnsiUpper), + Some(3), + Some(4), + Some(55), + ), + ( + "Ü-%%{R,3,4,5}_%{a,c,14,6}_examplé_%{R,3,14,6}_你好_%{r,iii,14,6}_слово_%{C,3,14,6}_word_%{c,в,14,6}.txt", + CF::AnsiLower(AnsiLower), + CF::RomanUpper(RomanUpper), + CF::RomanLower(RomanLower), + CF::CyrillicUpper(CyrillicUpper), + CF::CyrillicLower(CyrillicLower), + Some(3), + Some(14), + Some(6), + ), + ]; + + for (idx, &(input, c1, c2, c3, c4, c5, start, step, width)) in inputs.iter().enumerate() { + let parsed = Template::parse(input).expect("Should parse a single counter"); + assert_eq!(parsed.counter_count(), 5); + + assert_eq!( + parsed.parts(), + &[ + TP::Text("Ü-"), + TP::Text("%{"), + TP::Text("R,3,4,5}_"), + TP::CounterBuilder(CounterBuilder { format: c1, start, step, width }), + TP::Text("_example\u{301}_"), + TP::CounterBuilder(CounterBuilder { format: c2, start, step, width }), + TP::Text("_你好_"), + TP::CounterBuilder(CounterBuilder { format: c3, start, step, width }), + TP::Text("_слово_"), + TP::CounterBuilder(CounterBuilder { format: c4, start, step, width }), + TP::Text("_word_"), + TP::CounterBuilder(CounterBuilder { format: c5, start, step, width }), + TP::Text(".txt") + ], + "Failed to pass {} index", + idx + ); + } +} + +#[test] +fn test_template_parse_unclosed_delimiter() { + let input = "file_%{N.txt"; + match Template::parse(input) { + Err(TemplateError::Parse(parse_err)) => { + assert_eq!(parse_err.reason, "Unclosed delimiter"); + assert_eq!(parse_err.expected, Some("}")); + } + _ => panic!("Expected 'unclosed delimiter'."), + } +} + +#[test] +fn test_template_parse_extra_commas() { + let input = "some %{N,2,2,2,} text"; + let result = Template::parse(input); + match result { + Err(TemplateError::Parse(parse_err)) => { + assert_eq!( + parse_err, + ParseError { + input: "some %{N,2,2,2,} text", + span: 14..15, + reason: "Extra arguments", + expected: Some("no additional arguments"), + found: None, + } + ); + } + Ok(_) => panic!("Expected TemplateError::Parse"), + Err(_) => panic!("Expected TemplateError::Parse"), + } +} diff --git a/yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs b/yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs new file mode 100644 index 000000000..163fe6d13 --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs @@ -0,0 +1,232 @@ +//! This module provides functionality to generate file paths from input strings +//! containing counters and template variables. It parses lines of text and +//! produces either fixed filenames or filenames based on templates with +//! dynamically updated counters. + +use std::{error::Error, fmt, fmt::Write, ops::Range}; + +use unicode_width::UnicodeWidthStr; + +use super::{Tuple, counters::Counter, filename_template::{ParseError, ParsedLine, TemplatePart}}; + +#[cfg(test)] +mod tests; + +/// Generates a sequence of file paths from an input lines of text. +/// +/// Each line is parsed into either a fixed filename or a template containing +/// counters. If the line contains a template, counters are created and +/// dynamically updated across lines, ensuring consistent numbering and +/// formatting. +/// +/// For details on counter syntax and template parsing, see +/// [`super::filename_template::Template::parse`]. +/// +/// If any line contains counters, the number of counters must remain consistent +/// across all lines. If there's a mismatch in the expected and actual number of +/// counters in any line, the function returns error. +/// +/// # Error Handling +/// +/// Instead of stopping execution at the first encountered error, the function +/// collects all errors, allowing the caller to see every problematic line at +/// once. +/// +/// # Flexibility +/// +/// While the number of counters per line must be consistent, individual counter +/// parameters (such as format, start, step, and width) may vary line by line. +/// Counters update their values accordingly based on each line’s +/// specifications. +pub fn generate_names<'a, T>(lines: &mut T) -> Result, NameGenerationErrors<'a>> +where + T: Iterator, +{ + let mut results = Vec::::new(); + let mut errors = Vec::new(); + + let mut counters = Vec::new(); + + for (idx, line) in lines.enumerate() { + match ParsedLine::try_from(line) { + Ok(ParsedLine::Fixed(literal)) => { + results.push(Tuple::new(idx, literal)); + } + Ok(ParsedLine::Countable(template)) => { + if counters.is_empty() { + counters.extend(template.parts().iter().filter_map(|part| match part { + TemplatePart::Text(_) => None, + TemplatePart::CounterBuilder(builder) => Some(builder.build()), + })) + } + + if counters.len() != template.counter_count() { + errors.push(NameGenError::MismatchCounters { + expected: counters.len(), + got: template.counter_count(), + line_number: idx + 1, + content: line, + }); + continue; + } + + let mut out = String::new(); + let mut counter_idx = 0; + + for part in template.parts() { + match part { + TemplatePart::Text(text) => { + out.push_str(text); + } + TemplatePart::CounterBuilder(builder) => { + let counter = &mut counters[counter_idx]; + counter.update_from(*builder); + + let _ = counter.write_value(&mut out); + counter.advance(); + + counter_idx += 1; + } + } + } + + results.push(Tuple::new(idx, out)); + } + Err(error) => { + errors.push(NameGenError::ParseError { line_number: idx + 1, error }); + } + } + } + + if errors.is_empty() { Ok(results) } else { Err(NameGenerationErrors { errors }) } +} + +/// Represents errors that can occur during filename generation. +#[derive(Debug, PartialEq, Eq)] +pub enum NameGenError<'a> { + /// Error parsing a line into a valid counter template. + ParseError { line_number: usize, error: ParseError<'a> }, + + /// Error indicating mismatch between the expected and actual + /// number of counters at some line + MismatchCounters { + expected: usize, + got: usize, + line_number: usize, + content: &'a str, + }, +} + +impl fmt::Display for NameGenError<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + // Calculates the number of digits in a given line number (for formatting + // alignment). + fn print_width(mut n: usize) -> usize { + let mut width = 1; + while n >= 10 { + n /= 10; + width += 1; + } + width + } + + match self { + NameGenError::ParseError { line_number, error } => { + let Range { start, end } = error.span; + + // Calculate the width needed for the line number. + let line_num_width = print_width(*line_number); + + // Calculate the width needed to print the line number and + // separator (e.g., "182| "). + // let available = term_width.saturating_sub(line_num_width as u16 + 2) as + // usize; + + // Calculate the width of the string before the error start. + let input_len_left = UnicodeWidthStr::width(&error.input[..start]); + // Calculate the width of the error span, ensuring at least 1 character. + let input_len_span = UnicodeWidthStr::width(&error.input[start..end]).max(1); + + // Constructs a hint string indicating expected and found values, if applicable. + let mut hint = String::new(); + + if let Some(exp) = &error.expected { + let _ = write!(hint, " Expected: '{exp}'"); + } + if let Some(fnd) = &error.found { + let _ = write!(hint, ", found: '{fnd}'"); + } + + // Print the error header + write!(fmt, "Error: {}", error.reason)?; + + // Write a blank line with alignment for the line number. + writeln!(fmt, "\n{:>offset$}|", "", offset = line_num_width)?; + + // Write the line number and input, with optional ellipses. + write!(fmt, "{line_number}| ")?; + + write!(fmt, "{}", error.input)?; + + // Write the caret line indicating the error span and the hint. + writeln!( + fmt, + "\n{:>num_offset$}| {:>offset$}{:^>length$}{}\n", + "", + "", + "", + hint, + num_offset = line_num_width, + offset = input_len_left, + length = input_len_span + ) + } + + NameGenError::MismatchCounters { expected, got, line_number, content } => { + // Calculate the width needed for the line number. + let line_num_width = print_width(*line_number); + + // Calculate the width of the content. + let input_len_span = UnicodeWidthStr::width(*content).max(1); + + let hint = format!(" Expected {expected} counters, but got {got}"); + + // Print the error header + write!(fmt, "Error: Mismatch counter numbers")?; + + // Write a blank line with alignment. + writeln!(fmt, "\n{:>offset$}|", "", offset = line_num_width)?; + + // Write the line number and content. + writeln!(fmt, "{line_number}| {content}")?; + + // Write the caret line spanning the entire content and the hint. + writeln!( + fmt, + "{:>num_offset$}| {:^>length$}{}\n", + "", + "", + hint, + num_offset = line_num_width, + length = input_len_span + ) + } + } + } +} + +impl Error for NameGenError<'_> {} + +/// Represents a collection of errors that occurred during filename generation. +#[derive(Debug, PartialEq, Eq)] +pub struct NameGenerationErrors<'a> { + pub errors: Vec>, +} + +impl<'a> fmt::Display for NameGenerationErrors<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.errors.iter().try_for_each(|e| write!(f, "{}", e)) + } +} + +impl<'a> Error for NameGenerationErrors<'a> {} diff --git a/yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs b/yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs new file mode 100644 index 000000000..02cbaeb2a --- /dev/null +++ b/yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs @@ -0,0 +1,133 @@ +use super::*; + +// Helper to quickly compare Ok(Vec) +fn assert_ok_paths(result: Result, NameGenerationErrors>, expected: &[&str]) { + match result { + Ok(paths) => { + let actual: Vec<_> = paths.iter().map(|p| p.to_string_lossy()).collect(); + let expected: Vec<_> = expected.iter().copied().map(String::from).collect(); + assert_eq!(actual, expected, "Expected {:?}, got {:?}", expected, actual); + } + Err(errs) => panic!("Expected Ok(...), got Err({:?})", errs), + } +} + +#[test] +fn test_generate_names_no_counters() { + // All lines are just plain filenames (no counters) + let mut input = "file1.txt\nfile2.txt\nanother_file\n".lines(); + let result = generate_names(&mut input); + // Should succeed, returning the same lines as PathBuf + assert_ok_paths(result, &["file1.txt", "file2.txt", "another_file"]); +} + +#[test] +fn test_generate_names() { + let input = [ + // Start = 1, Step = 1, Width = 1 + "file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 1 + "file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 1 + // Start = 5, Step = 1, Width = 1 + "file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5 + "file_%{D }_%{d }_%{N }_%{n }_%{A }_%{a }_%{R }_%{r }_%{C }_%{c }.txt", // print 6 + // Start = 5 (two times), Step = 1, Width = 1 + "file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5 + "file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5 (again) + // Start = 5, Step = 3, Width = 1 + "file_%{D,_,3}_%{d,_,3}_%{N,_,3}_%{n,_,3}_%{A,_,3}_%{a,_,3}_%{R,_,3}_%{r,_,3}_%{C,_,3}_%{c,_,3}.txt", // print 6 + "file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 9 + // Start = 5, Step = 3, Width = 3 + "file_%{D,_,_,3}_%{d,_,_,3}_%{N,_,_,3}_%{n,_,_,3}_%{A,_,_,3}_%{a,_,_,3}_%{R,_,_,3}_%{r,_,_,3}_%{C,_,_,3}_%{c,_,_,3}.txt", // print 012 + "file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 015 + // Change counter formats, Start = 5, Step = 3, Width = 3 + "file_%{A}_%{R}_%{C}_%{N}_%{a}_%{r}_%{c}_%{n}_%{D}_%{d}.txt", // print 018 + ] + .join("\n"); + + let result = generate_names(&mut input.lines()); + assert_ok_paths(result, &[ + "file_1_1_1_1_A_a_I_i_А_а.txt", + "file_2_2_2_2_B_b_II_ii_Б_б.txt", + "file_5_5_5_5_E_e_V_v_Д_д.txt", + "file_6_6_6_6_F_f_VI_vi_Е_е.txt", + "file_5_5_5_5_E_e_V_v_Д_д.txt", + "file_5_5_5_5_E_e_V_v_Д_д.txt", + "file_6_6_6_6_F_f_VI_vi_Е_е.txt", + "file_9_9_9_9_I_i_IX_ix_И_и.txt", + "file_012_012_012_012_00L_00l_XII_xii_00М_00м.txt", + "file_015_015_015_015_00O_00o_0XV_0xv_00П_00п.txt", + "file_00R_XVIII_00Т_018_00r_xviii_00т_018_018_018.txt", + ]); +} + +#[test] +fn test_generate_names_mismatch_counters() { + // First line has 2 counters, second line has 1 + let input = "\ + file_%{n}_%{a}.txt\n\ + file_%{n}.txt\ + "; + let result = generate_names(&mut input.lines()).unwrap_err(); + // Should produce PathGenError::MismatchCounters + assert_eq!(result.errors, &[NameGenError::MismatchCounters { + expected: 2, + got: 1, + line_number: 2, + content: "file_%{n}.txt", + }]); +} + +#[test] +fn test_generate_names_parse_errors() { + let input = "\ + Ü-Wagen examplé_слово_%{???}.txt\n\ + Ü-Wagen examplé_слово_%{n,???}.txt\n\ + Ü-Wagen examplé_слово_%{n,1,???}.txt\n\ + Ü-Wagen examplé_слово_%{n,1,1,???}.txt\n\ + Ü-Wagen examplé_слово_%{n,1,1,1,???}.txt\n\ + Ü-Wagen examplé_слово_%{n,1,1,1,}.txt\n\ + Ü-Wagen examplé_слово_%{n}.txt\n\ + Ü-Wagen examplé_слово_%{n}_%{n}.txt + "; + let output = generate_names(&mut input.lines()).unwrap_err().to_string(); + + let expected = "\ +Error: Unexpected counter kind + | +1| Ü-Wagen examplé_слово_%{???}.txt + | ^^^ Expected: 'one of D, d, N, n, A, a, R, r, C, c', found: '???' + +Error: Invalid digit found in string + | +2| Ü-Wagen examplé_слово_%{n,???}.txt + | ^^^ Expected: 'digit', found: '???' + +Error: Invalid digit found in string + | +3| Ü-Wagen examplé_слово_%{n,1,???}.txt + | ^^^ Expected: 'digit', found: '???' + +Error: Invalid digit found in string + | +4| Ü-Wagen examplé_слово_%{n,1,1,???}.txt + | ^^^ Expected: 'digit', found: '???' + +Error: Extra arguments + | +5| Ü-Wagen examplé_слово_%{n,1,1,1,???}.txt + | ^^^^ Expected: 'no additional arguments' + +Error: Extra arguments + | +6| Ü-Wagen examplé_слово_%{n,1,1,1,}.txt + | ^ Expected: 'no additional arguments' + +Error: Mismatch counter numbers + | +8| Ü-Wagen examplé_слово_%{n}_%{n}.txt + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Expected 1 counters, but got 2 + +"; + + assert_eq!(output, expected); +}