From ecf1c18240346d76dafce7aa6005abdca3bfc716 Mon Sep 17 00:00:00 2001 From: Mark Poliakov Date: Fri, 17 Oct 2025 09:24:54 +0300 Subject: [PATCH] shell: add basic tab completion (single-option) --- userspace/Cargo.lock | 4 + .../graphics/colors/src/sys/yggdrasil.rs | 2 +- userspace/graphics/term/src/main.rs | 4 + userspace/lib/libcolors/src/input.rs | 1 + userspace/lib/stuff/Cargo.toml | 4 + userspace/lib/stuff/src/lib.rs | 1 + userspace/lib/stuff/src/readline.rs | 261 ++++++++++++++++++ userspace/tools/shell/Cargo.toml | 1 + userspace/tools/shell/src/error.rs | 4 + userspace/tools/shell/src/main.rs | 104 +++++-- userspace/tools/shell/src/readline.rs | 141 ---------- 11 files changed, 367 insertions(+), 160 deletions(-) create mode 100644 userspace/lib/stuff/src/readline.rs delete mode 100644 userspace/tools/shell/src/readline.rs diff --git a/userspace/Cargo.lock b/userspace/Cargo.lock index e59a61f4..e0334eee 100644 --- a/userspace/Cargo.lock +++ b/userspace/Cargo.lock @@ -2801,6 +2801,7 @@ dependencies = [ "logsink", "nom", "runtime", + "stuff", "thiserror 1.0.69", ] @@ -2984,6 +2985,9 @@ name = "stuff" version = "0.1.0" dependencies = [ "bytemuck", + "cross", + "log", + "thiserror 1.0.69", ] [[package]] diff --git a/userspace/graphics/colors/src/sys/yggdrasil.rs b/userspace/graphics/colors/src/sys/yggdrasil.rs index cf350400..b5aaa833 100644 --- a/userspace/graphics/colors/src/sys/yggdrasil.rs +++ b/userspace/graphics/colors/src/sys/yggdrasil.rs @@ -268,7 +268,7 @@ fn convert_key_event(raw: yggdrasil_abi::io::KeyboardKeyEvent) -> Option return None, KeyboardKey::Unknown => return None, KeyboardKey::CapsLock => return None, - KeyboardKey::Tab => return None, + KeyboardKey::Tab => Key::Tab, KeyboardKey::F(_) => return None, }; diff --git a/userspace/graphics/term/src/main.rs b/userspace/graphics/term/src/main.rs index dae3f9b0..ef80f40d 100644 --- a/userspace/graphics/term/src/main.rs +++ b/userspace/graphics/term/src/main.rs @@ -308,6 +308,10 @@ impl Terminal { pty_master.write_all(&[termios.erase_char()]).ok(); need_redraw = s.scroll_end(); } + (KeyModifiers::NONE, Key::Tab) => { + pty_master.write_all(b"\t").ok(); + need_redraw = s.scroll_end(); + } (KeyModifiers::CTRL, Key::Char(ch)) if ch.is_ascii_lowercase() => { let byte = ch - 0x60; pty_master.write_all(&[byte]).unwrap(); diff --git a/userspace/lib/libcolors/src/input.rs b/userspace/lib/libcolors/src/input.rs index 9ceef623..aaa9426d 100644 --- a/userspace/lib/libcolors/src/input.rs +++ b/userspace/lib/libcolors/src/input.rs @@ -20,4 +20,5 @@ pub enum Key { Home, End, Backspace, + Tab, } diff --git a/userspace/lib/stuff/Cargo.toml b/userspace/lib/stuff/Cargo.toml index 0c10d932..e2430826 100644 --- a/userspace/lib/stuff/Cargo.toml +++ b/userspace/lib/stuff/Cargo.toml @@ -4,7 +4,11 @@ version = "0.1.0" edition = "2021" [dependencies] +cross.workspace = true + bytemuck.workspace = true +thiserror.workspace = true +log.workspace = true [lints] workspace = true diff --git a/userspace/lib/stuff/src/lib.rs b/userspace/lib/stuff/src/lib.rs index a183a1cb..157fd9bb 100644 --- a/userspace/lib/stuff/src/lib.rs +++ b/userspace/lib/stuff/src/lib.rs @@ -1 +1,2 @@ +pub mod readline; pub mod tar; diff --git a/userspace/lib/stuff/src/readline.rs b/userspace/lib/stuff/src/readline.rs new file mode 100644 index 00000000..46f42165 --- /dev/null +++ b/userspace/lib/stuff/src/readline.rs @@ -0,0 +1,261 @@ +use std::{ + fmt, + io::{self, Stdout, Write}, +}; + +use cross::term::{TermKey, TerminalInput}; + +#[derive(thiserror::Error, Debug)] +pub enum ReadlineError { + #[error("{0}")] + Io(#[from] io::Error), +} + +pub trait ReadlineProvider { + fn prompt(&mut self, stdout: &mut Stdout); + fn clear_screen(&mut self, stdout: &mut Stdout); + fn completions(&mut self, raw_command: &str) -> Vec; +} + +struct CompletionState {} + +pub struct Readline<'a, P: ReadlineProvider = ()> { + stdin: &'a mut TerminalInput, + stdout: &'a mut Stdout, + buffer: &'a mut String, + provider: &'a mut P, + + pos: usize, + + completion: CompletionState, +} + +enum Outcome { + Interrupt, + Clear, + Data(usize), +} + +impl ReadlineProvider for () { + fn prompt(&mut self, stdout: &mut Stdout) { + stdout.write_all(b"# ").ok(); + } + + fn clear_screen(&mut self, stdout: &mut Stdout) { + stdout.write_all(b"\x1B[H\x1B[J").ok(); + } + + fn completions(&mut self, _raw_command: &str) -> Vec { + vec![] + } +} + +impl CompletionState { + fn new() -> Self { + Self {} + } + + fn tab(&mut self, buffer: &String, provider: &mut P) -> Option { + let completions = provider.completions(buffer.as_ref()); + + if completions.len() == 1 { + let completion = &completions[0]; + Some(completion.clone()) + } else { + None + } + } +} + +impl<'a, P: ReadlineProvider> Readline<'a, P> { + fn new( + stdin: &'a mut TerminalInput, + stdout: &'a mut Stdout, + buffer: &'a mut String, + provider: &'a mut P, + ) -> Self { + Self { + pos: buffer.chars().count(), + completion: CompletionState::new(), + + stdin, + stdout, + buffer, + provider, + } + } + + fn accept_completion(&mut self, completion: &str) { + match self.buffer.rsplit_once(' ') { + Some((head, tail)) => { + let new_buffer = format!("{head} {completion}"); + for _ in 0..tail.chars().count() { + self.stdout.write_all(b"\x08").ok(); + } + self.stdout.write_all(completion.as_bytes()).ok(); + self.stdout.flush().ok(); + self.pos = new_buffer.chars().count(); + *self.buffer = new_buffer; + } + None => { + todo!() + } + } + } + + fn handle_char(&mut self, key: TermKey) -> Result, ReadlineError> { + let mut ch_buffer = [0; 8]; + + match key { + TermKey::Eof => { + if self.pos == 0 { + return Ok(Some(Outcome::Data(self.pos))); + } else { + return Ok(None); + } + } + TermKey::Char(ch) => match ch { + // ^C + '\x03' => return Ok(Some(Outcome::Interrupt)), + // ^L + '\x0C' => return Ok(Some(Outcome::Clear)), + // Backspace + '\x7F' => { + // backspace + if self.pos != 0 { + self.stdout.write_all(b"\x08 \x08").ok(); + self.stdout.flush().ok(); + + self.buffer.pop(); + self.pos -= 1; + } + } + // Tab + '\t' => { + // TODO parse the buffer into a command struct and do ops on the command + // or at least check the token/word before current to see if it's a + // separator like ';', '&&' etc + if let Some(completion) = self.completion.tab(&self.buffer, self.provider) { + self.accept_completion(&completion); + } + } + ch if ch.is_whitespace() || ch.is_ascii_graphic() => { + let bytes = ch.encode_utf8(&mut ch_buffer); + self.stdout.write_all(bytes.as_bytes()).ok(); + self.stdout.flush().ok(); + + if ch != '\r' { + self.buffer.push(ch); + self.pos += 1; + } + + if ch == '\r' || ch == '\n' { + if ch == '\r' { + self.buffer.push('\n'); + self.pos += 1; + self.stdout.write_all(b"\n").ok(); + self.stdout.flush().ok(); + } + return Ok(Some(Outcome::Data(self.pos))); + } + } + ch => { + log::warn!("TODO: handle char: {ch:?}"); + } + }, + TermKey::Up => { + log::warn!("TODO: shell history up"); + } + TermKey::Down => { + log::warn!("TODO: shell history down"); + } + TermKey::Left | TermKey::Right => { + log::warn!("TODO: readline cursor"); + } + _ => (), + } + + Ok(None) + } + + fn try_readline(&mut self) -> Result { + // let mut pos = self.buffer.chars().count(); + + self.pos = self.buffer.chars().count(); + self.stdout.write_all(self.buffer.as_bytes()).ok(); + self.stdout.flush().ok(); + + loop { + let key = self.stdin.read_key()?; + match self.handle_char(key)? { + Some(outcome) => break Ok(outcome), + None => (), + } + } + + // loop { + // let key = self.stdin.read_key()?; + + // } + + // Ok(Outcome::Data(pos)) + } + + pub fn readline(&mut self) -> Result { + loop { + self.provider.prompt(self.stdout); + self.stdout.flush().ok(); + + match self.try_readline()? { + Outcome::Data(n) => break Ok(n), + Outcome::Clear => { + self.provider.clear_screen(self.stdout); + } + Outcome::Interrupt => { + self.stdout.write_all(b"\r\n").ok(); + self.buffer.clear(); + } + } + } + // loop { + // prompt(stdout); + // stdout.flush().ok(); + // + // match readline_inner(stdin, stdout, buffer)? { + // Outcome::Data(n) => break Ok(n), + // Outcome::Clear => { + // stdout.write_all(b"\x1B[H\x1B[J").ok(); + // } + // Outcome::Interrupt => { + // stdout.write_all(b"\r\n").ok(); + // buffer.clear(); + // } + // } + // } + } +} + +// fn readline_inner( +// stdin: &mut TerminalInput, +// stdout: &mut Stdout, +// buffer: &mut String, +// ) -> Result { +// } + +pub fn readline( + stdin: &mut TerminalInput, + stdout: &mut Stdout, + buffer: &mut String, + provider: &mut P, +) -> Result { + let mut readline = Readline::new(stdin, stdout, buffer, provider); + readline.readline() +} + +// pub fn readline( +// stdin: &mut TerminalInput, +// stdout: &mut Stdout, +// buffer: &mut String, +// prompt: P, +// ) -> Result { +// } diff --git a/userspace/tools/shell/Cargo.toml b/userspace/tools/shell/Cargo.toml index ad09d235..1faf941f 100644 --- a/userspace/tools/shell/Cargo.toml +++ b/userspace/tools/shell/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] cross.workspace = true logsink.workspace = true +stuff.workspace = true clap.workspace = true thiserror.workspace = true diff --git a/userspace/tools/shell/src/error.rs b/userspace/tools/shell/src/error.rs index cbdbcc27..ffccc865 100644 --- a/userspace/tools/shell/src/error.rs +++ b/userspace/tools/shell/src/error.rs @@ -1,9 +1,13 @@ use std::io; +use stuff::readline; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] Io(#[from] io::Error), + #[error("{0}")] + Readline(#[from] readline::ReadlineError), #[error("")] InvalidUsage, } diff --git a/userspace/tools/shell/src/main.rs b/userspace/tools/shell/src/main.rs index 997e9dcb..6bbb1c0e 100644 --- a/userspace/tools/shell/src/main.rs +++ b/userspace/tools/shell/src/main.rs @@ -10,8 +10,8 @@ #![allow(clippy::new_without_default, clippy::should_implement_trait)] use std::{ - fs::File, - io::{stdout, BufRead, BufReader, Write}, + fs::{self, File}, + io::{self, stdout, BufRead, BufReader, Write}, path::Path, process::ExitCode, }; @@ -24,12 +24,12 @@ use cross::{ }; use error::Error; use exec::Outcome; +use stuff::readline::{self, ReadlineError, ReadlineProvider}; pub mod builtin; pub mod command; pub mod error; pub mod exec; -pub mod readline; pub mod syntax; #[derive(Debug, Parser)] @@ -47,26 +47,93 @@ pub enum ShellInput { Interactive(TerminalInput), } +pub struct ReadlineProviderImpl {} + +fn list_cwd_files() -> io::Result> { + let dir = fs::read_dir(".")?; + let mut names = vec![]; + for entry in dir { + let Ok(entry) = entry else { + continue; + }; + + let filename = entry.file_name(); + let filename = filename.to_str().unwrap(); + if filename.starts_with(".") { + continue; + } + + names.push(filename.into()); + } + + Ok(names) +} + +impl ReadlineProvider for ReadlineProviderImpl { + fn prompt(&mut self, stdout: &mut io::Stdout) { + let cwd = match std::env::current_dir() { + Ok(cwd) => format!("{}", cwd.display()), + Err(_) => "???".into(), + }; + + stdout.write_all(format!("{cwd} $ ").as_bytes()).ok(); + } + + fn clear_screen(&mut self, stdout: &mut io::Stdout) { + stdout.write_all(b"\x1B[H\x1B[J").ok(); + } + + fn completions(&mut self, raw_command: &str) -> Vec { + let (is_command, last_word) = match raw_command.rsplit_once(' ') { + Some((_, last)) => (false, last), + None => (true, raw_command.trim()), + }; + + if is_command { + // Lookup command completion + vec![] + } else { + match list_cwd_files() { + Ok(list) => list + .into_iter() + .filter(|e| e.starts_with(last_word)) + .collect(), + Err(error) => { + log::error!("Completion error: {error}"); + vec![] + } + } + } + } +} + impl ShellInput { - pub fn read_line(&mut self, line: &mut String, continuation: bool) -> Result { + pub fn read_line( + &mut self, + readline_provider: &mut ReadlineProviderImpl, + line: &mut String, + continuation: bool, + ) -> Result { match self { Self::File(file) => Ok(file.read_line(line)?), Self::Interactive(stdin) => { let mut stdout = stdout(); - readline::readline(stdin, &mut stdout, line, |stdout| { - let prompt = if !continuation { - let cwd = std::env::current_dir(); - let cwd = match cwd { - Ok(cwd) => format!("{}", cwd.display()), - Err(_) => "???".into(), - }; - format!("{cwd} $ ") - } else { - "> ".to_owned() - }; - stdout.write_all(prompt.as_bytes()).ok(); - }) + readline::readline(stdin, &mut stdout, line, readline_provider).map_err(Error::from) + // readline::readline(stdin, &mut stdout, line, |stdout| { + // let prompt = if !continuation { + // let cwd = std::env::current_dir(); + // let cwd = match cwd { + // Ok(cwd) => format!("{}", cwd.display()), + // Err(_) => "???".into(), + // }; + // format!("{cwd} $ ") + // } else { + // "> ".to_owned() + // }; + // stdout.write_all(prompt.as_bytes()).ok(); + // }) + // .map_err(Error::from) } } } @@ -109,9 +176,10 @@ fn run_single(_env: &Environment, _command: &str) -> Outcome { fn run(mut input: ShellInput, env: &mut Environment) -> Result<(), Error> { let mut command_text = String::new(); let mut line = String::new(); + let mut readline_provider = ReadlineProviderImpl {}; loop { line.clear(); - let len = input.read_line(&mut line, !command_text.is_empty())?; + let len = input.read_line(&mut readline_provider, &mut line, !command_text.is_empty())?; if len == 0 { break Ok(()); } diff --git a/userspace/tools/shell/src/readline.rs b/userspace/tools/shell/src/readline.rs deleted file mode 100644 index ce863a6f..00000000 --- a/userspace/tools/shell/src/readline.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::io::{Stdout, Write}; - -use cross::term::{TermKey, TerminalInput}; - -use crate::Error; - -enum Outcome { - Interrupt, - Clear, - Data(usize), -} - -struct Readline<'a> { - stdin: &'a mut TerminalInput, - stdout: &'a mut Stdout, - buffer: &'a mut String, -} - -impl<'a> Readline<'a> { - fn new(stdin: &'a mut TerminalInput, stdout: &'a mut Stdout, buffer: &'a mut String) -> Self { - Self { - stdin, - stdout, - buffer, - } - } - - fn handle_char(&mut self, ch: char) {} -} - -fn readline_inner( - stdin: &mut TerminalInput, - stdout: &mut Stdout, - buffer: &mut String, -) -> Result { - let mut ch_buffer = [0; 8]; - let mut pos = buffer.chars().count(); - - stdout.write_all(buffer.as_bytes()).ok(); - stdout.flush().ok(); - - loop { - let key = stdin.read_key()?; - - match key { - TermKey::Eof => { - if pos == 0 { - break; - } else { - continue; - } - } - TermKey::Char(ch) => match ch { - // ^C - '\x03' => return Ok(Outcome::Interrupt), - // ^L - '\x0C' => return Ok(Outcome::Clear), - // Backspace - '\x7F' => { - // backspace - if pos != 0 { - stdout.write_all(b"\x08 \x08").ok(); - stdout.flush().ok(); - - buffer.pop(); - pos -= 1; - } - } - // Tab - '\t' => { - // TODO parse the buffer into a command struct and do ops on the command - // or at least check the token/word before current to see if it's a - // separator like ';', '&&' etc - let (is_command, last_word) = match buffer.rsplit_once(' ') { - Some((_, last)) => (true, last), - None => (false, buffer.trim()), - }; - log::info!("Tab {last_word:?}"); - } - ch if ch.is_whitespace() || ch.is_ascii_graphic() => { - let bytes = ch.encode_utf8(&mut ch_buffer); - stdout.write_all(bytes.as_bytes()).ok(); - stdout.flush().ok(); - - if ch != '\r' { - buffer.push(ch); - pos += 1; - } - - if ch == '\r' || ch == '\n' { - if ch == '\r' { - buffer.push('\n'); - pos += 1; - stdout.write_all(b"\n").ok(); - stdout.flush().ok(); - } - break; - } - } - ch => { - log::warn!("TODO: handle char: {ch:?}"); - } - }, - TermKey::Up => { - log::warn!("TODO: shell history up"); - } - TermKey::Down => { - log::warn!("TODO: shell history down"); - } - TermKey::Left | TermKey::Right => { - log::warn!("TODO: readline cursor"); - } - _ => (), - } - } - - Ok(Outcome::Data(pos)) -} - -pub fn readline( - stdin: &mut TerminalInput, - stdout: &mut Stdout, - buffer: &mut String, - prompt: P, -) -> Result { - loop { - prompt(stdout); - stdout.flush().ok(); - - match readline_inner(stdin, stdout, buffer)? { - Outcome::Data(n) => break Ok(n), - Outcome::Clear => { - stdout.write_all(b"\x1B[H\x1B[J").ok(); - } - Outcome::Interrupt => { - stdout.write_all(b"\r\n").ok(); - buffer.clear(); - } - } - } -}