mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
#![cfg(all(feature = "embed", feature = "test-support"))]

use mxsh::ShellBuilder;
use mxsh::advanced::{
    DeferredExpansion, DeferredPipelineStageWork, DeferredReason, PlannedAndOr,
    PlannedPipelineStage, PlannedSimpleCommandKind, Planner,
};
use mxsh::ast::{AndOrList, BinOpType, Command as AstCommand, Pipeline, Program};
use mxsh::runtime::testing::InMemoryRuntime;

fn first_command(program: &Program) -> &AstCommand {
    let AndOrList::Pipeline(pipeline) = program.body()[0].and_or_list() else {
        panic!("expected a pipeline");
    };
    &pipeline.commands()[0]
}

fn first_pipeline(program: &Program) -> &Pipeline {
    let AndOrList::Pipeline(pipeline) = program.body()[0].and_or_list() else {
        panic!("expected a pipeline");
    };
    pipeline
}

#[test]
fn plan_simple_command_exposes_builtin_phase_information() {
    let program = Program::parse("echo hello\n").expect("script should parse");
    let AstCommand::Simple(command) = first_command(&program) else {
        panic!("expected a simple command");
    };

    let mut shell = ShellBuilder::new()
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    let plan = Planner::new(&mut shell, &mut runtime)
        .prepare_simple_command(command)
        .expect("planning should succeed");

    assert_eq!(plan.source_line(), Some(1));
    assert!(plan.assignments().is_empty());
    assert!(plan.redirects().is_empty());
    match plan.kind() {
        PlannedSimpleCommandKind::Builtin { argv } => {
            assert_eq!(argv, &["echo".to_string(), "hello".to_string()]);
        }
        other => panic!("expected builtin plan, got {other:?}"),
    }
}

#[test]
fn plan_program_preserves_deferred_execution_boundaries() {
    let program = Program::parse("echo left && echo right\n").expect("script should parse");

    let mut shell = ShellBuilder::new()
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    let plan = Planner::new(&mut shell, &mut runtime).prepare(&program);

    match plan.command_lists()[0].and_or_list() {
        PlannedAndOr::BinOp { op, left, right } => {
            assert_eq!(*op, BinOpType::And);
            assert_eq!(right.reason(), DeferredReason::ShortCircuitRhs);
            match left.as_ref() {
                PlannedAndOr::Pipeline(pipeline) => {
                    assert!(matches!(
                        pipeline.stages(),
                        [PlannedPipelineStage::Lazy(lazy)]
                            if lazy.reason() == DeferredReason::NeedsCurrentShellState
                    ));
                }
                other => panic!("expected left pipeline, got {other:?}"),
            }
        }
        other => panic!("expected binop plan, got {other:?}"),
    }
}

#[test]
fn plan_pipeline_classifies_external_and_shell_stages() {
    let program = Program::parse("emit hi | : | upper\n").expect("script should parse");
    let pipeline = first_pipeline(&program);

    let mut shell = ShellBuilder::new()
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("emit", |_argv, _env, _cwd, _stdio| 0);
    runtime.register_command("upper", |_argv, _env, _cwd, _stdio| 0);

    let plan = Planner::new(&mut shell, &mut runtime).prepare_pipeline(pipeline);

    assert!(matches!(
        plan.stages(),
        [
            PlannedPipelineStage::PreparedExternal(_),
            PlannedPipelineStage::SimpleCommand(_),
            PlannedPipelineStage::PreparedExternal(_)
        ]
    ));
    match &plan.stages()[1] {
        PlannedPipelineStage::SimpleCommand(plan) => match plan.kind() {
            PlannedSimpleCommandKind::Builtin { argv } => {
                assert_eq!(argv, &[":".to_string()]);
            }
            other => panic!("expected builtin middle stage, got {other:?}"),
        },
        other => panic!("expected shell stage, got {other:?}"),
    }
}

#[test]
fn plan_pipeline_defers_stateful_expansion_stages() {
    let program = Program::parse("echo ${X:=hi} | upper\n").expect("script should parse");
    let pipeline = first_pipeline(&program);

    let mut shell = ShellBuilder::new()
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("upper", |_argv, _env, _cwd, _stdio| 0);

    let plan = Planner::new(&mut shell, &mut runtime).prepare_pipeline(pipeline);

    assert!(matches!(
        &plan.stages()[1],
        PlannedPipelineStage::PreparedExternal(_)
    ));
    match &plan.stages()[0] {
        PlannedPipelineStage::Lazy(lazy) => {
            assert_eq!(lazy.reason(), DeferredReason::NeedsCurrentShellState);
            assert!(matches!(
                lazy.deferred_work(),
                Some(DeferredPipelineStageWork::Expansion(
                    DeferredExpansion::ParameterExpansion {
                        op: mxsh::ast::ParameterOp::Equal,
                        ..
                    }
                ))
            ));
        }
        other => panic!("expected deferred shell stage, got {other:?}"),
    }
}

#[test]
fn plan_pipeline_reports_command_substitution_work() {
    let program = Program::parse("echo $(printf hi) | upper\n").expect("script should parse");
    let pipeline = first_pipeline(&program);

    let mut shell = ShellBuilder::new()
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("upper", |_argv, _env, _cwd, _stdio| 0);

    let plan = Planner::new(&mut shell, &mut runtime).prepare_pipeline(pipeline);

    match &plan.stages()[0] {
        PlannedPipelineStage::Lazy(lazy) => assert!(matches!(
            lazy.deferred_work(),
            Some(DeferredPipelineStageWork::Expansion(
                DeferredExpansion::CommandSubstitution { .. }
            ))
        )),
        other => panic!("expected deferred shell stage, got {other:?}"),
    }
}