mod cache;
mod resolve;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
pub const PROBE_SCHEMA_VERSION: u32 = 4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedConfig {
pub schema_version: u32,
pub prober: String,
pub compiler_name: String,
pub version_line: String,
pub resolved_tokens: Option<Vec<String>>,
}
pub struct ProbeRequest<'a> {
pub compiler: &'a str,
pub args: &'a [String],
pub key_args: &'a [String],
pub per_tu_paths: &'a [String],
pub windows_aware: bool,
}
pub trait Prober {
fn id(&self) -> &'static str;
fn probe(&self, req: &ProbeRequest<'_>) -> Result<ResolvedConfig>;
}
pub struct CcProber;
impl Prober for CcProber {
fn id(&self) -> &'static str {
"cc"
}
fn probe(&self, req: &ProbeRequest<'_>) -> Result<ResolvedConfig> {
let output = Command::new(req.compiler)
.arg("--version")
.output()
.with_context(|| format!("running `{} --version`", req.compiler))?;
if !output.status.success() {
anyhow::bail!("`{} --version` exited {}", req.compiler, output.status);
}
let version_line = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("unknown")
.to_string();
let compiler_name = Path::new(req.compiler)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(req.compiler)
.to_string();
Ok(ResolvedConfig {
schema_version: PROBE_SCHEMA_VERSION,
prober: self.id().to_string(),
compiler_name,
version_line,
resolved_tokens: resolve_invocation(
req.compiler,
req.args,
req.windows_aware,
req.per_tu_paths,
),
})
}
}
fn resolve_invocation(
compiler: &str,
args: &[String],
windows_aware: bool,
per_tu_paths: &[String],
) -> Option<Vec<String>> {
let output = Command::new(compiler)
.arg("-###")
.args(args)
.output()
.ok()?;
let stderr = String::from_utf8_lossy(&output.stderr);
let resolved = resolve::resolved_semantic_tokens(&stderr, windows_aware, per_tu_paths);
if resolved.is_none() {
tracing::debug!(
compiler,
exit_code = ?output.status.code(),
stderr_lines = stderr.lines().count(),
stdout_lines = output.stdout.iter().filter(|b| **b == b'\n').count(),
"cc -### resolved no cc1 line; probe-captured flags will refuse. head:\n{}",
probe_stderr_head(&stderr)
);
}
resolved
}
fn probe_stderr_head(stderr: &str) -> String {
const MAX_LINES: usize = 12;
const MAX_CHARS: usize = 300;
stderr
.lines()
.take(MAX_LINES)
.map(|line| {
let line = line.trim();
match line.char_indices().nth(MAX_CHARS) {
Some((cut, _)) => format!("{}…", &line[..cut]),
None => line.to_string(),
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn probe(
cache_dir: &Path,
prober: &dyn Prober,
req: &ProbeRequest<'_>,
) -> Result<ResolvedConfig> {
let key = cache::probe_key(prober.id(), req);
if let Some(key) = &key
&& let Some(hit) = cache::load(cache_dir, key)
{
return Ok(hit);
}
crate::opcounts::record_probe_run();
let config = prober.probe(req)?;
if let Some(key) = &key {
cache::store(cache_dir, key, &config);
}
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::{NamedTempFile, TempDir};
#[derive(Default)]
struct CountingProber {
runs: AtomicUsize,
}
impl Prober for CountingProber {
fn id(&self) -> &'static str {
"test"
}
fn probe(&self, _req: &ProbeRequest<'_>) -> Result<ResolvedConfig> {
self.runs.fetch_add(1, Ordering::SeqCst);
Ok(ResolvedConfig {
schema_version: PROBE_SCHEMA_VERSION,
prober: "test".to_string(),
compiler_name: "fake".to_string(),
version_line: "fake 1.0".to_string(),
resolved_tokens: None,
})
}
}
fn req(compiler: &str) -> ProbeRequest<'_> {
ProbeRequest {
compiler,
args: &[],
key_args: &[],
per_tu_paths: &[],
windows_aware: true,
}
}
#[test]
fn probe_runs_prober_once_then_serves_from_cache() {
let cache = TempDir::new().unwrap();
let compiler = NamedTempFile::new().unwrap();
let prober = CountingProber::default();
let req = req(compiler.path().to_str().unwrap());
let first = probe(cache.path(), &prober, &req).unwrap();
let second = probe(cache.path(), &prober, &req).unwrap();
assert_eq!(first, second, "memoized result must match the original");
assert_eq!(
prober.runs.load(Ordering::SeqCst),
1,
"second probe must be served from the on-disk cache"
);
}
#[test]
fn probe_falls_back_to_running_when_compiler_is_unresolvable() {
let cache = TempDir::new().unwrap();
let prober = CountingProber::default();
let req = req("/nonexistent/kache-probe-test-cc");
let _ = probe(cache.path(), &prober, &req).unwrap();
let _ = probe(cache.path(), &prober, &req).unwrap();
assert_eq!(
prober.runs.load(Ordering::SeqCst),
2,
"an unkeyable probe is not memoized — both calls run"
);
}
#[test]
fn cc_prober_has_stable_id() {
assert_eq!(CcProber.id(), "cc");
}
#[test]
fn cc_prober_reads_a_real_compiler_version() {
let Ok(config) = CcProber.probe(&req("cc")) else {
return;
};
assert!(
!config.version_line.is_empty(),
"version line should be populated"
);
assert_eq!(config.prober, "cc");
assert_eq!(config.schema_version, PROBE_SCHEMA_VERSION);
}
#[test]
fn cc_prober_resolves_the_invocation_with_flags() {
let src = NamedTempFile::new().unwrap();
let args: Vec<String> = ["-O2", "-x", "c", "-c", src.path().to_str().unwrap()]
.iter()
.map(|s| s.to_string())
.collect();
let request = ProbeRequest {
compiler: "cc",
args: &args,
key_args: &args,
per_tu_paths: &[],
windows_aware: true,
};
let Ok(config) = CcProber.probe(&request) else {
return;
};
if let Some(tokens) = config.resolved_tokens {
assert!(
tokens.iter().any(|t| t == "-O2"),
"resolved `-cc1` tokens should carry -O2: {tokens:?}"
);
}
}
#[test]
fn probe_stderr_head_is_bounded_on_lines_and_chars() {
let long_line = "x".repeat(1000);
let many = (0..50)
.map(|i| format!("line{i} {long_line}"))
.collect::<Vec<_>>()
.join("\n");
let head = super::probe_stderr_head(&many);
assert_eq!(head.lines().count(), 12, "line budget must be enforced");
for line in head.lines() {
assert!(
line.chars().count() <= 301,
"char budget must be enforced (300 + ellipsis): {}",
line.chars().count()
);
assert!(line.ends_with('\u{2026}'), "a clipped line must say so");
}
}
#[test]
fn probe_stderr_head_clips_on_char_boundaries() {
let wide = "é".repeat(400);
let head = super::probe_stderr_head(&wide);
assert!(head.chars().count() <= 301);
assert!(head.starts_with('é'));
}
#[test]
fn probe_stderr_head_leaves_short_output_alone() {
let head = super::probe_stderr_head("clang version 19\nTarget: x86_64\n");
assert_eq!(head, "clang version 19\nTarget: x86_64");
}
}