alba-shell 0.2.0

Alba's embedded shell: a deterministic, cross-platform, POSIX-like interpreter for beam commands
Documentation
//! Word expansion: variables, quoting, field splitting, tilde
//! expansion, globbing, and command substitution.
//!
//! Each `Word` becomes a `Vec<Segment>` first (one segment per literal
//! or expanded part, tagged with whether it came from a quoted
//! context), then, for `expand_words`, split into fields on unquoted
//! whitespace and globbed. `expand_word_single` skips both steps: it
//! just concatenates every segment's text, which is what an assignment
//! value or a redirect target needs.

use std::path::{Path, PathBuf};

use glob::{MatchOptions, Pattern};
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;

use crate::ast::{Program, Word, WordPart};
use crate::interp::{Flow, ShellOutputLine, execute_captured};
use crate::state::ShellState;

/// What command substitution needs from its caller: where to forward
/// its stderr, and the cancellation token to honour.
pub(crate) struct ExpandCtx<'a> {
    pub output: &'a UnboundedSender<ShellOutputLine>,
    pub cancel: &'a CancellationToken,
}

/// One piece of a word's expansion.
///
/// `literal` marks text the shell must take exactly as written: it is
/// exempt from field splitting *and* from acting as glob syntax. Two
/// different sources set it, and they are equivalent for both purposes:
/// quoting (`SingleQuoted`, a whole `DoubleQuoted` part) and a backslash
/// escape (`Escaped`, one character). `Text` is never literal — glob
/// characters and split characters in it are live. `Var`/`CmdSubst` are
/// literal only inside a `DoubleQuoted` part, where they are folded into
/// that part's single merged segment rather than surfaced on their own.
struct Segment {
    text: String,
    literal: bool,
}

/// Expands `word` with no splitting and no globbing: every segment's
/// text concatenated in order. Used for assignment values and redirect
/// targets, neither of which is field-split or globbed.
pub(crate) async fn expand_word_single(
    word: &Word,
    state: &mut ShellState,
    ctx: &ExpandCtx<'_>,
) -> Result<String, Flow> {
    let segments = build_segments(word, state, ctx).await?;
    Ok(segments.into_iter().map(|s| s.text).collect())
}

/// Expands `words` into the final argument fields a command sees:
/// variables and command substitutions resolved, unquoted results
/// split on whitespace, and glob-eligible fields resolved against the
/// shell's cwd.
pub(crate) async fn expand_words(
    words: &[Word],
    state: &mut ShellState,
    ctx: &ExpandCtx<'_>,
) -> Result<Vec<String>, Flow> {
    let mut fields = Vec::new();
    for word in words {
        let segments = build_segments(word, state, ctx).await?;
        for piece in split_into_fields(segments) {
            let plan = plan_field(&piece);
            match plan.pattern.as_deref().and_then(|p| resolve_glob(p, state)) {
                Some(matches) => fields.extend(matches),
                None => fields.push(plan.literal),
            }
        }
    }
    Ok(fields)
}

/// Builds the segment list for one word: literal text, quoted text
/// verbatim, variables read from `state`, and command substitutions run
/// (recursively) through the interpreter. Tilde expansion is applied
/// here too, since it only ever rewrites the word's first segment.
async fn build_segments(
    word: &Word,
    state: &mut ShellState,
    ctx: &ExpandCtx<'_>,
) -> Result<Vec<Segment>, Flow> {
    let mut segments = Vec::with_capacity(word.parts.len());
    for (index, part) in word.parts.iter().enumerate() {
        match part {
            WordPart::Text(text) => {
                let text = if index == 0 {
                    tilde_expand(text, word.parts.get(1), state).unwrap_or_else(|| text.clone())
                } else {
                    text.clone()
                };
                segments.push(Segment {
                    text,
                    literal: false,
                });
            }
            // A backslash-escaped character: one literal character, never
            // split on and never glob syntax.
            WordPart::Escaped(c) => segments.push(Segment {
                text: c.to_string(),
                literal: true,
            }),
            WordPart::SingleQuoted(text) => segments.push(Segment {
                text: text.clone(),
                literal: true,
            }),
            WordPart::DoubleQuoted(inner) => {
                let mut text = String::new();
                for inner_part in inner {
                    match inner_part {
                        WordPart::Text(t) | WordPart::SingleQuoted(t) => text.push_str(t),
                        WordPart::Escaped(c) => text.push(*c),
                        WordPart::Var(name) => text.push_str(&expand_var(name, state)),
                        WordPart::LastExit => text.push_str(&state.last_exit.to_string()),
                        WordPart::CmdSubst(program) => {
                            text.push_str(&run_cmd_subst(program, state, ctx).await?);
                        }
                        // Not produced by the parser inside a
                        // `DoubleQuoted` (see `ast::WordPart`'s doc
                        // comment), kept only so this match stays
                        // exhaustive if that ever changes.
                        WordPart::DoubleQuoted(_) => {}
                    }
                }
                segments.push(Segment {
                    text,
                    literal: true,
                });
            }
            WordPart::Var(name) => segments.push(Segment {
                text: expand_var(name, state),
                literal: false,
            }),
            // `$?` is a decimal integer, so nothing in it can split or
            // glob; it is still tagged non-literal so it behaves exactly
            // like any other expansion rather than as a special case.
            WordPart::LastExit => segments.push(Segment {
                text: state.last_exit.to_string(),
                literal: false,
            }),
            WordPart::CmdSubst(program) => segments.push(Segment {
                text: run_cmd_subst(program, state, ctx).await?,
                literal: false,
            }),
        }
    }
    Ok(segments)
}

