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::{Component, 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    include_search_roots: Vec<CppIncludeSearchRoot>,
25}
26
27/// Why one compiler include-search entry exists.
28///
29/// The distinction is semantic. `-isystem` declares an external surface even
30/// when a test places that surface below the temporary workspace root, while an
31/// ordinary `-I` entry is external only when it points outside the workspace.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum CppIncludeSearchRootKind {
34    Project,
35    Quote,
36    System,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40struct CppIncludeSearchRoot {
41    path: PathBuf,
42    kind: CppIncludeSearchRootKind,
43}
44
45/// What all compile configurations for one source prove about an angle include.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum CppExternalIncludeResolution {
48    /// No compile command names the source file.
49    MissingCompileContext,
50    /// Every configuration agrees that no explicit external root contains it.
51    Undeclared,
52    /// Configurations select different headers, or only some select a header.
53    Conflicting,
54    /// Every configuration selects this exact external header.
55    Declared { root: PathBuf, header: PathBuf },
56}
57
58#[derive(Debug, Default)]
59pub struct CppCompileContexts {
60    by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
61}
62
63impl CppCompileContexts {
64    pub fn load(project: &dyn Project) -> Self {
65        let database_path = project.root().join("compile_commands.json");
66        let Ok(database) = std::fs::read_to_string(database_path) else {
67            return Self::default();
68        };
69        let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
70            return Self::default();
71        };
72
73        let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
74        for entry in entries {
75            let Some(source) = entry.source_path(project.root()) else {
76                continue;
77            };
78            if !source.starts_with(project.root()) {
79                continue;
80            }
81            let Some(context) = entry.compile_context(project.root()) else {
82                continue;
83            };
84            // A build that compiles one file in several configurations records
85            // one entry per configuration. Keeping every distinct one lets the
86            // caller decide per name whether the configurations agree; dropping
87            // them would make "compiled two ways" look like "never compiled".
88            // Entries that parse to the same context are one configuration.
89            let candidates = by_source.entry(source).or_default();
90            if !candidates.contains(&context) {
91                candidates.push(context);
92            }
93        }
94        Self { by_source }
95    }
96
97    /// Every distinct compile configuration the database records for `file`,
98    /// empty when no entry names it.
99    ///
100    /// Exactly one context is an unambiguous selection. More than one means the
101    /// include closures can differ, so a name is absent only where every
102    /// candidate agrees that it is.
103    pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
104        self.by_source
105            .get(&file.abs_path().normalize())
106            .map_or(&[], Vec::as_slice)
107    }
108
109    /// Resolve one angle include through explicit external roots in every
110    /// compile configuration for `file`.
111    ///
112    /// This method never probes an implicit compiler sysroot and never executes
113    /// the compiler. A result is declared only when every configuration selects
114    /// the same existing file. This preserves the multi-configuration honesty
115    /// required by C++ diagnostics and external semantic packs.
116    pub fn resolve_external_angle_include(
117        &self,
118        file: &ProjectFile,
119        include: &Path,
120    ) -> CppExternalIncludeResolution {
121        let contexts = self.contexts_for(file);
122        let Some(first_context) = contexts.first() else {
123            return CppExternalIncludeResolution::MissingCompileContext;
124        };
125        let first = first_context.resolve_external_angle_include(file.root(), include);
126        if contexts
127            .iter()
128            .skip(1)
129            .any(|context| context.resolve_external_angle_include(file.root(), include) != first)
130        {
131            return CppExternalIncludeResolution::Conflicting;
132        }
133        match first {
134            Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
135            None => CppExternalIncludeResolution::Undeclared,
136        }
137    }
138
139    /// Every distinct explicit root that can supply an external angle include.
140    ///
141    /// The result is sorted for deterministic dependency discovery. It is a
142    /// source-set inventory, not proof that every compile configuration reaches
143    /// every root; per-reference resolution must still use
144    /// [`Self::resolve_external_angle_include`].
145    pub fn external_angle_include_roots(&self, workspace_root: &Path) -> Vec<PathBuf> {
146        let mut roots = self
147            .by_source
148            .values()
149            .flatten()
150            .flat_map(|context| context.external_angle_include_roots(workspace_root))
151            .map(Path::to_path_buf)
152            .collect::<Vec<_>>();
153        roots.sort();
154        roots.dedup();
155        roots
156    }
157}
158
159impl CppCompileContext {
160    /// Explicit external roots in compiler search order for angle includes.
161    pub fn external_angle_include_roots<'a>(
162        &'a self,
163        workspace_root: &'a Path,
164    ) -> impl Iterator<Item = &'a Path> + 'a {
165        self.include_search_roots.iter().filter_map(move |root| {
166            (root.kind != CppIncludeSearchRootKind::Quote
167                && (root.kind == CppIncludeSearchRootKind::System
168                    || !root.path.starts_with(workspace_root)))
169            .then_some(root.path.as_path())
170        })
171    }
172
173    fn resolve_external_angle_include(
174        &self,
175        workspace_root: &Path,
176        include: &Path,
177    ) -> Option<(PathBuf, PathBuf)> {
178        if include.is_absolute()
179            || include
180                .components()
181                .any(|component| !matches!(component, Component::Normal(_)))
182        {
183            return None;
184        }
185        self.external_angle_include_roots(workspace_root)
186            .filter_map(|root| {
187                let root = root.canonicalize().ok()?;
188                let candidate = root.join(include).canonicalize().ok()?;
189                (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
190            })
191            .next()
192    }
193}
194
195#[derive(Debug, Deserialize)]
196struct CompilationDatabaseEntry {
197    directory: PathBuf,
198    file: PathBuf,
199    arguments: Option<Vec<String>>,
200    command: Option<String>,
201}
202
203impl CompilationDatabaseEntry {
204    fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
205        absolute_path(
206            &command_directory(workspace_root, &self.directory)?,
207            &self.file,
208        )
209    }
210
211    fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
212        let arguments = match &self.arguments {
213            Some(arguments) if !arguments.is_empty() => arguments.clone(),
214            Some(_) => return None,
215            None => shlex::split(self.command.as_deref()?)?,
216        };
217        parse_compile_arguments(
218            &command_directory(workspace_root, &self.directory)?,
219            &arguments,
220        )
221    }
222}
223
224fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
225    absolute_path(workspace_root, directory)
226}
227
228fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
229    if arguments.is_empty() {
230        return None;
231    }
232
233    let mut project_include_roots = Vec::new();
234    let mut system_include_roots = Vec::new();
235    let mut forced_includes = Vec::new();
236    let mut defined_macros = HashSet::default();
237    let mut include_search_roots = Vec::new();
238    let mut index = 1;
239    while index < arguments.len() {
240        let argument = &arguments[index];
241        match argument.as_str() {
242            "-I" | "/I" => {
243                let path = argument_path(directory, arguments.get(index + 1)?)?;
244                project_include_roots.push(path.clone());
245                include_search_roots.push(CppIncludeSearchRoot {
246                    path,
247                    kind: CppIncludeSearchRootKind::Project,
248                });
249                index += 2;
250            }
251            "-iquote" => {
252                let path = argument_path(directory, arguments.get(index + 1)?)?;
253                project_include_roots.push(path.clone());
254                include_search_roots.push(CppIncludeSearchRoot {
255                    path,
256                    kind: CppIncludeSearchRootKind::Quote,
257                });
258                index += 2;
259            }
260            "-isystem" | "/external:I" | "/imsvc" => {
261                let path = argument_path(directory, arguments.get(index + 1)?)?;
262                system_include_roots.push(path.clone());
263                include_search_roots.push(CppIncludeSearchRoot {
264                    path,
265                    kind: CppIncludeSearchRootKind::System,
266                });
267                index += 2;
268            }
269            "-include" => {
270                forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
271                index += 2;
272            }
273            "-D" => {
274                defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
275                index += 2;
276            }
277            _ => {
278                if let Some(path) = argument
279                    .strip_prefix("/external:I")
280                    .or_else(|| argument.strip_prefix("/imsvc"))
281                {
282                    let path = argument_path(directory, path)?;
283                    system_include_roots.push(path.clone());
284                    include_search_roots.push(CppIncludeSearchRoot {
285                        path,
286                        kind: CppIncludeSearchRootKind::System,
287                    });
288                } else if let Some(path) = argument
289                    .strip_prefix("-I")
290                    .or_else(|| argument.strip_prefix("/I"))
291                {
292                    let path = argument_path(directory, path)?;
293                    project_include_roots.push(path.clone());
294                    include_search_roots.push(CppIncludeSearchRoot {
295                        path,
296                        kind: CppIncludeSearchRootKind::Project,
297                    });
298                } else if let Some(path) = argument.strip_prefix("-iquote") {
299                    let path = argument_path(directory, path)?;
300                    project_include_roots.push(path.clone());
301                    include_search_roots.push(CppIncludeSearchRoot {
302                        path,
303                        kind: CppIncludeSearchRootKind::Quote,
304                    });
305                } else if let Some(path) = argument.strip_prefix("-isystem") {
306                    let path = argument_path(directory, path)?;
307                    system_include_roots.push(path.clone());
308                    include_search_roots.push(CppIncludeSearchRoot {
309                        path,
310                        kind: CppIncludeSearchRootKind::System,
311                    });
312                } else if let Some(definition) = argument.strip_prefix("-D") {
313                    defined_macros.insert(macro_name(definition)?);
314                }
315                index += 1;
316            }
317        }
318    }
319
320    Some(CppCompileContext {
321        project_include_roots,
322        system_include_roots,
323        forced_includes,
324        defined_macros,
325        include_search_roots,
326    })
327}
328
329fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
330    if raw.is_empty() {
331        return None;
332    }
333    absolute_path(directory, Path::new(raw))
334}
335
336fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
337    let path = if path.is_absolute() {
338        path.to_path_buf()
339    } else {
340        directory.join(path)
341    }
342    .normalize();
343    path.is_absolute().then_some(path)
344}
345
346fn macro_name(definition: &str) -> Option<String> {
347    let end = definition.find('=').unwrap_or(definition.len());
348    let name = &definition[..end];
349    (!name.is_empty()).then(|| name.to_string())
350}
351
352#[cfg(test)]
353mod tests {
354    use super::{CppCompileContexts, CppExternalIncludeResolution};
355    use brokk_bifrost_core::analyzer::project::TestProject;
356    use brokk_bifrost_core::analyzer::{Language, ProjectFile};
357
358    fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
359        let temp = tempfile::tempdir().expect("temp dir");
360        let root = temp.path().canonicalize().expect("canonical root");
361        ProjectFile::new(root.clone(), "src/main.cpp")
362            .write("int main() { return 0; }")
363            .expect("source");
364        if let Some(database) = database {
365            ProjectFile::new(root.clone(), "compile_commands.json")
366                .write(database)
367                .expect("database");
368        }
369        (temp, TestProject::new(root, Language::Cpp))
370    }
371
372    #[test]
373    fn missing_or_malformed_database_has_no_context() {
374        let (_temp, project) = project_with_database(None);
375        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
376        assert!(
377            CppCompileContexts::load(&project)
378                .contexts_for(&file)
379                .is_empty()
380        );
381
382        let (_temp, project) = project_with_database(Some("not json"));
383        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
384        assert!(
385            CppCompileContexts::load(&project)
386                .contexts_for(&file)
387                .is_empty()
388        );
389    }
390
391    #[test]
392    fn arguments_entry_collects_include_paths_and_macro_names() {
393        let (_temp, project) = project_with_database(Some(
394            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
395        ));
396        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
397        let contexts = CppCompileContexts::load(&project);
398        let [context] = contexts.contexts_for(&file) else {
399            panic!("one matching context");
400        };
401
402        assert_eq!(
403            vec![
404                project.root_path().join("include"),
405                project.root_path().join("quotes"),
406            ],
407            context.project_include_roots
408        );
409        assert_eq!(
410            vec![project.root_path().join("system-include")],
411            context.system_include_roots
412        );
413        assert!(context.defined_macros.contains("DEBUG"));
414        assert!(context.defined_macros.contains("FEATURE"));
415    }
416
417    #[test]
418    fn quoted_command_entry_is_tokenized_without_executing_it() {
419        let (_temp, project) = project_with_database(Some(
420            r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
421        ));
422        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
423        let contexts = CppCompileContexts::load(&project);
424        let [context] = contexts.contexts_for(&file) else {
425            panic!("one matching context");
426        };
427
428        assert_eq!(
429            vec![project.root_path().join("project include")],
430            context.project_include_roots
431        );
432        assert!(context.defined_macros.contains("NAME"));
433    }
434
435    #[test]
436    fn a_file_compiled_in_two_configurations_keeps_both() {
437        let (_temp, project) = project_with_database(Some(
438            r#"[
439                {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
440                {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
441                {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
442            ]"#,
443        ));
444        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
445        let contexts = CppCompileContexts::load(&project);
446        let candidates = contexts.contexts_for(&file);
447
448        // Before #1627 a second entry deleted the first and the file lost its
449        // context entirely, which read downstream as "never compiled".
450        assert_eq!(2, candidates.len());
451        assert!(candidates[0].defined_macros.is_empty());
452        assert!(candidates[1].defined_macros.contains("OTHER"));
453    }
454
455    #[test]
456    fn repeated_identical_entries_are_one_configuration() {
457        let (_temp, project) = project_with_database(Some(
458            r#"[
459                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
460                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
461            ]"#,
462        ));
463        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
464        let contexts = CppCompileContexts::load(&project);
465
466        // Two entries that parse to the same flags are not a disagreement, so
467        // the selection stays unambiguous.
468        assert_eq!(1, contexts.contexts_for(&file).len());
469    }
470
471    #[test]
472    fn an_unmatched_file_has_no_context() {
473        let (_temp, project) = project_with_database(Some(
474            r#"[{"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}]"#,
475        ));
476        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
477        assert!(
478            CppCompileContexts::load(&project)
479                .contexts_for(&file)
480                .is_empty()
481        );
482    }
483
484    #[test]
485    fn an_explicit_system_root_declares_an_angle_include() {
486        let (_temp, project) = project_with_database(Some(
487            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
488        ));
489        let header = ProjectFile::new(
490            project.root_path().to_path_buf(),
491            "fake-system/include/vector",
492        );
493        header
494            .write("namespace std { class vector {}; }")
495            .expect("header");
496        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
497
498        assert_eq!(
499            CppExternalIncludeResolution::Declared {
500                root: project
501                    .root_path()
502                    .join("fake-system/include")
503                    .canonicalize()
504                    .expect("canonical root"),
505                header: header.abs_path().canonicalize().expect("canonical header"),
506            },
507            CppCompileContexts::load(&project)
508                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
509        );
510    }
511
512    #[test]
513    fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
514        let external = tempfile::tempdir().expect("external root");
515        let external_root = external
516            .path()
517            .canonicalize()
518            .expect("canonical external root");
519        std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
520            .expect("external header");
521        let database = format!(
522            r#"[{{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","{}","-I","include","-c","src/main.cpp"]}}]"#,
523            external_root.display()
524        );
525        let (_temp, project) = project_with_database(Some(&database));
526        ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
527            .write("class Local {};")
528            .expect("local header");
529        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
530        let contexts = CppCompileContexts::load(&project);
531
532        assert!(matches!(
533            contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
534            CppExternalIncludeResolution::Declared { .. }
535        ));
536        assert_eq!(
537            CppExternalIncludeResolution::Undeclared,
538            contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
539        );
540    }
541
542    #[test]
543    fn configurations_must_agree_on_the_external_header() {
544        let first = tempfile::tempdir().expect("first root");
545        let second = tempfile::tempdir().expect("second root");
546        let first = first.path().canonicalize().expect("canonical first root");
547        let second = second.path().canonicalize().expect("canonical second root");
548        std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
549            .expect("first header");
550        std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
551            .expect("second header");
552        let database = format!(
553            r#"[
554                {{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}},
555                {{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}}
556            ]"#,
557            first.display(),
558            second.display()
559        );
560        let (_temp, project) = project_with_database(Some(&database));
561        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
562
563        assert_eq!(
564            CppExternalIncludeResolution::Conflicting,
565            CppCompileContexts::load(&project)
566                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
567        );
568    }
569
570    #[test]
571    fn external_include_cannot_escape_its_declared_root() {
572        let external = tempfile::tempdir().expect("external root");
573        let root = external.path().canonicalize().expect("canonical root");
574        std::fs::create_dir_all(root.join("include")).expect("include directory");
575        std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
576        let database = format!(
577            r#"[{{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}}]"#,
578            root.join("include").display()
579        );
580        let (_temp, project) = project_with_database(Some(&database));
581        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
582
583        assert_eq!(
584            CppExternalIncludeResolution::Undeclared,
585            CppCompileContexts::load(&project)
586                .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
587        );
588        assert_eq!(
589            CppExternalIncludeResolution::Undeclared,
590            CppCompileContexts::load(&project)
591                .resolve_external_angle_include(&file, &root.join("outside.hpp"))
592        );
593    }
594
595    #[test]
596    fn msvc_project_and_system_include_flags_preserve_search_order() {
597        let (_temp, project) = project_with_database(Some(
598            r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
599        ));
600        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
601        let contexts = CppCompileContexts::load(&project);
602        let [context] = contexts.contexts_for(&file) else {
603            panic!("one MSVC compile context");
604        };
605
606        assert_eq!(1, context.project_include_roots.len());
607        assert_eq!(2, context.system_include_roots.len());
608    }
609}