Skip to content
Merged
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
65 changes: 65 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ flate2 = "1.1"
serde_json = "1.0.149"
toml = "0.9.8"
thiserror = "2"
trybuild = "1.0.116"
log = "0.4.28"
sha2 = "0.10.9"
tar = "0.4.44"
Expand All @@ -37,6 +38,7 @@ zip = "2"
rstest = "0.26.1"
rstest-bdd = "0.5.0"
rstest-bdd-macros = "0.5.0"
proptest = "1"
rustc_lexer = "0.1.0"
whitaker_clones_core = { path = "crates/whitaker_clones_core", version = "0.2.5" }
whitaker_sarif = { path = "crates/whitaker_sarif", version = "0.2.5" }
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ For version pinning, installation details, and configuration options, see the

## The Lints

Whitaker currently ships eight lints, with more on the way:
Whitaker currently ships nine standard lints plus one experimental lint that
requires explicit opt-in.

| Lint | What it does |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -48,6 +49,10 @@ Whitaker currently ships eight lints, with more on the way:
| `no_unwrap_or_else_panic` | Catches sneaky panics hidden inside `unwrap_or_else` closures. If you're going to panic, at least be upfront about it. |
| `no_std_fs_operations` | Forbids `std::fs` operations, nudging you toward capability-based filesystem access via `cap_std`. |

Experimental lints are not enabled by default. The current experimental lint is
`rstest_helper_should_be_fixture`, which is available only when installer and
suite flows opt in with `--experimental` or the corresponding suite feature.

## Features

- **Localised diagnostics** — Messages available in English, Welsh (Cymraeg),
Expand Down
44 changes: 44 additions & 0 deletions crates/rstest_helper_should_be_fixture/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[package]
name = "rstest_helper_should_be_fixture"
version = "0.2.5"
edition = "2024"
publish = false
description = "Dylint lint that bootstraps rstest helper fixture extraction checks"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true

[lib]
crate-type = ["cdylib", "rlib"]
test = false

[features]
default = []
dylint-driver = [
"dep:whitaker-common",
"dep:dylint_linting",
"dep:log",
"dep:rustc_lint",
"dep:rustc_session",
"dep:serde",
"dep:whitaker",
]
constituent = ["dylint-driver", "dylint_linting/constituent"]

[dependencies]
whitaker-common = { workspace = true, optional = true }
dylint_linting = { workspace = true, optional = true }
log = { workspace = true, optional = true }
rustc_lint = { workspace = true, optional = true }
rustc_session = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
whitaker = { workspace = true, features = ["dylint-driver"], optional = true }

[dev-dependencies]
proptest = { workspace = true }
rstest = { workspace = true }
rstest-bdd = { workspace = true }
rstest-bdd-macros = { workspace = true }
toml = { workspace = true }
trybuild = { workspace = true }
208 changes: 208 additions & 0 deletions crates/rstest_helper_should_be_fixture/src/driver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! Dylint driver bootstrap for the `rstest` helper fixture lint.
//!
//! The driver owns compiler integration and configuration loading. Pure
//! configuration normalization stays in small helper methods so it can be
//! tested without constructing rustc contexts.

use log::debug;
use rustc_lint::{LateContext, LateLintPass};
use serde::Deserialize;
use whitaker::SharedConfig;
use whitaker_common::attributes::AttributePath;
use whitaker_common::i18n::{Localizer, get_localizer_for_lint};
use whitaker_common::rstest::RstestDetectionOptions;

const LINT_NAME: &str = "rstest_helper_should_be_fixture";

const DEFAULT_PROVIDER_PARAM_ATTRIBUTES: &[&str] =
&["case", "values", "files", "future", "context"];

type ConfigLoadResult = Result<Config, String>;

dylint_linting::impl_late_lint! {
pub RSTEST_HELPER_SHOULD_BE_FIXTURE,
Warn,
"repeated rstest helper calls should be extracted into fixtures",
RstestHelperShouldBeFixture::default()
}

/// Configuration for the `rstest_helper_should_be_fixture` lint.
///
/// Values are loaded from `dylint.toml` and normalized so threshold settings
/// keep repeated-helper semantics.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(default, deny_unknown_fields)]
struct Config {
min_calls: usize,
min_distinct_tests: usize,
require_identical_fixture_arg_names: bool,
provider_param_attributes: Vec<String>,
use_source_callee_fallback: bool,
}

impl Default for Config {
fn default() -> Self {
Self {
min_calls: 2,
min_distinct_tests: 2,
require_identical_fixture_arg_names: false,
provider_param_attributes: DEFAULT_PROVIDER_PARAM_ATTRIBUTES
.iter()
.map(ToString::to_string)
.collect(),
use_source_callee_fallback: false,
}
}
}

