Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/src/attributes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub(super) const TEST_LIKE_PATHS: &[&[&str]] = &[
&["gpui", "test"],
&["rstest"],
&["rstest", "rstest"],
&["rstest_parametrize"],
&["rstest", "rstest_parametrize"],
&["case"],
&["rstest", "case"],
];
Expand Down
2 changes: 2 additions & 0 deletions common/src/attributes/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ fn path_is_doc(#[case] path: AttributePath, #[case] expected: bool) {
#[case::rstest_qualified("rstest::rstest", true)]
#[case::case_imported("case", true)]
#[case::case_qualified("rstest::case", true)]
#[case::rstest_parametrize_bare("rstest_parametrize", true)]
#[case::rstest_parametrize_qualified("rstest::rstest_parametrize", true)]
#[case::core_prelude_test("core::prelude::v1::test", true)]
#[case::std_prelude_test("std::prelude::rust_2024::test", true)]
#[case::other("allow", false)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Regression example covering `#[tokio::test]` inside a module with a
//! non-standard test name, compiled under `--test` harness.
//!
//! This validates that the `has_test_module_name` fallback correctly recognises
//! test functions inside modules whose names follow test naming conventions
//! (e.g. `service_tests`) even when `#[cfg(test)]` is not present in HIR.
//!
//! The module is inline rather than `#[path]`-loaded because `Test::example()`
//! copies only the single `.rs` file to a temp directory and does not preserve
//! subdirectories.
//!
//! Regression test for <https://github.com/leynos/whitaker/issues/132>.

mod service_tests {
#[tokio::test]
async fn expect_in_path_module_is_allowed() {
let value: Result<&str, ()> = Ok("ok");
value.expect("non-standard module name test should permit expect");
}
}

fn main() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! Regression example covering real `#[rstest]` handling for
//! `no_expect_outside_tests`.
//!
//! This uses the actual `rstest` crate (not a stub auxiliary proc-macro) to
//! validate that Whitaker correctly classifies functions annotated with
//! `#[rstest]` and `#[case]` as test-only code.
//!
//! `rstest_parametrize` was removed from the `rstest` crate in version 0.5.0
//! and replaced by the unified `#[rstest]` attribute with `#[case(...)]`.
//! Whitaker still recognizes `rstest_parametrize` in the attribute registry for
//! backwards compatibility with older projects, but this example covers only the
//! current `#[rstest]` form since rstest 0.26.1 is the declared version.
//!
//! Regression test for <https://github.com/leynos/whitaker/issues/189>.

use rstest::rstest;

#[rstest]
#[case(1)]
#[case(42)]
fn rstest_allows_expect_in_test_context(#[case] value: i32) {
let parsed = Some(value).expect("value should be present");
assert_eq!(parsed, value);
}

#[rstest]
fn rstest_allows_expect_without_cases() {
let result: Result<&str, ()> = Ok("ok");
result.expect("rstest without cases should be test-only");
}

fn main() {}
136 changes: 12 additions & 124 deletions crates/no_expect_outside_tests/src/driver/mod.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
//! Lint crate forbidding `.expect(..)` outside test and doctest contexts.
//!
//! The lint inspects method calls named `expect`, verifies that the receiver
//! is an `Option` or `Result`, and checks the surrounding traversal context for
//! test-like attributes or `cfg(test)` guards. Doctest harnesses are skipped via
//! `Crate::is_doctest`, ensuring documentation examples remain ergonomic. When
//! no test context is present, the lint emits a denial with a note describing
//! the enclosing function and the receiver type to guide remediation. Teams can
//! extend the recognised test attributes through `dylint.toml` when bespoke
//! macros are in play.
//! The lint inspects method calls named `expect`, verifies the receiver is an
//! `Option` or `Result`, and checks the surrounding context for test-like
//! attributes, `cfg(test)` guards, or harness descriptors. Doctest harnesses
//! are skipped via `Crate::is_doctest`. Teams can extend the recognised test
//! attributes through `dylint.toml` when bespoke macros are in play.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

use std::collections::HashSet;
use std::ffi::OsStr;
Expand All @@ -17,15 +14,19 @@ use log::debug;
use rustc_hir as hir;
use rustc_lint::{LateContext, LateLintPass};
use rustc_middle::ty::{self, Ty};
use rustc_span::{Span, Symbol, sym};
use rustc_span::sym;
use serde::Deserialize;
use whitaker::SharedConfig;
use whitaker::hir::has_test_like_hir_attributes;
use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint};

