use crate::error::AnalysisError;
use crate::ir::{File, Program};
use crate::lang::{LanguageAdapter, adapter_for_path, adapters};
use crate::metrics::{self, DEFINITION_VERSION, FileMetrics};
use crate::result::{AnalysisResult, FileResult, FunctionResult, MetricsOutput, RunInfo};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn analyze(root: &Path, project: Option<&str>) -> Result<AnalysisResult, AnalysisError> {
let root = &root.canonicalize().map_err(|e| io_error(root, &e))?;
let project = match project {
Some(p) => p.to_string(),
None => default_project_name(root)?,
};
let mut program = Program::default();
let mut failures = Vec::new();
for (path, relative) in targets(root)? {
let adapter = adapter_for_path(&relative.to_string_lossy())?;
match parse(adapter, &path, &relative) {
Ok(file) => program.files.push(file),
Err(failure) => failures.push(failure),
}
}
let dir = if root.is_dir() {
root
} else {
root.parent().expect("a canonical file path has a parent")
};
let run = run_info(
project,
git(dir, &["remote", "get-url", "origin"]),
git(dir, &["rev-parse", "HEAD"]),
&program,
);
Ok(assemble(program, failures, run))
}
pub fn analyze_source(filename: &str, source: &str) -> Result<AnalysisResult, AnalysisError> {
let file = adapter_for_path(filename)?.to_ir(filename, source)?;
let stem = Path::new(filename)
.file_stem()
.expect("a file name with a supported extension has a stem");
let project = stem.to_string_lossy().into_owned();
let program = Program { files: vec![file] };
let run = run_info(project, None, None, &program);
Ok(assemble(program, vec![], run))
}
fn assemble(program: Program, failures: Vec<FileResult>, run: RunInfo) -> AnalysisResult {
let computed = metrics::compute(&program);
let mut files: Vec<FileResult> = program
.files
.iter()
.zip(&computed.files)
.map(|(f, m)| file_result(f, m))
.collect();
files.extend(failures);
files.sort_by(|a, b| a.path().cmp(b.path()));
AnalysisResult {
run,
project: MetricsOutput::from(&computed.project),
files,
}
}
fn file_result(file: &File, metrics: &FileMetrics) -> FileResult {
let functions = file
.functions
.iter()
.zip(&metrics.functions)
.map(|(f, m)| FunctionResult {
name: f.name.clone(),
start_line: f.range.first_line(),
end_line: f.range.last_line(),
metrics: m.into(),
})
.collect();
FileResult::Ok {
path: file.path.clone(),
language: file.language.clone(),
metrics: (&metrics.metrics).into(),
functions,
}
}
fn io_error(path: &Path, e: &dyn std::fmt::Display) -> AnalysisError {
AnalysisError::Io {
path: path.display().to_string(),
message: e.to_string(),
}
}
fn targets(root: &Path) -> Result<Vec<(PathBuf, PathBuf)>, AnalysisError> {
if root.is_file() {
let name = PathBuf::from(root.file_name().expect("a file path has a file name"));
return Ok(vec![(root.to_path_buf(), name)]);
}
let supported = |p: &Path| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| adapters().any(|a| a.extensions().contains(&e)))
};
let mut targets = Vec::new();
for entry in ignore::WalkBuilder::new(root).build() {
let entry = entry.map_err(|e| io_error(root, &e))?;
let path = entry.path();
if entry.file_type().is_some_and(|t| t.is_file()) && supported(path) {
let relative = path
.strip_prefix(root)
.expect("walked paths are under the root")
.to_path_buf();
targets.push((path.to_path_buf(), relative));
}
}
targets.sort_by(|a, b| a.1.cmp(&b.1));
Ok(targets)
}
fn parse(adapter: &dyn LanguageAdapter, path: &Path, relative: &Path) -> Result<File, FileResult> {
let name = relative.to_string_lossy().replace('\\', "/");
let failure = |error: AnalysisError| FileResult::Error {
path: name.clone(),
language: Some(adapter.language().to_string()),
error: error.to_string(),
};
let source = std::fs::read_to_string(path).map_err(|e| {
failure(AnalysisError::Io {
path: name.clone(),
message: e.to_string(),
})
})?;
adapter.to_ir(&name, &source).map_err(failure)
}
fn run_info(
project: String,
repository: Option<String>,
commit: Option<String>,
program: &Program,
) -> RunInfo {
let parsers: BTreeMap<String, String> = program
.files
.iter()
.map(|f| {
let adapter = adapters()
.find(|a| a.language() == f.language)
.expect("files come from an adapter");
(f.language.clone(), adapter.parser_version())
})
.collect();
RunInfo {
project,
repository,
commit,
tool_version: env!("CARGO_PKG_VERSION").to_string(),
metric_definition_version: DEFINITION_VERSION.to_string(),
parsers,
timestamp: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.expect("UTC timestamps are always formattable"),
}
}
fn default_project_name(root: &Path) -> Result<String, AnalysisError> {
let name = if root.is_file() {
root.file_stem()
} else {
root.file_name()
};
name.map(|n| n.to_string_lossy().into_owned())
.ok_or_else(|| AnalysisError::Io {
path: root.display().to_string(),
message: "cannot derive a project name from this path; pass a project name explicitly"
.into(),
})
}
fn git(dir: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}