use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use lanekeep_core::files::normalize;
use serde::Deserialize;
use serde_json::Value;
use crate::ConfigError;
pub(crate) type BuiltinComponent = fn(&str) -> Option<(&'static [u8], u32)>;
const BUILTIN_PREFIX: &str = "lanekeep/";
const COMPONENT_EXTENSION: &str = "wasm";
pub(crate) fn is_json(path: &Path) -> bool {
path.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleReference {
Builtin(String),
BuiltinComponent(String),
Component(PathBuf),
Module(String),
}
impl RuleReference {
#[must_use]
pub const fn is_component(&self) -> bool {
matches!(self, Self::Component(_) | Self::BuiltinComponent(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRule {
pub specifier: String,
pub reference: RuleReference,
pub options: Option<Value>,
}
pub(crate) struct Parsed {
pub(crate) config: crate::RawConfig,
pub(crate) rules: Vec<ResolvedRule>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonConfig {
#[serde(rename = "$schema", default)]
_schema: Option<String>,
#[serde(default)]
include: Vec<String>,
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
namespaces: Vec<String>,
#[serde(default)]
severity: std::collections::BTreeMap<String, String>,
#[serde(default)]
timeouts: crate::RawTimeouts,
#[serde(default)]
suppressions: crate::RawSuppressions,
#[serde(default)]
rules: Vec<JsonRule>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum JsonRule {
Plain(String),
Configured {
rule: String,
#[serde(default)]
options: Value,
},
}
impl JsonRule {
fn specifier(&self) -> &str {
match self {
Self::Plain(specifier) => specifier,
Self::Configured { rule, .. } => rule,
}
}
fn options(&self) -> Option<Value> {
match self {
Self::Plain(_) => None,
Self::Configured { options, .. } => Some(options.clone()),
}
}
}
pub(crate) fn parse(
config_path: &Path,
rules_root: &Path,
components: BuiltinComponent,
) -> Result<Parsed, ConfigError> {
let display = config_path.display().to_string();
let text = std::fs::read_to_string(config_path).map_err(|e| ConfigError::Unreadable {
path: display.clone(),
detail: e.to_string(),
})?;
let config: JsonConfig = serde_json::from_str(&text).map_err(|e| ConfigError::Shape {
path: display.clone(),
detail: e.to_string(),
})?;
let mut rules = Vec::with_capacity(config.rules.len());
for rule in &config.rules {
let specifier = rule.specifier();
validate_specifier(specifier, &display)?;
rules.push(ResolvedRule {
specifier: specifier.to_owned(),
reference: classify(specifier, rules_root, components),
options: rule.options(),
});
}
Ok(Parsed {
config: crate::RawConfig {
include: config.include,
exclude: config.exclude,
namespaces: config.namespaces,
severity: config.severity,
timeouts: config.timeouts,
suppressions: config.suppressions,
rules: Vec::new(),
},
rules,
})
}
fn classify(specifier: &str, rules_root: &Path, components: BuiltinComponent) -> RuleReference {
if let Some(name) = specifier.strip_prefix(BUILTIN_PREFIX) {
return if components(name).is_some() {
RuleReference::BuiltinComponent(name.to_owned())
} else {
RuleReference::Builtin(name.to_owned())
};
}
let path = Path::new(specifier);
if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case(COMPONENT_EXTENSION))
{
return RuleReference::Component(normalize(&rules_root.join(path)));
}
RuleReference::Module(specifier.to_owned())
}
pub(crate) fn rules_module(rules: &[ResolvedRule]) -> String {
let mut imports = String::new();
let mut references = Vec::with_capacity(rules.len());
for (index, rule) in rules.iter().enumerate() {
if rule.reference.is_component() {
references.push("null".to_owned());
continue;
}
let binding = format!("__lanekeepRule{index}");
let _ = writeln!(
imports,
"import {binding} from {};",
js_string(&rule.specifier)
);
references.push(match &rule.options {
None => binding,
Some(options) => {
let specifier = js_string(&rule.specifier);
format!(
"(function() {{ var __r = {binding}; var __o = {literal}; \
if (typeof __r === 'function') return __r(__o); \
if (__o !== null && __o !== undefined) \
throw new Error({specifier} + ' takes no options — \
it exports a rule object, not a factory'); \
return __r; }})()",
binding = binding,
literal = literal(options),
specifier = specifier,
)
}
});
}
format!(
"{imports}globalThis.__lanekeepConfig = {{ rules: [{}] }};\n",
references.join(", "),
)
}
fn validate_specifier(specifier: &str, display: &str) -> Result<(), ConfigError> {
if specifier.is_empty() {
return Err(ConfigError::Shape {
path: display.to_owned(),
detail: "a rule entry is an empty string".to_owned(),
});
}
if specifier.contains(['\'', '"', '\\', '\n', '\r']) {
return Err(ConfigError::Shape {
path: display.to_owned(),
detail: format!(
"the rule specifier {specifier:?} contains a quote, a backslash or a newline"
),
});
}
Ok(())
}
pub(crate) fn literal<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value)
.unwrap_or_else(|_| "null".to_owned())
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029")
}
fn js_string(value: &str) -> String {
format!("'{value}'")
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("lanekeep-json-{name}"))
}
fn no_components(_name: &str) -> Option<(&'static [u8], u32)> {
None
}
fn one_component(name: &str) -> Option<(&'static [u8], u32)> {
match name {
"compiled" => Some((b"\0asm\x01\x00\x00\x00", 0)),
_ => None,
}
}
fn parse_config(name: &str, json: &str) -> Result<Parsed, ConfigError> {
parse_config_with(name, json, no_components)
}
fn parse_config_with(
name: &str,
json: &str,
components: BuiltinComponent,
) -> Result<Parsed, ConfigError> {
let dir = fixture_dir(name);
std::fs::create_dir_all(&dir).expect("creates dir");
let path = dir.join("lanekeep.json");
std::fs::write(&path, json).expect("writes");
parse(&path, &dir, components)
}
fn compile(name: &str, json: &str) -> Result<String, ConfigError> {
let parsed = parse_config(name, json)?;
Ok(rules_module(&parsed.rules))
}
#[test]
fn a_bare_rule_is_imported_and_used_as_it_comes() {
let source =
compile("bare-rule", r#"{"rules": ["lanekeep/no-default-export"]}"#).expect("compiles");
assert!(source.contains("import __lanekeepRule0 from 'lanekeep/no-default-export';"));
assert!(source.contains("rules: [__lanekeepRule0]"));
}
#[test]
fn a_configured_rule_is_called_with_its_options() {
let source = compile(
"configured-rule",
r#"{"rules": [{"rule": "lanekeep/no-restricted-imports",
"options": {"restrictions": [{"module": "stripe"}]}}]}"#,
)
.expect("compiles");
assert!(
source.contains(r#"{"restrictions":[{"module":"stripe"}]}"#),
"the options literal must appear in the entry module:\n{source}"
);
assert!(
source.contains("__lanekeepRule0"),
"the rule binding must be referenced:\n{source}"
);
}
#[test]
fn a_local_rule_keeps_its_relative_path() {
let source =
compile("local-rule", r#"{"rules": ["./lanekeep/rules/mine.ts"]}"#).expect("compiles");
assert!(source.contains("from './lanekeep/rules/mine.ts';"));
}
#[test]
fn globs_and_namespaces_survive() {
let parsed = parse_config(
"globs-and-namespaces",
r#"{"include": ["src/**/*.go"], "exclude": ["**/*_test.go"], "namespaces": ["acme"]}"#,
)
.expect("parses");
assert_eq!(parsed.config.include, ["src/**/*.go"]);
assert_eq!(parsed.config.exclude, ["**/*_test.go"]);
assert_eq!(parsed.config.namespaces, ["acme"]);
}
#[test]
fn no_configuration_data_reaches_the_entry_module() {
let source = compile(
"no-data-in-module",
r#"{"include": ["src/**/*.go"], "exclude": ["**/*_test.go"],
"namespaces": ["acme"], "severity": {"acme/a": "warn"},
"timeouts": {"rule": 100, "global": 5000},
"suppressions": {"requireExpiry": true, "maxExpiryDays": 30,
"forbidFileScope": true},
"rules": ["lanekeep/no-default-export"]}"#,
)
.expect("compiles");
for absent in [
"src/**/*.go",
"*_test.go",
"acme",
"warn",
"severity",
"timeouts",
"suppressions",
"requireExpiry",
"maxExpiryDays",
"forbidFileScope",
"include",
"exclude",
] {
assert!(
!source.contains(absent),
"`{absent}` should not reach the sandbox: {source}"
);
}
}
#[test]
fn severity_and_timeouts_are_read_in_rust() {
let parsed = parse_config(
"severity-and-timeouts",
r#"{"severity": {"acme/a": "warn"}, "timeouts": {"rule": 100, "global": 5000}}"#,
)
.expect("parses");
assert_eq!(
parsed.config.severity.get("acme/a").map(String::as_str),
Some("warn")
);
assert_eq!(parsed.config.timeouts.rule, Some(100));
assert_eq!(parsed.config.timeouts.global, Some(5000));
}
#[test]
fn suppressions_are_read_in_rust() {
let parsed = parse_config(
"suppressions-in-rust",
r#"{"suppressions": {"requireExpiry": true, "maxExpiryDays": 30, "forbidFileScope": true}}"#,
)
.expect("parses");
assert!(parsed.config.suppressions.require_expiry);
assert_eq!(parsed.config.suppressions.max_expiry_days, Some(30));
assert!(parsed.config.suppressions.forbid_file_scope);
}
#[test]
fn a_builtin_resolves_to_its_name() {
let parsed =
parse_config("builtin", r#"{"rules": ["lanekeep/no-package-init"]}"#).expect("parses");
assert_eq!(
parsed.rules[0].reference,
RuleReference::Builtin("no-package-init".to_owned())
);
assert_eq!(parsed.rules[0].options, None);
}
#[test]
fn a_module_reference_keeps_its_specifier() {
let parsed =
parse_config("module-ref", r#"{"rules": ["./rules/mine.ts"]}"#).expect("parses");
assert_eq!(
parsed.rules[0].reference,
RuleReference::Module("./rules/mine.ts".to_owned())
);
}
#[test]
fn a_component_reference_resolves_to_a_path() {
let parsed =
parse_config("component-path", r#"{"rules": ["./rules/mine.wasm"]}"#).expect("parses");
assert_eq!(
parsed.rules[0].reference,
RuleReference::Component(
fixture_dir("component-path")
.join("rules")
.join("mine.wasm")
)
);
}
#[test]
fn a_component_reference_imports_nothing() {
let source = compile(
"component-placeholder",
r#"{"rules": ["./rules/mine.wasm"]}"#,
)
.expect("a component is resolved without the sandbox");
assert_eq!(source, "globalThis.__lanekeepConfig = { rules: [null] };\n");
}
#[test]
fn a_rule_after_a_component_keeps_its_position_in_the_array() {
let source = compile(
"component-numbering",
r#"{"rules": ["./rules/mine.wasm", "lanekeep/no-default-export", "./mine.ts"]}"#,
)
.expect("compiles");
assert!(
source.contains("import __lanekeepRule1 from 'lanekeep/no-default-export';"),
"{source}"
);
assert!(
source.contains("rules: [null, __lanekeepRule1, __lanekeepRule2]"),
"{source}"
);
}
#[test]
fn a_built_in_with_a_component_resolves_to_one() {
let parsed = parse_config_with(
"builtin-component",
r#"{"rules": ["lanekeep/compiled"]}"#,
one_component,
)
.expect("parses");
assert_eq!(
parsed.rules[0].reference,
RuleReference::BuiltinComponent("compiled".to_owned())
);
assert_eq!(parsed.rules[0].specifier, "lanekeep/compiled");
}
#[test]
fn the_same_built_in_is_a_module_when_no_component_ships() {
let parsed = parse_config_with(
"builtin-still-typescript",
r#"{"rules": ["lanekeep/compiled"]}"#,
no_components,
)
.expect("parses");
assert_eq!(
parsed.rules[0].reference,
RuleReference::Builtin("compiled".to_owned())
);
}
#[test]
fn a_built_in_component_imports_nothing() {
let source = {
let parsed = parse_config_with(
"builtin-component-placeholder",
r#"{"rules": ["lanekeep/compiled"]}"#,
one_component,
)
.expect("parses");
rules_module(&parsed.rules)
};
assert_eq!(source, "globalThis.__lanekeepConfig = { rules: [null] };\n");
}
#[test]
fn a_rule_after_a_built_in_component_keeps_its_position() {
let parsed = parse_config_with(
"builtin-component-numbering",
r#"{"rules": ["lanekeep/compiled", "lanekeep/no-default-export", "./mine.ts"]}"#,
one_component,
)
.expect("parses");
let source = rules_module(&parsed.rules);
assert!(
source.contains("import __lanekeepRule1 from 'lanekeep/no-default-export';"),
"{source}"
);
assert!(
source.contains("rules: [null, __lanekeepRule1, __lanekeepRule2]"),
"{source}"
);
}
#[test]
fn options_are_carried_as_data() {
let parsed = parse_config(
"options-as-data",
r#"{"rules": [{"rule": "lanekeep/x", "options": {"limit": 3}}, {"rule": "lanekeep/y"}]}"#,
)
.expect("parses");
assert_eq!(
parsed.rules[0].options,
Some(serde_json::json!({"limit": 3}))
);
assert_eq!(parsed.rules[1].options, Some(Value::Null));
}
#[test]
fn an_absent_severity_map_is_empty_rather_than_missing() {
let parsed = parse_config("absent-severity", r#"{"rules": []}"#).expect("parses");
assert!(parsed.config.severity.is_empty());
assert_eq!(parsed.config.timeouts.rule, None);
assert_eq!(parsed.config.timeouts.global, None);
}
#[test]
fn the_schema_key_is_accepted_and_ignored() {
compile(
"schema-key",
r#"{"$schema": "https://example.com/s.json", "rules": []}"#,
)
.expect("a $schema key is not an error");
}
#[test]
fn an_unknown_key_is_refused() {
let error = compile("unknown-key", r#"{"includes": ["src/**"]}"#).expect_err("refused");
assert!(
format!("{error}").contains("includes"),
"the error should name the key: {error}"
);
}
#[test]
fn a_specifier_that_would_escape_the_import_is_refused() {
for (name, hostile) in [
(
"hostile-quote",
r#"{"rules": ["a'; globalThis.x = 1; import b from 'c"]}"#,
),
(
"hostile-newline",
"{\"rules\": [\"a\\nimport b from 'c'\"]}",
),
] {
compile(name, hostile).expect_err("a specifier with a quote or newline is refused");
}
}
#[test]
fn an_empty_specifier_is_refused() {
compile("empty-specifier", r#"{"rules": [""]}"#)
.expect_err("an empty specifier cannot import anything");
}
#[test]
fn malformed_json_is_reported_as_shape() {
let error = compile("malformed", "{ not json }").expect_err("refused");
assert!(matches!(error, ConfigError::Shape { .. }));
}
#[test]
fn the_shipped_schema_and_the_parser_agree() {
let schema: Value =
serde_json::from_str(include_str!("../../../schema/lanekeep.schema.json"))
.expect("the shipped schema is valid JSON");
let mut declared: Vec<&str> = schema["properties"]
.as_object()
.expect("the schema declares properties")
.keys()
.map(String::as_str)
.collect();
declared.sort_unstable();
assert_eq!(
declared,
[
"$schema",
"exclude",
"include",
"namespaces",
"rules",
"severity",
"suppressions",
"timeouts"
],
"the schema's fields changed; the parser below has to change with it"
);
let everything = r#"{
"$schema": "https://example.com/s.json",
"include": ["src/**"],
"exclude": ["**/x"],
"namespaces": ["acme"],
"severity": {"acme/a": "warn"},
"timeouts": {"rule": 100, "global": 5000},
"suppressions": {"requireExpiry": true, "maxExpiryDays": 30,
"forbidFileScope": true},
"rules": ["lanekeep/no-default-export"]
}"#;
compile("schema-agreement", everything)
.expect("the parser accepts every field the schema declares");
}
#[test]
fn json_is_recognized_by_extension() {
assert!(is_json(Path::new("lanekeep.json")));
assert!(is_json(Path::new("LANEKEEP.JSON")));
assert!(!is_json(Path::new("lanekeep.config.ts")));
}
}