script.rs

//! Non-interactive script execution through the persistent session driver.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;

use crate::builtin::SessionState;
use crate::capsule::{
    BackgroundCapsule, SupervisorCompletion, SupervisorOutcome, supervisor_completion,
};
use crate::command::CommandRegistry;
use crate::eval::Clock;
use crate::module::{ModuleId, ModuleProgram, ModuleSourceRegistry};
use crate::plan::SessionOptions;
use crate::resolve::ExecutableProbe;
use crate::session::{
    BackgroundFailure, BackgroundFailureReason, Session, SubmitError, SubmitOutcome,
};
use crate::{BindingMutability, Environment, ScopeStack, Status, Value};
use flash_platform::Platform;

/// The normally completed result of one non-interactive source file.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScriptCompletion {
    status: Option<Status>,
    background_failures: Vec<BackgroundFailure>,
}

impl ScriptCompletion {
    /// The final foreground job status, or `None` when no job ran.
    #[must_use]
    pub const fn status(&self) -> Option<&Status> {
        self.status.as_ref()
    }

    /// Joined background failures in ascending job-identity order.
    #[must_use]
    pub fn background_failures(&self) -> &[BackgroundFailure] {
        &self.background_failures
    }
}

/// A source-anchored parse or runtime failure from script execution.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptError {
    rendered: String,
    background_failures: Vec<BackgroundFailure>,
}

impl ScriptError {
    fn submit(error: SubmitError) -> Self {
        let rendered = match error {
            SubmitError::Diagnostic(rendered) => rendered,
            SubmitError::Runtime { rendered, .. } => rendered,
            SubmitError::Output(error) => format!("fsh: output write failed: {error}\n"),
        };
        Self {
            rendered,
            background_failures: Vec::new(),
        }
    }

    fn module_submit(
        error: SubmitError,
        source: &flash_syntax::SourceFile,
        sources: &ModuleSourceRegistry,
    ) -> Self {
        let SubmitError::Runtime { error, .. } = error else {
            return Self::submit(error);
        };
        let rendered = crate::session::render_runtime_diagnostic(
            source,
            &error,
            sources.entries().map(|entry| entry.source().clone()),
        );
        Self {
            rendered,
            background_failures: Vec::new(),
        }
    }

    /// Render the complete user-facing diagnostic.
    #[must_use]
    pub fn render(&self) -> &str {
        &self.rendered
    }

    /// Joined background failures in ascending job-identity order.
    #[must_use]
    pub fn background_failures(&self) -> &[BackgroundFailure] {
        &self.background_failures
    }

    fn with_background_failures(mut self, failures: Vec<BackgroundFailure>) -> Self {
        self.background_failures = failures;
        self
    }
}

impl fmt::Display for ScriptError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.rendered)
    }
}

impl std::error::Error for ScriptError {}

/// Parse and execute one source file in statement order.
///
/// Pure statements, environment mutations, internal commands, external
/// processes, and mixed byte boundaries all reuse the same stateful execution
/// path as interactive submissions.
#[allow(clippy::too_many_arguments)]
pub fn execute_script(
    name: impl Into<String>,
    text: impl Into<String>,
    cwd: &Path,
    environment: &mut Environment,
    registry: &CommandRegistry,
    probe: &dyn ExecutableProbe,
    options: &SessionOptions,
    platform: &dyn Platform,
    clock: Arc<dyn Clock>,
    output: &mut dyn Write,
) -> Result<ScriptCompletion, ScriptError> {
    execute_source(
        name,
        text,
        cwd,
        environment,
        ScopeStack::new(),
        registry,
        probe,
        options,
        platform,
        clock,
        true,
        output,
    )
}

