mirror of
https://github.com/sxyazi/yazi.git
synced 2026-07-25 08:41:05 +00:00
Add counters to bulk rename function
This commit is contained in:
parent
e9742cb809
commit
f64c0d9694
14 changed files with 2291 additions and 19 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4595,6 +4595,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"unicode-width 0.2.0",
|
||||
"yazi-binding",
|
||||
"yazi-boot",
|
||||
"yazi-config",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<Vec<Tuple>> {
|
||||
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<Tuple>,
|
||||
|
|
|
|||
64
yazi-actor/src/mgr/bulk_rename/counters/ansi.rs
Normal file
64
yazi-actor/src/mgr/bulk_rename/counters/ansi.rs
Normal file
|
|
@ -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<const UPPERCASE: bool>(value: &str) -> Option<u32> {
|
||||
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<const UPPERCASE: bool>(
|
||||
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)
|
||||
}
|
||||
104
yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs
Normal file
104
yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs
Normal file
|
|
@ -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<const UPPERCASE: bool>(value: &str) -> Option<u32> {
|
||||
if invalid_string::<UPPERCASE>(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<const UPPERCASE: bool>(
|
||||
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<const UPPERCASE: bool>(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, 'ё' | 'й' | 'ъ' | 'ы' | 'ь')
|
||||
})
|
||||
}
|
||||
}
|
||||
33
yazi-actor/src/mgr/bulk_rename/counters/digit.rs
Normal file
33
yazi-actor/src/mgr/bulk_rename/counters/digit.rs
Normal file
|
|
@ -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<u32> {
|
||||
value.parse().ok()
|
||||
}
|
||||
}
|
||||
99
yazi-actor/src/mgr/bulk_rename/counters/geneal.rs
Normal file
99
yazi-actor/src/mgr/bulk_rename/counters/geneal.rs
Normal file
|
|
@ -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<u32> {
|
||||
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(())
|
||||
}
|
||||
218
yazi-actor/src/mgr/bulk_rename/counters/mod.rs
Normal file
218
yazi-actor/src/mgr/bulk_rename/counters/mod.rs
Normal file
|
|
@ -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<u32>;
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
183
yazi-actor/src/mgr/bulk_rename/counters/roman.rs
Normal file
183
yazi-actor/src/mgr/bulk_rename/counters/roman.rs
Normal file
|
|
@ -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<const UPPERCASE: bool>(start: &str) -> Option<u32> {
|
||||
if invalid_string::<UPPERCASE>(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<const UPPERCASE: bool>(
|
||||
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<const UPPERCASE: bool>(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))
|
||||
}
|
||||
177
yazi-actor/src/mgr/bulk_rename/counters/test.rs
Normal file
177
yazi-actor/src/mgr/bulk_rename/counters/test.rs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
693
yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs
Normal file
693
yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs
Normal file
|
|
@ -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<usize>;
|
||||
|
||||
impl<'a> TryFrom<&'a str> for ParsedLine<'a> {
|
||||
type Error = ParseError<'a>;
|
||||
|
||||
fn try_from(input: &'a str) -> Result<Self, Self::Error> {
|
||||
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<TemplatePart<'a>>,
|
||||
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<TemplatePart<'_>> {
|
||||
&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: `%{<COUNT_TYPE>,<START_VALUE>,<COUNT_STEP>,<COUNT_WIDTH>}`
|
||||
///
|
||||
/// * `<COUNT_TYPE>`: 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.
|
||||
///
|
||||
/// * `<START_VALUE>` (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.
|
||||
///
|
||||
/// - `<COUNT_STEP>` (optional): Step size, integer (e.g., `2`) or `_` for
|
||||
/// unspecified.
|
||||
///
|
||||
/// - `<COUNT_WIDTH>` (optional): Minimum width with zero-padding. Integer
|
||||
/// (e.g., `3`) or `_` for unspecified.
|
||||
///
|
||||
/// Optional parameters must be specified sequentially: `<START_VALUE>` is
|
||||
/// required if `<COUNT_STEP>` or `<COUNT_WIDTH>` 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<Template<'_>, 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::<usize>::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
|
||||
/// `[<COUNT_TYPE>,<START_VALUE>,<COUNT_STEP>,<COUNT_WIDTH>]`, 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<CounterBuilder, ParseError<'_>> {
|
||||
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<ParseError<'a>> 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<Self::Item> {
|
||||
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<u32>,
|
||||
|
||||
/// The step size for incrementing the counter.
|
||||
step: Option<u32>,
|
||||
|
||||
/// The minimum output width, zero-padded if needed.
|
||||
width: Option<usize>,
|
||||
}
|
||||
|
||||
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<u32> {
|
||||
self.start
|
||||
}
|
||||
|
||||
/// Returns the step size for advancing the counter.
|
||||
#[inline]
|
||||
pub fn step(&self) -> Option<u32> {
|
||||
self.step
|
||||
}
|
||||
|
||||
/// Returns the minimum width of the generated output.
|
||||
#[inline]
|
||||
pub fn width(&self) -> Option<usize> {
|
||||
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<Self, ParseError<'a>> {
|
||||
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, ParseError<'a>> {
|
||||
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, ParseError<'a>> {
|
||||
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, ParseError<'a>> {
|
||||
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<T>,
|
||||
) -> Result<Option<T>, 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::<T>() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
312
yazi-actor/src/mgr/bulk_rename/filename_template/test.rs
Normal file
312
yazi-actor/src/mgr/bulk_rename/filename_template/test.rs
Normal file
|
|
@ -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<u32>, Option<u32>, Option<usize>); 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"),
|
||||
}
|
||||
}
|
||||
232
yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs
Normal file
232
yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs
Normal file
|
|
@ -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<Vec<Tuple>, NameGenerationErrors<'a>>
|
||||
where
|
||||
T: Iterator<Item = &'a str>,
|
||||
{
|
||||
let mut results = Vec::<Tuple>::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<NameGenError<'a>>,
|
||||
}
|
||||
|
||||
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> {}
|
||||
133
yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs
Normal file
133
yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
use super::*;
|
||||
|
||||
// Helper to quickly compare Ok(Vec<Tuple>)
|
||||
fn assert_ok_paths(result: Result<Vec<Tuple>, 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue