refactor(natu): remove custom Nix linter

This commit was merged in pull request #184.
This commit is contained in:
2026-10-13 21:59:41 -04:00
parent 08563c6769
commit 55f61f71d8
1104 changed files with 23 additions and 354220 deletions

View File

@@ -24,7 +24,6 @@ in
configs/nixos/hosts/neogaia/hardware-configuration.nix \
configs/nixos/hosts/pikachu/hardware-configuration.nix \
configs/nixos/hosts/zeus/hardware-configuration.nix \
src/natu/testdata \
third_party \
-- .
treefmt

View File

@@ -6,7 +6,6 @@ inputs.treefmt-nix.lib.evalModule inputs.nixpkgs.legacyPackages.${system} {
settings.formatter.nixfmt.excludes = [
"configs/nixos/hosts/*/hardware-configuration.nix"
"src/natu/testdata/*.nix"
"third_party/**"
];
}

View File

@@ -1,59 +0,0 @@
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
rust_library(
name = "natu_lib",
crate_name = "natu",
srcs = [
"ast.rs",
"file_store.rs",
"natu.rs",
"parser.rs",
"rules.rs",
"rules/let_order.rs",
"rules/list_to_attrs_map.rs",
"rules/manual_inherit.rs",
"rules/manual_inherit_from.rs",
"rules/redundant_let.rs",
"rules/redundant_parameter_forwarding.rs",
"rules/unquoted_uri.rs",
"scope_graph.rs",
],
deps = [
"//src/status:status",
"//third_party/crates:rnix",
"//third_party/crates/rowan-0.16.1:rowan",
],
)
rust_test(
name = "file_store_test",
srcs = ["file_store_test.rs"],
deps = [":natu_lib"],
)
rust_test(
name = "natu_test",
srcs = ["natu_test.rs"],
data = [
"testdata/clean.nix",
"testdata/invalid.nix",
"testdata/lint_findings.nix",
],
deps = [
":natu_lib",
"//src/status:status",
"//third_party/crates:googletest",
],
)
rust_binary(
name = "natu",
crate_name = "natu_cli",
srcs = ["natu_main.rs"],
deps = [
":natu_lib",
"//src/monorepo:monorepo",
"//src/status:status",
"//third_party/crates:clap",
],
)

View File

@@ -1,21 +0,0 @@
[package]
name = "natu"
version = "0.1.0"
edition = "2024"
[lib]
path = "natu.rs"
[[bin]]
name = "natu"
path = "natu_main.rs"
[dependencies]
clap = { workspace = true, features = ["derive"] }
monorepo = { workspace = true }
rnix = { workspace = true }
rowan = { workspace = true }
status = { workspace = true }
[dev-dependencies]
googletest = { workspace = true }

View File

