Skip to main content

brokk_bifrost_cpp/
compile_context.rs

1//! `compile_commands.json` ingestion.
2//!
3//! `analyzer/cpp/mod.rs` keeps the `OnceLock<CppCompileContexts>` that memoizes
4//! [`CppCompileContexts::load`] per analyzer generation; the database format and
5//! the argument grammar are here.
6
7use brokk_bifrost_core::analyzer::ProjectFile;
8use brokk_bifrost_core::analyzer::project::Project;
9use brokk_bifrost_core::hash::{HashMap, HashSet};
10use brokk_bifrost_core::path_normalization::NormalizePath;
11use serde::Deserialize;
12use std::path::{Path, PathBuf};
13
14/// The compiler configuration Bifrost can safely use for one source file.
15///
16/// This is deliberately narrower than a compiler invocation. It records only
17/// context that later semantic diagnostics need and never executes `command`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CppCompileContext {
20    pub project_include_roots: Vec<PathBuf>,
21    pub system_include_roots: Vec<PathBuf>,
22    pub forced_includes: Vec<PathBuf>,
23    pub defined_macros: HashSet<String>,
24}
25
26#[derive(Debug, Default)]
27pub struct CppCompileContexts {
28    by_source: HashMap<PathBuf, CppCompileContext>,
29}
30
31impl CppCompileContexts {
32    pub fn load(project: &dyn Project) -> Self {
33        let database_path = project.root().join("compile_commands.json");
34        let Ok(database) = std::fs::read_to_string(database_path) else {
35            return Self::default();
36        };
37        let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
38            return Self::default();
39        };
40
41        let mut by_source = HashMap::default();
42        let mut ambiguous_sources = HashSet::default();
43        for entry in entries {
44            let Some(source) = entry.source_path(project.root()) else {
45                continue;
46            };
47            if !source.starts_with(project.root()) || ambiguous_sources.contains(&source) {
48                continue;
49            }
50            let Some(context) = entry.compile_context(project.root()) else {
51                continue;
52            };
53            if by_source.insert(source.clone(), context).is_some() {
54                by_source.remove(&source);
55                ambiguous_sources.insert(source);
56            }
57        }
58        Self { by_source }
59    }
60
61    pub fn for_file(&self, file: &ProjectFile) -> Option<&CppCompileContext> {
62        self.by_source.get(&file.abs_path().normalize())
63    }
64}
65
66#[derive(Debug, Deserialize)]
67struct CompilationDatabaseEntry {
68    directory: PathBuf,
69    file: PathBuf,
70    arguments: Option<Vec<String>>,
71    command: Option<String>,
72}
73
74impl CompilationDatabaseEntry {
75    fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
76        absolute_path(
77            &command_directory(workspace_root, &self.directory)?,
78            &self.file,
79        )
80    }
81
82    fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
83        let arguments = match &self.arguments {
84            Some(arguments) if !arguments.is_empty() => arguments.clone(),
85            Some(_) => return None,
86            None => shlex::split(self.command.as_deref()?)?,
87        };
88        parse_compile_arguments(
89            &command_directory(workspace_root, &self.directory)?,
90            &arguments,
91        )
92    }
93}
94
95fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
96    absolute_path(workspace_root, directory)
97}
98
99fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
100    if arguments.is_empty() {
101        return None;
102    }
103
104    let mut project_include_roots = Vec::new();
105    let mut system_include_roots = Vec::new();
106    let mut forced_includes = Vec::new();
107    let mut defined_macros = HashSet::default();
108    let mut index = 1;
109    while index < arguments.len() {
110        let argument = &arguments[index];
111        match argument.as_str() {
112            "-I" => {
113                project_include_roots.push(argument_path(directory, arguments.get(index + 1)?)?);
114                index += 2;
115            }
116            "-iquote" => {
117                project_include_roots.push(argument_path(directory, arguments.get(index + 1)?)?);
118                index += 2;
119            }
120            "-isystem" => {
121                system_include_roots.push(argument_path(directory, arguments.get(index + 1)?)?);
122                index += 2;
123            }
124            "-include" => {
125                forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
126                index += 2;
127            }
128            "-D" => {
129                defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
130                index += 2;
131            }
132            _ => {
133                if let Some(path) = argument.strip_prefix("-I") {
134                    project_include_roots.push(argument_path(directory, path)?);
135                } else if let Some(path) = argument.strip_prefix("-iquote") {
136                    project_include_roots.push(argument_path(directory, path)?);
137                } else if let Some(path) = argument.strip_prefix("-isystem") {
138                    system_include_roots.push(argument_path(directory, path)?);
139                } else if let Some(definition) = argument.strip_prefix("-D") {
140                    defined_macros.insert(macro_name(definition)?);
141                }
142                index += 1;
143            }
144        }
145    }
146
147    Some(CppCompileContext {
148        project_include_roots,
149        system_include_roots,
150        forced_includes,
151        defined_macros,
152    })
153}
154
155fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
156    if raw.is_empty() {
157        return None;
158    }
159    absolute_path(directory, Path::new(raw))
160}
161
162fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
163    let path = if path.is_absolute() {
164        path.to_path_buf()
165    } else {
166        directory.join(path)
167    }
168    .normalize();
169    path.is_absolute().then_some(path)
170}
171
172fn macro_name(definition: &str) -> Option<String> {
173    let end = definition.find('=').unwrap_or(definition.len());
174    let name = &definition[..end];
175    (!name.is_empty()).then(|| name.to_string())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::CppCompileContexts;
181    use brokk_bifrost_core::analyzer::project::TestProject;
182    use brokk_bifrost_core::analyzer::{Language, ProjectFile};
183
184    fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
185        let temp = tempfile::tempdir().expect("temp dir");
186        let root = temp.path().canonicalize().expect("canonical root");
187        ProjectFile::new(root.clone(), "src/main.cpp")
188            .write("int main() { return 0; }")
189            .expect("source");
190        if let Some(database) = database {
191            ProjectFile::new(root.clone(), "compile_commands.json")
192                .write(database)
193                .expect("database");
194        }
195        (temp, TestProject::new(root, Language::Cpp))
196    }
197
198    #[test]
199    fn missing_or_malformed_database_has_no_context() {
200        let (_temp, project) = project_with_database(None);
201        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
202        assert!(CppCompileContexts::load(&project).for_file(&file).is_none());
203
204        let (_temp, project) = project_with_database(Some("not json"));
205        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
206        assert!(CppCompileContexts::load(&project).for_file(&file).is_none());
207    }
208
209    #[test]
210    fn arguments_entry_collects_include_paths_and_macro_names() {
211        let (_temp, project) = project_with_database(Some(
212            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
213        ));
214        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
215        let contexts = CppCompileContexts::load(&project);
216        let context = contexts.for_file(&file).expect("matching context");
217
218        assert_eq!(
219            vec![
220                project.root_path().join("include"),
221                project.root_path().join("quotes"),
222            ],
223            context.project_include_roots
224        );
225        assert_eq!(
226            vec![project.root_path().join("system-include")],
227            context.system_include_roots
228        );
229        assert!(context.defined_macros.contains("DEBUG"));
230        assert!(context.defined_macros.contains("FEATURE"));
231    }
232
233    #[test]
234    fn quoted_command_entry_is_tokenized_without_executing_it() {
235        let (_temp, project) = project_with_database(Some(
236            r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
237        ));
238        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
239        let contexts = CppCompileContexts::load(&project);
240        let context = contexts.for_file(&file).expect("matching context");
241
242        assert_eq!(
243            vec![project.root_path().join("project include")],
244            context.project_include_roots
245        );
246        assert!(context.defined_macros.contains("NAME"));
247    }
248
249    #[test]
250    fn duplicate_or_unmatched_entries_do_not_supply_context() {
251        let (_temp, project) = project_with_database(Some(
252            r#"[
253                {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
254                {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
255                {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
256            ]"#,
257        ));
258        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
259        assert!(CppCompileContexts::load(&project).for_file(&file).is_none());
260    }
261}