pub mod sarif;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use super::{build_tool_timeout, run_command_with_timeout};
use crate::core::tools::{StaticTool, ToolDiagnostic};
use sarif::{parse_roslyn_sarif, roslyn_file_matches};
pub struct RoslynTool;
impl StaticTool for RoslynTool {
fn name(&self) -> &str {
"roslyn"
}
fn language(&self) -> &str {
"csharp"
}
fn is_available(&self) -> bool {
which::which("dotnet").is_ok()
}
fn run(&self, file: &Path, _content: &str) -> Result<Vec<ToolDiagnostic>> {
let dir = file.parent().unwrap_or_else(|| Path::new("."));
let csproj = match find_csproj(dir) {
Some(p) => p,
None => return Ok(Vec::new()),
};
let all_diags = build_project_diags(&csproj);
let target = file.to_string_lossy().into_owned();
let filtered = all_diags
.into_iter()
.filter(|d| roslyn_file_matches(&d.file, &target))
.collect();
Ok(filtered)
}
fn is_project_scoped(&self) -> bool {
true
}
fn run_project(&self, files: &[PathBuf]) -> Result<Vec<ToolDiagnostic>> {
let mut by_csproj: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for f in files {
let dir = f.parent().unwrap_or_else(|| Path::new("."));
if let Some(proj) = find_csproj(dir) {
by_csproj.entry(proj).or_default().push(f.clone());
} else {
tracing::debug!(
"run_project: no .csproj found for {}; skipping",
f.display()
);
}
}
let mut out = Vec::new();
for (csproj, proj_files) in &by_csproj {
let all_diags = build_project_diags(csproj);
for diag in all_diags {
let matches_any = proj_files
.iter()
.any(|pf| roslyn_file_matches(&diag.file, &pf.to_string_lossy()));
if matches_any {
out.push(diag);
}
}
}
Ok(out)
}
}
fn build_project_diags(csproj: &Path) -> Vec<ToolDiagnostic> {
let dir = csproj.parent().unwrap_or_else(|| Path::new("."));
let tmp = match tempfile::Builder::new().suffix(".sarif").tempfile() {
Ok(f) => f,
Err(e) => {
tracing::debug!("failed to create temp sarif file: {e}");
return Vec::new();
}
};
let sarif_path = tmp.into_temp_path();
let tmp_path = sarif_path.to_string_lossy().into_owned();
let csproj_path = csproj.to_string_lossy().into_owned();
let _ = run_command_with_timeout(
"dotnet",
&["restore", &csproj_path],
dir,
build_tool_timeout(),
);
let errorlog_arg = format!("-p:ErrorLog={}%2Cversion=2.1", tmp_path);
let build_res = run_command_with_timeout(
"dotnet",
&[
"build",
&csproj_path,
"--no-restore",
"--no-incremental",
&errorlog_arg,
"-p:EnableNETAnalyzers=true",
"-p:AnalysisLevel=latest-all",
"-p:EnforceCodeStyleInBuild=true",
],
dir,
build_tool_timeout(),
);
if let Err(e) = build_res {
tracing::debug!("dotnet build invocation failed: {e}");
}
let sarif = match std::fs::read_to_string(&tmp_path) {
Ok(s) => s,
Err(e) => {
tracing::debug!("failed to read SARIF output {tmp_path}: {e}");
String::new()
}
};
parse_roslyn_sarif(&sarif)
}
fn find_csproj(start: &Path) -> Option<PathBuf> {
const MAX_ASCENT: usize = 24;
let mut current = start;
for _ in 0..MAX_ASCENT {
if let Ok(entries) = std::fs::read_dir(current) {
let mut csprojs: Vec<PathBuf> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".csproj"))
.map(|e| e.path())
.collect();
if !csprojs.is_empty() {
csprojs.sort();
return csprojs.into_iter().next();
}
}
if current.join(".git").exists() {
return None;
}
match current.parent() {
Some(p) => current = p,
None => return None,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tools::Severity;
#[test]
fn roslyn_tool_is_project_scoped() {
assert!(RoslynTool.is_project_scoped());
}
#[test]
fn run_project_filters_diagnostics_to_requested_files() {
let diags: Vec<ToolDiagnostic> = vec![
ToolDiagnostic {
tool: "roslyn".into(),
file: "/abs/Proj/Foo.cs".into(),
line: 1,
col: 1,
severity: Severity::Warning,
code: Some("CA1".into()),
message: "foo".into(),
},
ToolDiagnostic {
tool: "roslyn".into(),
file: "/abs/Proj/Bar.cs".into(),
line: 2,
col: 1,
severity: Severity::Error,
code: Some("CA2".into()),
message: "bar".into(),
},
ToolDiagnostic {
tool: "roslyn".into(),
file: "/abs/Proj/Baz.cs".into(),
line: 3,
col: 1,
severity: Severity::Hint,
code: Some("CA3".into()),
message: "baz".into(),
},
];
let requested: Vec<PathBuf> = vec![
PathBuf::from("/abs/Proj/Foo.cs"),
PathBuf::from("/abs/Proj/Bar.cs"),
];
let kept: Vec<_> = diags
.iter()
.filter(|d| {
requested
.iter()
.any(|pf| roslyn_file_matches(&d.file, &pf.to_string_lossy()))
})
.collect();
assert_eq!(kept.len(), 2, "expected Foo.cs and Bar.cs diagnostics");
let codes: Vec<_> = kept.iter().filter_map(|d| d.code.as_deref()).collect();
assert!(codes.contains(&"CA1"), "Foo.cs diagnostic missing");
assert!(codes.contains(&"CA2"), "Bar.cs diagnostic missing");
assert!(!codes.contains(&"CA3"), "Baz.cs should be excluded");
}
}