use anyhow::Result;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use crate::config::CppAnalyzerConfig;
use crate::file_index::FileIndex;
use crate::graph::Product;
use crate::processor::{check_command_output, format_command, run_command_capture};
use super::{DepAnalyzer, ScanResult};
const CPP_MATCH_EXTENSIONS: &[&str] = &["c", "cc", "cpp", "cxx"];
pub struct CppDepAnalyzer {
config: CppAnalyzerConfig,
verbose: bool,
canonical_root: OnceLock<PathBuf>,
pkg_config_include_paths: OnceLock<Vec<PathBuf>>,
command_include_paths: OnceLock<Vec<PathBuf>>,
}
impl CppDepAnalyzer {
pub const fn new(config: CppAnalyzerConfig, verbose: bool) -> Self {
Self {
config,
verbose,
canonical_root: OnceLock::new(),
pkg_config_include_paths: OnceLock::new(),
command_include_paths: OnceLock::new(),
}
}
fn canonical_root(&self) -> &Path {
self.canonical_root.get_or_init(|| {
Path::new(".")
.canonicalize()
.unwrap_or_else(|_| PathBuf::from("."))
})
}
fn get_pkg_config_include_paths(
&self,
ctx: &crate::build_context::BuildContext,
) -> Result<&[PathBuf]> {
super::cached_include_paths(&self.pkg_config_include_paths, || {
super::query_pkg_config_include_paths(ctx, "cpp", &self.config.pkg_config, self.verbose)
})
}
fn get_command_include_paths(
&self,
ctx: &crate::build_context::BuildContext,
) -> Result<&[PathBuf]> {
super::cached_include_paths(&self.command_include_paths, || {
super::run_include_path_commands(
ctx,
"cpp",
&self.config.include_path_commands,
self.verbose,
)
})
}
fn is_project_local(&self, path: &Path) -> bool {
path.canonicalize()
.is_ok_and(|canonical| canonical.starts_with(self.canonical_root()))
}
fn is_excluded(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
self.config
.src_exclude_dirs
.iter()
.any(|seg| path_str.contains(seg))
}
fn scan_dependencies_compiler(
&self,
ctx: &crate::build_context::BuildContext,
source: &Path,
is_cpp: bool,
) -> Result<Vec<PathBuf>> {
let compiler = if is_cpp {
&self.config.cxx
} else {
&self.config.cc
};
let mut cmd = Command::new(compiler);
cmd.arg("-MM");
for inc in &self.config.include_paths {
cmd.arg(format!("-I{inc}"));
}
for inc in self.get_pkg_config_include_paths(ctx)? {
cmd.arg(format!("-I{}", inc.display()));
}
for inc in self.get_command_include_paths(ctx)? {
cmd.arg(format!("-I{}", inc.display()));
}
let flags = if is_cpp {
&self.config.cxxflags
} else {
&self.config.cflags
};
for flag in flags {
cmd.arg(flag);
}
cmd.arg(source);
if self.verbose {
eprintln!("[cpp] {}", format_command(&cmd));
}
let output = run_command_capture(ctx, &cmd)?;
check_command_output(
&output,
format_args!("Dependency scan of {}", source.display()),
)?;
let content = String::from_utf8_lossy(&output.stdout).to_string();
Ok(self.parse_dep_file(&content))
}
fn parse_dep_file(&self, content: &str) -> Vec<PathBuf> {
let joined = content.replace("\\\n", " ");
let deps_part = match joined.find(':') {
Some(pos) => &joined[pos + 1..],
None => return Vec::new(),
};
let tokens: Vec<&str> = deps_part.split_whitespace().collect();
if tokens.is_empty() {
return Vec::new();
}
let canonical_root = self.canonical_root();
tokens[1..]
.iter()
.filter_map(|token| {
let path = PathBuf::from(token);
if path.is_absolute() {
if self.is_project_local(&path) {
if let Ok(rel) = path.strip_prefix(canonical_root) {
Some(rel.to_path_buf())
} else if let Ok(canonical) = path.canonicalize() {
canonical
.strip_prefix(canonical_root)
.ok()
.map(std::path::Path::to_path_buf)
} else {
None
}
} else {
None
}
} else {
Some(path)
}
})
.collect()
}
fn search_dirs(
&self,
ctx: &crate::build_context::BuildContext,
source: &Path,
) -> Result<Vec<PathBuf>> {
Ok(
std::iter::once(crate::processor::parent_dir_or_empty(source).to_path_buf())
.chain(self.config.include_paths.iter().map(PathBuf::from))
.chain(self.get_pkg_config_include_paths(ctx)?.iter().cloned())
.chain(self.get_command_include_paths(ctx)?.iter().cloned())
.map(|dir| without_cur_dir(&dir))
.collect(),
)
}
fn scan_dependencies(
&self,
ctx: &crate::build_context::BuildContext,
source: &Path,
is_cpp: bool,
) -> Result<Vec<PathBuf>> {
self.scan_dependencies_compiler(ctx, source, is_cpp)
}
}
impl DepAnalyzer for CppDepAnalyzer {
fn description(&self) -> &'static str {
"Scan C/C++ source files for #include dependencies"
}
fn enabled(&self) -> bool {
self.config.enabled
}
fn auto_detect(&self, file_index: &FileIndex) -> bool {
let extensions = [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"];
for ext in extensions {
if file_index.has_extension(ext) {
return true;
}
}
false
}
fn match_product(&self, p: &Product) -> Option<PathBuf> {
if p.inputs.is_empty() {
return None;
}
let source = &p.inputs[0];
if self.is_excluded(source) {
return None;
}
let ext = source.extension().and_then(|s| s.to_str()).unwrap_or("");
if CPP_MATCH_EXTENSIONS.contains(&ext) {
Some(source.clone())
} else {
None
}
}
fn scan(
&self,
ctx: &crate::build_context::BuildContext,
source: &Path,
_file_index: &FileIndex,
) -> Result<ScanResult> {
let ext = source.extension().and_then(|s| s.to_str()).unwrap_or("");
let is_cpp = ext == "cc" || ext == "cpp" || ext == "cxx";
let deps = self.scan_dependencies(ctx, source, is_cpp)?;
let absent = shadow_probes(&self.search_dirs(ctx, source)?, &deps);
Ok(ScanResult::deps(deps, absent))
}
fn fingerprint_parts(&self, ctx: &crate::build_context::BuildContext) -> Result<Vec<String>> {
let mut parts = super::config_fingerprint(&self.config)?;
parts.extend(
self.get_pkg_config_include_paths(ctx)?
.iter()
.chain(self.get_command_include_paths(ctx)?)
.map(|p| p.display().to_string()),
);
Ok(parts)
}
}
fn shadow_probes(search_dirs: &[PathBuf], deps: &[PathBuf]) -> Vec<PathBuf> {
let mut probes: Vec<PathBuf> = Vec::new();
for dep in deps {
for (found_at, dir) in search_dirs.iter().enumerate() {
let Ok(spelling) = dep.strip_prefix(dir) else {
continue;
};
for earlier in &search_dirs[..found_at] {
let probe = earlier.join(spelling);
if !probe.exists() && !probes.contains(&probe) {
probes.push(probe);
}
}
}
}
probes
}
fn without_cur_dir(dir: &Path) -> PathBuf {
dir.components()
.filter(|c| !matches!(c, std::path::Component::CurDir))
.collect()
}
inventory::submit! {
crate::registries::AnalyzerPlugin {
name: "cpp",
description: "Scan C/C++ source files for #include dependencies (using compiler -MM)",
is_native: false,
create: |toml_value, verbose| {
let cfg: CppAnalyzerConfig = toml::from_str(&toml::to_string(toml_value)?)?;
Ok(Box::new(CppDepAnalyzer::new(cfg, verbose)))
},
defconfig_toml: || {
toml::to_string_pretty(&crate::config::CppAnalyzerConfig::default()).ok()
},
known_fields: crate::registries::typed_known_fields::<crate::config::CppAnalyzerConfig>,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shadow_probes_cover_every_spelling_before_the_hit() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("src");
let include = tmp.path().join("include");
let other = tmp.path().join("other");
fs_create(&[&src, &include, &other]);
let header = include.join("x.h");
std::fs::write(&header, "").unwrap();
std::fs::write(other.join("x.h"), "").unwrap();
let dirs = vec![src.clone(), include, other];
let probes = shadow_probes(&dirs, &[header]);
assert_eq!(probes, vec![src.join("x.h")]);
}
#[test]
fn without_cur_dir_strips_dot_components() {
assert_eq!(
without_cur_dir(Path::new("./include")),
PathBuf::from("include")
);
assert_eq!(without_cur_dir(Path::new(".")), PathBuf::new());
}
fn fs_create(dirs: &[&Path]) {
for dir in dirs {
std::fs::create_dir_all(dir).unwrap();
}
}
}