diff --git a/AGENTS.md b/AGENTS.md index aab6be4..a25c857 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index bedb295..377e144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,6 +402,8 @@ dependencies = [ "monorepo", "rnix", "rowan", + "serde", + "serde_json", "status", ] diff --git a/src/xatu/BUILD b/src/xatu/BUILD index f60733b..1bb2fa3 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -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", ], ) diff --git a/src/xatu/Cargo.toml b/src/xatu/Cargo.toml index d12e186..81e2b62 100644 --- a/src/xatu/Cargo.toml +++ b/src/xatu/Cargo.toml @@ -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 } diff --git a/src/xatu/README.md b/src/xatu/README.md new file mode 100644 index 0000000..ccbbf77 --- /dev/null +++ b/src/xatu/README.md @@ -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. diff --git a/src/xatu/ast.rs b/src/xatu/ast.rs index 1deb464..08ec804 100644 --- a/src/xatu/ast.rs +++ b/src/xatu/ast.rs @@ -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, pub tokens: Vec, pub errors: Vec, + 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, diff --git a/src/xatu/debug_test.rs b/src/xatu/debug_test.rs new file mode 100644 index 0000000..25b10b7 --- /dev/null +++ b/src/xatu/debug_test.rs @@ -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 { + 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 { + Ok(PathBuf::from(std::env::var("TEST_SRCDIR").or_fail()?) + .join(std::env::var("TEST_WORKSPACE").or_fail()?) + .join("src/xatu")) +} diff --git a/src/xatu/file_store.rs b/src/xatu/file_store.rs index ef2aafe..c701afa 100644 --- a/src/xatu/file_store.rs +++ b/src/xatu/file_store.rs @@ -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); diff --git a/src/xatu/parser.rs b/src/xatu/parser.rs index 61545c3..c239b39 100644 --- a/src/xatu/parser.rs +++ b/src/xatu/parser.rs @@ -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, Vec) { let mut nodes = Vec::new(); let mut tokens = Vec::new(); diff --git a/src/xatu/scope_graph.rs b/src/xatu/scope_graph.rs new file mode 100644 index 0000000..b40ded2 --- /dev/null +++ b/src/xatu/scope_graph.rs @@ -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, + pub declarations: Vec, + pub references: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Scope { + pub parent: Option, + pub range: SourceRange, +} + +#[derive(Debug, Serialize)] +pub struct Occurrence { + pub name: String, + pub range: SourceRange, + pub scope: usize, +} diff --git a/src/xatu/testdata/clean.json b/src/xatu/testdata/clean.json new file mode 100644 index 0000000..5cbc496 --- /dev/null +++ b/src/xatu/testdata/clean.json @@ -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 + } + ] + } + } + } + ] +} diff --git a/src/xatu/testdata/clean.nix b/src/xatu/testdata/clean.nix index fa09933..ad5871c 100644 --- a/src/xatu/testdata/clean.nix +++ b/src/xatu/testdata/clean.nix @@ -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; diff --git a/src/xatu/testdata/lint_findings.json b/src/xatu/testdata/lint_findings.json new file mode 100644 index 0000000..a0526f1 --- /dev/null +++ b/src/xatu/testdata/lint_findings.json @@ -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 + } + ] + } + } + } + ] +} diff --git a/src/xatu/testdata/lint_findings.nix b/src/xatu/testdata/lint_findings.nix index 17b4837..4bcf0fb 100644 --- a/src/xatu/testdata/lint_findings.nix +++ b/src/xatu/testdata/lint_findings.nix @@ -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; diff --git a/src/xatu/xatu.rs b/src/xatu/xatu.rs index bc8b40f..082f9e7 100644 --- a/src/xatu/xatu.rs +++ b/src/xatu/xatu.rs @@ -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, +} + +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) -> 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) -> status::Result { let mut store = FileStore::default(); diff --git a/src/xatu/xatu_main.rs b/src/xatu/xatu_main.rs index 256984d..2383f04 100644 --- a/src/xatu/xatu_main.rs +++ b/src/xatu/xatu_main.rs @@ -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, } -fn analyze(args: Args) -> status::Result { +fn collect_files(args: Args) -> status::Result> { let paths: Vec = args .paths .into_iter() @@ -44,7 +71,7 @@ fn analyze(args: Args) -> status::Result { .flatten() .collect(); - xatu::future_sight(files) + Ok(files) } fn collect_source_files(path: &Path) -> status::Result> {