use std::collections::{HashMap, HashSet};
use crate::diag::{Diagnostic, Repair, RepairKind, Severity};
use crate::ir::{Module, Node, NodeId, Value};
pub const RULE_UNUSED_LETS: &str = "unused-lets";
pub const RULE_DEPENDS_ON: &str = "depends-on";
pub const RULE_COMPLEXITY: &str = "complexity";
pub const RULE_HARDCODED_SECRETS: &str = "hardcoded-secrets";
pub const RULE_DEPRECATED: &str = "deprecated";
pub const ALL_RULES: &[&str] = &[
RULE_UNUSED_LETS,
RULE_DEPENDS_ON,
RULE_COMPLEXITY,
RULE_HARDCODED_SECRETS,
RULE_DEPRECATED,
];
const COMPLEXITY_LIMIT: u64 = 100;
const ENTROPY_BITS: f64 = 4.5;
const ENTROPY_MIN_LEN: usize = 20;
#[derive(Debug, Clone, Default)]
pub struct LintOptions {
pub disabled: HashSet<String>,
}
impl LintOptions {
pub fn with_disabled(names: &[String]) -> Result<Self, String> {
let mut disabled = HashSet::new();
for name in names {
if !ALL_RULES.contains(&name.as_str()) {
return Err(format!(
"unknown lint rule `{name}` (expected one of: {})",
ALL_RULES.join(", ")
));
}
disabled.insert(name.clone());
}
Ok(Self { disabled })
}
fn enabled(&self, rule: &str) -> bool {
!self.disabled.contains(rule)
}
}
pub fn lint(module: &Module) -> Vec<Diagnostic> {
lint_with(module, &LintOptions::default())
}
pub fn lint_with(module: &Module, options: &LintOptions) -> Vec<Diagnostic> {
let mut out = Vec::new();
if options.enabled(RULE_UNUSED_LETS) {
out.extend(unused_lets(module));
}
if options.enabled(RULE_DEPENDS_ON) {
out.extend(depends_on(module));
}
if options.enabled(RULE_COMPLEXITY) {
out.extend(complexity(module));
}
if options.enabled(RULE_HARDCODED_SECRETS) {
out.extend(hardcoded_secrets(module));
}
if options.enabled(RULE_DEPRECATED) {
out.extend(deprecated(module));
}
out
}
fn warning(
module: &Module,
code: &str,
message: String,
path: Option<String>,
node: Option<NodeId>,
) -> Diagnostic {
Diagnostic {
severity: Severity::Warning,
code: code.into(),
message,
path,
span: node.and_then(|id| module.node_spans.get(&id).cloned()),
repair: None,
confidence: 1.0,
file: node.and_then(|id| module.node_files.get(&id).cloned()),
}
}
fn ref_targets(module: &Module, id: NodeId) -> HashSet<NodeId> {
let mut targets = HashSet::new();
let mut stack = vec![id];
let mut seen = HashSet::new();
while let Some(nid) = stack.pop() {
if !seen.insert(nid) {
continue;
}
match module.get(nid) {
Some(Node::Ref(target)) => {
targets.insert(*target);
}
Some(Node::Table { fields }) => {
stack.extend(fields.values().copied());
}
Some(Node::Array { elements }) => {
stack.extend(elements.iter().copied());
}
Some(Node::Binary { lhs, rhs, .. }) => {
stack.push(*lhs);
stack.push(*rhs);
}
Some(Node::Unary { expr, .. }) => {
stack.push(*expr);
}
Some(Node::Interpolate { parts }) => {
for part in parts {
if let crate::ir::InterpPart::Expr(e) = part {
stack.push(*e);
}
}
}
Some(Node::Semantic { inner, .. }) => {
stack.push(*inner);
}
Some(Node::Env {
default: Some(def), ..
}) => {
stack.push(*def);
}
Some(Node::Secret { locator }) => {
stack.push(*locator);
}
_ => {}
}
}
targets
}
fn unused_lets(module: &Module) -> Vec<Diagnostic> {
let mut used = HashSet::new();
for node in module.arena.iter() {
if let Node::Ref(target) = node {
used.insert(*target);
}
}
let mut names: Vec<&String> = module.bindings.keys().collect();
names.sort();
let mut out = Vec::new();
for name in names {
let id = module.bindings[name];
if !used.contains(&id) {
out.push(warning(
module,
"L001",
format!("unused let binding '{name}'"),
Some(name.clone()),
Some(id),
));
}
}
out
}
fn depends_on(module: &Module) -> Vec<Diagnostic> {
let paths = module.value_paths();
let mut scopes: HashMap<NodeId, String> = HashMap::new();
if let Some(root) = module.root {
let mut stack = vec![(root, String::new())];
while let Some((id, path)) = stack.pop() {
if module.depends_on.contains_key(&id) {
scopes.insert(id, path.clone());
}
if let Some(Node::Table { fields }) = module.get(id) {
for (key, child) in fields {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
stack.push((*child, child_path));
}
}
}
}
let mut entries: Vec<(NodeId, String)> = scopes.into_iter().collect();
entries.sort_by(|a, b| a.1.cmp(&b.1));
let mut out = Vec::new();
for (table_id, scope_path) in entries {
let dep = &module.depends_on[&table_id];
let declared: HashSet<&str> = dep.paths.iter().map(String::as_str).collect();
let mut referenced: HashSet<String> = HashSet::new();
if let Some(Node::Table { fields }) = module.get(table_id) {
for child in fields.values() {
for target in ref_targets(module, *child) {
if let Some(name) = paths.get(&target) {
referenced.insert(name.clone());
}
}
}
}
let span_node = Some(table_id);
if dep.paths.is_empty() {
if !referenced.is_empty() {
let mut missing: Vec<&String> = referenced.iter().collect();
missing.sort();
for name in missing {
out.push(missing_entry(module, &scope_path, span_node, name));
}
}
continue;
}
for path in &dep.paths {
if path == &scope_path
|| (!scope_path.is_empty() && path.starts_with(&format!("{scope_path}.")))
{
out.push(warning(
module,
"L002",
format!("unnecessary depends_on entry '{path}' (own scope)"),
Some(scope_path.clone()),
span_node,
));
} else if module.resolve_value_path(path).is_none() {
out.push(warning(
module,
"L002",
format!("dangling depends_on path '{path}'"),
Some(scope_path.clone()),
span_node,
));
}
}
let mut missing: Vec<&String> = referenced
.iter()
.filter(|name| !declared.contains(name.as_str()))
.collect();
missing.sort();
for name in missing {
out.push(missing_entry(module, &scope_path, span_node, name));
}
}
out
}
fn missing_entry(
module: &Module,
scope_path: &str,
span_node: Option<NodeId>,
name: &str,
) -> Diagnostic {
let mut diag = warning(
module,
"L002",
format!("missing depends_on entry for '{name}'"),
Some(scope_path.to_string()),
span_node,
);
diag.repair = Some(Repair {
original: String::new(),
suggested: name.to_string(),
kind: RepairKind::Other("add_depends_on".to_string()),
safe: true,
});
diag
}
fn operator_count(module: &Module, id: NodeId) -> u64 {
let mut count = 0u64;
let mut stack = vec![id];
let mut seen = HashSet::new();
while let Some(nid) = stack.pop() {
if !seen.insert(nid) {
continue;
}
match module.get(nid) {
Some(Node::Binary { lhs, rhs, .. }) => {
count += 1;
stack.push(*lhs);
stack.push(*rhs);
}
Some(Node::Unary { expr, .. }) => {
count += 1;
stack.push(*expr);
}
Some(Node::Interpolate { parts }) => {
for part in parts {
if let crate::ir::InterpPart::Expr(e) = part {
count += 1;
stack.push(*e);
}
}
}
Some(Node::Ref(_)) | Some(Node::Ident(_)) => {
count += 1;
}
Some(Node::Env { default, .. }) => {
count += 1;
if let Some(def) = default {
stack.push(*def);
}
}
Some(Node::Secret { locator }) => {
count += 1;
stack.push(*locator);
}
Some(Node::Table { fields }) => {
stack.extend(fields.values().copied());
}
Some(Node::Array { elements }) => {
stack.extend(elements.iter().copied());
}
Some(Node::Semantic { inner, .. }) => {
stack.push(*inner);
}
_ => {}
}
}
count
}
fn complexity(module: &Module) -> Vec<Diagnostic> {
let mut out = Vec::new();
let check = |path: String, id: NodeId, out: &mut Vec<Diagnostic>| {
let count = operator_count(module, id);
if count > COMPLEXITY_LIMIT {
out.push(warning(
module,
"L003",
format!("expression complexity {count} exceeds limit {COMPLEXITY_LIMIT}"),
Some(path),
Some(id),
));
}
};
let mut names: Vec<&String> = module.bindings.keys().collect();
names.sort();
for name in names {
check(name.clone(), module.bindings[name], &mut out);
}
if let Some(root) = module.root {
if let Some(Node::Table { fields }) = module.get(root).cloned() {
for (key, id) in &fields {
check(key.clone(), *id, &mut out);
}
}
}
out
}
fn is_credential_name(key: &str) -> bool {
const SIGNALS: &[&str] = &[
"password",
"passwd",
"secret",
"token",
"apikey",
"privatekey",
"auth",
"credential",
"accesskey",
"secretkey",
];
let normalized: String = key
.to_lowercase()
.chars()
.filter(|c| *c != '_' && *c != '-')
.collect();
SIGNALS.iter().any(|signal| {
normalized == *signal || normalized.starts_with(signal) || normalized.ends_with(signal)
})
}
fn is_placeholder(value: &str) -> bool {
if has_url_scheme(value) {
return true;
}
matches!(
value.to_lowercase().as_str(),
"changeme"
| "password"
| "xxx"
| "test"
| "example"
| "placeholder"
| "redacted"
| "***"
| "***secret***"
| ""
)
}
fn has_url_scheme(value: &str) -> bool {
let bytes = value.as_bytes();
let mut i = 0;
if bytes.first().is_some_and(|c| c.is_ascii_alphabetic()) {
i += 1;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'+' | b'-' | b'.'))
{
i += 1;
}
}
value.len() > i + 2 && &value[i..i + 3] == "://"
}
fn shannon_entropy(value: &str) -> f64 {
use std::collections::BTreeMap;
let mut counts = BTreeMap::new();
let mut total = 0usize;
for c in value.chars() {
*counts.entry(c).or_insert(0usize) += 1;
total += 1;
}
if total == 0 {
return 0.0;
}
counts
.values()
.map(|count| {
let p = *count as f64 / total as f64;
-p * p.log2()
})
.sum()
}
fn hardcoded_secrets(module: &Module) -> Vec<Diagnostic> {
let mut out = Vec::new();
let mut literals: Vec<(String, NodeId, String)> = Vec::new();
if let Some(root) = module.root {
let mut stack = vec![(root, String::new())];
while let Some((id, path)) = stack.pop() {
match module.get(id) {
Some(Node::Table { fields }) => {
let mut entries: Vec<_> = fields.iter().collect();
entries.reverse();
for (key, child) in entries {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
match module.get(*child) {
Some(Node::Literal(Value::String(text))) => {
literals.push((child_path, *child, text.clone()));
}
_ => stack.push((*child, child_path)),
}
}
}
Some(Node::Array { elements }) => {
let mut indexed: Vec<_> = elements.iter().enumerate().collect();
indexed.reverse();
for (i, child) in indexed {
let child_path = format!("{path}[{i}]");
match module.get(*child) {
Some(Node::Literal(Value::String(text))) => {
literals.push((child_path, *child, text.clone()));
}
_ => stack.push((*child, child_path)),
}
}
}
_ => {}
}
}
}
let mut names: Vec<&String> = module.bindings.keys().collect();
names.sort();
for name in names {
if let Some(Node::Literal(Value::String(text))) = module.get(module.bindings[name]) {
literals.push((name.clone(), module.bindings[name], text.clone()));
}
}
for (path, id, text) in literals {
let key = path.rsplit('.').next().unwrap_or(&path);
let key = key.split('[').next().unwrap_or(key);
let named = is_credential_name(key) && !is_placeholder(&text);
let entropic =
text.chars().count() >= ENTROPY_MIN_LEN && shannon_entropy(&text) >= ENTROPY_BITS;
if named || entropic {
out.push(warning(
module,
"L004",
format!(
"possible hard-coded secret in key '{path}' (use secret() or secret.env())"
),
Some(path),
Some(id),
));
}
}
out
}
fn deprecated(module: &Module) -> Vec<Diagnostic> {
let Some(schema_id) = module.schema else {
return Vec::new();
};
let Some(root) = module.root else {
return Vec::new();
};
let mut out = Vec::new();
let mut stack = vec![(schema_id, root, String::new())];
while let Some((schema, value, path)) = stack.pop() {
let (
Some(Node::SchemaTable {
fields: schema_fields,
..
}),
Some(Node::Table {
fields: value_fields,
}),
) = (module.get(schema), module.get(value))
else {
continue;
};
let mut entries: Vec<_> = schema_fields.iter().collect();
entries.reverse();
for (name, field_id) in entries {
let child_path = if path.is_empty() {
name.clone()
} else {
format!("{path}.{name}")
};
let Some(value_id) = value_fields.get(name).copied() else {
continue;
};
match module.get(*field_id) {
Some(Node::SchemaField { field }) => {
if let Some(reason) = &field.deprecated {
let message = if reason.is_empty() {
format!("deprecated field '{child_path}' is still used")
} else {
format!("deprecated field '{child_path}' is still used: {reason}")
};
out.push(warning(
module,
"L005",
message,
Some(child_path),
Some(value_id),
));
}
}
Some(Node::SchemaTable { .. }) => {
stack.push((*field_id, value_id, child_path));
}
Some(Node::SchemaArray { element }) => {
if let Some(Node::Array { elements }) = module.get(value_id) {
for (i, element_id) in elements.iter().enumerate() {
stack.push((*element, *element_id, format!("{child_path}[{i}]")));
}
}
}
_ => {}
}
}
}
out.reverse();
out
}