feat(xatu): construct lexical scope graphs and dump parser output

This commit is contained in:
2026-09-24 22:46:54 -04:00
parent 98d734e977
commit 0e48f1d7ec
16 changed files with 1295 additions and 19 deletions

View File

@@ -120,3 +120,5 @@
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
Gitea rebases the synthetic upstream root commit without the `third_party/pi` prefix.
Preserve `git-subtree-dir` and `git-subtree-split` trailers on Pi vendor commits.
- Xatu file discovery skips symlinks, including Bazel runfiles.
Copy Nix fixtures into `TEST_TMPDIR` before passing their paths to CLI tests.

2
Cargo.lock generated
View File

@@ -402,6 +402,8 @@ dependencies = [
"monorepo",
"rnix",
"rowan",
"serde",
"serde_json",
"status",
]

View File

@@ -11,11 +11,13 @@ rust_library(
"rules/manual_inherit.rs",
"rules/manual_inherit_from.rs",
"rules/unquoted_uri.rs",
"scope_graph.rs",
"xatu.rs",
],
deps = [
"//src/status:status",
"//third_party/crates:rnix",
"//third_party/crates:serde",
"//third_party/crates/rowan-0.16.1:rowan",
],
)
@@ -41,6 +43,23 @@ rust_test(
],
)
rust_test(
name = "debug_test",
srcs = ["debug_test.rs"],
data = [
":xatu",
"testdata/clean.json",
"testdata/clean.nix",
"testdata/invalid.nix",
"testdata/lint_findings.json",
"testdata/lint_findings.nix",
],
deps = [
"//third_party/crates:googletest",
"//third_party/crates:serde_json",
],
)
rust_binary(
name = "xatu",
crate_name = "xatu_cli",
@@ -50,5 +69,6 @@ rust_binary(
"//src/monorepo:monorepo",
"//src/status:status",
"//third_party/crates:clap",
"//third_party/crates:serde_json",
],
)

View File

@@ -16,6 +16,8 @@ monorepo = { workspace = true }
rnix = { workspace = true }
rowan = { workspace = true }
status = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
googletest = { workspace = true }

76
src/xatu/README.md Normal file
View File

@@ -0,0 +1,76 @@
# Xatu scope graphs
Xatu constructs two representations from one rnix syntax tree.
`parser.rs` owns all rnix access.
`ast.rs` contains the lowered representation for lint rules and the `ParsedFile` container.
`scope_graph.rs` contains the lexical scope graph.
`ParsedFile` holds both representations.
The graph follows the scope, declaration, and reference model in Néron et al., *A Theory of Name Resolution* (2015).
See sections 2.2–2.3 and 4.
Graph construction does not resolve references or order declarations.
## Initial coverage
Construction targets the syntax in `src/dotfiles/registry.nix`:
- Recursive let scopes and simple identifier declarations.
- Lambda scopes, identifier parameters, patterns, defaults, and pattern aliases.
- Plain `inherit` references and declarations in lets.
- References in the source expression of `inherit (expression)`.
- References inside attribute values, dynamic attributes, interpolations, assertions, and other expression children.
Static attribute labels do not introduce lexical declarations or references.
Non-recursive attrsets do not introduce lexical scopes.
Plain `inherit` in a let reads its enclosing scope.
Qualified inheritance reads its source expression in the let scope.
Construction omits `with`, recursive attrsets, and legacy lets, including their subtrees.
It does not declare dotted, quoted, or dynamic let bindings.
Other files can produce incomplete graphs without diagnostics.
No completeness guarantee applies outside the supported subset.
Vector indices identify scopes, declarations, and references within one file.
Each occurrence records its name, scope, and half-open UTF-8 byte range.
Scope ranges locate syntax constructs; parent links define lexical nesting.
An inherited name can have declaration and reference occurrences at the same source range.
The graph does not follow Nix imports or resolve attribute members.
## Debug output
Run `bazel run //src/xatu:xatu -- --debug src/dotfiles/registry.nix` to inspect parser output.
The command writes JSON containing file paths and complete `ParsedFile` values.
It skips lint rules and leaves the normal analysis report unchanged.
It sorts files by path and removes duplicate paths before assigning file IDs.
Parse errors remain in JSON and produce exit code 2.
Read errors go to stderr and produce exit code 2 without JSON output.
Successful construction produces exit code 0, including files with potential lint findings.
The debug format is an inspection interface, not a stable interchange schema.
## Tests
Run `bazel test //src/xatu:all`.
Existing black-box tests check lint findings.
`debug_test.rs` invokes the CLI and compares parsed JSON against expected fixture output.
The test also checks parse errors, read errors, and deterministic file ordering.
`clean.nix` places outer let declarations in consumer-first, depth-first dependency order from the entry expression.
`lint_findings.nix` deliberately uses a different order and retains cases for existing lint rules.
No ordering rule exists yet.
Regenerate expected JSON after intentional parser or fixture changes:
```bash
bazel build //src/xatu:xatu
binary="$(pwd)/bazel-bin/src/xatu/xatu"
(
cd src/xatu
for fixture in clean lint_findings; do
"$binary" --debug "testdata/$fixture.nix" > "testdata/$fixture.json"
done
)
```
Review regenerated graphs before accepting them.
Expected JSON files are generated test artifacts; do not edit them manually.

