1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum TestError {
44 #[error("could not set up the rule test: {0}")]
46 Setup(String),
47
48 #[error("rule failed to load:\n{0}")]
53 Load(String),
54
55 #[error("rule failed while running:\n{0}")]
57 Run(String),
58
59 #[error("{0}")]
61 Mismatch(String),
62}
63
64static NEXT_ID: AtomicU64 = AtomicU64::new(0);
66
67#[derive(Debug)]
71pub struct RuleTester {
72 dir: PathBuf,
73 extension: String,
74}
75
76impl RuleTester {
77 pub fn new(name: &str, rule_source: &str) -> Result<Self, TestError> {
89 Self::with_extension(name, rule_source, "ts")
90 }
91
92 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 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 fn build(
139 name: &str,
140 rule_source: &str,
141 extension: &str,
142 rule_expr: &str,
143 ) -> Result<Self, TestError> {
144 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 pub fn run(&self, source: &str) -> Result<Vec<Violation>, TestError> {
184 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 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 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 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
298fn indent(source: &str) -> String {
301 source.lines().fold(String::new(), |mut out, line| {
302 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 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 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 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 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}