Skip to content

Commit

Permalink
Add check 'missing-patch-comment' using rust and rnix
Browse files Browse the repository at this point in the history
  • Loading branch information
rmcgibbo committed Jan 31, 2021
1 parent 7802f26 commit 8fb211a
Show file tree
Hide file tree
Showing 18 changed files with 640 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ jobs:
uses: cachix/install-nix-action@v12

- name: Run tests
run: nix-shell --run ./run-tests.py
run: nix run -c ./run-tests.py
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ast-checks/target
222 changes: 222 additions & 0 deletions ast-checks/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions ast-checks/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "nixpkgs-hammer-ast-checks"
version = "0.0.0"
authors = ["Robert T. McGibbon <[email protected]>"]
edition = "2018"

[[bin]]
name = "missing-patch-comment"
path = "src/missing-patch-comment.rs"

[dependencies]
codespan = "0.11"
rnix = "0.7.0"
rowan = { version = "0.6.2", features = [ "serde1" ] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
34 changes: 34 additions & 0 deletions ast-checks/src/comment_finders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use crate::tree_utils::{next_siblings, prev_siblings, walk_kind};
use rnix::{NodeOrToken, SyntaxElement, SyntaxKind::*, SyntaxNode};

pub fn find_comment_within(node: &SyntaxNode) -> Option<String> {
// find the first TOKEN_COMMENT within
let tok = walk_kind(node, TOKEN_COMMENT).next()?;
// iterate over sibling comments and concatenate them
let node_iter = next_siblings(&tok);
let doc = collect_comment_text(node_iter);
Some(doc).filter(|it| !it.is_empty())
}

pub fn find_comment_above(node: &SyntaxNode) -> Option<String> {
// note: the prev_siblings iterators includes self, which
// isn't a TOKEN_COMMENT, so we need to skip one
let node_iter = prev_siblings(&NodeOrToken::Node(node.clone())).skip(1);
let doc = collect_comment_text(node_iter);
Some(doc).filter(|it| !it.is_empty())
}

fn collect_comment_text(node_iter: impl Iterator<Item = SyntaxElement>) -> String {
// take all immediately subsequent TOKEN_COMMENT's text,
// skipping over whitspace-only tokens
// Note this would be more clearly written using map_while, but that
// doesn't seem to be on rust stable yet.
node_iter
.filter_map(|node| node.into_token())
.take_while(|tok| tok.kind() == TOKEN_COMMENT || tok.kind().is_trivia())
.map(|tok| tok.text().to_string())
.map(|s| s.trim_start_matches('#').trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
102 changes: 102 additions & 0 deletions ast-checks/src/missing-patch-comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use codespan::{FileId, Files};
use comment_finders::{find_comment_above, find_comment_within};
use rnix::types::*;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, env, error::Error, fs, process};
use tree_utils::walk_keyvalues_filter_key;

mod comment_finders;
mod tree_utils;

#[derive(Serialize, Deserialize, Debug)]
struct SourceLocation {
column: usize,
line: usize,
file: String,
}

#[derive(Serialize, Deserialize, Debug)]
struct MissingCommentMessage {
cond: bool,
locations: Vec<SourceLocation>,
msg: &'static str,
name: &'static str,
}

fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
let mut report = HashMap::new();

let mut files = Files::new();
for filename in args {
let file_id = files.add(filename.clone(), fs::read_to_string(&filename)?);
report.insert(filename.clone(), analyze_single_file(&files, file_id)?);
}

println!("{}", serde_json::to_string(&report)?);
Ok(())
}

fn analyze_single_file(
files: &Files<String>,
file_id: FileId,
) -> Result<Vec<MissingCommentMessage>, Box<dyn Error>> {
let ast = rnix::parse(files.source(file_id)).as_result()?;
let root = ast.root().inner().ok_or("No elements")?;
let mut report: Vec<MissingCommentMessage> = vec![];

// Find a keys in the attrset called 'patches' that
// *do not* have a comment directly above them
let patches_without_top_comment = walk_keyvalues_filter_key(&root, "patches").filter(|kv| {
// Look for a comment directly above the `patches = ...` line
// (I'm interpreting that as the comment which applies to all patches.)
find_comment_above(kv.node()).is_none()
});

for kv in patches_without_top_comment {
// Attempt to cast the value of the `patches={value}` to a List
let value = match kv.value().and_then(List::cast) {
Some(l) => l,
None => {
let offset = kv.node().text_range().start().to_usize() as u32;
let loc = files.location(file_id, offset)?;
eprintln!(
"Unexpected format at line {} column {}. `patches` is not a list",
loc.line, loc.column
);
process::exit(1);
}
};

// For each element in the list of patches, look for
// a comment directly above the element or, if we don't see
// one of those, look for a comment within AST of the element.
for item in value.items() {
let has_comment_above = find_comment_above(&item).is_some();
let has_comment_within = find_comment_within(&item).is_some();

if !(has_comment_above || has_comment_within) {
let start = item.text_range().start().to_usize() as u32;
let loc = files.location(file_id, start)?;

report.push(MissingCommentMessage {
cond: true,
msg: "Please add a comment on the line above explaining the purpose of this patch.",
name: "missing-patch-comment",
locations: vec![SourceLocation {
file: files
.name(file_id)
.to_str()
.ok_or("encoding error")?
.to_string(),
// 1-based to 0-based indexing
column: loc.column.to_usize() + 1,
line: loc.line.to_usize() + 1,
}],
});
}
}
}

Ok(report)
}
Loading

0 comments on commit 8fb211a

Please sign in to comment.