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::collections::BTreeSet;
13use std::ffi::OsString;
14use std::path::{Component, Path, PathBuf};
15
16const COMPILATION_DATABASE_PATH: &str = "compile_commands.json";
17
18/// The maximum number of workspace-relative source paths retained in the
19/// compile-database coverage report. The count remains exact; the sample is
20/// intentionally bounded so a generated database cannot make discovery
21/// evidence unbounded.
22const MAX_MISSING_WORKSPACE_SOURCE_SAMPLE: usize = 32;
23
24/// Whether `file` is the workspace compilation database consumed by
25/// [`CppCompileContexts::load`].
26pub fn is_cpp_compile_context_input(file: &ProjectFile) -> bool {
27    file.rel_path() == Path::new(COMPILATION_DATABASE_PATH)
28}
29
30/// The compiler configuration Bifrost can safely use for one source file.
31///
32/// This is deliberately narrower than a compiler invocation. It records only
33/// context that later semantic diagnostics need and never executes `command`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CppCompileContext {
36    pub project_include_roots: Vec<PathBuf>,
37    pub system_include_roots: Vec<PathBuf>,
38    pub forced_includes: Vec<PathBuf>,
39    pub defined_macros: HashSet<String>,
40    include_search_roots: Vec<CppIncludeSearchRoot>,
41    /// The invocation's driver basename (`arguments[0]`, minus one trailing
42    /// extension such as `.exe`), used by [`Self::tu_language`] only when
43    /// nothing stronger settles the language.
44    driver_basename: Option<String>,
45    /// Language forced by `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
46    /// The strongest evidence [`Self::tu_language`] considers.
47    explicit_language: Option<CompiledLanguage>,
48    /// Language implied by `-std=<value>`: the C family (`c11`, `gnu17`, ...)
49    /// versus the C++ family (`c++17`, `gnu++20`, ...), told apart by whether
50    /// the value contains `++`.
51    std_language: Option<CompiledLanguage>,
52}
53
54/// The language a compile-database entry says its translation unit is
55/// compiled as. See [`CppCompileContext::tu_language`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CompiledLanguage {
58    C,
59    Cpp,
60}
61
62/// Why one compiler include-search entry exists.
63///
64/// The distinction is semantic. `-isystem` declares an external surface even
65/// when a test places that surface below the temporary workspace root, while an
66/// ordinary `-I` entry is external only when it points outside the workspace.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68enum CppIncludeSearchRootKind {
69    Project,
70    Quote,
71    System,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75struct CppIncludeSearchRoot {
76    path: PathBuf,
77    kind: CppIncludeSearchRootKind,
78}
79
80/// What all compile configurations for one source prove about an angle include.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CppExternalIncludeResolution {
83    /// No compile command names the source file.
84    MissingCompileContext,
85    /// Every configuration agrees that no explicit external root contains it.
86    Undeclared,
87    /// Configurations select different headers, or only some select a header.
88    Conflicting,
89    /// Every configuration selects this exact external header.
90    Declared { root: PathBuf, header: PathBuf },
91}
92
93/// Informational coverage from one compile database against the analyzer's
94/// C++ workspace listing.
95///
96/// A missing source is not a discovery failure. Build systems routinely leave
97/// generated translation units in `compile_commands.json` after a clean or
98/// partial checkout. This report is therefore kept separate from compile
99/// contexts and dependency-discovery completeness. The count is exact over
100/// distinct canonical workspace-relative paths; the sample is deterministic
101/// and bounded by [`MAX_MISSING_WORKSPACE_SOURCE_SAMPLE`].
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct CppCompileDatabaseCoverage {
104    missing_workspace_source_count: usize,
105    missing_workspace_source_sample: Vec<PathBuf>,
106}
107
108impl CppCompileDatabaseCoverage {
109    /// The exact number of distinct in-workspace compile-database source paths
110    /// absent from the supplied analyzer listing.
111    pub fn missing_workspace_source_count(&self) -> usize {
112        self.missing_workspace_source_count
113    }
114
115    /// A sorted, workspace-relative sample of missing source paths.
116    pub fn missing_workspace_source_sample(&self) -> &[PathBuf] {
117        &self.missing_workspace_source_sample
118    }
119}
120
121#[derive(Debug, Default)]
122pub struct CppCompileContexts {
123    by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
124    /// Every distinct source path named by an in-workspace compile-database
125    /// entry, including entries whose compiler arguments are malformed. The
126    /// source identity is retained independently of whether a context can be
127    /// used for include resolution so coverage can report stale generated
128    /// entries without turning them into diagnostics.
129    database_sources: HashSet<PathBuf>,
130}
131
132impl CppCompileContexts {
133    pub fn load(project: &dyn Project) -> Self {
134        let workspace_root = canonical_or_normalized_path(project.root());
135        let database_path = workspace_root.join(COMPILATION_DATABASE_PATH);
136        let Ok(database) = std::fs::read_to_string(database_path) else {
137            return Self::default();
138        };
139        let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
140            return Self::default();
141        };
142
143        let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
144        let mut database_sources = HashSet::default();
145        for entry in entries {
146            let Some(source) = entry.source_path(&workspace_root) else {
147                continue;
148            };
149            let source_identity = canonical_or_normalized_path(&source);
150            if source_identity.strip_prefix(&workspace_root).is_err() {
151                continue;
152            }
153            database_sources.insert(source_identity.clone());
154            let Some(context) = entry.compile_context(&workspace_root) else {
155                continue;
156            };
157            // A build that compiles one file in several configurations records
158            // one entry per configuration. Keeping every distinct one lets the
159            // caller decide per name whether the configurations agree; dropping
160            // them would make "compiled two ways" look like "never compiled".
161            // Entries that parse to the same context are one configuration.
162            let candidates = by_source.entry(source_identity).or_default();
163            if !candidates.contains(&context) {
164                candidates.push(context);
165            }
166        }
167        Self {
168            by_source,
169            database_sources,
170        }
171    }
172
173    /// Compare compile-database source identities with the analyzer's exact
174    /// C++ workspace listing.
175    ///
176    /// The listing is supplied by the caller so discovery can preserve its
177    /// listing-driven scan and avoid a second workspace walk. Both sides are
178    /// compared as canonical [`PathBuf`] identities under `workspace_root`;
179    /// no source text, display path, or string-prefix identity participates.
180    /// Entries outside `workspace_root` are ordinary build inputs and are
181    /// silently excluded.
182    pub fn missing_workspace_sources<'a>(
183        &self,
184        workspace_root: &Path,
185        workspace_files: impl IntoIterator<Item = &'a ProjectFile>,
186    ) -> CppCompileDatabaseCoverage {
187        let workspace_root = canonical_or_normalized_path(workspace_root);
188        let listed_sources = workspace_files
189            .into_iter()
190            .map(|file| canonical_or_normalized_path(&file.abs_path()))
191            .collect::<HashSet<_>>();
192        let mut missing_count = 0usize;
193        let mut sample = BTreeSet::new();
194        for source in &self.database_sources {
195            let Some(relative) = (|| {
196                let relative = source.strip_prefix(&workspace_root).ok()?;
197                (!relative.as_os_str().is_empty() && !listed_sources.contains(source))
198                    .then(|| relative.to_path_buf().normalize())
199            })() else {
200                continue;
201            };
202            missing_count = missing_count.saturating_add(1);
203            sample.insert(relative);
204            if sample.len() > MAX_MISSING_WORKSPACE_SOURCE_SAMPLE {
205                sample.pop_last();
206            }
207        }
208        CppCompileDatabaseCoverage {
209            missing_workspace_source_count: missing_count,
210            missing_workspace_source_sample: sample.into_iter().collect(),
211        }
212    }
213
214    /// Every distinct compile configuration the database records for `file`,
215    /// empty when no entry names it.
216    ///
217    /// Exactly one context is an unambiguous selection. More than one means the
218    /// include closures can differ, so a name is absent only where every
219    /// candidate agrees that it is.
220    pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
221        self.by_source
222            .get(&canonical_or_normalized_path(&file.abs_path()))
223            .map_or(&[], Vec::as_slice)
224    }
225
226    /// Resolve one angle include through explicit external roots in every
227    /// compile configuration for `file`.
228    ///
229    /// This method never probes an implicit compiler sysroot and never executes
230    /// the compiler. A result is declared only when every configuration selects
231    /// the same existing file. This preserves the multi-configuration honesty
232    /// required by C++ diagnostics and external semantic packs.
233    pub fn resolve_external_angle_include(
234        &self,
235        file: &ProjectFile,
236        include: &Path,
237    ) -> CppExternalIncludeResolution {
238        let contexts = self.contexts_for(file);
239        let Some(first_context) = contexts.first() else {
240            return CppExternalIncludeResolution::MissingCompileContext;
241        };
242        let first = first_context.resolve_external_angle_include(file.root(), include);
243        if contexts
244            .iter()
245            .skip(1)
246            .any(|context| context.resolve_external_angle_include(file.root(), include) != first)
247        {
248            return CppExternalIncludeResolution::Conflicting;
249        }
250        match first {
251            Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
252            None => CppExternalIncludeResolution::Undeclared,
253        }
254    }
255
256    /// Every distinct explicit root that can supply an external angle include.
257    ///
258    /// The result is sorted for deterministic dependency discovery. It is a
259    /// source-set inventory, not proof that every compile configuration reaches
260    /// every root; per-reference resolution must still use
261    /// [`Self::resolve_external_angle_include`].
262    pub fn external_angle_include_roots(&self, workspace_root: &Path) -> Vec<PathBuf> {
263        let mut roots = self
264            .by_source
265            .values()
266            .flatten()
267            .flat_map(|context| context.external_angle_include_roots(workspace_root))
268            .map(Path::to_path_buf)
269            .collect::<Vec<_>>();
270        roots.sort();
271        roots.dedup();
272        roots
273    }
274}
275
276impl CppCompileContext {
277    /// The language this compile configuration says `source_path` is
278    /// compiled as, strongest evidence first:
279    ///
280    /// 1. An explicit `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
281    /// 2. A `-std=<value>` family (C when the value has no `++`, C++ when it
282    ///    does).
283    /// 3. The driver basename: `g++`, `clang++`, `c++`, and similar names
284    ///    ending in `++` compile as C++ regardless of extension.
285    /// 4. `source_path`'s extension: exactly `c` (case-sensitive, matching
286    ///    `is_c_source_file`) is C, everything else is C++. This is also the
287    ///    rule `cl.exe` itself uses when neither `/TC` nor `/TP` is given.
288    pub fn tu_language(&self, source_path: &Path) -> CompiledLanguage {
289        if let Some(language) = self.explicit_language {
290            return language;
291        }
292        if let Some(language) = self.std_language {
293            return language;
294        }
295        if self
296            .driver_basename
297            .as_deref()
298            .is_some_and(|driver| driver.ends_with("++"))
299        {
300            return CompiledLanguage::Cpp;
301        }
302        if source_path
303            .extension()
304            .and_then(|extension| extension.to_str())
305            == Some("c")
306        {
307            CompiledLanguage::C
308        } else {
309            CompiledLanguage::Cpp
310        }
311    }
312
313    /// Explicit external roots in compiler search order for angle includes.
314    pub fn external_angle_include_roots<'a>(
315        &'a self,
316        workspace_root: &'a Path,
317    ) -> impl Iterator<Item = &'a Path> + 'a {
318        self.include_search_roots.iter().filter_map(move |root| {
319            (root.kind != CppIncludeSearchRootKind::Quote
320                && (root.kind == CppIncludeSearchRootKind::System
321                    || !root.path.starts_with(workspace_root)))
322            .then_some(root.path.as_path())
323        })
324    }
325
326    fn resolve_external_angle_include(
327        &self,
328        workspace_root: &Path,
329        include: &Path,
330    ) -> Option<(PathBuf, PathBuf)> {
331        if include.is_absolute()
332            || include
333                .components()
334                .any(|component| !matches!(component, Component::Normal(_)))
335        {
336            return None;
337        }
338        self.external_angle_include_roots(workspace_root)
339            .filter_map(|root| {
340                let root = root.canonicalize().ok()?;
341                let candidate = root.join(include).canonicalize().ok()?;
342                (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
343            })
344            .next()
345    }
346}
347
348#[derive(Debug, Deserialize)]
349struct CompilationDatabaseEntry {
350    directory: PathBuf,
351    file: PathBuf,
352    arguments: Option<Vec<String>>,
353    command: Option<String>,
354}
355
356impl CompilationDatabaseEntry {
357    fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
358        absolute_path(
359            &command_directory(workspace_root, &self.directory)?,
360            &self.file,
361        )
362    }
363
364    fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
365        let arguments = match &self.arguments {
366            Some(arguments) if !arguments.is_empty() => arguments.clone(),
367            Some(_) => return None,
368            None => shlex::split(self.command.as_deref()?)?,
369        };
370        parse_compile_arguments(
371            &command_directory(workspace_root, &self.directory)?,
372            &arguments,
373        )
374    }
375}
376
377fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
378    absolute_path(workspace_root, directory)
379}
380
381fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
382    if arguments.is_empty() {
383        return None;
384    }
385
386    let driver_basename = driver_basename(&arguments[0]);
387    let mut project_include_roots = Vec::new();
388    let mut system_include_roots = Vec::new();
389    let mut forced_includes = Vec::new();
390    let mut defined_macros = HashSet::default();
391    let mut include_search_roots = Vec::new();
392    let mut explicit_language = None;
393    let mut std_language = None;
394    let mut index = 1;
395    while index < arguments.len() {
396        let argument = &arguments[index];
397        match argument.as_str() {
398            "-x" => {
399                if let Some(language) = language_from_x_value(arguments.get(index + 1)?) {
400                    explicit_language = Some(language);
401                }
402                index += 2;
403            }
404            "/TC" => {
405                explicit_language = Some(CompiledLanguage::C);
406                index += 1;
407            }
408            "/TP" => {
409                explicit_language = Some(CompiledLanguage::Cpp);
410                index += 1;
411            }
412            "-I" | "/I" => {
413                let path = argument_path(directory, arguments.get(index + 1)?)?;
414                project_include_roots.push(path.clone());
415                include_search_roots.push(CppIncludeSearchRoot {
416                    path,
417                    kind: CppIncludeSearchRootKind::Project,
418                });
419                index += 2;
420            }
421            "-iquote" => {
422                let path = argument_path(directory, arguments.get(index + 1)?)?;
423                project_include_roots.push(path.clone());
424                include_search_roots.push(CppIncludeSearchRoot {
425                    path,
426                    kind: CppIncludeSearchRootKind::Quote,
427                });
428                index += 2;
429            }
430            "-isystem" | "/external:I" | "/imsvc" => {
431                let path = argument_path(directory, arguments.get(index + 1)?)?;
432                system_include_roots.push(path.clone());
433                include_search_roots.push(CppIncludeSearchRoot {
434                    path,
435                    kind: CppIncludeSearchRootKind::System,
436                });
437                index += 2;
438            }
439            "-include" => {
440                forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
441                index += 2;
442            }
443            "-D" => {
444                defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
445                index += 2;
446            }
447            // The compiler applies -D and -U in command order, so a later -U
448            // removes an earlier -D. An -U proves nothing on its own: a header
449            // can still define the name, so only the surviving defines are
450            // positive facts (#2011).
451            "-U" => {
452                defined_macros.remove(&macro_name(arguments.get(index + 1)?)?);
453                index += 2;
454            }
455            _ => {
456                if let Some(path) = argument
457                    .strip_prefix("/external:I")
458                    .or_else(|| argument.strip_prefix("/imsvc"))
459                {
460                    let path = argument_path(directory, path)?;
461                    system_include_roots.push(path.clone());
462                    include_search_roots.push(CppIncludeSearchRoot {
463                        path,
464                        kind: CppIncludeSearchRootKind::System,
465                    });
466                } else if let Some(path) = argument
467                    .strip_prefix("-I")
468                    .or_else(|| argument.strip_prefix("/I"))
469                {
470                    let path = argument_path(directory, path)?;
471                    project_include_roots.push(path.clone());
472                    include_search_roots.push(CppIncludeSearchRoot {
473                        path,
474                        kind: CppIncludeSearchRootKind::Project,
475                    });
476                } else if let Some(path) = argument.strip_prefix("-iquote") {
477                    let path = argument_path(directory, path)?;
478                    project_include_roots.push(path.clone());
479                    include_search_roots.push(CppIncludeSearchRoot {
480                        path,
481                        kind: CppIncludeSearchRootKind::Quote,
482                    });
483                } else if let Some(path) = argument.strip_prefix("-isystem") {
484                    let path = argument_path(directory, path)?;
485                    system_include_roots.push(path.clone());
486                    include_search_roots.push(CppIncludeSearchRoot {
487                        path,
488                        kind: CppIncludeSearchRootKind::System,
489                    });
490                } else if let Some(definition) = argument.strip_prefix("-D") {
491                    defined_macros.insert(macro_name(definition)?);
492                } else if let Some(name) = argument.strip_prefix("-U") {
493                    defined_macros.remove(&macro_name(name)?);
494                } else if let Some(value) = argument.strip_prefix("-std=") {
495                    std_language = Some(if value.contains("++") {
496                        CompiledLanguage::Cpp
497                    } else {
498                        CompiledLanguage::C
499                    });
500                } else if let Some(value) = argument.strip_prefix("-x")
501                    && let Some(language) = language_from_x_value(value)
502                {
503                    explicit_language = Some(language);
504                }
505                index += 1;
506            }
507        }
508    }
509
510    Some(CppCompileContext {
511        project_include_roots,
512        system_include_roots,
513        forced_includes,
514        defined_macros,
515        include_search_roots,
516        driver_basename,
517        explicit_language,
518        std_language,
519    })
520}
521
522/// The driver invocation's basename, with one trailing extension (`.exe` on
523/// Windows) stripped. `-c++`-style drivers such as `g++`, `clang++`, and
524/// `arm-none-eabi-g++` keep their identifying `++` suffix; `cl.exe` reduces to
525/// `cl`, which decides nothing by itself.
526fn driver_basename(driver: &str) -> Option<String> {
527    Path::new(driver)
528        .file_stem()
529        .and_then(|stem| stem.to_str())
530        .map(str::to_owned)
531}
532
533/// The C/C++ family named by an `-x` value, split (`-x c++`) or glued
534/// (`-xc++`) alike. `None` for a value this evidence hierarchy does not
535/// classify, such as `-x assembler`.
536fn language_from_x_value(value: &str) -> Option<CompiledLanguage> {
537    let family = value
538        .strip_suffix("-header")
539        .or_else(|| value.strip_suffix("-cpp-output"))
540        .unwrap_or(value);
541    match family {
542        "c" => Some(CompiledLanguage::C),
543        "c++" => Some(CompiledLanguage::Cpp),
544        _ => None,
545    }
546}
547
548fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
549    if raw.is_empty() {
550        return None;
551    }
552    absolute_path(directory, Path::new(raw))
553}
554
555fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
556    let path = if path.is_absolute() {
557        path.to_path_buf()
558    } else {
559        directory.join(path)
560    }
561    .normalize();
562    path.is_absolute().then_some(path)
563}
564
565/// Canonicalize an existing path, or canonicalize its nearest existing
566/// ancestor and append the missing tail. The latter is required for stale
567/// generated entries, whose source path is precisely the path that does not
568/// exist. Keeping the operation component-based also preserves platform
569/// prefixes and avoids string path identity.
570fn canonical_or_normalized_path(path: &Path) -> PathBuf {
571    let path = path.to_path_buf().normalize();
572    let mut missing_tail: Vec<OsString> = Vec::new();
573    let mut current = path.as_path();
574    loop {
575        if let Ok(canonical) = current.canonicalize() {
576            let mut result = canonical.normalize();
577            for component in missing_tail.iter().rev() {
578                result.push(component);
579            }
580            return result.normalize();
581        }
582        let Some(name) = current.file_name() else {
583            return path;
584        };
585        missing_tail.push(name.to_owned());
586        let Some(parent) = current.parent() else {
587            return path;
588        };
589        current = parent;
590    }
591}
592
593fn macro_name(definition: &str) -> Option<String> {
594    let end = definition.find('=').unwrap_or(definition.len());
595    let name = &definition[..end];
596    (!name.is_empty()).then(|| name.to_string())
597}
598
599#[cfg(test)]
600mod tests {
601    use super::{CompiledLanguage, CppCompileContexts, CppExternalIncludeResolution};
602    use brokk_bifrost_core::analyzer::project::{FilesystemProject, Project, TestProject};
603    use brokk_bifrost_core::analyzer::{Language, ProjectFile};
604    use std::path::{Path, PathBuf};
605
606    /// Parses one argument vector into a compile context the same way
607    /// [`super::CompilationDatabaseEntry::compile_context`] does, without a
608    /// project or a `compile_commands.json` fixture.
609    fn context(arguments: &[&str]) -> super::CppCompileContext {
610        let arguments = arguments
611            .iter()
612            .map(|argument| argument.to_string())
613            .collect::<Vec<_>>();
614        super::parse_compile_arguments(Path::new("/workspace"), &arguments)
615            .expect("non-empty argument vector parses")
616    }
617
618    fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
619        let temp = tempfile::tempdir().expect("temp dir");
620        let root = temp.path().canonicalize().expect("canonical root");
621        ProjectFile::new(root.clone(), "src/main.cpp")
622            .write("int main() { return 0; }")
623            .expect("source");
624        if let Some(database) = database {
625            ProjectFile::new(root.clone(), "compile_commands.json")
626                .write(database)
627                .expect("database");
628        }
629        (temp, TestProject::new(root, Language::Cpp))
630    }
631
632    #[test]
633    fn missing_or_malformed_database_has_no_context() {
634        let (_temp, project) = project_with_database(None);
635        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
636        assert!(
637            CppCompileContexts::load(&project)
638                .contexts_for(&file)
639                .is_empty()
640        );
641
642        let (_temp, project) = project_with_database(Some("not json"));
643        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
644        assert!(
645            CppCompileContexts::load(&project)
646                .contexts_for(&file)
647                .is_empty()
648        );
649    }
650
651    #[test]
652    fn arguments_entry_collects_include_paths_and_macro_names() {
653        let (_temp, project) = project_with_database(Some(
654            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
655        ));
656        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
657        let contexts = CppCompileContexts::load(&project);
658        let [context] = contexts.contexts_for(&file) else {
659            panic!("one matching context");
660        };
661
662        assert_eq!(
663            vec![
664                project.root_path().join("include"),
665                project.root_path().join("quotes"),
666            ],
667            context.project_include_roots
668        );
669        assert_eq!(
670            vec![project.root_path().join("system-include")],
671            context.system_include_roots
672        );
673        assert!(context.defined_macros.contains("DEBUG"));
674        assert!(context.defined_macros.contains("FEATURE"));
675    }
676
677    #[test]
678    fn quoted_command_entry_is_tokenized_without_executing_it() {
679        let (_temp, project) = project_with_database(Some(
680            r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
681        ));
682        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
683        let contexts = CppCompileContexts::load(&project);
684        let [context] = contexts.contexts_for(&file) else {
685            panic!("one matching context");
686        };
687
688        assert_eq!(
689            vec![project.root_path().join("project include")],
690            context.project_include_roots
691        );
692        assert!(context.defined_macros.contains("NAME"));
693    }
694
695    #[test]
696    fn undefine_flags_apply_in_command_order() {
697        let context = context(&[
698            "cc",
699            "-DKEPT",
700            "-DCANCELLED",
701            "-U",
702            "CANCELLED",
703            "-UREVIVED",
704            "-DREVIVED",
705            "-UNEVER_DEFINED",
706        ]);
707        assert!(context.defined_macros.contains("KEPT"));
708        assert!(context.defined_macros.contains("REVIVED"));
709        assert!(!context.defined_macros.contains("CANCELLED"));
710        assert!(!context.defined_macros.contains("NEVER_DEFINED"));
711    }
712
713    #[test]
714    fn a_file_compiled_in_two_configurations_keeps_both() {
715        let (_temp, project) = project_with_database(Some(
716            r#"[
717                {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
718                {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
719                {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
720            ]"#,
721        ));
722        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
723        let contexts = CppCompileContexts::load(&project);
724        let candidates = contexts.contexts_for(&file);
725
726        // Before #1627 a second entry deleted the first and the file lost its
727        // context entirely, which read downstream as "never compiled".
728        assert_eq!(2, candidates.len());
729        assert!(candidates[0].defined_macros.is_empty());
730        assert!(candidates[1].defined_macros.contains("OTHER"));
731    }
732
733    #[test]
734    fn repeated_identical_entries_are_one_configuration() {
735        let (_temp, project) = project_with_database(Some(
736            r#"[
737                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
738                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
739            ]"#,
740        ));
741        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
742        let contexts = CppCompileContexts::load(&project);
743
744        // Two entries that parse to the same flags are not a disagreement, so
745        // the selection stays unambiguous.
746        assert_eq!(1, contexts.contexts_for(&file).len());
747    }
748
749    #[test]
750    fn missing_workspace_sources_are_canonical_bounded_and_deterministic() {
751        let (_temp, project) = project_with_database(None);
752        let root = project.root_path().to_path_buf();
753        ProjectFile::new(root.clone(), "src/present.cpp")
754            .write("int present() { return 0; }")
755            .expect("present source");
756        ProjectFile::new(root.clone(), "src/present.c")
757            .write("int present_c(void) { return 0; }")
758            .expect("present C source");
759        ProjectFile::new(root.clone(), "include/present.hin")
760            .write("int generated_declaration(void);")
761            .expect("present C header template");
762
763        let mut entries = vec![serde_json::json!({
764            "directory": ".",
765            "file": "src/./present.cpp",
766            "arguments": ["clang++", "-c", "src/present.cpp"]
767        })];
768        entries.push(serde_json::json!({
769            "directory": ".",
770            "file": "src/present.c",
771            "arguments": ["clang", "-c", "src/present.c"]
772        }));
773        entries.push(serde_json::json!({
774            "directory": ".",
775            "file": "include/present.hin",
776            "arguments": ["clang", "-x", "c-header", "include/present.hin"]
777        }));
778        entries.extend(
779            (0..(super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3)).map(|index| {
780                serde_json::json!({
781                    "directory": ".",
782                    "file": format!("generated/./unit-{index:02}.cpp"),
783                    "arguments": ["clang++", "-c", format!("generated/unit-{index:02}.cpp")]
784                })
785            }),
786        );
787        // This duplicate resolves to the same canonical path and must not
788        // inflate the exact count or change the sample.
789        entries.push(serde_json::json!({
790            "directory": "generated",
791            "file": "../generated/unit-00.cpp",
792            "arguments": ["clang++", "-c", "../generated/unit-00.cpp"]
793        }));
794        ProjectFile::new(root.clone(), "compile_commands.json")
795            .write(serde_json::to_string(&entries).expect("database JSON"))
796            .expect("database");
797
798        let contexts = CppCompileContexts::load(&project);
799        let listed = project
800            .analyzable_files(Language::Cpp)
801            .expect("C++ listing");
802        let coverage = contexts.missing_workspace_sources(&root, &listed);
803
804        assert_eq!(
805            super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3,
806            coverage.missing_workspace_source_count()
807        );
808        assert_eq!(
809            (0..super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE)
810                .map(|index| Path::new("generated").join(format!("unit-{index:02}.cpp")))
811                .collect::<Vec<_>>(),
812            coverage.missing_workspace_source_sample()
813        );
814        assert!(
815            !coverage
816                .missing_workspace_source_sample()
817                .contains(&PathBuf::from("src/present.cpp"))
818        );
819
820        let reversed = entries.into_iter().rev().collect::<Vec<_>>();
821        ProjectFile::new(root.clone(), "compile_commands.json")
822            .write(serde_json::to_string(&reversed).expect("reversed database JSON"))
823            .expect("reversed database");
824        let reversed_coverage =
825            CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
826        assert_eq!(coverage, reversed_coverage);
827    }
828
829    #[test]
830    fn missing_workspace_sources_excludes_outside_root_and_ignored_files() {
831        let temp = tempfile::tempdir().expect("workspace root");
832        let root = temp.path().canonicalize().expect("canonical root");
833        let outside = tempfile::tempdir().expect("outside root");
834        let outside_source = outside
835            .path()
836            .canonicalize()
837            .expect("canonical outside root")
838            .join("outside.cpp")
839            .to_string_lossy()
840            .into_owned();
841        ProjectFile::new(root.clone(), "src/present.cpp")
842            .write("int present() { return 0; }")
843            .expect("present source");
844        ProjectFile::new(root.clone(), "ignored.cpp")
845            .write("int ignored() { return 0; }")
846            .expect("ignored source");
847        ProjectFile::new(root.clone(), ".bifrostignore")
848            .write("ignored.cpp\n")
849            .expect("ignore file");
850        let database = serde_json::json!([
851            {
852                "directory": ".",
853                "file": "src/../src/present.cpp",
854                "arguments": ["clang++", "-c", "src/present.cpp"]
855            },
856            {
857                "directory": ".",
858                "file": "ignored.cpp",
859                "arguments": ["clang++", "-c", "ignored.cpp"]
860            },
861            {
862                "directory": ".",
863                "file": outside_source.clone(),
864                "arguments": ["clang++", "-c", outside_source]
865            }
866        ]);
867        ProjectFile::new(root.clone(), "compile_commands.json")
868            .write(database.to_string())
869            .expect("database");
870        let project = FilesystemProject::new(root.clone()).expect("filesystem project");
871        let listed = project
872            .analyzable_files(Language::Cpp)
873            .expect("C++ listing");
874        assert!(
875            listed
876                .iter()
877                .any(|file| file.rel_path() == Path::new("src/present.cpp"))
878        );
879        assert!(
880            !listed
881                .iter()
882                .any(|file| file.rel_path() == Path::new("ignored.cpp"))
883        );
884
885        let coverage = CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
886        assert_eq!(1, coverage.missing_workspace_source_count());
887        assert_eq!(
888            &[PathBuf::from("ignored.cpp")],
889            coverage.missing_workspace_source_sample()
890        );
891    }
892
893    #[test]
894    fn an_unmatched_file_has_no_context() {
895        let (_temp, project) = project_with_database(Some(
896            r#"[{"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}]"#,
897        ));
898        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
899        assert!(
900            CppCompileContexts::load(&project)
901                .contexts_for(&file)
902                .is_empty()
903        );
904    }
905
906    #[test]
907    fn an_explicit_system_root_declares_an_angle_include() {
908        let (_temp, project) = project_with_database(Some(
909            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
910        ));
911        let header = ProjectFile::new(
912            project.root_path().to_path_buf(),
913            "fake-system/include/vector",
914        );
915        header
916            .write("namespace std { class vector {}; }")
917            .expect("header");
918        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
919
920        assert_eq!(
921            CppExternalIncludeResolution::Declared {
922                root: project
923                    .root_path()
924                    .join("fake-system/include")
925                    .canonicalize()
926                    .expect("canonical root"),
927                header: header.abs_path().canonicalize().expect("canonical header"),
928            },
929            CppCompileContexts::load(&project)
930                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
931        );
932    }
933
934    #[test]
935    fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
936        let external = tempfile::tempdir().expect("external root");
937        let external_root = external
938            .path()
939            .canonicalize()
940            .expect("canonical external root");
941        std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
942            .expect("external header");
943        let database = serde_json::json!([{
944            "directory": ".",
945            "file": "src/main.cpp",
946            "arguments": [
947                "clang++",
948                "-I",
949                external_root,
950                "-I",
951                "include",
952                "-c",
953                "src/main.cpp"
954            ]
955        }])
956        .to_string();
957        let (_temp, project) = project_with_database(Some(&database));
958        ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
959            .write("class Local {};")
960            .expect("local header");
961        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
962        let contexts = CppCompileContexts::load(&project);
963
964        assert!(matches!(
965            contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
966            CppExternalIncludeResolution::Declared { .. }
967        ));
968        assert_eq!(
969            CppExternalIncludeResolution::Undeclared,
970            contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
971        );
972    }
973
974    #[test]
975    fn configurations_must_agree_on_the_external_header() {
976        let first = tempfile::tempdir().expect("first root");
977        let second = tempfile::tempdir().expect("second root");
978        let first = first.path().canonicalize().expect("canonical first root");
979        let second = second.path().canonicalize().expect("canonical second root");
980        std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
981            .expect("first header");
982        std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
983            .expect("second header");
984        let database = serde_json::json!([
985            {
986                "directory": ".",
987                "file": "src/main.cpp",
988                "arguments": ["clang++", "-isystem", first, "-c", "src/main.cpp"]
989            },
990            {
991                "directory": ".",
992                "file": "src/main.cpp",
993                "arguments": ["clang++", "-isystem", second, "-c", "src/main.cpp"]
994            }
995        ])
996        .to_string();
997        let (_temp, project) = project_with_database(Some(&database));
998        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
999
1000        assert_eq!(
1001            CppExternalIncludeResolution::Conflicting,
1002            CppCompileContexts::load(&project)
1003                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1004        );
1005    }
1006
1007    #[test]
1008    fn external_include_cannot_escape_its_declared_root() {
1009        let external = tempfile::tempdir().expect("external root");
1010        let root = external.path().canonicalize().expect("canonical root");
1011        std::fs::create_dir_all(root.join("include")).expect("include directory");
1012        std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
1013        let database = serde_json::json!([{
1014            "directory": ".",
1015            "file": "src/main.cpp",
1016            "arguments": [
1017                "clang++",
1018                "-isystem",
1019                root.join("include"),
1020                "-c",
1021                "src/main.cpp"
1022            ]
1023        }])
1024        .to_string();
1025        let (_temp, project) = project_with_database(Some(&database));
1026        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1027
1028        assert_eq!(
1029            CppExternalIncludeResolution::Undeclared,
1030            CppCompileContexts::load(&project)
1031                .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
1032        );
1033        assert_eq!(
1034            CppExternalIncludeResolution::Undeclared,
1035            CppCompileContexts::load(&project)
1036                .resolve_external_angle_include(&file, &root.join("outside.hpp"))
1037        );
1038    }
1039
1040    #[test]
1041    fn msvc_project_and_system_include_flags_preserve_search_order() {
1042        let (_temp, project) = project_with_database(Some(
1043            r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
1044        ));
1045        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1046        let contexts = CppCompileContexts::load(&project);
1047        let [context] = contexts.contexts_for(&file) else {
1048            panic!("one MSVC compile context");
1049        };
1050
1051        assert_eq!(1, context.project_include_roots.len());
1052        assert_eq!(2, context.system_include_roots.len());
1053    }
1054
1055    #[test]
1056    fn default_by_extension_c_is_c_and_everything_else_is_cpp() {
1057        let cx = context(&["cc", "-c", "file.c"]);
1058        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1059
1060        let cx = context(&["cc", "-c", "file.cc"]);
1061        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1062        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.hpp")));
1063    }
1064
1065    #[test]
1066    fn driver_basename_ending_in_plus_plus_selects_cpp_over_a_c_extension() {
1067        let cx = context(&["clang++", "-c", "file.c"]);
1068        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1069
1070        // A driver that does not end in `++` (even a cross-compiler prefix
1071        // form) falls through to extension evidence.
1072        let cx = context(&["arm-none-eabi-gcc", "-c", "file.c"]);
1073        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1074
1075        // Prefixed driver names ending in `++` are still recognized.
1076        let cx = context(&["arm-none-eabi-g++", "-c", "file.h"]);
1077        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.h")));
1078    }
1079
1080    #[test]
1081    fn driver_exe_suffix_is_stripped_before_the_plus_plus_check() {
1082        let cx = context(&["clang++.exe", "-c", "file.c"]);
1083        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1084    }
1085
1086    #[test]
1087    fn std_family_outranks_the_driver_basename() {
1088        // -std outranks the driver: a g++ invocation pinned to a C standard
1089        // compiles as C even though the driver name ends in `++`.
1090        let cx = context(&["g++", "-std=gnu11", "-c", "file.c"]);
1091        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1092
1093        let cx = context(&["gcc", "-std=c++17", "-c", "file.cc"]);
1094        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1095
1096        let cx = context(&["gcc", "-std=c17", "-c", "file.cc"]);
1097        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cc")));
1098
1099        let cx = context(&["gcc", "-std=gnu++20", "-c", "file.c"]);
1100        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1101    }
1102
1103    #[test]
1104    fn explicit_x_split_form_outranks_std_and_driver() {
1105        let cx = context(&["clang", "-x", "c", "-std=c++17", "-c", "file.cpp"]);
1106        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1107    }
1108
1109    #[test]
1110    fn explicit_x_glued_form_is_recognized() {
1111        let cx = context(&["gcc", "-xc++", "-c", "file.c"]);
1112        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1113
1114        let cx = context(&["g++", "-xc", "-c", "file.cpp"]);
1115        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1116    }
1117
1118    #[test]
1119    fn explicit_x_on_a_c_file_overrides_the_extension() {
1120        // The precedence example from the ExecPlan: `-x c++` on a `.c` file
1121        // compiles as C++.
1122        let cx = context(&["gcc", "-x", "c++", "-c", "file.c"]);
1123        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1124    }
1125
1126    #[test]
1127    fn unrecognized_x_value_is_ignored() {
1128        let cx = context(&["gcc", "-x", "assembler", "-c", "file.s"]);
1129        // No C/C++ evidence at all in this configuration, so the extension
1130        // (not "c") decides.
1131        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.s")));
1132    }
1133
1134    #[test]
1135    fn msvc_tc_and_tp_force_the_language_regardless_of_extension() {
1136        let cx = context(&["cl.exe", "/TC", "/c", "file.cpp"]);
1137        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1138
1139        let cx = context(&["cl.exe", "/TP", "/c", "file.c"]);
1140        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1141    }
1142
1143    #[test]
1144    fn cl_exe_without_tc_or_tp_decides_by_extension() {
1145        let cx = context(&["cl.exe", "/c", "file.c"]);
1146        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1147
1148        let cx = context(&["cl.exe", "/c", "file.cpp"]);
1149        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cpp")));
1150    }
1151}