use crate::code_lint::ast::{self, AstNode, ParsedFile};
use crate::code_lint::contract::{CodeRule, RuleTarget};
use crate::diagnostic::{Diagnostic, Language, RuleName, ViolationTemplate, violation_template};
use crate::rule_declaration::{
Classification, Consensus, Declaration, Example, ImpactedQuality, Precision, Reference,
RuleDoc, RuleOptions, Topic,
};
use std::path::Path;
const TEMPLATE: ViolationTemplate = violation_template! {
summary: {
Python => "`assert` packs several checks into {construct}.",
Rust => "`{callee}!` packs several checks into {construct}.",
},
rationale: "When one assertion holds several independent checks, a failure does not say which check failed, and the diff shows the whole compound value instead of the mismatching part.",
suggestion: {
Python => "Split the checks into separate `assert` statements, or compare one domain object directly.",
Rust => "Split the checks into separate `assert!` / `assert_eq!` calls, or compare one domain struct directly.",
},
};
pub const RULE: CodeRule = CodeRule {
declaration: Declaration {
name: RuleName("packed-assertion"),
template: &TEMPLATE,
languages: &[Language::Python, Language::Rust],
options: RuleOptions::code_rule(()),
classification: Classification {
topics: &[Topic::TEST_ASSERTIONS],
precision: Precision::Exact,
consensus: Consensus::Opinionated,
impacted_quality: ImpactedQuality::Maintainability,
},
doc: RuleDoc {
summary: "Flags assertions that pack several checks into one condition.",
what_it_does: indoc::indoc! {r"
Flags a test assertion that joins checks with a top-level `and` (Python
`assert`) or `&&` (Rust `assert!`), or that compares against a tuple, list or
array of boolean literals, such as `assert_eq!((valid, active), (true, false))`.
Conditions joined by `or` are not flagged."},
why_is_this_bad: indoc::indoc! {r"
When a packed assertion fails, the report only says the whole condition was false:
it does not say which part failed, and a tuple of booleans shows `True`/`False`
values with no name attached. Finding the culprit means rerunning the test or adding
prints.
Write one assertion per check, so each failure names its condition and shows its
values, or compare the result against one expected object or struct."},
known_problems: Some(indoc::indoc! {r"
In Python, only `assert` statements are checked, not `unittest` methods such as
`self.assertTrue(a and b)`."}),
references: &[Reference {
title: "pytest: How to write and report assertions in tests",
url: "https://docs.pytest.org/en/stable/how-to/assert.html",
}],
examples: &[
Example {
language: Language::Python,
flagged: indoc::indoc! {r"
def test_login():
assert user.is_active and user.is_verified
"},
flagged_span: "assert user.is_active and user.is_verified",
fixed: indoc::indoc! {r"
def test_login():
assert user.is_active
assert user.is_verified
"},
},
Example {
language: Language::Rust,
flagged: indoc::indoc! {r"
#[test]
fn test_login() {
assert!(user.is_active && user.is_verified);
}
"},
flagged_span: "assert!(user.is_active && user.is_verified)",
fixed: indoc::indoc! {r"
#[test]
fn test_login() {
assert!(user.is_active);
assert!(user.is_verified);
}
"},
},
],
},
},
target: RuleTarget::TestsOnly,
check: check_file,
};
const CONSTRUCT: &str = "construct";
const CALLEE: &str = "callee";
const BOOLEAN_COLLECTION_COMPARISON: &str = "a comparison against a collection of boolean literals";
fn check_file(rule: &CodeRule, path: &Path, file: &ParsedFile, (): ()) -> Vec<Diagnostic> {
match file.lang() {
Language::Rust => ast::rust::collect_macro_invocations(file)
.iter()
.filter_map(|node| check_rust_assertion_macro(rule, node, path))
.collect(),
Language::Python => ast::python::collect_assert_statements(file)
.iter()
.filter_map(|assert_statement| {
check_python_assert_statement(rule, assert_statement, path)
})
.collect(),
}
}
fn check_rust_assertion_macro(
rule: &CodeRule,
macro_node: &AstNode<'_>,
path: &Path,
) -> Option<Diagnostic> {
let macro_name = ast::rust::macro_terminal_name(macro_node);
if (macro_name == "assert" || macro_name == "debug_assert")
&& ast::rust::has_top_level_logical_and(macro_node)
{
return Some(rule.diagnostic_at_node(
path,
macro_node,
&[
(CONSTRUCT, "a compound `&&` condition"),
(CALLEE, ¯o_name),
],
));
}
if (macro_name.starts_with("assert_") || macro_name.starts_with("debug_assert_"))
&& ast::rust::extract_macro_arguments(macro_node)
.iter()
.any(ast::rust::is_boolean_literal_collection)
{
return Some(rule.diagnostic_at_node(
path,
macro_node,
&[
(CONSTRUCT, BOOLEAN_COLLECTION_COMPARISON),
(CALLEE, ¯o_name),
],
));
}
None
}
fn check_python_assert_statement(
rule: &CodeRule,
assert_statement: &ast::python::PythonAssert<'_>,
path: &Path,
) -> Option<Diagnostic> {
if assert_statement.has_top_level_logical_and {
return Some(rule.diagnostic_at_node(
path,
&assert_statement.node,
&[(CONSTRUCT, "a compound `and` condition")],
));
}
if assert_statement.has_boolean_literal_comparison {
return Some(rule.diagnostic_at_node(
path,
&assert_statement.node,
&[(CONSTRUCT, BOOLEAN_COLLECTION_COMPARISON)],
));
}
None
}
#[cfg(test)]
crate::test_utils::rule_test!(
RULE,
{
Python => {
pass: [
atomic_assertion => r#"
def test_atomic():
assert ready
assert actual == expected
"#,
disjunctive_or_condition => r#"
def test_disjunction():
assert a == 1 or b == 2
"#,
single_element_boolean_tuple => r#"
def test_single_element():
assert (flag,) == (True,)
"#,
logical_and_inside_function_call => r#"
def test_nested_call():
assert check_connection(ready and connected)
"#,
non_boolean_tuple_equality => r#"
def test_tuple_values():
assert (width, height) == (1920, 1080)
"#,
non_boolean_list_equality => r#"
def test_list_values():
assert [first, second] == [1, 2]
"#,
],
fail: [
compound_and_in_assert => r#"
def test_example():
assert a == 1 and b == 2
"# => r#"assert a == 1 and b == 2"#,
boolean_tuple_equality => r#"
def test_example():
assert (valid, active) == (True, False)
"# => r#"assert (valid, active) == (True, False)"#,
boolean_list_equality => r#"
def test_example():
assert [first, second] == [True, True]
"# => r#"assert [first, second] == [True, True]"#,
],
},
Rust => {
pass: [
atomic_assertion => r#"
#[test]
fn test_atomic() {
assert!(ready);
assert_eq!(actual, expected);
}
"#,
disjunctive_or_condition => r#"
#[test]
fn test_disjunction() {
assert!(a == 1 || b == 2);
}
"#,
single_element_boolean_tuple => r#"
#[test]
fn test_single_tuple() {
assert_eq!((flag,), (true,));
}
"#,
logical_and_inside_function_call => r#"
#[test]
fn test_nested_call() {
assert!(check_connection(ready && connected));
}
"#,
non_boolean_tuple_equality => r#"
#[test]
fn test_tuple_values() {
assert_eq!((width, height), (1920, 1080));
}
"#,
non_boolean_array_equality => r#"
#[test]
fn test_array_values() {
assert_eq!([compute(true), compute(false)], expected);
}
"#,
],
fail: [
compound_and_in_assert => r#"
#[test]
fn test_example() {
assert!(a == 1 && b == 2);
}
"# => r#"assert!(a == 1 && b == 2)"#,
compound_and_in_debug_assert => r#"
#[test]
fn test_example() {
debug_assert!(ready && connected);
}
"# => r#"debug_assert!(ready && connected)"#,
boolean_tuple_equality => r#"
#[test]
fn test_example() {
assert_eq!((valid, active), (true, false));
}
"# => r#"assert_eq!((valid, active), (true, false))"#,
boolean_array_equality => r#"
#[test]
fn test_example() {
debug_assert_ne!([first, second], [true, true]);
}
"# => r#"debug_assert_ne!([first, second], [true, true])"#,
],
},
}
);