@@ -1,92 +0,0 @@
use crate::FileId;
#[derive(Debug)]
pub struct ParsedFile {
pub file_id: FileId,
pub root: Expr,
pub nodes: Vec<Node>,
pub tokens: Vec<Token>,
pub errors: Vec<ParseError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub range: SourceRange,
pub data: NodeData,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SourceRange {
pub start: usize,
pub end: usize,
}
impl std::fmt::Display for SourceRange {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}..{}", self.start, self.end)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeData {
Binding(Binding),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Binding {
pub range: SourceRange,
pub key: Ident,
pub value: Expr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ident {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expr {
Ident(Ident),
Select(Select),
Apply(Apply),
Let {
range: SourceRange,
bindings: Vec<Binding>,
body: Box<Expr>,
},
AttrSet(Vec<Binding>),
Lambda {
parameters: Vec<Ident>,
body: Box<Expr>,
},
Other(Vec<Expr>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Select {
pub receiver: String,
pub attr: Ident,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Apply {
pub lambda: Box<Expr>,
pub argument: Box<Expr>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub range: SourceRange,
pub data: TokenData,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenData {
UnquotedUri(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub range: SourceRange,
pub message: String,
}

View File

@@ -1,33 +0,0 @@
use std::path::PathBuf;
#[derive(Debug, Default)]
pub struct FileStore {
files: Vec<SourceFile>,
}
impl FileStore {
pub fn add(&mut self, file: SourceFile) -> FileId {
let id = FileId(self.files.len());
self.files.push(file);
id
}
pub fn get(&self, id: FileId) -> &SourceFile {
&self.files[id.0]
}
}
impl From<Vec<SourceFile>> for FileStore {
fn from(files: Vec<SourceFile>) -> Self {
Self { files }
}
}
#[derive(Debug)]
pub struct SourceFile {
pub path: PathBuf,
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileId(usize);

View File

@@ -1,33 +0,0 @@
use std::path::PathBuf;
use natu::{FileStore, SourceFile};
fn source_file(path: &str, source: &str) -> SourceFile {
SourceFile {
path: PathBuf::from(path),
source: source.to_owned(),
}
}
#[test]
fn added_file_can_be_retrieved_by_id() {
let mut store = FileStore::default();
let id = store.add(source_file("first.nix", "{ inherit foo; }"));
let file = store.get(id);
assert_eq!(PathBuf::from("first.nix"), file.path);
assert_eq!("{ inherit foo; }", file.source);
}
#[test]
fn file_ids_refer_to_their_added_files() {
let mut store = FileStore::default();
let first = store.add(source_file("first.nix", "first"));
let second = store.add(source_file("second.nix", "second"));
assert_ne!(first, second);
assert_eq!("first", store.get(first).source);
assert_eq!("second", store.get(second).source);
}

View File

@@ -1,121 +0,0 @@
use std::collections::HashSet;
mod ast;
mod file_store;
mod parser;
mod rules;
mod scope_graph;
use scope_graph::ScopeGraph;
pub use file_store::{FileId, FileStore, SourceFile};
pub use rules::{Diagnostic, Finding};
#[derive(Debug)]
pub struct Report {
pub files: FileStore,
pub diagnostics: Vec<Diagnostic>,
}
impl Report {
pub fn is_clean(&self) -> bool {
self.diagnostics.is_empty()
}
}
impl std::fmt::Display for Report {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut reported_let_order_files = HashSet::new();
for diagnostic in &self.diagnostics {
if diagnostic.finding.rule_id == "let_order" {
if !reported_let_order_files.insert(diagnostic.file_id) {
continue;
}
let file = self.files.get(diagnostic.file_id);
writeln!(
formatter,
"{} [let_order] Let bindings must follow dependency order",
file.path.display()
)?;
let mut findings = self
.diagnostics
.iter()
.filter(|candidate| {
candidate.file_id == diagnostic.file_id
&& candidate.finding.rule_id == "let_order"
})
.collect::<Vec<_>>();
findings.sort_by_key(|finding| finding.finding.range.start);
for finding in findings {
let (start, _) = line_range(&file.source, finding.finding.range);
let expected_order =
finding.finding.expected_order.as_ref().expect("let order");
writeln!(
formatter,
" let at line {start}: {}",
expected_order.join(", ")
)?;
}
continue;
}
let file = self.files.get(diagnostic.file_id);
let (start, end) = line_range(&file.source, diagnostic.finding.range);
let location = if start == end {
start.to_string()
} else {
format!("{start}-{end}")
};
writeln!(formatter, "{}:{location} {diagnostic}", file.path.display())?;
}
Ok(())
}
}
fn line_range(source: &str, range: ast::SourceRange) -> (usize, usize) {
let line_at = |offset| {
source
.bytes()
.take(offset)
.filter(|byte| *byte == b'\n')
.count()
+ 1
};
let start = line_at(range.start);
let end = line_at(range.end);
(start, end)
}
/// Analyzes source files and returns the diagnostics Natu finds.
pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
let mut store = FileStore::default();
let mut diagnostics = Vec::new();
for file in files {
let file_id = store.add(file);
let file = store.get(file_id);
let parsed = parser::parse_file(file_id, &file.source);
if !parsed.errors.is_empty() {
return Err(status::Status::failed_precondition(format!(
"failed to parse {}",
file.path.display()
)));
}
let graph = ScopeGraph::from_file(&parsed);
diagnostics.extend(rules::run_rules(&parsed, &graph, &file.source));
}
Ok(Report {
files: store,
diagnostics,
})
}

View File

@@ -1,105 +0,0 @@
use clap::Parser;
use natu::{Report, SourceFile};
use status::ResultExt;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn main() -> ExitCode {
let args = Args::parse();
match analyze(args) {
Ok(report) if report.is_clean() => {
println!("No findings.");
ExitCode::SUCCESS
}
Ok(report) => {
print!("{report}");
ExitCode::from(1)
}
Err(error) => {
eprintln!("{error}");
ExitCode::from(2)
}
}
}
#[derive(Debug, Parser)]
struct Args {
#[arg(value_name = "PATH", default_value = ".")]
paths: Vec<PathBuf>,
}
fn analyze(args: Args) -> status::Result<Report> {
let paths: Vec<PathBuf> = args
.paths
.into_iter()
.map(monorepo::workspace::resolve_workspace_path)
.collect();
let files: Vec<SourceFile> = paths
.iter()
.map(|path| collect_source_files(path))
.collect::<status::Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
natu::future_sight(files)
}
fn collect_source_files(path: &Path) -> status::Result<Vec<SourceFile>> {
let metadata = std::fs::symlink_metadata(path)
.with_context(|| format!("failed to read metadata for {}", path.display()))?;
if metadata.file_type().is_dir() {
if is_excluded_dir(path) {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in std::fs::read_dir(path)
.with_context(|| format!("failed to read directory {}", path.display()))?
{
let entry =
entry.with_context(|| format!("failed to read entry in {}", path.display()))?;
files.extend(collect_source_files(&entry.path())?);
}
return Ok(files);
}
if metadata.file_type().is_file() && is_nix_file(path) {
return Ok(vec![SourceFile {
path: path.to_path_buf(),
source: std::fs::read_to_string(path)
.with_context(|| format!("failed to read source file {}", path.display()))?,
}]);
}
Ok(Vec::new())
}
// TODO: Add configurable exclusions.
fn is_excluded_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name,
".git"
| "bazel-bin"
| "bazel-dev"
| "bazel-out"
| "bazel-testlogs"
| "target"
| "testdata"
| "third_party"
) || name == "result"
|| name.starts_with("result-")
})
}
fn is_nix_file(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension == "nix")
}

View File

@@ -1,303 +0,0 @@
use std::path::{Path, PathBuf};
use googletest::matchers::{contains, eq, is_true};
use googletest::{OrFail, expect_that, gtest};
use status::Code;
use natu::SourceFile;
fn fixture_source(path: &str) -> googletest::Result<SourceFile> {
Ok(SourceFile {
path: PathBuf::from(path),
source: std::fs::read_to_string(fixture_path(path)?).or_fail()?,
})
}
fn fixture_path(path: &str) -> googletest::Result<PathBuf> {
if Path::new(path).exists() {
return Ok(PathBuf::from(path));
}
let runfiles = PathBuf::from(std::env::var("TEST_SRCDIR").or_fail()?);
let workspace = std::env::var("TEST_WORKSPACE").or_fail()?;
Ok(runfiles.join(workspace).join("src/natu").join(path))
}
#[gtest]
fn natu_accepts_clean_source() -> googletest::Result<()> {
let report = natu::future_sight(vec![fixture_source("testdata/clean.nix")?]).or_fail()?;
expect_that!(report.is_clean(), is_true());
Ok(())
}
#[gtest]
fn natu_rejects_invalid_source() -> googletest::Result<()> {
let error = natu::future_sight(vec![fixture_source("testdata/invalid.nix")?])
.err()
.or_fail()?;
expect_that!(error.code(), eq(Code::FailedPrecondition));
Ok(())
}
#[gtest]
fn natu_reports_out_of_order_let_bindings() -> googletest::Result<()> {
let report = natu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;
let let_order_count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "let_order")
.count();
expect_that!(let_order_count, eq(2));
expect_that!(
report.to_string(),
eq(concat!(
"testdata/lint_findings.nix:11 [manual_inherit] Use `inherit` for this assignment\n",
"testdata/lint_findings.nix:12 [manual_inherit_from] Use `inherit` for this selected attribute\n",
"testdata/lint_findings.nix:13 [unquoted_uri] URI literals should be quoted\n",
"testdata/lint_findings.nix [let_order] Let bindings must follow dependency order\n",
" let at line 1: first, second, nested\n",
" let at line 4: nestedSecond, nestedFirst\n",
))
);
Ok(())
}
#[gtest]
fn natu_preserves_suppressed_bindings() -> googletest::Result<()> {
let report = natu::future_sight(vec![SourceFile {
path: PathBuf::from("testdata/nolint.nix"),
source: "{ config, ... }: let # NOLINT(redundant_let)\n cfg = config.example; # NOLINT(let_order)\n value = cfg;\nin value\n".into(),
}])
.or_fail()?;
expect_that!(report.is_clean(), is_true());
Ok(())
}
#[gtest]
fn natu_reports_select_dependency_order() -> googletest::Result<()> {
let report = natu::future_sight(vec![SourceFile {
path: PathBuf::from("testdata/select_dependencies.nix"),
source: r#"
let
dependency = { value = 1; };
entry = dependency.value;
in
entry
"#
.into(),
}])
.or_fail()?;
let count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "let_order")
.count();
expect_that!(count, eq(1));
Ok(())
}
#[gtest]
fn natu_reports_redundant_let() -> googletest::Result<()> {
let report = natu::future_sight(vec![SourceFile {
path: PathBuf::from("testdata/redundant_let.nix"),
source: r#"
let
helper = 1;
value = helper;
in
value
"#
.into(),
}])
.or_fail()?;
let count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "redundant_let")
.count();
expect_that!(count, eq(1));
Ok(())
}
#[gtest]
fn natu_accepts_nonredundant_let() -> googletest::Result<()> {
let report = natu::future_sight(vec![
SourceFile {
path: PathBuf::from("testdata/recursive_let.nix"),
source: r#"
let
recursive = recursive;
in
recursive
"#
.into(),
},
SourceFile {
path: PathBuf::from("testdata/dependent_let.nix"),
source: r#"
let
value = 1;
dependent = value;
in
value
"#
.into(),
},
])
.or_fail()?;
let count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "redundant_let")
.count();
expect_that!(count, eq(0));
Ok(())
}
#[gtest]
fn natu_reports_redundant_parameter_forwarding() -> googletest::Result<()> {
let report = natu::future_sight(vec![
SourceFile {
path: PathBuf::from("testdata/attribute_parameter.nix"),
source: r#"
{ hosts, ... }:
let
collect = { hosts }: hosts;
in
collect { inherit hosts; }
"#
.into(),
},
SourceFile {
path: PathBuf::from("testdata/plain_parameter.nix"),
source: r#"
value:
let
transform = value: value;
in
transform value
"#
.into(),
},
SourceFile {
path: PathBuf::from("testdata/curried_parameter.nix"),
source: r#"
{ hosts, ... }:
let
transform = prefix: hosts: suffix: hosts;
in
transform "prefix" hosts "suffix"
"#
.into(),
},
SourceFile {
path: PathBuf::from("testdata/local_parameter.nix"),
source: r#"
let
transform = value: value;
in
transform value
"#
.into(),
},
])
.or_fail()?;
let count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "redundant_parameter_forwarding")
.count();
expect_that!(count, eq(3));
Ok(())
}
#[gtest]
fn natu_reports_list_to_attrs_after_map() -> googletest::Result<()> {
let report = natu::future_sight(vec![SourceFile {
path: PathBuf::from("testdata/list_to_attrs.nix"),
source: r#"
let
generated = lib.listToAttrs (
map (item: lib.nameValuePair item item) items
);
generated_pairs = lib.listToAttrs (
concatLists (
mapAttrsToList (name: _: lib.nameValuePair name name) attrs
)
);
precomputed = lib.listToAttrs pairs;
in
{
inherit generated generated_pairs precomputed;
}
"#
.into(),
}])
.or_fail()?;
let count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "list_to_attrs_map")
.count();
expect_that!(count, eq(1));
Ok(())
}
#[gtest]
fn natu_reports_unquoted_uri() -> googletest::Result<()> {
let report = natu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;
let findings = report
.diagnostics
.iter()
.map(|diagnostic| {
(
report.files.get(diagnostic.file_id).path.clone(),
diagnostic.finding.rule_id,
)
})
.collect::<Vec<_>>();
expect_that!(
findings,
contains(eq(&(
PathBuf::from("testdata/lint_findings.nix"),
"unquoted_uri"
)))
);
Ok(())
}
#[gtest]
fn natu_reports_manual_inherit() -> googletest::Result<()> {
let report = natu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;
let manual_inherit_count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "manual_inherit")
.count();
expect_that!(manual_inherit_count, eq(1));
Ok(())
}
#[gtest]
fn natu_reports_manual_inherit_from() -> googletest::Result<()> {
let report = natu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;
let manual_inherit_from_count = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "manual_inherit_from")
.count();
expect_that!(manual_inherit_from_count, eq(1));
Ok(())
}

