//! The shell's parser: command line -> tokens -> pipeline, and the drive //! letter mapping (`B:/notes` <-> `/mnt/disk0/notes`). //! //! Pure functions with no syscalls, so the same file is compiled into the //! shell (user/sh.rs) *and* into the kernel's test suite (tests/shell.rs), //! where it's tested without starting any processes. //! //! Grammar (a small subset of what a Unix shell accepts): //! //! line = pipeline [ "&" ] [ "#" comment ] //! pipeline = command { "|" command } //! command = { word | "<" word | ">" word | ">>" word } //! word = { plain-char | 'single quoted' | "double quoted" } //! //! `|`, `<`, `>`, `>>` and `&` are operators even without spaces around //! them (`ls|wc` works). Quotes just group: no `$variables` or escapes. #![allow(dead_code)] use alloc::{format, string::String, vec::Vec}; /// One word of a command. `quoted` words are never drive-translated, so /// `grep "A:" file` searches for the text A:. #[derive(Debug, Clone, PartialEq)] pub struct Word { pub text: String, pub quoted: bool, } #[derive(Debug, Clone, PartialEq)] pub enum Token { Word(Word), Pipe, // | In, // < Out, // > Append, // >> Background, // & } /// Split a line into tokens. Errors on an unterminated quote. pub fn tokenize(line: &str) -> Result, String> { let mut tokens = Vec::new(); let mut chars = line.chars().peekable(); // The word being built, if any (a word can mix plain and quoted parts: // a"b c"d is the single word `ab cd`). let mut word: Option = None; while let Some(c) = chars.next() { let op = match c { '|' => Some(Token::Pipe), '<' => Some(Token::In), '&' => Some(Token::Background), '>' if chars.peek() == Some(&'>') => { chars.next(); Some(Token::Append) } '>' => Some(Token::Out), _ => None, }; if op.is_some() || c.is_whitespace() || (c == '#' && word.is_none()) { // Anything here ends the current word. if let Some(w) = word.take() { tokens.push(Token::Word(w)); } if let Some(op) = op { tokens.push(op); } else if c == '#' { break; // comment: ignore the rest of the line } continue; } let w = word.get_or_insert(Word { text: String::new(), quoted: false }); if c == '\'' || c == '"' { w.quoted = true; loop { match chars.next() { Some(q) if q == c => break, Some(other) => w.text.push(other), None => return Err(format!("unterminated {} quote", c)), } } } else { w.text.push(c); } } if let Some(w) = word { tokens.push(Token::Word(w)); } Ok(tokens) } /// One program (or builtin) with its arguments and redirections. #[derive(Debug, Clone, Default, PartialEq)] pub struct Command { pub words: Vec, // words[0] is the command name pub stdin: Option, // < file pub stdout: Option<(Word, bool)>, // > file (false) or >> file (true) } /// `a | b | c`, maybe with a trailing `&` (run in the background). #[derive(Debug, Clone, PartialEq)] pub struct Pipeline { pub commands: Vec, pub background: bool, } /// Parse a line. `Ok(None)` for an empty line or a comment. pub fn parse(line: &str) -> Result, String> { let tokens = tokenize(line)?; if tokens.is_empty() { return Ok(None); } let mut commands = Vec::new(); let mut current = Command::default(); let mut background = false; let mut iter = tokens.into_iter().peekable(); while let Some(token) = iter.next() { if background { return Err(String::from("`&` must be at the end of the line")); } match token { Token::Word(w) => current.words.push(w), Token::Pipe => { if current.words.is_empty() { return Err(String::from("missing command before `|`")); } commands.push(core::mem::take(&mut current)); } Token::Background => background = true, Token::In | Token::Out | Token::Append => { let target = match iter.next() { Some(Token::Word(w)) => w, _ => return Err(String::from("missing file name after redirection")), }; match token { Token::In => current.stdin = Some(target), Token::Out => current.stdout = Some((target, false)), _ => current.stdout = Some((target, true)), } } } } if current.words.is_empty() { // e.g. `> file`, `&` or `ls |`: nothing to run. return Err(String::from(if commands.is_empty() { "missing command" } else { "missing command after `|`" })); } commands.push(current); Ok(Some(Pipeline { commands, background })) } // --- drive letters ----------------------------------------------------------- /// A drive letter and the directory it stands for. #[derive(Debug, Clone, PartialEq)] pub struct Drive { pub letter: char, // always upper case pub path: String, // absolute, no trailing slash except "/" pub fs: String, // file system type, e.g. "fat32" } /// Assign letters from the kernel's mount table (the text from the /// `mounts` syscall: one "path fstype" per line, in mount order). /// /// * A: is always the root, `/`. /// * C: is the share, `/mnt/share` (boot contract v2's exFAT partition), /// whenever it is mounted. /// * The disks (the other mounts under /mnt: /mnt/disk0, /mnt/disk1, ...) /// take the free letters in mount order: B:, then D:, E:, ... (or B:, C:, /// D:, ... when there is no share, as under contract v1). /// /// Other mounts such as /tmp are reachable through A: (A:/tmp). pub fn assign_drives(mount_table: &str) -> Vec { const SHARE: &str = "/mnt/share"; let mounts: Vec<(&str, &str)> = mount_table .lines() .filter_map(|l| l.split_once(' ')) .map(|(p, fs)| (p, fs.trim())) .collect(); let root_fs = mounts.iter().find(|(p, _)| *p == "/").map(|(_, fs)| *fs).unwrap_or("ramfs"); let mut drives = Vec::new(); drives.push(Drive { letter: 'A', path: String::from("/"), fs: String::from(root_fs) }); let share = mounts.iter().find(|(p, _)| *p == SHARE); if let Some((path, fs)) = share { drives.push(Drive { letter: 'C', path: String::from(*path), fs: String::from(*fs) }); } let mut letter = b'B'; for (path, fs) in mounts.iter().filter(|(p, _)| p.starts_with("/mnt/") && *p != SHARE) { if share.is_some() && letter == b'C' { letter += 1; // C: is the share's } if letter > b'Z' { break; } drives.push(Drive { letter: letter as char, path: String::from(*path), fs: String::from(*fs) }); letter += 1; } drives.sort_by_key(|d| d.letter); // `drives` lists them A:, B:, C:, ... drives } /// Does `word` start with a drive letter ("B:", "b:/x", "C:notes")? pub fn drive_letter(word: &str) -> Option { let b = word.as_bytes(); if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { Some((b[0] as char).to_ascii_uppercase()) } else { None } } /// `B:/notes/todo.txt` -> `/mnt/disk0/notes/todo.txt`. Letters are case /// insensitive; `B:` and `B:/` are the drive's root, and `B:notes` means /// `B:/notes` (drives have no current directory of their own). `None` if /// `word` doesn't start with a known drive letter. pub fn translate(word: &str, drives: &[Drive]) -> Option { let letter = drive_letter(word)?; let drive = drives.iter().find(|d| d.letter == letter)?; let rest = word[2..].trim_start_matches('/'); Some(if rest.is_empty() { drive.path.clone() } else if drive.path == "/" { format!("/{}", rest) } else { format!("{}/{}", drive.path, rest) }) } /// The other direction, for the prompt: `/mnt/disk0/bin` -> `B:/bin`, /// `/tmp` -> `A:/tmp`. Picks the drive with the longest matching path. pub fn display(path: &str, drives: &[Drive]) -> String { let mut best: Option<(&Drive, &str)> = None; for d in drives { let rest = if d.path == "/" { path.strip_prefix('/') } else { // "/mnt/disk0" matches "/mnt/disk0" and "/mnt/disk0/x", not "/mnt/disk01". path.strip_prefix(d.path.as_str()) .filter(|r| r.is_empty() || r.starts_with('/')) .map(|r| r.trim_start_matches('/')) }; if let Some(rest) = rest { if best.map_or(true, |(b, _)| d.path.len() > b.path.len()) { best = Some((d, rest)); } } } match best { Some((d, rest)) => format!("{}:/{}", d.letter, rest), None => String::from(path), } }