#[cfg(feature = "hostlib")]
mod imp {
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use harn_lexer::Span;
use harn_lint::{LintDiagnostic, LintSeverity};
use harn_parser::DiagnosticCode;
use harn_vm::{VmClosure, VmValue};
const RULE_SUFFIX: &str = ".lint.harn";
enum LoadedRule {
Ok { id: String, lint: Arc<VmClosure> },
Failed { id: String, error: String },
}
fn discover_rule_paths(file: &Path) -> Vec<PathBuf> {
let Some((manifest, dir)) = crate::package::find_nearest_manifest(file) else {
return Vec::new();
};
let mut paths = Vec::new();
for rel in &manifest.rules.rule_dirs {
let Ok(entries) = std::fs::read_dir(dir.join(rel)) else {
continue;
};
let mut files: Vec<_> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(RULE_SUFFIX))
})
.collect();
files.sort();
paths.extend(files);
}
paths
}
fn rule_id_for(path: &Path) -> String {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.trim_end_matches(RULE_SUFFIX).to_string())
.filter(|stem| !stem.is_empty())
.unwrap_or_else(|| "harn-script-rule".to_string())
}
fn sandbox_vm() -> harn_vm::Vm {
let mut vm = harn_vm::Vm::new();
harn_vm::register_vm_stdlib(&mut vm);
harn_rules_hostlib::install(&mut vm);
vm
}
fn rule_error_diagnostic(id: &str, error: &str) -> LintDiagnostic {
LintDiagnostic {
code: DiagnosticCode::LintRuleEngine,
rule: std::borrow::Cow::Owned(id.to_string()),
message: format!("lint rule '{id}' failed: {error}"),
span: Span {
start: 0,
end: 0,
line: 1,
column: 1,
end_line: 1,
},
severity: LintSeverity::Error,
suggestion: None,
fix: None,
}
}
fn severity_from(value: Option<&VmValue>) -> LintSeverity {
match value.map(VmValue::as_str_cow).as_deref() {
Some("error") => LintSeverity::Error,
Some("info") => LintSeverity::Info,
_ => LintSeverity::Warning,
}
}
fn finding_to_diagnostic(id: &str, finding: &VmValue) -> Option<LintDiagnostic> {
let dict = finding.as_dict()?;
let message = dict.get("message")?.as_str_cow().into_owned();
if message.is_empty() {
return None;
}
let optional_int = |key: &str| {
dict.get(key)
.and_then(VmValue::as_int)
.filter(|n| *n >= 0)
.map(|n| n as usize)
};
let line = optional_int("line")
.or_else(|| optional_int("start_row").map(|row| row + 1))
.unwrap_or(1)
.max(1);
let column = optional_int("column")
.or_else(|| optional_int("start_col").map(|col| col + 1))
.unwrap_or(1)
.max(1);
let end_line = optional_int("end_line")
.or_else(|| optional_int("end_row").map(|row| row + 1))
.unwrap_or(line)
.max(line);
let start = optional_int("start_byte").unwrap_or(0);
let end = optional_int("end_byte").unwrap_or(start).max(start);
let rule_id = dict
.get("rule")
.or_else(|| dict.get("rule_id"))
.map(VmValue::as_str_cow)
.filter(|rule| !rule.is_empty())
.map(|rule| rule.into_owned())
.unwrap_or_else(|| id.to_string());
Some(LintDiagnostic {
code: DiagnosticCode::LintRuleEngine,
rule: std::borrow::Cow::Owned(rule_id),
message,
span: Span {
start,
end,
line,
column,
end_line,
},
severity: severity_from(dict.get("severity")),
suggestion: None,
fix: None,
})
}
fn map_findings(id: &str, items: &[VmValue], out: &mut Vec<LintDiagnostic>) {
for item in items {
if let Some(diag) = finding_to_diagnostic(id, item) {
out.push(diag);
}
}
}
fn map_return(id: &str, value: &VmValue, out: &mut Vec<LintDiagnostic>) {
match value {
VmValue::List(items) => map_findings(id, items, out),
VmValue::Dict(dict) => {
if dict.contains_key("message") {
if let Some(diag) = finding_to_diagnostic(id, value) {
out.push(diag);
}
} else if let Some(VmValue::List(items)) = dict.get("diagnostics") {
map_findings(id, items, out);
} else {
out.push(rule_error_diagnostic(
id,
"lint(source) returned a dict without `message` or a `diagnostics` list",
));
}
}
_ => out.push(rule_error_diagnostic(
id,
"lint(source) must return a finding, a list of findings, or a rules_diagnostics result",
)),
}
}
pub(crate) async fn run(files: &[PathBuf]) -> HashMap<PathBuf, Vec<LintDiagnostic>> {
let mut out: HashMap<PathBuf, Vec<LintDiagnostic>> = HashMap::new();
let mut file_rule_paths: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
let mut unique_rule_paths: Vec<PathBuf> = Vec::new();
let mut seen = HashSet::new();
for file in files {
let paths = discover_rule_paths(file);
if paths.is_empty() {
continue;
}
for path in &paths {
if seen.insert(path.clone()) {
unique_rule_paths.push(path.clone());
}
}
file_rule_paths.insert(file.clone(), paths);
}
if unique_rule_paths.is_empty() {
return out; }
let mut vm = sandbox_vm();
let mut loaded_rules: HashMap<PathBuf, LoadedRule> = HashMap::new();
for path in &unique_rule_paths {
let id = rule_id_for(path);
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
loaded_rules.insert(
path.clone(),
LoadedRule::Failed {
id,
error: error.to_string(),
},
);
continue;
}
};
match vm
.load_module_exports_from_source(path.clone(), &source)
.await
{
Ok(exports) => {
if let Some(lint) = exports.get("lint") {
loaded_rules.insert(
path.clone(),
LoadedRule::Ok {
id,
lint: lint.clone(),
},
);
}
}
Err(error) => {
loaded_rules.insert(
path.clone(),
LoadedRule::Failed {
id,
error: error.to_string(),
},
);
}
}
}
if loaded_rules.is_empty() {
return out;
}
for file in files {
let Some(rule_paths) = file_rule_paths.get(file) else {
continue;
};
let Ok(source) = std::fs::read_to_string(file) else {
continue;
};
let mut diagnostics = Vec::new();
for path in rule_paths {
let Some(rule) = loaded_rules.get(path) else {
continue;
};
match rule {
LoadedRule::Failed { id, error } => {
diagnostics.push(rule_error_diagnostic(id, error));
}
LoadedRule::Ok { id, lint } => {
let arg = VmValue::String(Arc::from(source.as_str()));
match vm.call_closure_pub(lint, &[arg]).await {
Ok(value) => map_return(id, &value, &mut diagnostics),
Err(error) => {
diagnostics.push(rule_error_diagnostic(id, &error.to_string()));
}
}
}
}
}
if !diagnostics.is_empty() {
out.insert(file.clone(), diagnostics);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn finding(pairs: &[(&str, VmValue)]) -> VmValue {
let mut map = BTreeMap::new();
for (k, v) in pairs {
map.insert((*k).to_string(), v.clone());
}
VmValue::Dict(Arc::new(map))
}
fn s(text: &str) -> VmValue {
VmValue::String(Arc::from(text))
}
#[test]
fn maps_a_full_finding() {
let d = finding_to_diagnostic(
"no-todo",
&finding(&[
("message", s("nope")),
("rule_id", s("delegated-rule")),
("severity", s("error")),
("line", VmValue::Int(7)),
("column", VmValue::Int(3)),
("start_byte", VmValue::Int(10)),
("end_byte", VmValue::Int(14)),
]),
)
.expect("maps");
assert_eq!(d.message, "nope");
assert_eq!(d.severity, LintSeverity::Error);
assert_eq!(d.rule.as_ref(), "delegated-rule");
assert_eq!((d.span.line, d.span.column), (7, 3));
assert_eq!((d.span.start, d.span.end), (10, 14));
}
#[test]
fn maps_a_rules_diagnostics_envelope() {
let diagnostics = VmValue::List(Arc::new(vec![finding(&[
("message", s("delegated")),
("rule_id", s("structural-rule")),
("severity", s("info")),
("start_row", VmValue::Int(4)),
("start_col", VmValue::Int(2)),
("end_row", VmValue::Int(5)),
])]));
let mut out = Vec::new();
map_return(
"script-rule",
&finding(&[("diagnostics", diagnostics)]),
&mut out,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule.as_ref(), "structural-rule");
assert_eq!(out[0].severity, LintSeverity::Info);
assert_eq!(
(out[0].span.line, out[0].span.column, out[0].span.end_line),
(5, 3, 6)
);
}
#[test]
fn maps_a_single_finding_dict() {
let mut out = Vec::new();
map_return(
"script-rule",
&finding(&[("message", s("single"))]),
&mut out,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].message, "single");
assert_eq!(out[0].rule.as_ref(), "script-rule");
}
#[test]
fn defaults_location_and_severity() {
let d = finding_to_diagnostic("r", &finding(&[("message", s("m"))])).expect("maps");
assert_eq!(d.severity, LintSeverity::Warning);
assert_eq!((d.span.line, d.span.column), (1, 1));
}
#[test]
fn a_finding_without_a_message_is_skipped() {
assert!(finding_to_diagnostic("r", &finding(&[("severity", s("error"))])).is_none());
assert!(finding_to_diagnostic("r", &finding(&[("message", s(""))])).is_none());
}
#[test]
fn non_list_return_is_rule_error() {
let mut out = Vec::new();
map_return("r", &VmValue::Nil, &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].severity, LintSeverity::Error);
assert!(out[0].message.contains("must return a finding"));
}
#[test]
fn rule_error_is_an_error_diagnostic() {
let d = rule_error_diagnostic("boom", "kaboom");
assert_eq!(d.severity, LintSeverity::Error);
assert!(d.message.contains("boom") && d.message.contains("kaboom"));
}
}
}
#[cfg(feature = "hostlib")]
pub(crate) use imp::run as run_project_script_rules;
#[cfg(not(feature = "hostlib"))]
pub(crate) async fn run_project_script_rules(
_files: &[std::path::PathBuf],
) -> std::collections::HashMap<std::path::PathBuf, Vec<harn_lint::LintDiagnostic>> {
std::collections::HashMap::new()
}