View File

@@ -1,295 +0,0 @@
use rnix::ast::{Attr, HasEntry};
use rowan::ast::AstNode;
use crate::{FileId, ast};
pub fn parse_file(file_id: FileId, source: &str) -> ast::ParsedFile {
let parse = rnix::Root::parse(source);
let source_len = source.len();
let syntax = parse.syntax();
let root = rnix::ast::Root::cast(syntax.clone()).expect("parser root is a root node");
let root = convert_expr(root.expr().expect("parser root has an expression"));
let (nodes, tokens) = convert_syntax(syntax);
ast::ParsedFile {
file_id,
root,
nodes,
tokens,
errors: parse
.errors()
.iter()
.map(|error| convert_parse_error(error, source_len))
.collect(),
}
}
fn convert_syntax(syntax: rnix::SyntaxNode) -> (Vec<ast::Node>, Vec<ast::Token>) {
let mut nodes = Vec::new();
let mut tokens = Vec::new();
for element in syntax.descendants_with_tokens() {
match element {
rnix::NodeOrToken::Node(node) => nodes.extend(convert_node(node)),
rnix::NodeOrToken::Token(token) => tokens.extend(convert_token(token)),
}
}
(nodes, tokens)
}
fn convert_expr(expr: rnix::ast::Expr) -> ast::Expr {
match expr {
rnix::ast::Expr::Ident(ident) => ast::Expr::Ident(ident.into()),
rnix::ast::Expr::Select(select) => convert_select_expr(select),
rnix::ast::Expr::Apply(apply) => ast::Expr::Apply(ast::Apply {
lambda: Box::new(convert_expr(apply.lambda().expect("applications have a lambda"))),
argument: Box::new(convert_expr(
apply.argument().expect("applications have an argument"),
)),
}),
rnix::ast::Expr::LetIn(let_in) => ast::Expr::Let {
range: let_in.syntax().text_range().into(),
bindings: let_in
.attrpath_values()
.filter_map(convert_binding_ast)
.collect(),
body: Box::new(convert_expr(
let_in.body().expect("let expressions have bodies"),
)),
},
rnix::ast::Expr::AttrSet(attr_set) => ast::Expr::AttrSet(convert_attr_set(attr_set)),
rnix::ast::Expr::Lambda(lambda) => ast::Expr::Lambda {
parameters: lambda_parameters(&lambda),
body: Box::new(convert_expr(lambda.body().expect("lambdas have bodies"))),
},
expr => ast::Expr::Other(
expr.syntax()
.children()
.filter_map(rnix::ast::Expr::cast)
.map(convert_expr)
.collect(),
),
}
}
fn convert_select_expr(select: rnix::ast::Select) -> ast::Expr {
select.clone().try_into().map(ast::Expr::Select).unwrap_or_else(|_| {
ast::Expr::Other(
select
.syntax()
.children()
.filter_map(rnix::ast::Expr::cast)
.map(convert_expr)
.collect(),
)
})
}
fn convert_binding_ast(binding: rnix::ast::AttrpathValue) -> Option<ast::Binding> {
let mut attrs = binding.attrpath()?.attrs();
let Attr::Ident(key) = attrs.next()? else {
return None;
};
Some(ast::Binding {
range: binding.syntax().text_range().into(),
key: key.into(),
value: convert_expr(binding.value()?),
})
}
fn convert_attr_set(attr_set: rnix::ast::AttrSet) -> Vec<ast::Binding> {
attr_set
.entries()
.flat_map(|entry| match entry {
rnix::ast::Entry::AttrpathValue(binding) => {
convert_binding_ast(binding).into_iter().collect()
}
rnix::ast::Entry::Inherit(inherit) => convert_inherit(inherit),
})
.collect()
}
fn convert_inherit(inherit: rnix::ast::Inherit) -> Vec<ast::Binding> {
let value = inherit
.from()
.and_then(|from| from.expr())
.map(convert_expr);
inherit
.attrs()
.filter_map(|attr| match attr {
Attr::Ident(ident) => Some(ident),
_ => None,
})
.map(|key| ast::Binding {
range: key.syntax().text_range().into(),
value: value
.clone()
.unwrap_or_else(|| ast::Expr::Ident(key.clone().into())),
key: key.into(),
})
.collect()
}
fn lambda_parameters(lambda: &rnix::ast::Lambda) -> Vec<ast::Ident> {
match lambda.param() {
Some(rnix::ast::Param::IdentParam(parameter)) => {
parameter.ident().map(Into::into).into_iter().collect()
}
Some(rnix::ast::Param::Pattern(pattern)) => pattern
.pat_entries()
.filter_map(|entry| entry.ident())
.chain(pattern.pat_bind().and_then(|binding| binding.ident()))
.map(Into::into)
.collect(),
None => Vec::new(),
}
}
fn convert_node(node: rnix::SyntaxNode) -> Option<ast::Node> {
match node.kind() {
rnix::SyntaxKind::NODE_ATTRPATH_VALUE => convert_binding(node),
_ => None,
}
}
fn convert_binding(node: rnix::SyntaxNode) -> Option<ast::Node> {
let binding = rnix::ast::AttrpathValue::cast(node)?;
let range = binding.syntax().text_range().into();
let mut attrs = binding.attrpath()?.attrs();
let Attr::Ident(key) = attrs.next()? else {
return None;
};
if attrs.next().is_some() {
return None;
}
Some(ast::Node {
range,
data: ast::NodeData::Binding(ast::Binding {
range,
key: key.into(),
value: convert_expr(binding.value()?),
}),
})
}
impl TryFrom<rnix::ast::AttrpathValue> for ast::Binding {
type Error = ();
fn try_from(binding: rnix::ast::AttrpathValue) -> Result<Self, Self::Error> {
let mut attrs = binding.attrpath().ok_or(())?.attrs();
let key = match attrs.next().ok_or(())? {
Attr::Ident(ident) => ident.into(),
_ => return Err(()),
};
if attrs.next().is_some() {
return Err(());
}
let value = binding.value().ok_or(())?.try_into()?;
Ok(Self {
range: binding.syntax().text_range().into(),
key,
value,
})
}
}
impl TryFrom<rnix::ast::Expr> for ast::Expr {
type Error = ();
fn try_from(expr: rnix::ast::Expr) -> Result<Self, Self::Error> {
match expr {
rnix::ast::Expr::Ident(ident) => Ok(Self::Ident(ident.into())),
rnix::ast::Expr::Select(select) => Ok(Self::Select(select.try_into()?)),
_ => Err(()),
}
}
}
impl From<rnix::ast::Ident> for ast::Ident {
fn from(ident: rnix::ast::Ident) -> Self {
Self {
name: ident.to_string(),
}
}
}
impl TryFrom<rnix::ast::Select> for ast::Select {
type Error = ();
fn try_from(select: rnix::ast::Select) -> Result<Self, Self::Error> {
if select.or_token().is_some() {
return Err(());
}
let mut attrs = select.attrpath().ok_or(())?.attrs();
let attr = match attrs.next().ok_or(())? {
Attr::Ident(ident) => ident.into(),
_ => return Err(()),
};
if attrs.next().is_some() {
return Err(());
}
Ok(Self {
receiver: select.expr().ok_or(())?.syntax().to_string(),
attr,
})
}
}
fn convert_token(token: rnix::SyntaxToken) -> Option<ast::Token> {
match token.kind() {
rnix::SyntaxKind::TOKEN_URI => Some(ast::Token {
range: token.text_range().into(),
data: ast::TokenData::UnquotedUri(token.text().to_string()),
}),
_ => None,
}
}
fn convert_parse_error(error: &rnix::ParseError, source_len: usize) -> ast::ParseError {
ast::ParseError {
range: parse_error_range(error, source_len),
message: error.to_string(),
}
}
fn parse_error_range(error: &rnix::ParseError, source_len: usize) -> ast::SourceRange {
match error {
rnix::ParseError::Unexpected(range)
| rnix::ParseError::UnexpectedExtra(range)
| rnix::ParseError::UnexpectedWanted(_, range, _)
| rnix::ParseError::UnexpectedDoubleBind(range)
| rnix::ParseError::DuplicatedArgs(range, _) => (*range).into(),
rnix::ParseError::UnexpectedEOF
| rnix::ParseError::UnexpectedEOFWanted(_)
| rnix::ParseError::RecursionLimitExceeded => ast::SourceRange {
start: source_len,
end: source_len,
},
_ => ast::SourceRange {
start: source_len,
end: source_len,
},
}
}
impl From<rnix::TextRange> for ast::SourceRange {
fn from(range: rnix::TextRange) -> Self {
Self {
start: usize::from(range.start()),
end: usize::from(range.end()),
}
}
}

