use crossterm::{cursor, execute, terminal}; use eyre::{Result, bail}; use regex::Regex; use rustyline::DefaultEditor; use rustyline::error::ReadlineError; use std::collections::{LinkedList, VecDeque}; use std::io; use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; use std::thread::spawn; const STDOUT_BUFFER_SIZE: usize = 1024; const STDERR_BUFFER_SIZE: usize = 1024; fn main() -> Result<()> { color_eyre::install()?; let mut line_editor = DefaultEditor::new()?; let mut prompt_spec = "$p$g"; loop { let cwd = std::env::current_dir()?; let full_cwd = format_path(&cwd); let mut interpolated_prompt = prompt_spec.replace("$p", &full_cwd).replace("$g", ">"); interpolated_prompt.push_str(" "); let line = match line_editor.readline(&interpolated_prompt) { Ok(line) => line, Err(ReadlineError::Eof) => break, Err(err) => return Err(err.into()), }; let Ok(command) = Command::parse(&line) else { eprintln!("unrecognized command: {}", line); continue; }; line_editor.add_history_entry(&line)?; let Ok(CommandReceivers { stdout, stderr, status, }) = command.run() else { eprintln!("unimplemented command: {}", line); continue; }; // print stdout, stderr interleaved until status produces something let stdout_writer = spawn(move || { loop { let Ok(bytes) = stdout.recv() else { break; }; let mut out = io::stderr(); out.write_all(bytes.as_slice()) .expect("stdout write failed"); out.flush().expect("stdout flush failed"); } }); let stderr_writer = spawn(move || { loop { let Ok(bytes) = stderr.recv() else { break; }; let mut out = io::stderr(); out.write_all(bytes.as_slice()) .expect("stderr write failed"); out.flush().expect("stdout flush failed"); } }); stdout_writer.join().expect("stdout writer thread failed"); stderr_writer.join().expect("stderr writer thread failed"); let command_result = status .recv() .expect("failed to receive exit status from command"); match command_result { CommandStatus::ExitShell => break, CommandStatus::Code(_) => {} } } Ok(()) } /// Turns "/home/mulk/foo/longfilenames/excellent.text" into "C:\\HOME\\MULK\\FOO\\LONGFILE~1\\EXCELL~1.TEX" fn format_path(p: &Path) -> String { use std::path::Component::*; let mut prefix: Option = None; let mut components = Vec::new(); for component in p.components() { match component { Normal(c) => { let s = c.to_string_lossy().to_uppercase(); let s_parts: Vec<&str> = s.splitn(2, '.').collect(); let mut name: String; let mut ext: Option; if s_parts.len() == 1 { name = s.clone(); ext = None; } else { name = s_parts[0].into(); ext = Some(s_parts[1].into()); } let mut result = String::new(); if name.len() > 8 || ext.as_ref().map_or(false, |e| e.len() > 3) { name = name.chars().take(6).collect::(); name.push_str("~1"); } result.push_str(&name.to_uppercase()); if let Some(mut e) = ext { if e.len() > 3 { e = e.chars().take(3).collect::(); } result.push_str("."); result.push_str(&e.to_uppercase()); } components.push(result); } Prefix(c) => prefix = Some(component.as_os_str().to_string_lossy().to_uppercase()), RootDir => { if prefix.is_none() { prefix = Some("C:".into()) } components.push("".into()); } CurDir => components.push(".".into()), ParentDir => components.push("..".into()), } } let mut result = String::new(); if let Some(p) = prefix { result.push_str(&p) }; result.push_str(&components.join("\\")); result } #[derive(Debug)] enum FileSpec { Con, Lpt1, Lpt2, Lpt3, Prn, Path(PathBuf), } #[derive(Debug)] enum RedirectType { OutputOverwrite, // > OutputAppend, // >> InputFrom, // < } #[derive(Debug)] struct Redirect { redirect_type: RedirectType, target: FileSpec, } #[derive(Debug)] enum Command { Pipe { left: Box, right: Box, }, Redirect { command: Box, redirects: Vec, }, External { program: String, args: Vec, }, Builtin(BuiltinCommand), Empty, } #[derive(Debug)] enum CommandStatus { Code(u16), ExitShell, } struct CommandReceivers { stdout: Receiver>, stderr: Receiver>, status: Receiver, } struct CommandSenders { stdout: SyncSender>, stderr: SyncSender>, status: SyncSender, } struct CommandContext { senders: CommandSenders, receivers: CommandReceivers, } impl CommandContext { fn new() -> Self { let (sout, rout) = sync_channel(STDOUT_BUFFER_SIZE); let (serr, rerr) = sync_channel(STDERR_BUFFER_SIZE); let (sexit, rexit) = sync_channel(1); Self { senders: CommandSenders { stdout: sout, stderr: serr, status: sexit, }, receivers: CommandReceivers { stdout: rout, stderr: rerr, status: rexit, }, } } fn split(self) -> (CommandSenders, CommandReceivers) { (self.senders, self.receivers) } } impl Command { pub(crate) fn run(&self) -> Result { use crate::BuiltinCommand::*; use crate::Command::*; use crate::CommandStatus::*; let (senders, receivers) = CommandContext::new().split(); let CommandSenders { stdout, stderr, status: status, } = senders; match self { Empty => { status.send(Code(0))?; } Builtin(EchoText { message }) => { stdout.send(message.bytes().collect())?; stdout.send(b"\n".into())?; status.send(Code(0))?; } Builtin(Cls) => { let mut stdout_handle = io::stdout(); execute!(stdout_handle, terminal::Clear(terminal::ClearType::All))?; execute!(stdout_handle, cursor::MoveTo(0, 0))?; status.send(Code(0))?; } Builtin(Exit) => { status.send(ExitShell)?; } _ => bail!("Command::run not implemented for {:?}", self), } Ok(receivers) } } #[derive(Debug)] enum BuiltinCommand { // File-oriented Copy { from: FileSpec, to: FileSpec, }, Deltree { path: PathBuf, }, Dir { path: PathBuf, }, Fc, Find, Mkdir { path: PathBuf, }, Move, Remove { path: PathBuf, }, Rename { from: FileSpec, to: FileSpec, }, Replace, Rmdir { path: PathBuf, }, Sort, Tree { path: PathBuf, }, Type { file: FileSpec, }, Xcopy { from: FileSpec, to: FileSpec, recursive: bool, }, // Shell-oriented Append, Chdir { path: PathBuf, }, EchoOff, EchoOn, EchoPlain, EchoText { message: String, }, Exit, PathGet, PathSet { value: String, }, PromptGet, PromptSet { message: String, }, Set { name: String, value: String, }, Setver, Ver, // Utilities Date, Time, // Screen-oriented Cls, More, // Dummies Verify, Fastopen, Smartdrv, Sizer, // For later Assign, Attrib, Chkdsk, Doskey, Dosshell, Edit, Fasthelp, Help, Join, Mem, Power, Subst, Truename, // For much later, if ever Break, Chcp, Ctty, Defrag, Diskcopy, Emm386, Fdisk, Format, Interlnk, Keyb, Label, Mode, Msav, Msbackup, Mscdex, Msd, Print, Qbasic, Restore, Scandisk, Share, Sys, Undelete, Unformat, Vol, Vsafe, // Scripting Call, Choice, Echo, For, Goto, If, Pause, Prompt, Rem { message: String, }, Shift, } #[derive(Debug, Clone, PartialEq)] enum Token { Word(String), Pipe, RedirectOut, // > RedirectAppend, // >> RedirectIn, // < Newline, Eof, } struct Lexer { input: Vec, position: usize, current_char: Option, } impl Lexer { fn new(input: &str) -> Self { let chars: Vec = input.chars().collect(); let current_char = chars.get(0).copied(); Self { input: chars, position: 0, current_char, } } fn advance(&mut self) { self.position += 1; self.current_char = self.input.get(self.position).copied(); } fn peek(&self) -> Option { self.input.get(self.position + 1).copied() } fn skip_whitespace(&mut self) { while let Some(ch) = self.current_char { if ch.is_whitespace() && ch != '\n' { self.advance(); } else { break; } } } fn read_word(&mut self) -> String { let mut word = String::new(); let mut in_quotes = false; let mut quote_char = '"'; while let Some(ch) = self.current_char { match ch { // Handle quotes '"' | '\'' if !in_quotes => { in_quotes = true; quote_char = ch; self.advance(); }, ch if in_quotes && ch == quote_char => { in_quotes = false; self.advance(); }, // Stop at special characters when not in quotes '|' | '>' | '<' | '\n' if !in_quotes => { break; }, // Stop at whitespace when not in quotes ch if !in_quotes && ch.is_whitespace() => { break; }, // Regular character _ => { word.push(ch); self.advance(); } } } word } fn next_token(&mut self) -> Token { loop { match self.current_char { None => return Token::Eof, Some('\n') => { self.advance(); return Token::Newline; }, Some(ch) if ch.is_whitespace() => { self.skip_whitespace(); continue; }, Some('|') => { self.advance(); return Token::Pipe; }, Some('>') => { self.advance(); if self.current_char == Some('>') { self.advance(); return Token::RedirectAppend; } else { return Token::RedirectOut; } }, Some('<') => { self.advance(); return Token::RedirectIn; }, Some(_) => { let word = self.read_word(); if word.is_empty() { // This shouldn't happen, but just in case self.advance(); continue; } return Token::Word(word); } } } } fn tokenize(&mut self) -> Vec { let mut tokens = Vec::new(); loop { let token = self.next_token(); let is_eof = matches!(token, Token::Eof); tokens.push(token); if is_eof { break; } } tokens } } struct Parser { tokens: Vec, position: usize, } impl Parser { fn new(tokens: Vec) -> Self { Self { tokens, position: 0, } } fn current_token(&self) -> &Token { self.tokens.get(self.position).unwrap_or(&Token::Eof) } fn advance(&mut self) { if self.position < self.tokens.len() { self.position += 1; } } fn expect_word(&mut self) -> Result { match self.current_token() { Token::Word(word) => { let result = word.clone(); self.advance(); Ok(result) }, token => Err(eyre::eyre!("Expected word, found {:?}", token)), } } fn parse_command(&mut self) -> Result { self.parse_pipeline() } fn parse_pipeline(&mut self) -> Result { let mut left = self.parse_redirected_command()?; while matches!(self.current_token(), Token::Pipe) { self.advance(); // consume | let right = self.parse_redirected_command()?; left = Command::Pipe { left: Box::new(left), right: Box::new(right), }; } Ok(left) } fn parse_redirected_command(&mut self) -> Result { let mut command = self.parse_simple_command()?; let mut redirects = Vec::new(); while matches!(self.current_token(), Token::RedirectOut | Token::RedirectAppend | Token::RedirectIn) { let redirect_type = match self.current_token() { Token::RedirectOut => RedirectType::OutputOverwrite, Token::RedirectAppend => RedirectType::OutputAppend, Token::RedirectIn => RedirectType::InputFrom, _ => unreachable!(), }; self.advance(); // consume redirect token let target_str = self.expect_word()?; let target = parse_filespec(&target_str); redirects.push(Redirect { redirect_type, target, }); } if redirects.is_empty() { Ok(command) } else { Ok(Command::Redirect { command: Box::new(command), redirects, }) } } fn parse_simple_command(&mut self) -> Result { use BuiltinCommand::*; use Command::*; if matches!(self.current_token(), Token::Eof | Token::Newline) { return Ok(Empty); } let command_name = self.expect_word()?; let mut args = Vec::new(); // Collect arguments until we hit a special token while matches!(self.current_token(), Token::Word(_)) { if let Token::Word(arg) = self.current_token() { args.push(arg.clone()); self.advance(); } } match command_name.to_uppercase().as_str() { "ECHO" => { if args.is_empty() { Ok(Builtin(EchoPlain)) } else { match args[0].to_uppercase().as_str() { "ON" if args.len() == 1 => Ok(Builtin(EchoOn)), "OFF" if args.len() == 1 => Ok(Builtin(EchoOff)), _ => Ok(Builtin(EchoText { message: args.join(" "), })), } } }, "CLS" => Ok(Builtin(Cls)), "EXIT" => Ok(Builtin(Exit)), "MORE" => Ok(Builtin(More)), "VERIFY" => Ok(Builtin(Verify)), // File-oriented commands "COPY" => { if args.len() >= 2 { Ok(Builtin(Copy { from: parse_filespec(&args[0]), to: parse_filespec(&args[1]), })) } else { Err(eyre::eyre!("COPY requires source and destination")) } }, "DIR" => Ok(Builtin(Dir { path: if args.is_empty() { PathBuf::from(".") } else { PathBuf::from(&args[0]) }, })), "TYPE" => { if args.is_empty() { Err(eyre::eyre!("TYPE requires a filename")) } else { Ok(Builtin(Type { file: parse_filespec(&args[0]), })) } }, "CD" | "CHDIR" => Ok(Builtin(Chdir { path: if args.is_empty() { std::env::current_dir()? } else { PathBuf::from(&args[0]) }, })), "MKDIR" | "MD" => { if args.is_empty() { Err(eyre::eyre!("MKDIR requires a directory name")) } else { Ok(Builtin(Mkdir { path: PathBuf::from(&args[0]), })) } }, "RMDIR" | "RD" => { if args.is_empty() { Err(eyre::eyre!("RMDIR requires a directory name")) } else { Ok(Builtin(Rmdir { path: PathBuf::from(&args[0]), })) } }, "DEL" | "ERASE" => { if args.is_empty() { Err(eyre::eyre!("DEL requires a filename")) } else { Ok(Builtin(Remove { path: PathBuf::from(&args[0]), })) } }, "REM" => Ok(Builtin(Rem { message: args.join(" "), })), "VER" => Ok(Builtin(Ver)), "DATE" => Ok(Builtin(Date)), "TIME" => Ok(Builtin(Time)), "PATH" => { if args.is_empty() { Ok(Builtin(PathGet)) } else { Ok(Builtin(PathSet { value: args.join(" "), })) } }, "SET" => { if args.is_empty() { Err(eyre::eyre!("SET requires variable name")) } else { let full_arg = args.join(" "); if let Some(eq_pos) = full_arg.find('=') { let name = full_arg[..eq_pos].trim().to_string(); let value = full_arg[eq_pos + 1..].trim().to_string(); Ok(Builtin(Set { name, value })) } else { Err(eyre::eyre!("SET requires variable=value format")) } } }, // Drive change (like C:, D:, etc.) _ if command_name.len() == 2 && command_name.ends_with(':') => Ok(Empty), // External command _ => Ok(External { program: command_name, args, }), } } } fn parse_filespec(p0: &str) -> FileSpec { match p0 { "CON" => FileSpec::Con, "LPT1" => FileSpec::Lpt1, "LPT2" => FileSpec::Lpt2, "LPT3" => FileSpec::Lpt3, "PRN" => FileSpec::Prn, _ => FileSpec::Path(PathBuf::from(p0)), } } impl Command { fn parse(input: &str) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { return Ok(Command::Empty); } let mut lexer = Lexer::new(trimmed); let tokens = lexer.tokenize(); let mut parser = Parser::new(tokens); parser.parse_command() } }