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:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "shuma-line"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "shuma — el cerebro del input del shell: analiza la línea de comandos (bash), la clasifica para resaltado, autocompleta y separa los pipes. Agnóstico de GUI/TUI."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! El dialecto de la línea — qué sintaxis se analiza.
|
||||
//!
|
||||
//! Hoy sólo Bash. El tipo existe para que el shell pueda, más adelante,
|
||||
//! conmutar a zsh/fish/python sin que los consumidores cambien: el
|
||||
//! analizador despacha sobre el `Dialect` y cada nuevo dialecto entra
|
||||
//! con su propio lexer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Sintaxis con la que se interpreta la línea de comandos.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum Dialect {
|
||||
/// Shell Bourne-again — el dialecto inicial.
|
||||
#[default]
|
||||
Bash,
|
||||
}
|
||||
|
||||
impl Dialect {
|
||||
/// Nombre legible del dialecto.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Dialect::Bash => "bash",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! `LineState` — el estado editable del input del shell.
|
||||
//!
|
||||
//! Mantiene el texto y la posición del cursor (offset de byte, siempre
|
||||
//! en un límite de carácter) y expone las operaciones de edición. Es
|
||||
//! agnóstico: un frontend GPUI o TUI sólo traduce sus eventos de teclado
|
||||
//! a estas llamadas y luego pinta [`LineState::tokens`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::complete::{complete, Completion, CompletionSource};
|
||||
use crate::dialect::Dialect;
|
||||
use crate::lexer::tokenize;
|
||||
use crate::pipeline::{split_pipeline, Pipeline};
|
||||
use crate::token::Token;
|
||||
|
||||
/// El input del shell: texto + cursor + dialecto.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LineState {
|
||||
text: String,
|
||||
/// Offset de byte del cursor; invariante: siempre en límite de carácter.
|
||||
cursor: usize,
|
||||
dialect: Dialect,
|
||||
}
|
||||
|
||||
impl LineState {
|
||||
/// Línea vacía con el dialecto por defecto (bash).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Texto actual.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Posición del cursor en bytes.
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Dialecto activo.
|
||||
pub fn dialect(&self) -> Dialect {
|
||||
self.dialect
|
||||
}
|
||||
|
||||
/// Cambia el dialecto (bash hoy; zsh/fish/python a futuro).
|
||||
pub fn set_dialect(&mut self, dialect: Dialect) {
|
||||
self.dialect = dialect;
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
/// Reemplaza toda la línea y deja el cursor al final.
|
||||
pub fn set_text(&mut self, text: impl Into<String>) {
|
||||
self.text = text.into();
|
||||
self.cursor = self.text.len();
|
||||
}
|
||||
|
||||
/// Vacía la línea.
|
||||
pub fn clear(&mut self) {
|
||||
self.text.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Inserta texto en el cursor y lo avanza.
|
||||
pub fn insert(&mut self, s: &str) {
|
||||
self.text.insert_str(self.cursor, s);
|
||||
self.cursor += s.len();
|
||||
}
|
||||
|
||||
/// Inserta un carácter en el cursor.
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
let mut buf = [0u8; 4];
|
||||
self.insert(c.encode_utf8(&mut buf));
|
||||
}
|
||||
|
||||
/// Borra el carácter a la izquierda del cursor.
|
||||
pub fn backspace(&mut self) {
|
||||
if let Some(prev) = self.text[..self.cursor].chars().next_back() {
|
||||
let bl = prev.len_utf8();
|
||||
self.text.replace_range(self.cursor - bl..self.cursor, "");
|
||||
self.cursor -= bl;
|
||||
}
|
||||
}
|
||||
|
||||
/// Borra el carácter a la derecha del cursor.
|
||||
pub fn delete(&mut self) {
|
||||
if let Some(next) = self.text[self.cursor..].chars().next() {
|
||||
let nl = next.len_utf8();
|
||||
self.text.replace_range(self.cursor..self.cursor + nl, "");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mueve el cursor un carácter a la izquierda.
|
||||
pub fn move_left(&mut self) {
|
||||
if let Some(prev) = self.text[..self.cursor].chars().next_back() {
|
||||
self.cursor -= prev.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mueve el cursor un carácter a la derecha.
|
||||
pub fn move_right(&mut self) {
|
||||
if let Some(next) = self.text[self.cursor..].chars().next() {
|
||||
self.cursor += next.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lleva el cursor al inicio.
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Lleva el cursor al final.
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.text.len();
|
||||
}
|
||||
|
||||
/// Mueve el cursor al inicio de la palabra anterior.
|
||||
pub fn move_word_left(&mut self) {
|
||||
let mut c = self.cursor;
|
||||
let prev = |c: usize, t: &str| t[..c].chars().next_back();
|
||||
// Salta el espacio en blanco, luego la palabra.
|
||||
while let Some(ch) = prev(c, &self.text) {
|
||||
if ch.is_whitespace() {
|
||||
c -= ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(ch) = prev(c, &self.text) {
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
c -= ch.len_utf8();
|
||||
}
|
||||
self.cursor = c;
|
||||
}
|
||||
|
||||
/// Mueve el cursor al final de la palabra siguiente.
|
||||
pub fn move_word_right(&mut self) {
|
||||
let mut c = self.cursor;
|
||||
let next = |c: usize, t: &str| t[c..].chars().next();
|
||||
while let Some(ch) = next(c, &self.text) {
|
||||
if ch.is_whitespace() {
|
||||
c += ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(ch) = next(c, &self.text) {
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
c += ch.len_utf8();
|
||||
}
|
||||
self.cursor = c;
|
||||
}
|
||||
|
||||
/// Análisis de la línea: los tokens clasificados, listos para pintar.
|
||||
pub fn tokens(&self) -> Vec<Token> {
|
||||
tokenize(&self.text, self.dialect)
|
||||
}
|
||||
|
||||
/// La línea descompuesta en etapas de pipeline.
|
||||
pub fn pipeline(&self) -> Pipeline {
|
||||
split_pipeline(&self.tokens())
|
||||
}
|
||||
|
||||
/// Autocompletado en la posición actual del cursor.
|
||||
pub fn complete(&self, source: &dyn CompletionSource) -> Completion {
|
||||
complete(&self.text, self.cursor, self.dialect, source)
|
||||
}
|
||||
|
||||
/// Aplica un candidato de autocompletado: reemplaza el rango que
|
||||
/// indicó la [`Completion`] y deja el cursor tras lo insertado.
|
||||
pub fn apply_completion(&mut self, completion: &Completion, candidate: &str) {
|
||||
let (s, e) = (completion.replace_start, completion.replace_end);
|
||||
if s <= e && e <= self.text.len() {
|
||||
self.text.replace_range(s..e, candidate);
|
||||
self.cursor = s + candidate.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::complete::StaticSource;
|
||||
use crate::token::TokenKind;
|
||||
|
||||
#[test]
|
||||
fn insert_advances_the_cursor() {
|
||||
let mut l = LineState::new();
|
||||
l.insert("ls -la");
|
||||
assert_eq!(l.text(), "ls -la");
|
||||
assert_eq!(l.cursor(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_the_char_before_cursor() {
|
||||
let mut l = LineState::new();
|
||||
l.insert("abc");
|
||||
l.backspace();
|
||||
assert_eq!(l.text(), "ab");
|
||||
assert_eq!(l.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_is_utf8_safe() {
|
||||
let mut l = LineState::new();
|
||||
l.insert("café");
|
||||
l.backspace(); // quita la 'é' (2 bytes)
|
||||
assert_eq!(l.text(), "caf");
|
||||
l.insert_char('é');
|
||||
l.move_left();
|
||||
l.move_left();
|
||||
assert_eq!(l.cursor(), 2); // entre 'a' y 'f'
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_char_at_cursor() {
|
||||
let mut l = LineState::new();
|
||||
l.set_text("hola");
|
||||
l.move_home();
|
||||
l.delete();
|
||||
assert_eq!(l.text(), "ola");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_motions_jump_between_words() {
|
||||
let mut l = LineState::new();
|
||||
l.set_text("git commit now");
|
||||
l.move_word_left();
|
||||
assert_eq!(&l.text()[l.cursor()..], "now");
|
||||
l.move_word_left();
|
||||
assert_eq!(&l.text()[l.cursor()..], "commit now");
|
||||
l.move_word_right();
|
||||
assert_eq!(l.cursor(), "git commit".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_reflect_the_current_text() {
|
||||
let mut l = LineState::new();
|
||||
l.set_text("cat f | grep x");
|
||||
let cmds: Vec<_> = l
|
||||
.tokens()
|
||||
.into_iter()
|
||||
.filter(|t| t.kind == TokenKind::Command)
|
||||
.map(|t| t.text)
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["cat", "grep"]);
|
||||
assert!(l.pipeline().is_piped());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_completion_replaces_the_prefix() {
|
||||
let mut l = LineState::new();
|
||||
l.insert("ca");
|
||||
let source = StaticSource { commands: vec!["cargo".into()], paths: vec![] };
|
||||
let c = l.complete(&source);
|
||||
l.apply_completion(&c, "cargo");
|
||||
assert_eq!(l.text(), "cargo");
|
||||
assert_eq!(l.cursor(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_after_text_keeps_the_rest() {
|
||||
let mut l = LineState::new();
|
||||
l.set_text("ls /home");
|
||||
// Cursor tras "ls".
|
||||
l.move_home();
|
||||
l.move_right();
|
||||
l.move_right();
|
||||
let source = StaticSource { commands: vec!["lsblk".into()], paths: vec![] };
|
||||
let c = l.complete(&source);
|
||||
l.apply_completion(&c, "lsblk");
|
||||
assert_eq!(l.text(), "lsblk /home");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! El lexer — convierte una línea de texto en tokens clasificados.
|
||||
//!
|
||||
//! Dos pasadas: un escaneo léxico que reconoce comillas, variables,
|
||||
//! tuberías, redirecciones, operadores y palabras; y una pasada de
|
||||
//! clasificación que distingue el *comando* (la primera palabra de cada
|
||||
//! etapa) de sus *argumentos*.
|
||||
|
||||
use crate::dialect::Dialect;
|
||||
use crate::token::{Token, TokenKind};
|
||||
|
||||
/// Analiza `input` según `dialect` y devuelve los tokens, contiguos y
|
||||
/// clasificados, cubriendo toda la línea.
|
||||
pub fn tokenize(input: &str, dialect: Dialect) -> Vec<Token> {
|
||||
let raw = match dialect {
|
||||
Dialect::Bash => scan_bash(input),
|
||||
};
|
||||
classify(raw)
|
||||
}
|
||||
|
||||
/// `true` si `c` corta una palabra suelta.
|
||||
fn is_word_break(c: char) -> bool {
|
||||
c.is_whitespace() || matches!(c, '|' | '&' | ';' | '<' | '>' | '"' | '\'' | '$')
|
||||
}
|
||||
|
||||
/// Detecta una redirección a partir de `p`: un dígito opcional, luego
|
||||
/// `>`/`<`, y un segundo `>`/`<` opcional (`>>`, `<<`). Devuelve la
|
||||
/// posición final, o `None`.
|
||||
fn try_redirect(chars: &[(usize, char)], p: usize) -> Option<usize> {
|
||||
let n = chars.len();
|
||||
let mut q = p;
|
||||
if q < n && chars[q].1.is_ascii_digit() {
|
||||
q += 1;
|
||||
}
|
||||
if q < n && (chars[q].1 == '>' || chars[q].1 == '<') {
|
||||
let r = chars[q].1;
|
||||
q += 1;
|
||||
if q < n && chars[q].1 == r {
|
||||
q += 1;
|
||||
}
|
||||
Some(q)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Escaneo léxico de Bash.
|
||||
fn scan_bash(input: &str) -> Vec<Token> {
|
||||
let chars: Vec<(usize, char)> = input.char_indices().collect();
|
||||
let n = chars.len();
|
||||
let byte_at = |p: usize| if p < n { chars[p].0 } else { input.len() };
|
||||
let mut tokens: Vec<Token> = Vec::new();
|
||||
let mut push = |tokens: &mut Vec<Token>, kind: TokenKind, sp: usize, ep: usize| {
|
||||
let (sb, eb) = (byte_at(sp), byte_at(ep));
|
||||
tokens.push(Token::new(kind, sb, eb, &input[sb..eb]));
|
||||
};
|
||||
|
||||
let mut p = 0;
|
||||
while p < n {
|
||||
let c = chars[p].1;
|
||||
|
||||
// Espacio en blanco.
|
||||
if c.is_whitespace() {
|
||||
let mut q = p;
|
||||
while q < n && chars[q].1.is_whitespace() {
|
||||
q += 1;
|
||||
}
|
||||
push(&mut tokens, TokenKind::Whitespace, p, q);
|
||||
p = q;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comentario hasta fin de línea.
|
||||
if c == '#' {
|
||||
let mut q = p;
|
||||
while q < n && chars[q].1 != '\n' {
|
||||
q += 1;
|
||||
}
|
||||
push(&mut tokens, TokenKind::Comment, p, q);
|
||||
p = q;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cadena entre comillas simples — literal.
|
||||
if c == '\'' {
|
||||
let mut q = p + 1;
|
||||
while q < n && chars[q].1 != '\'' {
|
||||
q += 1;
|
||||
}
|
||||
if q < n {
|
||||
q += 1; // incluye la comilla de cierre
|
||||
}
|
||||
push(&mut tokens, TokenKind::StringLit, p, q);
|
||||
p = q;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cadena entre comillas dobles — admite `\"`.
|
||||
if c == '"' {
|
||||
let mut q = p + 1;
|
||||
while q < n {
|
||||
if chars[q].1 == '\\' && q + 1 < n {
|
||||
q += 2;
|
||||
continue;
|
||||
}
|
||||
if chars[q].1 == '"' {
|
||||
break;
|
||||
}
|
||||
q += 1;
|
||||
}
|
||||
if q < n {
|
||||
q += 1;
|
||||
}
|
||||
push(&mut tokens, TokenKind::StringLit, p, q);
|
||||
p = q;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Variable / sustitución.
|
||||
if c == '$' {
|
||||
let mut q = p + 1;
|
||||
if q < n && chars[q].1 == '{' {
|
||||
while q < n && chars[q].1 != '}' {
|
||||
q += 1;
|
||||
}
|
||||
if q < n {
|
||||
q += 1;
|
||||
}
|
||||
} else if q < n && chars[q].1 == '(' {
|
||||
let mut depth = 0;
|
||||
while q < n {
|
||||
match chars[q].1 {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
q += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
q += 1;
|
||||
}
|
||||
} else {
|
||||
while q < n && (chars[q].1.is_alphanumeric() || chars[q].1 == '_') {
|
||||
q += 1;
|
||||
}
|
||||
}
|
||||
push(&mut tokens, TokenKind::Variable, p, q);
|
||||
p = q;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tubería vs. OR lógico.
|
||||
if c == '|' {
|
||||
if p + 1 < n && chars[p + 1].1 == '|' {
|
||||
push(&mut tokens, TokenKind::Operator, p, p + 2);
|
||||
p += 2;
|
||||
} else {
|
||||
push(&mut tokens, TokenKind::Pipe, p, p + 1);
|
||||
p += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `&&`, `&>`, `&`.
|
||||
if c == '&' {
|
||||
if p + 1 < n && chars[p + 1].1 == '&' {
|
||||
push(&mut tokens, TokenKind::Operator, p, p + 2);
|
||||
p += 2;
|
||||
} else if p + 1 < n && chars[p + 1].1 == '>' {
|
||||
push(&mut tokens, TokenKind::Redirect, p, p + 2);
|
||||
p += 2;
|
||||
} else {
|
||||
push(&mut tokens, TokenKind::Operator, p, p + 1);
|
||||
p += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Separador de comandos.
|
||||
if c == ';' {
|
||||
push(&mut tokens, TokenKind::Operator, p, p + 1);
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Redirección (con dígito de descriptor opcional).
|
||||
if let Some(q) = try_redirect(&chars, p) {
|
||||
push(&mut tokens, TokenKind::Redirect, p, q);
|
||||
p = q;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Palabra suelta — argumento o flag.
|
||||
let mut q = p;
|
||||
while q < n && !is_word_break(chars[q].1) {
|
||||
q += 1;
|
||||
}
|
||||
if q == p {
|
||||
// Carácter aislado no reconocido: no estancar el bucle.
|
||||
push(&mut tokens, TokenKind::Unknown, p, p + 1);
|
||||
p += 1;
|
||||
} else {
|
||||
let kind = if chars[p].1 == '-' {
|
||||
TokenKind::Flag
|
||||
} else {
|
||||
TokenKind::Argument
|
||||
};
|
||||
push(&mut tokens, kind, p, q);
|
||||
p = q;
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Segunda pasada: la primera palabra de cada etapa es el comando.
|
||||
fn classify(mut tokens: Vec<Token>) -> Vec<Token> {
|
||||
let mut expect_command = true;
|
||||
for t in &mut tokens {
|
||||
match t.kind {
|
||||
TokenKind::Whitespace | TokenKind::Comment | TokenKind::Redirect => {}
|
||||
TokenKind::Pipe | TokenKind::Operator => expect_command = true,
|
||||
TokenKind::Argument => {
|
||||
if expect_command {
|
||||
t.kind = TokenKind::Command;
|
||||
}
|
||||
expect_command = false;
|
||||
}
|
||||
_ => expect_command = false,
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn kinds(input: &str) -> Vec<TokenKind> {
|
||||
tokenize(input, Dialect::Bash)
|
||||
.into_iter()
|
||||
.filter(|t| t.kind != TokenKind::Whitespace)
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_cover_the_whole_line() {
|
||||
let input = "ls -la /home";
|
||||
let toks = tokenize(input, Dialect::Bash);
|
||||
assert_eq!(toks.first().unwrap().start, 0);
|
||||
assert_eq!(toks.last().unwrap().end, input.len());
|
||||
for w in toks.windows(2) {
|
||||
assert_eq!(w[0].end, w[1].start, "los tokens son contiguos");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_word_is_the_command() {
|
||||
assert_eq!(
|
||||
kinds("ls -la /home"),
|
||||
vec![TokenKind::Command, TokenKind::Flag, TokenKind::Argument]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_after_pipe_is_a_command_again() {
|
||||
let k = kinds("cat file | grep error");
|
||||
assert_eq!(
|
||||
k,
|
||||
vec![
|
||||
TokenKind::Command,
|
||||
TokenKind::Argument,
|
||||
TokenKind::Pipe,
|
||||
TokenKind::Command,
|
||||
TokenKind::Argument,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_reset_the_command_position() {
|
||||
let k = kinds("make && ./run ; echo done");
|
||||
assert_eq!(k[0], TokenKind::Command); // make
|
||||
assert_eq!(k[2], TokenKind::Command); // ./run, tras &&
|
||||
assert_eq!(k[4], TokenKind::Command); // echo, tras ;
|
||||
assert_eq!(k[5], TokenKind::Argument); // done
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_are_single_string_tokens() {
|
||||
assert_eq!(
|
||||
kinds("echo \"hola mundo\" 'literal'"),
|
||||
vec![TokenKind::Command, TokenKind::StringLit, TokenKind::StringLit]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_are_recognized() {
|
||||
assert_eq!(
|
||||
kinds("echo $HOME ${PATH} $(date)"),
|
||||
vec![
|
||||
TokenKind::Command,
|
||||
TokenKind::Variable,
|
||||
TokenKind::Variable,
|
||||
TokenKind::Variable,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirects_with_descriptors() {
|
||||
let k = kinds("cmd 2> err.log >> out.log");
|
||||
assert_eq!(k[1], TokenKind::Redirect);
|
||||
assert_eq!(k[3], TokenKind::Redirect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_distinct_from_logical_or() {
|
||||
assert_eq!(kinds("a | b")[1], TokenKind::Pipe);
|
||||
assert_eq!(kinds("a || b")[1], TokenKind::Operator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_runs_to_end_of_line() {
|
||||
let k = kinds("ls # esto es un comentario");
|
||||
assert_eq!(k, vec![TokenKind::Command, TokenKind::Comment]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_unicode_without_panicking() {
|
||||
let toks = tokenize("echo 'añoño café' ☕", Dialect::Bash);
|
||||
assert_eq!(toks.last().unwrap().end, "echo 'añoño café' ☕".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_line_yields_no_tokens() {
|
||||
assert!(tokenize("", Dialect::Bash).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! `shuma-line` — el cerebro del input del shell.
|
||||
//!
|
||||
//! La función principal del shell es su línea de comandos, y esta línea
|
||||
//! no es un campo de texto tonto: analiza lo que se escribe para
|
||||
//! resaltarlo, autocompletarlo y entender sus tuberías. Toda esa
|
||||
//! inteligencia vive aquí, **agnóstica de frontend** — la usa igual el
|
||||
//! shell GPUI de brahman que una versión TUI.
|
||||
//!
|
||||
//! - [`dialect`] — el [`Dialect`] de la línea (bash hoy; zsh/fish/python
|
||||
//! a futuro, conmutable).
|
||||
//! - [`token`] — el [`Token`] y su [`TokenKind`] (la clase de resaltado).
|
||||
//! - [`lexer`] — [`tokenize`]: el análisis léxico + clasificación.
|
||||
//! - [`pipeline`] — [`split_pipeline`]: la línea descompuesta en etapas
|
||||
//! separadas por `|`.
|
||||
//! - [`complete`] — el motor de autocompletado y su [`CompletionSource`].
|
||||
//! - [`editor`] — [`LineState`], el estado editable del input.
|
||||
//!
|
||||
//! Un frontend traduce sus eventos de teclado a métodos de `LineState` y
|
||||
//! pinta `LineState::tokens()` con un color por `TokenKind`. Nada más.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod complete;
|
||||
pub mod dialect;
|
||||
pub mod editor;
|
||||
pub mod lexer;
|
||||
pub mod pipeline;
|
||||
pub mod token;
|
||||
|
||||
pub use complete::{complete, Completion, CompletionKind, CompletionSource, StaticSource};
|
||||
pub use dialect::Dialect;
|
||||
pub use editor::LineState;
|
||||
pub use lexer::tokenize;
|
||||
pub use pipeline::{split_pipeline, Pipeline, Stage};
|
||||
pub use token::{Token, TokenKind};
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Pipeline — la línea descompuesta en sus etapas separadas por `|`.
|
||||
//!
|
||||
//! Procesar los pipes es el primer paso para que el shell sea inteligente
|
||||
//! con la línea: saber cuántas etapas hay, cuál es el comando de cada
|
||||
//! una y qué argumentos lleva.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::token::{Token, TokenKind};
|
||||
|
||||
/// Una etapa del pipeline — un comando y sus argumentos.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Stage {
|
||||
/// Nombre del comando, si la etapa lo tiene.
|
||||
pub command: Option<String>,
|
||||
/// Argumentos y flags, en orden de aparición.
|
||||
pub args: Vec<String>,
|
||||
/// Todos los tokens de la etapa (sin la `|` que la separa).
|
||||
pub tokens: Vec<Token>,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
fn from_tokens(tokens: Vec<Token>) -> Self {
|
||||
let mut command = None;
|
||||
let mut args = Vec::new();
|
||||
for t in &tokens {
|
||||
match t.kind {
|
||||
TokenKind::Command => command = Some(t.text.clone()),
|
||||
TokenKind::Argument | TokenKind::Flag => args.push(t.text.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Self { command, args, tokens }
|
||||
}
|
||||
}
|
||||
|
||||
/// La línea completa descompuesta en etapas de pipeline.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Pipeline {
|
||||
pub stages: Vec<Stage>,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Cantidad de etapas.
|
||||
pub fn len(&self) -> usize {
|
||||
self.stages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.stages.is_empty()
|
||||
}
|
||||
|
||||
/// `true` si la línea encadena dos o más comandos por `|`.
|
||||
pub fn is_piped(&self) -> bool {
|
||||
self.stages.len() > 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Descompone los tokens clasificados en etapas separadas por `|`.
|
||||
/// El espacio en blanco a los lados se conserva dentro de cada etapa;
|
||||
/// una etapa vacía (p. ej. la línea termina en `|`) también cuenta.
|
||||
pub fn split_pipeline(tokens: &[Token]) -> Pipeline {
|
||||
if tokens.is_empty() {
|
||||
return Pipeline::default();
|
||||
}
|
||||
let mut stages = Vec::new();
|
||||
let mut current: Vec<Token> = Vec::new();
|
||||
for t in tokens {
|
||||
if t.kind == TokenKind::Pipe {
|
||||
stages.push(Stage::from_tokens(std::mem::take(&mut current)));
|
||||
} else {
|
||||
current.push(t.clone());
|
||||
}
|
||||
}
|
||||
stages.push(Stage::from_tokens(current));
|
||||
Pipeline { stages }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dialect::Dialect;
|
||||
use crate::lexer::tokenize;
|
||||
|
||||
fn pipeline(line: &str) -> Pipeline {
|
||||
split_pipeline(&tokenize(line, Dialect::Bash))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_command_is_one_stage() {
|
||||
let p = pipeline("ls -la");
|
||||
assert_eq!(p.len(), 1);
|
||||
assert!(!p.is_piped());
|
||||
assert_eq!(p.stages[0].command.as_deref(), Some("ls"));
|
||||
assert_eq!(p.stages[0].args, vec!["-la"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_creates_two_stages() {
|
||||
let p = pipeline("cat data.json | grep error");
|
||||
assert_eq!(p.len(), 2);
|
||||
assert!(p.is_piped());
|
||||
assert_eq!(p.stages[0].command.as_deref(), Some("cat"));
|
||||
assert_eq!(p.stages[1].command.as_deref(), Some("grep"));
|
||||
assert_eq!(p.stages[1].args, vec!["error"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_stage_pipeline() {
|
||||
let p = pipeline("cat f | sort | uniq -c");
|
||||
assert_eq!(p.len(), 3);
|
||||
assert_eq!(p.stages[2].command.as_deref(), Some("uniq"));
|
||||
assert_eq!(p.stages[2].args, vec!["-c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_pipe_leaves_an_empty_stage() {
|
||||
let p = pipeline("ls |");
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!(p.stages[1].command, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_line_has_no_stages() {
|
||||
assert!(pipeline("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Tokens — los fragmentos clasificados de una línea de comandos.
|
||||
//!
|
||||
//! El análisis recubre la línea entera: los tokens son contiguos (cada
|
||||
//! byte cae en exactamente uno, incluido el espacio en blanco). Así un
|
||||
//! frontend —GPUI o TUI— sólo recorre los tokens y pinta cada uno con el
|
||||
//! color de su [`TokenKind`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Clase de un token — y, a la vez, su clase de resaltado.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TokenKind {
|
||||
/// El nombre del programa a ejecutar (primera palabra de una etapa).
|
||||
Command,
|
||||
/// Un argumento simple.
|
||||
Argument,
|
||||
/// Una opción — empieza con `-` o `--`.
|
||||
Flag,
|
||||
/// Una cadena entre comillas (`"..."` o `'...'`).
|
||||
StringLit,
|
||||
/// Una expansión de variable o sustitución (`$VAR`, `${VAR}`, `$(...)`).
|
||||
Variable,
|
||||
/// El operador de tubería `|`.
|
||||
Pipe,
|
||||
/// Una redirección (`>`, `>>`, `<`, `2>`, `&>`).
|
||||
Redirect,
|
||||
/// Un operador de secuencia o lógico (`&&`, `||`, `;`, `&`).
|
||||
Operator,
|
||||
/// Un comentario (`# ...`).
|
||||
Comment,
|
||||
/// Espacio en blanco.
|
||||
Whitespace,
|
||||
/// Algo que el lexer no supo clasificar.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TokenKind {
|
||||
/// `true` si el token lleva contenido del usuario (no es separador).
|
||||
pub fn is_content(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TokenKind::Command
|
||||
| TokenKind::Argument
|
||||
| TokenKind::Flag
|
||||
| TokenKind::StringLit
|
||||
| TokenKind::Variable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Un fragmento clasificado de la línea, con su rango en bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Token {
|
||||
pub kind: TokenKind,
|
||||
/// Offset de byte donde empieza (inclusivo).
|
||||
pub start: usize,
|
||||
/// Offset de byte donde termina (exclusivo).
|
||||
pub end: usize,
|
||||
/// El texto del token.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
pub(crate) fn new(kind: TokenKind, start: usize, end: usize, text: &str) -> Self {
|
||||
Self { kind, start, end, text: text.to_string() }
|
||||
}
|
||||
|
||||
/// Largo en bytes.
|
||||
pub fn len(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user