View File

@@ -1,187 +0,0 @@
use crate::FileId;
use crate::ast;
use crate::rules::list_to_attrs_map::ListToAttrsMap;
use crate::rules::manual_inherit::ManualInherit;
use crate::rules::manual_inherit_from::ManualInheritFrom;
use crate::rules::unquoted_uri::UnquotedUri;
use crate::scope_graph::ScopeGraph;
pub mod let_order;
pub mod list_to_attrs_map;
pub mod manual_inherit;
pub mod manual_inherit_from;
pub mod redundant_let;
pub mod redundant_parameter_forwarding;
pub mod unquoted_uri;
#[derive(Debug)]
pub struct Diagnostic {
pub file_id: FileId,
pub finding: Finding,
}
#[derive(Debug)]
pub struct Finding {
pub rule_id: &'static str,
pub range: ast::SourceRange,
pub message: &'static str,
pub binding: Option<String>,
pub expected_order: Option<Vec<String>>,
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"[{}] {}",
self.finding.rule_id, self.finding.message
)?;
if let Some(binding) = &self.finding.binding {
write!(formatter, ": {binding}")?;
}
Ok(())
}
}
pub trait NodeRule {
type Node;
const ID: &'static str;
fn select(data: &ast::NodeData) -> Option<&Self::Node>;
fn check(node: &Self::Node) -> Option<&'static str>;
}
pub trait TokenRule {
type Token;
const ID: &'static str;
fn select(data: &ast::TokenData) -> Option<&Self::Token>;
fn check(token: &Self::Token) -> Option<&'static str>;
}
pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, source: &str) -> Vec<Diagnostic> {
let node_diagnostics = file
.nodes
.iter()
.flat_map(|node| {
[
run_node_rule::<ListToAttrsMap>(node),
run_node_rule::<ManualInherit>(node),
run_node_rule::<ManualInheritFrom>(node),
]
})
.flatten();
let token_diagnostics = file
.tokens
.iter()
.flat_map(|token| [run_token_rule::<UnquotedUri>(token)])
.flatten();
let let_order_diagnostics =
let_order::check(graph, source)
.into_iter()
.map(|finding| Finding {
rule_id: "let_order",
range: finding.range,
message: "Let bindings must follow dependency order",
binding: None,
expected_order: Some(finding.expected_order),
});
let redundant_let_diagnostics =
redundant_let::check(&file.root)
.into_iter()
.map(|finding| Finding {
rule_id: "redundant_let",
range: finding.range,
message: "Inline this returned let binding",
binding: None,
expected_order: None,
});
let redundant_parameter_forwarding_diagnostics =
redundant_parameter_forwarding::check(&file.root)
.into_iter()
.map(|finding| Finding {
rule_id: "redundant_parameter_forwarding",
range: finding.range,
message: "Use the outer parameter instead of forwarding it",
binding: None,
expected_order: None,
});
node_diagnostics
.chain(token_diagnostics)
.chain(let_order_diagnostics)
.chain(redundant_let_diagnostics)
.chain(redundant_parameter_forwarding_diagnostics)
.filter(|finding| !is_suppressed(source, finding.range, finding.rule_id))
.map(|finding| Diagnostic {
file_id: file.file_id,
finding,
})
.collect()
}
pub fn is_suppressed(source: &str, range: ast::SourceRange, rule_id: &str) -> bool {
let line = source[range.start..].lines().next().unwrap_or_default();
line_suppresses(line, rule_id) || preceding_line_suppresses(source, range, rule_id)
}
fn preceding_line_suppresses(source: &str, range: ast::SourceRange, rule_id: &str) -> bool {
let Some((preceding_source, _current_line)) = source[..range.start].rsplit_once('\n') else {
return false;
};
let preceding_line = preceding_source.lines().next_back().unwrap_or_default();
line_suppresses(preceding_line, rule_id)
}
fn line_suppresses(line: &str, rule_id: &str) -> bool {
let Some(comment) = line.split_once('#').map(|(_, comment)| comment.trim()) else {
return false;
};
if comment == "NOLINT" {
return true;
}
comment
.strip_prefix("NOLINT(")
.and_then(|rules| rules.strip_suffix(')'))
.is_some_and(|rules| {
rules
.split(',')
.any(|candidate| candidate.trim() == rule_id)
})
}
fn run_node_rule<R>(node: &ast::Node) -> Option<Finding>
where
R: NodeRule,
{
let typed_node = R::select(&node.data)?;
let message = R::check(typed_node)?;
Some(Finding {
rule_id: R::ID,
range: node.range,
message,
binding: None,
expected_order: None,
})
}
fn run_token_rule<R>(token: &ast::Token) -> Option<Finding>
where
R: TokenRule,
{
let typed_token = R::select(&token.data)?;
let message = R::check(typed_token)?;
Some(Finding {
rule_id: R::ID,
range: token.range,
message,
binding: None,
expected_order: None,
})
}

