diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..660eb93 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.check.command": "clippy" +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0f18592 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sesh" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5.37", features = ["derive", "env"] } +hostname = "0.4.1" +users = "0.11.0" + +[build-dependencies] +roff = "0.2.2" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0e00e07 --- /dev/null +++ b/build.rs @@ -0,0 +1,32 @@ +use roff::{Roff, bold, italic, roman}; + +fn main() { + let page = Roff::new() + .control("TH", ["SESH", "1"]) + .control("SH", ["NAME"]) + .text([roman("sesh - Semantic Shell")]) + .control("SH", ["SYNOPSIS"]) + .text([ + bold("sesh"), + roman(" [options]"), + ]) + .control("SH", ["DESCRIPTION"]) + .text([ + bold("sesh"), + roman("is a shell designed to be as semantic to use as possible"), + ]) + .control("SH", ["OPTIONS"]) + .control("TP", []) + .text([ + bold("-n"), + roman(", "), + bold("--bits"), + roman("="), + italic("BITS"), + ]) + .text([roman( + "Set the number of bits to modify. Default is one bit.", + )]) + .render(); + print!("{}", page); +} diff --git a/src/builtins.rs b/src/builtins.rs new file mode 100644 index 0000000..998c289 --- /dev/null +++ b/src/builtins.rs @@ -0,0 +1,20 @@ +//! builtins to sesh +#![allow(clippy::type_complexity)] + +/// List of builtins +pub const BUILTINS: [(&str, fn (args: Vec, state: &mut super::State) -> i32); 2] = [("cd", cd), ("exit", exit)]; + +/// Change the directory +pub fn cd(args: Vec, state: &mut super::State) -> i32 { + if args[1] == ".." { + state.working_dir.pop(); + return 0; + } + state.working_dir.push(args[1].clone()); + 0 +} + +/// Exit the shell +pub fn exit(_: Vec, _: &mut super::State) -> i32 { + std::process::exit(0); +} diff --git a/src/escapes.rs b/src/escapes.rs new file mode 100644 index 0000000..fed840c --- /dev/null +++ b/src/escapes.rs @@ -0,0 +1,98 @@ +//! Escape sequences +//! +//! Thanks, stack overflow! +//! +//! (modifications were made) + +use std::fmt::Display; + +/// Escape error +#[derive(Debug, PartialEq)] +pub enum EscapeError { + /// there's an escape at the end of the string + EscapeAtEndOfString, + /// unknown unicode character in a \u escape + InvalidUnicodeChar(char), + /// invalid unicode codepoint + InvalidUnicodeCodepoint(u32) +} + +impl Display for EscapeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EscapeError::EscapeAtEndOfString => { + f.write_str("escape at end of statement") + }, + EscapeError::InvalidUnicodeChar(c) => { + f.write_fmt(format_args!("invalid character in a unicode escape: {}", *c)) + }, + EscapeError::InvalidUnicodeCodepoint(c) => { + f.write_fmt(format_args!("invalid unicode codepoint in escape: {}", *c)) + } + } + } +} + +/// iterator +struct InterpretEscapedString<'a> { + /// chars + s: std::str::Chars<'a>, +} + +impl<'a> Iterator for InterpretEscapedString<'a> { + type Item = Result; + + fn next(&mut self) -> Option { + let mut ret_next = false; + let out = self.s.next().map(|c| match c { + '\\' => match self.s.next() { + None => Err(EscapeError::EscapeAtEndOfString), + Some('n') => Ok('\n'), + Some('t') => Ok('\t'), + Some('\\') => Ok('\\'), + Some('"') => Ok('"'), + Some('\'') => Ok('\''), + Some('e') => Ok('\x1b'), + Some('\n') => { + ret_next = true; + Err(EscapeError::EscapeAtEndOfString) + }, + Some('u') | Some('U') | Some('x') => { + let code = [self.s.next(), self.s.next(), self.s.next(), self.s.next()]; + if code.iter().any(|val| val.is_none()) { + return Err(EscapeError::EscapeAtEndOfString); + } + let code = TryInto::<[char; 4]>::try_into( + code.iter().map(|ch| ch.unwrap().to_ascii_lowercase()).collect::>(), + ) + .unwrap(); + + for c in code { + if !(c.is_numeric() || ['a', 'b', 'c', 'd', 'e', 'f'].contains(&c)) { + return Err(EscapeError::InvalidUnicodeChar(c)) + } + } + + let code = u32::from_str_radix(&String::from_iter(code), 16).unwrap(); + let out = char::from_u32(code); + if out.is_none() { + return Err(EscapeError::InvalidUnicodeCodepoint(code)); + } + Ok(out.unwrap()) + }, + Some(c) => Ok(c), + }, + c => Ok(c), + }); + if ret_next { + self.next() + } else { + out + } + } +} + +/// interpret an escaped string +pub fn interpret_escaped_string(s: &str) -> Result { + (InterpretEscapedString { s: s.chars() }).collect() +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..13a4050 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,215 @@ +//! Semantic Shell + +#![warn(missing_docs, clippy::missing_docs_in_private_items)] +#![feature(cfg_match)] + +use std::{ + ffi::{OsStr, OsString}, + io::Write, + path::PathBuf, + rc::Rc, + sync::Mutex, +}; + +use clap::Parser; + +mod builtins; +mod escapes; + +/// sesh is a shell designed to be as semantic to use as possible +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args {} + +/// A single shell variable +#[derive(Clone, Debug, PartialEq, Eq)] +struct ShellVar { + /// The name of it + name: String, + /// The value of it + value: String, +} +/// A lot of [ShellVar]s. +type ShellVars = Vec; + +/// Whether a variable is local or not. +#[derive(Clone, Debug, PartialEq, Eq)] +enum VariableLocality { + /// A local variable. + Local, + /// A nonlocal variable. + Nonlocal, +} +/// A reference to a variable. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Variable { + /// A local variable. + Local(String), + /// A nonlocal variable. + Nonlocal(OsString), +} + +/// The state of the shell +#[derive(Clone, Debug)] +struct State { + /// Environment variables + env: Rc>, + /// Shell-local variables only accessible via builtins. + shell_env: ShellVars, + /// The focused variable. + focus: Variable, + /// The previous history of the states. + history: Vec, + /// Current working directory. + working_dir: PathBuf, +} + +fn split_statement(statement: &str) -> Vec { + let mut out = vec![String::new()]; + let mut i: usize = 0; + let mut in_str = (false, ' '); + for ch in statement.chars() { + if ['"', '\'', '`'].contains(&ch) { + if in_str.0 && in_str.1 == ch { + in_str.0 = false + } else { + in_str = (true, ch); + } + continue; + } + if !in_str.0 && ch == ' ' { + i += 1; + if i >= out.len() { + out.push(String::new()); + } + continue; + } + out[i].push(ch); + } + out.iter() + .map(|v| v.trim().to_string()) + .collect::>() +} + +/// Evaluate a statement. May include multiple. +fn eval(statement: &str, state: &mut State) { + let statement = escapes::interpret_escaped_string(statement); + if statement.is_err() { + println!("sesh: invalid escape: {}", statement.unwrap_err()); + return; + } + let statements = statement + .unwrap() + .split("\n") + .map(|val| val.split(";").collect::>()) + .collect::>>() + .iter() + .map(|val| val.iter().map(|val| val.trim()).collect::>()) + .collect::>>() + .concat() + .iter() + .map(|val| split_statement(val)) + .collect::>>(); + + for statement in statements { + if statement.is_empty() || statement[0].is_empty() { + continue; + } + if let Some(builtin) = builtins::BUILTINS.iter().find(|v| v.0 == statement[0]) { + let status = builtin.1(statement, state); + for (i, var) in state.shell_env.clone().into_iter().enumerate() { + if var.name == "STATUS" { + state.shell_env.swap_remove(i); + } + } + + state.shell_env.push(ShellVar { + name: "STATUS".to_string(), + value: status.to_string(), + }); + continue; + } + match std::process::Command::new(statement[0].clone()) + .args(&statement[1..]) + .current_dir(state.working_dir.clone()) + .spawn() + { + Ok(mut child) => { + for (i, var) in state.shell_env.clone().into_iter().enumerate() { + if var.name == "STATUS" { + state.shell_env.swap_remove(i); + } + } + + state.shell_env.push(ShellVar { + name: "STATUS".to_string(), + value: child.wait().unwrap().code().unwrap().to_string(), + }); + continue; + } + Err(error) => { + println!("sesh: error spawning program: {}", error); + return; + } + } + } + + state.env = Rc::new(Mutex::new(std::env::vars_os())); + state.history.push(state.clone()); +} + +fn main() -> Result<(), Box> { + let interactive = true; + let mut state = State { + env: Rc::new(Mutex::new(std::env::vars_os())), + shell_env: Vec::new(), + focus: Variable::Local(String::new()), + history: Vec::new(), + working_dir: std::env::current_dir() + .unwrap_or(std::env::home_dir().unwrap_or(PathBuf::from("/"))), + }; + state.shell_env.push(ShellVar { + name: "PROMPT".to_string(), + value: "$u@$h $P> ".to_string(), + }); + loop { + let mut prompt = state + .shell_env + .iter() + .find(|var| var.name == "PROMPT") + .unwrap_or(&ShellVar { + name: "PROMPT".to_string(), + value: String::new(), + }) + .value + .clone(); + prompt = prompt.replace( + "$u", + &users::get_effective_username() + .unwrap_or(users::get_current_username().unwrap_or("?".into())) + .to_string_lossy(), + ); + prompt = prompt.replace( + "$h", + &hostname::get().unwrap_or("?".into()).to_string_lossy(), + ); + + prompt = prompt.replace("$p", &state.working_dir.as_os_str().to_string_lossy()); + prompt = prompt.replace( + "$P", + &state + .working_dir + .file_name() + .unwrap_or(OsStr::new("?")) + .to_string_lossy(), + ); + + print!("{}", prompt); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + eval(&input, &mut state); + } +}