From be8744795ce4ed5fefc88eb3e79dea862fd44499 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 19 Sep 2026 17:51:46 -0400 Subject: [PATCH] feat(xatu): report unquoted URI literals --- src/xatu/BUILD | 1 + src/xatu/ast.rs | 12 +++++++ src/xatu/parser.rs | 33 +++++++++++++++--- src/xatu/rules.rs | 52 ++++++++++++++++++++++++----- src/xatu/rules/unquoted_uri.rs | 19 +++++++++++ src/xatu/testdata/clean.nix | 1 + src/xatu/testdata/lint_findings.nix | 1 + src/xatu/xatu_test.rs | 24 +++++++++++++ 8 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 src/xatu/rules/unquoted_uri.rs diff --git a/src/xatu/BUILD b/src/xatu/BUILD index dea31a6..a10f057 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -10,6 +10,7 @@ rust_library( "rules.rs", "rules/deprecated_to_path.rs", "rules/empty_inherit.rs", + "rules/unquoted_uri.rs", "xatu.rs", ], deps = [ diff --git a/src/xatu/ast.rs b/src/xatu/ast.rs index 41472b1..220f61b 100644 --- a/src/xatu/ast.rs +++ b/src/xatu/ast.rs @@ -4,6 +4,7 @@ use crate::FileId; pub struct ParsedFile { pub file_id: FileId, pub nodes: Vec, + pub tokens: Vec, pub errors: Vec, } @@ -58,6 +59,17 @@ pub struct Inherit { pub attr_count: usize, } +#[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, diff --git a/src/xatu/parser.rs b/src/xatu/parser.rs index 300fc10..97a7ccb 100644 --- a/src/xatu/parser.rs +++ b/src/xatu/parser.rs @@ -6,13 +6,12 @@ pub fn parse_file(file_id: FileId, source: &str) -> ast::ParsedFile { let parse = rnix::Root::parse(source); let source_len = source.len(); + let (nodes, tokens) = convert_syntax(parse.syntax()); + ast::ParsedFile { file_id, - nodes: parse - .syntax() - .descendants() - .filter_map(convert_node) - .collect(), + nodes, + tokens, errors: parse .errors() .iter() @@ -21,6 +20,20 @@ pub fn parse_file(file_id: FileId, source: &str) -> ast::ParsedFile { } } +fn convert_syntax(syntax: rnix::SyntaxNode) -> (Vec, Vec) { + 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_node(node: rnix::SyntaxNode) -> Option { match node.kind() { rnix::SyntaxKind::NODE_APPLY => convert_apply(node), @@ -106,6 +119,16 @@ impl From for ast::Inherit { } } +fn convert_token(token: rnix::SyntaxToken) -> Option { + 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), diff --git a/src/xatu/rules.rs b/src/xatu/rules.rs index 068f921..02aef3b 100644 --- a/src/xatu/rules.rs +++ b/src/xatu/rules.rs @@ -2,9 +2,11 @@ use crate::FileId; use crate::ast; use crate::rules::deprecated_to_path::DeprecatedToPath; use crate::rules::empty_inherit::EmptyInherit; +use crate::rules::unquoted_uri::UnquotedUri; pub mod deprecated_to_path; pub mod empty_inherit; +pub mod unquoted_uri; #[derive(Debug)] pub struct Diagnostic { @@ -38,22 +40,40 @@ pub trait NodeRule { 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) -> Vec { - file.nodes - .iter() - .flat_map(|node| { - [ - run_node_rule::(node), - run_node_rule::(node), - ] + let node_diagnostics = file.nodes.iter().flat_map(|node| { + [ + run_node_rule::(node), + run_node_rule::(node), + ] + .into_iter() + .flatten() + .map(|finding| Diagnostic { + file_id: file.file_id, + finding, + }) + }); + + let token_diagnostics = file.tokens.iter().flat_map(|token| { + [run_token_rule::(token)] .into_iter() .flatten() .map(|finding| Diagnostic { file_id: file.file_id, finding, }) - }) - .collect() + }); + + node_diagnostics.chain(token_diagnostics).collect() } fn run_node_rule(node: &ast::Node) -> Option @@ -69,3 +89,17 @@ where message, }) } + +fn run_token_rule(token: &ast::Token) -> Option +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, + }) +} diff --git a/src/xatu/rules/unquoted_uri.rs b/src/xatu/rules/unquoted_uri.rs new file mode 100644 index 0000000..cb7410e --- /dev/null +++ b/src/xatu/rules/unquoted_uri.rs @@ -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") + } +} diff --git a/src/xatu/testdata/clean.nix b/src/xatu/testdata/clean.nix index 1e4a786..0963365 100644 --- a/src/xatu/testdata/clean.nix +++ b/src/xatu/testdata/clean.nix @@ -2,5 +2,6 @@ { path = ./. + "/foo"; + url = "https://example.com"; inherit pkgs; } diff --git a/src/xatu/testdata/lint_findings.nix b/src/xatu/testdata/lint_findings.nix index a177ee9..bd543bb 100644 --- a/src/xatu/testdata/lint_findings.nix +++ b/src/xatu/testdata/lint_findings.nix @@ -3,4 +3,5 @@ { inherit; path = builtins.toPath src; + url = https://example.com; } diff --git a/src/xatu/xatu_test.rs b/src/xatu/xatu_test.rs index 49cbd89..09b4d27 100644 --- a/src/xatu/xatu_test.rs +++ b/src/xatu/xatu_test.rs @@ -112,3 +112,27 @@ fn xatu_reports_empty_inherit() -> googletest::Result<()> { ); Ok(()) } + +#[gtest] +fn xatu_reports_unquoted_uri() -> googletest::Result<()> { + let report = xatu::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::>(); + expect_that!( + findings, + contains(eq(&( + PathBuf::from("testdata/lint_findings.nix"), + "unquoted_uri" + ))) + ); + Ok(()) +}