mxsh 0.1.0

Embeddable POSIX-style shell parser and runtime
Documentation
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use crate::ast::{Assignment, IoRedirect, SimpleCommand};
use crate::builtin::BuiltinHost;
use crate::embed::{DiagnosticCategory, DiagnosticKind};
use crate::plan::{CommandResolutionError, PlannedSimpleCommand, PlannedSimpleCommandKind};
use crate::policy::VariableAttributes;
use crate::sys::Runtime;

use super::frontend::{emit_diagnostic, range_for_line};
use super::*;

#[derive(Debug, Clone)]
pub(crate) enum CommandResolution {
    NotExecutable(PathBuf),
    NotFound,
}

#[derive(Debug, Clone)]
pub(crate) enum CommandLookup {
    Empty,
    Function,
    SpecialBuiltin,
    Builtin,
    UnspecifiedUtility,
    ShellOverride,
    External { program: String },
    ExternalFailed(CommandResolution),
    CommandNotFoundHandler,
}

#[derive(Debug)]
struct ResolvedSimpleCommand<'ast> {
    assignments: &'ast [Assignment],
    redirects: &'ast [IoRedirect],
    argv: Vec<String>,
    command_name: Option<String>,
    lookup: CommandLookup,
    should_restore_assignments: bool,
    has_command_substitution: bool,
    source_line: Option<u32>,
}

fn resolution_error(resolution: CommandResolution) -> CommandResolutionError {
    match resolution {
        CommandResolution::NotExecutable(path) => CommandResolutionError::NotExecutable(path),
        CommandResolution::NotFound => CommandResolutionError::NotFound,
    }
}

impl<'ast> ResolvedSimpleCommand<'ast> {
    fn into_planned(self, state: &ShellState) -> PlannedSimpleCommand<'ast> {
        let Self {
            assignments,
            redirects,
            argv,
            command_name,
            lookup,
            should_restore_assignments,
            has_command_substitution,
            source_line,
        } = self;
        let kind = match command_name {
            None => PlannedSimpleCommandKind::AssignmentsOnly {
                has_command_substitution,
            },
            Some(command_name) => match lookup {
                CommandLookup::UnspecifiedUtility => {
                    PlannedSimpleCommandKind::UnspecifiedUtility { command_name }
                }
                CommandLookup::SpecialBuiltin | CommandLookup::Builtin => {
                    PlannedSimpleCommandKind::Builtin { argv }
                }
                CommandLookup::ShellOverride => PlannedSimpleCommandKind::ShellOverride { argv },
                CommandLookup::External { program } => {
                    PlannedSimpleCommandKind::External { program, argv }
                }
                CommandLookup::ExternalFailed(resolution) => {
                    PlannedSimpleCommandKind::ResolutionFailure {
                        command_name,
                        resolution: resolution_error(resolution),
                    }
                }
                CommandLookup::CommandNotFoundHandler => {
                    PlannedSimpleCommandKind::CommandNotFoundHandler { argv }
                }
                CommandLookup::Function => {
                    PlannedSimpleCommandKind::Function { command_name, argv }
                }
                CommandLookup::Empty => {
                    unreachable!(
                        "resolved command name should be present for non-empty command lookup"
                    )
                }
            },
        };
        let assignment_attrib = match kind {
            PlannedSimpleCommandKind::AssignmentsOnly { .. } => {
                if state.has_option(OPT_ALLEXPORT) {
                    VariableAttributes::EXPORT
                } else {
                    VariableAttributes::empty()
                }
            }
            _ => VariableAttributes::EXPORT,
        };
        PlannedSimpleCommand {
            assignments,
            redirects,
            assignment_attrib,
            restore_assignments: should_restore_assignments,
            kind,
            source_line,
        }
    }
}

pub(super) fn shell_override_for<'a>(
    state: &'a ShellState,
    argv: &[String],
) -> Option<&'a crate::policy::CommandOverride> {
    let name = argv.first()?;
    let command_override = state.definition.command_policy.override_for(name)?;
    command_override.matches(argv).then_some(command_override)
}

pub(super) fn has_shell_override(state: &ShellState, argv: &[String]) -> bool {
    shell_override_for(state, argv).is_some()
}

pub(super) fn run_shell_override<R: Runtime>(
    state: &mut ShellState,
    runtime: &mut R,
    argv: &[String],
) -> Option<i32> {
    let _ = runtime;
    let command_override = shell_override_for(state, argv)?.clone();
    let mut context = BuiltinHost::new(state);
    Some(command_override.run(&mut context, &argv[1..]))
}

pub(super) fn run_command_not_found_handler<R: Runtime>(
    state: &mut ShellState,
    runtime: &mut R,
    argv: &[String],
) -> Option<i32> {
    let _ = runtime;
    let handler = state
        .definition
        .command_policy
        .command_not_found_handler()?
        .clone();
    let mut context = BuiltinHost::new(state);
    Some(handler(&mut context, argv))
}

fn resolve_source_path_candidate(path: &Path) -> Option<PathBuf> {
    let Ok(meta) = fs::metadata(path) else {
        return None;
    };
    if !meta.is_file() {
        return None;
    }
    if fs::File::open(path).is_err() {
        return None;
    }
    Some(path.to_path_buf())
}