/// Executes a fully loaded module program in named-dependency-first order.
///
/// Named dependencies initialize once per canonical module. Each module owns
/// an isolated lexical root seeded with immutable snapshots of its imports;
/// load-only dependencies remain dormant. The root module additionally sees
/// the explicitly supplied immutable `args` list in a synthetic parent frame.
#[allow(clippy::too_many_arguments)]
pub fn execute_module_program(
    program: &ModuleProgram,
    script_arguments: &[String],
    cwd: &Path,
    environment: &mut Environment,
    registry: &CommandRegistry,
    probe: &dyn ExecutableProbe,
    options: &SessionOptions,
    platform: &dyn Platform,
    clock: Arc<dyn Clock>,
    output: &mut dyn Write,
) -> Result<ScriptCompletion, ScriptError> {
    let mut session = Session::with_scope_and_registry(
        ScopeStack::new(),
        cwd,
        environment.clone(),
        *options,
        registry.clone(),
    );
    session.enable_script_job_control(Arc::clone(&clock));
    let binding_types = Arc::new(program.runtime_binding_types());
    let mut instances: BTreeMap<ModuleId, BTreeMap<String, Value>> = BTreeMap::new();
    let mut outcome: Result<SubmitOutcome, ScriptError> = Ok(SubmitOutcome::Continued);

    for module in module_initialization_order(program) {
        let mut scope = ScopeStack::new();
        if &module == program.graph().root() {
            scope
                .declare(
                    "args",
                    BindingMutability::Immutable,
                    Value::list(
                        script_arguments
                            .iter()
                            .cloned()
                            .map(Value::string)
                            .collect(),
                    ),
                )
                .expect("a fresh root input frame has no binding collisions");
            scope.push();
        }
        for import in program.names().imports(&module) {
            let value = instances
                .get(import.target())
                .and_then(|exports| exports.get(import.name()))
                .expect("named dependencies initialize before their importers")
                .clone();
            scope
                .declare(import.name(), BindingMutability::Immutable, value)
                .expect("module name analysis rejects import binding collisions");
        }

        let source = program
            .sources()
            .source(&module)
            .expect("a loaded module program registers every source");
        let script = program
            .sources()
            .script(&module)
            .expect("a loaded module program registers every syntax tree");
        match session.submit_module_source(
            source,
            script,
            scope,
            Arc::clone(&binding_types),
            probe,
            platform,
            clock.as_ref(),
            output,
        ) {
            Ok((SubmitOutcome::Continued, completed_scope)) => {
                let exports = program
                    .names()
                    .exports(&module)
                    .map(|export| {
                        let value = completed_scope
                            .get(export.name())
                            .expect("an analyzed export names a completed root binding")
                            .clone();
                        (export.name().to_owned(), value)
                    })
                    .collect();
                instances.insert(module, exports);
            }
            Ok((exit @ SubmitOutcome::Exit(_), _)) => {
                outcome = Ok(exit);
                break;
            }
            Err(error) => {
                outcome = Err(ScriptError::module_submit(error, source, program.sources()));
                break;
            }
        }
    }

    finish_script_session(&mut session, environment, platform, outcome)
}

fn module_initialization_order(program: &ModuleProgram) -> Vec<ModuleId> {
    fn visit(
        program: &ModuleProgram,
        module: &ModuleId,
        initialized: &mut BTreeSet<ModuleId>,
        order: &mut Vec<ModuleId>,
    ) {
        if initialized.contains(module) {
            return;
        }
        for import in program.names().imports(module) {
            visit(program, import.target(), initialized, order);
        }
        if initialized.insert(module.clone()) {
            order.push(module.clone());
        }
    }

    let mut initialized = BTreeSet::new();
    let mut order = Vec::new();
    visit(
        program,
        program.graph().root(),
        &mut initialized,
        &mut order,
    );
    order
}

/// Execute one isolated conditional chain without creating a background-job
/// coordinator.
///
/// Environment entries become the child's complete immutable lexical seed, and
/// every external process inherits the caller's process group.
#[allow(clippy::too_many_arguments)]
pub fn execute_chain_subshell(
    name: impl Into<String>,
    text: impl Into<String>,
    cwd: &Path,
    environment: &mut Environment,
    registry: &CommandRegistry,
    probe: &dyn ExecutableProbe,
    options: &SessionOptions,
    platform: &dyn Platform,
    clock: Arc<dyn Clock>,
    output: &mut dyn Write,
) -> Result<ScriptCompletion, ScriptError> {
    let options = options.inherit_process_group();
    let scope = ScopeStack::from_environment(environment);
    execute_source(
        name,
        text,
        cwd,
        environment,
        scope,
        registry,
        probe,
        &options,
        platform,
        clock,
        false,
        output,
    )
}

