Skip to main content

lanekeep_testkit/
lib.rs

1//! Fixture-based rule testing harness for lanekeep.
2//!
3//! `RuleTester`: the harness every rule is tested through.
4//!
5//! This is not optional infrastructure. Without it, community rule contributions are
6//! unreviewable — a reviewer cannot tell a rule that works from one that happens not to
7//! have been tried on the case it gets wrong.
8//!
9//! # It runs the real path
10//!
11//! A tester builds a throwaway project on disk and runs the actual engine over it: real
12//! config loading, real gates, real sandbox, real query matching. Nothing is stubbed.
13//!
14//! That costs more than calling a handler directly, and buys the thing that matters. A rule
15//! can be correct in isolation and still never fire because its gate excludes the file, its
16//! query does not compile against the language it named, or its card fails validation. A
17//! harness that skipped those would pass rules that do nothing.
18//!
19//! # Usage
20//!
21//! ```no_run
22//! use lanekeep_testkit::RuleTester;
23//!
24//! let tester = RuleTester::new("no-debugger", RULE_SOURCE).expect("builds");
25//! tester.accepts("const a = 1;").expect("clean code passes");
26//! tester.reports_at("debugger;", &[(1, 1)]).expect("violations are found");
27//! # const RULE_SOURCE: &str = "";
28//! ```
29
30use std::fmt::Write as _;
31use std::path::PathBuf;
32use std::sync::Arc;
33use std::sync::atomic::{AtomicU64, Ordering};
34
35use lanekeep_core::Violation;
36use lanekeep_engine::Engine;
37use lanekeep_js::RuleRoot;
38use lanekeep_lang_js::{JavaScript, TypeScript};
39use thiserror::Error;
40
41/// Why a rule test could not run, or did not hold.
42#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum TestError {
44    /// The harness could not set up its temporary project.
45    #[error("could not set up the rule test: {0}")]
46    Setup(String),
47
48    /// The rule or its config failed to load.
49    ///
50    /// Distinct from a failed assertion: the rule never ran, so nothing was proven either
51    /// way, and reporting it as "no violations found" would be actively misleading.
52    #[error("rule failed to load:\n{0}")]
53    Load(String),
54
55    /// The run aborted — a rule threw, or breached a budget.
56    #[error("rule failed while running:\n{0}")]
57    Run(String),
58
59    /// The rule reported something other than what was expected.
60    #[error("{0}")]
61    Mismatch(String),
62}
63
64/// Distinguishes testers built in the same process.
65static NEXT_ID: AtomicU64 = AtomicU64::new(0);
66
67/// A rule under test, with a throwaway project to run it in.
68///
69/// The project is removed when the tester is dropped.
70#[derive(Debug)]
71pub struct RuleTester {
72    dir: PathBuf,
73    extension: String,
74}
75
76impl RuleTester {
77    /// Build a tester for a rule's source.
78    ///
79    /// `name` labels the temporary directory and need not be unique — every tester gets its
80    /// own directory regardless, so a test file with a `fn tester()` helper shared across
81    /// cases works. It has to: two testers sharing a directory would delete each other's
82    /// project mid-run, and the resulting error would point at the config rather than at
83    /// the collision.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`TestError::Setup`] if the temporary project cannot be written.
88    pub fn new(name: &str, rule_source: &str) -> Result<Self, TestError> {
89        Self::with_extension(name, rule_source, "ts")
90    }
91
92    /// Build a tester whose subject files use a given extension.
93    ///
94    /// Needed for a rule targeting `tsx`, since which grammar parses a file is decided by
95    /// its extension — a TSX rule tested against a `.ts` file would never match.
96    ///
97    /// # Errors
98    ///
99    /// As [`RuleTester::new`].
100    pub fn with_extension(
101        name: &str,
102        rule_source: &str,
103        extension: &str,
104    ) -> Result<Self, TestError> {
105        Self::build(name, rule_source, extension, "rule")
106    }
107
108    /// Build a tester for a *factory* rule — one whose default export returns a rule when
109    /// called with options — using the given options expression.
110    ///
111    /// `options` is JavaScript, spliced into the generated config as `rule(<options>)`.
112    /// Passing the options as source rather than as a serialized value is deliberate: a
113    /// factory takes whatever its author designed, and a harness that only accepted JSON
114    /// could not test one taking a function or a regular expression.
115    ///
116    /// ```no_run
117    /// # use lanekeep_testkit::RuleTester;
118    /// let tester = RuleTester::configured(
119    ///     "restricted",
120    ///     RULE_SOURCE,
121    ///     "{ restrictions: [{ module: 'lodash' }] }",
122    /// )
123    /// .expect("builds");
124    /// # const RULE_SOURCE: &str = "";
125    /// ```
126    ///
127    /// # Errors
128    ///
129    /// As [`RuleTester::new`].
130    pub fn configured(name: &str, rule_source: &str, options: &str) -> Result<Self, TestError> {
131        Self::build(name, rule_source, "ts", &format!("rule({options})"))
132    }
133
134    /// Write the throwaway project.
135    ///
136    /// `rule_expr` is what goes in the config's `rules` array — the imported module for a
137    /// plain rule, a call for a factory.
138    fn build(
139        name: &str,
140        rule_source: &str,
141        extension: &str,
142        rule_expr: &str,
143    ) -> Result<Self, TestError> {
144        // Unique per tester: the counter separates testers in one process, the process id
145        // separates the processes nextest spawns per test.
146        let seq = NEXT_ID.fetch_add(1, Ordering::Relaxed);
147        let dir = std::env::temp_dir().join(format!(
148            "lanekeep-ruletest-{name}-{}-{seq}",
149            std::process::id()
150        ));
151        let _ = std::fs::remove_dir_all(&dir);
152
153        let tester = Self {
154            dir,
155            extension: extension.to_owned(),
156        };
157        tester.write("rule.ts", rule_source)?;
158        tester.write(
159            "lanekeep.config.ts",
160            &format!(
161                "import {{ defineConfig }} from 'lanekeep';\n\
162                 import rule from './rule';\n\
163                 export default defineConfig({{ include: ['subject/**'], rules: [{rule_expr}] }});\n"
164            ),
165        )?;
166        Ok(tester)
167    }
168
169    fn write(&self, path: &str, contents: &str) -> Result<(), TestError> {
170        let full = self.dir.join(path);
171        if let Some(parent) = full.parent() {
172            std::fs::create_dir_all(parent).map_err(|e| TestError::Setup(e.to_string()))?;
173        }
174        std::fs::write(full, contents).map_err(|e| TestError::Setup(e.to_string()))
175    }
176
177    /// Run the rule over a single source file and return what it reported.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`TestError::Load`] if the rule does not load, or [`TestError::Run`] if it
182    /// throws or breaches a budget.
183    pub fn run(&self, source: &str) -> Result<Vec<Violation>, TestError> {
184        // A fresh subject each time, so one case cannot see another's file.
185        let _ = std::fs::remove_dir_all(self.dir.join("subject"));
186        self.write(&format!("subject/input.{}", self.extension), source)?;
187
188        let root = RuleRoot::new(&self.dir).map_err(|e| TestError::Setup(e.to_string()))?;
189        let config_path = self.dir.join("lanekeep.config.ts");
190
191        let sandbox =
192            lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
193                .map_err(|e| TestError::Load(e.to_string()))?;
194        let config = lanekeep_config::load(&sandbox, &root, &config_path)
195            .map_err(|e| TestError::Load(e.to_string()))?;
196
197        let engine = Engine::prepare(
198            &config,
199            &self.dir,
200            root,
201            &config_path,
202            &lanekeep_languages::registry(),
203            Arc::new(TypeScript),
204            Arc::new(JavaScript),
205        )
206        .map_err(|e| TestError::Load(e.to_string()))?;
207
208        engine
209            .run()
210            .map(|outcome| outcome.violations)
211            .map_err(|e| TestError::Run(e.to_string()))
212    }
213
214    /// Assert the rule reports nothing for this source.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`TestError::Mismatch`] listing what was reported, since "expected none,
219    /// got some" is only actionable if you can see which.
220    pub fn accepts(&self, source: &str) -> Result<(), TestError> {
221        let violations = self.run(source)?;
222        if violations.is_empty() {
223            return Ok(());
224        }
225
226        let mut message = format!(
227            "expected no violations, but the rule reported {}:\n",
228            violations.len()
229        );
230        for violation in &violations {
231            let _ = writeln!(
232                message,
233                "  {}:{} {}",
234                violation.location.position.line,
235                violation.location.position.column,
236                violation.message
237            );
238        }
239        let _ = write!(message, "\nsource:\n{}", indent(source));
240        Err(TestError::Mismatch(message))
241    }
242
243    /// Assert the rule reports at exactly these one-based positions, in order.
244    ///
245    /// Positions rather than a count, because a rule reporting the right number of
246    /// violations in the wrong places is a rule that is wrong — and a count-only assertion
247    /// is exactly what lets that through.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`TestError::Mismatch`] showing expected and actual side by side.
252    pub fn reports_at(&self, source: &str, expected: &[(u32, u32)]) -> Result<(), TestError> {
253        let violations = self.run(source)?;
254        let actual: Vec<(u32, u32)> = violations
255            .iter()
256            .map(|v| (v.location.position.line, v.location.position.column))
257            .collect();
258
259        if actual == expected {
260            return Ok(());
261        }
262
263        Err(TestError::Mismatch(format!(
264            "reported positions did not match\n  expected: {expected:?}\n  actual:   {actual:?}\n\nsource:\n{}",
265            indent(source)
266        )))
267    }
268
269    /// Assert the rule reports exactly these messages, in order.
270    ///
271    /// For a rule that substitutes its own message per match — the position alone would not
272    /// show whether the right one was chosen.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`TestError::Mismatch`] showing both lists.
277    pub fn reports_messages(&self, source: &str, expected: &[&str]) -> Result<(), TestError> {
278        let violations = self.run(source)?;
279        let actual: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
280
281        if actual == expected {
282            return Ok(());
283        }
284
285        Err(TestError::Mismatch(format!(
286            "reported messages did not match\n  expected: {expected:?}\n  actual:   {actual:?}\n\nsource:\n{}",
287            indent(source)
288        )))
289    }
290}
291
292impl Drop for RuleTester {
293    fn drop(&mut self) {
294        let _ = std::fs::remove_dir_all(&self.dir);
295    }
296}
297
298/// Indent source for inclusion in a failure message, so it is visibly quoted rather than
299/// running together with the assertion text.
300fn indent(source: &str) -> String {
301    source.lines().fold(String::new(), |mut out, line| {
302        // Writing into a String cannot fail; swallowing the Result keeps this a fold
303        // rather than a loop with an unreachable error arm.
304        let _ = writeln!(out, "  | {line}");
305        out
306    })
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    const DEBUGGER: &str = "import { defineRule } from 'lanekeep';\n\
314        export default defineRule({\n\
315          id: 'local/no-debugger',\n\
316          query: '(debugger_statement) @stmt',\n\
317          card: {\n\
318            message: 'debugger statement',\n\
319            remediation: 'remove it',\n\
320            examples: { bad: 'debugger;', good: 'log();' },\n\
321          },\n\
322          check(ctx, m) { ctx.report(m.stmt); },\n\
323        });\n";
324
325    fn tester(name: &str) -> RuleTester {
326        RuleTester::new(name, DEBUGGER).expect("builds")
327    }
328
329    #[test]
330    fn accepts_clean_source() {
331        tester("accepts")
332            .accepts("const a = 1;\n")
333            .expect("should accept");
334    }
335
336    #[test]
337    fn reports_at_the_expected_positions() {
338        tester("positions")
339            .reports_at("const a = 1;\ndebugger;\n", &[(2, 1)])
340            .expect("should report");
341    }
342
343    #[test]
344    fn reports_several_in_order() {
345        tester("several")
346            .reports_at("debugger;\nconst a = 1;\ndebugger;\n", &[(1, 1), (3, 1)])
347            .expect("should report both");
348    }
349
350    #[test]
351    fn accepts_fails_loudly_and_shows_what_was_found() {
352        // A harness that said only "expected none, got some" would leave the author
353        // guessing which case tripped.
354        let err = tester("accepts-fail")
355            .accepts("debugger;\n")
356            .expect_err("should not accept");
357
358        let rendered = err.to_string();
359        assert!(rendered.contains("expected no violations"), "{rendered}");
360        assert!(
361            rendered.contains("debugger statement"),
362            "should show the message: {rendered}"
363        );
364        assert!(rendered.contains("1:1"), "should show where: {rendered}");
365    }
366
367    #[test]
368    fn a_position_mismatch_shows_both_sides() {
369        let err = tester("position-fail")
370            .reports_at("debugger;\n", &[(5, 5)])
371            .expect_err("should not match");
372
373        let rendered = err.to_string();
374        assert!(rendered.contains("expected: [(5, 5)]"), "{rendered}");
375        assert!(rendered.contains("actual:   [(1, 1)]"), "{rendered}");
376    }
377
378    #[test]
379    fn checks_messages_when_a_rule_substitutes_its_own() {
380        let rule = "import { defineRule } from 'lanekeep';\n\
381            export default defineRule({\n\
382              id: 'local/named',\n\
383              query: '(variable_declarator name: (identifier) @name)',\n\
384              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
385              check(ctx, m) { ctx.report(m.name, `saw ${ctx.text(m.name)}`); },\n\
386            });\n";
387
388        RuleTester::new("messages", rule)
389            .expect("builds")
390            .reports_messages(
391                "const alpha = 1;\nconst beta = 2;\n",
392                &["saw alpha", "saw beta"],
393            )
394            .expect("should match");
395    }
396
397    #[test]
398    fn a_rule_that_does_not_load_is_distinguished_from_one_that_found_nothing() {
399        // The distinction that matters most. Reporting a load failure as "no violations"
400        // would make a broken rule look like a passing one — which is the same failure
401        // mode the config's has_check test exists to prevent, one layer up.
402        let broken = "import { defineRule } from 'lanekeep';\n\
403            export default defineRule({\n\
404              id: 'local/broken',\n\
405              query: '(no_such_node) @x',\n\
406              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
407              check() {},\n\
408            });\n";
409
410        let err = RuleTester::new("broken", broken)
411            .expect("builds")
412            .accepts("const a = 1;\n")
413            .expect_err("must not pass");
414
415        assert!(matches!(err, TestError::Load(_)), "{err:?}");
416        assert!(err.to_string().contains("no_such_node"), "{err}");
417    }
418
419    #[test]
420    fn a_throwing_rule_is_reported_as_a_run_failure() {
421        let throwing = "import { defineRule } from 'lanekeep';\n\
422            export default defineRule({\n\
423              id: 'local/throws',\n\
424              query: '(debugger_statement) @s',\n\
425              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
426              check() { throw new Error('boom'); },\n\
427            });\n";
428
429        let err = RuleTester::new("throwing", throwing)
430            .expect("builds")
431            .accepts("debugger;\n")
432            .expect_err("must not pass");
433
434        assert!(matches!(err, TestError::Run(_)), "{err:?}");
435        assert!(err.to_string().contains("boom"), "{err}");
436    }
437
438    #[test]
439    fn cases_do_not_leak_into_each_other() {
440        // Each case rewrites the subject directory. Without that, a violation from an
441        // earlier case would still be on disk and show up in the next one.
442        let tester = tester("isolation");
443        tester
444            .reports_at("debugger;\n", &[(1, 1)])
445            .expect("first case");
446        tester
447            .accepts("const a = 1;\n")
448            .expect("second case must not see the first");
449    }
450
451    #[test]
452    fn a_tsx_rule_can_be_tested_against_tsx() {
453        // Which grammar parses a file is decided by its extension, so a TSX rule tested
454        // against a `.ts` subject would silently never match.
455        let rule = "import { defineRule } from 'lanekeep';\n\
456            export default defineRule({\n\
457              id: 'local/no-jsx',\n\
458              language: 'tsx',\n\
459              query: '(jsx_element) @el',\n\
460              card: { message: 'jsx', remediation: 'do not', examples: { bad: '<a/>', good: 'a()' } },\n\
461              check(ctx, m) { ctx.report(m.el); },\n\
462            });\n";
463
464        RuleTester::with_extension("tsx", rule, "tsx")
465            .expect("builds")
466            .reports_at("const a = <div>hi</div>;\n", &[(1, 11)])
467            .expect("should report the element");
468    }
469
470    #[test]
471    fn the_temporary_project_is_cleaned_up() {
472        let path = {
473            let tester = tester("cleanup");
474            tester.accepts("const a = 1;\n").expect("runs");
475            tester.dir.clone()
476        };
477        assert!(
478            !path.exists(),
479            "the tester should remove its project on drop"
480        );
481    }
482}