Skip to content

Commit 0643593

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 0643593

File tree

32 files changed

+506
-41
lines changed

32 files changed

+506
-41
lines changed

src/files.rs

Lines changed: 94 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,75 @@ 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+
/// Counts the non-trivia (non-whitespace, non-comment) tokens under a syntax node.
23+
fn count_non_trivia_tokens(node: &rnix::SyntaxNode) -> usize {
24+
node.descendants_with_tokens()
25+
.filter(|element| element.as_token().is_some_and(|t| !t.kind().is_trivia()))
26+
.count()
27+
}
28+
29+
/// Finds the first `with` expression in the syntax tree that is overly broad, meaning it either:
30+
///
31+
/// - Contains more than [`WITH_MAX_TOKENS`] non-trivia tokens, or
32+
/// - Covers more than [`WITH_MAX_FILE_FRACTION`] of the file's total non-trivia tokens.
33+
///
34+
/// Large `with` scopes shadow variables across a wide region, making static analysis unreliable
35+
/// and code harder to understand. Small, tightly-scoped uses (e.g. `with lib.maintainers; [...]`)
36+
/// are fine.
37+
///
38+
/// Returns `Some(node)` for the first offending `with` node, or `None` if no such node exists.
39+
fn find_overly_broad_with(syntax: &rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
40+
let file_tokens = count_non_trivia_tokens(syntax);
41+
42+
syntax
43+
.descendants()
44+
.filter(|node| node.kind() == SyntaxKind::NODE_WITH)
45+
.find(|node| {
46+
let with_tokens = count_non_trivia_tokens(node);
47+
with_tokens > WITH_MAX_TOKENS
48+
|| with_tokens as f64 > WITH_MAX_FILE_FRACTION * file_tokens as f64
49+
})
50+
}
51+
52+
/// Runs ratchet checks on all Nix files in the Nixpkgs tree, returning a ratchet result for each.
1253
pub fn check_files(
1354
nixpkgs_path: &Path,
1455
nix_file_store: &mut NixFileStore,
1556
) -> 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 {}))
57+
process_nix_files(nixpkgs_path, nix_file_store, |nix_file| {
58+
Ok(Success(ratchet::File {
59+
top_level_with: check_top_level_with(nixpkgs_path, nix_file),
60+
}))
1961
})
2062
}
2163

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(
64+
/// Checks a single Nix file for top-level `with` expressions that contain nested scope-defining
65+
/// constructs. Returns [`RatchetState::Loose`] with a problem if such a `with` is found, or
66+
/// [`RatchetState::Tight`] if the file is clean.
67+
fn check_top_level_with(
2568
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-
}))
69+
nix_file: &nix_file::NixFile,
70+
) -> RatchetState<ratchet::DoesNotIntroduceToplevelWiths> {
71+
if let Some(offending_with) = find_overly_broad_with(nix_file.syntax_root.syntax()) {
72+
let relative_path =
73+
RelativePathBuf::from_path(nix_file.path.clone().strip_prefix(nixpkgs_path).unwrap())
74+
.unwrap();
75+
RatchetState::Loose(
76+
npv_169::TopLevelWithMayShadowVariablesAndBreakStaticChecks::new(
77+
relative_path,
78+
offending_with.to_string(),
79+
)
80+
.into(),
81+
)
82+
} else {
83+
RatchetState::Tight
84+
}
5185
}
5286

5387
/// Recursively collects all Nix files in the relative `dir` within `base`
@@ -63,7 +97,7 @@ fn collect_nix_files(
6397

6498
let absolute_path = entry.path();
6599

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

src/problem/mod.rs

Lines changed: 7 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,11 @@ pub enum Problem {
131133
NewTopLevelPackageShouldBeByNameWithCustomArgument(
132134
npv_163::NewTopLevelPackageShouldBeByNameWithCustomArgument,
133135
),
136+
137+
/// NPV-169: top-level with may shadow variables and break static checks
138+
TopLevelWithMayShadowVariablesAndBreakStaticChecks(
139+
npv_169::TopLevelWithMayShadowVariablesAndBreakStaticChecks,
140+
),
134141
}
135142

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

src/problem/npv_169.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use relative_path::RelativePathBuf;
2+
use std::fmt;
3+
4+
#[derive(Clone)]
5+
pub struct TopLevelWithMayShadowVariablesAndBreakStaticChecks {
6+
file: RelativePathBuf,
7+
node: String,
8+
}
9+
10+
impl TopLevelWithMayShadowVariablesAndBreakStaticChecks {
11+
pub fn new(file: RelativePathBuf, node: String) -> Self {
12+
Self { file, node }
13+
}
14+
}
15+
16+
impl fmt::Display for TopLevelWithMayShadowVariablesAndBreakStaticChecks {
17+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
18+
let Self { file, node: _node } = self;
19+
write!(
20+
f,
21+
"- {file}: Top level with is discouraged as it may shadow variables and break static checks."
22+
)
23+
}
24+
}

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-basic/expected

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- test.nix: Top level with is discouraged as it may shadow variables and break static checks.
2+
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 = ./.; }

tests/nowith-basic/main/test.nix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
with lib;
2+
3+
2
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- test.nix: Top level with is discouraged as it may shadow variables and break static checks.
2+
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 = ./.; }
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
stdenv,
3+
lib,
4+
}:
5+
6+
stdenv.mkDerivation {
7+
pname = "test";
8+
version = "1.0";
9+
10+
src = ./.;
11+
12+
meta = with lib; {
13+
maintainers = [ maintainers.johndoe ];
14+
};
15+
}

0 commit comments

Comments
 (0)