refactor(natu): rename xatu
This commit was merged in pull request #97.
This commit is contained in:
56
src/natu/BUILD
Normal file
56
src/natu/BUILD
Normal file
@@ -0,0 +1,56 @@
|
||||
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/manual_inherit.rs",
|
||||
"rules/manual_inherit_from.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",
|
||||
],
|
||||
)
|
||||
21
src/natu/Cargo.toml
Normal file
21
src/natu/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[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 }
|
||||
84
src/natu/ast.rs
Normal file
84
src/natu/ast.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
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),
|
||||
Let {
|
||||
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 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,
|
||||
}
|
||||
33
src/natu/file_store.rs
Normal file
33
src/natu/file_store.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
33
src/natu/file_store_test.rs
Normal file
33
src/natu/file_store_test.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
81
src/natu/natu.rs
Normal file
81
src/natu/natu.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
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 {
|
||||
for diagnostic in &self.diagnostics {
|
||||
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));
|
||||
}
|
||||
|
||||
Ok(Report {
|
||||
files: store,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
105
src/natu/natu_main.rs
Normal file
105
src/natu/natu_main.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
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")
|
||||
}
|
||||
105
src/natu/natu_test.rs
Normal file
105
src/natu/natu_test.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
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(4));
|
||||
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(())
|
||||
}
|
||||
239
src/natu/parser.rs
Normal file
239
src/natu/parser.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
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::LetIn(let_in) => ast::Expr::Let {
|
||||
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(
|
||||
attr_set
|
||||
.attrpath_values()
|
||||
.filter_map(convert_binding_ast)
|
||||
.collect(),
|
||||
),
|
||||
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_binding_ast(binding: rnix::ast::AttrpathValue) -> Option<ast::Binding> {
|
||||
let mut attrs = binding.attrpath()?.attrs();
|
||||
let Attr::Ident(key) = attrs.next()? else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if attrs.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ast::Binding {
|
||||
range: binding.syntax().text_range().into(),
|
||||
key: key.into(),
|
||||
value: convert_expr(binding.value()?),
|
||||
})
|
||||
}
|
||||
|
||||
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 data = binding.try_into().ok()?;
|
||||
|
||||
Some(ast::Node {
|
||||
range,
|
||||
data: ast::NodeData::Binding(data),
|
||||
})
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
123
src/natu/rules.rs
Normal file
123
src/natu/rules.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use crate::FileId;
|
||||
use crate::ast;
|
||||
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 manual_inherit;
|
||||
pub mod manual_inherit_from;
|
||||
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>,
|
||||
}
|
||||
|
||||
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) -> Vec<Diagnostic> {
|
||||
let node_diagnostics = file
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|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).into_iter().map(|finding| Finding {
|
||||
rule_id: "let_order",
|
||||
range: finding.range,
|
||||
message: "Let bindings must follow dependency order",
|
||||
binding: Some(finding.binding),
|
||||
});
|
||||
|
||||
node_diagnostics
|
||||
.chain(token_diagnostics)
|
||||
.chain(let_order_diagnostics)
|
||||
.map(|finding| Diagnostic {
|
||||
file_id: file.file_id,
|
||||
finding,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
71
src/natu/rules/let_order.rs
Normal file
71
src/natu/rules/let_order.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crate::{
|
||||
ast,
|
||||
scope_graph::{LetScope, NodeId, ScopeGraph},
|
||||
};
|
||||
|
||||
pub struct Finding {
|
||||
pub range: ast::SourceRange,
|
||||
pub binding: String,
|
||||
}
|
||||
|
||||
pub fn check(graph: &ScopeGraph) -> Vec<Finding> {
|
||||
graph
|
||||
.let_scopes()
|
||||
.iter()
|
||||
.flat_map(|let_scope| {
|
||||
let expected = expected_order(graph, let_scope);
|
||||
let positions = expected
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(position, declaration)| (*declaration, position))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let_scope
|
||||
.bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(move |(position, binding)| {
|
||||
positions
|
||||
.iter()
|
||||
.find(|(declaration, _)| *declaration == binding.declaration)
|
||||
.filter(|(_, expected_position)| position != *expected_position)
|
||||
.map(|_| Finding {
|
||||
range: binding.range,
|
||||
binding: binding.name.clone(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/natu/rules/manual_inherit.rs
Normal file
24
src/natu/rules/manual_inherit.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/natu/rules/manual_inherit_from.rs
Normal file
24
src/natu/rules/manual_inherit_from.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/natu/rules/unquoted_uri.rs
Normal file
19
src/natu/rules/unquoted_uri.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
194
src/natu/scope_graph.rs
Normal file
194
src/natu/scope_graph.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
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 { bindings, body } => self.build_let(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(¶meter.name, scope);
|
||||
}
|
||||
self.build_expr(body, scope)
|
||||
}
|
||||
ast::Expr::Other(expressions) => expressions
|
||||
.iter()
|
||||
.flat_map(|expression| self.build_expr(expression, scope))
|
||||
.collect(),
|
||||
ast::Expr::Select(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_let(
|
||||
&mut self,
|
||||
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 {
|
||||
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) 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,
|
||||
}
|
||||
29
src/natu/testdata/clean.nix
vendored
Normal file
29
src/natu/testdata/clean.nix
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{ 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;
|
||||
}
|
||||
4
src/natu/testdata/invalid.nix
vendored
Normal file
4
src/natu/testdata/invalid.nix
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{ pkgs }:
|
||||
|
||||
{
|
||||
inherit pkgs;
|
||||
17
src/natu/testdata/lint_findings.nix
vendored
Normal file
17
src/natu/testdata/lint_findings.nix
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user