shell: add basic tab completion (single-option)

This commit is contained in:
2025-10-17 09:24:54 +03:00
parent a87c8a7ee2
commit ecf1c18240
11 changed files with 367 additions and 160 deletions
+4
View File
@@ -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]]
@@ -268,7 +268,7 @@ fn convert_key_event(raw: yggdrasil_abi::io::KeyboardKeyEvent) -> Option<Keyboar
KeyboardKey::Delete => return None,
KeyboardKey::Unknown => return None,
KeyboardKey::CapsLock => return None,
KeyboardKey::Tab => return None,
KeyboardKey::Tab => Key::Tab,
KeyboardKey::F(_) => return None,
};
+4
View File
@@ -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();
+1
View File
@@ -20,4 +20,5 @@ pub enum Key {
Home,
End,
Backspace,
Tab,
}
+4
View File
@@ -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
+1
View File
@@ -1 +1,2 @@
pub mod readline;
pub mod tar;
+261
View File
@@ -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<String>;
}
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<String> {
vec![]
}
}
impl CompletionState {
fn new() -> Self {
Self {}
}
fn tab<P: ReadlineProvider>(&mut self, buffer: &String, provider: &mut P) -> Option<String> {
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<Option<Outcome>, 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<Outcome, ReadlineError> {
// 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<usize, ReadlineError> {
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<Outcome, ReadlineError> {
// }
pub fn readline<P: ReadlineProvider>(
stdin: &mut TerminalInput,
stdout: &mut Stdout,
buffer: &mut String,
provider: &mut P,
) -> Result<usize, ReadlineError> {
let mut readline = Readline::new(stdin, stdout, buffer, provider);
readline.readline()
}
// pub fn readline<P: Fn(&mut Stdout)>(
// stdin: &mut TerminalInput,
// stdout: &mut Stdout,
// buffer: &mut String,
// prompt: P,
// ) -> Result<usize, ReadlineError> {
// }
+1
View File
@@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
cross.workspace = true
logsink.workspace = true
stuff.workspace = true
clap.workspace = true
thiserror.workspace = true
+4
View File
@@ -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,
}
+86 -18
View File
@@ -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<Vec<String>> {
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<String> {
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<usize, Error> {
pub fn read_line(
&mut self,
readline_provider: &mut ReadlineProviderImpl,
line: &mut String,
continuation: bool,
) -> Result<usize, Error> {
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(());
}
-141
View File
@@ -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<Outcome, Error> {
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<P: Fn(&mut Stdout)>(
stdin: &mut TerminalInput,
stdout: &mut Stdout,
buffer: &mut String,
prompt: P,
) -> Result<usize, Error> {
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();
}
}
}
}