Skip to content

Commit 32decc6

Browse files
philiptaronlucasew
andcommitted
NPV-169: detect top-level with expressions that shadow scope
Add a ratchet check that prevents introducing new top-level `with` expressions whose body contains attr sets, let-in bindings, or nested with expressions. Existing instances are grandfathered via the ratchet. Co-authored-by: Lucas Eduardo Wendt <lucas59356@gmail.com>
1 parent 3ec2e01 commit 32decc6

File tree

27 files changed

+585
-41
lines changed

27 files changed

+585
-41
lines changed

src/files.rs

Lines changed: 98 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
use crate::problem::npv_169;
2+
use crate::ratchet::RatchetState;
13
use relative_path::RelativePath;
24
use relative_path::RelativePathBuf;
5+
6+
use rnix::SyntaxKind;
7+
use rowan::ast::AstNode;
38
use std::collections::BTreeMap;
49
use std::path::Path;
510

@@ -8,46 +13,79 @@ use crate::validation::ResultIteratorExt;
813
use crate::validation::Validation::Success;
914
use crate::{nix_file, ratchet, structure, validation};
1015

11-
/// Runs check on all Nix files, returning a ratchet result for each
16+
/// The maximum number of non-trivia tokens allowed under a single `with` expression.
17+
const WITH_MAX_TOKENS: usize = 125;
18+
19+
/// The maximum fraction of a file's non-trivia tokens that a single `with` expression may cover.
20+
const WITH_MAX_FILE_FRACTION: f64 = 0.25;
21+
22+
/// Files with fewer than this many non-trivia tokens are exempt from the `with` check entirely.
23+
/// Small files don't benefit much from restricting `with` scope.
24+
const WITH_FILE_MIN_TOKENS: usize = 50;
25+
26+
/// Counts the non-trivia (non-whitespace, non-comment) tokens under a syntax node.
27+
fn count_non_trivia_tokens(node: &rnix::SyntaxNode) -> usize {
28+
node.descendants_with_tokens()
29+
.filter(|element| element.as_token().is_some_and(|t| !t.kind().is_trivia()))
30+
.count()
31+
}
32+
33+
/// Finds the first `with` expression in the syntax tree that is overly broad, meaning it either:
34+
///
35+
/// - Contains more than [`WITH_MAX_TOKENS`] non-trivia tokens, or
36+
/// - Covers more than [`WITH_MAX_FILE_FRACTION`] of the file's total non-trivia tokens.
37+
///
38+
/// Files with fewer than [`WITH_FILE_MIN_TOKENS`] non-trivia tokens are exempt.
39+
///
40+
/// Large `with` scopes shadow variables across a wide region, making static analysis unreliable
41+
/// and code harder to understand. Small, tightly-scoped uses (e.g. `with lib.maintainers; [...]`)
42+
/// are fine.
43+
///
44+
/// Returns `Some(node)` for the first offending `with` node, or `None` if no such node exists.
45+
fn find_overly_broad_with(syntax: &rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
46+
let file_tokens = count_non_trivia_tokens(syntax);
47+
48+
if file_tokens < WITH_FILE_MIN_TOKENS {
49+
return None;
50+
}
51+
52+
syntax
53+
.descendants()
54+
.filter(|node| node.kind() == SyntaxKind::NODE_WITH)
55+
.find(|node| {
56+
let with_tokens = count_non_trivia_tokens(node);
57+
with_tokens > WITH_MAX_TOKENS
58+
|| with_tokens as f64 > WITH_MAX_FILE_FRACTION * file_tokens as f64
59+
})
60+
}
61+
62+
/// Runs ratchet checks on all Nix files in the Nixpkgs tree, returning a ratchet result for each.
1263
pub fn check_files(
1364
nixpkgs_path: &Path,
1465
nix_file_store: &mut NixFileStore,
1566
) -> validation::Result<BTreeMap<RelativePathBuf, ratchet::File>> {
16-
process_nix_files(nixpkgs_path, nix_file_store, |_nix_file| {
17-
// Noop for now, only boilerplate to make it easier to add future file-based checks
18-
Ok(Success(ratchet::File {}))
67+
process_nix_files(nixpkgs_path, nix_file_store, |nix_file| {
68+
Ok(Success(ratchet::File {
69+
top_level_with: check_top_level_with(nixpkgs_path, nix_file),
70+
}))
1971
})
2072
}
2173

22-
/// Processes all Nix files in a Nixpkgs directory according to a given function `f`, collecting the
23-
/// results into a mapping from each file to a ratchet value.
24-
fn process_nix_files(
74+
/// Checks a single Nix file for top-level `with` expressions that contain nested scope-defining
75+
/// constructs. Returns [`RatchetState::Loose`] with a problem if such a `with` is found, or
76+
/// [`RatchetState::Tight`] if the file is clean.
77+
fn check_top_level_with(
2578
nixpkgs_path: &Path,
26-
nix_file_store: &mut NixFileStore,
27-
f: impl Fn(&nix_file::NixFile) -> validation::Result<ratchet::File>,
28-
) -> validation::Result<BTreeMap<RelativePathBuf, ratchet::File>> {
29-
// Get all Nix files
30-
let files = {
31-
let mut files = vec![];
32-
collect_nix_files(nixpkgs_path, &RelativePathBuf::new(), &mut files)?;
33-
files
34-
};
35-
36-
let results = files
37-
.into_iter()
38-
.map(|path| {
39-
// Get the (optionally-cached) parsed Nix file
40-
let nix_file = nix_file_store.get(&path.to_path(nixpkgs_path))?;
41-
let result = f(nix_file)?;
42-
let val = result.map(|ratchet| (path, ratchet));
43-
Ok::<_, anyhow::Error>(val)
44-
})
45-
.collect_vec()?;
46-
47-
Ok(validation::sequence(results).map(|entries| {
48-
// Convert the Vec to a BTreeMap
49-
entries.into_iter().collect()
50-
}))
79+
nix_file: &nix_file::NixFile,
80+
) -> RatchetState<ratchet::DoesNotIntroduceToplevelWiths> {
81+
if find_overly_broad_with(nix_file.syntax_root.syntax()).is_some() {
82+
let relative_path =
83+
RelativePathBuf::from_path(nix_file.path.clone().strip_prefix(nixpkgs_path).unwrap())
84+
.unwrap();
85+
RatchetState::Loose(npv_169::OverlyBroadWith::new(relative_path).into())
86+
} else {
87+
RatchetState::Tight
88+
}
5189
}
5290

5391
/// Recursively collects all Nix files in the relative `dir` within `base`
@@ -63,7 +101,7 @@ fn collect_nix_files(
63101

64102
let absolute_path = entry.path();
65103

66-
// We'll get to every file based on directory recursion, no need to follow symlinks.
104+
// We reach every file via directory recursion, no need to follow symlinks.
67105
if absolute_path.is_symlink() {
68106
continue;
69107
}
@@ -75,3 +113,30 @@ fn collect_nix_files(
75113
}
76114
Ok(())
77115
}
116+
117+
/// Processes all Nix files in a Nixpkgs directory according to a given function `f`, collecting the
118+
/// results into a mapping from each file to a ratchet value.
119+
fn process_nix_files<F: Fn(&nix_file::NixFile) -> validation::Result<ratchet::File>>(
120+
nixpkgs_path: &Path,
121+
nix_file_store: &mut NixFileStore,
122+
f: F,
123+
) -> validation::Result<BTreeMap<RelativePathBuf, ratchet::File>> {
124+
// Get all Nix files
125+
let files = {
126+
let mut files = vec![];
127+
collect_nix_files(nixpkgs_path, &RelativePathBuf::new(), &mut files)?;
128+
files
129+
};
130+
131+
let file_results: Vec<validation::Validation<(RelativePathBuf, ratchet::File)>> = files
132+
.into_iter()
133+
.map(|path| {
134+
// Get the (optionally-cached) parsed Nix file
135+
let nix_file = nix_file_store.get(&path.to_path(nixpkgs_path))?;
136+
let val = f(nix_file)?.map(|file| (path, file));
137+
Ok::<_, anyhow::Error>(val)
138+
})
139+
.collect_vec()?;
140+
141+
Ok(validation::sequence(file_results).map(|entries| entries.into_iter().collect()))
142+
}

src/problem/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ pub mod npv_161;
3636
pub mod npv_162;
3737
pub mod npv_163;
3838

39+
pub mod npv_169;
40+
3941
#[derive(Clone, Display, EnumFrom)]
4042
pub enum Problem {
4143
/// NPV-100: attribute is not defined but it should be defined automatically
@@ -131,6 +133,9 @@ pub enum Problem {
131133
NewTopLevelPackageShouldBeByNameWithCustomArgument(
132134
npv_163::NewTopLevelPackageShouldBeByNameWithCustomArgument,
133135
),
136+
137+
/// NPV-169: `with` expression covers too much of the file
138+
OverlyBroadWith(npv_169::OverlyBroadWith),
134139
}
135140

136141
fn indent_definition(column: usize, definition: &str) -> String {

src/problem/npv_169.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use std::fmt;
2+
3+
use indoc::writedoc;
4+
use relative_path::RelativePathBuf;
5+
6+
#[derive(Clone)]
7+
pub struct OverlyBroadWith {
8+
file: RelativePathBuf,
9+
}
10+
11+
impl OverlyBroadWith {
12+
pub fn new(file: RelativePathBuf) -> Self {
13+
Self { file }
14+
}
15+
}
16+
17+
impl fmt::Display for OverlyBroadWith {
18+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
19+
let Self { file } = self;
20+
writedoc!(
21+
f,
22+
"
23+
- {file}: `with` expression covers too much of the file.
24+
Large-scoped `with` expressions shadow bindings and make static analysis unreliable.
25+
Prefer one of these alternatives:
26+
- Use fully qualified names, e.g. `lib.mkOption` instead of `with lib; mkOption`
27+
- Use `inherit (lib) mkOption mkIf;` in a `let` block
28+
- Limit `with` to small scopes, e.g. `maintainers = with lib.maintainers; [ ... ]`
29+
",
30+
)
31+
}
32+
}

src/ratchet.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,21 @@ impl Package {
6262
}
6363
}
6464

65-
pub struct File {}
65+
/// The ratchet value for a single Nix file in the Nixpkgs tree.
66+
pub struct File {
67+
/// The ratchet value for the check that files do not introduce top-level `with` expressions
68+
/// that shadow scope.
69+
pub top_level_with: RatchetState<DoesNotIntroduceToplevelWiths>,
70+
}
6671

6772
impl File {
68-
/// Validates the ratchet checks for a top-level package
69-
pub fn compare(
70-
_name: &RelativePath,
71-
_optional_from: Option<&Self>,
72-
_to: &Self,
73-
) -> Validation<()> {
74-
Success(())
73+
/// Validates the ratchet checks for a Nix file.
74+
pub fn compare(name: &RelativePath, optional_from: Option<&Self>, to: &Self) -> Validation<()> {
75+
validation::sequence_([RatchetState::compare(
76+
name.as_str(),
77+
optional_from.map(|x| &x.top_level_with),
78+
&to.top_level_with,
79+
)])
7580
}
7681
}
7782

@@ -191,3 +196,20 @@ impl ToProblem for UsesByName {
191196
}
192197
}
193198
}
199+
200+
/// The ratchet value for the check that Nix files do not introduce top-level `with` expressions
201+
/// whose body contains nested scope-defining constructs (other `with`s, `let...in`, or attribute
202+
/// sets).
203+
///
204+
/// Such `with` expressions shadow variables in the enclosing scope, making static analysis
205+
/// unreliable. This ratchet is tight when a file has no such `with` expressions, and loose when
206+
/// one is found.
207+
pub enum DoesNotIntroduceToplevelWiths {}
208+
209+
impl ToProblem for DoesNotIntroduceToplevelWiths {
210+
type ToContext = Problem;
211+
212+
fn to_problem(_name: &str, _optional_from: Option<()>, to: &Self::ToContext) -> Problem {
213+
to.clone()
214+
}
215+
}

tests/nowith-absolute-cap/expected

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- test.nix: `with` expression covers too much of the file.
2+
Large-scoped `with` expressions shadow bindings and make static analysis unreliable.
3+
Prefer one of these alternatives:
4+
- Use fully qualified names, e.g. `lib.mkOption` instead of `with lib; mkOption`
5+
- Use `inherit (lib) mkOption mkIf;` in a `let` block
6+
- Limit `with` to small scopes, e.g. `maintainers = with lib.maintainers; [ ... ]`
7+
8+
This PR introduces additional instances of discouraged patterns as listed above. Merging is discouraged but would not break the base branch.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import <test-nixpkgs> { root = ./.; }

0 commit comments

Comments
 (0)