View File

@@ -1,20 +1,23 @@
use crate::FileId;
use serde::Serialize;
#[derive(Debug)]
use crate::{FileId, scope_graph::ScopeGraph};
#[derive(Debug, Serialize)]
pub struct ParsedFile {
pub file_id: FileId,
pub nodes: Vec<Node>,
pub tokens: Vec<Token>,
pub errors: Vec<ParseError>,
pub scope_graph: ScopeGraph,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Node {
pub range: SourceRange,
pub data: NodeData,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SourceRange {
pub start: usize,
pub end: usize,
@@ -26,46 +29,46 @@ impl std::fmt::Display for SourceRange {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum NodeData {
Binding(Binding),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Binding {
pub key: Ident,
pub value: Expr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Ident {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Expr {
Ident(Ident),
Select(Select),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Select {
pub receiver: String,
pub attr: Ident,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Token {
pub range: SourceRange,
pub data: TokenData,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum TokenData {
UnquotedUri(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ParseError {
pub range: SourceRange,
pub message: String,

89
src/xatu/debug_test.rs Normal file
View File

@@ -0,0 +1,89 @@
use std::path::PathBuf;
use std::process::{Command, Output};
use googletest::matchers::eq;
use googletest::{OrFail, expect_that, gtest};
use serde_json::Value;
#[gtest]
fn debug_matches_fixture_representations() -> googletest::Result<()> {
for name in ["clean", "lint_findings"] {
let output = run_debug(&[&format!("testdata/{name}.nix")])?;
expect_that!(output.status.code(), eq(Some(0)));
expect_that!(output.stderr.is_empty(), eq(true));
let actual: Value = serde_json::from_slice(&output.stdout).or_fail()?;
let expected: Value = serde_json::from_str(
&std::fs::read_to_string(fixture_dir()?.join(format!("testdata/{name}.json")))
.or_fail()?,
)
.or_fail()?;
expect_that!(actual, eq(&expected));
}
Ok(())
}
#[gtest]
fn debug_retains_parse_errors() -> googletest::Result<()> {
let output = run_debug(&["testdata/invalid.nix"])?;
expect_that!(output.status.code(), eq(Some(2)));
expect_that!(output.stderr.is_empty(), eq(true));
let actual: Value = serde_json::from_slice(&output.stdout).or_fail()?;
expect_that!(
actual["files"][0]["parsed"]["errors"]
.as_array()
.or_fail()?
.is_empty(),
eq(false)
);
Ok(())
}
#[gtest]
fn debug_orders_and_deduplicates_files() -> googletest::Result<()> {
let first = run_debug(&[
"testdata/lint_findings.nix",
"testdata/clean.nix",
"testdata/clean.nix",
])?;
let second = run_debug(&["testdata/clean.nix", "testdata/lint_findings.nix"])?;
expect_that!(first.status.code(), eq(Some(0)));
expect_that!(second.status.code(), eq(Some(0)));
expect_that!(first.stdout, eq(&second.stdout));
Ok(())
}
#[gtest]
fn debug_reports_io_errors_on_stderr() -> googletest::Result<()> {
let output = run_debug(&["testdata/missing.nix"])?;
expect_that!(output.status.code(), eq(Some(2)));
expect_that!(output.stdout.is_empty(), eq(true));
expect_that!(output.stderr.is_empty(), eq(false));
Ok(())
}
fn run_debug(paths: &[&str]) -> googletest::Result<Output> {
let directory = fixture_dir()?;
static NEXT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let working = PathBuf::from(std::env::var("TEST_TMPDIR").or_fail()?).join(id.to_string());
std::fs::create_dir_all(working.join("testdata")).or_fail()?;
// Discovery skips symlinks, including Bazel's fixture runfiles.
for name in ["clean", "lint_findings", "invalid"] {
let path = format!("testdata/{name}.nix");
std::fs::copy(directory.join(&path), working.join(&path)).or_fail()?;
}
let output = Command::new(directory.join("xatu"))
.current_dir(&working)
.env_remove("BUILD_WORKSPACE_DIRECTORY")
.arg("--debug")
.args(paths)
.output();
std::fs::remove_dir_all(working).or_fail()?;
output.or_fail()
}
fn fixture_dir() -> googletest::Result<PathBuf> {
Ok(PathBuf::from(std::env::var("TEST_SRCDIR").or_fail()?)
.join(std::env::var("TEST_WORKSPACE").or_fail()?)
.join("src/xatu"))
}

View File

@@ -29,5 +29,5 @@ pub struct SourceFile {
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
pub struct FileId(usize);

View File

@@ -1,18 +1,21 @@
use rnix::ast::Attr;
use rnix::ast::{Attr, HasEntry};
use rowan::ast::AstNode;
use crate::scope_graph::{Occurrence, Scope, ScopeGraph};
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 scope_graph = build_scope_graph(parse.syntax());
let (nodes, tokens) = convert_syntax(parse.syntax());
ast::ParsedFile {
file_id,
nodes,
tokens,
scope_graph,
errors: parse
.errors()
.iter()
@@ -21,6 +24,136 @@ pub fn parse_file(file_id: FileId, source: &str) -> ast::ParsedFile {
}
}
fn build_scope_graph(syntax: rnix::SyntaxNode) -> ScopeGraph {
let mut graph = ScopeGraph::default();
graph.scopes.push(Scope {
parent: None,
range: syntax.text_range().into(),
});
collect_scopes(syntax, 0, &mut graph);
graph
}
fn collect_scopes(node: rnix::SyntaxNode, scope: usize, graph: &mut ScopeGraph) {
use rnix::SyntaxKind::*;
match node.kind() {
NODE_LET_IN => {
let binding = rnix::ast::LetIn::cast(node.clone()).unwrap();
let inner = add_scope(&node, scope, graph);
for entry in binding.entries() {
match entry {
rnix::ast::Entry::AttrpathValue(binding) => {
if let Some(path) = binding.attrpath() {
let attrs: Vec<_> = path.attrs().collect();
if let [Attr::Ident(ident)] = attrs.as_slice() {
graph.declarations.push(occurrence(ident, inner));
}
}
collect_scopes(binding.syntax().clone(), inner, graph);
}
rnix::ast::Entry::Inherit(inherit) => {
for attr in inherit.attrs() {
if let Attr::Ident(ident) = attr {
graph.declarations.push(occurrence(&ident, inner));
}
}
// Plain inherit reads the enclosing scope, not the recursive let scope.
let reference_scope = if inherit.from().is_some() {
inner
} else {
scope
};
collect_scopes(inherit.syntax().clone(), reference_scope, graph);
}
}
}
if let Some(body) = binding.body() {
collect_scopes(body.syntax().clone(), inner, graph);
}
}
NODE_LAMBDA => {
let lambda = rnix::ast::Lambda::cast(node.clone()).unwrap();
let inner = add_scope(&node, scope, graph);
if let Some(param) = lambda.param() {
match param {
rnix::ast::Param::IdentParam(param) => {
if let Some(ident) = param.ident() {
graph.declarations.push(occurrence(&ident, inner));
}
}
rnix::ast::Param::Pattern(pattern) => {
for entry in pattern.pat_entries() {
if let Some(ident) = entry.ident() {
graph.declarations.push(occurrence(&ident, inner));
}
if let Some(default) = entry.default() {
collect_scopes(default.syntax().clone(), inner, graph);
}
}
if let Some(ident) = pattern.pat_bind().and_then(|bind| bind.ident()) {
graph.declarations.push(occurrence(&ident, inner));
}
}
}
}
if let Some(body) = lambda.body() {
collect_scopes(body.syntax().clone(), inner, graph);
}
}
NODE_IDENT => {
let ident = rnix::ast::Ident::cast(node).unwrap();
graph.references.push(occurrence(&ident, scope));
}
NODE_ATTRPATH => {
// Static attribute names are labels, not lexical references.
for child in node.children().filter(|child| child.kind() != NODE_IDENT) {
collect_scopes(child, scope, graph);
}
}
NODE_INHERIT => {
let inherit = rnix::ast::Inherit::cast(node).unwrap();
if let Some(from) = inherit.from() {
collect_scopes(from.syntax().clone(), scope, graph);
} else {
for attr in inherit.attrs() {
if let Attr::Ident(ident) = attr {
graph.references.push(occurrence(&ident, scope));
}
}
}
}
NODE_WITH | NODE_LEGACY_LET => {}
NODE_ATTR_SET
if rnix::ast::AttrSet::cast(node.clone())
.unwrap()
.rec_token()
.is_some() => {}
_ => {
for child in node.children() {
collect_scopes(child, scope, graph);
}
}
}
}
fn add_scope(node: &rnix::SyntaxNode, parent: usize, graph: &mut ScopeGraph) -> usize {
let id = graph.scopes.len();
graph.scopes.push(Scope {
parent: Some(parent),
range: node.text_range().into(),
});
id
}
fn occurrence(ident: &rnix::ast::Ident, scope: usize) -> Occurrence {
Occurrence {
name: ident.to_string(),
range: ident.syntax().text_range().into(),
scope,
}
}
fn convert_syntax(syntax: rnix::SyntaxNode) -> (Vec<ast::Node>, Vec<ast::Token>) {
let mut nodes = Vec::new();
let mut tokens = Vec::new();

27
src/xatu/scope_graph.rs Normal file
View File

@@ -0,0 +1,27 @@
use serde::Serialize;
use crate::ast::SourceRange;
/// Captures lexical binding structure without resolving references.
///
/// Vector indices identify scopes and occurrences within one file.
/// Construction follows Néron et al., *A Theory of Name Resolution* (2015), sections 2.2–2.3 and 4.
#[derive(Debug, Default, Serialize)]
pub struct ScopeGraph {
pub scopes: Vec<Scope>,
pub declarations: Vec<Occurrence>,
pub references: Vec<Occurrence>,
}
#[derive(Debug, Serialize)]
pub struct Scope {
pub parent: Option<usize>,
pub range: SourceRange,
}
#[derive(Debug, Serialize)]
pub struct Occurrence {
pub name: String,
pub range: SourceRange,
pub scope: usize,
}

427
src/xatu/testdata/clean.json vendored Normal file
View File

@@ -0,0 +1,427 @@
{
"files": [
{
"path": "testdata/clean.nix",
"parsed": {
"file_id": 0,
"nodes": [
{
"range": {
"start": 374,
"end": 392
},
"data": {
"Binding": {
"key": {
"name": "extra"
},
"value": {
"Ident": {
"name": "normalize"
}
}
}
}
},
{
"range": {
"start": 528,
"end": 546
},
"data": {
"Binding": {
"key": {
"name": "different"
},
"value": {
"Ident": {
"name": "other"
}
}
}
}
}
],
"tokens": [],
"errors": [],
"scope_graph": {
"scopes": [
{
"parent": null,
"range": {
"start": 0,
"end": 691
}
},
{
"parent": 0,
"range": {
"start": 0,
"end": 690
}
},
{
"parent": 1,
"range": {
"start": 10,
"end": 690
}
},
{
"parent": 2,
"range": {
"start": 25,
"end": 213
}
},
{
"parent": 3,
"range": {
"start": 36,
"end": 213
}
},
{
"parent": 2,
"range": {
"start": 229,
"end": 277
}
},
{
"parent": 2,
"range": {
"start": 315,
"end": 394
}
}
],
"declarations": [
{
"name": "pkgs",
"range": {
"start": 2,
"end": 6
},
"scope": 1
},
{
"name": "render",
"range": {
"start": 16,
"end": 22
},
"scope": 2
},
{
"name": "value",
"range": {
"start": 25,
"end": 30
},
"scope": 3
},
{
"name": "normalized",
"range": {
"start": 46,
"end": 56
},
"scope": 4
},
{
"name": "value",
"range": {
"start": 90,
"end": 95
},
"scope": 4
},
{
"name": "prefix",
"range": {
"start": 118,
"end": 124
},
"scope": 4
},
{
"name": "normalize",
"range": {
"start": 217,
"end": 226
},
"scope": 2
},
{
"name": "value",
"range": {
"start": 229,
"end": 234
},
"scope": 5
},
{
"name": "fallback",
"range": {
"start": 281,
"end": 289
},
"scope": 2
},
{
"name": "project",
"range": {
"start": 305,
"end": 312
},
"scope": 2
},
{
"name": "value",
"range": {
"start": 317,
"end": 322
},
"scope": 6
},
{
"name": "normalize",
"range": {
"start": 324,
"end": 333
},
"scope": 6
},
{
"name": "args",
"range": {
"start": 344,
"end": 348
},
"scope": 6
}
],
"references": [
{
"name": "normalize",
"range": {
"start": 59,
"end": 68
},
"scope": 4
},
{
"name": "value",
"range": {
"start": 69,
"end": 74
},
"scope": 4
},
{
"name": "value",
"range": {
"start": 90,
"end": 95
},
"scope": 3
},
{
"name": "pkgs",
"range": {
"start": 112,
"end": 116
},
"scope": 4
},
{
"name": "normalized",
"range": {
"start": 144,
"end": 154
},
"scope": 4
},
{
"name": "null",
"range": {
"start": 158,
"end": 162
},
"scope": 4
},
{
"name": "normalized",
"range": {
"start": 172,
"end": 182
},
"scope": 4
},
{
"name": "prefix",
"range": {
"start": 189,
"end": 195
},
"scope": 4
},
{
"name": "normalized",
"range": {
"start": 198,
"end": 208
},
"scope": 4
},
{
"name": "value",
"range": {
"start": 239,
"end": 244
},
"scope": 5
},
{
"name": "null",
"range": {
"start": 248,
"end": 252
},
"scope": 5
},
{
"name": "fallback",
"range": {
"start": 258,
"end": 266
},
"scope": 5
},
{
"name": "value",
"range": {
"start": 272,
"end": 277
},
"scope": 5
},
{
"name": "value",
"range": {
"start": 336,
"end": 341
},
"scope": 6
},
{
"name": "args",
"range": {
"start": 361,
"end": 365
},
"scope": 6
},
{
"name": "normalize",
"range": {
"start": 382,
"end": 391
},
"scope": 6
},
{
"name": "render",
"range": {
"start": 412,
"end": 418
},
"scope": 2
},
{
"name": "project",
"range": {
"start": 420,
"end": 427
},
"scope": 2
},
{
"name": "pkgs",
"range": {
"start": 520,
"end": 524
},
"scope": 2
},
{
"name": "other",
"range": {
"start": 540,
"end": 545
},
"scope": 2
},
{
"name": "nested",
"range": {
"start": 566,
"end": 572
},
"scope": 2
},
{
"name": "quoted",
"range": {
"start": 595,
"end": 601
},
"scope": 2
},
{
"name": "dynamic",
"range": {
"start": 607,
"end": 614
},
"scope": 2
},
{
"name": "dynamic",
"range": {
"start": 618,
"end": 625
},
"scope": 2
},
{
"name": "pkgs",
"range": {
"start": 636,
"end": 640
},
"scope": 2
},
{
"name": "pkgs",
"range": {
"start": 666,
"end": 670
},
"scope": 2
},
{
"name": "null",
"range": {
"start": 683,
"end": 687
},
"scope": 2
}
]
}
}
}
]
}

View File

@@ -1,6 +1,19 @@
{ pkgs }:
let
render = value:
let
normalized = normalize value;
inherit value;
inherit (pkgs) prefix;
in
assert normalized != null;
{ ${normalized} = "${prefix}${normalized}"; };
normalize = value: if value == null then fallback else value;
fallback = "default";
project = { value, normalize ? value }@args: { inherit (args) value; extra = normalize; };
in
{
result = render (project { value = "name"; }).value;
path = ./. + "/foo";
url = "https://example.com";
inherit pkgs;

402
src/xatu/testdata/lint_findings.json vendored Normal file
View File

@@ -0,0 +1,402 @@
{
"files": [
{
"path": "testdata/lint_findings.nix",
"parsed": {
"file_id": 0,
"nodes": [
{
"range": {
"start": 173,
"end": 191
},
"data": {
"Binding": {
"key": {
"name": "extra"
},
"value": {
"Ident": {
"name": "normalize"
}
}
}
}
},
{
"range": {
"start": 458,
"end": 476
},
"data": {
"Binding": {
"key": {
"name": "package"
},
"value": {
"Ident": {
"name": "package"
}
}
}
}
},
{
"range": {
"start": 479,
"end": 496
},
"data": {
"Binding": {
"key": {
"name": "from"
},
"value": {
"Select": {
"receiver": "pkgs",
"attr": {
"name": "from"
}
}
}
}
}
}
],
"tokens": [
{
"range": {
"start": 505,
"end": 524
},
"data": {
"UnquotedUri": "https://example.com"
}
}
],
"errors": [],
"scope_graph": {
"scopes": [
{
"parent": null,
"range": {
"start": 0,
"end": 528
}
},
{
"parent": 0,
"range": {
"start": 0,
"end": 527
}
},
{
"parent": 1,
"range": {
"start": 10,
"end": 527
}
},
{
"parent": 2,
"range": {
"start": 52,
"end": 100
}
},
{
"parent": 2,
"range": {
"start": 114,
"end": 193
}
},
{
"parent": 2,
"range": {
"start": 206,
"end": 394
}
},
{
"parent": 5,
"range": {
"start": 217,
"end": 394
}
}
],
"declarations": [
{
"name": "pkgs",
"range": {
"start": 2,
"end": 6
},
"scope": 1
},
{
"name": "fallback",
"range": {
"start": 16,
"end": 24
},
"scope": 2
},
{
"name": "normalize",
"range": {
"start": 40,
"end": 49
},
"scope": 2
},
{
"name": "value",
"range": {
"start": 52,
"end": 57
},
"scope": 3
},
{
"name": "project",
"range": {
"start": 104,
"end": 111
},
"scope": 2
},
{
"name": "value",
"range": {
"start": 116,
"end": 121
},
"scope": 4
},
{
"name": "normalize",
"range": {
"start": 123,
"end": 132
},
"scope": 4
},
{
"name": "args",
"range": {
"start": 143,
"end": 147
},
"scope": 4
},
{
"name": "render",
"range": {
"start": 197,
"end": 203
},
"scope": 2
},
{
"name": "value",
"range": {
"start": 206,
"end": 211
},
"scope": 5
},
{
"name": "prefix",
"range": {
"start": 242,
"end": 248
},
"scope": 6
},
{
"name": "value",
"range": {
"start": 264,
"end": 269
},
"scope": 6
},
{
"name": "normalized",
"range": {
"start": 277,
"end": 287
},
"scope": 6
}
],
"references": [
{
"name": "value",
"range": {
"start": 62,
"end": 67
},
"scope": 3
},
{
"name": "null",
"range": {
"start": 71,
"end": 75
},
"scope": 3
},
{
"name": "fallback",
"range": {
"start": 81,
"end": 89
},
"scope": 3
},
{
"name": "value",
"range": {
"start": 95,
"end": 100
},
"scope": 3
},
{
"name": "value",
"range": {
"start": 135,
"end": 140
},
"scope": 4
},
{
"name": "args",
"range": {
"start": 160,
"end": 164
},
"scope": 4
},
{
"name": "normalize",
"range": {
"start": 181,
"end": 190
},
"scope": 4
},
{
"name": "pkgs",
"range": {
"start": 236,
"end": 240
},
"scope": 6
},
{
"name": "value",
"range": {
"start": 264,
"end": 269
},
"scope": 5
},
{
"name": "normalize",
"range": {
"start": 290,
"end": 299
},
"scope": 6
},
{
"name": "value",
"range": {
"start": 300,
"end": 305
},
"scope": 6
},
{
"name": "normalized",
"range": {
"start": 325,
"end": 335
},
"scope": 6
},
{
"name": "null",
"range": {
"start": 339,
"end": 343
},
"scope": 6
},
{
"name": "normalized",
"range": {
"start": 353,
"end": 363
},
"scope": 6
},
{
"name": "prefix",
"range": {
"start": 370,
"end": 376
},
"scope": 6
},
{
"name": "normalized",
"range": {
"start": 379,
"end": 389
},
"scope": 6
},
{
"name": "render",
"range": {
"start": 412,
"end": 418
},
"scope": 2
},
{
"name": "project",
"range": {
"start": 420,
"end": 427
},
"scope": 2
},
{
"name": "package",
"range": {
"start": 468,
"end": 475
},
"scope": 2
},
{
"name": "pkgs",
"range": {
"start": 486,
"end": 490
},
"scope": 2
}
]
}
}
}
]
}

View File

@@ -1,4 +1,19 @@
{ pkgs }:
let
fallback = "default";
normalize = value: if value == null then fallback else value;
project = { value, normalize ? value }@args: { inherit (args) value; extra = normalize; };
render = value:
let
inherit (pkgs) prefix;
inherit value;
normalized = normalize value;
in
assert normalized != null;
{ ${normalized} = "${prefix}${normalized}"; };
in
{
result = render (project { value = "name"; }).value;
package = package;
from = pkgs.from;
url = https://example.com;

View File

@@ -2,6 +2,7 @@ mod ast;
mod file_store;
mod parser;
mod rules;
mod scope_graph;
pub use file_store::{FileId, FileStore, SourceFile};
pub use rules::{Diagnostic, Finding};
@@ -29,6 +30,43 @@ impl std::fmt::Display for Report {
}
}
/// Contains parser output without lint diagnostics.
#[derive(Debug, serde::Serialize)]
pub struct DebugReport {
files: Vec<DebugFile>,
}
impl DebugReport {
pub fn has_parse_errors(&self) -> bool {
self.files.iter().any(|file| !file.parsed.errors.is_empty())
}
}
#[derive(Debug, serde::Serialize)]
struct DebugFile {
path: std::path::PathBuf,
parsed: ast::ParsedFile,
}
/// Constructs both parser representations without running lint rules.
pub fn debug(mut files: Vec<SourceFile>) -> DebugReport {
files.sort_by(|left, right| left.path.cmp(&right.path));
files.dedup_by(|left, right| left.path == right.path);
let mut store = FileStore::default();
let files = files
.into_iter()
.map(|file| {
let file_id = store.add(file);
let file = store.get(file_id);
DebugFile {
path: file.path.clone(),
parsed: parser::parse_file(file_id, &file.source),
}
})
.collect();
DebugReport { files }
}
/// Analyzes source files and returns the diagnostics Xatu finds.
pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
let mut store = FileStore::default();

View File

@@ -2,12 +2,36 @@ use clap::Parser;
use status::ResultExt;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use xatu::{Report, SourceFile};
use xatu::SourceFile;
fn main() -> ExitCode {
let args = Args::parse();
match analyze(args) {
let debug = args.debug;
let files = match collect_files(args) {
Ok(files) => files,
Err(error) => {
eprintln!("{error}");
return ExitCode::from(2);
}
};
if debug {
let report = xatu::debug(files);
match serde_json::to_string_pretty(&report) {
Ok(json) => println!("{json}"),
Err(error) => {
eprintln!("{error}");
return ExitCode::from(2);
}
}
return if report.has_parse_errors() {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
};
}
match xatu::future_sight(files) {
Ok(report) if report.is_clean() => {
println!("No findings.");
ExitCode::SUCCESS
@@ -25,11 +49,14 @@ fn main() -> ExitCode {
#[derive(Debug, Parser)]
struct Args {
/// Dumps parser representations as JSON instead of running lint rules.
#[arg(long)]
debug: bool,
#[arg(value_name = "PATH", default_value = ".")]
paths: Vec<PathBuf>,
}
fn analyze(args: Args) -> status::Result<Report> {
fn collect_files(args: Args) -> status::Result<Vec<SourceFile>> {
let paths: Vec<PathBuf> = args
.paths
.into_iter()
@@ -44,7 +71,7 @@ fn analyze(args: Args) -> status::Result<Report> {
.flatten()
.collect();
xatu::future_sight(files)
Ok(files)
}
fn collect_source_files(path: &Path) -> status::Result<Vec<SourceFile>> {