Compare commits

..

1 Commits

Author SHA1 Message Date
c4ea12b3ce feat(xatu): add scope graph 2026-09-26 20:05:59 -04:00
7 changed files with 8 additions and 164 deletions

View File

@@ -8,7 +8,6 @@ rust_library(
"file_store.rs", "file_store.rs",
"parser.rs", "parser.rs",
"rules.rs", "rules.rs",
"rules/let_order.rs",
"rules/manual_inherit.rs", "rules/manual_inherit.rs",
"rules/manual_inherit_from.rs", "rules/manual_inherit_from.rs",
"rules/unquoted_uri.rs", "rules/unquoted_uri.rs",

View File

@@ -3,9 +3,7 @@ use crate::ast;
use crate::rules::manual_inherit::ManualInherit; use crate::rules::manual_inherit::ManualInherit;
use crate::rules::manual_inherit_from::ManualInheritFrom; use crate::rules::manual_inherit_from::ManualInheritFrom;
use crate::rules::unquoted_uri::UnquotedUri; use crate::rules::unquoted_uri::UnquotedUri;
use crate::scope_graph::ScopeGraph;
pub mod let_order;
pub mod manual_inherit; pub mod manual_inherit;
pub mod manual_inherit_from; pub mod manual_inherit_from;
pub mod unquoted_uri; pub mod unquoted_uri;
@@ -21,22 +19,15 @@ pub struct Finding {
pub rule_id: &'static str, pub rule_id: &'static str,
pub range: ast::SourceRange, pub range: ast::SourceRange,
pub message: &'static str, pub message: &'static str,
pub binding: Option<String>,
} }
impl std::fmt::Display for Diagnostic { impl std::fmt::Display for Diagnostic {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!( write!(
formatter, formatter,
"[{}] {}", "{}: {}: {}",
self.finding.rule_id, self.finding.message self.finding.range, self.finding.rule_id, self.finding.message,
)?; )
if let Some(binding) = &self.finding.binding {
write!(formatter, ": {binding}")?;
}
Ok(())
} }
} }
@@ -58,7 +49,7 @@ pub trait TokenRule {
fn check(token: &Self::Token) -> Option<&'static str>; fn check(token: &Self::Token) -> Option<&'static str>;
} }
pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph) -> Vec<Diagnostic> { pub fn run_rules(file: &ast::ParsedFile) -> Vec<Diagnostic> {
let node_diagnostics = file let node_diagnostics = file
.nodes .nodes
.iter() .iter()
@@ -75,16 +66,8 @@ pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph) -> Vec<Diagnostic>
.flat_map(|token| [run_token_rule::<UnquotedUri>(token)]) .flat_map(|token| [run_token_rule::<UnquotedUri>(token)])
.flatten(); .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 node_diagnostics
.chain(token_diagnostics) .chain(token_diagnostics)
.chain(let_order_diagnostics)
.map(|finding| Diagnostic { .map(|finding| Diagnostic {
file_id: file.file_id, file_id: file.file_id,
finding, finding,
@@ -103,7 +86,6 @@ where
rule_id: R::ID, rule_id: R::ID,
range: node.range, range: node.range,
message, message,
binding: None,
}) })
} }
@@ -118,6 +100,5 @@ where
rule_id: R::ID, rule_id: R::ID,
range: token.range, range: token.range,
message, message,
binding: None,
}) })
} }

View File

@@ -1,71 +0,0 @@
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);
}
}
}

View File

@@ -1,22 +1,6 @@
{ pkgs }: { 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"; path = ./. + "/foo";
url = "https://example.com"; url = "https://example.com";
inherit pkgs; inherit pkgs;

View File

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

View File

@@ -4,8 +4,6 @@ mod parser;
mod rules; mod rules;
mod scope_graph; mod scope_graph;
use scope_graph::ScopeGraph;
pub use file_store::{FileId, FileStore, SourceFile}; pub use file_store::{FileId, FileStore, SourceFile};
pub use rules::{Diagnostic, Finding}; pub use rules::{Diagnostic, Finding};
@@ -25,34 +23,13 @@ impl std::fmt::Display for Report {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for diagnostic in &self.diagnostics { for diagnostic in &self.diagnostics {
let file = self.files.get(diagnostic.file_id); let file = self.files.get(diagnostic.file_id);
let (start, end) = line_range(&file.source, diagnostic.finding.range); writeln!(formatter, "{}:{diagnostic}", file.path.display())?;
let location = if start == end {
start.to_string()
} else {
format!("{start}-{end}")
};
writeln!(formatter, "{}:{location} {diagnostic}", file.path.display())?;
} }
Ok(()) 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 Xatu finds. /// Analyzes source files and returns the diagnostics Xatu finds.
pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> { pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
let mut store = FileStore::default(); let mut store = FileStore::default();
@@ -70,8 +47,7 @@ pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
))); )));
} }
let graph = ScopeGraph::from_file(&parsed); diagnostics.extend(rules::run_rules(&parsed));
diagnostics.extend(rules::run_rules(&parsed, &graph));
} }
Ok(Report { Ok(Report {

View File

@@ -41,19 +41,6 @@ fn xatu_rejects_invalid_source() -> googletest::Result<()> {
Ok(()) Ok(())
} }
#[gtest]
fn xatu_reports_out_of_order_let_bindings() -> googletest::Result<()> {
let report = xatu::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] #[gtest]
fn xatu_reports_unquoted_uri() -> googletest::Result<()> { fn xatu_reports_unquoted_uri() -> googletest::Result<()> {
let report = xatu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?; let report = xatu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;