//! Coincidencia de patrones POSIX (§2.13) y expansión de rutas. Propio, sin `regex` ni `glob`: //! son ~150 líneas y evitan dos deps en la raíz de confianza del arranque. //! //! Sirve para dos cosas distintas con las mismas reglas: expandir `*.txt` a ficheros, y decidir //! si `abc` encaja en el patrón de una rama de `case`. /// ¿`texto` encaja en `patron`? `*`, `?`, `[abc]`, `[!abc]`, `[a-z]`, y `\` escapa. pub fn encaja(patron: &str, texto: &str) -> bool { coincide(&patron.chars().collect::>(), &texto.chars().collect::>()) } fn coincide(p: &[char], t: &[char]) -> bool { if p.is_empty() { return t.is_empty(); } match p[0] { '*' => { // voraz con retroceso: `*` puede comerse de 0 a todo for corte in 0..=t.len() { if coincide(&p[1..], &t[corte..]) { return true; } } false } '?' => !t.is_empty() && coincide(&p[1..], &t[1..]), '[' => { if t.is_empty() { return false; } match clase(p) { Some((acepta, largo)) => acepta(t[0]) && coincide(&p[largo..], &t[1..]), // `[` sin cerrar es un literal None => t[0] == '[' && coincide(&p[1..], &t[1..]), } } '\\' if p.len() > 1 => !t.is_empty() && t[0] == p[1] && coincide(&p[2..], &t[1..]), c => !t.is_empty() && t[0] == c && coincide(&p[1..], &t[1..]), } } /// Analiza `[…]` desde `p[0] == '['`. Devuelve `(predicado, cuántos chars ocupa)`. #[allow(clippy::type_complexity)] fn clase(p: &[char]) -> Option<(Box bool>, usize)> { let mut i = 1; let negada = matches!(p.get(i), Some('!') | Some('^')); if negada { i += 1; } let mut sueltos: Vec = Vec::new(); let mut rangos: Vec<(char, char)> = Vec::new(); let mut primero = true; loop { let mut c = *p.get(i)?; if c == ']' && !primero { i += 1; break; } primero = false; // `\` dentro de la clase escapa: `[\\\`\"\$]` es el conjunto de los cuatro caracteres, // y es literalmente el que usa el `config.status` que genera cualquier `configure`. if c == '\\' { if let Some(d) = p.get(i + 1) { c = *d; sueltos.push(c); i += 2; continue; } } if p.get(i + 1) == Some(&'-') && p.get(i + 2).is_some_and(|d| *d != ']') { rangos.push((c, p[i + 2])); i += 3; } else { sueltos.push(c); i += 1; } } let pred = move |c: char| { let dentro = sueltos.contains(&c) || rangos.iter().any(|(a, b)| c >= *a && c <= *b); dentro != negada }; Some((Box::new(pred), i)) } /// ¿La palabra tiene algún metacarácter de ruta sin escapar? Si no, no hay nada que expandir. pub fn tiene_meta(s: &str) -> bool { let cs: Vec = s.chars().collect(); let mut i = 0; while i < cs.len() { match cs[i] { '\\' => i += 2, '*' | '?' | '[' => return true, _ => i += 1, } } false } /// Expande un patrón de ruta. Devuelve las coincidencias ORDENADAS, o vacío si no hay ninguna /// (quien llama deja entonces la palabra tal cual, que es lo que manda POSIX). pub fn expandir(patron: &str) -> Vec { let absoluto = patron.starts_with('/'); let partes: Vec<&str> = patron.split('/').filter(|p| !p.is_empty()).collect(); let mut actuales: Vec = vec![if absoluto { "/".into() } else { String::new() }]; for (n, parte) in partes.iter().enumerate() { let ultima = n + 1 == partes.len(); let mut siguientes = Vec::new(); for base in &actuales { if !tiene_meta(parte) { let cand = unir(base, parte); let existe = std::path::Path::new(&cand).symlink_metadata().is_ok(); if existe || !ultima { // sin meta, no hace falta listar el directorio if existe { siguientes.push(cand); } } continue; } let dir = if base.is_empty() { ".".to_string() } else { base.clone() }; let Ok(entradas) = std::fs::read_dir(&dir) else { continue }; let mut nombres: Vec = entradas .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| { // un patrón no matchea los que empiezan con punto salvo que el patrón lo diga !(n.starts_with('.') && !parte.starts_with('.')) }) .filter(|n| encaja(parte, n)) .collect(); nombres.sort(); for n in nombres { let cand = unir(base, &n); if ultima || std::path::Path::new(&cand).is_dir() { siguientes.push(cand); } } } actuales = siguientes; if actuales.is_empty() { return Vec::new(); } } actuales } fn unir(base: &str, hoja: &str) -> String { if base.is_empty() { hoja.to_string() } else if base == "/" { format!("/{hoja}") } else { format!("{base}/{hoja}") } } #[cfg(test)] mod pruebas { use super::*; #[test] fn asterisco_y_signo() { assert!(encaja("*.txt", "a.txt")); assert!(encaja("*", "")); assert!(!encaja("*.txt", "a.txtx")); assert!(encaja("a?c", "abc")); assert!(!encaja("a?c", "ac")); assert!(encaja("loop*", "loop0")); } #[test] fn clases() { assert!(encaja("[abc]x", "bx")); assert!(!encaja("[abc]x", "dx")); assert!(encaja("[!abc]x", "dx")); assert!(encaja("[a-f]1", "c1")); assert!(!encaja("[a-f]1", "g1")); // `]` como primer carácter es literal assert!(encaja("[]a]", "]")); } #[test] fn el_patron_de_case_del_init() { // `case $n in proc|sys|dev|run|tmp|store|init) continue ;;` — patrones sin meta for n in ["proc", "sys", "dev"] { assert!(encaja(n, n)); } assert!(!encaja("proc", "procfs")); } #[test] fn barra_escapa() { assert!(encaja(r"a\*b", "a*b")); assert!(!encaja(r"a\*b", "axb")); } #[test] fn sin_coincidencia_devuelve_vacio() { assert!(expandir("/no-existe-jamas-*/x").is_empty()); } #[test] fn expande_en_el_arbol_real() { let hits = expandir("/dev/nul*"); assert_eq!(hits, vec!["/dev/null".to_string()]); } }