mod awk;
mod commands;
mod copy;
mod cwd;
mod database;
mod decoders;
mod embedded;
pub(crate) mod git;
mod invocation;
mod make;
mod operands;
mod options;
mod path_options;
mod sed;
pub(crate) mod tables;
#[cfg(test)]
mod tests;
mod text;
pub(crate) mod tokens;
use crate::action::AtomicAction;
use crate::lexer::{LexError, Token};
use crate::paths;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseOutcome {
Parsed(Vec<AtomicAction>),
Unparseable {
reason: String,
atoms: Vec<AtomicAction>,
},
}
impl ParseOutcome {
#[must_use]
pub fn into_parts(self) -> (Vec<AtomicAction>, Option<String>) {
match self {
Self::Parsed(atoms) => (atoms, None),
Self::Unparseable { reason, atoms } => (atoms, Some(reason)),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ShellContext<'a> {
pub home: &'a str,
pub project: Option<&'a str>,
pub cwd: &'a [Option<String>],
}
impl ShellContext<'_> {
pub(crate) fn paths(&self, word: &str) -> Result<Vec<String>, ClassifyError> {
if let Some(path) = paths::resolve(word, self.home, self.project, None) {
return Ok(vec![path]);
}
self.cwd
.iter()
.map(|cwd| {
paths::resolve(word, self.home, self.project, cwd.as_deref())
.ok_or_else(|| ClassifyError::UnknownDirectory(word.to_owned()))
})
.collect()
}
pub(crate) fn traversals(&self, word: &str) -> Vec<String> {
if let Some(path) = paths::traversal(word, self.home, self.project, None) {
return vec![path];
}
self.cwd
.iter()
.filter_map(|cwd| paths::traversal(word, self.home, self.project, cwd.as_deref()))
.collect()
}
}
pub const MAX_DEPTH: u8 = 4;
pub const MAX_ATOMS: usize = 2048;
pub const MAX_DIRS: usize = 16;
#[cfg(test)]
#[must_use]
pub fn classify(command: &str, ctx: &ShellContext<'_>) -> ParseOutcome {
classify_globs(command, ctx).0
}
#[must_use]
pub fn classify_globs(command: &str, ctx: &ShellContext<'_>) -> (ParseOutcome, Vec<AtomicAction>) {
let mut sink = Sink::default();
let mut stopped = commands::classify_into(command, ctx, &mut sink, 0).err();
let split = command.replace("${IFS}", " ").replace("$IFS", " ");
if split != command {
let again = commands::classify_into(&split, ctx, &mut sink, 0).err();
stopped = stopped.or(again);
}
let outcome = match sink.skipped.or(stopped) {
Some(e) => ParseOutcome::Unparseable {
reason: e.to_string(),
atoms: sink.atoms,
},
None if sink.atoms.is_empty() => ParseOutcome::Unparseable {
reason: "no command found".to_owned(),
atoms: Vec::new(),
},
None => ParseOutcome::Parsed(sink.atoms),
};
(outcome, sink.globs)
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ClassifyError {
#[error(transparent)]
Lex(#[from] LexError),
#[error("command nesting deeper than {MAX_DEPTH}")]
TooDeep,
#[error("command expands to more than {MAX_ATOMS} actions")]
TooManyActions,
#[error("cannot interpret `{option}` for `{program}`")]
ShellOption { program: String, option: String },
#[error("`{program} {option}` runs code from a file or module the command line names")]
LoadsCode { program: String, option: String },
#[error("`{0}` names a path relative to a directory that is not known")]
UnknownDirectory(String),
#[error("`cd` may lead to more than {MAX_DIRS} directories")]
TooManyDirectories,
#[error("unbalanced parentheses")]
UnbalancedParens,
#[error("the shell runs text from `{0}` that the command line does not show")]
UnknownStdin(String),
#[error("`{0}` depends on a variable whose value is not known")]
UnknownValue(String),
#[error("a variable set on the line may hold more than {MAX_DIRS} combinations of values")]
TooManyValues,
}
pub(crate) fn is_lowercase(name: &str) -> bool {
name.bytes().any(|b| b.is_ascii_lowercase())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
#[derive(Debug, Default)]
pub(crate) struct Sink {
atoms: Vec<AtomicAction>,
skipped: Option<ClassifyError>,
glob_words: Vec<String>,
globs: Vec<AtomicAction>,
dollar_words: Vec<String>,
locals: Vec<(String, u8, Option<Vec<String>>)>,
depth: u8,
}
impl Sink {
pub(crate) fn enter(&mut self, depth: u8) {
self.depth = depth;
}
pub(crate) fn note_local(&mut self, name: &str, values: Option<Vec<String>>) {
self.locals
.retain(|(n, d, _)| !(n == name && *d == self.depth));
self.locals.push((name.to_owned(), self.depth, values));
}
pub(crate) fn known_locals(&self, depth: u8) -> Vec<(String, Vec<String>)> {
self.locals
.iter()
.filter(|(_, d, _)| *d == depth)
.filter_map(|(n, _, v)| Some((n.clone(), v.clone()?)))
.collect()
}
pub(crate) fn push(&mut self, atom: AtomicAction) -> Result<(), ClassifyError> {
if let AtomicAction::EnvRead { name } = &atom
&& is_lowercase(name)
&& self
.locals
.iter()
.any(|(n, d, v)| n == name && *d == self.depth && v.is_some())
{
return Ok(());
}
if self.atoms.contains(&atom) {
return Ok(());
}
if self.atoms.len() >= MAX_ATOMS {
return Err(ClassifyError::TooManyActions);
}
self.atoms.push(atom);
Ok(())
}
pub(crate) fn skip(&mut self, error: ClassifyError) {
self.skipped.get_or_insert(error);
}
fn paths(&mut self, ctx: &ShellContext<'_>, word: &str) -> Vec<String> {
if paths::has_unexpanded_variable(word)
&& self.dollar_words.iter().any(|w| w.contains(word))
{
let atom = AtomicAction::Shell {
argv: vec!["@unknown-path".to_owned(), word.to_owned()],
};
if let Err(full) = self.push(atom) {
self.skip(full);
}
self.skip(ClassifyError::UnknownValue(word.to_owned()));
}
ctx.paths(word).unwrap_or_else(|e| {
if paths::other_user_home(word, ctx.home) {
let atom = AtomicAction::Shell {
argv: vec!["@other-home".to_owned(), word.to_owned()],
};
if let Err(full) = self.push(atom) {
self.skip(full);
}
}
self.skip(e);
Vec::new()
})
}
pub(crate) fn note_globs(&mut self, tokens: &[Token]) {
for token in tokens {
if let Token::Word(w) = token {
if w.glob && !self.glob_words.contains(&w.text) {
self.glob_words.push(w.text.clone());
}
self.note_dollar_word(w);
}
}
}
pub(crate) fn note_dollar_word(&mut self, word: &crate::lexer::Word) {
let expands = !word.substitutions.is_empty()
|| word
.text
.match_indices('$')
.any(|(pos, _)| !word.literal.iter().any(|span| span.contains(&pos)));
if expands && !self.dollar_words.contains(&word.text) {
self.dollar_words.push(word.text.clone());
}
}
pub(crate) fn read(&mut self, ctx: &ShellContext<'_>, word: &str) -> Result<(), ClassifyError> {
self.fs(ctx, word, |path| AtomicAction::FsRead { path })
}
pub(crate) fn write(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
) -> Result<(), ClassifyError> {
self.fs(ctx, word, |path| AtomicAction::FsWrite { path })
}
pub(crate) fn redirect_read(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
) -> Result<(), ClassifyError> {
self.redirect(ctx, word, tables::STREAM_SOURCES, |path| {
AtomicAction::FsRead { path }
})
}
pub(crate) fn redirect_write(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
) -> Result<(), ClassifyError> {
self.redirect(ctx, word, tables::STREAM_SINKS, |path| {
AtomicAction::FsWrite { path }
})
}
fn redirect(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
streams: &[&str],
atom: fn(String) -> AtomicAction,
) -> Result<(), ClassifyError> {
let stream = !word.contains(['*', '?', '['])
&& ctx
.paths(word)
.is_ok_and(|paths| paths.iter().all(|p| streams.contains(&p.as_str())));
if stream {
return Ok(());
}
self.fs(ctx, word, atom)
}
fn fs(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
atom: fn(String) -> AtomicAction,
) -> Result<(), ClassifyError> {
let glob =
word.contains(['*', '?', '[']) && self.glob_words.iter().any(|w| w.ends_with(word));
let traversals = ctx.traversals(word);
for path in self.paths(ctx, word).into_iter().chain(traversals) {
let atom = atom(path);
if glob && !self.globs.contains(&atom) {
self.globs.push(atom.clone());
}
self.push(atom)?;
}
Ok(())
}
}