use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct CompileCommand {
directory: String,
command: String,
file: String,
}
#[derive(Debug)]
pub struct CompileCommandsDb {
flags: HashMap<PathBuf, Vec<String>>,
}
impl CompileCommandsDb {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let entries: Vec<CompileCommand> = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let mut flags: HashMap<PathBuf, Vec<String>> = HashMap::new();
for entry in entries {
let file_path = if Path::new(&entry.file).is_absolute() {
PathBuf::from(&entry.file)
} else {
PathBuf::from(&entry.directory).join(&entry.file)
};
let extracted = extract_useful_flags(&entry.command);
flags.insert(file_path, extracted);
}
Ok(Self { flags })
}
pub fn get_flags(&self, source_file: &Path) -> &[String] {
self.flags
.get(source_file)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn find_in_project(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir;
loop {
let candidate = dir.join("compile_commands.json");
if candidate.is_file() {
return Some(candidate);
}
match dir.parent() {
Some(parent) => dir = parent,
None => return None,
}
}
}
}
fn extract_useful_flags(command: &str) -> Vec<String> {
let mut result = Vec::new();
let mut tokens = shell_split(command).into_iter().peekable();
tokens.next();
while let Some(token) = tokens.next() {
let t = token.as_str();
if t == "-c" || t == "-o" {
if t == "-o" {
tokens.next();
}
continue;
}
if t.starts_with("-D")
|| t.starts_with("-I")
|| t.starts_with("-isystem")
|| t.starts_with("-std=")
|| t.starts_with("-f")
|| t.starts_with("-m")
{
if (t == "-I" || t == "-isystem") && !t.contains(' ') {
result.push(token.clone());
if let Some(val) = tokens.next() {
result.push(val);
}
} else {
result.push(token.clone());
}
}
}
result
}
fn shell_split(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quote: Option<char> = None;
for ch in s.chars() {
match in_quote {
Some(q) if ch == q => {
in_quote = None;
}
Some(_) => {
current.push(ch);
}
None if ch == '"' || ch == '\'' => {
in_quote = Some(ch);
}
None if ch.is_whitespace() => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
None => {
current.push(ch);
}
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_extract_useful_flags_defines_and_includes() {
let cmd = "clang -c -DFOO=1 -I/usr/include -std=c11 -O2 -o out.o src.c";
let flags = extract_useful_flags(cmd);
assert!(flags.contains(&"-DFOO=1".to_string()));
assert!(flags.contains(&"-I/usr/include".to_string()));
assert!(flags.contains(&"-std=c11".to_string()));
assert!(!flags.contains(&"-c".to_string()));
assert!(!flags.contains(&"-o".to_string()));
assert!(!flags.contains(&"out.o".to_string()));
assert!(!flags.contains(&"src.c".to_string()));
}
#[test]
fn test_extract_useful_flags_split_include() {
let cmd = "clang -c -I /usr/include -o out.o src.c";
let flags = extract_useful_flags(cmd);
let i_pos = flags.iter().position(|f| f == "-I");
assert!(i_pos.is_some());
assert_eq!(flags[i_pos.unwrap() + 1], "/usr/include");
}
#[test]
fn test_load_compile_commands() {
let json = r#"[
{
"directory": "/src",
"command": "clang -c -DKERNEL=1 -I/src/include -o out.o drivers/foo.c",
"file": "/src/drivers/foo.c"
}
]"#;
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(json.as_bytes()).unwrap();
let db = CompileCommandsDb::load(tmp.path()).unwrap();
let flags = db.get_flags(Path::new("/src/drivers/foo.c"));
assert!(flags.contains(&"-DKERNEL=1".to_string()));
assert!(flags.contains(&"-I/src/include".to_string()));
}
#[test]
fn test_get_flags_unknown_file() {
let db = CompileCommandsDb {
flags: HashMap::new(),
};
assert!(db.get_flags(Path::new("/nonexistent.c")).is_empty());
}
#[test]
fn test_find_in_project_not_found() {
assert!(CompileCommandsDb::find_in_project(Path::new("/tmp")).is_none());
}
}