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::{Path, PathBuf};
32use std::sync::Arc;
33use std::sync::atomic::{AtomicU64, Ordering};
34
35use lanekeep_core::Violation;
36use lanekeep_engine::Engine;
37use lanekeep_js::{BuiltinComponent, BuiltinComponentMap, BuiltinSource, 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 /// The config file the project was written with, relative to [`RuleTester::dir`].
75 ///
76 /// A field rather than a constant because a component tester writes a `lanekeep.json` and a
77 /// source tester writes a `lanekeep.config.ts` — see [`RuleTester::for_component`] for why
78 /// that is forced rather than chosen.
79 config: &'static str,
80 /// How `lanekeep/<name>` resolves to a component, for a tester built by
81 /// [`RuleTester::for_built_in`]. [`no_components`] for every other constructor, which is
82 /// what [`RuleRoot::new`] starts with anyway.
83 components: BuiltinComponent,
84 /// The matching source-map lookup, on the same terms.
85 component_maps: BuiltinComponentMap,
86 /// How `lanekeep/<name>` resolves to a built-in module's source — `lanekeep/patterns`
87 /// and friends — for a rule that imports one. [`no_builtins`] for every constructor,
88 /// which is what [`RuleRoot::new`] starts with anyway.
89 builtins: BuiltinSource,
90}
91
92/// A build with no built-in components, which is what every tester but a built-in one wants.
93///
94/// Written here rather than reached for in `lanekeep-js` because that crate keeps its own
95/// equivalents private, and a `fn` item is cheaper than widening its API for one caller.
96const fn no_components(_: &str) -> Option<(&'static [u8], u32)> {
97 None
98}
99
100/// The source-map half of [`no_components`].
101const fn no_component_maps(_: &str) -> Option<&'static [u8]> {
102 None
103}
104
105/// A build with no built-in modules, which is what every tester but a module-rule one wants.
106///
107/// On the same terms as [`no_components`]: `lanekeep-js` keeps its own equivalent private,
108/// and a `fn` item is cheaper than widening its API for one caller.
109const fn no_builtins(_: &str) -> Option<&'static str> {
110 None
111}
112
113/// What a source rule's project is configured by.
114const TS_CONFIG: &str = "lanekeep.config.ts";
115
116/// What a component rule's project is configured by.
117const JSON_CONFIG: &str = "lanekeep.json";
118
119/// Where a component tester puts the rule, relative to the project root.
120///
121/// Inside the rules root, which is the project root here, because `lanekeep-config` confines a
122/// component reference to it — a `.wasm` outside is refused before it is read.
123const COMPONENT_PATH: &str = "rules/rule.wasm";
124
125impl RuleTester {
126 /// Build a tester for a rule's source.
127 ///
128 /// `name` labels the temporary directory and need not be unique — every tester gets its
129 /// own directory regardless, so a test file with a `fn tester()` helper shared across
130 /// cases works. It has to: two testers sharing a directory would delete each other's
131 /// project mid-run, and the resulting error would point at the config rather than at
132 /// the collision.
133 ///
134 /// # Errors
135 ///
136 /// Returns [`TestError::Setup`] if the temporary project cannot be written.
137 pub fn new(name: &str, rule_source: &str) -> Result<Self, TestError> {
138 Self::with_extension(name, rule_source, "ts")
139 }
140
141 /// Build a tester whose subject files use a given extension.
142 ///
143 /// Needed for a rule targeting `tsx`, since which grammar parses a file is decided by
144 /// its extension — a TSX rule tested against a `.ts` file would never match.
145 ///
146 /// # Errors
147 ///
148 /// As [`RuleTester::new`].
149 pub fn with_extension(
150 name: &str,
151 rule_source: &str,
152 extension: &str,
153 ) -> Result<Self, TestError> {
154 Self::build(name, rule_source, extension, "rule")
155 }
156
157 /// Build a tester for a *factory* rule — one whose default export returns a rule when
158 /// called with options — using the given options expression.
159 ///
160 /// `options` is JavaScript, spliced into the generated config as `rule(<options>)`.
161 /// Passing the options as source rather than as a serialized value is deliberate: a
162 /// factory takes whatever its author designed, and a harness that only accepted JSON
163 /// could not test one taking a function or a regular expression.
164 ///
165 /// ```no_run
166 /// # use lanekeep_testkit::RuleTester;
167 /// let tester = RuleTester::configured(
168 /// "restricted",
169 /// RULE_SOURCE,
170 /// "{ restrictions: [{ module: 'lodash' }] }",
171 /// )
172 /// .expect("builds");
173 /// # const RULE_SOURCE: &str = "";
174 /// ```
175 ///
176 /// # Errors
177 ///
178 /// As [`RuleTester::new`].
179 pub fn configured(name: &str, rule_source: &str, options: &str) -> Result<Self, TestError> {
180 Self::configured_with_extension(name, rule_source, "ts", options)
181 }
182
183 /// Build a tester for a factory rule whose subject files use a given extension.
184 ///
185 /// [`RuleTester::configured`] and [`RuleTester::with_extension`] each vary one axis, and
186 /// a rule that is both parameterized and non-TypeScript needs both — every built-in
187 /// targeting Rust, Go or Python is in that position the moment it takes an option. The
188 /// absence was not a decision: `configured` predates any parameterized rule outside
189 /// TypeScript, and the two Rust-targeting built-ins that document an `allow` option had
190 /// no test reaching them here at all.
191 ///
192 /// # Errors
193 ///
194 /// As [`RuleTester::new`].
195 pub fn configured_with_extension(
196 name: &str,
197 rule_source: &str,
198 extension: &str,
199 options: &str,
200 ) -> Result<Self, TestError> {
201 Self::build(name, rule_source, extension, &format!("rule({options})"))
202 }
203
204 /// Build a tester for a rule compiled to a WebAssembly component.
205 ///
206 /// `bytes` are the component itself — `lanekeep_rules::component("no-unwrap")`, or whatever
207 /// a project's own build produced. They are written to `rules/rule.wasm` inside the
208 /// throwaway project, because a component reference is confined to the rules root.
209 ///
210 /// # It generates a `lanekeep.json`, and it has to
211 ///
212 /// Every other constructor writes a `lanekeep.config.ts` that imports the rule. A `.wasm` is
213 /// not a value a TypeScript module can import: a component is resolved in Rust, by path, and
214 /// answers `metadata` for itself. So the component path is the JSON config path, and that is
215 /// forced by what a component is rather than chosen for convenience.
216 ///
217 /// # `extension` is required rather than defaulted
218 ///
219 /// [`RuleTester::new`] defaults to `ts` and [`RuleTester::with_extension`] varies it, which
220 /// is right when TypeScript is the overwhelmingly common case. It is not the common case
221 /// here — a rule authored as a component is one written in the language it inspects, and the
222 /// two that exist target Rust — so a default would be wrong more often than not, and the
223 /// pair of `*_with_extension` variants it would need doubles the constructor count for
224 /// nothing.
225 ///
226 /// # Errors
227 ///
228 /// As [`RuleTester::new`].
229 pub fn for_component(name: &str, bytes: &[u8], extension: &str) -> Result<Self, TestError> {
230 Self::build_component(name, bytes, extension, None)
231 }
232
233 /// Build a tester for a component rule configured with options.
234 ///
235 /// `options` is **JSON**, and the difference from [`RuleTester::configured`] is the whole
236 /// point rather than an inconvenience. That one splices JavaScript source into a config, so
237 /// a factory rule can be handed a function or a regular expression. A component cannot close
238 /// over a host-supplied value at all: its options cross the boundary as data, through the
239 /// world's `configure(options-json)`. Accepting source here would suggest otherwise, and
240 /// would test a shape no real config can produce.
241 ///
242 /// # Errors
243 ///
244 /// As [`RuleTester::new`], plus [`TestError::Setup`] if `options` is not valid JSON — which
245 /// is caught here rather than left to surface as a config parse error naming a generated
246 /// file the caller never wrote.
247 pub fn for_component_configured(
248 name: &str,
249 bytes: &[u8],
250 extension: &str,
251 options: &str,
252 ) -> Result<Self, TestError> {
253 let options: serde_json::Value = serde_json::from_str(options)
254 .map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
255 Self::build_component(name, bytes, extension, Some(options))
256 }
257
258 /// Build a tester for a built-in rule, named the way a real config names it.
259 ///
260 /// `name` is the bare rule name — `"no-default-export"` — and the generated `lanekeep.json`
261 /// carries `"lanekeep/no-default-export"`. `components` is the lookup that answers it, which
262 /// for lanekeep's own rules is `lanekeep_rules::component`; it is a parameter rather than a
263 /// dependency because `lanekeep-rules` dev-depends on this crate, and an edge the other way
264 /// would put each crate ahead of the other in the publication order.
265 ///
266 /// # What this exists for, and what [`RuleTester::for_component`] cannot do
267 ///
268 /// `for_component` writes an artifact to a path, and **a path reference contributes every
269 /// rule the artifact hosts**. That is right for a component built from one `rust-rules/`
270 /// crate and wrong for a shared one: the four TypeScript built-ins live in a single
271 /// `typescript-builtins.wasm`, so pointing a tester at its bytes runs all four and there is
272 /// no way to say which one is under test. Naming the *specifier* is how a config says it —
273 /// resolution goes through the embedded table, which carries the rule's index, and the
274 /// engine is handed one rule. Until this constructor existed, a rule of a shared component
275 /// could not be tested through `RuleTester` at all.
276 ///
277 /// # It is slower than every other constructor, by a lot
278 ///
279 /// A built-in that ships as a component is compiled at load, and the shared TypeScript one
280 /// is 12.4 MiB. The first `run` on a tester pays that — seconds, not milliseconds — and
281 /// later runs on the same tester map what it wrote into the throwaway project. Prefer one
282 /// tester over a table of cases to a tester per case.
283 ///
284 /// # Errors
285 ///
286 /// As [`RuleTester::new`].
287 pub fn for_built_in(
288 name: &str,
289 extension: &str,
290 components: BuiltinComponent,
291 ) -> Result<Self, TestError> {
292 Self::build_built_in(name, extension, components, None)
293 }
294
295 /// Build a tester for a built-in rule configured with options.
296 ///
297 /// `options` is **JSON**, for the reason [`RuleTester::for_component_configured`] gives: a
298 /// built-in that ships as a component takes its options as data through `configure`, and one
299 /// that ships as a module is reached from a `lanekeep.json` here too, where JSON is all a
300 /// config can write. Neither form can close over a host-supplied value.
301 ///
302 /// # Errors
303 ///
304 /// As [`RuleTester::for_component_configured`].
305 pub fn for_built_in_configured(
306 name: &str,
307 extension: &str,
308 components: BuiltinComponent,
309 options: &str,
310 ) -> Result<Self, TestError> {
311 let options: serde_json::Value = serde_json::from_str(options)
312 .map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
313 Self::build_built_in(name, extension, components, Some(options))
314 }
315
316 /// Serve source maps for built-in components too.
317 ///
318 /// Diagnostics only, and separate from the constructor because of it: a map decides where a
319 /// *thrown* rule is reported and nothing about what a rule finds, so a test asserting
320 /// behavior needs none. `lanekeep_rules::component_source_map` is the lookup for lanekeep's
321 /// own rules.
322 #[must_use]
323 pub const fn with_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
324 self.component_maps = maps;
325 self
326 }
327
328 /// Serve the built-in modules a rule imports — `lanekeep/patterns` and friends.
329 ///
330 /// The lookup for lanekeep's own rules is `lanekeep_rules::source`; it is a parameter
331 /// rather than a dependency for the same reason [`RuleTester::for_built_in`]'s
332 /// `components` is — `lanekeep-rules` dev-depends on this crate, and an edge the other
333 /// way would put each crate ahead of the other in the publication order.
334 #[must_use]
335 pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
336 self.builtins = builtins;
337 self
338 }
339
340 /// Write the throwaway project for a built-in rule named by its specifier.
341 fn build_built_in(
342 name: &str,
343 extension: &str,
344 components: BuiltinComponent,
345 options: Option<serde_json::Value>,
346 ) -> Result<Self, TestError> {
347 let mut tester = Self::empty(name, extension, JSON_CONFIG);
348 tester.components = components;
349
350 let reference = format!("lanekeep/{name}");
351 let rule = match options {
352 None => serde_json::Value::String(reference),
353 Some(options) => serde_json::json!({ "rule": reference, "options": options }),
354 };
355 let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
356 tester.write(JSON_CONFIG, &config.to_string())?;
357 Ok(tester)
358 }
359
360 /// Write the throwaway project.
361 ///
362 /// `rule_expr` is what goes in the config's `rules` array — the imported module for a
363 /// plain rule, a call for a factory.
364 fn build(
365 name: &str,
366 rule_source: &str,
367 extension: &str,
368 rule_expr: &str,
369 ) -> Result<Self, TestError> {
370 let tester = Self::empty(name, extension, TS_CONFIG);
371 // Nested one level rather than sitting at the fixture's own top, so a rule that
372 // imports a sibling module the way this repository's local rules do — `../modules/x`
373 // from `lanekeep/rules/some-rule.ts` — resolves inside the fixture instead of
374 // escaping it. `mirror_modules` is what makes `../modules/x` resolve to something
375 // real rather than merely legal.
376 tester.write("rules/rule.ts", rule_source)?;
377 tester.mirror_modules()?;
378 tester.write(
379 TS_CONFIG,
380 &format!(
381 "import {{ defineConfig }} from 'lanekeep';\n\
382 import rule from './rules/rule';\n\
383 export default defineConfig({{ include: ['subject/**'], rules: [{rule_expr}] }});\n"
384 ),
385 )?;
386 Ok(tester)
387 }
388
389 /// Copy this repository's own `lanekeep/modules/` into the fixture, as `modules/` — a
390 /// sibling of `rules/rule.ts`, one level up from it, exactly as `lanekeep/modules/` sits
391 /// relative to `lanekeep/rules/` in the real repository.
392 ///
393 /// A rule tested here is given as a source string, not a path, so there is no file on
394 /// disk this crate could otherwise learn the rule's real location from — and no way to
395 /// thread one through without changing the shape every existing caller of `new`,
396 /// `with_extension` and `configured` already depends on. Locating the source directory
397 /// from this crate's own manifest directory keeps that shape unchanged.
398 ///
399 /// A no-op when the source directory does not exist, which is true for everything other
400 /// than this workspace's own tests: a rule with no relative import never reads the copy,
401 /// and a project outside this repository that depends on this crate to test its own
402 /// rules has no such directory to find.
403 fn mirror_modules(&self) -> Result<(), TestError> {
404 let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lanekeep/modules");
405 let Ok(read_dir) = std::fs::read_dir(&source) else {
406 return Ok(());
407 };
408
409 // Read-dir order is not guaranteed; fixed order keeps a failure in here reproducible.
410 let mut paths = read_dir
411 .map(|entry| {
412 entry
413 .map(|e| e.path())
414 .map_err(|e| TestError::Setup(e.to_string()))
415 })
416 .collect::<Result<Vec<_>, _>>()?;
417 paths.sort();
418
419 for path in paths {
420 if !path.is_file() {
421 continue;
422 }
423 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
424 continue;
425 };
426 let contents =
427 std::fs::read_to_string(&path).map_err(|e| TestError::Setup(e.to_string()))?;
428 self.write(&format!("modules/{name}"), &contents)?;
429 }
430 Ok(())
431 }
432
433 /// Write the throwaway project for a component rule.
434 ///
435 /// `options` is `None` for the bare form and `Some` for the object form, and the two are not
436 /// the same config: `lanekeep-config` reads a bare string as a rule used as it comes and
437 /// `{ "rule", "options" }` as one configured, which is the distinction a rule author already
438 /// makes between a rule and a rule factory. `Some(Value::Null)` is therefore a third thing
439 /// again — a rule explicitly configured with nothing — and is reachable from here on
440 /// purpose.
441 fn build_component(
442 name: &str,
443 bytes: &[u8],
444 extension: &str,
445 options: Option<serde_json::Value>,
446 ) -> Result<Self, TestError> {
447 let tester = Self::empty(name, extension, JSON_CONFIG);
448 tester.write_bytes(COMPONENT_PATH, bytes)?;
449
450 let reference = format!("./{COMPONENT_PATH}");
451 let rule = match options {
452 None => serde_json::Value::String(reference),
453 Some(options) => serde_json::json!({ "rule": reference, "options": options }),
454 };
455 let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
456
457 // `to_string` rather than `to_string_pretty`: nothing reads this by eye except when a
458 // test fails, and a failure prints the error rather than the config.
459 tester.write(JSON_CONFIG, &config.to_string())?;
460 Ok(tester)
461 }
462
463 /// A tester with its own directory and nothing in it yet.
464 fn empty(name: &str, extension: &str, config: &'static str) -> Self {
465 // Unique per tester: the counter separates testers in one process, the process id
466 // separates the processes nextest spawns per test.
467 let seq = NEXT_ID.fetch_add(1, Ordering::Relaxed);
468 let dir = std::env::temp_dir().join(format!(
469 "lanekeep-ruletest-{name}-{}-{seq}",
470 std::process::id()
471 ));
472 let _ = std::fs::remove_dir_all(&dir);
473
474 Self {
475 dir,
476 extension: extension.to_owned(),
477 config,
478 components: no_components,
479 component_maps: no_component_maps,
480 builtins: no_builtins,
481 }
482 }
483
484 /// Write a fixture file into the tester's project, at a path relative to it.
485 ///
486 /// A cross-file rule — one whose `reduce` reads files other than the subject — needs a
487 /// corpus to read, and this is how a test builds it. The subject itself is still written
488 /// by [`RuleTester::run`]; this is for the *other* files the rule reaches through
489 /// `ctx.files()`.
490 ///
491 /// # Errors
492 ///
493 /// Returns [`TestError::Setup`] if the file cannot be written.
494 pub fn write_fixture(&self, path: &str, contents: &str) -> Result<(), TestError> {
495 self.write(path, contents)
496 }
497
498 fn write(&self, path: &str, contents: &str) -> Result<(), TestError> {
499 self.write_bytes(path, contents.as_bytes())
500 }
501
502 fn write_bytes(&self, path: &str, contents: &[u8]) -> Result<(), TestError> {
503 let full = self.dir.join(path);
504 if let Some(parent) = full.parent() {
505 std::fs::create_dir_all(parent).map_err(|e| TestError::Setup(e.to_string()))?;
506 }
507 std::fs::write(full, contents).map_err(|e| TestError::Setup(e.to_string()))
508 }
509
510 /// Run the rule over a single source file and return what it reported.
511 ///
512 /// # Errors
513 ///
514 /// Returns [`TestError::Load`] if the rule does not load, or [`TestError::Run`] if it
515 /// throws or breaches a budget.
516 pub fn run(&self, source: &str) -> Result<Vec<Violation>, TestError> {
517 // A fresh subject each time, so one case cannot see another's file.
518 let _ = std::fs::remove_dir_all(self.dir.join("subject"));
519 self.write(&format!("subject/input.{}", self.extension), source)?;
520
521 let root = RuleRoot::new(&self.dir)
522 .map_err(|e| TestError::Setup(e.to_string()))?
523 .with_builtins(self.builtins)
524 .with_builtin_components(self.components)
525 .with_builtin_component_maps(self.component_maps);
526 let config_path = self.dir.join(self.config);
527
528 let sandbox =
529 lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
530 .map_err(|e| TestError::Load(e.to_string()))?;
531 // **The throwaway project is named as the artifact root, and this crate is the one
532 // caller entitled to do it.** `LoadOptions::artifacts` is `None` by default because a
533 // rules root is not generally a project root, and guessing one would make loading a
534 // config write into a directory nobody asked for. Here the two are the same directory,
535 // this crate created it, and `Drop` removes it. Without it every `run` compiles each
536 // component twice from scratch — tolerable for a 26 KB Rust rule and seconds per case
537 // for the 12.4 MiB shared TypeScript one, which is what `for_built_in` reaches.
538 let config = lanekeep_config::load_with(
539 &sandbox,
540 &root,
541 &config_path,
542 lanekeep_config::LoadOptions {
543 artifacts: Some(&self.dir),
544 ..lanekeep_config::LoadOptions::default()
545 },
546 )
547 .map_err(|e| TestError::Load(e.to_string()))?;
548
549 let engine = Engine::prepare(
550 &config,
551 &self.dir,
552 root,
553 &config_path,
554 &lanekeep_languages::registry(),
555 Arc::new(TypeScript),
556 Arc::new(JavaScript),
557 )
558 .map_err(|e| TestError::Load(e.to_string()))?;
559
560 engine
561 .run()
562 .map(|outcome| outcome.violations)
563 .map_err(|e| TestError::Run(e.to_string()))
564 }
565
566 /// Assert the rule reports nothing for this source.
567 ///
568 /// # Errors
569 ///
570 /// Returns [`TestError::Mismatch`] listing what was reported, since "expected none,
571 /// got some" is only actionable if you can see which.
572 pub fn accepts(&self, source: &str) -> Result<(), TestError> {
573 let violations = self.run(source)?;
574 if violations.is_empty() {
575 return Ok(());
576 }
577
578 let mut message = format!(
579 "expected no violations, but the rule reported {}:\n",
580 violations.len()
581 );
582 for violation in &violations {
583 let _ = writeln!(
584 message,
585 " {}:{} {}",
586 violation.location.position.line,
587 violation.location.position.column,
588 violation.message
589 );
590 }
591 let _ = write!(message, "\nsource:\n{}", indent(source));
592 Err(TestError::Mismatch(message))
593 }
594
595 /// Assert the rule reports at exactly these one-based positions, in order.
596 ///
597 /// Positions rather than a count, because a rule reporting the right number of
598 /// violations in the wrong places is a rule that is wrong — and a count-only assertion
599 /// is exactly what lets that through.
600 ///
601 /// # Errors
602 ///
603 /// Returns [`TestError::Mismatch`] showing expected and actual side by side.
604 pub fn reports_at(&self, source: &str, expected: &[(u32, u32)]) -> Result<(), TestError> {
605 let violations = self.run(source)?;
606 let actual: Vec<(u32, u32)> = violations
607 .iter()
608 .map(|v| (v.location.position.line, v.location.position.column))
609 .collect();
610
611 if actual == expected {
612 return Ok(());
613 }
614
615 Err(TestError::Mismatch(format!(
616 "reported positions did not match\n expected: {expected:?}\n actual: {actual:?}\n\nsource:\n{}",
617 indent(source)
618 )))
619 }
620
621 /// Assert the rule reports exactly these messages, in order.
622 ///
623 /// For a rule that substitutes its own message per match — the position alone would not
624 /// show whether the right one was chosen.
625 ///
626 /// # Errors
627 ///
628 /// Returns [`TestError::Mismatch`] showing both lists.
629 pub fn reports_messages(&self, source: &str, expected: &[&str]) -> Result<(), TestError> {
630 let violations = self.run(source)?;
631 let actual: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
632
633 if actual == expected {
634 return Ok(());
635 }
636
637 Err(TestError::Mismatch(format!(
638 "reported messages did not match\n expected: {expected:?}\n actual: {actual:?}\n\nsource:\n{}",
639 indent(source)
640 )))
641 }
642}
643
644impl Drop for RuleTester {
645 fn drop(&mut self) {
646 let _ = std::fs::remove_dir_all(&self.dir);
647 }
648}
649
650/// Indent source for inclusion in a failure message, so it is visibly quoted rather than
651/// running together with the assertion text.
652fn indent(source: &str) -> String {
653 source.lines().fold(String::new(), |mut out, line| {
654 // Writing into a String cannot fail; swallowing the Result keeps this a fold
655 // rather than a loop with an unreachable error arm.
656 let _ = writeln!(out, " | {line}");
657 out
658 })
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 const DEBUGGER: &str = "import { defineRule } from 'lanekeep';\n\
666 export default defineRule({\n\
667 id: 'local/no-debugger',\n\
668 query: '(debugger_statement) @stmt',\n\
669 card: {\n\
670 message: 'debugger statement',\n\
671 remediation: 'remove it',\n\
672 examples: { bad: 'debugger;', good: 'log();' },\n\
673 },\n\
674 check(ctx, m) { ctx.report(m.stmt); },\n\
675 });\n";
676
677 fn tester(name: &str) -> RuleTester {
678 RuleTester::new(name, DEBUGGER).expect("builds")
679 }
680
681 #[test]
682 fn accepts_clean_source() {
683 tester("accepts")
684 .accepts("const a = 1;\n")
685 .expect("should accept");
686 }
687
688 #[test]
689 fn reports_at_the_expected_positions() {
690 tester("positions")
691 .reports_at("const a = 1;\ndebugger;\n", &[(2, 1)])
692 .expect("should report");
693 }
694
695 #[test]
696 fn reports_several_in_order() {
697 tester("several")
698 .reports_at("debugger;\nconst a = 1;\ndebugger;\n", &[(1, 1), (3, 1)])
699 .expect("should report both");
700 }
701
702 #[test]
703 fn accepts_fails_loudly_and_shows_what_was_found() {
704 // A harness that said only "expected none, got some" would leave the author
705 // guessing which case tripped.
706 let err = tester("accepts-fail")
707 .accepts("debugger;\n")
708 .expect_err("should not accept");
709
710 let rendered = err.to_string();
711 assert!(rendered.contains("expected no violations"), "{rendered}");
712 assert!(
713 rendered.contains("debugger statement"),
714 "should show the message: {rendered}"
715 );
716 assert!(rendered.contains("1:1"), "should show where: {rendered}");
717 }
718
719 #[test]
720 fn a_position_mismatch_shows_both_sides() {
721 let err = tester("position-fail")
722 .reports_at("debugger;\n", &[(5, 5)])
723 .expect_err("should not match");
724
725 let rendered = err.to_string();
726 assert!(rendered.contains("expected: [(5, 5)]"), "{rendered}");
727 assert!(rendered.contains("actual: [(1, 1)]"), "{rendered}");
728 }
729
730 #[test]
731 fn checks_messages_when_a_rule_substitutes_its_own() {
732 let rule = "import { defineRule } from 'lanekeep';\n\
733 export default defineRule({\n\
734 id: 'local/named',\n\
735 query: '(variable_declarator name: (identifier) @name)',\n\
736 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
737 check(ctx, m) { ctx.report(m.name, `saw ${ctx.text(m.name)}`); },\n\
738 });\n";
739
740 RuleTester::new("messages", rule)
741 .expect("builds")
742 .reports_messages(
743 "const alpha = 1;\nconst beta = 2;\n",
744 &["saw alpha", "saw beta"],
745 )
746 .expect("should match");
747 }
748
749 #[test]
750 fn a_rule_that_does_not_load_is_distinguished_from_one_that_found_nothing() {
751 // The distinction that matters most. Reporting a load failure as "no violations"
752 // would make a broken rule look like a passing one — which is the same failure
753 // mode the config's has_check test exists to prevent, one layer up.
754 let broken = "import { defineRule } from 'lanekeep';\n\
755 export default defineRule({\n\
756 id: 'local/broken',\n\
757 query: '(no_such_node) @x',\n\
758 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
759 check() {},\n\
760 });\n";
761
762 let err = RuleTester::new("broken", broken)
763 .expect("builds")
764 .accepts("const a = 1;\n")
765 .expect_err("must not pass");
766
767 assert!(matches!(err, TestError::Load(_)), "{err:?}");
768 assert!(err.to_string().contains("no_such_node"), "{err}");
769 }
770
771 #[test]
772 fn a_throwing_rule_is_reported_as_a_run_failure() {
773 let throwing = "import { defineRule } from 'lanekeep';\n\
774 export default defineRule({\n\
775 id: 'local/throws',\n\
776 query: '(debugger_statement) @s',\n\
777 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
778 check() { throw new Error('boom'); },\n\
779 });\n";
780
781 let err = RuleTester::new("throwing", throwing)
782 .expect("builds")
783 .accepts("debugger;\n")
784 .expect_err("must not pass");
785
786 assert!(matches!(err, TestError::Run(_)), "{err:?}");
787 assert!(err.to_string().contains("boom"), "{err}");
788 }
789
790 #[test]
791 fn cases_do_not_leak_into_each_other() {
792 // Each case rewrites the subject directory. Without that, a violation from an
793 // earlier case would still be on disk and show up in the next one.
794 let tester = tester("isolation");
795 tester
796 .reports_at("debugger;\n", &[(1, 1)])
797 .expect("first case");
798 tester
799 .accepts("const a = 1;\n")
800 .expect("second case must not see the first");
801 }
802
803 #[test]
804 fn a_tsx_rule_can_be_tested_against_tsx() {
805 // Which grammar parses a file is decided by its extension, so a TSX rule tested
806 // against a `.ts` subject would silently never match.
807 let rule = "import { defineRule } from 'lanekeep';\n\
808 export default defineRule({\n\
809 id: 'local/no-jsx',\n\
810 language: 'tsx',\n\
811 query: '(jsx_element) @el',\n\
812 card: { message: 'jsx', remediation: 'do not', examples: { bad: '<a/>', good: 'a()' } },\n\
813 check(ctx, m) { ctx.report(m.el); },\n\
814 });\n";
815
816 RuleTester::with_extension("tsx", rule, "tsx")
817 .expect("builds")
818 .reports_at("const a = <div>hi</div>;\n", &[(1, 11)])
819 .expect("should report the element");
820 }
821
822 /// Not a component. These cases assert what the harness *writes*, which is the whole of what
823 /// the component constructors do; running one is `crates/lanekeep-rules/tests/no_unwrap.rs`'s
824 /// job, and it has real components to do it with. Four bytes of magic keep the fixture from
825 /// looking like something that could be loaded.
826 const NOT_A_COMPONENT: &[u8] = b"\0asm not really";
827
828 /// The generated config, parsed back.
829 fn written_config(tester: &RuleTester) -> serde_json::Value {
830 let text = std::fs::read_to_string(tester.dir.join(JSON_CONFIG)).expect("config written");
831 serde_json::from_str(&text).expect("the generated config is JSON")
832 }
833
834 #[test]
835 fn a_component_tester_writes_the_bytes_and_points_a_json_config_at_them() {
836 let tester = RuleTester::for_component("component", NOT_A_COMPONENT, "rs").expect("builds");
837
838 assert_eq!(
839 std::fs::read(tester.dir.join(COMPONENT_PATH)).expect("component written"),
840 NOT_A_COMPONENT,
841 "the bytes must reach disk unchanged — a component is identified by them"
842 );
843 assert_eq!(
844 written_config(&tester)["rules"][0],
845 serde_json::json!("./rules/rule.wasm"),
846 "the bare form is a string, which is what `lanekeep-config` reads as a rule used \
847 as it comes"
848 );
849 assert!(
850 !tester.dir.join(TS_CONFIG).exists(),
851 "a component project must not also carry a TypeScript config"
852 );
853 }
854
855 #[test]
856 fn a_configured_component_tester_embeds_its_options_as_data() {
857 // The distinction `configured` cannot make: those options are spliced into JavaScript
858 // source, and these have to survive as a value, because `configure(options-json)` is
859 // where they arrive. A harness that stringified them would hand the rule `"{...}"`.
860 let tester = RuleTester::for_component_configured(
861 "configured",
862 NOT_A_COMPONENT,
863 "rs",
864 r#"{"allow": ["subject/input.rs"]}"#,
865 )
866 .expect("builds");
867
868 assert_eq!(
869 written_config(&tester)["rules"][0],
870 serde_json::json!({
871 "rule": "./rules/rule.wasm",
872 "options": { "allow": ["subject/input.rs"] },
873 })
874 );
875 }
876
877 #[test]
878 fn explicit_null_options_are_a_different_config_from_the_bare_form() {
879 // Three states, not two. `lanekeep-config` reads a bare string as a rule used as it
880 // comes and the object form as one configured, so a rule named with `null` options is
881 // not the same config as a rule named with none — and a harness collapsing them would
882 // make the bare case untestable.
883 let configured =
884 RuleTester::for_component_configured("null-options", NOT_A_COMPONENT, "rs", "null")
885 .expect("builds");
886 let bare = RuleTester::for_component("bare", NOT_A_COMPONENT, "rs").expect("builds");
887
888 assert_eq!(
889 written_config(&configured)["rules"][0],
890 serde_json::json!({ "rule": "./rules/rule.wasm", "options": null })
891 );
892 assert_ne!(
893 written_config(&configured)["rules"][0],
894 written_config(&bare)["rules"][0]
895 );
896 }
897
898 #[test]
899 fn options_that_are_not_json_are_refused_naming_the_options() {
900 // The failure mode this branch exists to prevent: spliced into the config unchecked, a
901 // malformed value becomes a parse error against a generated file the caller never wrote,
902 // pointing at a line number that means nothing to them.
903 let err = RuleTester::for_component_configured(
904 "bad-options",
905 NOT_A_COMPONENT,
906 "rs",
907 "{ allow: ['subject/input.rs'] }",
908 )
909 .expect_err("JavaScript object syntax is not JSON");
910
911 assert!(matches!(err, TestError::Setup(_)), "{err:?}");
912 assert!(
913 err.to_string().contains("`options` is not valid JSON"),
914 "{err}"
915 );
916 }
917
918 #[test]
919 fn the_temporary_project_is_cleaned_up() {
920 let path = {
921 let tester = tester("cleanup");
922 tester.accepts("const a = 1;\n").expect("runs");
923 tester.dir.clone()
924 };
925 assert!(
926 !path.exists(),
927 "the tester should remove its project on drop"
928 );
929 }
930}