/// Execute one decoded private job-supervisor capsule.
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn execute_background_capsule(
    capsule: BackgroundCapsule,
    registry: &CommandRegistry,
    probe: &dyn ExecutableProbe,
    platform: &dyn Platform,
    clock: Arc<dyn Clock>,
    output: &mut dyn Write,
) -> Result<(ScriptCompletion, SupervisorCompletion), ScriptError> {
    let (name, text, cwd, mut environment, current_status, scope, options) = capsule.into_parts();
    let options = options.inherit_process_group();
    let base_scope = scope.clone();
    let mut base_state = SessionState::new(&cwd, environment.clone());
    base_state.set_current_status(current_status.clone());
    let mut session = Session::with_scope_and_registry(
        scope,
        &cwd,
        environment.clone(),
        options,
        registry.clone(),
    );
    session.seed_current_status(current_status);
    let outcome = session
        .submit(name, text, probe, platform, clock.as_ref(), output)
        .map_err(ScriptError::submit)?;
    let supervisor_outcome = match outcome {
        SubmitOutcome::Continued => SupervisorOutcome::Continued,
        SubmitOutcome::Exit(code) => SupervisorOutcome::Exit(code),
    };
    let completion = finish_script_session(&mut session, &mut environment, platform, Ok(outcome))?;
    let mut updated_state = SessionState::new(session.cwd(), session.environment().clone());
    updated_state.set_current_status(session.current_status().cloned());
    let envelope = supervisor_completion(
        supervisor_outcome,
        completion.status().cloned(),
        base_scope,
        session.scope().clone(),
        base_state,
        updated_state,
    );
    Ok((completion, envelope))
}

#[allow(clippy::too_many_arguments)]
fn execute_source(
    name: impl Into<String>,
    text: impl Into<String>,
    cwd: &Path,
    environment: &mut Environment,
    scope: ScopeStack,
    registry: &CommandRegistry,
    probe: &dyn ExecutableProbe,
    options: &SessionOptions,
    platform: &dyn Platform,
    clock: Arc<dyn Clock>,
    enable_background_jobs: bool,
    output: &mut dyn Write,
) -> Result<ScriptCompletion, ScriptError> {
    let mut session = Session::with_scope_and_registry(
        scope,
        cwd,
        environment.clone(),
        *options,
        registry.clone(),
    );
    if enable_background_jobs {
        session.enable_script_job_control(Arc::clone(&clock));
    }
    let outcome = session
        .submit(name, text, probe, platform, clock.as_ref(), output)
        .map_err(ScriptError::submit);

    finish_script_session(&mut session, environment, platform, outcome)
}

fn finish_script_session(
    session: &mut Session,
    environment: &mut Environment,
    platform: &dyn Platform,
    outcome: Result<SubmitOutcome, ScriptError>,
) -> Result<ScriptCompletion, ScriptError> {
    // The join runs on every exit route, including a failing one: a script must
    // not orphan a child because one of its later statements failed.
    let failures = session.join_background_jobs(platform);
    let outcome = match outcome {
        Ok(outcome) => outcome,
        Err(error) => return Err(error.with_background_failures(failures)),
    };
    let foreground = match outcome {
        SubmitOutcome::Continued => session.current_status().cloned(),
        SubmitOutcome::Exit(code) => Some(
            Status::exit(i64::from(code), crate::Duration::ZERO)
                .expect("an explicit script exit is a valid status"),
        ),
    };
    let status = background_exit_status(&failures).or(foreground);
    *environment = session.environment().clone();
    Ok(ScriptCompletion {
        status,
        background_failures: failures,
    })
}

/// The exit status a failing background job imposes on its script.
///
/// The first failure in job-identity order wins. A quarantined record has no
/// aggregate status the platform ever established, so it contributes the
/// generic failure code rather than a status the shell would be inventing.
fn background_exit_status(failures: &[BackgroundFailure]) -> Option<Status> {
    let first = failures.first()?;
    match first.reason() {
        BackgroundFailureReason::Exited(status) => Some(status.clone()),
        BackgroundFailureReason::Observation(_) | BackgroundFailureReason::Signal(_) => {
            Some(Status::exit(1, crate::Duration::ZERO).expect("one is a valid failure status"))
        }
    }
}

Return to documentation →