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;
pub(crate) struct ExpandCtx<'a> {
pub output: &'a UnboundedSender<ShellOutputLine>,
pub cancel: &'a CancellationToken,
}
struct Segment {
text: String,
literal: bool,
}
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())
}
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)
}
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,
});
}
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?);
}
WordPart::DoubleQuoted(_) => {}
}
}
segments.push(Segment {
text,
literal: true,
});
}
WordPart::Var(name) => segments.push(Segment {
text: expand_var(name, state),
literal: false,
}),
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)
}
fn expand_var(name: &str, state: &ShellState) -> String {
state.get(name).unwrap_or_default().to_string()
}
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())
}
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}"))
}
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')
}
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),
}
}
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();
let walk = MatchOptions {
require_literal_separator: true,
..Default::default()
};
let leading_dot = MatchOptions {
require_literal_leading_dot: true,
..walk
};
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)
}
fn without_trailing_separators(pattern: &str) -> &str {
let trimmed = pattern.trim_end_matches(std::path::is_separator);
if trimmed.is_empty() { pattern } else { trimmed }
}