magellan 4.12.3

Deterministic codebase mapping tool for local development
Documentation
//! compile_commands.json loader for per-file C/C++ compilation flags.
//!
//! When a project provides a `compile_commands.json` (generated by cmake, bear, etc.),
//! magellan reads per-file flags (includes, defines, standard version) and passes them
//! to clang during LLVM IR compilation. This enables accurate CFG/call-graph extraction
//! for projects that require non-trivial build flags (e.g., the Linux kernel).

use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// One entry from compile_commands.json
#[derive(Debug, Deserialize)]
struct CompileCommand {
    /// Working directory for this compilation unit
    directory: String,
    /// Full compiler invocation (shell command)
    command: String,
    /// Source file this entry describes
    file: String,
}

/// Parsed compile_commands.json database.
///
/// Maps canonical source paths to the extra flags needed for compilation.
/// Loaded once and shared for an entire indexing run.
#[derive(Debug)]
pub struct CompileCommandsDb {
    /// file path (canonicalized) → flags to pass to clang
    flags: HashMap<PathBuf, Vec<String>>,
}

impl CompileCommandsDb {
    /// Load and parse compile_commands.json from `path`.
    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 })
    }

    /// Get compilation flags for `source_file`.
    ///
    /// Returns an empty slice when the file is not in the database.
    pub fn get_flags(&self, source_file: &Path) -> &[String] {
        self.flags
            .get(source_file)
            .map(|v| v.as_slice())
            .unwrap_or(&[])
    }

    /// Search for `compile_commands.json` by walking up from `start_dir`.
    ///
    /// Returns the first path found, or `None` if none exists.
    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,
            }
        }
    }
}

/// Extract flags useful for standalone compilation from a full compiler command.
///
/// Keeps `-D`, `-I`, `-isystem`, `-std=`, `-f`, `-m`, `-W` flags.
/// Drops input files, output flags, `-c`, `-o`, and the compiler binary itself.
fn extract_useful_flags(command: &str) -> Vec<String> {
    let mut result = Vec::new();
    let mut tokens = shell_split(command).into_iter().peekable();

    // Skip the compiler binary (first token)
    tokens.next();

    while let Some(token) = tokens.next() {
        let t = token.as_str();
        if t == "-c" || t == "-o" {
            // -o is followed by a value, consume it
            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")
        {
            // Some flags use separate value token: -I /path, -isystem /path
            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
}

/// Minimal shell-like splitting (handles quoted strings).
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());
    }
}