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 Command { Pipe { left: Box, right: Box, }, Redirect { command: Box, target: FileSpec, }, 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, } impl Command { fn parse(input: &str) -> Result { use BuiltinCommand::*; use Command::*; // Implement COMMAND.COM-style command line parsing. // See https://en.wikipedia.org/wiki/Command.com#Command_line_syntax // and https://stackoverflow.com/questions/4094699/how-does-the-windows-command-interpreter-cmd-exe-parse-scripts for details. // let whitespace = Regex::new(r"\s+")?; let mut split_input = whitespace.splitn(input, 2); let Some(name) = split_input.next() else { return Ok(Empty); }; let args = split_input.next().unwrap_or(""); match name.to_uppercase().as_str() { "" => Ok(Empty), "ECHO" => Ok(Builtin(match args.to_uppercase().as_str() { "ON" => EchoOn, "OFF" => EchoOff, "" => EchoPlain, _ => EchoText { message: args.to_string(), }, })), "CLS" => Ok(Builtin(Cls)), "EXIT" => Ok(Builtin(Exit)), "MORE" => Ok(Builtin(More)), "VERIFY" => Ok(Builtin(Verify)), // File-oriented commands "COPY" => { let parts: Vec<&str> = args.split_whitespace().collect(); if parts.len() >= 2 { Ok(Builtin(Copy { from: parse_filespec(parts[0])?, to: parse_filespec(parts[1])?, })) } else { Err(eyre::eyre!("COPY requires source and destination")) } }, "DELTREE" => Ok(Builtin(Deltree { path: PathBuf::from(args.trim()), })), "DIR" => Ok(Builtin(Dir { path: if args.trim().is_empty() { PathBuf::from(".") } else { PathBuf::from(args.trim()) }, })), "FC" => Ok(Builtin(Fc)), "FIND" => Ok(Builtin(Find)), "MKDIR" | "MD" => Ok(Builtin(Mkdir { path: PathBuf::from(args.trim()), })), "MOVE" => Ok(Builtin(Move)), "DEL" | "ERASE" => Ok(Builtin(Remove { path: PathBuf::from(args.trim()), })), "REN" | "RENAME" => { let parts: Vec<&str> = args.split_whitespace().collect(); if parts.len() >= 2 { Ok(Builtin(Rename { from: parse_filespec(parts[0])?, to: parse_filespec(parts[1])?, })) } else { Err(eyre::eyre!("RENAME requires source and destination")) } }, "REPLACE" => Ok(Builtin(Replace)), "RMDIR" | "RD" => Ok(Builtin(Rmdir { path: PathBuf::from(args.trim()), })), "SORT" => Ok(Builtin(Sort)), "TREE" => Ok(Builtin(Tree { path: if args.trim().is_empty() { PathBuf::from(".") } else { PathBuf::from(args.trim()) }, })), "TYPE" => Ok(Builtin(Type { file: parse_filespec(args.trim())?, })), "XCOPY" => { let parts: Vec<&str> = args.split_whitespace().collect(); let recursive = parts.contains(&"/S") || parts.contains(&"/s"); if parts.len() >= 2 { Ok(Builtin(Xcopy { from: parse_filespec(parts[0])?, to: parse_filespec(parts[1])?, recursive, })) } else { Err(eyre::eyre!("XCOPY requires source and destination")) } }, // Shell-oriented commands "APPEND" => Ok(Builtin(Append)), "CD" | "CHDIR" => Ok(Builtin(Chdir { path: if args.trim().is_empty() { std::env::current_dir()? } else { PathBuf::from(args.trim()) }, })), "PATH" => { if args.trim().is_empty() { Ok(Builtin(PathGet)) } else { Ok(Builtin(PathSet { value: args.to_string(), })) } }, "PROMPT" => { if args.trim().is_empty() { Ok(Builtin(PromptGet)) } else { Ok(Builtin(PromptSet { message: args.to_string(), })) } }, "SET" => { if args.trim().is_empty() { Err(eyre::eyre!("SET requires variable name")) } else if let Some(eq_pos) = args.find('=') { let name = args[..eq_pos].trim().to_string(); let value = args[eq_pos + 1..].trim().to_string(); Ok(Builtin(Set { name, value })) } else { Err(eyre::eyre!("SET requires variable=value format")) } }, "SETVER" => Ok(Builtin(Setver)), "VER" => Ok(Builtin(Ver)), // Utilities "DATE" => Ok(Builtin(Date)), "TIME" => Ok(Builtin(Time)), // Dummies "FASTOPEN" => Ok(Builtin(Fastopen)), "SMARTDRV" => Ok(Builtin(Smartdrv)), "SIZER" => Ok(Builtin(Sizer)), // For later "ASSIGN" => Ok(Builtin(Assign)), "ATTRIB" => Ok(Builtin(Attrib)), "CHKDSK" => Ok(Builtin(Chkdsk)), "DOSKEY" => Ok(Builtin(Doskey)), "DOSSHELL" => Ok(Builtin(Dosshell)), "EDIT" => Ok(Builtin(Edit)), "FASTHELP" => Ok(Builtin(Fasthelp)), "HELP" => Ok(Builtin(Help)), "JOIN" => Ok(Builtin(Join)), "MEM" => Ok(Builtin(Mem)), "POWER" => Ok(Builtin(Power)), "SUBST" => Ok(Builtin(Subst)), "TRUENAME" => Ok(Builtin(Truename)), // For much later, if ever "BREAK" => Ok(Builtin(Break)), "CHCP" => Ok(Builtin(Chcp)), "CTTY" => Ok(Builtin(Ctty)), "DEFRAG" => Ok(Builtin(Defrag)), "DISKCOPY" => Ok(Builtin(Diskcopy)), "EMM386" => Ok(Builtin(Emm386)), "FDISK" => Ok(Builtin(Fdisk)), "FORMAT" => Ok(Builtin(Format)), "INTERLNK" => Ok(Builtin(Interlnk)), "KEYB" => Ok(Builtin(Keyb)), "LABEL" => Ok(Builtin(Label)), "MODE" => Ok(Builtin(Mode)), "MSAV" => Ok(Builtin(Msav)), "MSBACKUP" => Ok(Builtin(Msbackup)), "MSCDEX" => Ok(Builtin(Mscdex)), "MSD" => Ok(Builtin(Msd)), "PRINT" => Ok(Builtin(Print)), "QBASIC" => Ok(Builtin(Qbasic)), "RESTORE" => Ok(Builtin(Restore)), "SCANDISK" => Ok(Builtin(Scandisk)), "SHARE" => Ok(Builtin(Share)), "SYS" => Ok(Builtin(Sys)), "UNDELETE" => Ok(Builtin(Undelete)), "UNFORMAT" => Ok(Builtin(Unformat)), "VOL" => Ok(Builtin(Vol)), "VSAFE" => Ok(Builtin(Vsafe)), // Scripting "CALL" => Ok(Builtin(Call)), "CHOICE" => Ok(Builtin(Choice)), "FOR" => Ok(Builtin(For)), "GOTO" => Ok(Builtin(Goto)), "IF" => Ok(Builtin(If)), "PAUSE" => Ok(Builtin(Pause)), "REM" => Ok(Builtin(Rem { message: args.to_string(), })), "SHIFT" => Ok(Builtin(Shift)), _ if name.len() == 2 && name.ends_with(":") => Ok(Empty), _ => { //let parts: Vec<&str> = args.split_whitespace().collect(); } //Err(eyre::eyre!("parse not implemented for {:?}", input)), } } } fn parse_filespec(spec: &str) -> Result { match spec.to_uppercase().as_str() { "CON" => Ok(FileSpec::Con), "LPT1" => Ok(FileSpec::Lpt1), "LPT2" => Ok(FileSpec::Lpt2), "LPT3" => Ok(FileSpec::Lpt3), "PRN" => Ok(FileSpec::Prn), _ => Ok(FileSpec::Path(PathBuf::from(spec))), } }