use normalize_facts_rules_interpret as interpret;
pub use normalize_rules_config::{RuleOverride, RulesConfig, SarifTool};
use normalize_syntax_rules::{self, DebugFlags};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
struct SendableEngine(interpret::CachedRuleEngine);
unsafe impl Send for SendableEngine {}
static ENGINE_CACHE: OnceLock<Mutex<HashMap<String, SendableEngine>>> = OnceLock::new();
fn engine_cache() -> &'static Mutex<HashMap<String, SendableEngine>> {
ENGINE_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn engine_cache_key(root: &Path, rule_id: &str) -> String {
format!("{}::{}", root.to_string_lossy(), rule_id)
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum RuleKind {
#[default]
All,
Syntax,
Fact,
Native,
Sarif,
}
impl std::fmt::Display for RuleKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::All => f.write_str("all"),
Self::Syntax => f.write_str("syntax"),
Self::Fact => f.write_str("fact"),
Self::Native => f.write_str("native"),
Self::Sarif => f.write_str("sarif"),
}
}
}
impl std::str::FromStr for RuleKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"all" => Ok(Self::All),
"syntax" => Ok(Self::Syntax),
"fact" => Ok(Self::Fact),
"native" => Ok(Self::Native),
"sarif" => Ok(Self::Sarif),
_ => Err(format!(
"unknown rule type: {s}; valid: all, syntax, fact, native, sarif"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RuleLockEntry {
source: String,
content_hash: String,
added: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct RulesLock {
rules: HashMap<String, RuleLockEntry>,
}
impl RulesLock {
fn load(path: &Path) -> Self {
if !path.exists() {
return Self::default();
}
std::fs::read_to_string(path)
.ok()
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or_default()
}
fn save(&self, path: &Path) -> std::io::Result<()> {
let content = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
std::fs::write(path, content)
}
}
#[derive(Clone, Debug)]
pub struct RulesRunConfig {
pub rule_tags: HashMap<String, Vec<String>>,
pub rules: RulesConfig,
pub walk: normalize_rules_config::WalkConfig,
}
fn tag_color(tag: &str) -> nu_ansi_term::Color {
use nu_ansi_term::Color;
const PALETTE: &[Color] = &[
Color::Cyan, Color::Green, Color::Magenta, Color::Fixed(80), Color::Fixed(111), Color::Fixed(141), Color::Fixed(78), Color::Fixed(117), Color::Fixed(183), Color::Fixed(159), ];
let mut hash = 0xcbf29ce484222325u64;
for byte in tag.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
PALETTE[(hash as usize) % PALETTE.len()]
}
fn paint_tag(tag: &str, use_colors: bool) -> String {
if use_colors {
tag_color(tag).paint(tag).to_string()
} else {
format!("[{}]", tag)
}
}
fn paint_severity(severity: &str, use_colors: bool) -> String {
if !use_colors {
return severity.to_string();
}
match severity.trim() {
"error" => nu_ansi_term::Color::Red.paint(severity).to_string(),
"warning" => nu_ansi_term::Color::Yellow.paint(severity).to_string(),
"info" => nu_ansi_term::Color::Blue.paint(severity).to_string(),
_ => nu_ansi_term::Color::DarkGray.paint(severity).to_string(),
}
}
fn paint_tags(tags: &[String], use_colors: bool) -> String {
tags.iter()
.map(|t| paint_tag(t, use_colors))
.collect::<Vec<_>>()
.join(" ")
}
fn expand_tag<'a>(
tag: &str,
rule_tags: &'a HashMap<String, Vec<String>>,
all_rules: &'a [UnifiedRule],
visited: &mut HashSet<String>,
) -> HashSet<&'a str> {
if !visited.insert(tag.to_string()) {
return HashSet::new();
}
let mut ids: HashSet<&'a str> = HashSet::new();
for r in all_rules {
if r.tags.iter().any(|t| t == tag) {
ids.insert(r.id.as_str());
}
}
if let Some(members) = rule_tags.get(tag) {
for member in members {
if all_rules.iter().any(|r| r.id == *member) {
ids.insert(member.as_str());
} else {
ids.extend(expand_tag(member, rule_tags, all_rules, visited));
}
}
}
ids
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RuleEntry {
pub id: String,
pub rule_type: String,
pub severity: String,
pub source: String,
pub message: String,
pub enabled: bool,
pub tags: Vec<String>,
pub recommended: bool,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RulesListReport {
pub rules: Vec<RuleEntry>,
pub total: usize,
pub syntax_count: usize,
pub fact_count: usize,
pub native_count: usize,
pub disabled_count: usize,
}
impl normalize_output::OutputFormatter for RulesListReport {
fn format_text(&self) -> String {
let mut out = String::new();
if self.rules.is_empty() {
return "No rules found.\n".to_string();
}
let breakdown = {
let mut parts = Vec::new();
if self.syntax_count > 0 {
parts.push(format!("{} syntax", self.syntax_count));
}
if self.fact_count > 0 {
parts.push(format!("{} fact", self.fact_count));
}
if self.native_count > 0 {
parts.push(format!("{} native", self.native_count));
}
parts.join(", ")
};
if self.disabled_count > 0 {
out.push_str(&format!(
"{} rules ({}) — {} disabled\n\n",
self.total, breakdown, self.disabled_count
));
} else {
out.push_str(&format!("{} rules ({})\n\n", self.total, breakdown));
}
for r in &self.rules {
let type_col = format!("{:<8}", format!("[{}]", r.rule_type));
let sev_col = format!("{:<8}", r.severity);
let state_col = if r.enabled { " " } else { "off" };
let tags_str = if r.tags.is_empty() {
String::new()
} else {
format!(
" {}",
r.tags
.iter()
.map(|t| format!("[{t}]"))
.collect::<Vec<_>>()
.join(" ")
)
};
out.push_str(&format!(
" {} {:<30} {} {} {:<7}{}\n",
type_col, r.id, sev_col, state_col, r.source, tags_str
));
out.push_str(&format!(" {}\n", r.message));
}
out.push_str("\nConfigure: [rules.\"<id>\"] in .normalize/config.toml\n");
out.push_str(" severity, enabled, allow — or: normalize rules enable/disable <id>\n");
out.push_str(" Global patterns: [rules] global-allow = [\"**/fixtures/**\"]\n");
out.push_str(" Custom tag groups: [rule-tags] my-group = [\"tag1\", \"tag2\"]\n");
out
}
fn format_pretty(&self) -> String {
use nu_ansi_term::{Color, Style};
let mut out = String::new();
if self.rules.is_empty() {
return "No rules found.\n".to_string();
}
let breakdown = {
let mut parts = Vec::new();
if self.syntax_count > 0 {
parts.push(format!("{} syntax", self.syntax_count));
}
if self.fact_count > 0 {
parts.push(format!("{} fact", self.fact_count));
}
if self.native_count > 0 {
parts.push(format!("{} native", self.native_count));
}
parts.join(", ")
};
let header = if self.disabled_count > 0 {
format!(
"{} rules ({}) — {} disabled",
Color::White.bold().paint(self.total.to_string()),
breakdown,
Color::DarkGray.paint(self.disabled_count.to_string())
)
} else {
format!(
"{} rules ({})",
Color::White.bold().paint(self.total.to_string()),
breakdown
)
};
out.push_str(&format!("{header}\n\n"));
let gray = Color::DarkGray;
out.push_str(&format!(
"{}\n",
gray.paint(format!(
" {:<6} {:<30} {:<8} ST {:<7} TAGS",
"TYPE", "ID", "SEVERITY", "SOURCE"
))
));
for r in &self.rules {
let type_col = paint_rule_type(&r.rule_type);
let sev_col = paint_severity(&format!("{:<8}", r.severity), true);
let state_col = if r.enabled {
Style::new().paint(" ● ").to_string()
} else {
Color::DarkGray.paint(" ○ ").to_string()
};
let tags_str = if r.tags.is_empty() {
String::new()
} else {
format!(" {}", paint_tags(&r.tags, true))
};
let id_padded = format!("{:<30}", r.id);
let id_col = if r.enabled {
id_padded
} else {
Color::DarkGray.paint(id_padded).to_string()
};
out.push_str(&format!(
" {type_col} {id_col} {sev_col} {state_col} {:<7}{tags_str}\n",
r.source
));
let desc = if r.enabled {
Color::DarkGray.paint(&r.message).to_string()
} else {
Color::DarkGray.dimmed().paint(&r.message).to_string()
};
out.push_str(&format!(" {desc}\n"));
}
let dim = Color::DarkGray;
out.push('\n');
out.push_str(
&dim.paint("Configure: [rules.\"<id>\"] in .normalize/config.toml\n")
.to_string(),
);
out.push_str(
&dim.paint(" severity, enabled, allow — or: normalize rules enable/disable <id>\n")
.to_string(),
);
out.push_str(
&dim.paint(" Global patterns: [rules] global-allow = [\"**/fixtures/**\"]\n")
.to_string(),
);
out.push_str(
&dim.paint(" Custom tag groups: [rule-tags] my-group = [\"tag1\", \"tag2\"]\n")
.to_string(),
);
out
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RuleInfoReport {
pub id: String,
pub rule_type: String,
pub severity: String,
pub enabled: bool,
pub builtin: bool,
pub tags: Vec<String>,
pub languages: Vec<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub allow: Vec<String>,
}
impl normalize_output::OutputFormatter for RuleInfoReport {
fn format_text(&self) -> String {
let mut out = String::new();
out.push_str(&format!("{} [{}]\n", self.id, self.rule_type));
out.push_str(&format!(" severity: {}\n", self.severity));
out.push_str(&format!(" enabled: {}\n", self.enabled));
if !self.tags.is_empty() {
out.push_str(&format!(" tags: {}\n", self.tags.join(", ")));
}
if !self.languages.is_empty() {
out.push_str(&format!(" langs: {}\n", self.languages.join(", ")));
}
if !self.allow.is_empty() {
out.push_str(&format!(" allow: {}\n", self.allow.join(" ")));
}
if let Some(ref fix) = self.fix {
if fix.is_empty() {
out.push_str(" fix: (delete match)\n");
} else {
out.push_str(&format!(" fix: {}\n", fix));
}
}
out.push_str(&format!(" message: {}\n", self.message));
if let Some(ref doc) = self.description {
out.push('\n');
out.push_str(doc);
out.push('\n');
} else {
out.push('\n');
out.push_str(
"(no documentation — add a markdown comment block after the frontmatter)\n",
);
}
out
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct TagEntry {
pub tag: String,
pub source: String,
pub count: usize,
pub rules: Vec<String>,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RulesTagsReport {
pub tags: Vec<TagEntry>,
}
impl normalize_output::OutputFormatter for RulesTagsReport {
fn format_text(&self) -> String {
if self.tags.is_empty() {
return "No tags found.\n".to_string();
}
let mut out = String::new();
for entry in &self.tags {
if entry.rules.is_empty() || entry.count == entry.rules.len() {
out.push_str(&format!(
"{:20} [{}] {} rule{}\n",
entry.tag,
entry.source,
entry.count,
if entry.count == 1 { "" } else { "s" }
));
} else {
out.push_str(&format!(
"{:20} [{}] {}\n",
entry.tag,
entry.source,
entry.rules.join(" ")
));
}
}
out
}
}
fn paint_rule_type(rule_type: &str) -> String {
use nu_ansi_term::Color;
let col = match rule_type {
"syntax" => Color::Cyan,
"fact" => Color::Blue,
"native" => Color::Green,
_ => Color::DarkGray,
};
col.paint(format!("{:<6}", rule_type)).to_string()
}
struct UnifiedRule {
id: String,
rule_type: &'static str,
severity: String,
source: &'static str,
message: String,
enabled: bool,
tags: Vec<String>,
recommended: bool,
}
pub struct ListFilters<'a> {
pub type_filter: &'a RuleKind,
pub tag: Option<&'a str>,
pub enabled: bool,
pub disabled: bool,
}
pub fn build_list_report(
root: &Path,
filters: &ListFilters<'_>,
config: &RulesRunConfig,
) -> RulesListReport {
let mut all_rules: Vec<UnifiedRule> = Vec::new();
if matches!(filters.type_filter, RuleKind::All | RuleKind::Syntax) {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
for r in &syntax_rules {
let source = if r.builtin { "builtin" } else { "project" };
all_rules.push(UnifiedRule {
id: r.id.clone(),
rule_type: "syntax",
severity: r.severity.to_string(),
source,
message: r.message.clone(),
enabled: r.enabled,
tags: r.tags.clone(),
recommended: r.recommended,
});
}
}
if matches!(filters.type_filter, RuleKind::All | RuleKind::Fact) {
let fact_rules = interpret::load_all_rules(root, &config.rules);
for r in &fact_rules {
let source = if r.builtin { "builtin" } else { "project" };
all_rules.push(UnifiedRule {
id: r.id.clone(),
rule_type: "fact",
severity: r.severity.to_string(),
source,
message: r.message.clone(),
enabled: r.enabled,
tags: r.tags.clone(),
recommended: r.recommended,
});
}
}
if matches!(filters.type_filter, RuleKind::All | RuleKind::Native) {
for desc in normalize_native_rules::NATIVE_RULES {
let override_ = config.rules.rules.get(desc.id);
let severity = override_
.and_then(|o| o.severity.as_deref())
.unwrap_or(desc.default_severity)
.to_string();
let enabled = override_
.and_then(|o| o.enabled)
.unwrap_or(desc.default_enabled);
let mut tags: Vec<String> = desc.tags.iter().map(|t| t.to_string()).collect();
if let Some(o) = override_ {
tags.extend(o.tags.iter().cloned());
}
all_rules.push(UnifiedRule {
id: desc.id.to_string(),
rule_type: "native",
severity,
source: "builtin",
message: desc.message.to_string(),
enabled,
tags,
recommended: false,
});
}
}
if let Some(tag) = filters.tag {
let rule_tags = &config.rule_tags;
let mut visited = HashSet::new();
let matching_ids: HashSet<String> = expand_tag(tag, rule_tags, &all_rules, &mut visited)
.into_iter()
.map(|s| s.to_string())
.collect();
all_rules.retain(|r| matching_ids.contains(&r.id));
}
if filters.enabled {
all_rules.retain(|r| r.enabled);
}
if filters.disabled {
all_rules.retain(|r| !r.enabled);
}
all_rules.sort_by(|a, b| a.rule_type.cmp(b.rule_type).then(a.id.cmp(&b.id)));
let syntax_count = all_rules.iter().filter(|r| r.rule_type == "syntax").count();
let fact_count = all_rules.iter().filter(|r| r.rule_type == "fact").count();
let native_count = all_rules.iter().filter(|r| r.rule_type == "native").count();
let disabled_count = all_rules.iter().filter(|r| !r.enabled).count();
let total = all_rules.len();
let rules = all_rules
.into_iter()
.map(|r| RuleEntry {
id: r.id,
rule_type: r.rule_type.to_string(),
severity: r.severity,
source: r.source.to_string(),
message: r.message,
enabled: r.enabled,
tags: r.tags,
recommended: r.recommended,
})
.collect();
RulesListReport {
rules,
total,
syntax_count,
fact_count,
native_count,
disabled_count,
}
}
fn build_unified_rules(
syntax_rules: &[normalize_syntax_rules::Rule],
fact_rules: &[interpret::FactsRule],
) -> Vec<UnifiedRule> {
syntax_rules
.iter()
.map(|r| UnifiedRule {
id: r.id.clone(),
rule_type: "syntax",
severity: r.severity.to_string(),
source: if r.builtin { "builtin" } else { "project" },
message: r.message.clone(),
enabled: r.enabled,
tags: r.tags.clone(),
recommended: r.recommended,
})
.chain(fact_rules.iter().map(|r| UnifiedRule {
id: r.id.clone(),
rule_type: "fact",
severity: r.severity.to_string(),
source: if r.builtin { "builtin" } else { "project" },
message: r.message.clone(),
enabled: r.enabled,
tags: r.tags.clone(),
recommended: r.recommended,
}))
.collect()
}
pub fn enable_disable(
root: &Path,
id_or_tag: &str,
enable: bool,
dry_run: bool,
config: &RulesRunConfig,
) -> Result<String, String> {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
let fact_rules = interpret::load_all_rules(root, &config.rules);
let all_unified = build_unified_rules(&syntax_rules, &fact_rules);
let rule_tags = &config.rule_tags;
let matched_ids: HashSet<&str> = {
if all_unified.iter().any(|r| r.id == id_or_tag) {
std::iter::once(id_or_tag).collect()
} else {
let mut visited = HashSet::new();
expand_tag(id_or_tag, rule_tags, &all_unified, &mut visited)
}
};
let matched_syntax: Vec<&normalize_syntax_rules::Rule> = syntax_rules
.iter()
.filter(|r| matched_ids.contains(r.id.as_str()))
.collect();
let matched_fact: Vec<&interpret::FactsRule> = fact_rules
.iter()
.filter(|r| matched_ids.contains(r.id.as_str()))
.collect();
if matched_syntax.is_empty() && matched_fact.is_empty() {
return Err(format!(
"No rules found matching '{}' (not a rule ID or tag)",
id_or_tag
));
}
let verb = if enable { "enable" } else { "disable" };
let config_path = root.join(".normalize").join("config.toml");
let changes_syntax: Vec<&str> = matched_syntax
.iter()
.filter(|r| r.enabled != enable)
.map(|r| r.id.as_str())
.collect();
let changes_fact: Vec<&str> = matched_fact
.iter()
.filter(|r| r.enabled != enable)
.map(|r| r.id.as_str())
.collect();
let already_syntax: Vec<&str> = matched_syntax
.iter()
.filter(|r| r.enabled == enable)
.map(|r| r.id.as_str())
.collect();
let already_fact: Vec<&str> = matched_fact
.iter()
.filter(|r| r.enabled == enable)
.map(|r| r.id.as_str())
.collect();
let mut out = String::new();
for id in &already_syntax {
out.push_str(&format!("{}: already {}d (no change)\n", id, verb));
}
for id in &already_fact {
out.push_str(&format!("{}: already {}d (no change)\n", id, verb));
}
if changes_syntax.is_empty() && changes_fact.is_empty() {
return Ok(out);
}
for id in &changes_syntax {
if dry_run {
out.push_str(&format!("[dry-run] would {} {}\n", verb, id));
} else {
out.push_str(&format!("{}d {}\n", verb, id));
}
}
for id in &changes_fact {
if dry_run {
out.push_str(&format!("[dry-run] would {} {}\n", verb, id));
} else {
out.push_str(&format!("{}d {}\n", verb, id));
}
}
if dry_run {
return Ok(out);
}
let content = std::fs::read_to_string(&config_path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_else(|e| {
tracing::warn!("failed to parse existing config, using defaults: {}", e);
toml_edit::DocumentMut::default()
});
if !doc.contains_key("rules") {
let mut t = toml_edit::Table::new();
t.set_implicit(true);
doc["rules"] = toml_edit::Item::Table(t);
} else if doc["rules"].is_inline_table() {
return Err("Cannot update rules config: the existing 'rules' entry in \
.normalize/config.toml is an inline table (e.g. `rules = {...}`). \
Convert it to a [rules] section first."
.to_string());
}
{
let rules_table = doc["rules"]
.as_table_mut()
.ok_or_else(|| "'rules' is not a TOML table".to_string())?;
if !rules_table.contains_key("rule") {
let mut t = toml_edit::Table::new();
t.set_implicit(true);
rules_table.insert("rule", toml_edit::Item::Table(t));
}
}
if !changes_syntax.is_empty() {
let rule_table = doc["rules"]["rule"]
.as_table_mut()
.ok_or_else(|| "'rules.rule' is not a TOML table".to_string())?;
for id in &changes_syntax {
if !rule_table.contains_key(id) {
rule_table[id] = toml_edit::Item::Table(toml_edit::Table::new());
}
rule_table[id]["enabled"] = toml_edit::value(enable);
}
}
if !changes_fact.is_empty() {
let rule_table = doc["rules"]["rule"]
.as_table_mut()
.ok_or_else(|| "'rules.rule' is not a TOML table".to_string())?;
for id in &changes_fact {
if !rule_table.contains_key(id) {
rule_table[id] = toml_edit::Item::Table(toml_edit::Table::new());
}
rule_table[id]["enabled"] = toml_edit::value(enable);
}
}
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {e}"))?;
}
std::fs::write(&config_path, doc.to_string())
.map_err(|e| format!("Failed to write config: {e}"))?;
Ok(out)
}
pub fn show_rule(
root: &Path,
id: &str,
use_colors: bool,
config: &RulesRunConfig,
) -> Result<String, String> {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
let fact_rules = interpret::load_all_rules(root, &config.rules);
let found_syntax = syntax_rules.iter().find(|r| r.id == id);
let found_fact = fact_rules.iter().find(|r| r.id == id);
let mut out = String::new();
match (found_syntax, found_fact) {
(Some(r), _) => {
out.push_str(&format!("{} [syntax]\n", r.id));
out.push_str(&format!(
" severity: {}\n",
paint_severity(&r.severity.to_string(), use_colors)
));
out.push_str(&format!(" enabled: {}\n", r.enabled));
if !r.tags.is_empty() {
out.push_str(&format!(
" tags: {}\n",
paint_tags(&r.tags, use_colors)
));
}
if !r.languages.is_empty() {
out.push_str(&format!(" langs: {}\n", r.languages.join(", ")));
}
if !r.allow.is_empty() {
out.push_str(&format!(
" allow: {}\n",
r.allow
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(" ")
));
}
if let Some(ref fix) = r.fix {
if fix.is_empty() {
out.push_str(" fix: (delete match)\n");
} else {
out.push_str(&format!(" fix: {}\n", fix));
}
}
out.push_str(&format!(" message: {}\n", r.message));
if let Some(ref doc) = r.doc {
out.push('\n');
out.push_str(doc);
out.push('\n');
} else {
out.push('\n');
out.push_str(
"(no documentation — add a markdown comment block after the frontmatter)\n",
);
}
out.push('\n');
out.push_str(&format_config_snippet(&r.id, config.rules.rules.get(&r.id)));
}
(_, Some(r)) => {
out.push_str(&format!("{} [fact]\n", r.id));
out.push_str(&format!(
" severity: {}\n",
paint_severity(&r.severity.to_string(), use_colors)
));
out.push_str(&format!(" enabled: {}\n", r.enabled));
if !r.tags.is_empty() {
out.push_str(&format!(
" tags: {}\n",
paint_tags(&r.tags, use_colors)
));
}
if !r.allow.is_empty() {
out.push_str(&format!(
" allow: {}\n",
r.allow
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(" ")
));
}
out.push_str(&format!(" message: {}\n", r.message));
if let Some(ref doc) = r.doc {
out.push('\n');
out.push_str(doc);
out.push('\n');
} else {
out.push('\n');
out.push_str(
"(no documentation — add a markdown comment block after the frontmatter)\n",
);
}
out.push('\n');
out.push_str(&format_config_snippet(&r.id, config.rules.rules.get(&r.id)));
}
_ => return Err(format!("Rule not found: {}", id)),
}
Ok(out)
}
fn format_config_snippet(
id: &str,
override_: Option<&normalize_rules_config::RuleOverride>,
) -> String {
let mut out = String::new();
out.push_str("Configuration (.normalize/config.toml):\n");
if let Some(o) = override_ {
out.push_str(&format!(" [rules.\"{id}\"]\n"));
if let Some(ref sev) = o.severity {
out.push_str(&format!(" severity = \"{sev}\"\n"));
}
if let Some(enabled) = o.enabled {
out.push_str(&format!(" enabled = {enabled}\n"));
}
if !o.allow.is_empty() {
let patterns = o
.allow
.iter()
.map(|p| format!("\"{p}\""))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!(" allow = [{patterns}]\n"));
}
} else {
out.push_str(" # No overrides set. Example:\n");
out.push_str(&format!(" [rules.\"{id}\"]\n"));
out.push_str(" severity = \"error\" # error | warning | info | hint\n");
out.push_str(" enabled = false # disable this rule\n");
out.push_str(" allow = [\"**/tests/**\"] # skip matching files\n");
}
out.push('\n');
out.push_str(&format!(" # Or use: normalize rules enable {id}\n"));
out.push_str(&format!(" # normalize rules disable {id}\n"));
out
}
pub fn list_tags(
root: &Path,
show_rules: bool,
tag_filter: Option<&str>,
use_colors: bool,
config: &RulesRunConfig,
) -> Result<String, String> {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
let fact_rules = interpret::load_all_rules(root, &config.rules);
let all_unified = build_unified_rules(&syntax_rules, &fact_rules);
let mut tag_map: std::collections::BTreeMap<String, (String, Vec<String>)> =
std::collections::BTreeMap::new();
for r in &syntax_rules {
for tag in &r.tags {
tag_map
.entry(tag.clone())
.or_insert_with(|| ("builtin".to_string(), Vec::new()))
.1
.push(r.id.clone());
}
}
for r in &fact_rules {
for tag in &r.tags {
tag_map
.entry(tag.clone())
.or_insert_with(|| ("builtin".to_string(), Vec::new()))
.1
.push(r.id.clone());
}
}
let rule_tags = &config.rule_tags;
for tag_name in rule_tags.keys() {
let entry = tag_map
.entry(tag_name.clone())
.or_insert_with(|| ("user-defined".to_string(), Vec::new()));
if entry.0 == "builtin" {
entry.0 = "builtin+user".to_string();
}
let mut visited = HashSet::new();
let resolved = expand_tag(tag_name, rule_tags, &all_unified, &mut visited);
for id in resolved {
if !entry.1.contains(&id.to_string()) {
entry.1.push(id.to_string());
}
}
}
if let Some(t) = tag_filter {
tag_map.retain(|k, _| k == t);
}
let mut out = String::new();
if tag_map.is_empty() {
out.push_str("No tags found.\n");
return Ok(out);
}
for (tag, (origin, ids)) in &tag_map {
let count = ids.len();
let tag_display = if use_colors {
tag_color(tag).paint(tag.as_str()).to_string()
} else {
tag.clone()
};
if show_rules {
let ids_str: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
out.push_str(&format!(
"{:20} [{}] {}\n",
tag_display,
origin,
ids_str.join(" ")
));
} else {
out.push_str(&format!(
"{:20} [{}] {} rule{}\n",
tag_display,
origin,
count,
if count == 1 { "" } else { "s" }
));
}
}
Ok(out)
}
pub fn show_rule_structured(
root: &Path,
id: &str,
config: &RulesRunConfig,
) -> Result<RuleInfoReport, String> {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
let fact_rules = interpret::load_all_rules(root, &config.rules);
let found_syntax = syntax_rules.iter().find(|r| r.id == id);
let found_fact = fact_rules.iter().find(|r| r.id == id);
match (found_syntax, found_fact) {
(Some(r), _) => Ok(RuleInfoReport {
id: r.id.clone(),
rule_type: "syntax".to_string(),
severity: r.severity.to_string(),
enabled: r.enabled,
builtin: r.builtin,
tags: r.tags.clone(),
languages: r.languages.clone(),
message: r.message.clone(),
fix: r.fix.clone(),
description: r.doc.clone(),
allow: r.allow.iter().map(|p| p.as_str().to_string()).collect(),
}),
(_, Some(r)) => Ok(RuleInfoReport {
id: r.id.clone(),
rule_type: "fact".to_string(),
severity: r.severity.to_string(),
enabled: r.enabled,
builtin: r.builtin,
tags: r.tags.clone(),
languages: Vec::new(),
message: r.message.clone(),
fix: None,
description: r.doc.clone(),
allow: r.allow.iter().map(|p| p.as_str().to_string()).collect(),
}),
_ => Err(format!("Rule not found: {}", id)),
}
}
pub fn list_tags_structured(
root: &Path,
tag_filter: Option<&str>,
config: &RulesRunConfig,
) -> Result<RulesTagsReport, String> {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
let fact_rules = interpret::load_all_rules(root, &config.rules);
let all_unified = build_unified_rules(&syntax_rules, &fact_rules);
let mut tag_map: std::collections::BTreeMap<String, (String, Vec<String>)> =
std::collections::BTreeMap::new();
for r in &syntax_rules {
for tag in &r.tags {
tag_map
.entry(tag.clone())
.or_insert_with(|| ("builtin".to_string(), Vec::new()))
.1
.push(r.id.clone());
}
}
for r in &fact_rules {
for tag in &r.tags {
tag_map
.entry(tag.clone())
.or_insert_with(|| ("builtin".to_string(), Vec::new()))
.1
.push(r.id.clone());
}
}
let rule_tags = &config.rule_tags;
for tag_name in rule_tags.keys() {
let entry = tag_map
.entry(tag_name.clone())
.or_insert_with(|| ("user-defined".to_string(), Vec::new()));
if entry.0 == "builtin" {
entry.0 = "builtin+user".to_string();
}
let mut visited = HashSet::new();
let resolved = expand_tag(tag_name, rule_tags, &all_unified, &mut visited);
for id in resolved {
if !entry.1.contains(&id.to_string()) {
entry.1.push(id.to_string());
}
}
}
if let Some(t) = tag_filter {
tag_map.retain(|k, _| k == t);
}
let tags = tag_map
.into_iter()
.map(|(tag, (source, rules))| {
let count = rules.len();
TagEntry {
tag,
source,
count,
rules,
}
})
.collect();
Ok(RulesTagsReport { tags })
}
pub async fn collect_fact_diagnostics(
root: &Path,
config: &RulesConfig,
filter_ids: Option<&HashSet<String>>,
filter_rule: Option<&str>,
) -> Vec<normalize_facts_rules_api::Diagnostic> {
collect_fact_diagnostics_incremental(root, config, filter_ids, filter_rule, None).await
}
pub async fn collect_fact_diagnostics_incremental(
root: &Path,
config: &RulesConfig,
filter_ids: Option<&HashSet<String>>,
filter_rule: Option<&str>,
changed_files: Option<&[PathBuf]>,
) -> Vec<normalize_facts_rules_api::Diagnostic> {
let all_rules_unfiltered = interpret::load_all_rules(root, config);
let all_rules: Vec<_> = all_rules_unfiltered
.into_iter()
.filter(|r| r.enabled)
.filter(|r| filter_ids.is_none_or(|ids| ids.contains(&r.id)))
.filter(|r| filter_rule.is_none_or(|id| r.id == id))
.collect();
if all_rules.is_empty() {
return Vec::new();
}
let relations = match ensure_relations(root).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("failed to build relations for fact rules: {}", e);
return Vec::new();
}
};
let mut all_diagnostics: Vec<normalize_facts_rules_api::Diagnostic> = Vec::new();
if let Some(changed) = changed_files {
let changed_strs: Vec<&str> = changed
.iter()
.map(|p| p.to_str().unwrap_or(""))
.filter(|s| !s.is_empty())
.collect();
let mut cache = engine_cache().lock().unwrap();
for rule in &all_rules {
let cache_key = engine_cache_key(root, &rule.id);
let mut cached_engine: Option<interpret::CachedRuleEngine> =
cache.remove(&cache_key).map(|s| s.0);
let mut diagnostics = match interpret::run_rule_with_cache(
&mut cached_engine,
rule,
&relations,
&changed_strs,
) {
Ok(d) => d,
Err(e) => {
tracing::warn!(rule_id = %rule.id, "incremental fact rule failed: {}", e);
if let Some(engine) = cached_engine {
cache.insert(cache_key, SendableEngine(engine));
}
continue;
}
};
if let Some(engine) = cached_engine {
cache.insert(cache_key, SendableEngine(engine));
}
if !rule.allow.is_empty() {
diagnostics.retain(|d| {
let match_str = match d.location.as_ref() {
Some(loc) => loc.file.as_str(),
None => d.message.as_str(),
};
!rule.allow.iter().any(|p| p.matches(match_str))
});
}
use normalize_facts_rules_api::DiagnosticLevel;
use normalize_rules_config::Severity;
match rule.severity {
Severity::Error => {
for d in &mut diagnostics {
d.level = DiagnosticLevel::Error;
}
}
Severity::Info | Severity::Hint => {
for d in &mut diagnostics {
if d.level == DiagnosticLevel::Warning {
d.level = DiagnosticLevel::Hint;
}
}
}
Severity::Warning => {}
}
all_diagnostics.extend(diagnostics);
}
} else {
let rule_refs: Vec<&interpret::FactsRule> = all_rules.iter().collect();
all_diagnostics = match interpret::run_rules_batch(&rule_refs, &relations) {
Ok(diagnostics) => diagnostics,
Err(e) => {
tracing::warn!("fact rules batch failed: {}", e);
Vec::new()
}
};
}
interpret::filter_inline_allowed(&mut all_diagnostics, root);
all_diagnostics
}
pub fn apply_native_rules_config(
report: &mut normalize_output::diagnostics::DiagnosticsReport,
config: &RulesConfig,
) {
use normalize_output::diagnostics::Severity;
report.issues.retain_mut(|issue| {
let Some(override_) = config.rules.get(&issue.rule_id) else {
return true;
};
if override_.enabled == Some(false) {
return false;
}
if !override_.allow.is_empty() {
let patterns: Vec<glob::Pattern> = override_
.allow
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.collect();
if patterns.iter().any(|p| p.matches(&issue.file)) {
return false;
}
}
if let Some(sev_str) = &override_.severity {
issue.severity = match sev_str.as_str() {
"error" => Severity::Error,
"warning" => Severity::Warning,
"info" => Severity::Info,
"hint" => Severity::Hint,
_ => issue.severity,
};
}
true
});
}
#[cfg(unix)]
pub fn try_rules_via_daemon(
root: &Path,
filter_ids: Option<&HashSet<String>>,
filter_rule: Option<&str>,
engine: Option<&str>,
filter_files: Option<&[String]>,
) -> Option<Vec<normalize_output::diagnostics::Issue>> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
let socket_path = dirs::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("normalize")
.join("daemon.sock");
if !socket_path.exists() {
return None;
}
let mut stream = UnixStream::connect(&socket_path).ok()?;
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.ok();
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
let filter_ids_vec: Option<Vec<String>> = filter_ids.map(|ids| ids.iter().cloned().collect());
let filter_files_vec: Option<Vec<String>> = filter_files.map(|fs| fs.to_vec());
let request = serde_json::json!({
"cmd": "run_rules",
"root": root,
"filter_ids": filter_ids_vec,
"filter_rule": filter_rule,
"engine": engine,
"filter_files": filter_files_vec,
});
let json = serde_json::to_string(&request).ok()?;
stream.write_all(&[0x01]).ok()?;
stream.write_all(json.as_bytes()).ok()?;
stream.write_all(b"\n").ok()?;
let mut hdr = [0u8; 5];
stream.read_exact(&mut hdr).ok()?;
if hdr[0] != 0x01 {
return None;
}
let len = u32::from_le_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(len);
aligned.resize(len, 0);
stream.read_exact(&mut aligned[..]).ok()?;
let issues =
rkyv::from_bytes::<Vec<normalize_output::diagnostics::Issue>, rkyv::rancor::Error>(
&aligned,
)
.ok()?;
tracing::info!(
root = ?root,
issues = issues.len(),
engine = ?engine,
"rules served from daemon cache (rkyv binary)"
);
Some(issues)
}
#[cfg(not(unix))]
pub fn try_rules_via_daemon(
_root: &Path,
_filter_ids: Option<&HashSet<String>>,
_filter_rule: Option<&str>,
_engine: Option<&str>,
_filter_files: Option<&[String]>,
) -> Option<Vec<normalize_output::diagnostics::Issue>> {
None
}
#[allow(clippy::too_many_arguments)]
pub fn run_rules_report(
root: &Path,
project_root: &Path,
filter_rule: Option<&str>,
filter_tag: Option<&str>,
engine: &RuleKind,
debug: &[String],
config: &RulesRunConfig,
files: Option<&[std::path::PathBuf]>,
path_filter: &normalize_rules_config::PathFilter,
) -> normalize_output::diagnostics::DiagnosticsReport {
use normalize_output::diagnostics::DiagnosticsReport;
let mut report = DiagnosticsReport::new();
let rule_tags = &config.rule_tags;
let filter_ids: Option<HashSet<String>> = filter_tag.and_then(|tag| {
if rule_tags.contains_key(tag) {
let syntax_rules = normalize_syntax_rules::load_all_rules(root, &config.rules);
let fact_rules = interpret::load_all_rules(root, &config.rules);
let all_unified = build_unified_rules(&syntax_rules, &fact_rules);
let mut visited = HashSet::new();
let ids = expand_tag(tag, rule_tags, &all_unified, &mut visited);
Some(ids.iter().map(|s| s.to_string()).collect())
} else {
None
}
});
let effective_tag = if filter_ids.is_some() {
None
} else {
filter_tag
};
let daemon_engine = match engine {
RuleKind::Syntax => Some("syntax"),
RuleKind::Fact => Some("fact"),
RuleKind::Native => Some("native"),
RuleKind::All => None, _ => None, };
let daemon_covers_request = matches!(
engine,
RuleKind::All | RuleKind::Syntax | RuleKind::Fact | RuleKind::Native
);
let filter_files_rel: Option<Vec<String>> = files.and_then(|fs| {
let mut out = Vec::with_capacity(fs.len());
for f in fs {
let rel = if f.is_absolute() {
f.strip_prefix(project_root).ok()?.to_path_buf()
} else {
f.clone()
};
out.push(rel.to_string_lossy().into_owned());
}
Some(out)
});
let daemon_start = std::time::Instant::now();
let daemon_result = if daemon_covers_request {
try_rules_via_daemon(
project_root,
filter_ids.as_ref(),
filter_rule,
daemon_engine,
filter_files_rel.as_deref(),
)
} else {
None
};
if let Some(daemon_issues) = daemon_result {
eprintln!("[timings] daemon-cache: {:.1?}", daemon_start.elapsed());
for issue in daemon_issues {
report.issues.push(issue);
}
if matches!(engine, RuleKind::All | RuleKind::Syntax) {
report.sources_run.push("syntax-rules".into());
}
if matches!(engine, RuleKind::All | RuleKind::Fact) {
report.sources_run.push("fact-rules".into());
}
if matches!(engine, RuleKind::All | RuleKind::Native) {
report.sources_run.push("native".into());
}
report.daemon_cached = true;
} else {
if matches!(engine, RuleKind::All | RuleKind::Syntax) {
let debug_flags = DebugFlags::from_args(debug);
let findings = crate::cmd_rules::run_syntax_rules(
root,
project_root,
filter_rule,
effective_tag,
filter_ids.as_ref(),
&config.rules,
&debug_flags,
files,
path_filter,
&config.walk,
);
let unique_files: HashSet<&std::path::Path> =
findings.iter().map(|f| f.file.as_path()).collect();
report.files_checked = report.files_checked.max(unique_files.len());
for f in &findings {
report.issues.push(finding_to_issue(f, root));
}
report.sources_run.push("syntax-rules".into());
}
if matches!(engine, RuleKind::All | RuleKind::Fact) {
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
tracing::warn!("failed to create tokio runtime: {}", e);
panic!("failed to create tokio runtime: {}", e)
});
let diagnostics = std::thread::Builder::new()
.stack_size(64 * 1024 * 1024) .spawn({
let project_root = project_root.to_path_buf();
let rules = config.rules.clone();
let filter_ids = filter_ids.clone();
let filter_rule = filter_rule.map(|s| s.to_string());
move || {
rt.block_on(collect_fact_diagnostics(
&project_root,
&rules,
filter_ids.as_ref(),
filter_rule.as_deref(),
))
}
})
.expect("failed to spawn fact engine thread")
.join()
.expect("fact engine thread panicked");
let allowed_files = build_gitignore_allowed_set(project_root, &config.walk);
let global_allow: Vec<glob::Pattern> = config
.rules
.global_allow
.iter()
.filter_map(|s| glob::Pattern::new(s).ok())
.collect();
for d in &diagnostics {
let file = match &d.location {
Some(loc) => loc.file.as_str(),
None => d.message.as_str(),
};
if !allowed_files.contains(file) {
continue;
}
if global_allow.is_empty() || !global_allow.iter().any(|p| p.matches(file)) {
report.issues.push(abi_diagnostic_to_issue(d));
}
}
report.sources_run.push("fact-rules".into());
}
}
if matches!(engine, RuleKind::All | RuleKind::Sarif) {
let sarif_report = run_sarif_tools(root, &config.rules.sarif_tools);
report.merge(sarif_report);
}
report.sort();
report
}
fn sarif_watch_mtime(root: &Path, patterns: &[String]) -> Option<u64> {
let mut max_mtime: Option<u64> = None;
for pattern in patterns {
let full_pattern = root.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
if let Ok(paths) = glob::glob(&pattern_str) {
for entry in paths.flatten() {
let mtime = normalize_native_rules::cache_file_mtime_nanos(&entry);
if mtime > 0 {
max_mtime = Some(max_mtime.map_or(mtime, |prev| prev.max(mtime)));
}
}
}
}
max_mtime
}
pub fn run_sarif_tools(
root: &Path,
tools: &[SarifTool],
) -> normalize_output::diagnostics::DiagnosticsReport {
use normalize_output::diagnostics::{DiagnosticsReport, Issue, Severity};
let mut report = DiagnosticsReport::new();
let root_str = root.to_string_lossy();
for tool in tools {
if tool.command.is_empty() {
continue;
}
enum CacheDecision {
Run,
Hit,
Miss(Box<normalize_native_rules::FindingsCache>, u64),
}
let cache_decision = if tool.watch.is_empty() {
CacheDecision::Run
} else {
match sarif_watch_mtime(root, &tool.watch) {
None => CacheDecision::Run,
Some(max_mtime) => {
let cache = normalize_native_rules::FindingsCache::open(root);
let cache_path = format!("sarif:{}", tool.name);
if let Some(json) = cache.get(&cache_path, max_mtime, "", "sarif") {
if let Ok(issues) = serde_json::from_str::<Vec<Issue>>(&json) {
for issue in issues {
let source = issue.source.clone();
if !report.sources_run.contains(&source) {
report.sources_run.push(source);
}
report.issues.push(issue);
}
}
CacheDecision::Hit
} else {
CacheDecision::Miss(Box::new(cache), max_mtime)
}
}
}
};
if matches!(cache_decision, CacheDecision::Hit) {
continue;
}
let issues_start = report.issues.len();
let args: Vec<String> = tool
.command
.iter()
.map(|a| a.replace("{root}", &root_str))
.collect();
let output = std::process::Command::new(&args[0])
.args(&args[1..])
.current_dir(root)
.output();
let stdout = match output {
Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
Err(e) => {
let msg = format!("failed to run: {e}");
eprintln!("normalize: SARIF tool '{}' {}", tool.name, msg);
report
.tool_errors
.push(normalize_output::diagnostics::ToolFailure {
tool: tool.name.clone(),
message: msg,
});
continue;
}
};
let sarif: serde_json::Value = match serde_json::from_str(&stdout) {
Ok(v) => v,
Err(e) => {
let msg = format!("did not emit valid JSON: {e}");
eprintln!("normalize: SARIF tool '{}' {}", tool.name, msg);
report
.tool_errors
.push(normalize_output::diagnostics::ToolFailure {
tool: tool.name.clone(),
message: msg,
});
continue;
}
};
let runs = match sarif.get("runs").and_then(|v| v.as_array()) {
Some(r) => r,
None => {
let msg = "output missing 'runs' array".to_string();
eprintln!("normalize: SARIF tool '{}' {}", tool.name, msg);
report
.tool_errors
.push(normalize_output::diagnostics::ToolFailure {
tool: tool.name.clone(),
message: msg,
});
continue;
}
};
for run in runs {
let driver_name = run
.pointer("/tool/driver/name")
.and_then(|v| v.as_str())
.unwrap_or(&tool.name);
let source = format!("sarif:{}", driver_name);
let results = run.get("results").and_then(|v| v.as_array());
let Some(results) = results else { continue };
for result in results {
let rule_id = result
.get("ruleId")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let message = result
.pointer("/message/text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let level = result
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("warning");
let severity = match level {
"error" => Severity::Error,
"warning" => Severity::Warning,
"note" | "none" => Severity::Info,
_ => Severity::Warning,
};
let loc = result.pointer("/locations/0/physicalLocation");
let file = loc
.and_then(|l| l.pointer("/artifactLocation/uri"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let line = loc
.and_then(|l| l.pointer("/region/startLine"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let column = loc
.and_then(|l| l.pointer("/region/startColumn"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
report.issues.push(Issue {
file,
line,
column,
end_line: None,
end_column: None,
rule_id,
message,
severity,
source: source.clone(),
related: vec![],
suggestion: None,
});
}
report.sources_run.push(source);
}
if let CacheDecision::Miss(cache, max_mtime) = cache_decision {
let cache_path = format!("sarif:{}", tool.name);
let tool_issues = &report.issues[issues_start..];
if let Ok(json) = serde_json::to_string(tool_issues) {
cache.put(&cache_path, max_mtime, "", "sarif", &json);
}
}
}
report
}
async fn ensure_relations(root: &Path) -> Result<normalize_facts_rules_api::Relations, String> {
match build_relations_from_index(root).await {
Ok(r) => Ok(r),
Err(_) => {
tracing::info!("Facts index not found. Building...");
let normalize_dir = get_normalize_dir(root);
let db_path = normalize_dir.join("index.sqlite");
let mut idx = normalize_facts::FileIndex::open(&db_path, root)
.await
.map_err(|e| format!("Failed to open index: {}", e))?;
let count = idx
.refresh()
.await
.map_err(|e| format!("Failed to index files: {}", e))?;
tracing::info!("Indexed {} files.", count);
let stats = idx
.refresh_call_graph()
.await
.map_err(|e| format!("Failed to index call graph: {}", e))?;
tracing::info!(
"Indexed {} symbols, {} calls, {} imports.",
stats.symbols,
stats.calls,
stats.imports
);
build_relations_from_index(root).await
}
}
}
fn get_normalize_dir(root: &Path) -> std::path::PathBuf {
if let Ok(index_dir) = std::env::var("NORMALIZE_INDEX_DIR") {
let path = std::path::PathBuf::from(&index_dir);
if path.is_absolute() {
return path;
}
let data_home = std::env::var("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".local/share")
});
return data_home.join("normalize").join(&index_dir);
}
root.join(".normalize")
}
pub async fn build_relations_from_index(
root: &Path,
) -> Result<normalize_facts_rules_api::Relations, String> {
use normalize_facts_rules_api::Relations;
let normalize_dir = get_normalize_dir(root);
let db_path = normalize_dir.join("index.sqlite");
let idx = normalize_facts::FileIndex::open(&db_path, root)
.await
.map_err(|e| format!("Failed to open index: {}", e))?;
let mut relations = Relations::new();
let symbols = idx
.all_symbols_with_details()
.await
.map_err(|e| format!("Failed to get symbols: {}", e))?;
for (file, name, kind, start_line, end_line, parent, visibility, is_impl) in &symbols {
relations.add_symbol(file, name, kind, *start_line as u32);
relations.add_symbol_range(file, name, *start_line as u32, *end_line as u32);
relations.add_visibility(file, name, visibility);
if let Some(parent_name) = parent {
relations.add_parent(file, name, parent_name);
}
if *is_impl {
relations.add_is_impl(file, name);
}
}
let attrs = idx
.all_symbol_attributes()
.await
.map_err(|e| format!("Failed to get symbol attributes: {}", e))?;
for (file, name, attribute) in &attrs {
relations.add_attribute(file, name, attribute);
}
let implements = idx
.all_symbol_implements()
.await
.map_err(|e| format!("Failed to get symbol implements: {}", e))?;
for (file, name, interface) in &implements {
relations.add_implements(file, name, interface);
}
let type_methods = idx
.all_type_methods()
.await
.map_err(|e| format!("Failed to get type methods: {}", e))?;
for (file, type_name, method_name) in &type_methods {
relations.add_type_method(file, type_name, method_name);
}
let imports = idx
.all_imports()
.await
.map_err(|e| format!("Failed to get imports: {}", e))?;
for (file, module, name, _line) in imports {
relations.add_import(&file, &module, &name);
}
let calls = idx
.all_calls_with_qualifiers()
.await
.map_err(|e| format!("Failed to get calls: {}", e))?;
for (file, caller, callee, qualifier, line) in &calls {
relations.add_call(file, caller, callee, *line);
if let Some(qual) = qualifier {
relations.add_qualifier(file, caller, callee, qual);
}
}
let cfg_edges = idx
.all_cfg_edges()
.await
.map_err(|e| format!("Failed to get CFG edges: {}", e))?;
for (file, func, func_line, from, to, kind, exception_type) in &cfg_edges {
relations.add_cfg_edge(file, func, *func_line, *from, *to, kind, exception_type);
}
let cfg_effects = idx
.all_cfg_effects()
.await
.map_err(|e| format!("Failed to get CFG effects: {}", e))?;
for (file, func, func_line, block, kind, line, label) in &cfg_effects {
relations.add_cfg_effect(file, func, *func_line, *block, kind, *line, label);
}
Ok(relations)
}
fn rules_dir(global: bool) -> Option<PathBuf> {
if global {
dirs::config_dir().map(|d| d.join("normalize").join("rules"))
} else {
Some(PathBuf::from(".normalize").join("rules"))
}
}
fn lock_file_path(global: bool) -> Option<PathBuf> {
if global {
dirs::config_dir().map(|d| d.join("normalize").join("rules.lock"))
} else {
Some(PathBuf::from(".normalize").join("rules.lock"))
}
}
fn detect_extension(url: &str) -> &'static str {
if url.ends_with(".dl") { "dl" } else { "scm" }
}
pub fn add_rule(url: &str, global: bool) -> Result<(), String> {
let rules_dir =
rules_dir(global).ok_or_else(|| "Could not determine rules directory".to_string())?;
std::fs::create_dir_all(&rules_dir)
.map_err(|e| format!("Failed to create rules directory: {e}"))?;
let content = download_url(url).map_err(|e| format!("Failed to download rule: {e}"))?;
let rule_id = extract_rule_id(&content).ok_or_else(|| {
"Could not extract rule ID from downloaded content. Rule must have TOML frontmatter with 'id' field".to_string()
})?;
let ext = detect_extension(url);
let rule_path = rules_dir.join(format!("{}.{}", rule_id, ext));
std::fs::write(&rule_path, &content).map_err(|e| format!("Failed to save rule: {e}"))?;
let lock_path =
lock_file_path(global).ok_or_else(|| "Could not determine lock file path".to_string())?;
let mut lock = RulesLock::load(&lock_path);
lock.rules.insert(
rule_id.clone(),
RuleLockEntry {
source: url.to_string(),
content_hash: content_hash(&content),
added: chrono::Utc::now().format("%Y-%m-%d").to_string(),
},
);
if let Err(e) = lock.save(&lock_path) {
eprintln!("Warning: Failed to update lock file: {}", e);
}
println!("Added rule '{}' from {}", rule_id, url);
println!("Saved to: {}", rule_path.display());
Ok(())
}
pub fn update_rules(rule_id: Option<&str>) -> Result<(), String> {
let mut updated = Vec::new();
let mut errors: Vec<(String, String)> = Vec::new();
for global in [false, true] {
if let (Some(lock_path), Some(rules_dir)) = (lock_file_path(global), rules_dir(global)) {
let lock = RulesLock::load(&lock_path);
for (id, entry) in &lock.rules {
if rule_id.is_some() && rule_id != Some(id.as_str()) {
continue;
}
match download_url(&entry.source) {
Ok(content) => {
let ext = detect_extension(&entry.source);
let path = rules_dir.join(format!("{}.{}", id, ext));
if let Err(e) = std::fs::write(&path, &content) {
errors.push((id.clone(), e.to_string()));
} else {
updated.push(id.clone());
}
}
Err(e) => {
errors.push((id.clone(), e.to_string()));
}
}
}
}
}
if updated.is_empty() && errors.is_empty() {
println!("No imported rules to update.");
} else {
for id in &updated {
println!("Updated: {}", id);
}
for (id, err) in &errors {
eprintln!("Failed to update {}: {}", id, err);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(format!("{} rule(s) failed to update", errors.len()))
}
}
pub fn remove_rule(rule_id: &str) -> Result<(), String> {
let mut removed = false;
for global in [false, true] {
if removed {
break;
}
if let (Some(lock_path), Some(rules_dir)) = (lock_file_path(global), rules_dir(global)) {
let mut lock = RulesLock::load(&lock_path);
if lock.rules.remove(rule_id).is_some() {
let _ = lock.save(&lock_path);
for ext in ["scm", "dl"] {
let rule_path = rules_dir.join(format!("{}.{}", rule_id, ext));
let _ = std::fs::remove_file(&rule_path);
}
removed = true;
}
}
}
if removed {
println!("Removed rule '{}'", rule_id);
Ok(())
} else {
Err(format!("Rule '{}' not found in lock file", rule_id))
}
}
fn download_url(url: &str) -> Result<String, String> {
let response = ureq::get(url)
.call()
.map_err(|e| format!("HTTP request failed: {}", e))?;
if response.status() != 200 {
return Err(format!(
"HTTP {}: {}",
response.status(),
response.status_text()
));
}
response
.into_string()
.map_err(|e| format!("Failed to read response: {}", e))
}
fn extract_rule_id(content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let mut in_frontmatter = false;
let mut toml_lines = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed == "# ---" {
if in_frontmatter {
break;
}
in_frontmatter = true;
continue;
}
if in_frontmatter {
if let Some(rest) = trimmed.strip_prefix("# ") {
toml_lines.push(rest);
} else if let Some(rest) = trimmed.strip_prefix('#') {
toml_lines.push(rest);
}
}
}
if toml_lines.is_empty() {
return None;
}
let toml_content = toml_lines.join("\n");
let table: toml::Table = toml_content.parse().ok()?;
table.get("id")?.as_str().map(|s| s.to_string())
}
fn content_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn finding_to_issue(
f: &normalize_syntax_rules::Finding,
root: &std::path::Path,
) -> normalize_output::diagnostics::Issue {
use normalize_output::diagnostics::Issue;
let effective_root;
let root = if root.is_file() {
effective_root = root.parent().unwrap_or(root).to_path_buf();
&effective_root
} else {
root
};
let rel_path = f.file.strip_prefix(root).unwrap_or(&f.file);
Issue {
file: rel_path.to_string_lossy().to_string(),
line: Some(f.start_line),
column: Some(f.start_col),
end_line: Some(f.end_line),
end_column: Some(f.end_col),
rule_id: f.rule_id.clone(),
message: f.message.clone(),
severity: syntax_severity(f.severity),
source: "syntax-rules".into(),
related: Vec::new(),
suggestion: f.fix.clone(),
}
}
fn syntax_severity(s: normalize_syntax_rules::Severity) -> normalize_output::diagnostics::Severity {
use normalize_output::diagnostics::Severity;
match s {
normalize_syntax_rules::Severity::Error => Severity::Error,
normalize_syntax_rules::Severity::Warning => Severity::Warning,
normalize_syntax_rules::Severity::Info => Severity::Info,
normalize_syntax_rules::Severity::Hint => Severity::Hint,
}
}
pub fn abi_diagnostic_to_issue(
d: &normalize_facts_rules_api::Diagnostic,
) -> normalize_output::diagnostics::Issue {
use normalize_output::diagnostics::{Issue, RelatedLocation};
let (file, line, column) = match &d.location {
Some(loc) => (
loc.file.to_string(),
Some(loc.line as usize),
loc.column.map(|c| c as usize),
),
None => (String::new(), None, None),
};
let related = d
.related
.iter()
.map(|loc| RelatedLocation {
file: loc.file.to_string(),
line: Some(loc.line as usize),
message: None,
})
.collect();
let suggestion = d.suggestion.clone();
Issue {
file,
line,
column,
end_line: None,
end_column: None,
rule_id: d.rule_id.to_string(),
message: d.message.to_string(),
severity: abi_level(d.level),
source: "fact-rules".into(),
related,
suggestion,
}
}
fn abi_level(
level: normalize_facts_rules_api::DiagnosticLevel,
) -> normalize_output::diagnostics::Severity {
use normalize_output::diagnostics::Severity;
match level {
normalize_facts_rules_api::DiagnosticLevel::Hint => Severity::Hint,
normalize_facts_rules_api::DiagnosticLevel::Warning => Severity::Warning,
normalize_facts_rules_api::DiagnosticLevel::Error => Severity::Error,
}
}
pub fn format_diagnostic(diag: &normalize_facts_rules_api::Diagnostic, use_colors: bool) -> String {
crate::loader::format_diagnostic(diag, use_colors)
}
fn build_gitignore_allowed_set(
root: &Path,
walk_config: &normalize_rules_config::WalkConfig,
) -> HashSet<String> {
let ignore_files = walk_config.ignore_files();
let has_gitignore = ignore_files.contains(&".gitignore");
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(false)
.git_ignore(has_gitignore)
.git_global(has_gitignore)
.git_exclude(has_gitignore);
for file in &ignore_files {
if *file != ".gitignore" {
let ignore_path = root.join(file);
if ignore_path.exists() {
builder.add_ignore(ignore_path);
}
}
}
let excludes = walk_config.compiled_excludes(root);
let root_owned = root.to_path_buf();
builder.filter_entry(move |e| {
let path = e.path();
let rel = path.strip_prefix(&root_owned).unwrap_or(path);
if rel.as_os_str().is_empty() {
return true;
}
let is_dir = e.file_type().is_some_and(|ft| ft.is_dir());
!excludes
.matched_path_or_any_parents(rel, is_dir)
.is_ignore()
});
let mut allowed = HashSet::new();
for entry in builder.build().flatten() {
if let Ok(rel) = entry.path().strip_prefix(root) {
allowed.insert(rel.to_string_lossy().into_owned());
}
}
allowed
}