Skip to content

Commit 3900888

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 3900888

File tree

32 files changed

+502
-41
lines changed

32 files changed

+502
-41
lines changed

src/files.rs

Lines changed: 91 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,72 @@ 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+
/// Finds the first `with` expression in the syntax tree whose body contains another scope-defining
17+
/// construct (`with`, `let...in`, or attribute set). Such `with` expressions are problematic because
18+
/// they shadow variables in the enclosing scope, making it impossible for static analysis tools to
19+
/// determine which bindings are in scope.
20+
///
21+
/// Returns `Some(node)` for the first offending `with` node, or `None` if no such node exists.
22+
fn find_invalid_withs(syntax: &rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
23+
syntax
24+
.descendants()
25+
.filter(|node| node.kind() == rnix::SyntaxKind::NODE_WITH)
26+
.filter(|node| {
27+
node.descendants()
28+
.map(|child| {
29+
// Skip the `with` node itself; we only care about its descendants.
30+
if child == *node {
31+
return None;
32+
}
33+
// Any of these nested constructs means the `with` may shadow bindings
34+
// that the inner scope tries to reference.
35+
match child.kind() {
36+
SyntaxKind::NODE_WITH => Some(node),
37+
SyntaxKind::NODE_LET_IN => Some(node),
38+
SyntaxKind::NODE_ATTR_SET => Some(node),
39+
_ => None,
40+
}
41+
})
42+
.any(|node| node.is_some())
43+
})
44+
.take(1)
45+
.last()
46+
}
47+
48+
/// Runs ratchet checks on all Nix files in the Nixpkgs tree, returning a ratchet result for each.
1249
pub fn check_files(
1350
nixpkgs_path: &Path,
1451
nix_file_store: &mut NixFileStore,
1552
) -> 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 {}))
53+
process_nix_files(nixpkgs_path, nix_file_store, |nix_file| {
54+
Ok(Success(ratchet::File {
55+
top_level_with: check_top_level_with(nixpkgs_path, nix_file),
56+
}))
1957
})
2058
}
2159

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

5384
/// Recursively collects all Nix files in the relative `dir` within `base`
@@ -63,7 +94,7 @@ fn collect_nix_files(
6394

6495
let absolute_path = entry.path();
6596

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

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Validated successfully
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)