use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Operator {
And,
Or,
Semi,
Pipe,
PipeErr,
Background,
}
impl Operator {
pub fn as_str(&self) -> &'static str {
match self {
Operator::And => "&&",
Operator::Or => "||",
Operator::Semi => ";",
Operator::Pipe => "|",
Operator::PipeErr => "|&",
Operator::Background => "&",
}
}
}
impl fmt::Display for Operator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct ParsedPipeline {
pub segments: Vec<ShellSegment>,
pub operators: Vec<Operator>,
pub structural_substitutions: Vec<SubstitutionSpan>,
pub has_parse_errors: bool,
}
impl ParsedPipeline {
pub fn empty_with_error() -> Self {
Self {
segments: vec![],
operators: vec![],
structural_substitutions: vec![],
has_parse_errors: true,
}
}
pub fn find_pipeline<T>(&self, f: &impl Fn(&ParsedPipeline) -> Option<T>) -> Option<T> {
if let Some(hit) = f(self) {
return Some(hit);
}
for sub in &self.structural_substitutions {
if let Some(hit) = sub.pipeline.find_pipeline(f) {
return Some(hit);
}
}
for seg in &self.segments {
for sub in &seg.substitutions {
if let Some(hit) = sub.pipeline.find_pipeline(f) {
return Some(hit);
}
}
}
None
}
pub fn any_pipeline(&self, f: &impl Fn(&ParsedPipeline) -> bool) -> bool {
self.find_pipeline(&|p| if f(p) { Some(()) } else { None })
.is_some()
}
pub fn find_segment<T>(&self, f: &impl Fn(&ShellSegment) -> Option<T>) -> Option<T> {
for sub in &self.structural_substitutions {
if let Some(hit) = sub.pipeline.find_segment(f) {
return Some(hit);
}
}
for seg in &self.segments {
for sub in &seg.substitutions {
if let Some(hit) = sub.pipeline.find_segment(f) {
return Some(hit);
}
}
if let Some(hit) = f(seg) {
return Some(hit);
}
}
None
}
pub fn filter_segments<T>(&self, f: &impl Fn(&ShellSegment) -> Option<T>) -> Vec<T> {
let mut out = Vec::new();
self.filter_segments_into(f, &mut out);
out
}
fn filter_segments_into<T>(&self, f: &impl Fn(&ShellSegment) -> Option<T>, out: &mut Vec<T>) {
for sub in &self.structural_substitutions {
sub.pipeline.filter_segments_into(f, out);
}
for seg in &self.segments {
for sub in &seg.substitutions {
sub.pipeline.filter_segments_into(f, out);
}
if let Some(hit) = f(seg) {
out.push(hit);
}
}
}
pub fn has_parse_errors_recursive(&self) -> bool {
self.any_pipeline(&|p| p.has_parse_errors)
}
}
#[derive(Debug, Clone)]
pub struct ShellSegment {
pub command: String,
pub redirection: Option<Redirection>,
pub substitutions: Vec<SubstitutionSpan>,
}
#[derive(Debug, Clone)]
pub struct SubstitutionSpan {
pub start: usize,
pub end: usize,
pub pipeline: ParsedPipeline,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Redirection {
pub operator: &'static str,
pub fd: Option<u32>,
pub target: String,
}
impl fmt::Display for Redirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.fd {
Some(fd) => write!(
f,
"output redirection ({fd}{} {})",
self.operator, self.target
),
None => write!(f, "output redirection ({} {})", self.operator, self.target),
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("tree-sitter failed to produce a syntax tree")]
pub struct ParseError;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum IndirectExecution {
Eval,
ShellSpawn,
CommandWrapper,
SourceScript,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandCharacteristics {
pub base_command: String,
pub indirect_execution: Option<IndirectExecution>,
pub has_dynamic_command: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFlag {
pub name: String,
pub value: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommandArg {
Flag(ParsedFlag),
Positional(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedCommand {
pub command: String,
pub args: Vec<CommandArg>,
}
impl ParsedCommand {
pub fn subcommand(&self) -> Option<&str> {
self.args.iter().find_map(|a| match a {
CommandArg::Positional(s) => Some(s.as_str()),
_ => None,
})
}
pub fn flags(&self) -> impl Iterator<Item = &ParsedFlag> {
self.args.iter().filter_map(|a| match a {
CommandArg::Flag(f) => Some(f),
_ => None,
})
}
pub fn positional(&self) -> impl Iterator<Item = &str> {
self.args.iter().filter_map(|a| match a {
CommandArg::Positional(s) => Some(s.as_str()),
_ => None,
})
}
pub fn has_flag(&self, name: &str) -> bool {
self.flags().any(|f| f.name == name)
}
pub fn to_words(&self) -> Vec<String> {
let mut words = vec![self.command.clone()];
for arg in &self.args {
match arg {
CommandArg::Flag(f) => match &f.value {
Some(v) => words.push(format!("{}={}", f.name, v)),
None => words.push(f.name.clone()),
},
CommandArg::Positional(s) => words.push(s.clone()),
}
}
words
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ResolvedCommand {
Resolved(ParsedCommand),
Unanalyzable(UnanalyzableCommand),
}
#[derive(Debug, Clone)]
pub struct UnanalyzableCommand {
pub command: String,
pub kind: IndirectExecution,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct WrapperSpec {
pub name: String,
#[serde(default)]
pub short_value_flags: Vec<String>,
#[serde(default)]
pub long_value_flags: Vec<String>,
#[serde(default)]
pub unanalyzable_flags: Vec<String>,
#[serde(default)]
pub skip_env_assignments: bool,
#[serde(default)]
pub has_terminator: bool,
#[serde(default)]
pub skip_positionals: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CommandConfig {
pub wrappers: Vec<WrapperSpec>,
pub shells: Vec<String>,
pub eval_commands: Vec<String>,
pub source_commands: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::super::parse_with_substitutions;
fn parse(cmd: &str) -> super::ParsedPipeline {
parse_with_substitutions(cmd).expect("parse failed")
}
#[test]
fn find_segment_returns_first_match() {
let p = parse("echo hello && ls -la");
let found = p.find_segment(&|seg| {
if seg.command.starts_with("ls") {
Some(seg.command.clone())
} else {
None
}
});
assert_eq!(found.as_deref(), Some("ls -la"));
}
#[test]
fn find_segment_returns_none_when_no_match() {
let p = parse("echo hello && ls -la");
let found = p.find_segment(&|seg| {
if seg.command.starts_with("git") {
Some(())
} else {
None
}
});
assert!(found.is_none());
}
#[test]
fn find_segment_recurses_into_substitutions() {
let p = parse("echo $(git status)");
let found = p.find_segment(&|seg| {
if seg.command.contains("git status") {
Some(seg.command.clone())
} else {
None
}
});
assert_eq!(found.as_deref(), Some("git status"));
}
#[test]
fn find_segment_visits_substitutions_before_parent() {
let p = parse("echo $(date)");
let all: Vec<String> = p.filter_segments(&|seg| Some(seg.command.clone()));
assert_eq!(all, vec!["date", "echo $(date)"]);
}
#[test]
fn find_segment_visits_structural_substitutions_first() {
let p = parse("for i in $(seq 10); do echo $i; done");
let all: Vec<String> = p.filter_segments(&|seg| Some(seg.command.clone()));
assert_eq!(all[0], "seq 10");
}
#[test]
fn filter_segments_collects_all_matches() {
let p = parse("echo a && echo b && ls c");
let echoes: Vec<String> = p.filter_segments(&|seg| {
if seg.command.starts_with("echo") {
Some(seg.command.clone())
} else {
None
}
});
assert_eq!(echoes, vec!["echo a", "echo b"]);
}
#[test]
fn filter_segments_collects_from_nested() {
let p = parse("echo $(git status && git diff)");
let gits: Vec<String> = p.filter_segments(&|seg| {
if seg.command.starts_with("git") {
Some(seg.command.clone())
} else {
None
}
});
assert_eq!(gits, vec!["git status", "git diff"]);
}
#[test]
fn no_errors_on_valid_input() {
assert!(!parse("echo hello").has_parse_errors_recursive());
}
#[test]
fn no_errors_on_compound() {
assert!(!parse("echo a && echo b | cat").has_parse_errors_recursive());
}
#[test]
fn no_errors_on_substitution() {
assert!(!parse("echo $(date)").has_parse_errors_recursive());
}
}