1use 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
51pub const RULE_STEPS: usize = 10_000_000;
54
55const EVALUATES: &[&str] = &["eval", "require", "provide"];
58
59#[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#[derive(Clone, Debug, PartialEq)]
71pub struct RawFinding {
72 pub at: Vec<usize>,
74 pub message: String,
75 pub help: Option<String>,
76 pub fix: Option<Sexp>,
78}
79
80#[derive(Clone, Debug, PartialEq)]
82pub enum RuleFailure {
83 Load(String),
85 Escapes(Vec<String>),
87 Undefined(String),
89 Exhausted(String),
91 Raised(String),
93 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
115pub 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 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 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 #[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 #[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 #[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}