View File

@@ -1,87 +0,0 @@
use crate::{
ast,
rules::is_suppressed,
scope_graph::{LetScope, NodeId, ScopeGraph},
};
pub struct Finding {
pub range: ast::SourceRange,
pub expected_order: Vec<String>,
}
pub fn check(graph: &ScopeGraph, source: &str) -> Vec<Finding> {
graph
.let_scopes()
.iter()
.flat_map(|let_scope| {
let expected = expected_order(graph, let_scope)
.into_iter()
.filter(|declaration| !binding_suppressed(let_scope, *declaration, source))
.collect::<Vec<_>>();
let actual = let_scope
.bindings
.iter()
.filter(|binding| !is_suppressed(source, binding.range, "let_order"))
.map(|binding| binding.declaration)
.collect::<Vec<_>>();
(!actual.starts_with(&expected)).then(|| Finding {
range: let_scope.range,
expected_order: expected
.iter()
.map(|declaration| binding_name(let_scope, *declaration).to_owned())
.collect(),
})
})
.collect()
}
fn binding_suppressed(let_scope: &LetScope, declaration: NodeId, source: &str) -> bool {
let binding = let_scope
.bindings
.iter()
.find(|binding| binding.declaration == declaration)
.expect("let declaration");
is_suppressed(source, binding.range, "let_order")
}
fn binding_name(let_scope: &LetScope, declaration: NodeId) -> &str {
&let_scope
.bindings
.iter()
.find(|binding| binding.declaration == declaration)
.expect("let declaration")
.name
}
fn expected_order(graph: &ScopeGraph, let_scope: &LetScope) -> Vec<NodeId> {
let mut order = Vec::new();
visit_references(graph, &let_scope.entry_references, let_scope, &mut order);
order
}
fn visit_references(
graph: &ScopeGraph,
references: &[NodeId],
let_scope: &LetScope,
order: &mut Vec<NodeId>,
) {
for reference in references {
for declaration in graph.resolve(*reference) {
let Some(binding) = let_scope
.bindings
.iter()
.find(|binding| binding.declaration == declaration)
else {
continue;
};
if order.contains(&declaration) {
continue;
}
order.push(declaration);
visit_references(graph, &binding.references, let_scope, order);
}
}
}

