use std::fmt::Write as _;
use std::path::Path;
use serde::Deserialize;
use serde_json::Value;
use crate::ConfigError;
pub(crate) fn is_json(path: &Path) -> bool {
path.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
}
#[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: Value,
#[serde(default)]
timeouts: Value,
#[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,
}
}
}
pub(crate) fn entry_source(config_path: &Path) -> Result<String, 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 imports = String::new();
let mut references = Vec::with_capacity(config.rules.len());
for (index, rule) in config.rules.iter().enumerate() {
let specifier = rule.specifier();
validate_specifier(specifier, &display)?;
let binding = format!("__lanekeepRule{index}");
let _ = writeln!(imports, "import {binding} from {};", js_string(specifier));
references.push(match rule {
JsonRule::Plain(_) => binding,
JsonRule::Configured { options, .. } => {
format!("{binding}({})", literal(options))
}
});
}
Ok(format!(
"{imports}globalThis.__lanekeepConfig = {{\n \
include: {},\n exclude: {},\n namespaces: {},\n \
severity: {},\n timeouts: {},\n rules: [{}],\n}};\n",
literal(&config.include),
literal(&config.exclude),
literal(&config.namespaces),
object_or_empty(&config.severity),
object_or_empty(&config.timeouts),
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(())
}
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 object_or_empty(value: &Value) -> String {
if value.is_null() {
"{}".to_owned()
} else {
literal(value)
}
}
fn js_string(value: &str) -> String {
format!("'{value}'")
}
#[cfg(test)]
mod tests {
use super::*;
fn compile(json: &str) -> Result<String, ConfigError> {
let dir = std::env::temp_dir().join(format!("lanekeep-json-{:x}", json.len()));
std::fs::create_dir_all(&dir).expect("creates dir");
let path = dir.join("lanekeep.json");
std::fs::write(&path, json).expect("writes");
entry_source(&path)
}
#[test]
fn a_bare_rule_is_imported_and_used_as_it_comes() {
let source = compile(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(
r#"{"rules": [{"rule": "lanekeep/no-restricted-imports",
"options": {"restrictions": [{"module": "stripe"}]}}]}"#,
)
.expect("compiles");
assert!(source.contains("__lanekeepRule0({\"restrictions\":[{\"module\":\"stripe\"}]})"));
}
#[test]
fn a_local_rule_keeps_its_relative_path() {
let source = compile(r#"{"rules": ["./lanekeep/rules/mine.ts"]}"#).expect("compiles");
assert!(source.contains("from './lanekeep/rules/mine.ts';"));
}
#[test]
fn globs_and_namespaces_survive() {
let source = compile(
r#"{"include": ["src/**/*.go"], "exclude": ["**/*_test.go"], "namespaces": ["acme"]}"#,
)
.expect("compiles");
assert!(source.contains(r#"include: ["src/**/*.go"]"#));
assert!(source.contains(r#"exclude: ["**/*_test.go"]"#));
assert!(source.contains(r#"namespaces: ["acme"]"#));
}
#[test]
fn an_absent_severity_map_becomes_an_empty_object() {
let source = compile(r#"{"rules": []}"#).expect("compiles");
assert!(source.contains("severity: {}"));
assert!(source.contains("timeouts: {}"));
}
#[test]
fn the_schema_key_is_accepted_and_ignored() {
compile(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(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 hostile in [
r#"{"rules": ["a'; globalThis.x = 1; import b from 'c"]}"#,
"{\"rules\": [\"a\\nimport b from 'c'\"]}",
] {
compile(hostile).expect_err("a specifier with a quote or newline is refused");
}
}
#[test]
fn an_empty_specifier_is_refused() {
compile(r#"{"rules": [""]}"#).expect_err("an empty specifier cannot import anything");
}
#[test]
fn malformed_json_is_reported_as_shape() {
let error = compile("{ 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",
"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},
"rules": ["lanekeep/no-default-export"]
}"#;
compile(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")));
}
}