Compare commits

...

2 Commits

Author SHA1 Message Date
2572c4a56b feat(xatu): report unquoted URI literals 2026-09-19 17:40:13 -04:00
ad00301ac9 docs(rust-style): order declarations by dependency 2026-09-19 17:38:09 -04:00
9 changed files with 224 additions and 73 deletions

View File

@@ -0,0 +1,38 @@
# Declaration rules
## Order declarations by dependency
### Rule
Order Rust declarations in depth-first dependency order.
Place each function, struct, and enum before private declarations that support it.
For structs and enums, order supporting declarations by field or variant order.
For functions, order helper declarations by first use in the function body.
Apply the same rule recursively to each supporting declaration.
### Rationale
Dependency order lets readers start at the public operation or type.
Readers can then continue downward into the declarations that explain each part.
### Examples
```rust
pub struct Report {
files: FileStore,
diagnostics: Vec<Diagnostic>,
}
struct FileStore {
files: Vec<SourceFile>,
}
struct SourceFile {
path: PathBuf,
source: String,
}
struct Diagnostic {
message: String,
}
```

View File

@@ -10,6 +10,7 @@ rust_library(
"rules.rs",
"rules/deprecated_to_path.rs",
"rules/empty_inherit.rs",
"rules/unquoted_uri.rs",
"xatu.rs",
],
deps = [

View File

@@ -4,6 +4,7 @@ use crate::FileId;
pub struct ParsedFile {
pub file_id: FileId,
pub nodes: Vec<Node>,
pub tokens: Vec<Token>,
pub errors: Vec<ParseError>,
}
@@ -31,6 +32,17 @@ pub enum NodeData {
Inherit(Inherit),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub range: SourceRange,
pub data: TokenData,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenData {
Uri(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Apply {
pub function: Expr,

View File

@@ -13,6 +13,11 @@ pub fn parse_file(file_id: FileId, source: &str) -> ast::ParsedFile {
.descendants()
.filter_map(convert_node)
.collect(),
tokens: parse
.syntax()
.descendants_with_tokens()
.filter_map(convert_token)
.collect(),
errors: parse
.errors()
.iter()
@@ -29,6 +34,21 @@ fn convert_node(node: rnix::SyntaxNode) -> Option<ast::Node> {
}
}
fn convert_token(element: rnix::SyntaxElement) -> Option<ast::Token> {
let token = match element {
rnix::NodeOrToken::Token(token) => token,
rnix::NodeOrToken::Node(_) => return None,
};
match token.kind() {
rnix::SyntaxKind::TOKEN_URI => Some(ast::Token {
range: token.text_range().into(),
data: ast::TokenData::Uri(token.text().to_string()),
}),
_ => None,
}
}
fn convert_apply(node: rnix::SyntaxNode) -> Option<ast::Node> {
let apply = rnix::ast::Apply::cast(node)?;
let range = apply.syntax().text_range().into();

View File

@@ -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,6 +40,42 @@ 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<Diagnostic> {
let node_diagnostics = file.nodes.iter().flat_map(|node| {
[
run_node_rule::<DeprecatedToPath>(node),
run_node_rule::<EmptyInherit>(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::<UnquotedUri>(token)]
.into_iter()
.flatten()
.map(|finding| Diagnostic {
file_id: file.file_id,
finding,
})
});
node_diagnostics.chain(token_diagnostics).collect()
}
fn run_node_rule<R>(node: &ast::Node) -> Option<Finding>
where
R: NodeRule,
@@ -52,20 +90,16 @@ where
})
}
pub fn run_rules(file: &ast::ParsedFile) -> Vec<Diagnostic> {
file.nodes
.iter()
.flat_map(|node| {
[
run_node_rule::<DeprecatedToPath>(node),
run_node_rule::<EmptyInherit>(node),
]
.into_iter()
.flatten()
.map(|finding| Diagnostic {
file_id: file.file_id,
finding,
})
})
.collect()
fn run_token_rule<R>(token: &ast::Token) -> Option<Finding>
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,
})
}

View File

@@ -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::Uri(uri) => Some(uri),
}
}
fn check(_token: &Self::Token) -> Option<&'static str> {
Some("URI literals should be quoted")
}
}

View File

@@ -3,4 +3,5 @@
{
inherit;
path = builtins.toPath src;
url = https://example.com;
}

View File

@@ -4,28 +4,47 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use xatu::{Report, SourceFile};
fn is_nix_file(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension == "nix")
fn main() -> ExitCode {
let args = Args::parse();
match analyze(args) {
Ok(report) if report.is_clean() => {
println!("No findings.");
ExitCode::SUCCESS
}
Ok(report) => {
print!("{report}");
ExitCode::from(1)
}
Err(error) => {
eprintln!("{error}");
ExitCode::from(2)
}
}
}
// TODO: Add configurable exclusions.
fn is_excluded_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name,
".git"
| "bazel-bin"
| "bazel-dev"
| "bazel-out"
| "bazel-testlogs"
| "target"
| "testdata"
| "third_party"
) || name == "result"
|| name.starts_with("result-")
})
#[derive(Debug, Parser)]
struct Args {
#[arg(value_name = "PATH", default_value = ".")]
paths: Vec<PathBuf>,
}
fn analyze(args: Args) -> status::Result<Report> {
let paths: Vec<PathBuf> = args
.paths
.into_iter()
.map(monorepo::workspace::resolve_workspace_path)
.collect();
let files: Vec<SourceFile> = paths
.iter()
.map(|path| collect_source_files(path))
.collect::<status::Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
xatu::future_sight(files)
}
fn collect_source_files(path: &Path) -> status::Result<Vec<SourceFile>> {
@@ -61,43 +80,26 @@ fn collect_source_files(path: &Path) -> status::Result<Vec<SourceFile>> {
Ok(Vec::new())
}
#[derive(Debug, Parser)]
struct Args {
#[arg(value_name = "PATH", default_value = ".")]
paths: Vec<PathBuf>,
// TODO: Add configurable exclusions.
fn is_excluded_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name,
".git"
| "bazel-bin"
| "bazel-dev"
| "bazel-out"
| "bazel-testlogs"
| "target"
| "testdata"
| "third_party"
) || name == "result"
|| name.starts_with("result-")
})
}
fn analyze(args: Args) -> status::Result<Report> {
let paths: Vec<PathBuf> = args
.paths
.into_iter()
.map(monorepo::workspace::resolve_workspace_path)
.collect();
let files: Vec<SourceFile> = paths
.iter()
.map(|path| collect_source_files(path))
.collect::<status::Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
xatu::future_sight(files)
}
fn main() -> ExitCode {
match analyze(Args::parse()) {
Ok(report) if report.is_clean() => {
println!("No findings.");
ExitCode::SUCCESS
}
Ok(report) => {
print!("{report}");
ExitCode::from(1)
}
Err(error) => {
eprintln!("{error}");
ExitCode::from(2)
}
}
fn is_nix_file(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension == "nix")
}

View File

@@ -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::<Vec<_>>();
expect_that!(
findings,
contains(eq(&(
PathBuf::from("testdata/lint_findings.nix"),
"unquoted_uri"
)))
);
Ok(())
}