View File

@@ -1,58 +0,0 @@
use crate::{ast, rules::NodeRule};
pub struct ListToAttrsMap;
impl NodeRule for ListToAttrsMap {
type Node = ast::Binding;
const ID: &'static str = "list_to_attrs_map";
fn select(data: &ast::NodeData) -> Option<&Self::Node> {
let ast::NodeData::Binding(binding) = data;
Some(binding)
}
fn check(node: &Self::Node) -> Option<&'static str> {
matches_list_to_attrs_map(&node.value)
.then_some("Use `lib.genAttrs'` instead of `lib.listToAttrs` after `map`")
}
}
fn matches_list_to_attrs_map(expr: &ast::Expr) -> bool {
let ast::Expr::Apply(application) = unwrap_expr(expr) else {
return false;
};
is_lib_list_to_attrs(&application.lambda) && is_map_application(&application.argument)
}
fn unwrap_expr(expr: &ast::Expr) -> &ast::Expr {
match expr {
ast::Expr::Other(expressions) if expressions.len() == 1 => unwrap_expr(&expressions[0]),
_ => expr,
}
}
fn is_lib_list_to_attrs(expr: &ast::Expr) -> bool {
matches!(
unwrap_expr(expr),
ast::Expr::Select(select) if select.receiver == "lib" && select.attr.name == "listToAttrs"
)
}
fn is_map_application(expr: &ast::Expr) -> bool {
let ast::Expr::Apply(application) = unwrap_expr(expr) else {
return false;
};
is_map(&application.lambda)
}
fn is_map(expr: &ast::Expr) -> bool {
match unwrap_expr(expr) {
ast::Expr::Ident(ident) => ident.name == "map",
ast::Expr::Apply(application) => is_map(&application.lambda),
_ => false,
}
}

View File

@@ -1,24 +0,0 @@
use crate::{ast, rules::NodeRule};
pub struct ManualInherit;
impl NodeRule for ManualInherit {
type Node = ast::Binding;
const ID: &'static str = "manual_inherit";
fn select(data: &ast::NodeData) -> Option<&Self::Node> {
let ast::NodeData::Binding(binding) = data;
Some(binding)
}
fn check(node: &Self::Node) -> Option<&'static str> {
match &node.value {
ast::Expr::Ident(value) if node.key == *value => {
Some("Use `inherit` for this assignment")
}
_ => None,
}
}
}

View File

@@ -1,24 +0,0 @@
use crate::{ast, rules::NodeRule};
pub struct ManualInheritFrom;
impl NodeRule for ManualInheritFrom {
type Node = ast::Binding;
const ID: &'static str = "manual_inherit_from";
fn select(data: &ast::NodeData) -> Option<&Self::Node> {
let ast::NodeData::Binding(binding) = data;
Some(binding)
}
fn check(node: &Self::Node) -> Option<&'static str> {
match &node.value {
ast::Expr::Select(select) if node.key == select.attr => {
Some("Use `inherit` for this selected attribute")
}
_ => None,
}
}
}

View File

@@ -1,98 +0,0 @@
use crate::ast;
pub struct Finding {
pub range: ast::SourceRange,
}
pub fn check(expr: &ast::Expr) -> Vec<Finding> {
let mut findings = Vec::new();
check_expr(expr, &mut findings);
findings
}
fn check_expr(expr: &ast::Expr, findings: &mut Vec<Finding>) {
match expr {
ast::Expr::Let {
range,
bindings,
body,
} => {
if is_redundant(bindings, body) {
findings.push(Finding { range: *range });
}
for binding in bindings {
check_expr(&binding.value, findings);
}
check_expr(body, findings);
}
ast::Expr::AttrSet(bindings) => {
for binding in bindings {
check_expr(&binding.value, findings);
}
}
ast::Expr::Lambda { body, .. } => check_expr(body, findings),
ast::Expr::Apply(application) => {
check_expr(&application.lambda, findings);
check_expr(&application.argument, findings);
}
ast::Expr::Other(expressions) => {
for expression in expressions {
check_expr(expression, findings);
}
}
ast::Expr::Ident(_) | ast::Expr::Select(_) => { }
}
}
fn is_redundant(bindings: &[ast::Binding], body: &ast::Expr) -> bool {
let Some((binding_index, binding)) = bindings
.iter()
.enumerate()
.find(|(_, binding)| is_ident(body, &binding.key.name))
else {
return false;
};
!references_name(&binding.value, &binding.key.name)
&& !bindings.iter().enumerate().any(|(candidate_index, candidate)| {
candidate_index != binding_index && references_name(&candidate.value, &binding.key.name)
})
}
fn is_ident(expr: &ast::Expr, name: &str) -> bool {
matches!(unwrap_expr(expr), ast::Expr::Ident(ident) if ident.name == name)
}
fn references_name(expr: &ast::Expr, name: &str) -> bool {
match expr {
ast::Expr::Ident(ident) => ident.name == name,
ast::Expr::Select(select) => select.receiver == name,
ast::Expr::Apply(application) => {
references_name(&application.lambda, name) || references_name(&application.argument, name)
}
ast::Expr::Let { bindings, body, .. } => {
!bindings.iter().any(|binding| binding.key.name == name)
&& (bindings
.iter()
.any(|binding| references_name(&binding.value, name))
|| references_name(body, name))
}
ast::Expr::AttrSet(bindings) => bindings
.iter()
.any(|binding| references_name(&binding.value, name)),
ast::Expr::Lambda { parameters, body } => {
!parameters.iter().any(|parameter| parameter.name == name)
&& references_name(body, name)
}
ast::Expr::Other(expressions) => expressions
.iter()
.any(|expression| references_name(expression, name)),
}
}
fn unwrap_expr(expr: &ast::Expr) -> &ast::Expr {
match expr {
ast::Expr::Other(expressions) if expressions.len() == 1 => unwrap_expr(&expressions[0]),
_ => expr,
}
}

View File

