use std::collections::{BTreeSet, HashMap};
use rpm_spec::ast::{
BinOp, CondExpr, Conditional, ExprAst, FilesContent, PreambleContent, Span, SpecItem,
};
use crate::diagnostic::{Applicability, Diagnostic, LintCategory, Severity, Suggestion};
use crate::lint::{Lint, LintMetadata};
use crate::visit::{self, Visit};
const MAX_CUBES: usize = 64;
pub(crate) const MAX_ATOMS_FOR_TAUTOLOGY: usize = 8;
pub(crate) type AtomId = u32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct Literal {
pub atom: AtomId,
pub negated: bool,
}
impl Literal {
fn flip(self) -> Self {
Self {
atom: self.atom,
negated: !self.negated,
}
}
}
pub(crate) type Cube = BTreeSet<Literal>;
pub(crate) type Dnf = BTreeSet<Cube>;
#[derive(Debug, Default)]
pub(crate) struct AtomTable {
next: AtomId,
by_canon: HashMap<String, AtomId>,
}
impl AtomTable {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn intern(&mut self, expr: &ExprAst<Span>) -> AtomId {
let key = canonicalise(expr);
self.intern_key(key)
}
pub(crate) fn intern_key(&mut self, key: String) -> AtomId {
if let Some(&id) = self.by_canon.get(&key) {
return id;
}
let id = self.next;
self.next += 1;
self.by_canon.insert(key, id);
id
}
pub(crate) fn len(&self) -> usize {
self.next as usize
}
}
fn canonicalise(expr: &ExprAst<Span>) -> String {
match expr.peel_parens() {
ExprAst::Integer { value, .. } => value.to_string(),
ExprAst::String { value, .. } => format!("\"{value}\""),
ExprAst::Macro { text, .. } => text.clone(),
ExprAst::Identifier { name, .. } => name.clone(),
ExprAst::Not { inner, .. } => format!("!{}", canonicalise(inner)),
ExprAst::Binary { kind, lhs, rhs, .. } => {
format!(
"({}{}{})",
canonicalise(lhs),
kind.as_str(),
canonicalise(rhs)
)
}
_ => String::new(),
}
}
pub(crate) fn to_dnf(expr: &ExprAst<Span>, atoms: &mut AtomTable, negate: bool) -> Option<Dnf> {
let bare = expr.peel_parens();
match bare {
ExprAst::Not { inner, .. } => to_dnf(inner, atoms, !negate),
ExprAst::Binary {
kind: BinOp::LogOr,
lhs,
rhs,
..
} => {
if !negate {
let mut left = to_dnf(lhs, atoms, false)?;
let right = to_dnf(rhs, atoms, false)?;
left.extend(right);
if left.len() > MAX_CUBES {
return None;
}
Some(left)
} else {
let left = to_dnf(lhs, atoms, true)?;
let right = to_dnf(rhs, atoms, true)?;
distribute_and(&left, &right)
}
}
ExprAst::Binary {
kind: BinOp::LogAnd,
lhs,
rhs,
..
} => {
if !negate {
let left = to_dnf(lhs, atoms, false)?;
let right = to_dnf(rhs, atoms, false)?;
distribute_and(&left, &right)
} else {
let mut left = to_dnf(lhs, atoms, true)?;
let right = to_dnf(rhs, atoms, true)?;
left.extend(right);
if left.len() > MAX_CUBES {
return None;
}
Some(left)
}
}
_ => {
let atom = atoms.intern(bare);
let mut cube = BTreeSet::new();
cube.insert(Literal {
atom,
negated: negate,
});
let mut dnf = BTreeSet::new();
dnf.insert(cube);
Some(dnf)
}
}
}
pub(crate) fn distribute_and(a: &Dnf, b: &Dnf) -> Option<Dnf> {
let mut out: Dnf = BTreeSet::new();
for ca in a {
for cb in b {
let mut merged = ca.clone();
merged.extend(cb.iter().copied());
if has_internal_contradiction(&merged) {
continue;
}
out.insert(merged);
if out.len() > MAX_CUBES {
return None;
}
}
}
Some(out)
}
fn has_internal_contradiction(cube: &Cube) -> bool {
cube.iter().any(|lit| cube.contains(&lit.flip()))
}
pub(crate) fn simplify_subsumption(dnf: &Dnf) -> (Dnf, bool) {
let mut out = Dnf::new();
let mut simplified = false;
'outer: for c in dnf {
for other in dnf {
if !std::ptr::eq(c, other) && other.is_subset(c) && other != c {
simplified = true;
continue 'outer;
}
}
out.insert(c.clone());
}
(out, simplified)
}
pub(crate) fn is_tautology(dnf: &Dnf, atom_count: usize) -> Option<bool> {
if atom_count > MAX_ATOMS_FOR_TAUTOLOGY {
return None;
}
if dnf.is_empty() {
return Some(false);
}
let total = 1u32 << atom_count;
for assignment in 0..total {
let satisfies_any = dnf.iter().any(|cube| eval_cube(cube, assignment));
if !satisfies_any {
return Some(false);
}
}
Some(true)
}
pub(crate) fn eval_cube(cube: &Cube, assignment: u32) -> bool {
cube.iter().all(|lit| {
let bit = (assignment >> lit.atom) & 1 == 1;
if lit.negated { !bit } else { bit }
})
}
pub(crate) fn is_contradiction(dnf: &Dnf) -> bool {
dnf.is_empty()
}
fn analyse_branch(
expr: &CondExpr<Span>,
) -> Option<(
Dnf,
/*atom_count*/ usize,
/*orig_cube_count*/ usize,
)> {
let CondExpr::Parsed(ast) = expr else {
return None;
};
if !has_boolean_structure(ast) {
return None;
}
let mut atoms = AtomTable::new();
let raw_dnf = to_dnf(ast, &mut atoms, false)?;
let source_or_operands = count_or_operands(ast);
let orig_cube_count = source_or_operands.max(raw_dnf.len());
let (simplified, _) = simplify_subsumption(&raw_dnf);
Some((simplified, atoms.len(), orig_cube_count))
}
fn count_or_operands<T>(ast: &ExprAst<T>) -> usize {
match ast.peel_parens() {
ExprAst::Binary {
kind: BinOp::LogOr,
lhs,
rhs,
..
} => count_or_operands(lhs) + count_or_operands(rhs),
_ => 1,
}
}
fn has_boolean_structure<T>(ast: &ExprAst<T>) -> bool {
use rpm_spec::ast::ExprAst;
matches!(
ast.peel_parens(),
ExprAst::Binary {
kind: BinOp::LogAnd | BinOp::LogOr,
..
} | ExprAst::Not { .. }
)
}
pub static REDUNDANCY_METADATA: LintMetadata = LintMetadata {
id: "RPM110",
name: "boolean-dnf-redundancy",
description: "Expression contains operands that are absorbed by others — DNF normalisation \
reveals shorter equivalent form.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct BooleanDnfRedundancy {
diagnostics: Vec<Diagnostic>,
}
impl BooleanDnfRedundancy {
pub fn new() -> Self {
Self::default()
}
fn check<B>(&mut self, node: &Conditional<Span, B>) {
for branch in &node.branches {
let Some((dnf, _atoms, orig_cubes)) = analyse_branch(&branch.expr) else {
continue;
};
if is_contradiction(&dnf) || dnf.iter().any(|c| c.is_empty()) {
continue;
}
if dnf.len() < orig_cubes {
self.diagnostics.push(
Diagnostic::new(
&REDUNDANCY_METADATA,
Severity::Warn,
format!(
"boolean expression has {orig_cubes} top-level cubes that \
normalise to {}; some operands are absorbed by others",
dnf.len()
),
branch.data,
)
.with_suggestion(Suggestion::new(
"drop the absorbed operands; keep the smallest covering cubes",
Vec::new(),
Applicability::Manual,
)),
);
}
}
}
}
impl<'ast> Visit<'ast> for BooleanDnfRedundancy {
fn visit_top_conditional(&mut self, node: &'ast Conditional<Span, SpecItem<Span>>) {
self.check(node);
visit::walk_top_conditional(self, node);
}
fn visit_preamble_conditional(&mut self, node: &'ast Conditional<Span, PreambleContent<Span>>) {
self.check(node);
visit::walk_preamble_conditional(self, node);
}
fn visit_files_conditional(&mut self, node: &'ast Conditional<Span, FilesContent<Span>>) {
self.check(node);
visit::walk_files_conditional(self, node);
}
}
impl Lint for BooleanDnfRedundancy {
fn metadata(&self) -> &'static LintMetadata {
&REDUNDANCY_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
pub static TAUTOLOGY_METADATA: LintMetadata = LintMetadata {
id: "RPM111",
name: "boolean-tautology-by-cubes",
description: "Boolean expression is tautologically true under every assignment — drop the guard.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct BooleanTautologyByCubes {
diagnostics: Vec<Diagnostic>,
}
impl BooleanTautologyByCubes {
pub fn new() -> Self {
Self::default()
}
fn check<B>(&mut self, node: &Conditional<Span, B>) {
for branch in &node.branches {
let Some((dnf, atom_count, _)) = analyse_branch(&branch.expr) else {
continue;
};
if matches!(is_tautology(&dnf, atom_count), Some(true)) {
self.diagnostics.push(
Diagnostic::new(
&TAUTOLOGY_METADATA,
Severity::Warn,
"boolean expression is always true — every truth assignment \
satisfies at least one cube; drop the `%if` guard",
branch.data,
)
.with_suggestion(Suggestion::new(
"unwrap the `%if` block: keep the body, drop `%if`/`%endif`",
Vec::new(),
Applicability::Manual,
)),
);
}
}
}
}
impl<'ast> Visit<'ast> for BooleanTautologyByCubes {
fn visit_top_conditional(&mut self, node: &'ast Conditional<Span, SpecItem<Span>>) {
self.check(node);
visit::walk_top_conditional(self, node);
}
fn visit_preamble_conditional(&mut self, node: &'ast Conditional<Span, PreambleContent<Span>>) {
self.check(node);
visit::walk_preamble_conditional(self, node);
}
fn visit_files_conditional(&mut self, node: &'ast Conditional<Span, FilesContent<Span>>) {
self.check(node);
visit::walk_files_conditional(self, node);
}
}
impl Lint for BooleanTautologyByCubes {
fn metadata(&self) -> &'static LintMetadata {
&TAUTOLOGY_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
pub static CONTRADICTION_METADATA: LintMetadata = LintMetadata {
id: "RPM112",
name: "boolean-contradiction-by-cubes",
description: "Boolean expression is unsatisfiable — every cube collapses to internal contradiction.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct BooleanContradictionByCubes {
diagnostics: Vec<Diagnostic>,
}
impl BooleanContradictionByCubes {
pub fn new() -> Self {
Self::default()
}
fn check<B>(&mut self, node: &Conditional<Span, B>) {
for branch in &node.branches {
let Some((dnf, _, _)) = analyse_branch(&branch.expr) else {
continue;
};
if is_contradiction(&dnf) {
self.diagnostics.push(Diagnostic::new(
&CONTRADICTION_METADATA,
Severity::Warn,
"boolean expression is unsatisfiable — every cube has an internal \
`A && !A` contradiction; the branch is dead code",
branch.data,
));
}
}
}
}
impl<'ast> Visit<'ast> for BooleanContradictionByCubes {
fn visit_top_conditional(&mut self, node: &'ast Conditional<Span, SpecItem<Span>>) {
self.check(node);
visit::walk_top_conditional(self, node);
}
fn visit_preamble_conditional(&mut self, node: &'ast Conditional<Span, PreambleContent<Span>>) {
self.check(node);
visit::walk_preamble_conditional(self, node);
}
fn visit_files_conditional(&mut self, node: &'ast Conditional<Span, FilesContent<Span>>) {
self.check(node);
visit::walk_files_conditional(self, node);
}
}
impl Lint for BooleanContradictionByCubes {
fn metadata(&self) -> &'static LintMetadata {
&CONTRADICTION_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn run<L: Lint>(src: &str, mut lint: L) -> Vec<Diagnostic> {
let outcome = parse(src);
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
fn dnf_of(src: &str) -> Option<Dnf> {
let outcome = parse(&format!("Name: x\n%if {src}\nLicense: MIT\n%endif\n"));
let item = outcome.spec.items.iter().find_map(|i| match i {
rpm_spec::ast::SpecItem::Conditional(c) => Some(c),
_ => None,
})?;
let CondExpr::Parsed(ast) = &item.branches[0].expr else {
return None;
};
let mut atoms = AtomTable::new();
let raw = to_dnf(ast, &mut atoms, false)?;
let (simplified, _) = simplify_subsumption(&raw);
Some(simplified)
}
#[test]
fn dnf_handles_simple_and() {
let dnf = dnf_of("A && B").unwrap();
assert_eq!(dnf.len(), 1, "{dnf:?}");
assert_eq!(dnf.iter().next().unwrap().len(), 2);
}
#[test]
fn dnf_distributes_over_or() {
let dnf = dnf_of("(A || B) && C").unwrap();
assert_eq!(dnf.len(), 2, "{dnf:?}");
}
#[test]
fn dnf_subsumes_redundant_cube() {
let dnf = dnf_of("(A && B) || (A && B && C)").unwrap();
assert_eq!(dnf.len(), 1, "{dnf:?}");
}
#[test]
fn dnf_recognises_contradiction() {
let dnf = dnf_of("A && !A").unwrap();
assert!(is_contradiction(&dnf), "{dnf:?}");
}
#[test]
fn dnf_recognises_tautology() {
let dnf = dnf_of("A || !A").unwrap();
assert_eq!(is_tautology(&dnf, 1), Some(true), "{dnf:?}");
}
#[test]
fn dnf_tautology_three_atoms() {
let dnf = dnf_of("A || (!A && B) || (!A && !B)").unwrap();
assert_eq!(is_tautology(&dnf, 2), Some(true), "{dnf:?}");
}
#[test]
fn dnf_bails_on_unsupported_grammar() {
let src = "Name: x\n%if 1 + 2 == 3\nLicense: MIT\n%endif\n";
let outcome = parse(src);
let item = outcome
.spec
.items
.iter()
.find_map(|i| match i {
rpm_spec::ast::SpecItem::Conditional(c) => Some(c),
_ => None,
})
.unwrap();
assert!(matches!(item.branches[0].expr, CondExpr::Raw(_)));
}
#[test]
fn rpm110_flags_subsumed_cube() {
let src = "Name: x\n%if (A && B) || (A && B && C)\nLicense: MIT\n%endif\n";
let diags = run(src, BooleanDnfRedundancy::new());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM110");
}
#[test]
fn rpm110_flags_duplicate_or_branch() {
let src = "Name: x\n%if A || A\nLicense: MIT\n%endif\n";
let diags = run(src, BooleanDnfRedundancy::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm110_silent_for_minimal_expression() {
let src = "Name: x\n%if A && B\nLicense: MIT\n%endif\n";
assert!(run(src, BooleanDnfRedundancy::new()).is_empty());
}
#[test]
fn rpm110_silent_for_pure_atom() {
let src = "Name: x\n%if A\nLicense: MIT\n%endif\n";
assert!(run(src, BooleanDnfRedundancy::new()).is_empty());
}
#[test]
fn rpm111_flags_excluded_middle() {
let src = "Name: x\n%if A || !A\nLicense: MIT\n%endif\n";
let diags = run(src, BooleanTautologyByCubes::new());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM111");
}
#[test]
fn rpm111_flags_three_atom_tautology() {
let src = "Name: x\n%if A || (!A && B) || (!A && !B)\nLicense: MIT\n%endif\n";
let diags = run(src, BooleanTautologyByCubes::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm111_silent_for_satisfiable_non_tautology() {
let src = "Name: x\n%if A && B\nLicense: MIT\n%endif\n";
assert!(run(src, BooleanTautologyByCubes::new()).is_empty());
}
#[test]
fn rpm112_flags_a_and_not_a() {
let src = "Name: x\n%if A && !A\nLicense: MIT\n%endif\n";
let diags = run(src, BooleanContradictionByCubes::new());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM112");
}
#[test]
fn rpm112_flags_multi_atom_contradiction() {
let src = "Name: x\n%if (A && !B) && B\nLicense: MIT\n%endif\n";
let diags = run(src, BooleanContradictionByCubes::new());
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn rpm112_silent_for_satisfiable() {
let src = "Name: x\n%if A && B\nLicense: MIT\n%endif\n";
assert!(run(src, BooleanContradictionByCubes::new()).is_empty());
}
}