feat(shuma): shuma-line — el cerebro agnóstico del input del shell

Análisis de la línea de comandos bash, listo para GUI o TUI:
- lexer: tokeniza + clasifica (comando vs argumento por etapa),
  reconoce comillas, variables, tuberías, redirecciones, operadores.
- pipeline: descompone la línea en etapas separadas por |.
- complete: autocompletado posicional (comando / flag / ruta) con
  CompletionSource inyectable; diccionario de flags por comando.
- LineState: input editable UTF-8-safe (cursor, motions, completado).
- Dialect conmutable (bash hoy; zsh/fish/python a futuro).

32 tests. #![forbid(unsafe_code)], cero deps de UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 18:08:26 +00:00
parent 2b340fdf40
commit cf337c88d7
11 changed files with 1902 additions and 0 deletions
@@ -0,0 +1,250 @@
//! Autocompletado — sugerencias inteligentes según la posición del cursor.
//!
//! El motor decide *qué* se está escribiendo (un comando, un flag o una
//! ruta) mirando la estructura de la línea, y delega la búsqueda de
//! candidatos concretos en una [`CompletionSource`] que el frontend
//! provee (escaneo del `PATH`, del sistema de archivos, etc.).
use serde::{Deserialize, Serialize};
use crate::dialect::Dialect;
use crate::lexer::tokenize;
use crate::token::TokenKind;
/// Origen de candidatos concretos — lo implementa el frontend, que sí
/// conoce el sistema (el `PATH`, el disco). El motor de `shuma-line` se
/// mantiene agnóstico.
pub trait CompletionSource {
/// Nombres de comandos disponibles (típicamente, escaneo del `PATH`).
fn commands(&self) -> Vec<String>;
/// Rutas de archivo que empiezan con `prefix`.
fn paths(&self, prefix: &str) -> Vec<String>;
}
/// Qué clase de cosa se está completando.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompletionKind {
/// El nombre de un comando.
Command,
/// Una opción de un comando.
Flag,
/// Una ruta del sistema de archivos.
Path,
}
/// El resultado de un intento de autocompletado.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Completion {
pub kind: CompletionKind,
/// Candidatos, ordenados y sin repetir.
pub candidates: Vec<String>,
/// Inicio del rango de bytes a reemplazar al aceptar un candidato.
pub replace_start: usize,
/// Fin del rango de bytes a reemplazar.
pub replace_end: usize,
}
impl Completion {
/// `true` si no hay ningún candidato.
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
}
/// Pistas de flags por comando — un diccionario mínimo de los comandos
/// más usados. La fuente real de un frontend puede ampliarlo.
pub fn flag_hints(command: &str) -> &'static [&'static str] {
match command {
"ls" => &["-l", "-a", "-la", "-lh", "-R", "-t", "--all", "--color", "--human-readable"],
"grep" => &["-i", "-v", "-r", "-n", "-E", "-l", "-c", "--color", "--include"],
"rm" => &["-r", "-f", "-rf", "-i", "-v"],
"cp" => &["-r", "-a", "-v", "-p", "-u"],
"mv" => &["-f", "-i", "-n", "-v"],
"cargo" => &["--release", "--workspace", "--all-features", "-p", "--bin", "--example"],
"git" => &["--version", "--help", "-C"],
"docker" => &["-d", "-it", "--name", "--restart", "-p", "-e", "-v", "--rm"],
"ps" => &["-e", "-f", "-aux", "-u"],
"tar" => &["-c", "-x", "-z", "-v", "-f", "-czf", "-xzf"],
"curl" => &["-s", "-L", "-o", "-O", "-X", "-H", "-d"],
_ => &[],
}
}
/// Calcula el autocompletado para `line` con el cursor en `cursor`
/// (offset de byte). Nunca entra en pánico si `cursor` cae en mitad de
/// un carácter: se ajusta al límite válido anterior.
pub fn complete(
line: &str,
cursor: usize,
dialect: Dialect,
source: &dyn CompletionSource,
) -> Completion {
let mut cursor = cursor.min(line.len());
while cursor > 0 && !line.is_char_boundary(cursor) {
cursor -= 1;
}
let tokens = tokenize(line, dialect);
// Token que se está editando: aquel cuyo contenido llega al cursor.
let word_token = tokens
.iter()
.find(|t| t.start < cursor && cursor <= t.end && t.kind.is_content());
let (prefix, repl_start, repl_end) = match word_token {
Some(t) => (&line[t.start..cursor], t.start, cursor),
None => ("", cursor, cursor),
};
let word_start = repl_start;
// Recorre los tokens previos a la palabra para saber si la etapa
// actual ya tiene comando (→ estamos en posición de argumento).
let mut stage_command: Option<String> = None;
let mut has_command = false;
for t in &tokens {
if t.end > word_start {
break;
}
match t.kind {
TokenKind::Pipe | TokenKind::Operator => {
stage_command = None;
has_command = false;
}
TokenKind::Command => {
stage_command = Some(t.text.clone());
has_command = true;
}
_ => {}
}
}
let (kind, mut candidates) = if !has_command {
let cs = source
.commands()
.into_iter()
.filter(|c| c.starts_with(prefix))
.collect();
(CompletionKind::Command, cs)
} else if prefix.starts_with('-') {
let hints = stage_command.as_deref().map(flag_hints).unwrap_or(&[]);
let cs = hints
.iter()
.filter(|f| f.starts_with(prefix))
.map(|s| s.to_string())
.collect();
(CompletionKind::Flag, cs)
} else {
(CompletionKind::Path, source.paths(prefix))
};
candidates.sort();
candidates.dedup();
candidates.truncate(200);
Completion { kind, candidates, replace_start: repl_start, replace_end: repl_end }
}
/// Fuente de candidatos con listas fijas — útil para tests y para un
/// arranque sin escaneo del sistema.
#[derive(Debug, Clone, Default)]
pub struct StaticSource {
pub commands: Vec<String>,
pub paths: Vec<String>,
}
impl CompletionSource for StaticSource {
fn commands(&self) -> Vec<String> {
self.commands.clone()
}
fn paths(&self, prefix: &str) -> Vec<String> {
self.paths
.iter()
.filter(|p| p.starts_with(prefix))
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> StaticSource {
StaticSource {
commands: vec![
"ls".into(),
"lsblk".into(),
"grep".into(),
"git".into(),
"cargo".into(),
],
paths: vec![
"Cargo.toml".into(),
"Cargo.lock".into(),
"src/".into(),
"README.md".into(),
],
}
}
fn complete_at(line: &str, cursor: usize) -> Completion {
complete(line, cursor, Dialect::Bash, &source())
}
#[test]
fn completes_command_names_from_prefix() {
let c = complete_at("ls", 2);
assert_eq!(c.kind, CompletionKind::Command);
assert_eq!(c.candidates, vec!["ls", "lsblk"]);
assert_eq!((c.replace_start, c.replace_end), (0, 2));
}
#[test]
fn completes_flags_for_the_stage_command() {
let c = complete_at("ls -l", 5);
assert_eq!(c.kind, CompletionKind::Flag);
assert!(c.candidates.contains(&"-l".to_string()));
assert!(c.candidates.contains(&"-la".to_string()));
assert!(c.candidates.iter().all(|f| f.starts_with("-l")));
}
#[test]
fn completes_paths_in_argument_position() {
let c = complete_at("cat Cargo", 9);
assert_eq!(c.kind, CompletionKind::Path);
assert_eq!(c.candidates, vec!["Cargo.lock", "Cargo.toml"]);
}
#[test]
fn completes_command_after_a_pipe() {
// Tras `| g`, se completa un comando nuevo, no una ruta.
let c = complete_at("cat f | g", 9);
assert_eq!(c.kind, CompletionKind::Command);
assert_eq!(c.candidates, vec!["git", "grep"]);
}
#[test]
fn empty_line_offers_all_commands() {
let c = complete_at("", 0);
assert_eq!(c.kind, CompletionKind::Command);
assert_eq!(c.candidates.len(), 5);
}
#[test]
fn completing_in_whitespace_starts_a_fresh_word() {
// Cursor tras `cargo ` → posición de argumento, prefijo vacío.
let c = complete_at("cargo ", 6);
assert_eq!(c.kind, CompletionKind::Path);
assert_eq!((c.replace_start, c.replace_end), (6, 6));
}
#[test]
fn flag_completion_knows_the_command() {
let c = complete_at("cargo --re", 10);
assert_eq!(c.kind, CompletionKind::Flag);
assert_eq!(c.candidates, vec!["--release"]);
}
#[test]
fn cursor_past_end_is_clamped() {
let c = complete_at("gi", 999);
assert_eq!(c.candidates, vec!["git"]);
}
}