use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
pub use crate::commands::{AssertTarget, StepKind};
use crate::constants::{
KEYWORD_ASYNC, KEYWORD_AWAIT, KEYWORD_BREAK, KEYWORD_CANCEL, KEYWORD_CONTINUE, KEYWORD_ELSE,
KEYWORD_EXPORT, KEYWORD_FOR, KEYWORD_FUNC, KEYWORD_IF, KEYWORD_IMPORT, KEYWORD_LET,
KEYWORD_REMOTE, KEYWORD_RETURN, KEYWORD_TYPE, KEYWORD_WHILE,
};
#[derive(Debug, Clone, Default)]
pub struct ModuleFuncs {
pub functions: HashSet<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ModuleTable {
pub modules: HashMap<String, Option<ModuleFuncs>>,
}
impl ModuleTable {
pub fn reserved_base_names(&self) -> HashSet<String> {
let mut out = HashSet::new();
for funcs in self.modules.values().flatten() {
out.extend(funcs.functions.iter().cloned());
}
out
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Command {
InheritEnv,
Workdir,
Workspace,
Env,
Echo,
Run,
Copy,
WithIo,
CopyGit,
HashSha256,
Symlink,
Mkdir,
Install,
Ls,
Cwd,
Read,
ReadLine,
Prompt,
Write,
Append,
Remove,
Expand,
AssertEq,
AssertContains,
Exit,
Async,
Timeout,
Sleep,
ListAppend,
}
pub const COMMANDS: &[Command] = &[
Command::InheritEnv,
Command::Workdir,
Command::Workspace,
Command::Env,
Command::Echo,
Command::Run,
Command::Copy,
Command::WithIo,
Command::CopyGit,
Command::HashSha256,
Command::Symlink,
Command::Mkdir,
Command::Install,
Command::Ls,
Command::Cwd,
Command::Read,
Command::ReadLine,
Command::Prompt,
Command::Write,
Command::Append,
Command::Remove,
Command::Expand,
Command::AssertEq,
Command::AssertContains,
Command::Exit,
Command::Timeout,
Command::Sleep,
Command::ListAppend,
];
pub const COMMAND_CATEGORIES: &[&str] =
&["INHERIT", "WITH", "COPY", "HASH", "READ", "ASSERT", "LIST"];
impl Command {
pub const fn as_str(self) -> &'static str {
match self {
Command::InheritEnv => "INHERIT_ENV",
Command::Workdir => "WORKDIR",
Command::Workspace => "WORKSPACE",
Command::Env => "ENV",
Command::Echo => "ECHO",
Command::Run => "RUN",
Command::Copy => "COPY",
Command::WithIo => "WITH_IO",
Command::CopyGit => "COPY_GIT",
Command::HashSha256 => "HASH_SHA256",
Command::Symlink => "SYMLINK",
Command::Mkdir => "MKDIR",
Command::Install => "INSTALL",
Command::Ls => "LS",
Command::Cwd => "CWD",
Command::Read => "READ",
Command::ReadLine => "READ_LINE",
Command::Prompt => "PROMPT",
Command::Write => "WRITE",
Command::Append => "APPEND",
Command::Remove => "REMOVE",
Command::Expand => "EXPAND",
Command::AssertEq => "ASSERT_EQ",
Command::AssertContains => "ASSERT_CONTAINS",
Command::Exit => "EXIT",
Command::Async => "ASYNC",
Command::Timeout => "TIMEOUT",
Command::Sleep => "SLEEP",
Command::ListAppend => "LIST_APPEND",
}
}
pub const fn syntax(self) -> &'static str {
match self {
Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
Command::Workdir => "WORKDIR <path>",
Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL|CACHE|SYSTEM [--local]",
Command::Env => "ENV KEY=value",
Command::Echo => "ECHO <message>",
Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
Command::Copy => "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
Command::WithIo => "WITH_IO [bindings] [command | { block }]",
Command::HashSha256 => "HASH_SHA256 <path>",
Command::Symlink => {
"SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>"
}
Command::Mkdir => "MKDIR <path>",
Command::Install => "INSTALL <source> <dest>",
Command::Ls => "LS [<path>]",
Command::Cwd => "CWD",
Command::Read => "READ [<path>]",
Command::ReadLine => "READ_LINE $var",
Command::Prompt => "PROMPT $var <message> [<default>]",
Command::Write => "WRITE <path> [<contents>]",
Command::Append => "APPEND <path> [<contents>]",
Command::Remove => "REMOVE [--recursive] <path>",
Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
Command::AssertEq => "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
Command::AssertContains => "ASSERT_CONTAINS <haystack> <needle>",
Command::Exit => "EXIT <code>",
Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
Command::Timeout => {
"TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
}
Command::Sleep => "SLEEP <duration>",
Command::ListAppend => "LIST_APPEND $list <item>",
}
}
pub const fn expects_inner_command(self) -> bool {
matches!(self, Command::WithIo | Command::Async | Command::Timeout)
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"INHERIT_ENV" => Some(Command::InheritEnv),
"WORKDIR" => Some(Command::Workdir),
"WORKSPACE" => Some(Command::Workspace),
"ENV" => Some(Command::Env),
"ECHO" => Some(Command::Echo),
"RUN" => Some(Command::Run),
"COPY" => Some(Command::Copy),
"WITH_IO" => Some(Command::WithIo),
"COPY_GIT" => Some(Command::CopyGit),
"HASH_SHA256" => Some(Command::HashSha256),
"SYMLINK" => Some(Command::Symlink),
"MKDIR" => Some(Command::Mkdir),
"INSTALL" => Some(Command::Install),
"LS" => Some(Command::Ls),
"CWD" => Some(Command::Cwd),
"READ" => Some(Command::Read),
"READ_LINE" => Some(Command::ReadLine),
"PROMPT" => Some(Command::Prompt),
"WRITE" => Some(Command::Write),
"APPEND" => Some(Command::Append),
"REMOVE" => Some(Command::Remove),
"EXPAND" => Some(Command::Expand),
"ASSERT_EQ" => Some(Command::AssertEq),
"ASSERT_CONTAINS" => Some(Command::AssertContains),
"EXIT" => Some(Command::Exit),
"ASYNC" => Some(Command::Async),
"TIMEOUT" => Some(Command::Timeout),
"SLEEP" => Some(Command::Sleep),
"LIST_APPEND" => Some(Command::ListAppend),
_ => None,
}
}
pub(crate) fn is_statement_keyword(s: &str) -> bool {
Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
}
}
pub const STRUCTURAL_KEYWORDS: &[&str] = &[
KEYWORD_LET,
KEYWORD_FOR,
KEYWORD_IF,
KEYWORD_ELSE,
KEYWORD_ASYNC,
KEYWORD_AWAIT,
KEYWORD_CANCEL,
KEYWORD_FUNC,
KEYWORD_RETURN,
KEYWORD_WHILE,
KEYWORD_BREAK,
KEYWORD_CONTINUE,
KEYWORD_REMOTE,
KEYWORD_IMPORT,
KEYWORD_EXPORT,
KEYWORD_TYPE,
];
pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Guard {
Attr {
ns: Ns,
key: Option<String>,
val: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ns {
Os,
Arch,
Bool,
Env,
}
impl fmt::Display for Ns {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Ns::Os => write!(f, "os"),
Ns::Arch => write!(f, "arch"),
Ns::Bool => write!(f, "bool"),
Ns::Env => write!(f, "env"),
}
}
}
pub const ARCH_VALUES: &[&str] = &[
"aarch64",
"arm",
"arm64ec",
"avr",
"bpf",
"csky",
"hexagon",
"loongarch32",
"loongarch64",
"mips",
"mips32r6",
"mips64",
"mips64r6",
"msp430",
"nvptx64",
"powerpc",
"powerpc64",
"riscv32",
"riscv64",
"s390x",
"sparc",
"sparc64",
"wasm32",
"wasm64",
"x86",
"x86_64",
"xtensa",
];
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum GuardExpr {
Predicate(Guard),
All(Vec<GuardExpr>),
Or(Vec<GuardExpr>),
Not(Box<GuardExpr>),
}
impl GuardExpr {
pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
let mut flat = Vec::new();
for expr in exprs {
match expr {
GuardExpr::All(children) => flat.extend(children),
other => flat.push(other),
}
}
match flat.len() {
0 => panic!("GuardExpr::all requires at least one expression"),
1 => flat.into_iter().next().unwrap(),
_ => GuardExpr::All(flat),
}
}
pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
let mut flat = Vec::new();
for expr in exprs {
match expr {
GuardExpr::Or(children) => flat.extend(children),
other => flat.push(other),
}
}
match flat.len() {
0 => panic!("GuardExpr::or requires at least one expression"),
1 => flat.into_iter().next().unwrap(),
_ => GuardExpr::Or(flat),
}
}
pub fn invert(expr: GuardExpr) -> GuardExpr {
match expr {
GuardExpr::Not(inner) => *inner,
other => GuardExpr::Not(Box::new(other)),
}
}
}
impl std::ops::Not for GuardExpr {
type Output = GuardExpr;
fn not(self) -> GuardExpr {
match self {
GuardExpr::Not(inner) => *inner,
other => GuardExpr::Not(Box::new(other)),
}
}
}
impl From<Guard> for GuardExpr {
fn from(guard: Guard) -> Self {
GuardExpr::Predicate(guard)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Arg {
String(String, bool),
Expr(Expr),
Parts(Vec<ArgPart>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum ArgPart {
Text(String, bool),
Expr(Expr),
}
impl Arg {
pub fn as_str(&self) -> &str {
match self {
Arg::String(s, _) => s,
Arg::Expr(_) | Arg::Parts(_) => "",
}
}
pub fn render(&self) -> String {
match self {
Arg::String(s, _) => s.clone(),
Arg::Expr(e) => e.to_string(),
Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
}
}
pub fn is_quoted(&self) -> bool {
matches!(self, Arg::String(_, true))
}
}
impl ArgPart {
pub fn render(&self) -> String {
match self {
ArgPart::Text(s, _) => s.clone(),
ArgPart::Expr(e) => e.to_string(),
}
}
}
impl From<String> for Arg {
fn from(s: String) -> Self {
Arg::String(s, false)
}
}
impl From<&str> for Arg {
fn from(s: &str) -> Self {
Arg::String(s.to_string(), false)
}
}
impl std::fmt::Display for Arg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Arg::String(s, _) => write!(f, "{}", s),
Arg::Expr(e) => write!(f, "{}", e),
Arg::Parts(parts) => {
for part in parts {
match part {
ArgPart::Text(s, _) => write!(f, "{}", s)?,
ArgPart::Expr(e) => write!(f, "{}", e)?,
}
}
Ok(())
}
}
}
}
impl AsRef<str> for Arg {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl PartialEq<str> for Arg {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<&str> for Arg {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum IoStream {
Stdin,
Stdout,
Stderr,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct IoBinding {
pub stream: IoStream,
pub pipe: Option<PipeTarget>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum PipeTarget {
Var(String),
}
pub use crate::value::{
OxDockType, SemaphoreState, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy,
clone_shared, drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed,
fmt_inline, fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor,
unshare_boxed, unshare_inline, unshare_shared,
};
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum CompareOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ArithOp {
Add,
Sub,
Mul,
Div,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MathOp {
PushConst(Value),
LoadVar(String),
LoadEnv(String),
LoadKeyPath { base: String, keys: Vec<String> },
Call { name: String, arity: usize },
Inspect(String),
Neg,
Add,
Sub,
Mul,
Div,
Lt,
Le,
Gt,
Ge,
Eq,
Ne,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum LogicalOp {
And,
Or,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Literal(Value),
Var(String),
Env(String),
KeyPath {
base: String,
keys: Vec<String>,
},
List(Vec<Expr>),
Map(Vec<(String, Expr)>),
Block(Vec<Step>),
Call {
name: String,
args: Vec<Expr>,
},
Inspect(String),
Compare {
op: CompareOp,
left: Box<Expr>,
right: Box<Expr>,
},
Arithmetic {
op: ArithOp,
left: Box<Expr>,
right: Box<Expr>,
},
CompiledMath(Vec<MathOp>),
FreshPipe,
UnsignedIntBoundary(u64),
Not(Box<Expr>),
Logical {
op: LogicalOp,
left: Box<Expr>,
right: Box<Expr>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Step {
pub guard: Option<GuardExpr>,
pub kind: StepKind,
pub scope_enter: usize,
pub scope_exit: usize,
}
impl Expr {
pub fn walk(&self, f: &mut impl FnMut(&Expr)) {
f(self);
match self {
Expr::Literal(_)
| Expr::Var(_)
| Expr::Env(_)
| Expr::KeyPath { .. }
| Expr::Inspect(_)
| Expr::FreshPipe
| Expr::UnsignedIntBoundary(_) => {}
Expr::Call { args, .. } => {
for arg in args {
arg.walk(f);
}
}
Expr::List(items) => {
for item in items {
item.walk(f);
}
}
Expr::Map(entries) => {
for (_, value) in entries {
value.walk(f);
}
}
Expr::Block(steps) => {
for step in steps {
step.kind.walk_step_exprs(f);
}
}
Expr::Compare { left, right, .. }
| Expr::Arithmetic { left, right, .. }
| Expr::Logical { left, right, .. } => {
left.walk(f);
right.walk(f);
}
Expr::CompiledMath(_) => {}
Expr::Not(inner) => inner.walk(f),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum WorkspaceTarget {
Snapshot,
Local,
Cache { local: bool },
System,
}
pub trait EnvLookup {
fn get_env(&self, key: &str) -> Option<&str>;
}
impl EnvLookup for HashMap<String, String> {
fn get_env(&self, key: &str) -> Option<&str> {
self.get(key).map(|s| s.as_str())
}
}
impl EnvLookup for Arc<HashMap<String, String>> {
fn get_env(&self, key: &str) -> Option<&str> {
(**self).get_env(key)
}
}
pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
let Guard::Attr { ns, key, val } = guard;
match ns {
Ns::Os => match val.as_deref() {
Some("macos") => platform_is_macos(),
Some("linux") => platform_is_linux(),
Some("windows") => platform_is_windows(),
_ => false,
},
Ns::Arch => match val.as_deref() {
Some(want) => ARCH_TARGET == want,
None => false,
},
Ns::Bool => val
.as_deref()
.map(|v| v.parse::<bool>().unwrap_or(false))
.unwrap_or(false),
Ns::Env => match key.as_deref() {
Some(var) => match val {
None => env.get_env(var).map(|v| !v.is_empty()).unwrap_or(false),
Some(want) => env
.get_env(var)
.map(|v| v == want.as_str())
.unwrap_or(false),
},
None => false,
},
}
}
#[allow(clippy::disallowed_macros)]
fn platform_is_windows() -> bool {
cfg!(windows)
}
#[allow(clippy::disallowed_macros)]
fn platform_is_macos() -> bool {
cfg!(target_os = "macos")
}
#[allow(clippy::disallowed_macros)]
fn platform_is_linux() -> bool {
cfg!(target_os = "linux")
}
const ARCH_TARGET: &str = std::env::consts::ARCH;
pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
match expr {
GuardExpr::Predicate(guard) => guard_allows(guard, env),
GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
GuardExpr::Not(child) => !guard_expr_allows(child, env),
}
}
pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
match expr {
Some(e) => guard_expr_allows(e, env),
None => true,
}
}
impl Guard {
pub fn is_disjoint_from(&self, other: &Self) -> bool {
match (self, other) {
(
Guard::Attr {
ns: n1,
key: k1,
val: Some(v1),
},
Guard::Attr {
ns: n2,
key: k2,
val: Some(v2),
},
) => n1 == n2 && k1 == k2 && v1 != v2,
_ => false,
}
}
}
impl GuardExpr {
pub fn is_disjoint_from(&self, other: &Self) -> bool {
match (self, other) {
(x, GuardExpr::Not(inner)) | (GuardExpr::Not(inner), x) => {
x == inner.as_ref() || unset_excludes(x, inner.as_ref())
}
(GuardExpr::Predicate(p1), GuardExpr::Predicate(p2)) => p1.is_disjoint_from(p2),
(GuardExpr::Or(arms), other) | (other, GuardExpr::Or(arms)) => {
arms.iter().all(|arm| arm.is_disjoint_from(other))
}
(GuardExpr::All(arms), other) | (other, GuardExpr::All(arms)) => {
arms.iter().any(|arm| arm.is_disjoint_from(other))
}
}
}
}
fn conjuncts(expr: &GuardExpr) -> Vec<&GuardExpr> {
match expr {
GuardExpr::All(items) => items.iter().collect(),
single => vec![single],
}
}
fn closed_domain_values(ns: Ns, key: Option<&str>) -> Option<&'static [&'static str]> {
match (ns, key) {
(Ns::Bool, None) => Some(&["true", "false"]),
(Ns::Os, None) => Some(&["macos", "linux", "windows"]),
(Ns::Arch, None) => Some(ARCH_VALUES),
_ => None,
}
}
#[derive(Clone)]
enum DnfLit {
Pos(Guard),
Neg(Guard),
}
impl DnfLit {
fn as_expr(&self) -> GuardExpr {
match self {
DnfLit::Pos(guard) => GuardExpr::Predicate(guard.clone()),
DnfLit::Neg(guard) => GuardExpr::Not(Box::new(GuardExpr::Predicate(guard.clone()))),
}
}
}
const MAX_DNF_DISJUNCTS: usize = 64;
fn dnf_push(expr: &GuardExpr, polarity: bool, out: &mut Vec<Vec<DnfLit>>) -> bool {
match (expr, polarity) {
(GuardExpr::Predicate(guard), true) => {
for disjunct in out.iter_mut() {
disjunct.push(DnfLit::Pos(guard.clone()));
}
true
}
(GuardExpr::Predicate(guard), false) => {
for disjunct in out.iter_mut() {
disjunct.push(DnfLit::Neg(guard.clone()));
}
true
}
(GuardExpr::All(children), true) | (GuardExpr::Or(children), false) => {
for child in children {
if !dnf_push(child, polarity, out) {
return false;
}
}
true
}
(GuardExpr::Or(children), true) | (GuardExpr::All(children), false) => {
let mut forked = Vec::new();
for disjunct in out.drain(..) {
for child in children {
let mut branch = vec![disjunct.clone()];
if !dnf_push(child, polarity, &mut branch) {
return false;
}
forked.extend(branch);
if forked.len() > MAX_DNF_DISJUNCTS {
return false;
}
}
}
*out = forked;
true
}
(GuardExpr::Not(inner), _) => dnf_push(inner, !polarity, out),
}
}
fn disjunct_satisfiable(lits: &[DnfLit]) -> bool {
for lit in lits {
match lit {
DnfLit::Pos(Guard::Attr {
ns: Ns::Bool, val, ..
}) if val.as_deref() != Some("true") => return false,
DnfLit::Neg(Guard::Attr {
ns: Ns::Bool,
key: None,
val: Some(value),
}) if value == "true" => return false,
_ => {}
}
}
for (index, first) in lits.iter().enumerate() {
for second in &lits[index + 1..] {
if first.as_expr().is_disjoint_from(&second.as_expr()) {
return false;
}
}
}
for lit in lits {
let (ns, key) = match lit {
DnfLit::Neg(Guard::Attr {
ns,
key,
val: Some(_),
}) => (*ns, key.as_deref()),
_ => continue,
};
let Some(domain) = closed_domain_values(ns, key) else {
continue;
};
let exhausted = domain.iter().all(|value| {
lits.iter().any(|other| match other {
DnfLit::Neg(Guard::Attr {
ns: other_ns,
key: other_key,
val: Some(other_val),
}) => *other_ns == ns && other_key.as_deref() == key && other_val == value,
_ => false,
})
});
if exhausted {
return false;
}
}
true
}
fn conjunction_dead(atoms: &[&GuardExpr]) -> bool {
if atoms.is_empty() {
return false;
}
let mut disjuncts = vec![Vec::new()];
for atom in atoms {
if !dnf_push(atom, true, &mut disjuncts) {
return false;
}
}
!disjuncts
.iter()
.any(|disjunct| disjunct_satisfiable(disjunct))
}
pub fn guard_satisfiable(expr: &GuardExpr) -> bool {
!conjunction_dead(&conjuncts(expr))
}
pub fn guard_valid(expr: &GuardExpr) -> bool {
match expr {
GuardExpr::Predicate(Guard::Attr {
ns: Ns::Bool,
val: Some(value),
..
}) => value.as_str() == "true",
GuardExpr::All(items) => items.iter().all(guard_valid),
GuardExpr::Not(inner) => !guard_satisfiable(inner),
_ => false,
}
}
pub fn guards_overlap(a: Option<&GuardExpr>, b: Option<&GuardExpr>) -> bool {
match (a, b) {
(None, None) => true,
(Some(x), None) | (None, Some(x)) => guard_satisfiable(x),
(Some(x), Some(y)) => {
let mut atoms = conjuncts(x);
atoms.extend(conjuncts(y));
!conjunction_dead(&atoms)
}
}
}
fn unset_excludes(x: &GuardExpr, inner: &GuardExpr) -> bool {
match (x, inner) {
(
GuardExpr::Predicate(Guard::Attr {
ns: n2, key: k2, ..
}),
GuardExpr::Predicate(Guard::Attr {
ns: n1,
key: k1,
val: None,
}),
) => n1 == n2 && k1 == k2,
_ => false,
}
}
impl fmt::Display for Guard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Guard::Attr {
ns: Ns::Env,
key: Some(var),
val: None,
} => write!(f, "env:{var}"),
Guard::Attr {
ns: Ns::Env,
key: Some(var),
val: Some(want),
} => write!(f, "eq(env:{var}, {want})"),
Guard::Attr {
ns,
key: _,
val: Some(value),
} => write!(f, "{ns}:{value}"),
Guard::Attr { ns, key, val: None } => match key {
Some(subject) => write!(f, "{ns}:{subject}"),
None => write!(f, "{ns}"),
},
}
}
}
impl fmt::Display for WorkspaceTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
WorkspaceTarget::Local => write!(f, "LOCAL"),
WorkspaceTarget::Cache { local: false } => write!(f, "CACHE"),
WorkspaceTarget::Cache { local: true } => write!(f, "CACHE --local"),
WorkspaceTarget::System => write!(f, "SYSTEM"),
}
}
}
impl fmt::Display for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expr::Literal(v) => write!(f, "{}", v),
Expr::Var(name) => write!(f, "${}", name),
Expr::Env(key) => write!(f, "env:{}", key),
Expr::KeyPath { base, keys } => {
write!(f, "${}", base)?;
for key in keys {
write!(f, ".{}", key)?;
}
Ok(())
}
Expr::Call { name, args } => {
write!(f, "{}(", name)?;
for (i, arg) in args.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{}", arg)?;
}
write!(f, ")")
}
Expr::Inspect(var) => write!(f, "INSPECT(${})", var),
Expr::List(items) => {
write!(f, "[")?;
for (i, item) in items.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{}", item)?;
}
write!(f, "]")
}
Expr::Map(entries) => {
write!(f, "{{")?;
for (i, (key, val)) in entries.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "\"{}\": {}", key, val)?;
}
write!(f, "}}")
}
Expr::Block(steps) => {
write!(f, "{{ ")?;
for (i, step) in steps.iter().enumerate() {
if i > 0 {
write!(f, "; ")?;
}
write!(f, "{}", step.kind)?;
}
write!(f, " }}")
}
Expr::Compare { op, left, right } => {
write!(f, "{} {} {}", left, op, right)
}
Expr::Arithmetic { op, left, right } => {
write!(f, "({} {} {})", left, op, right)
}
Expr::CompiledMath(ops) => {
write!(f, "{}", format_compiled_math(ops))
}
Expr::FreshPipe => write!(f, "<fresh pipe>"),
Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
Expr::Not(inner) => {
match inner.as_ref() {
Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
write!(f, "!({})", inner)
}
_ => write!(f, "!{}", inner),
}
}
Expr::Logical { op, left, right } => {
write!(f, "({} {} {})", left, op, right)
}
}
}
}
impl fmt::Display for CompareOp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CompareOp::Eq => write!(f, "=="),
CompareOp::Ne => write!(f, "!="),
CompareOp::Lt => write!(f, "<"),
CompareOp::Le => write!(f, "<="),
CompareOp::Gt => write!(f, ">"),
CompareOp::Ge => write!(f, ">="),
}
}
}
impl fmt::Display for ArithOp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArithOp::Add => write!(f, "+"),
ArithOp::Sub => write!(f, "-"),
ArithOp::Mul => write!(f, "*"),
ArithOp::Div => write!(f, "/"),
}
}
}
fn format_compiled_math(ops: &[MathOp]) -> String {
let mut stack: Vec<String> = Vec::new();
for op in ops {
match op {
MathOp::PushConst(v) => stack.push(format!("{}", v)),
MathOp::LoadVar(name) => stack.push(format!("${}", name)),
MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
MathOp::LoadKeyPath { base, keys } => {
let mut s = format!("${}", base);
for key in keys {
s.push('.');
s.push_str(key);
}
stack.push(s);
}
MathOp::Call { name, arity } => {
let mut args = Vec::new();
for _ in 0..*arity {
args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
}
args.reverse();
stack.push(format!("{}({})", name, args.join(", ")));
}
MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
MathOp::Neg => {
let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
stack.push(format!("(-{})", inner));
}
MathOp::Add => push_bin(&mut stack, "+"),
MathOp::Sub => push_bin(&mut stack, "-"),
MathOp::Mul => push_bin(&mut stack, "*"),
MathOp::Div => push_bin(&mut stack, "/"),
MathOp::Lt => push_bin(&mut stack, "<"),
MathOp::Le => push_bin(&mut stack, "<="),
MathOp::Gt => push_bin(&mut stack, ">"),
MathOp::Ge => push_bin(&mut stack, ">="),
MathOp::Eq => push_bin(&mut stack, "=="),
MathOp::Ne => push_bin(&mut stack, "!="),
}
}
if stack.len() == 1 {
let mut items = stack;
items.pop().unwrap_or_else(|| "<empty>".to_string())
} else {
stack.join(" ")
}
}
fn push_bin(stack: &mut Vec<String>, op: &str) {
let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
stack.push(format!("({} {} {})", left, op, right));
}
impl fmt::Display for LogicalOp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LogicalOp::And => write!(f, "&&"),
LogicalOp::Or => write!(f, "||"),
}
}
}
enum GuardDisplayContext {
Root,
InAnyArg,
InNot,
InAll,
}
impl GuardExpr {
fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
match self {
GuardExpr::Predicate(guard) => write!(f, "{}", guard),
GuardExpr::All(children) => {
let wrap = matches!(
ctx,
GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
) && children.len() > 1;
if wrap {
write!(f, "(")?;
}
for (i, child) in children.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
}
if wrap {
write!(f, ")")?;
}
Ok(())
}
GuardExpr::Or(children) => {
write!(f, "any(")?;
for (i, child) in children.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
}
write!(f, ")")
}
GuardExpr::Not(child) => {
write!(f, "not(")?;
child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
write!(f, ")")
}
}
}
}
impl fmt::Display for GuardExpr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.fmt_with_ctx(f, GuardDisplayContext::Root)
}
}
impl fmt::Display for Step {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(expr) = &self.guard {
write!(f, "[{}] ", expr)?;
}
write!(f, "{}", self.kind)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn statement_keywords_match_pest_grammar() {
use pest_meta::{ast::Expr, parser};
use std::collections::{HashMap, HashSet};
let pest_src = include_str!("dsl.pest");
let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
.expect("dsl.pest must parse as a pest grammar");
let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
let by_name: HashMap<&str, &Expr> = rules
.iter()
.map(|rule| (rule.name.as_str(), &rule.expr))
.collect();
let mut all_literals = HashSet::new();
for rule in &rules {
for node in rule.expr.iter_top_down() {
match node {
Expr::Str(literal) | Expr::Insens(literal) => {
all_literals.insert(literal.clone());
}
_ => {}
}
}
}
for kw in STRUCTURAL_KEYWORDS {
assert!(
all_literals.contains(*kw),
"keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
);
}
let mut seen = HashSet::new();
let mut stack = vec!["element".to_string(), "block_element".to_string()];
let mut stmt_literals = HashSet::new();
while let Some(name) = stack.pop() {
if !seen.insert(name.clone()) {
continue;
}
let Some(expr) = by_name.get(name.as_str()) else {
continue;
};
for node in expr.iter_top_down() {
match node {
Expr::Str(literal) | Expr::Insens(literal) => {
stmt_literals.insert(literal.clone());
}
Expr::Ident(dependency) => stack.push(dependency.clone()),
_ => {}
}
}
}
assert!(
seen.contains("element") && seen.contains("block_element"),
"grammar must define element and block_element instruction rules"
);
for kw in stmt_literals {
if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
assert!(
crate::Command::is_statement_keyword(&kw)
|| CLAUSE_KEYWORDS.contains(&kw.as_str()),
"uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
);
}
}
}
#[test]
fn command_names_group_by_category() {
for command in COMMANDS {
let name = command.as_str();
let Some((prefix, _)) = name.split_once('_') else {
continue;
};
assert!(
COMMAND_CATEGORIES.contains(&prefix),
"command {name} introduces unregistered category {prefix}; add it to COMMAND_CATEGORIES"
);
}
}
}
#[cfg(test)]
mod guard_analysis_tests {
use super::*;
fn attr(ns: Ns, key: Option<&str>, val: Option<&str>) -> GuardExpr {
GuardExpr::Predicate(Guard::Attr {
ns,
key: key.map(str::to_string),
val: val.map(str::to_string),
})
}
fn os(value: &str) -> GuardExpr {
attr(Ns::Os, None, Some(value))
}
fn arch(value: &str) -> GuardExpr {
attr(Ns::Arch, None, Some(value))
}
fn unix() -> GuardExpr {
GuardExpr::Or(vec![os("macos"), os("linux")])
}
fn windows() -> GuardExpr {
os("windows")
}
fn env_eq(var: &str, value: &str) -> GuardExpr {
attr(Ns::Env, Some(var), Some(value))
}
fn not(expr: GuardExpr) -> GuardExpr {
GuardExpr::Not(Box::new(expr))
}
#[test]
fn partition_negations_are_jointly_dead() {
let os_exhausted = GuardExpr::All(vec![not(os("macos")), not(os("linux")), not(windows())]);
assert!(
!guard_satisfiable(&os_exhausted),
"no host is outside `macos`/`linux`/`windows`"
);
let os_partial = GuardExpr::All(vec![not(os("macos")), not(os("linux"))]);
assert!(guard_satisfiable(&os_partial), "`windows` is still open");
let bool_exhausted = GuardExpr::All(vec![
not(attr(Ns::Bool, None, Some("true"))),
not(attr(Ns::Bool, None, Some("false"))),
]);
assert!(!guard_satisfiable(&bool_exhausted));
let env_open = GuardExpr::All(vec![
not(env_eq("MODE", "dev")),
not(env_eq("MODE", "prod")),
]);
assert!(guard_satisfiable(&env_open), "MODE may hold a third value");
assert!(guard_satisfiable(¬(unix())));
}
#[test]
fn unix_alias_is_os_disjunction() {
assert!(!guards_overlap(Some(&unix()), Some(&windows())));
assert!(guards_overlap(Some(&unix()), Some(&os("macos"))));
let covered = GuardExpr::All(vec![GuardExpr::Not(Box::new(unix())), not(windows())]);
assert!(!guard_satisfiable(&covered));
}
#[test]
fn arch_guards_decide_by_value() {
assert!(!guards_overlap(
Some(&arch("x86_64")),
Some(&arch("aarch64"))
));
assert!(guards_overlap(Some(&arch("x86_64")), Some(&arch("x86_64"))));
assert!(guards_overlap(Some(&arch("aarch64")), Some(&os("macos"))));
let exhausted = GuardExpr::All(ARCH_VALUES.iter().map(|value| not(arch(value))).collect());
assert!(!guard_satisfiable(&exhausted));
let host = attr(Ns::Arch, None, Some(ARCH_TARGET));
assert!(guard_expr_allows(&host, &HashMap::new()));
}
#[test]
fn platform_overlap_matrix_is_exhaustive() {
let cases = [
("macos", "macos", true),
("macos", "linux", false),
("macos", "windows", false),
("linux", "linux", true),
("linux", "windows", false),
("windows", "windows", true),
];
for (v1, v2, expected) in cases {
let left = os(v1);
let right = os(v2);
assert_eq!(
guards_overlap(Some(&left), Some(&right)),
expected,
"os:{v1} vs os:{v2}"
);
}
}
#[test]
fn disjoint_pairs_never_cofire_here() {
let macos = os("macos");
let holds = |expr: &GuardExpr| guard_expr_allows(expr, &HashMap::new());
assert!(
!(holds(&macos) && holds(&windows())),
"os:macos and os:windows co-fired"
);
}
#[test]
fn overlap_follows_namespace_semantics() {
assert!(!guards_overlap(Some(&windows()), Some(&unix())));
assert!(!guards_overlap(Some(&os("macos")), Some(&os("linux"))));
assert!(guards_overlap(None, Some(&windows())));
assert!(guards_overlap(None, None));
let dead = GuardExpr::All(vec![os("macos"), windows()]);
assert!(!guard_satisfiable(&dead));
assert!(!guards_overlap(Some(&dead), Some(&os("macos"))));
assert!(guard_valid(&GuardExpr::Predicate(Guard::Attr {
ns: Ns::Bool,
key: None,
val: Some("true".to_string()),
})));
assert!(!guard_valid(&unix()));
}
#[test]
fn env_value_conflicts_decide_without_enumeration() {
let fast = env_eq("MODE", "fast");
let slow = env_eq("MODE", "slow");
assert!(
!guards_overlap(Some(&fast), Some(&slow)),
"same key distinct values are disjoint"
);
let also_fast = env_eq("MODE", "fast");
assert!(guards_overlap(Some(&fast), Some(&also_fast)));
let bare = attr(Ns::Env, Some("MODE"), None);
assert!(guards_overlap(Some(&bare), Some(&fast)));
}
}
#[cfg(test)]
mod guard_algebra_tests {
use super::*;
fn os(value: &str) -> GuardExpr {
GuardExpr::Predicate(Guard::Attr {
ns: Ns::Os,
key: None,
val: Some(value.to_string()),
})
}
#[test]
fn or_distributes_over_disjointness() {
fn env_eq(var: &str, value: &str) -> GuardExpr {
GuardExpr::Predicate(Guard::Attr {
ns: Ns::Env,
key: Some(var.to_string()),
val: Some(value.to_string()),
})
}
let mixed = GuardExpr::Or(vec![env_eq("MODE", "dev"), env_eq("MODE", "prod")]);
assert!(!mixed.is_disjoint_from(&env_eq("MODE", "dev")));
assert!(mixed.is_disjoint_from(&env_eq("MODE", "test")));
let cross = GuardExpr::Or(vec![env_eq("MODE", "dev")]);
let platform = os("windows");
assert!(!cross.is_disjoint_from(&platform));
}
#[test]
fn all_distributes_over_disjointness() {
let stacked = GuardExpr::All(vec![
os("macos"),
GuardExpr::Or(vec![os("macos"), os("linux")]),
os("windows"),
]);
assert!(stacked.is_disjoint_from(&os("macos")));
let live = GuardExpr::All(vec![
os("macos"),
GuardExpr::Predicate(Guard::Attr {
ns: Ns::Env,
key: Some("MODE".to_string()),
val: Some("dev".to_string()),
}),
]);
assert!(!live.is_disjoint_from(&os("macos")));
}
#[test]
fn negation_is_structural_not_syntactic() {
let not_win = GuardExpr::Not(Box::new(os("windows")));
assert!(not_win.is_disjoint_from(&os("windows")));
assert!(os("windows").is_disjoint_from(¬_win));
assert!(!not_win.is_disjoint_from(&os("macos")));
}
}