Skip to content

Commit

Permalink
feat: set up do-not-land and if-change-then-change (#5)
Browse files Browse the repository at this point in the history
do-not-land asserts that if you have a 'DO NOT LAND' somewhere in the code then it can't get checked in; with hold-the-line this works out to a 'DO NOT LAND' in the diff

if-change-then-change keeps different code blocks in sync. has to go through libgit2 and eventually rely on `${upstream}` from trunk, because it needs to know the exact set of modified lines, not just the set of modified files. for some frustrating reason `diff_tree_to_workdir_with_index` doesn't recognize untracked files, even though i explicitly set it to true; apparently in trunk we go thru a `git status` API instead. (i'm not married to the current syntax, but it's the best that we have right now; it also suffers from a number of structural deficiencies, e.g. doesn't support multiple other files nor block labels)

there are still a number of antipatterns in here (e.g. the unwrap in main.rs) but for now i think i'm reasonably happy with how the impl looks. tests are certainly terrible and need to be written, but that's a separate problem.
  • Loading branch information
sxlijin authored Oct 5, 2022
1 parent 71f374f commit aa9804f
Show file tree
Hide file tree
Showing 13 changed files with 393 additions and 61 deletions.
2 changes: 1 addition & 1 deletion .trunk/trunk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ cli:
lint:
linters:
- name: horton
type: lsp_json
type: sarif
files: [ALL]
command: ["${workspace}/target/debug/horton", "--file", "${path}"]
success_codes: [0, 1]
Expand Down
15 changes: 12 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ name = "horton"
path = "src/lib.rs"

[dependencies]
anyhow = "1.0.64"
clap = { version = "4.0.8", features = ["derive"] }
env_logger = "0.9.1"
git2 = { version = "0.15", default-features = false }
lazy_static = "1.4.0"
log = "0.4.17"
regex = "1.6.0"
serde = { version = "1.0.145", features = ["derive"] }
serde_json = "1.0.85"
anyhow = "1.0.64"
log = "0.4.17"
env_logger = "0.9.1"
serde-sarif = "0.3.4"

[dev-dependencies]
assert_cmd = "2.0"
function_name = "0.2.0"
predicates = "2.1"
tempfile = "3.3.0"