@@ -1,244 +0,0 @@
use crate::ast;
pub struct Finding {
pub range: ast::SourceRange,
}
pub fn check(expr: &ast::Expr) -> Vec<Finding> {
let mut findings = Vec::new();
check_expr(expr, &[], &mut findings);
findings
}
fn check_expr(expr: &ast::Expr, outer_parameters: &[ast::Ident], findings: &mut Vec<Finding>) {
match expr {
ast::Expr::Let { bindings, body, .. } => {
check_let(bindings, body, outer_parameters, findings);
for binding in bindings {
check_expr(&binding.value, outer_parameters, findings);
}
check_expr(body, outer_parameters, findings);
}
ast::Expr::AttrSet(bindings) => {
for binding in bindings {
check_expr(&binding.value, outer_parameters, findings);
}
}
ast::Expr::Lambda { parameters, body } => check_expr(body, parameters, findings),
ast::Expr::Apply(application) => {
check_expr(&application.lambda, outer_parameters, findings);
check_expr(&application.argument, outer_parameters, findings);
}
ast::Expr::Other(expressions) => {
for expression in expressions {
check_expr(expression, outer_parameters, findings);
}
}
ast::Expr::Ident(_) | ast::Expr::Select(_) => { }
}
}
fn check_let(
bindings: &[ast::Binding],
let_body: &ast::Expr,
outer_parameters: &[ast::Ident],
findings: &mut Vec<Finding>,
) {
for binding in bindings {
let parameters = curried_parameters(&binding.value);
for (parameter_index, parameter) in parameters.iter().enumerate() {
if !outer_parameters
.iter()
.any(|outer_parameter| outer_parameter.name == parameter.name)
{
continue;
}
let usage = helper_usage(
bindings,
let_body,
&binding.key.name,
&parameter.name,
parameter_index,
);
if usage.calls > 0 && !usage.invalid {
findings.push(Finding {
range: binding.range,
});
break;
}
}
}
}
#[derive(Default)]
struct Usage {
calls: usize,
invalid: bool,
}
fn helper_usage(
bindings: &[ast::Binding],
body: &ast::Expr,
helper_name: &str,
parameter_name: &str,
parameter_index: usize,
) -> Usage {
let mut usage = Usage::default();
for binding in bindings {
inspect_usage(
&binding.value,
helper_name,
parameter_name,
parameter_index,
&mut usage,
);
}
inspect_usage(body, helper_name, parameter_name, parameter_index, &mut usage);
usage
}
fn inspect_usage(
expr: &ast::Expr,
helper_name: &str,
parameter_name: &str,
parameter_index: usize,
usage: &mut Usage,
) {
match expr {
ast::Expr::Ident(ident) => {
if ident.name == helper_name {
usage.invalid = true;
}
}
ast::Expr::Select(_) => { }
ast::Expr::Apply(_) => {
let (lambda, arguments) = application_parts(expr);
if is_ident(lambda, helper_name) {
let Some(argument) = arguments.get(parameter_index) else {
usage.invalid = true;
return;
};
if is_forwarded_parameter(argument, parameter_name) {
usage.calls += 1;
} else {
usage.invalid = true;
}
for argument in arguments {
inspect_usage(
argument,
helper_name,
parameter_name,
parameter_index,
usage,
);
}
} else {
for argument in arguments {
inspect_usage(
argument,
helper_name,
parameter_name,
parameter_index,
usage,
);
}
inspect_usage(lambda, helper_name, parameter_name, parameter_index, usage);
}
}
ast::Expr::Let { bindings, body, .. } => {
if bindings.iter().any(|binding| {
binding.key.name == helper_name || binding.key.name == parameter_name
}) {
return;
}
for binding in bindings {
inspect_usage(
&binding.value,
helper_name,
parameter_name,
parameter_index,
usage,
);
}
inspect_usage(body, helper_name, parameter_name, parameter_index, usage);
}
ast::Expr::AttrSet(bindings) => {
for binding in bindings {
inspect_usage(
&binding.value,
helper_name,
parameter_name,
parameter_index,
usage,
);
}
}
ast::Expr::Lambda { parameters, body } => {
if parameters.iter().any(|parameter| {
parameter.name == helper_name || parameter.name == parameter_name
}) {
return;
}
inspect_usage(body, helper_name, parameter_name, parameter_index, usage);
}
ast::Expr::Other(expressions) => {
for expression in expressions {
inspect_usage(
expression,
helper_name,
parameter_name,
parameter_index,
usage,
);
}
}
}
}
fn curried_parameters(expr: &ast::Expr) -> Vec<&ast::Ident> {
let mut parameters = Vec::new();
let mut body = expr;
while let ast::Expr::Lambda {
parameters: lambda_parameters,
body: lambda_body,
} = body
{
parameters.extend(lambda_parameters);
body = lambda_body;
}
parameters
}
fn application_parts(expr: &ast::Expr) -> (&ast::Expr, Vec<&ast::Expr>) {
let mut arguments = Vec::new();
let mut lambda = unwrap_expr(expr);
while let ast::Expr::Apply(application) = lambda {
arguments.push(&*application.argument);
lambda = unwrap_expr(&application.lambda);
}
arguments.reverse();
(lambda, arguments)
}
fn is_ident(expr: &ast::Expr, name: &str) -> bool {
matches!(unwrap_expr(expr), ast::Expr::Ident(ident) if ident.name == name)
}
fn is_forwarded_parameter(expr: &ast::Expr, parameter_name: &str) -> bool {
match unwrap_expr(expr) {
ast::Expr::Ident(ident) => ident.name == parameter_name,
ast::Expr::AttrSet(bindings) => matches!(
bindings.as_slice(),
[binding] if binding.key.name == parameter_name && is_ident(&binding.value, parameter_name)
),
_ => false,
}
}
fn unwrap_expr(expr: &ast::Expr) -> &ast::Expr {
match expr {
ast::Expr::Other(expressions) if expressions.len() == 1 => unwrap_expr(&expressions[0]),
_ => expr,
}
}

View File

@@ -1,19 +0,0 @@
use crate::{ast, rules::TokenRule};
pub struct UnquotedUri;
impl TokenRule for UnquotedUri {
type Token = String;
const ID: &'static str = "unquoted_uri";
fn select(data: &ast::TokenData) -> Option<&Self::Token> {
match data {
ast::TokenData::UnquotedUri(uri) => Some(uri),
}
}
fn check(_token: &Self::Token) -> Option<&'static str> {
Some("URI literals should be quoted")
}
}

View File