impl Config {
fn normalized(self) -> Self {
Self {
min_calls: self.min_calls.max(2),
min_distinct_tests: self.min_distinct_tests.max(2),
require_identical_fixture_arg_names: self.require_identical_fixture_arg_names,
provider_param_attributes: normalize_provider_attributes(
self.provider_param_attributes,
),
use_source_callee_fallback: self.use_source_callee_fallback,
}
}

fn detection_options(&self) -> RstestDetectionOptions {
let provider_param_attributes = self
.provider_param_attributes
.iter()
.flat_map(|attribute| {
[
AttributePath::from(attribute.as_str()),
AttributePath::from(format!("rstest::{attribute}")),
]
})
.collect();
RstestDetectionOptions::new(provider_param_attributes, self.use_source_callee_fallback)
}
}

/// Lint pass bootstrap for repeated `rstest` helper extraction.
pub struct RstestHelperShouldBeFixture {
config: Config,
detection_options: RstestDetectionOptions,
localizer: Localizer,
}

impl Default for RstestHelperShouldBeFixture {
fn default() -> Self {
let config = Config::default();
let detection_options = config.detection_options();
Self {
config,
detection_options,
localizer: Localizer::new(None),
}
}
}

impl RstestHelperShouldBeFixture {
fn apply_loaded_crate_configuration(
&mut self,
config: ConfigLoadResult,
shared_config: SharedConfig,
) {
let config = match config {
Ok(config) => config,
Err(error) => {
debug!(
target: LINT_NAME,
"failed to parse `{LINT_NAME}` configuration: {error}; using defaults"
);
Config::default()
}
};

self.apply_crate_configuration(config, shared_config);
}

fn apply_crate_configuration(&mut self, config: Config, shared_config: SharedConfig) {
debug!(
target: LINT_NAME,
"applying `{LINT_NAME}` configuration: min_calls={}, min_distinct_tests={}, \
require_identical_fixture_arg_names={}, provider_param_attributes={:?}, \
use_source_callee_fallback={}, locale={:?}",
config.min_calls,
config.min_distinct_tests,
config.require_identical_fixture_arg_names,
config.provider_param_attributes,
config.use_source_callee_fallback,
shared_config.locale(),
);
self.config = config;
self.detection_options = self.config.detection_options();
self.localizer = get_localizer_for_lint(LINT_NAME, shared_config.locale());
}
}

impl<'tcx> LateLintPass<'tcx> for RstestHelperShouldBeFixture {
fn check_crate(&mut self, _cx: &LateContext<'tcx>) {
self.apply_loaded_crate_configuration(load_configuration(), load_shared_config());
}
}

fn normalize_provider_attributes(attributes: Vec<String>) -> Vec<String> {
let mut normalized = Vec::new();
for attribute in attributes
.into_iter()
.map(|attribute| attribute.trim().trim_start_matches("rstest::").to_owned())
.filter(|attribute| !attribute.is_empty())
{
if !normalized.contains(&attribute) {
normalized.push(attribute);
}
}

if normalized.is_empty() {
return default_provider_param_attributes();
}

normalized
}

fn default_provider_param_attributes() -> Vec<String> {
DEFAULT_PROVIDER_PARAM_ATTRIBUTES
.iter()
.map(ToString::to_string)
.collect()
}

fn load_configuration() -> ConfigLoadResult {
debug!(target: LINT_NAME, "loading `{LINT_NAME}` configuration");
loaded_configuration(dylint_linting::config::<Config>(LINT_NAME))
}

fn load_shared_config() -> SharedConfig {
// SAFETY / NOTE: `SharedConfig::load` does not currently propagate I/O
// errors, so this named boundary documents the infallible call site
// pending https://github.com/leynos/whitaker/issues/233.
debug!(target: LINT_NAME, "loading shared Whitaker configuration");
SharedConfig::load()
}

fn loaded_configuration<E>(loaded: Result<Option<Config>, E>) -> ConfigLoadResult
where
E: std::fmt::Display,
{
match loaded {
Ok(Some(config)) => {
debug!(target: LINT_NAME, "loaded explicit `{LINT_NAME}` configuration");
Ok(config.normalized())
}
Ok(None) => {
debug!(target: LINT_NAME, "no `{LINT_NAME}` configuration found; using defaults");
Ok(Config::default())
}
Err(error) => Err(error.to_string()),
}
}

#[cfg(test)]
#[path = "driver_tests.rs"]
mod tests;
Loading
Loading