diff --git a/src/xatu/BUILD b/src/xatu/BUILD index 79bd95c..1bb2fa3 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -11,6 +11,7 @@ rust_library( "rules/manual_inherit.rs", "rules/manual_inherit_from.rs", "rules/unquoted_uri.rs", + "scope_graph.rs", "xatu.rs", ], deps = [ diff --git a/src/xatu/README.md b/src/xatu/README.md index bbef586..24216ea 100644 --- a/src/xatu/README.md +++ b/src/xatu/README.md @@ -1,18 +1,51 @@ -# Xatu debug output +# 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: - -- File IDs. -- Lowered rule nodes and tokens. -- Parse errors. - +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 parsing produces exit code 0, including files with potential lint findings. +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 @@ -23,6 +56,10 @@ Existing black-box tests check lint findings. The test also checks parse errors, read errors, and deterministic file ordering. It copies Nix fixtures into `TEST_TMPDIR` because discovery skips symlinks, including Bazel runfiles. +`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 @@ -36,5 +73,5 @@ binary="$(pwd)/bazel-bin/src/xatu/xatu" ) ``` -Review regenerated output before accepting it. +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 787be4a..08ec804 100644 --- a/src/xatu/ast.rs +++ b/src/xatu/ast.rs @@ -1,6 +1,6 @@ use serde::Serialize; -use crate::FileId; +use crate::{FileId, scope_graph::ScopeGraph}; #[derive(Debug, Serialize)] pub struct ParsedFile { @@ -8,6 +8,7 @@ pub struct ParsedFile { pub nodes: Vec, pub tokens: Vec, pub errors: Vec, + pub scope_graph: ScopeGraph, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 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 index 5d043e8..5cbc496 100644 --- a/src/xatu/testdata/clean.json +++ b/src/xatu/testdata/clean.json @@ -7,8 +7,26 @@ "nodes": [ { "range": { - "start": 85, - "end": 103 + "start": 374, + "end": 392 + }, + "data": { + "Binding": { + "key": { + "name": "extra" + }, + "value": { + "Ident": { + "name": "normalize" + } + } + } + } + }, + { + "range": { + "start": 528, + "end": 546 }, "data": { "Binding": { @@ -25,7 +43,384 @@ } ], "tokens": [], - "errors": [] + "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 index 12d3646..a0526f1 100644 --- a/src/xatu/testdata/lint_findings.json +++ b/src/xatu/testdata/lint_findings.json @@ -7,8 +7,26 @@ "nodes": [ { "range": { - "start": 4, - "end": 22 + "start": 173, + "end": 191 + }, + "data": { + "Binding": { + "key": { + "name": "extra" + }, + "value": { + "Ident": { + "name": "normalize" + } + } + } + } + }, + { + "range": { + "start": 458, + "end": 476 }, "data": { "Binding": { @@ -25,8 +43,8 @@ }, { "range": { - "start": 25, - "end": 42 + "start": 479, + "end": 496 }, "data": { "Binding": { @@ -48,15 +66,336 @@ "tokens": [ { "range": { - "start": 51, - "end": 70 + "start": 505, + "end": 524 }, "data": { "UnquotedUri": "https://example.com" } } ], - "errors": [] + "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 cc3ee5d..3a0bec3 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};