pub(super) fn command_resolution_from_error(program: &str, err: &io::Error) -> CommandResolution {
    match err.kind() {
        io::ErrorKind::NotFound => CommandResolution::NotFound,
        io::ErrorKind::PermissionDenied => {
            let err_text = err.to_string();
            let path = err_text
                .split(": ")
                .next()
                .filter(|part| !part.is_empty())
                .unwrap_or(program);
            CommandResolution::NotExecutable(PathBuf::from(path))
        }
        _ => CommandResolution::NotExecutable(PathBuf::from(program)),
    }
}

pub(super) fn resolve_dot_path(state: &ShellState, name: &str) -> PathBuf {
    if name.contains('/') {
        return PathBuf::from(name);
    }
    let path_var = state.env_get("PATH").unwrap_or("/usr/bin:/bin");
    for dir in path_var.split(':') {
        let candidate = Path::new(dir).join(name);
        if let Some(path) = resolve_source_path_candidate(&candidate) {
            return path;
        }
    }
    PathBuf::from(name)
}

pub(super) fn command_failure_status(resolution: &CommandResolution) -> i32 {
    match resolution {
        CommandResolution::NotExecutable(_) => 126,
        CommandResolution::NotFound => 127,
    }
}

pub(super) fn report_command_resolution_error(
    state: &ShellState,
    program: &str,
    resolution: &CommandResolution,
    source_line: Option<u32>,
) {
    let (code, message) = match resolution {
        CommandResolution::NotExecutable(path) => (
            "resolve.not_executable",
            format!("{}: Permission denied", path.display()),
        ),
        CommandResolution::NotFound => {
            ("resolve.not_found", format!("{program}: command not found"))
        }
    };
    if let (Some(source), Some(line)) = (state.current_source.as_deref(), source_line) {
        let rendered = format!("{source}: line {line}: {message}");
        let _ = state.stderr_fd.write_line(&rendered);
        emit_diagnostic(
            state,
            DiagnosticKind::Error,
            DiagnosticCategory::CommandLookup,
            code,
            &message,
            Some(source.to_string()),
            Some(range_for_line(line)),
        );
    } else {
        let _ = state.stderr_fd.write_line(&message);
        emit_diagnostic(
            state,
            DiagnosticKind::Error,
            DiagnosticCategory::CommandLookup,
            code,
            &message,
            None,
            None,
        );
    }
}

pub(crate) fn resolve_simple_command<'ast, R: Runtime>(
    state: &mut ShellState,
    runtime: &mut R,
    sc: &'ast SimpleCommand,
) -> Result<PlannedSimpleCommand<'ast>, i32> {
    if sc.name.is_none() {
        let has_command_substitution = sc
            .assignments
            .iter()
            .any(|assign| shell_expand::word_contains_command_substitution(&assign.value));
        return Ok(ResolvedSimpleCommand {
            assignments: &sc.assignments,
            redirects: &sc.io_redirects,
            argv: Vec::new(),
            command_name: None,
            lookup: CommandLookup::Empty,
            should_restore_assignments: false,
            has_command_substitution,
            source_line: None,
        }
        .into_planned(state));
    }

    let name_word = sc.name.as_ref().unwrap();
    let Some(expanded_name) = shell_expand::expand_command_name(state, runtime, name_word) else {
        return Ok(ResolvedSimpleCommand {
            assignments: &sc.assignments,
            redirects: &sc.io_redirects,
            argv: Vec::new(),
            command_name: None,
            lookup: CommandLookup::Empty,
            should_restore_assignments: false,
            has_command_substitution: false,
            source_line: shell_expand::word_begin_line(name_word),
        }
        .into_planned(state));
    };
    if let Some(status) = state.take_expansion_error() {
        return Err(status);
    }

    let argv = shell_expand::build_argv(state, runtime, expanded_name.clone(), &sc.arguments);
    if let Some(status) = state.take_expansion_error() {
        return Err(status);
    }
    let command_name = argv.first().cloned().unwrap_or(expanded_name);
    let lookup = if has_shell_override(state, &argv) {
        CommandLookup::ShellOverride
    } else if state
        .definition
        .command_policy
        .is_unspecified_utility(&command_name)
    {
        CommandLookup::UnspecifiedUtility
    } else if state.functions.contains_key(&command_name) {
        CommandLookup::Function
    } else if super::builtins::is_special_builtin_in(state, &command_name) {
        CommandLookup::SpecialBuiltin
    } else if super::builtins::is_builtin_in(state, &command_name) {
        CommandLookup::Builtin
    } else {
        let path_var = state.env_get("PATH").unwrap_or("/usr/bin:/bin");
        match runtime.resolve_command_path(&command_name, path_var) {
            Ok(path) => CommandLookup::External {
                program: path.display().to_string(),
            },
            Err(err) => {
                let resolution = command_resolution_from_error(&command_name, &err);
                if matches!(resolution, CommandResolution::NotFound)
                    && state
                        .definition
                        .command_policy
                        .command_not_found_handler()
                        .is_some()
                {
                    CommandLookup::CommandNotFoundHandler
                } else {
                    CommandLookup::ExternalFailed(resolution)
                }
            }
        }
    };
    let should_restore_assignments = !sc.assignments.is_empty()
        && !matches!(
            lookup,
            CommandLookup::SpecialBuiltin | CommandLookup::Function
        );

    Ok(ResolvedSimpleCommand {
        assignments: &sc.assignments,
        redirects: &sc.io_redirects,
        argv,
        command_name: Some(command_name),
        lookup,
        should_restore_assignments,
        has_command_substitution: false,
        source_line: shell_expand::word_begin_line(name_word),
    }
    .into_planned(state))
}