/// `$VAR`/`${VAR}`: unset expands to the empty string, no `set -u`.
fn expand_var(name: &str, state: &ShellState) -> String {
    state.get(name).unwrap_or_default().to_string()
}

/// Runs `program` with its stdout captured, forwarding its stderr to
/// the outer output sender and discarding its exit code (the frozen
/// command-substitution simplification), then strips all trailing
/// newlines (interior ones survive, to split later if unquoted).
async fn run_cmd_subst(
    program: &Program,
    state: &mut ShellState,
    ctx: &ExpandCtx<'_>,
) -> Result<String, Flow> {
    if ctx.cancel.is_cancelled() {
        return Err(Flow::Exit(130));
    }
    let (_exit_code, captured) = execute_captured(program, state, ctx).await;
    Ok(captured.trim_end_matches('\n').to_string())
}

/// `~` at the very start of a word, unquoted, followed by `/` or the
/// end of the word, expands to `state.home()`. Anything else (a `~`
/// followed by other characters, `~` past the first part, or `~` inside
/// quotes) is left literal. No `~user`.
fn tilde_expand(
    first_text: &str,
    next_part: Option<&WordPart>,
    state: &ShellState,
) -> Option<String> {
    let rest = first_text.strip_prefix('~')?;
    let followed_by_slash_or_end = if rest.is_empty() {
        match next_part {
            None => true,
            Some(WordPart::Text(next)) => next.starts_with('/'),
            _ => false,
        }
    } else {
        rest.starts_with('/')
    };
    if !followed_by_slash_or_end {
        return None;
    }
    let home = state.home()?;
    Some(format!("{home}{rest}"))
}

/// Splits one word's segments into fields: a non-literal segment's text
/// is split on runs of ASCII space/tab/newline; a literal segment (from
/// quoting or a backslash escape) is never split and always keeps the
/// field it's in "real", even when empty (`''` is one empty field). A
/// word that expands to nothing produces zero fields.
///
/// Note that a `Text` segment cannot itself contain a split character:
/// the lexer already ended the word at unquoted whitespace, and an
/// escaped space arrives as a separate literal segment. Splitting is
/// therefore only ever observable on `Var`/`CmdSubst` results.
fn split_into_fields(segments: Vec<Segment>) -> Vec<Vec<Segment>> {
    let mut fields = Vec::new();
    let mut current = Vec::new();
    let mut current_started = false;

    for segment in segments {
        if segment.literal {
            current.push(Segment {
                text: segment.text,
                literal: true,
            });
            current_started = true;
            continue;
        }

        let bytes = segment.text.as_bytes();
        let mut piece_start = 0;
        let mut i = 0;
        while i < bytes.len() {
            if is_ascii_split_whitespace(bytes[i]) {
                if i > piece_start {
                    current.push(Segment {
                        text: segment.text[piece_start..i].to_string(),
                        literal: false,
                    });
                    current_started = true;
                }
                if current_started {
                    fields.push(std::mem::take(&mut current));
                    current_started = false;
                }
                while i < bytes.len() && is_ascii_split_whitespace(bytes[i]) {
                    i += 1;
                }
                piece_start = i;
                continue;
            }
            i += 1;
        }
        if piece_start < bytes.len() {
            current.push(Segment {
                text: segment.text[piece_start..].to_string(),
                literal: false,
            });
            current_started = true;
        }
    }

    if current_started {
        fields.push(current);
    }
    fields
}