@@ -1,206 +0,0 @@
use crate::ast::{self, ParsedFile};
#[derive(Debug)]
pub(crate) struct ScopeGraph {
nodes: Vec<Node>,
edges: Vec<Edge>,
lets: Vec<LetScope>,
}
impl ScopeGraph {
pub(crate) fn from_file(file: &ParsedFile) -> Self {
let mut graph = Self {
nodes: Vec::new(),
edges: Vec::new(),
lets: Vec::new(),
};
let root = graph.add_scope(None);
graph.build_expr(&file.root, root);
graph
}
pub(crate) fn let_scopes(&self) -> &[LetScope] {
&self.lets
}
fn build_expr(&mut self, expr: &ast::Expr, scope: NodeId) -> Vec<NodeId> {
match expr {
ast::Expr::Ident(ident) => vec![self.add_reference(&ident.name, scope)],
ast::Expr::Let {
range,
bindings,
body,
} => self.build_let(*range, bindings, body, scope),
ast::Expr::AttrSet(bindings) => bindings
.iter()
.flat_map(|binding| self.build_expr(&binding.value, scope))
.collect(),
ast::Expr::Lambda { parameters, body } => {
let scope = self.add_scope(Some(scope));
for parameter in parameters {
self.add_declaration(&parameter.name, scope);
}
self.build_expr(body, scope)
}
ast::Expr::Apply(application) => [
self.build_expr(&application.lambda, scope),
self.build_expr(&application.argument, scope),
]
.concat(),
ast::Expr::Other(expressions) => expressions
.iter()
.flat_map(|expression| self.build_expr(expression, scope))
.collect(),
ast::Expr::Select(select) => vec![self.add_reference(&select.receiver, scope)],
}
}
fn build_let(
&mut self,
range: ast::SourceRange,
bindings: &[ast::Binding],
body: &ast::Expr,
parent: NodeId,
) -> Vec<NodeId> {
let scope = self.add_scope(Some(parent));
let mut graph_bindings = bindings
.iter()
.map(|binding| Binding {
declaration: self.add_declaration(&binding.key.name, scope),
name: binding.key.name.clone(),
references: Vec::new(),
range: binding.range,
})
.collect::<Vec<_>>();
for (binding, graph_binding) in bindings.iter().zip(&mut graph_bindings) {
graph_binding.references = self.build_expr(&binding.value, scope);
}
let entry_references = self.build_expr(body, scope);
let references = graph_bindings
.iter()
.flat_map(|binding| binding.references.iter().copied())
.chain(entry_references.iter().copied())
.collect();
self.lets.push(LetScope {
range,
bindings: graph_bindings,
entry_references,
});
references
}
pub(crate) fn resolve(&self, reference: NodeId) -> Vec<NodeId> {
let Node::Reference(name) = &self.nodes[reference.0] else {
return Vec::new();
};
let Some(mut scope) = self.edge_target(reference, Node::is_scope) else {
return Vec::new();
};
loop {
let declarations = self
.edge_targets(scope, |node| matches!(node, Node::Declaration(declaration) if declaration.name == *name))
.collect::<Vec<_>>();
if !declarations.is_empty() {
return declarations;
}
let Some(parent) = self.edge_target(scope, Node::is_scope) else {
return Vec::new();
};
scope = parent;
}
}
fn add_scope(&mut self, parent: Option<NodeId>) -> NodeId {
let scope = self.add_node(Node::Scope);
if let Some(parent) = parent {
self.add_edge(scope, parent);
}
scope
}
fn add_declaration(&mut self, name: &str, scope: NodeId) -> NodeId {
let declaration = self.add_node(Node::Declaration(Declaration {
name: name.to_owned(),
}));
self.add_edge(scope, declaration);
declaration
}
fn add_reference(&mut self, name: &str, scope: NodeId) -> NodeId {
let reference = self.add_node(Node::Reference(name.to_owned()));
self.add_edge(reference, scope);
reference
}
fn add_node(&mut self, node: Node) -> NodeId {
let id = NodeId(self.nodes.len());
self.nodes.push(node);
id
}
fn add_edge(&mut self, from: NodeId, to: NodeId) {
self.edges.push(Edge { from, to });
}
fn edge_target(&self, from: NodeId, predicate: fn(&Node) -> bool) -> Option<NodeId> {
self.edge_targets(from, predicate).next()
}
fn edge_targets<'graph>(
&'graph self,
from: NodeId,
predicate: impl Fn(&Node) -> bool + 'graph,
) -> impl Iterator<Item = NodeId> + 'graph {
self.edges
.iter()
.filter(move |edge| edge.from == from && predicate(&self.nodes[edge.to.0]))
.map(|edge| edge.to)
}
}
#[derive(Debug)]
enum Node {
Scope,
Declaration(Declaration),
Reference(String),
}
impl Node {
fn is_scope(&self) -> bool {
matches!(self, Self::Scope)
}
}
#[derive(Debug)]
struct Declaration {
name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NodeId(usize);
#[derive(Debug)]
struct Edge {
from: NodeId,
to: NodeId,
}
#[derive(Debug)]
pub(crate) struct LetScope {
pub(crate) range: ast::SourceRange,
pub(crate) bindings: Vec<Binding>,
pub(crate) entry_references: Vec<NodeId>,
}
#[derive(Debug)]
pub(crate) struct Binding {
pub(crate) declaration: NodeId,
pub(crate) name: String,
pub(crate) references: Vec<NodeId>,
pub(crate) range: ast::SourceRange,
}

View File

@@ -1,29 +0,0 @@
{ pkgs }:
let
first = 1;
second = first;
in {
firstField = first;
secondField = second;
nested = let
innerSecond = innerFirst;
innerFirst = first;
in {
result = innerSecond;
};
parameter = first: let
usesParameter = first;
in {
result = usesParameter;
};
path = ./. + "/foo";
url = "https://example.com";
inherit pkgs;
different = other;
nested.package = nested.package;
"quoted" = quoted;
${dynamic} = dynamic;
from = pkgs.from.other;
fallback = pkgs.fallback or null;
}

View File

@@ -1,4 +0,0 @@
{ pkgs }:
{
inherit pkgs;

View File

@@ -1,17 +0,0 @@
let
second = first;
first = 1;
nested = let
nestedFirst = first;
nestedSecond = nestedFirst;
in {
result = nestedSecond;
};
in {
package = package;
from = pkgs.from;
url = https://example.com;
firstField = first;
secondField = second;
nestedField = nested;
}

View File

@@ -12,10 +12,7 @@ After editing Nix files, run:
```bash
nix run .#fix
bazel run //src/natu:natu -- <modified-nix-paths>
```
Pass every modified Nix path to Natu.
Finish when both commands succeed and the Nix change follows the loaded rules.
If a command fails, report the failure.
Finish when the command succeeds and the Nix change follows the loaded rules.
If the command fails, report the failure.