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;
41
42use tatara_lisp::{Sexp, Spanned};
43use tatara_lisp_eval::persist::Map;
44use tatara_lisp_eval::value::MapKey;
45use tatara_lisp_eval::vm::Budget;
46use tatara_lisp_eval::Value;
47
48use crate::hosted::{load_hosted_in_frame, CallError, FramedError, Hosted};
49use crate::uses::{Entry, Loader};
50
51/// The evaluation steps one rule may spend on one form, and on loading its
52/// program. A bound: a terminating rule under it reports the same findings.
53pub const RULE_STEPS: usize = 10_000_000;
54
55/// The names that evaluate data or reach another module, refused besides the
56/// host effects.
57const EVALUATES: &[&str] = &["eval", "require", "provide"];
58
59/// The rule frame: may a rule name `name`?
60#[must_use]
61pub fn permits(name: &str) -> bool {
62    let host = blue_lang_waku::Capability::ALL
63        .iter()
64        .any(|c| c.is_host_effect() && c.grants(name));
65    let prints = crate::docs::doc_of(name).is_some_and(|d| d.topic == crate::docs::Topic::Output);
66    !host && !prints && !EVALUATES.contains(&name)
67}
68
69/// What a rule reported, before the host resolves it against the source.
70#[derive(Clone, Debug, PartialEq)]
71pub struct RawFinding {
72    /// The child-index path from the form to the node.
73    pub at: Vec<usize>,
74    pub message: String,
75    pub help: Option<String>,
76    /// The tree that replaces the node at `at`.
77    pub fix: Option<Sexp>,
78}
79
80/// Why a rule produced no findings for a form.
81#[derive(Clone, Debug, PartialEq)]
82pub enum RuleFailure {
83    /// Its program did not parse, check or load.
84    Load(String),
85    /// Its program names what the frame refuses.
86    Escapes(Vec<String>),
87    /// Its file defines no such function.
88    Undefined(String),
89    /// It ran past its budget.
90    Exhausted(String),
91    /// It raised.
92    Raised(String),
93    /// It returned something that is not a list of findings.
94    Shape(String),
95}
96
97impl fmt::Display for RuleFailure {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            RuleFailure::Load(m) => write!(f, "its program did not load: {m}"),
101            RuleFailure::Escapes(names) => write!(
102                f,
103                "it names {} outside the rule frame: {}",
104                if names.len() == 1 { "a name" } else { "names" },
105                names.join(", ")
106            ),
107            RuleFailure::Undefined(m) => write!(f, "{m}"),
108            RuleFailure::Exhausted(m) => write!(f, "it ran past its budget: {m}"),
109            RuleFailure::Raised(m) => write!(f, "it raised: {m}"),
110            RuleFailure::Shape(m) => write!(f, "it returned {m}"),
111        }
112    }
113}
114
115/// A rule file, loaded under the rule frame, whose definitions can be called.
116pub struct RuleProgram {
117    hosted: Hosted<()>,
118}
119
120fn budget(steps: usize) -> Budget {
121    Budget {
122        fuel: Some(steps),
123        max_depth: Some(crate::execution_bounds().max_call_depth),
124        quantum: None,
125    }
126}
127
128impl RuleProgram {
129    /// Parse, check, frame-check and load a rule file.
130    ///
131    /// # Errors
132    ///
133    /// [`RuleFailure::Escapes`] for a program reaching outside the frame,
134    /// [`RuleFailure::Load`] for every other refusal.
135    pub fn load(entry: Entry<'_>, loader: &dyn Loader, steps: usize) -> Result<Self, RuleFailure> {
136        match load_hosted_in_frame(entry, loader, &mut (), |_| {}, &permits, budget(steps)) {
137            Ok(hosted) => Ok(Self { hosted }),
138            Err(FramedError::Escapes { names }) => Err(RuleFailure::Escapes(names)),
139            Err(FramedError::Run(e)) => Err(RuleFailure::Load(e.to_string())),
140        }
141    }
142
143    /// Call `def` on one form under a budget of `steps`.
144    ///
145    /// # Errors
146    ///
147    /// A [`RuleFailure`] naming why the rule produced nothing.
148    pub fn run(
149        &mut self,
150        def: &str,
151        form: &Spanned,
152        steps: usize,
153    ) -> Result<Vec<RawFinding>, RuleFailure> {
154        let arg = tatara_lisp_eval::code::spanned_to_value(form);
155        let value = self
156            .hosted
157            .call_budgeted(def, vec![arg], &mut (), budget(steps))
158            .map_err(|e| match e {
159                CallError::Exhausted { .. } => {
160                    RuleFailure::Exhausted(crate::pipeline::display_keys(&e.to_string()))
161                }
162                CallError::Raised { .. } => {
163                    RuleFailure::Raised(crate::pipeline::display_keys(&e.to_string()))
164                }
165                CallError::Undefined(_) | CallError::NotAFunction { .. } => {
166                    RuleFailure::Undefined(e.to_string())
167                }
168            })?;
169        findings_of(&value)
170    }
171}
172
173fn findings_of(value: &Value) -> Result<Vec<RawFinding>, RuleFailure> {
174    match value {
175        Value::Nil => Ok(Vec::new()),
176        Value::List(items) => items.iter().map(finding_of).collect(),
177        other => Err(RuleFailure::Shape(format!(
178            "{}, where a list of findings belongs",
179            other.type_name()
180        ))),
181    }
182}
183
184fn field<'a>(m: &'a Map, key: &str) -> Option<&'a Value> {
185    m.get(&MapKey::Keyword(std::sync::Arc::from(key)))
186        .filter(|v| !matches!(v, Value::Nil))
187}
188
189fn finding_of(v: &Value) -> Result<RawFinding, RuleFailure> {
190    let Value::Map(m) = v else {
191        return Err(RuleFailure::Shape(format!(
192            "a finding that is {}, not a map",
193            v.type_name()
194        )));
195    };
196    let at = match field(m, "at") {
197        Some(Value::List(xs)) => xs
198            .iter()
199            .map(|x| match x {
200                Value::Int(n) if *n >= 0 => usize::try_from(*n).ok(),
201                _ => None,
202            })
203            .collect::<Option<Vec<usize>>>()
204            .ok_or_else(|| RuleFailure::Shape("an `at` that is not a list of indices".into()))?,
205        Some(Value::Nil) | None => Vec::new(),
206        Some(other) => {
207            return Err(RuleFailure::Shape(format!(
208                "an `at` that is {}, not a list of indices",
209                other.type_name()
210            )))
211        }
212    };
213    let text = |key: &str, required: bool| -> Result<Option<String>, RuleFailure> {
214        match field(m, key) {
215            Some(Value::Str(s)) => Ok(Some(s.to_string())),
216            None if !required => Ok(None),
217            None => Err(RuleFailure::Shape(format!("a finding with no `{key}`"))),
218            Some(other) => Err(RuleFailure::Shape(format!(
219                "a `{key}` that is {}, not a string",
220                other.type_name()
221            ))),
222        }
223    };
224    let message = text("message", true)?.unwrap_or_default();
225    let help = text("help", false)?;
226    let fix = match field(m, "fix") {
227        None => None,
228        Some(tree) => Some(
229            tatara_lisp_eval::code::value_to_spanned(tree, tatara_lisp::Span::synthetic())
230                .map_err(|e| RuleFailure::Shape(format!("a `fix` that is not a tree: {e}")))?
231                .to_sexp(),
232        ),
233    };
234    Ok(RawFinding {
235        at,
236        message,
237        help,
238        fix,
239    })
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::uses::NoLoader;
246
247    fn form(src: &str) -> Spanned {
248        blue_lang_syntax::parse_program_tree(src)
249            .expect("parse")
250            .remove(0)
251    }
252
253    fn load(src: &str) -> Result<RuleProgram, RuleFailure> {
254        RuleProgram::load(Entry::anonymous(src), &NoLoader, RULE_STEPS)
255    }
256
257    /// The frame refuses the host and printing, and admits the pure stdlib.
258    ///
259    /// Red run (2026-10-10): `!host &&` dropped from `permits`: `read_file`
260    /// was admitted and the first assertion failed.
261    #[test]
262    fn the_rule_frame_refuses_the_host_printing_and_eval() {
263        for refused in [
264            "read_file",
265            "rm_rf",
266            "exec_capture",
267            "getenv",
268            "now",
269            "http_request",
270        ] {
271            assert!(!permits(refused), "{refused}");
272        }
273        for refused in ["write_stdout", "println", "eval", "require"] {
274            assert!(!permits(refused), "{refused}");
275        }
276        for admitted in [
277            "map", "filter", "first", "to_s", "symbol?", "list", "define",
278        ] {
279            assert!(permits(admitted), "{admitted}");
280        }
281    }
282
283    #[test]
284    fn a_rule_reports_findings_with_paths_messages_and_fixes() {
285        let mut p = load(
286            "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",
287        )
288        .expect("loads");
289        let got = p.run("r", &form("f(1, 2)\n"), RULE_STEPS).expect("runs");
290        assert_eq!(got.len(), 1);
291        assert_eq!(got[0].at, vec![2]);
292        assert_eq!(got[0].message, "two");
293        assert_eq!(got[0].help.as_deref(), Some("h"));
294        assert_eq!(
295            got[0].fix.as_ref().map(ToString::to_string).as_deref(),
296            Some("(b 2)")
297        );
298    }
299
300    #[test]
301    fn a_rule_receives_the_form_as_data() {
302        let mut p = load(
303            "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",
304        )
305        .expect("loads");
306        assert_eq!(p.run("r", &form("f(1)\n"), RULE_STEPS).unwrap().len(), 1);
307        assert!(p.run("r", &form("g(1)\n"), RULE_STEPS).unwrap().is_empty());
308    }
309
310    /// A rule that names what the frame refuses never runs. This crate's
311    /// tests build without `sys`, so the host names are unbound here and the
312    /// check stage refuses them first; printing and `eval` are bound, and
313    /// the frame is what refuses them. `blue-lang-cli`'s `project_rules`
314    /// test shows `read_file` refused by the frame where it is bound.
315    ///
316    /// Red run (2026-10-10): the escape check in `load_hosted_in_frame`
317    /// skipped: the program loaded, and the assertion failed on `Ok`.
318    #[test]
319    fn a_rule_naming_a_refused_name_is_refused_by_the_frame() {
320        let err = load("def r(form)\n  display(form)\n  eval(form)\nend\n")
321            .err()
322            .expect("refused");
323        assert_eq!(
324            err,
325            RuleFailure::Escapes(vec!["display".into(), "eval".into()])
326        );
327    }
328
329    /// A runaway rule is stopped by its budget and reported as a failure.
330    ///
331    /// Red run (2026-10-10): `fuel: Some(steps)` replaced by `fuel: None` in
332    /// `budget`: the test had not returned after a minute, and was stopped.
333    #[test]
334    fn a_runaway_rule_is_exhausted_not_a_crash() {
335        let mut p = load("def spin(n)\n  spin(n + 1)\nend\n\ndef r(form)\n  spin(0)\nend\n")
336            .expect("loads");
337        let err = p.run("r", &form("f(1)\n"), 10_000).unwrap_err();
338        assert!(matches!(err, RuleFailure::Exhausted(_)), "{err:?}");
339        assert!(err.to_string().contains("budget"), "{err}");
340    }
341
342    #[test]
343    fn a_value_that_is_not_findings_is_a_shape_failure() {
344        for body in ["1", "[1]", "[{message: \"m\", at: \"x\"}]", "[{at: []}]"] {
345            let mut p = load(&format!("def r(form)\n  {body}\nend\n")).expect("loads");
346            let err = p.run("r", &form("f(1)\n"), RULE_STEPS).unwrap_err();
347            assert!(matches!(err, RuleFailure::Shape(_)), "{body}: {err:?}");
348        }
349    }
350}