fn is_ascii_split_whitespace(byte: u8) -> bool {
    matches!(byte, b' ' | b'\t' | b'\n')
}

/// What one field resolves to before globbing: its literal text (used
/// verbatim when it is not a glob, or as the fallback when a glob
/// matches nothing) and, when any non-literal piece contains a glob
/// metacharacter, the pattern to match (literal pieces — quoted or
/// backslash-escaped — escaped with `glob::Pattern::escape` so they can
/// never themselves act as glob syntax).
struct FieldPlan {
    literal: String,
    pattern: Option<String>,
}

fn plan_field(pieces: &[Segment]) -> FieldPlan {
    let mut literal = String::new();
    let mut pattern = String::new();
    let mut has_glob_char = false;

    for piece in pieces {
        literal.push_str(&piece.text);
        if piece.literal {
            pattern.push_str(&Pattern::escape(&piece.text));
        } else {
            if piece.text.contains(['*', '?', '[']) {
                has_glob_char = true;
            }
            pattern.push_str(&piece.text);
        }
    }

    FieldPlan {
        literal,
        pattern: has_glob_char.then_some(pattern),
    }
}

/// Resolves `pattern`: `None` means no match, and the caller keeps the
/// field exactly as written. `Some` carries every match, forward-slashed
/// on every platform and sorted lexicographically. Directories match
/// like files (no trailing-slash special case).
///
/// A relative pattern resolves against `state.cwd` and yields results
/// relative to it. An absolute pattern (`/var/log/*.log`) is matched
/// as-is and yields absolute results: joining it onto `cwd` would be a
/// no-op that `Path::join` resolves to the pattern itself, after which
/// stripping the `cwd` prefix could never succeed and every absolute
/// glob would silently collapse to its literal text.
///
/// A wildcard never matches a leading dot, exactly as every POSIX shell
/// behaves: `*` skips hidden entries, and a hidden entry is reached only
/// by writing its dot out (`.env`, `.h*`). Without that rule a cleanup
/// beam running `rm -r -f *` would delete `.git`, and `cp * dist` would
/// publish `.env`.
fn resolve_glob(pattern: &str, state: &ShellState) -> Option<Vec<String>> {
    let absolute = Path::new(pattern).is_absolute();
    let full_pattern = if absolute {
        PathBuf::from(pattern)
    } else {
        state.cwd.join(pattern)
    };
    let full_pattern = full_pattern.to_string_lossy().to_string();

    // The leading-dot rule is applied by re-testing each candidate rather
    // than by handing `require_literal_leading_dot` to the directory
    // walk. `glob`'s walker reads it as "never look at a dot entry at
    // all", which also hides the ones an explicitly written dot asked
    // for, so `.h*` would match nothing; `Pattern::matches_path_with`
    // implements the real per-component rule (a wildcard may not consume
    // a dot that follows a separator, a literal dot may), which is the
    // one POSIX describes.
    let walk = MatchOptions {
        require_literal_separator: true,
        ..Default::default()
    };
    let leading_dot = MatchOptions {
        require_literal_leading_dot: true,
        ..walk
    };
    // Compiled without the trailing separator that `*/` (or `build/*/`)
    // ends on, while the walk keeps it: `glob_with` reads it as "match
    // directories only" and hands the match back *without* it, so a
    // pattern that still carried it could never match its own results.
    // Every candidate would be filtered out, the field would fall back to
    // its literal text, and `rm -r -f */` would quietly remove nothing at
    // all while reporting success.
    let compiled = Pattern::new(without_trailing_separators(&full_pattern)).ok()?;

    let mut matches: Vec<String> = glob::glob_with(&full_pattern, walk)
        .ok()?
        .filter_map(Result::ok)
        .filter(|path| compiled.matches_path_with(path, leading_dot))
        .filter_map(|path| {
            let shown = if absolute {
                path.as_path()
            } else {
                path.strip_prefix(&state.cwd).ok()?
            };
            Some(shown.to_string_lossy().replace('\\', "/"))
        })
        .collect();
    if matches.is_empty() {
        return None;
    }
    matches.sort();
    Some(matches)
}

/// `pattern` without the separators it ends on, for the re-test that the
/// leading-dot rule needs. A pattern made of nothing but separators (`/`)
/// is returned untouched rather than emptied, since an empty pattern
/// matches only an empty path.
fn without_trailing_separators(pattern: &str) -> &str {
    let trimmed = pattern.trim_end_matches(std::path::is_separator);
    if trimmed.is_empty() { pattern } else { trimmed }
}