use crate::context::{collect_context, summarise_context};
use crate::diagnostics::{DiagnosticContext, emit_diagnostic};

use test_context::{
collect_harness_marked_test_functions, extract_function_item, has_test_attribute,
is_harness_marked_test_function, is_test_named_module,
};

dylint_linting::impl_late_lint! {
pub NO_EXPECT_OUTSIDE_TESTS,
Deny,
Expand Down Expand Up @@ -220,120 +221,7 @@ fn is_likely_test_function<'tcx>(
false
}

fn is_test_named_module(node: hir::Node<'_>) -> bool {
let hir::Node::Item(item) = node else {
return false;
};
let hir::ItemKind::Mod { .. } = item.kind else {
return false;
};
let Some(ident) = item.kind.ident() else {
return false;
};
matches!(ident.name.as_str(), "test" | "tests")
}

fn extract_function_item(node: hir::Node<'_>) -> Option<&hir::Item<'_>> {
let hir::Node::Item(item) = node else {
return None;
};
matches!(item.kind, hir::ItemKind::Fn { .. }).then_some(item)
}

fn is_harness_marked_test_function(
function_hir_id: hir::HirId,
harness_marked_test_functions: &HashSet<hir::HirId>,
) -> bool {
harness_marked_test_functions.contains(&function_hir_id)
}

fn collect_harness_marked_test_functions<'tcx>(cx: &LateContext<'tcx>) -> HashSet<hir::HirId> {
let root_items = cx
.tcx
.hir_crate_items(())
.free_items()
.map(|id| cx.tcx.hir_item(id))
.collect::<Vec<_>>();
let mut harness_marked = HashSet::new();
collect_harness_marked_test_functions_in_group(cx, root_items.as_slice(), &mut harness_marked);
harness_marked
}

fn collect_harness_marked_test_functions_in_group<'tcx>(
cx: &LateContext<'tcx>,
items: &[&'tcx hir::Item<'tcx>],
harness_marked: &mut HashSet<hir::HirId>,
) {
for item in items
.iter()
.copied()
.filter(|item| matches!(item.kind, hir::ItemKind::Fn { .. }))
{
let Some(function_ident) = item.kind.ident() else {
continue;
};

let function_hir_id = item.hir_id();
let function_name = function_ident.name;
let function_span = item.span;
if items.iter().copied().any(|sibling| {
is_matching_harness_test_descriptor(
function_hir_id,
function_name,
function_span,
sibling,
)
}) {
harness_marked.insert(function_hir_id);
}
}

for item in items {
let hir::ItemKind::Mod(_, module) = item.kind else {
continue;
};

let module_items = module
.item_ids
.iter()
.map(|id| cx.tcx.hir_item(*id))
.collect::<Vec<_>>();
collect_harness_marked_test_functions_in_group(cx, module_items.as_slice(), harness_marked);
}
}

fn is_matching_harness_test_descriptor(
function_hir_id: hir::HirId,
function_name: Symbol,
function_span: Span,
sibling: &hir::Item<'_>,
) -> bool {
// `rustc --test` may synthesize a const descriptor that shares the test
// function's name and source range. The wrapper function and descriptor can
// carry different syntax contexts, so this must compare source bytes
// rather than exact `Span` identity.
sibling.hir_id() != function_hir_id
&& matches!(sibling.kind, hir::ItemKind::Const(..))
&& sibling.kind.ident().is_some_and(|ident| {
ident.name == function_name && sibling.span.source_equal(function_span)
})
}

// Check if any attribute is #[test].
fn has_test_attribute(attrs: &[hir::Attribute]) -> bool {
has_test_like_hir_attributes(attrs, &[])
}

// Detect source-level test framework attributes.
//
// The `rustc --test` harness may consume the original built-in marker entirely
// and replace it with a sibling const descriptor. That recovery path is
// covered by the example-based regression in `lib_ui_tests.rs`; this helper
// still only inspects source-level HIR attributes.
#[cfg(test)]
fn is_test_attribute(attr: &hir::Attribute) -> bool {
has_test_like_hir_attributes(std::slice::from_ref(attr), &[])
}
mod test_context;

#[cfg(all(test, feature = "dylint-driver"))]
mod tests;
Loading
Loading