use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::Arc;
use crate::calcit::{CalcitErr, CalcitErrKind, CalcitProc, CalcitSyntax, MacroCapability, MethodKind, NodeLocation};
use crate::call_stack::{CallStackList, StackKind};
#[derive(Debug, Clone)]
struct MacroExecutionContext {
macro_name: Arc<str>,
declared: Arc<HashSet<MacroCapability>>,
call_location: Option<NodeLocation>,
}
thread_local! {
static MACRO_EXECUTION_STACK: RefCell<Vec<MacroExecutionContext>> = const { RefCell::new(vec![]) };
}
struct ContextGuard;
impl Drop for ContextGuard {
fn drop(&mut self) {
MACRO_EXECUTION_STACK.with(|stack| {
stack.borrow_mut().pop();
});
}
}
pub fn with_macro_context<T>(
macro_name: Arc<str>,
declared: Arc<HashSet<MacroCapability>>,
call_location: Option<NodeLocation>,
f: impl FnOnce() -> Result<T, CalcitErr>,
) -> Result<T, CalcitErr> {
MACRO_EXECUTION_STACK.with(|stack| {
stack.borrow_mut().push(MacroExecutionContext {
macro_name,
declared,
call_location,
});
});
let _guard = ContextGuard;
f()
}
#[derive(Debug, Clone, Copy)]
enum CapabilityPolicy {
Requires(MacroCapability),
Forbidden(MacroCapability),
}
impl CapabilityPolicy {
fn capability(self) -> MacroCapability {
match self {
Self::Requires(capability) | Self::Forbidden(capability) => capability,
}
}
}
fn proc_policy(proc: CalcitProc) -> Option<CapabilityPolicy> {
use CalcitProc::*;
let required = match proc {
GetEnv => MacroCapability::EnvRead,
ReadFile | ReadDir => MacroCapability::FsRead,
NativeGetOs | NativeGetCalcitBackend | NativeGetCalcitRunningMode => MacroCapability::PlatformRead,
UnixTimeMs | CpuTime => MacroCapability::ClockRead,
GenerateId
| NativeResetGenSymIndex
| RegisterCalcitBuiltinImpls
| NativeBufListNew
| NativeBufListPush
| NativeBufListConcat
| Atom
| AtomDeref
| AddWatch
| RemoveWatch => MacroCapability::MutableState,
WriteFile => return Some(CapabilityPolicy::Forbidden(MacroCapability::FsWrite)),
Quit => return Some(CapabilityPolicy::Forbidden(MacroCapability::Process)),
_ => return None,
};
Some(CapabilityPolicy::Requires(required))
}
fn syntax_policy(syntax: &CalcitSyntax) -> Option<CapabilityPolicy> {
match syntax {
CalcitSyntax::Eval => Some(CapabilityPolicy::Requires(MacroCapability::DynamicEval)),
CalcitSyntax::Defatom | CalcitSyntax::Reset => Some(CapabilityPolicy::Requires(MacroCapability::MutableState)),
_ => None,
}
}
fn helper_chain(call_stack: &CallStackList) -> String {
let chain = call_stack
.0
.iter()
.filter(|frame| matches!(frame.kind, StackKind::Fn | StackKind::Macro | StackKind::Syntax | StackKind::Method))
.map(|frame| format!("{}/{}", frame.ns, frame.def))
.collect::<Vec<_>>();
if chain.is_empty() {
"(direct call)".to_owned()
} else {
chain.join(" -> ")
}
}
fn check_policy(policy: CapabilityPolicy, operation: &str, call_stack: &CallStackList) -> Result<(), CalcitErr> {
let context = MACRO_EXECUTION_STACK.with(|stack| stack.borrow().last().cloned());
let Some(context) = context else {
return Ok(());
};
let capability = policy.capability();
let (code, reason) = match policy {
CapabilityPolicy::Forbidden(_) => (
"E_MACRO_CAPABILITY_DISALLOWED",
format!("compile-time capability :{} is disallowed by policy", capability.as_str()),
),
CapabilityPolicy::Requires(_) if !context.declared.contains(&capability) => (
"E_MACRO_CAPABILITY_MISSING",
format!("compile-time capability :{} was not declared", capability.as_str()),
),
CapabilityPolicy::Requires(_) => return Ok(()),
};
let message = format!(
"macro `{}` cannot execute `{operation}`: {reason}\n helper-chain: {}",
context.macro_name,
helper_chain(call_stack)
);
let mut error = CalcitErr::use_msg_stack_location_with_code(CalcitErrKind::Effect, message, code, call_stack, context.call_location);
error.hint = Some(if capability.is_allowed() {
format!(
"Declare `:capabilities $ #{{}} :{}` on the strict Macro signature, or move the effect into generated runtime code.",
capability.as_str()
)
} else {
format!(
"Compile-time :{} cannot be enabled. Move the operation into generated runtime code or remove the host effect.",
capability.as_str()
)
});
Err(error)
}
pub fn check_proc(proc: CalcitProc, call_stack: &CallStackList) -> Result<(), CalcitErr> {
match proc_policy(proc) {
Some(policy) => check_policy(policy, proc.as_ref(), call_stack),
None => Ok(()),
}
}
pub fn check_syntax(syntax: &CalcitSyntax, call_stack: &CallStackList) -> Result<(), CalcitErr> {
match syntax_policy(syntax) {
Some(policy) => check_policy(policy, syntax.as_ref(), call_stack),
None => Ok(()),
}
}
pub fn check_registered(alias: &str, call_stack: &CallStackList) -> Result<(), CalcitErr> {
let policy = match alias {
"echo" | "println" | "eprintln" => CapabilityPolicy::Requires(MacroCapability::Log),
_ => CapabilityPolicy::Forbidden(MacroCapability::HostFfi),
};
check_policy(policy, alias, call_stack)
}
pub fn check_host_ffi(operation: &str, call_stack: &CallStackList) -> Result<(), CalcitErr> {
check_policy(CapabilityPolicy::Forbidden(MacroCapability::HostFfi), operation, call_stack)
}
pub fn check_method(name: &str, kind: &MethodKind, call_stack: &CallStackList) -> Result<(), CalcitErr> {
if matches!(kind, MethodKind::Invoke(_) | MethodKind::TagAccess) {
Ok(())
} else {
check_policy(
CapabilityPolicy::Forbidden(MacroCapability::HostFfi),
&format!(".{name}"),
call_stack,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calcit::{Calcit, CalcitScope};
#[test]
fn pure_context_rejects_env_read() {
let error = with_macro_context(Arc::from("app/read-env"), Arc::new(HashSet::new()), None, || {
check_proc(CalcitProc::GetEnv, &CallStackList::default())
})
.expect_err("env read must need a declaration");
assert_eq!(error.code(), Some("E_MACRO_CAPABILITY_MISSING"));
assert!(error.msg.contains(":env-read"));
}
#[test]
fn declared_env_read_is_allowed() {
let declared = Arc::new(HashSet::from([MacroCapability::EnvRead]));
with_macro_context(Arc::from("app/read-env"), declared, None, || {
check_proc(CalcitProc::GetEnv, &CallStackList::default())
})
.expect("declared env read should pass");
}
#[test]
fn registered_console_output_requires_log_capability() {
for alias in ["echo", "println", "eprintln"] {
let error = with_macro_context(Arc::from("app/log"), Arc::new(HashSet::new()), None, || {
check_registered(alias, &CallStackList::default())
})
.expect_err("compile-time console output must need a declaration");
assert_eq!(error.code(), Some("E_MACRO_CAPABILITY_MISSING"));
assert!(error.msg.contains(":log"));
let declared = Arc::new(HashSet::from([MacroCapability::Log]));
with_macro_context(Arc::from("app/log"), declared, None, || {
check_registered(alias, &CallStackList::default())
})
.expect("declared compile-time console output should pass");
}
}
#[test]
fn unrelated_registered_procedures_remain_forbidden_host_ffi() {
let declared = Arc::new(HashSet::from([MacroCapability::Log]));
let error = with_macro_context(Arc::from("app/ffi"), declared, None, || {
check_registered("custom-host-proc", &CallStackList::default())
})
.expect_err("log capability must not permit arbitrary registered procedures");
assert_eq!(error.code(), Some("E_MACRO_CAPABILITY_DISALLOWED"));
assert!(error.msg.contains(":host-ffi"));
}
#[test]
fn buf_list_procedures_require_mutable_state() {
for proc in [
CalcitProc::NativeBufListNew,
CalcitProc::NativeBufListPush,
CalcitProc::NativeBufListConcat,
] {
let error = with_macro_context(Arc::from("app/mutate-buffer"), Arc::new(HashSet::new()), None, || {
check_proc(proc, &CallStackList::default())
})
.expect_err("mutable BufList procedures must need a declaration");
assert_eq!(error.code(), Some("E_MACRO_CAPABILITY_MISSING"));
assert!(error.msg.contains(":mutable-state"));
}
}
#[test]
fn dangerous_capability_stays_forbidden_when_declared() {
let declared = Arc::new(HashSet::from([MacroCapability::FsWrite]));
let error = with_macro_context(Arc::from("app/write"), declared, None, || {
check_proc(CalcitProc::WriteFile, &CallStackList::default())
})
.expect_err("file writes are not an opt-in escape hatch");
assert_eq!(error.code(), Some("E_MACRO_CAPABILITY_DISALLOWED"));
}
#[test]
fn generated_runtime_effect_syntax_remains_pure() {
let emitted_runtime_call = Calcit::from(vec![Calcit::Proc(CalcitProc::GetEnv), Calcit::new_str("MODE")]);
let quoted = Calcit::from(vec![
Calcit::Syntax(CalcitSyntax::Quote, Arc::from("tests.capability")),
emitted_runtime_call,
]);
with_macro_context(Arc::from("tests.capability/emit-env"), Arc::new(HashSet::new()), None, || {
crate::runner::evaluate_expr("ed, &CalcitScope::default(), "tests.capability", &CallStackList::default())
})
.expect("quoting a runtime effect must not perform that effect during expansion");
}
#[test]
fn diagnostic_keeps_transitive_helper_chain() {
let stack = CallStackList::default()
.extend("tests.capability", "outer-macro", StackKind::Macro, &Calcit::Nil, &[])
.extend("tests.capability", "read-mode-helper", StackKind::Fn, &Calcit::Nil, &[]);
let error = with_macro_context(Arc::from("tests.capability/outer-macro"), Arc::new(HashSet::new()), None, || {
check_proc(CalcitProc::GetEnv, &stack)
})
.expect_err("transitive helper effects must be checked");
assert!(error.msg.contains("read-mode-helper"));
assert!(error.msg.contains("outer-macro"));
}
}