[profile.release]
codegen-units = 1
Expand Down
3 changes: 3 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.64.0"
components = ["cargo-watch"]
5 changes: 3 additions & 2 deletions src/lsp_json.rs → src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub struct Position {

#[derive(Serialize)]
pub struct Range {
pub path: String,
pub start: Position,
pub end: Position,
}
Expand All @@ -29,11 +30,11 @@ pub struct Diagnostic {
}

#[derive(Serialize, Default)]
pub struct LspJson {
pub struct Diagnostics {
pub diagnostics: Vec<Diagnostic>,
}

impl LspJson {
impl Diagnostics {
pub fn to_string(&self) -> anyhow::Result<String> {
let as_string = serde_json::to_string(&self)?;
Ok(as_string)
Expand Down
68 changes: 68 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use git2::{Delta, DiffOptions, Repository};
use std::collections::HashSet;
use std::path::PathBuf;

#[derive(Debug)]
pub struct Hunk {
pub path: PathBuf,
pub begin: i64,
pub end: i64,
}

#[derive(Debug, Default)]
pub struct NewOrModified {
/// Set of modified line ranges in new/existing files
pub hunks: Vec<Hunk>,

/// Set of new/modified files
pub paths: HashSet<PathBuf>,
}

pub fn modified_since(upstream: &str) -> anyhow::Result<NewOrModified> {
let repo = Repository::open(".")?;

let upstream_tree = repo.find_reference(upstream)?.peel_to_tree()?;

let diff = {
let mut diff_opts = DiffOptions::new();
diff_opts.include_untracked(true);

repo.diff_tree_to_workdir_with_index(Some(&upstream_tree), Some(&mut diff_opts))?
};

let mut ret = NewOrModified::default();
diff.foreach(
&mut |_, _| true,
None,
Some(&mut |delta, hunk| {
match delta.status() {
Delta::Unmodified
| Delta::Added
| Delta::Modified
| Delta::Renamed
| Delta::Copied
| Delta::Untracked => {
if let Some(path) = delta.new_file().path() {
let path = path.to_path_buf();

ret.paths.insert(path.clone());
ret.hunks.push(Hunk {
path,
begin: hunk.new_start() as i64,
end: (hunk.new_start() + hunk.new_lines()) as i64,
});
} else {
// TODO(sam): accumulate errors and return them
// See https://doc.rust-lang.org/rust-by-example/error/iter_result.html
log::error!("Found git delta where new_file had no path");
}
}
_ => (),
}
true
}),
None,
)?;

Ok(ret)
}
4 changes: 3 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pub mod lsp_json;
pub mod diagnostic;
pub mod git;
pub mod rules;
129 changes: 75 additions & 54 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,70 +1,91 @@
use std::fs::File;
use std::io::{BufRead, BufReader};

use anyhow::Context;
use clap::Parser;
use horton::lsp_json;
use regex::Regex;
use horton::diagnostic;
use horton::git;
use horton::rules::if_change_then_change::ictc;
use horton::rules::pls_no_land::pls_no_land;
use serde_sarif::sarif;

#[derive(Parser, Debug)]
#[clap(version = "0.1", author = "Trunk Technologies Inc.")]
struct Opts {
#[clap(short, long)]
file: String,
}

type LinesView = Vec<String>;

fn lines_view<R: BufRead>(reader: R) -> anyhow::Result<LinesView> {
let mut ret: LinesView = LinesView::default();
for line in reader.lines() {
let line = line?;
ret.push(line);
}
Ok(ret)
#[clap(long)]
// #[arg(default_value_t = String::from("refs/heads/main"))]
#[arg(default_value_t = String::from("HEAD"))]
upstream: String,
}

fn run() -> anyhow::Result<()> {
let opts: Opts = Opts::parse();
let re = Regex::new(r"(?i)(DO[\s_-]+NOT[\s_-]+LAND)").unwrap();

let in_file =
File::open(&opts.file).with_context(|| format!("failed to open: {}", opts.file))?;
let in_buff = BufReader::new(in_file);
let lines_view = lines_view(in_buff).context("failed to build lines view")?;
let mut ret = lsp_json::LspJson::default();
let mut ret = diagnostic::Diagnostics::default();
let modified = git::modified_since(&opts.upstream)?;

for (i, line) in lines_view.iter().enumerate() {
// trunk-ignore(horton/do-not-land)
if line.contains("trunk-ignore(horton/do-not-land)") {
continue;
}
let m = if let Some(m) = re.find(line) {
m
} else {
continue;
};
ret.diagnostics.extend(pls_no_land(&modified.paths)?);
ret.diagnostics.extend(ictc(&modified.hunks)?);

ret.diagnostics.push(lsp_json::Diagnostic {
range: lsp_json::Range {
start: lsp_json::Position {
line: i as u64,
character: m.start() as u64,
},
end: lsp_json::Position {
line: i as u64,
character: m.end() as u64,
},
},
severity: lsp_json::Severity::Error,
// trunk-ignore(horton/do-not-land)
code: "do-not-land".to_string(),
message: format!("Found '{}'", m.as_str()),
});
}
// TODO(sam): figure out how to stop using unwrap() inside the map() calls below
let results: Vec<sarif::Result> = ret
.diagnostics
.iter()
.map(|d| {
sarif::ResultBuilder::default()
.level("error")
.locations([sarif::LocationBuilder::default()
.physical_location(
sarif::PhysicalLocationBuilder::default()
.artifact_location(
sarif::ArtifactLocationBuilder::default()
.uri(d.range.path.clone())
.build()
.unwrap(),
)
.region(
sarif::RegionBuilder::default()
.start_line(d.range.start.line as i64 + 1)
.start_column(d.range.start.character as i64 + 1)
.end_line(d.range.end.line as i64 + 1)
.end_column(d.range.end.character as i64 + 1)
.build()
.unwrap(),
)
.build()
.unwrap(),
)
.build()
.unwrap()])
.message(
sarif::MessageBuilder::default()
.text(d.message.clone())
.build()
.unwrap(),
)
.rule_id(d.code.clone())
.build()
.unwrap()
})
.collect();

let run = sarif::RunBuilder::default()
.tool(
sarif::ToolBuilder::default()
.driver(
sarif::ToolComponentBuilder::default()
.name("horton")
.build()
.unwrap(),
)
.build()
.unwrap(),
)
.results(results)
.build()?;
let sarif_built = sarif::SarifBuilder::default()
.version("2.1.0")
.runs([run])
.build()?;

let diagnostics_str = ret.to_string()?;
println!("{}", diagnostics_str);
let sarif = serde_json::to_string_pretty(&sarif_built)?;
println!("{}", sarif);

Ok(())
}
Expand Down
Empty file removed src/rules/bin-bash.rs
Empty file.
Loading

0 comments on commit aa9804f

Please sign in to comment.