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