Skip to main content

blue_lang_runtime/
project_rules.rs

1//! The rule-plugin ABI: how a check rule written in blue is called.
2//!
3//! A project names its rules in its Bluefile (`rule(code, file, def)`); a rule
4//! is an ordinary blue definition of one argument:
5//!
6//! ```text
7//! def nil_predicate(form)        # form: one top-level form, as data
8//!   [{at: [2, 1], message: "write x == nil", fix: tree, help: "…"}, …]
9//! end
10//! ```
11//!
12//! **In:** one top-level form of the checked file, as the parser wrote it
13//! (annotations intact, nothing resolved), lowered to data the way a macro
14//! receives its arguments (`tatara_lisp_eval::code::spanned_to_value`):
15//! symbols are symbols, lists are lists, `quote` is `(quote x)`.
16//!
17//! **Out:** a list of findings, each a map: `at` (required) is the path from
18//! the form to the offending node, one child index per level (`[]` is the form
19//! itself); `message` (required) is a string; `help` is an optional string;
20//! `fix` is an optional tree that replaces the node at `at`. Nothing else is
21//! read, and a value of any other shape is a [`RuleFailure::Shape`], never a
22//! guess. The code and severity are the Bluefile row's, not the rule's.
23//!
24//! **Why paths, not byte offsets.** A rule sees data, and data carries no
25//! position; the host owns the source and resolves a path against the very
26//! tree it lowered, so a rule cannot point outside the form it was given, and
27//! the same rule compiled to wasm would need no access to the source at all.
28//! Tree in, data out: the ABI is a pure function, which is what lets
29//! `BLUE-EXECUTION.md` §I.2's wrapper run it as a module later.
30//!
31//! **The frame.** A rule runs under [`permits`]: every name the runtime binds
32//! except the host effects (`blue_lang_waku::Capability`'s five host
33//! bundles), printing, and the doors that evaluate data (`eval`, `require`,
34//! `provide`). It is checked over the rule's whole program, imported bidamas
35//! included, before anything runs, and a refused name the interpreter binds is
36//! rebound to a refusal (`hosted::load_hosted_in_frame`). Each call runs under
37//! a step budget; running past it is [`RuleFailure::Exhausted`], reported as a
38//! failure of the rule, never a crash of the check.
39
40use std::fmt;
41use std::sync::atomic::AtomicBool;
42use std::sync::Arc;
43
44use tatara_lisp::{Sexp, Spanned};
45use tatara_lisp_eval::persist::Map;
46use tatara_lisp_eval::value::MapKey;
47use tatara_lisp_eval::vm::Budget;
48use tatara_lisp_eval::Value;
49
50use crate::hosted::{load_hosted_in_frame, CallError, FramedError, Hosted};
51use crate::uses::{Entry, Loader};
52
53/// The evaluation steps one rule may spend on one form, and on loading its
54/// program. A bound: a terminating rule under it reports the same findings.
55pub const RULE_STEPS: usize = 10_000_000;
56
57/// The names that evaluate data or reach another module, refused besides the
58/// host effects.
59const EVALUATES: &[&str] = &["eval", "require", "provide"];
60
61/// The rule frame: may a rule name `name`?
62#[must_use]
63pub fn permits(name: &str) -> bool {
64    let host = blue_lang_waku::Capability::ALL
65        .iter()
66        .any(|c| c.is_host_effect() && c.grants(name));
67    let prints = crate::docs::doc_of(name).is_some_and(|d| d.topic == crate::docs::Topic::Output);
68    !host && !prints && !EVALUATES.contains(&name)
69}
70
71/// What a rule reported, before the host resolves it against the source.
72#[derive(Clone, Debug, PartialEq)]
73pub struct RawFinding {
74    /// The child-index path from the form to the node.
75    pub at: Vec<usize>,
76    pub message: String,
77    pub help: Option<String>,
78    /// The tree that replaces the node at `at`.
79    pub fix: Option<Sexp>,
80}
81
82/// Why a rule produced no findings for a form.
83#[derive(Clone, Debug, PartialEq)]
84pub enum RuleFailure {
85    /// Its program did not parse, check or load.
86    Load(String),
87    /// Its program names what the frame refuses.
88    Escapes(Vec<String>),
89    /// Its file defines no such function.
90    Undefined(String),
91    /// It ran past its budget.
92    Exhausted(String),
93    /// It raised.
94    Raised(String),
95    /// It returned something that is not a list of findings.
96    Shape(String),
97}
98
99impl fmt::Display for RuleFailure {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            RuleFailure::Load(m) => write!(f, "its program did not load: {m}"),
103            RuleFailure::Escapes(names) => write!(
104                f,
105                "it names {} outside the rule frame: {}",
106                if names.len() == 1 { "a name" } else { "names" },
107                names.join(", ")
108            ),
109            RuleFailure::Undefined(m) => write!(f, "{m}"),
110            RuleFailure::Exhausted(m) => write!(f, "it ran past its budget: {m}"),
111            RuleFailure::Raised(m) => write!(f, "it raised: {m}"),
112            RuleFailure::Shape(m) => write!(f, "it returned {m}"),
113        }
114    }
115}
116
117/// A rule file, loaded under the rule frame, whose definitions can be called.
118///
119/// Its load (the interpreter it builds included) and each call are also held
120/// to `guest_max_alloc_bytes` ([`crate::memory`]): a rule is code the check
121/// did not write, and one that holds more is [`RuleFailure::Exhausted`] with
122/// `memory-exhausted`.
123pub struct RuleProgram {
124    hosted: Hosted<()>,
125    halt: Arc<AtomicBool>,
126}
127
128fn past_memory(bound: u64) -> RuleFailure {
129    RuleFailure::Exhausted(blue_lang_proc::memory_refusal(bound))
130}
131
132fn budget(steps: usize) -> Budget {
133    Budget {
134        fuel: Some(steps),
135        max_depth: Some(crate::execution_bounds().max_call_depth),
136        quantum: None,
137    }
138}
139
140impl RuleProgram {
141    /// Parse, check, frame-check and load a rule file.
142    ///
143    /// # Errors
144    ///
145    /// [`RuleFailure::Escapes`] for a program reaching outside the frame,
146    /// [`RuleFailure::Load`] for every other refusal.
147    pub fn load(entry: Entry<'_>, loader: &dyn Loader, steps: usize) -> Result<Self, RuleFailure> {
148        let halt = Arc::new(AtomicBool::new(false));
149        let bound = crate::execution_bounds().guest_max_alloc_bytes;
150        let own_halt = |interp: &mut tatara_lisp_eval::Interpreter<()>| {
151            interp.set_interrupt(Arc::clone(&halt));
152        };
153        let (loaded, over) = crate::memory::held(bound, &halt, || {
154            load_hosted_in_frame(entry, loader, &mut (), own_halt, &permits, budget(steps))
155        });
156        if let Some(bound) = over {
157            return Err(past_memory(bound));
158        }
159        match loaded {
160            Ok(hosted) => Ok(Self { hosted, halt }),
161            Err(FramedError::Escapes { names }) => Err(RuleFailure::Escapes(names)),
162            Err(FramedError::Run(e)) => Err(RuleFailure::Load(e.to_string())),
163        }
164    }
165
166    /// Call `def` on one form under a budget of `steps`.
167    ///
168    /// # Errors
169    ///
170    /// A [`RuleFailure`] naming why the rule produced nothing.
171    pub fn run(
172        &mut self,
173        def: &str,
174        form: &Spanned,
175        steps: usize,
176    ) -> Result<Vec<RawFinding>, RuleFailure> {
177        let arg = tatara_lisp_eval::code::spanned_to_value(form);
178        let bound = crate::execution_bounds().guest_max_alloc_bytes;
179        let hosted = &mut self.hosted;
180        let (called, over) = crate::memory::held(bound, &self.halt, || {
181            hosted.call_budgeted(def, vec![arg], &mut (), budget(steps))
182        });
183        if let Some(bound) = over {
184            return Err(past_memory(bound));
185        }
186        let value = called.map_err(|e| match e {
187            CallError::Exhausted { .. } => {
188                RuleFailure::Exhausted(crate::pipeline::display_keys(&e.to_string()))
189            }
190            CallError::Raised { .. } => {
191                RuleFailure::Raised(crate::pipeline::display_keys(&e.to_string()))
192            }
193            CallError::Undefined(_) | CallError::NotAFunction { .. } => {
194                RuleFailure::Undefined(e.to_string())
195            }
196        })?;
197        findings_of(&value)
198    }
199}
200
201fn findings_of(value: &Value) -> Result<Vec<RawFinding>, RuleFailure> {
202    match value {
203        Value::Nil => Ok(Vec::new()),
204        Value::List(items) => items.iter().map(finding_of).collect(),
205        other => Err(RuleFailure::Shape(format!(
206            "{}, where a list of findings belongs",
207            other.type_name()
208        ))),
209    }
210}
211
212fn field<'a>(m: &'a Map, key: &str) -> Option<&'a Value> {
213    m.get(&MapKey::Keyword(std::sync::Arc::from(key)))
214        .filter(|v| !matches!(v, Value::Nil))
215}
216
217fn finding_of(v: &Value) -> Result<RawFinding, RuleFailure> {
218    let Value::Map(m) = v else {
219        return Err(RuleFailure::Shape(format!(
220            "a finding that is {}, not a map",
221            v.type_name()
222        )));
223    };
224    let at = match field(m, "at") {
225        Some(Value::List(xs)) => xs
226            .iter()
227            .map(|x| match x {
228                Value::Int(n) if *n >= 0 => usize::try_from(*n).ok(),
229                _ => None,
230            })
231            .collect::<Option<Vec<usize>>>()
232            .ok_or_else(|| RuleFailure::Shape("an `at` that is not a list of indices".into()))?,
233        Some(Value::Nil) | None => Vec::new(),
234        Some(other) => {
235            return Err(RuleFailure::Shape(format!(
236                "an `at` that is {}, not a list of indices",
237                other.type_name()
238            )))
239        }
240    };
241    let text = |key: &str, required: bool| -> Result<Option<String>, RuleFailure> {
242        match field(m, key) {
243            Some(Value::Str(s)) => Ok(Some(s.to_string())),
244            None if !required => Ok(None),
245            None => Err(RuleFailure::Shape(format!("a finding with no `{key}`"))),
246            Some(other) => Err(RuleFailure::Shape(format!(
247                "a `{key}` that is {}, not a string",
248                other.type_name()
249            ))),
250        }
251    };
252    let message = text("message", true)?.unwrap_or_default();
253    let help = text("help", false)?;
254    let fix = match field(m, "fix") {
255        None => None,
256        Some(tree) => Some(
257            tatara_lisp_eval::code::value_to_spanned(tree, tatara_lisp::Span::synthetic())
258                .map_err(|e| RuleFailure::Shape(format!("a `fix` that is not a tree: {e}")))?
259                .to_sexp(),
260        ),
261    };
262    Ok(RawFinding {
263        at,
264        message,
265        help,
266        fix,
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::uses::NoLoader;
274
275    fn form(src: &str) -> Spanned {
276        blue_lang_syntax::parse_program_tree(src)
277            .expect("parse")
278            .remove(0)
279    }
280
281    fn load(src: &str) -> Result<RuleProgram, RuleFailure> {
282        RuleProgram::load(Entry::anonymous(src), &NoLoader, RULE_STEPS)
283    }
284
285    /// The frame refuses the host and printing, and admits the pure stdlib.
286    ///
287    /// Red run (2026-10-10): `!host &&` dropped from `permits`: `read_file`
288    /// was admitted and the first assertion failed.
289    #[test]
290    fn the_rule_frame_refuses_the_host_printing_and_eval() {
291        for refused in [
292            "read_file",
293            "rm_rf",
294            "exec_capture",
295            "getenv",
296            "now",
297            "http_request",
298        ] {
299            assert!(!permits(refused), "{refused}");
300        }
301        for refused in ["write_stdout", "println", "eval", "require"] {
302            assert!(!permits(refused), "{refused}");
303        }
304        for admitted in [
305            "map", "filter", "first", "to_s", "symbol?", "list", "define",
306        ] {
307            assert!(permits(admitted), "{admitted}");
308        }
309    }
310
311    #[test]
312    fn a_rule_reports_findings_with_paths_messages_and_fixes() {
313        let mut p = load(
314            "def r(form)\n  tree = quote\n    b(unquote(nth(2, form)))\n  end\n  [{at: [2], message: \"two\", help: \"h\", fix: tree}]\nend\n",
315        )
316        .expect("loads");
317        let got = p.run("r", &form("f(1, 2)\n"), RULE_STEPS).expect("runs");
318        assert_eq!(got.len(), 1);
319        assert_eq!(got[0].at, vec![2]);
320        assert_eq!(got[0].message, "two");
321        assert_eq!(got[0].help.as_deref(), Some("h"));
322        assert_eq!(
323            got[0].fix.as_ref().map(ToString::to_string).as_deref(),
324            Some("(b 2)")
325        );
326    }
327
328    #[test]
329    fn a_rule_receives_the_form_as_data() {
330        let mut p = load(
331            "def r(form)\n  if symbol?(first(form)) && to_s(first(form)) == \"f\"\n    [{at: [], message: \"a call to f\"}]\n  else\n    []\n  end\nend\n",
332        )
333        .expect("loads");
334        assert_eq!(p.run("r", &form("f(1)\n"), RULE_STEPS).unwrap().len(), 1);
335        assert!(p.run("r", &form("g(1)\n"), RULE_STEPS).unwrap().is_empty());
336    }
337
338    /// A rule that names what the frame refuses never runs. This crate's
339    /// tests build without `sys`, so the host names are unbound here and the
340    /// check stage refuses them first; printing and `eval` are bound, and
341    /// the frame is what refuses them. `blue-lang-cli`'s `project_rules`
342    /// test shows `read_file` refused by the frame where it is bound.
343    ///
344    /// Red run (2026-10-10): the escape check in `load_hosted_in_frame`
345    /// skipped: the program loaded, and the assertion failed on `Ok`.
346    #[test]
347    fn a_rule_naming_a_refused_name_is_refused_by_the_frame() {
348        let err = load("def r(form)\n  display(form)\n  eval(form)\nend\n")
349            .err()
350            .expect("refused");
351        assert_eq!(
352            err,
353            RuleFailure::Escapes(vec!["display".into(), "eval".into()])
354        );
355    }
356
357    /// A runaway rule is stopped by its budget and reported as a failure.
358    ///
359    /// Red run (2026-10-10): `fuel: Some(steps)` replaced by `fuel: None` in
360    /// `budget`: the test had not returned after a minute, and was stopped.
361    #[test]
362    fn a_runaway_rule_is_exhausted_not_a_crash() {
363        let mut p = load("def spin(n)\n  spin(n + 1)\nend\n\ndef r(form)\n  spin(0)\nend\n")
364            .expect("loads");
365        let err = p.run("r", &form("f(1)\n"), 10_000).unwrap_err();
366        assert!(matches!(err, RuleFailure::Exhausted(_)), "{err:?}");
367        assert!(err.to_string().contains("budget"), "{err}");
368    }
369
370    #[test]
371    fn a_value_that_is_not_findings_is_a_shape_failure() {
372        for body in ["1", "[1]", "[{message: \"m\", at: \"x\"}]", "[{at: []}]"] {
373            let mut p = load(&format!("def r(form)\n  {body}\nend\n")).expect("loads");
374            let err = p.run("r", &form("f(1)\n"), RULE_STEPS).unwrap_err();
375            assert!(matches!(err, RuleFailure::Shape(_)), "{body}: {err:?}");
376        }
377    }
378}