use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use lanekeep_core::Violation;
use lanekeep_engine::Engine;
use lanekeep_js::{BuiltinComponent, BuiltinComponentMap, BuiltinSource, RuleRoot};
use lanekeep_lang_js::{JavaScript, TypeScript};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TestError {
#[error("could not set up the rule test: {0}")]
Setup(String),
#[error("rule failed to load:\n{0}")]
Load(String),
#[error("rule failed while running:\n{0}")]
Run(String),
#[error("{0}")]
Mismatch(String),
}
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct RuleTester {
dir: PathBuf,
extension: String,
config: &'static str,
components: BuiltinComponent,
component_maps: BuiltinComponentMap,
builtins: BuiltinSource,
}
const fn no_components(_: &str) -> Option<(&'static [u8], u32)> {
None
}
const fn no_component_maps(_: &str) -> Option<&'static [u8]> {
None
}
const fn no_builtins(_: &str) -> Option<&'static str> {
None
}
const TS_CONFIG: &str = "lanekeep.config.ts";
const JSON_CONFIG: &str = "lanekeep.json";
const COMPONENT_PATH: &str = "rules/rule.wasm";
impl RuleTester {
pub fn new(name: &str, rule_source: &str) -> Result<Self, TestError> {
Self::with_extension(name, rule_source, "ts")
}
pub fn with_extension(
name: &str,
rule_source: &str,
extension: &str,
) -> Result<Self, TestError> {
Self::build(name, rule_source, extension, "rule")
}
pub fn configured(name: &str, rule_source: &str, options: &str) -> Result<Self, TestError> {
Self::configured_with_extension(name, rule_source, "ts", options)
}
pub fn configured_with_extension(
name: &str,
rule_source: &str,
extension: &str,
options: &str,
) -> Result<Self, TestError> {
Self::build(name, rule_source, extension, &format!("rule({options})"))
}
pub fn for_component(name: &str, bytes: &[u8], extension: &str) -> Result<Self, TestError> {
Self::build_component(name, bytes, extension, None)
}
pub fn for_component_configured(
name: &str,
bytes: &[u8],
extension: &str,
options: &str,
) -> Result<Self, TestError> {
let options: serde_json::Value = serde_json::from_str(options)
.map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
Self::build_component(name, bytes, extension, Some(options))
}
pub fn for_built_in(
name: &str,
extension: &str,
components: BuiltinComponent,
) -> Result<Self, TestError> {
Self::build_built_in(name, extension, components, None)
}
pub fn for_built_in_configured(
name: &str,
extension: &str,
components: BuiltinComponent,
options: &str,
) -> Result<Self, TestError> {
let options: serde_json::Value = serde_json::from_str(options)
.map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
Self::build_built_in(name, extension, components, Some(options))
}
#[must_use]
pub const fn with_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
self.component_maps = maps;
self
}
#[must_use]
pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
self.builtins = builtins;
self
}
fn build_built_in(
name: &str,
extension: &str,
components: BuiltinComponent,
options: Option<serde_json::Value>,
) -> Result<Self, TestError> {
let mut tester = Self::empty(name, extension, JSON_CONFIG);
tester.components = components;
let reference = format!("lanekeep/{name}");
let rule = match options {
None => serde_json::Value::String(reference),
Some(options) => serde_json::json!({ "rule": reference, "options": options }),
};
let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
tester.write(JSON_CONFIG, &config.to_string())?;
Ok(tester)
}
fn build(
name: &str,
rule_source: &str,
extension: &str,
rule_expr: &str,
) -> Result<Self, TestError> {
let tester = Self::empty(name, extension, TS_CONFIG);
tester.write("rules/rule.ts", rule_source)?;
tester.mirror_modules()?;
tester.write(
TS_CONFIG,
&format!(
"import {{ defineConfig }} from 'lanekeep';\n\
import rule from './rules/rule';\n\
export default defineConfig({{ include: ['subject/**'], rules: [{rule_expr}] }});\n"
),
)?;
Ok(tester)
}
fn mirror_modules(&self) -> Result<(), TestError> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lanekeep/modules");
let Ok(read_dir) = std::fs::read_dir(&source) else {
return Ok(());
};
let mut paths = read_dir
.map(|entry| {
entry
.map(|e| e.path())
.map_err(|e| TestError::Setup(e.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
paths.sort();
for path in paths {
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let contents =
std::fs::read_to_string(&path).map_err(|e| TestError::Setup(e.to_string()))?;
self.write(&format!("modules/{name}"), &contents)?;
}
Ok(())
}
fn build_component(
name: &str,
bytes: &[u8],
extension: &str,
options: Option<serde_json::Value>,
) -> Result<Self, TestError> {
let tester = Self::empty(name, extension, JSON_CONFIG);
tester.write_bytes(COMPONENT_PATH, bytes)?;
let reference = format!("./{COMPONENT_PATH}");
let rule = match options {
None => serde_json::Value::String(reference),
Some(options) => serde_json::json!({ "rule": reference, "options": options }),
};
let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
tester.write(JSON_CONFIG, &config.to_string())?;
Ok(tester)
}
fn empty(name: &str, extension: &str, config: &'static str) -> Self {
let seq = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"lanekeep-ruletest-{name}-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
Self {
dir,
extension: extension.to_owned(),
config,
components: no_components,
component_maps: no_component_maps,
builtins: no_builtins,
}
}
pub fn write_fixture(&self, path: &str, contents: &str) -> Result<(), TestError> {
self.write(path, contents)
}
fn write(&self, path: &str, contents: &str) -> Result<(), TestError> {
self.write_bytes(path, contents.as_bytes())
}
fn write_bytes(&self, path: &str, contents: &[u8]) -> Result<(), TestError> {
let full = self.dir.join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).map_err(|e| TestError::Setup(e.to_string()))?;
}
std::fs::write(full, contents).map_err(|e| TestError::Setup(e.to_string()))
}
pub fn run(&self, source: &str) -> Result<Vec<Violation>, TestError> {
let _ = std::fs::remove_dir_all(self.dir.join("subject"));
self.write(&format!("subject/input.{}", self.extension), source)?;
let root = RuleRoot::new(&self.dir)
.map_err(|e| TestError::Setup(e.to_string()))?
.with_builtins(self.builtins)
.with_builtin_components(self.components)
.with_builtin_component_maps(self.component_maps);
let config_path = self.dir.join(self.config);
let sandbox =
lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
.map_err(|e| TestError::Load(e.to_string()))?;
let config = lanekeep_config::load_with(
&sandbox,
&root,
&config_path,
lanekeep_config::LoadOptions {
artifacts: Some(&self.dir),
..lanekeep_config::LoadOptions::default()
},
)
.map_err(|e| TestError::Load(e.to_string()))?;
let engine = Engine::prepare(
&config,
&self.dir,
root,
&config_path,
&lanekeep_languages::registry(),
Arc::new(TypeScript),
Arc::new(JavaScript),
)
.map_err(|e| TestError::Load(e.to_string()))?;
engine
.run()
.map(|outcome| outcome.violations)
.map_err(|e| TestError::Run(e.to_string()))
}
pub fn accepts(&self, source: &str) -> Result<(), TestError> {
let violations = self.run(source)?;
if violations.is_empty() {
return Ok(());
}
let mut message = format!(
"expected no violations, but the rule reported {}:\n",
violations.len()
);
for violation in &violations {
let _ = writeln!(
message,
" {}:{} {}",
violation.location.position.line,
violation.location.position.column,
violation.message
);
}
let _ = write!(message, "\nsource:\n{}", indent(source));
Err(TestError::Mismatch(message))
}
pub fn reports_at(&self, source: &str, expected: &[(u32, u32)]) -> Result<(), TestError> {
let violations = self.run(source)?;
let actual: Vec<(u32, u32)> = violations
.iter()
.map(|v| (v.location.position.line, v.location.position.column))
.collect();
if actual == expected {
return Ok(());
}
Err(TestError::Mismatch(format!(
"reported positions did not match\n expected: {expected:?}\n actual: {actual:?}\n\nsource:\n{}",
indent(source)
)))
}
pub fn reports_messages(&self, source: &str, expected: &[&str]) -> Result<(), TestError> {
let violations = self.run(source)?;
let actual: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
if actual == expected {
return Ok(());
}
Err(TestError::Mismatch(format!(
"reported messages did not match\n expected: {expected:?}\n actual: {actual:?}\n\nsource:\n{}",
indent(source)
)))
}
}
impl Drop for RuleTester {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn indent(source: &str) -> String {
source.lines().fold(String::new(), |mut out, line| {
let _ = writeln!(out, " | {line}");
out
})
}
#[cfg(test)]
mod tests {
use super::*;
const DEBUGGER: &str = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/no-debugger',\n\
query: '(debugger_statement) @stmt',\n\
card: {\n\
message: 'debugger statement',\n\
remediation: 'remove it',\n\
examples: { bad: 'debugger;', good: 'log();' },\n\
},\n\
check(ctx, m) { ctx.report(m.stmt); },\n\
});\n";
fn tester(name: &str) -> RuleTester {
RuleTester::new(name, DEBUGGER).expect("builds")
}
#[test]
fn accepts_clean_source() {
tester("accepts")
.accepts("const a = 1;\n")
.expect("should accept");
}
#[test]
fn reports_at_the_expected_positions() {
tester("positions")
.reports_at("const a = 1;\ndebugger;\n", &[(2, 1)])
.expect("should report");
}
#[test]
fn reports_several_in_order() {
tester("several")
.reports_at("debugger;\nconst a = 1;\ndebugger;\n", &[(1, 1), (3, 1)])
.expect("should report both");
}
#[test]
fn accepts_fails_loudly_and_shows_what_was_found() {
let err = tester("accepts-fail")
.accepts("debugger;\n")
.expect_err("should not accept");
let rendered = err.to_string();
assert!(rendered.contains("expected no violations"), "{rendered}");
assert!(
rendered.contains("debugger statement"),
"should show the message: {rendered}"
);
assert!(rendered.contains("1:1"), "should show where: {rendered}");
}
#[test]
fn a_position_mismatch_shows_both_sides() {
let err = tester("position-fail")
.reports_at("debugger;\n", &[(5, 5)])
.expect_err("should not match");
let rendered = err.to_string();
assert!(rendered.contains("expected: [(5, 5)]"), "{rendered}");
assert!(rendered.contains("actual: [(1, 1)]"), "{rendered}");
}
#[test]
fn checks_messages_when_a_rule_substitutes_its_own() {
let rule = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/named',\n\
query: '(variable_declarator name: (identifier) @name)',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check(ctx, m) { ctx.report(m.name, `saw ${ctx.text(m.name)}`); },\n\
});\n";
RuleTester::new("messages", rule)
.expect("builds")
.reports_messages(
"const alpha = 1;\nconst beta = 2;\n",
&["saw alpha", "saw beta"],
)
.expect("should match");
}
#[test]
fn a_rule_that_does_not_load_is_distinguished_from_one_that_found_nothing() {
let broken = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/broken',\n\
query: '(no_such_node) @x',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() {},\n\
});\n";
let err = RuleTester::new("broken", broken)
.expect("builds")
.accepts("const a = 1;\n")
.expect_err("must not pass");
assert!(matches!(err, TestError::Load(_)), "{err:?}");
assert!(err.to_string().contains("no_such_node"), "{err}");
}
#[test]
fn a_throwing_rule_is_reported_as_a_run_failure() {
let throwing = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/throws',\n\
query: '(debugger_statement) @s',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() { throw new Error('boom'); },\n\
});\n";
let err = RuleTester::new("throwing", throwing)
.expect("builds")
.accepts("debugger;\n")
.expect_err("must not pass");
assert!(matches!(err, TestError::Run(_)), "{err:?}");
assert!(err.to_string().contains("boom"), "{err}");
}
#[test]
fn cases_do_not_leak_into_each_other() {
let tester = tester("isolation");
tester
.reports_at("debugger;\n", &[(1, 1)])
.expect("first case");
tester
.accepts("const a = 1;\n")
.expect("second case must not see the first");
}
#[test]
fn a_tsx_rule_can_be_tested_against_tsx() {
let rule = "import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/no-jsx',\n\
language: 'tsx',\n\
query: '(jsx_element) @el',\n\
card: { message: 'jsx', remediation: 'do not', examples: { bad: '<a/>', good: 'a()' } },\n\
check(ctx, m) { ctx.report(m.el); },\n\
});\n";
RuleTester::with_extension("tsx", rule, "tsx")
.expect("builds")
.reports_at("const a = <div>hi</div>;\n", &[(1, 11)])
.expect("should report the element");
}
const NOT_A_COMPONENT: &[u8] = b"\0asm not really";
fn written_config(tester: &RuleTester) -> serde_json::Value {
let text = std::fs::read_to_string(tester.dir.join(JSON_CONFIG)).expect("config written");
serde_json::from_str(&text).expect("the generated config is JSON")
}
#[test]
fn a_component_tester_writes_the_bytes_and_points_a_json_config_at_them() {
let tester = RuleTester::for_component("component", NOT_A_COMPONENT, "rs").expect("builds");
assert_eq!(
std::fs::read(tester.dir.join(COMPONENT_PATH)).expect("component written"),
NOT_A_COMPONENT,
"the bytes must reach disk unchanged — a component is identified by them"
);
assert_eq!(
written_config(&tester)["rules"][0],
serde_json::json!("./rules/rule.wasm"),
"the bare form is a string, which is what `lanekeep-config` reads as a rule used \
as it comes"
);
assert!(
!tester.dir.join(TS_CONFIG).exists(),
"a component project must not also carry a TypeScript config"
);
}
#[test]
fn a_configured_component_tester_embeds_its_options_as_data() {
let tester = RuleTester::for_component_configured(
"configured",
NOT_A_COMPONENT,
"rs",
r#"{"allow": ["subject/input.rs"]}"#,
)
.expect("builds");
assert_eq!(
written_config(&tester)["rules"][0],
serde_json::json!({
"rule": "./rules/rule.wasm",
"options": { "allow": ["subject/input.rs"] },
})
);
}
#[test]
fn explicit_null_options_are_a_different_config_from_the_bare_form() {
let configured =
RuleTester::for_component_configured("null-options", NOT_A_COMPONENT, "rs", "null")
.expect("builds");
let bare = RuleTester::for_component("bare", NOT_A_COMPONENT, "rs").expect("builds");
assert_eq!(
written_config(&configured)["rules"][0],
serde_json::json!({ "rule": "./rules/rule.wasm", "options": null })
);
assert_ne!(
written_config(&configured)["rules"][0],
written_config(&bare)["rules"][0]
);
}
#[test]
fn options_that_are_not_json_are_refused_naming_the_options() {
let err = RuleTester::for_component_configured(
"bad-options",
NOT_A_COMPONENT,
"rs",
"{ allow: ['subject/input.rs'] }",
)
.expect_err("JavaScript object syntax is not JSON");
assert!(matches!(err, TestError::Setup(_)), "{err:?}");
assert!(
err.to_string().contains("`options` is not valid JSON"),
"{err}"
);
}
#[test]
fn the_temporary_project_is_cleaned_up() {
let path = {
let tester = tester("cleanup");
tester.accepts("const a = 1;\n").expect("runs");
tester.dir.clone()
};
assert!(
!path.exists(),
"the tester should remove its project on drop"
);
}
}