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 literal 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        self.resolve_external_include(file, &file.abs_path(), include, false)
239    }
240
241    /// Resolve one literal include using C include search semantics.
242    ///
243    /// A quoted include first checks the directory containing `including_file`,
244    /// then the explicit compiler search roots in command order. An angle
245    /// include skips the including-file directory and quote-only roots. The
246    /// result is declared only when every configuration selects the same
247    /// existing external file.
248    pub fn resolve_external_include(
249        &self,
250        file: &ProjectFile,
251        including_file: &Path,
252        include: &Path,
253        quoted: bool,
254    ) -> CppExternalIncludeResolution {
255        let contexts = self.contexts_for(file);
256        let Some(first_context) = contexts.first() else {
257            return CppExternalIncludeResolution::MissingCompileContext;
258        };
259        let first =
260            first_context.resolve_external_include(file.root(), including_file, include, quoted);
261        if contexts.iter().skip(1).any(|context| {
262            context.resolve_external_include(file.root(), including_file, include, quoted) != first
263        }) {
264            return CppExternalIncludeResolution::Conflicting;
265        }
266        match first {
267            Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
268            None => CppExternalIncludeResolution::Undeclared,
269        }
270    }
271
272    /// Every distinct explicit root that can supply an external angle include.
273    ///
274    /// The result is sorted for deterministic dependency discovery. It is a
275    /// source-set inventory, not proof that every compile configuration reaches
276    /// every root; per-reference resolution must still use
277    /// [`Self::resolve_external_angle_include`].
278    pub fn external_angle_include_roots(&self, workspace_root: &Path) -> Vec<PathBuf> {
279        let mut roots = self
280            .by_source
281            .values()
282            .flatten()
283            .flat_map(|context| context.external_angle_include_roots(workspace_root))
284            .map(Path::to_path_buf)
285            .collect::<Vec<_>>();
286        roots.sort();
287        roots.dedup();
288        roots
289    }
290}
291
292impl CppCompileContext {
293    /// The language this compile configuration says `source_path` is
294    /// compiled as, strongest evidence first:
295    ///
296    /// 1. An explicit `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
297    /// 2. A `-std=<value>` family (C when the value has no `++`, C++ when it
298    ///    does).
299    /// 3. The driver basename: `g++`, `clang++`, `c++`, and similar names
300    ///    ending in `++` compile as C++ regardless of extension.
301    /// 4. `source_path`'s extension: exactly `c` (case-sensitive, matching
302    ///    `is_c_source_file`) is C, everything else is C++. This is also the
303    ///    rule `cl.exe` itself uses when neither `/TC` nor `/TP` is given.
304    pub fn tu_language(&self, source_path: &Path) -> CompiledLanguage {
305        if let Some(language) = self.explicit_language {
306            return language;
307        }
308        if let Some(language) = self.std_language {
309            return language;
310        }
311        if self
312            .driver_basename
313            .as_deref()
314            .is_some_and(|driver| driver.ends_with("++"))
315        {
316            return CompiledLanguage::Cpp;
317        }
318        if source_path
319            .extension()
320            .and_then(|extension| extension.to_str())
321            == Some("c")
322        {
323            CompiledLanguage::C
324        } else {
325            CompiledLanguage::Cpp
326        }
327    }
328
329    /// Explicit external roots in compiler search order for angle includes.
330    pub fn external_angle_include_roots<'a>(
331        &'a self,
332        workspace_root: &'a Path,
333    ) -> impl Iterator<Item = &'a Path> + 'a {
334        self.external_include_roots(workspace_root, false)
335    }
336
337    fn external_include_roots<'a>(
338        &'a self,
339        workspace_root: &'a Path,
340        quoted: bool,
341    ) -> impl Iterator<Item = &'a Path> + 'a {
342        self.include_search_roots.iter().filter_map(move |root| {
343            (quoted || root.kind != CppIncludeSearchRootKind::Quote)
344                .then_some(root)
345                .filter(|root| {
346                    root.kind == CppIncludeSearchRootKind::System
347                        || !root.path.starts_with(workspace_root)
348                })
349                .map(|root| root.path.as_path())
350        })
351    }
352
353    fn resolve_external_include(
354        &self,
355        workspace_root: &Path,
356        including_file: &Path,
357        include: &Path,
358        quoted: bool,
359    ) -> Option<(PathBuf, PathBuf)> {
360        if include.is_absolute()
361            || (!quoted
362                && include
363                    .components()
364                    .any(|component| !matches!(component, Component::Normal(_))))
365            || (quoted
366                && include.components().any(|component| {
367                    matches!(component, Component::RootDir | Component::Prefix(_))
368                }))
369        {
370            return None;
371        }
372
373        let workspace_root = canonical_or_normalized_path(workspace_root);
374        if quoted {
375            let including_directory = including_file.parent()?;
376            if let Ok(candidate) = including_directory.join(include).canonicalize()
377                && candidate.is_file()
378            {
379                if candidate.starts_with(&workspace_root) {
380                    return None;
381                }
382                if let Some(root) = self
383                    .external_include_roots(&workspace_root, true)
384                    .filter_map(|root| {
385                        let root = root.canonicalize().ok()?;
386                        candidate.starts_with(&root).then_some(root)
387                    })
388                    .next()
389                {
390                    return Some((root, candidate));
391                }
392                return None;
393            }
394        }
395
396        self.external_include_roots(&workspace_root, quoted)
397            .filter_map(|root| {
398                let root = root.canonicalize().ok()?;
399                let candidate = root.join(include).canonicalize().ok()?;
400                (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
401            })
402            .next()
403    }
404}
405
406#[derive(Debug, Deserialize)]
407struct CompilationDatabaseEntry {
408    directory: PathBuf,
409    file: PathBuf,
410    arguments: Option<Vec<String>>,
411    command: Option<String>,
412}
413
414impl CompilationDatabaseEntry {
415    fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
416        absolute_path(
417            &command_directory(workspace_root, &self.directory)?,
418            &self.file,
419        )
420    }
421
422    fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
423        let arguments = match &self.arguments {
424            Some(arguments) if !arguments.is_empty() => arguments.clone(),
425            Some(_) => return None,
426            None => shlex::split(self.command.as_deref()?)?,
427        };
428        parse_compile_arguments(
429            &command_directory(workspace_root, &self.directory)?,
430            &arguments,
431        )
432    }
433}
434
435fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
436    absolute_path(workspace_root, directory)
437}
438
439fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
440    if arguments.is_empty() {
441        return None;
442    }
443
444    let driver_basename = driver_basename(&arguments[0]);
445    let mut project_include_roots = Vec::new();
446    let mut system_include_roots = Vec::new();
447    let mut forced_includes = Vec::new();
448    let mut defined_macros = HashSet::default();
449    let mut include_search_roots = Vec::new();
450    let mut explicit_language = None;
451    let mut std_language = None;
452    let mut index = 1;
453    while index < arguments.len() {
454        let argument = &arguments[index];
455        match argument.as_str() {
456            "-x" => {
457                if let Some(language) = language_from_x_value(arguments.get(index + 1)?) {
458                    explicit_language = Some(language);
459                }
460                index += 2;
461            }
462            "/TC" => {
463                explicit_language = Some(CompiledLanguage::C);
464                index += 1;
465            }
466            "/TP" => {
467                explicit_language = Some(CompiledLanguage::Cpp);
468                index += 1;
469            }
470            "-I" | "/I" => {
471                let path = argument_path(directory, arguments.get(index + 1)?)?;
472                project_include_roots.push(path.clone());
473                include_search_roots.push(CppIncludeSearchRoot {
474                    path,
475                    kind: CppIncludeSearchRootKind::Project,
476                });
477                index += 2;
478            }
479            "-iquote" => {
480                let path = argument_path(directory, arguments.get(index + 1)?)?;
481                project_include_roots.push(path.clone());
482                include_search_roots.push(CppIncludeSearchRoot {
483                    path,
484                    kind: CppIncludeSearchRootKind::Quote,
485                });
486                index += 2;
487            }
488            "-isystem" | "/external:I" | "/imsvc" => {
489                let path = argument_path(directory, arguments.get(index + 1)?)?;
490                system_include_roots.push(path.clone());
491                include_search_roots.push(CppIncludeSearchRoot {
492                    path,
493                    kind: CppIncludeSearchRootKind::System,
494                });
495                index += 2;
496            }
497            "-include" => {
498                forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
499                index += 2;
500            }
501            "-D" => {
502                defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
503                index += 2;
504            }
505            // The compiler applies -D and -U in command order, so a later -U
506            // removes an earlier -D. An -U proves nothing on its own: a header
507            // can still define the name, so only the surviving defines are
508            // positive facts (#2011).
509            "-U" => {
510                defined_macros.remove(&macro_name(arguments.get(index + 1)?)?);
511                index += 2;
512            }
513            _ => {
514                if let Some(path) = argument
515                    .strip_prefix("/external:I")
516                    .or_else(|| argument.strip_prefix("/imsvc"))
517                {
518                    let path = argument_path(directory, path)?;
519                    system_include_roots.push(path.clone());
520                    include_search_roots.push(CppIncludeSearchRoot {
521                        path,
522                        kind: CppIncludeSearchRootKind::System,
523                    });
524                } else if let Some(path) = argument
525                    .strip_prefix("-I")
526                    .or_else(|| argument.strip_prefix("/I"))
527                {
528                    let path = argument_path(directory, path)?;
529                    project_include_roots.push(path.clone());
530                    include_search_roots.push(CppIncludeSearchRoot {
531                        path,
532                        kind: CppIncludeSearchRootKind::Project,
533                    });
534                } else if let Some(path) = argument.strip_prefix("-iquote") {
535                    let path = argument_path(directory, path)?;
536                    project_include_roots.push(path.clone());
537                    include_search_roots.push(CppIncludeSearchRoot {
538                        path,
539                        kind: CppIncludeSearchRootKind::Quote,
540                    });
541                } else if let Some(path) = argument.strip_prefix("-isystem") {
542                    let path = argument_path(directory, path)?;
543                    system_include_roots.push(path.clone());
544                    include_search_roots.push(CppIncludeSearchRoot {
545                        path,
546                        kind: CppIncludeSearchRootKind::System,
547                    });
548                } else if let Some(definition) = argument.strip_prefix("-D") {
549                    defined_macros.insert(macro_name(definition)?);
550                } else if let Some(name) = argument.strip_prefix("-U") {
551                    defined_macros.remove(&macro_name(name)?);
552                } else if let Some(value) = argument.strip_prefix("-std=") {
553                    std_language = Some(if value.contains("++") {
554                        CompiledLanguage::Cpp
555                    } else {
556                        CompiledLanguage::C
557                    });
558                } else if let Some(value) = argument.strip_prefix("-x")
559                    && let Some(language) = language_from_x_value(value)
560                {
561                    explicit_language = Some(language);
562                }
563                index += 1;
564            }
565        }
566    }
567
568    Some(CppCompileContext {
569        project_include_roots,
570        system_include_roots,
571        forced_includes,
572        defined_macros,
573        include_search_roots,
574        driver_basename,
575        explicit_language,
576        std_language,
577    })
578}
579
580/// The driver invocation's basename, with one trailing extension (`.exe` on
581/// Windows) stripped. `-c++`-style drivers such as `g++`, `clang++`, and
582/// `arm-none-eabi-g++` keep their identifying `++` suffix; `cl.exe` reduces to
583/// `cl`, which decides nothing by itself.
584fn driver_basename(driver: &str) -> Option<String> {
585    Path::new(driver)
586        .file_stem()
587        .and_then(|stem| stem.to_str())
588        .map(str::to_owned)
589}
590
591/// The C/C++ family named by an `-x` value, split (`-x c++`) or glued
592/// (`-xc++`) alike. `None` for a value this evidence hierarchy does not
593/// classify, such as `-x assembler`.
594fn language_from_x_value(value: &str) -> Option<CompiledLanguage> {
595    let family = value
596        .strip_suffix("-header")
597        .or_else(|| value.strip_suffix("-cpp-output"))
598        .unwrap_or(value);
599    match family {
600        "c" => Some(CompiledLanguage::C),
601        "c++" => Some(CompiledLanguage::Cpp),
602        _ => None,
603    }
604}
605
606fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
607    if raw.is_empty() {
608        return None;
609    }
610    absolute_path(directory, Path::new(raw))
611}
612
613fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
614    let path = if path.is_absolute() {
615        path.to_path_buf()
616    } else {
617        directory.join(path)
618    }
619    .normalize();
620    path.is_absolute().then_some(path)
621}
622
623/// Canonicalize an existing path, or canonicalize its nearest existing
624/// ancestor and append the missing tail. The latter is required for stale
625/// generated entries, whose source path is precisely the path that does not
626/// exist. Keeping the operation component-based also preserves platform
627/// prefixes and avoids string path identity.
628fn canonical_or_normalized_path(path: &Path) -> PathBuf {
629    let path = path.to_path_buf().normalize();
630    let mut missing_tail: Vec<OsString> = Vec::new();
631    let mut current = path.as_path();
632    loop {
633        if let Ok(canonical) = current.canonicalize() {
634            let mut result = canonical.normalize();
635            for component in missing_tail.iter().rev() {
636                result.push(component);
637            }
638            return result.normalize();
639        }
640        let Some(name) = current.file_name() else {
641            return path;
642        };
643        missing_tail.push(name.to_owned());
644        let Some(parent) = current.parent() else {
645            return path;
646        };
647        current = parent;
648    }
649}
650
651fn macro_name(definition: &str) -> Option<String> {
652    let end = definition.find('=').unwrap_or(definition.len());
653    let name = &definition[..end];
654    (!name.is_empty()).then(|| name.to_string())
655}
656
657#[cfg(test)]
658mod tests {
659    use super::{CompiledLanguage, CppCompileContexts, CppExternalIncludeResolution};
660    use brokk_bifrost_core::analyzer::project::{FilesystemProject, Project, TestProject};
661    use brokk_bifrost_core::analyzer::{Language, ProjectFile};
662    use brokk_bifrost_test_macros::hardwon;
663    use std::path::{Path, PathBuf};
664
665    /// Parses one argument vector into a compile context the same way
666    /// [`super::CompilationDatabaseEntry::compile_context`] does, without a
667    /// project or a `compile_commands.json` fixture.
668    fn context(arguments: &[&str]) -> super::CppCompileContext {
669        let arguments = arguments
670            .iter()
671            .map(|argument| argument.to_string())
672            .collect::<Vec<_>>();
673        super::parse_compile_arguments(Path::new("/workspace"), &arguments)
674            .expect("non-empty argument vector parses")
675    }
676
677    fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
678        let temp = tempfile::tempdir().expect("temp dir");
679        let root = temp.path().canonicalize().expect("canonical root");
680        ProjectFile::new(root.clone(), "src/main.cpp")
681            .write("int main() { return 0; }")
682            .expect("source");
683        if let Some(database) = database {
684            ProjectFile::new(root.clone(), "compile_commands.json")
685                .write(database)
686                .expect("database");
687        }
688        (temp, TestProject::new(root, Language::Cpp))
689    }
690
691    #[test]
692    fn arguments_entry_collects_include_paths_and_macro_names() {
693        let (_temp, project) = project_with_database(Some(
694            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
695        ));
696        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
697        let contexts = CppCompileContexts::load(&project);
698        let [context] = contexts.contexts_for(&file) else {
699            panic!("one matching context");
700        };
701
702        assert_eq!(
703            vec![
704                project.root_path().join("include"),
705                project.root_path().join("quotes"),
706            ],
707            context.project_include_roots
708        );
709        assert_eq!(
710            vec![project.root_path().join("system-include")],
711            context.system_include_roots
712        );
713        assert!(context.defined_macros.contains("DEBUG"));
714        assert!(context.defined_macros.contains("FEATURE"));
715    }
716
717    #[test]
718    fn quoted_command_entry_is_tokenized_without_executing_it() {
719        let (_temp, project) = project_with_database(Some(
720            r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
721        ));
722        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
723        let contexts = CppCompileContexts::load(&project);
724        let [context] = contexts.contexts_for(&file) else {
725            panic!("one matching context");
726        };
727
728        assert_eq!(
729            vec![project.root_path().join("project include")],
730            context.project_include_roots
731        );
732        assert!(context.defined_macros.contains("NAME"));
733    }
734
735    #[test]
736    #[hardwon("#2011: conditional C visibility ignored command-order macro state")]
737    fn undefine_flags_apply_in_command_order() {
738        let context = context(&[
739            "cc",
740            "-DKEPT",
741            "-DCANCELLED",
742            "-U",
743            "CANCELLED",
744            "-UREVIVED",
745            "-DREVIVED",
746            "-UNEVER_DEFINED",
747        ]);
748        assert!(context.defined_macros.contains("KEPT"));
749        assert!(context.defined_macros.contains("REVIVED"));
750        assert!(!context.defined_macros.contains("CANCELLED"));
751        assert!(!context.defined_macros.contains("NEVER_DEFINED"));
752    }
753
754    #[test]
755    fn a_file_compiled_in_two_configurations_keeps_both() {
756        let (_temp, project) = project_with_database(Some(
757            r#"[
758                {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
759                {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
760                {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
761            ]"#,
762        ));
763        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
764        let contexts = CppCompileContexts::load(&project);
765        let candidates = contexts.contexts_for(&file);
766
767        // Before #1627 a second entry deleted the first and the file lost its
768        // context entirely, which read downstream as "never compiled".
769        assert_eq!(2, candidates.len());
770        assert!(candidates[0].defined_macros.is_empty());
771        assert!(candidates[1].defined_macros.contains("OTHER"));
772    }
773
774    #[test]
775    fn repeated_identical_entries_are_one_configuration() {
776        let (_temp, project) = project_with_database(Some(
777            r#"[
778                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
779                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
780            ]"#,
781        ));
782        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
783        let contexts = CppCompileContexts::load(&project);
784
785        // Two entries that parse to the same flags are not a disagreement, so
786        // the selection stays unambiguous.
787        assert_eq!(1, contexts.contexts_for(&file).len());
788    }
789
790    #[test]
791    fn missing_workspace_sources_are_canonical_bounded_and_deterministic() {
792        let (_temp, project) = project_with_database(None);
793        let root = project.root_path().to_path_buf();
794        ProjectFile::new(root.clone(), "src/present.cpp")
795            .write("int present() { return 0; }")
796            .expect("present source");
797        ProjectFile::new(root.clone(), "src/present.c")
798            .write("int present_c(void) { return 0; }")
799            .expect("present C source");
800        ProjectFile::new(root.clone(), "include/present.hin")
801            .write("int generated_declaration(void);")
802            .expect("present C header template");
803
804        let mut entries = vec![serde_json::json!({
805            "directory": ".",
806            "file": "src/./present.cpp",
807            "arguments": ["clang++", "-c", "src/present.cpp"]
808        })];
809        entries.push(serde_json::json!({
810            "directory": ".",
811            "file": "src/present.c",
812            "arguments": ["clang", "-c", "src/present.c"]
813        }));
814        entries.push(serde_json::json!({
815            "directory": ".",
816            "file": "include/present.hin",
817            "arguments": ["clang", "-x", "c-header", "include/present.hin"]
818        }));
819        entries.extend(
820            (0..(super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3)).map(|index| {
821                serde_json::json!({
822                    "directory": ".",
823                    "file": format!("generated/./unit-{index:02}.cpp"),
824                    "arguments": ["clang++", "-c", format!("generated/unit-{index:02}.cpp")]
825                })
826            }),
827        );
828        // This duplicate resolves to the same canonical path and must not
829        // inflate the exact count or change the sample.
830        entries.push(serde_json::json!({
831            "directory": "generated",
832            "file": "../generated/unit-00.cpp",
833            "arguments": ["clang++", "-c", "../generated/unit-00.cpp"]
834        }));
835        ProjectFile::new(root.clone(), "compile_commands.json")
836            .write(serde_json::to_string(&entries).expect("database JSON"))
837            .expect("database");
838
839        let contexts = CppCompileContexts::load(&project);
840        let listed = project
841            .analyzable_files(Language::Cpp)
842            .expect("C++ listing");
843        let coverage = contexts.missing_workspace_sources(&root, &listed);
844
845        assert_eq!(
846            super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3,
847            coverage.missing_workspace_source_count()
848        );
849        assert_eq!(
850            (0..super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE)
851                .map(|index| Path::new("generated").join(format!("unit-{index:02}.cpp")))
852                .collect::<Vec<_>>(),
853            coverage.missing_workspace_source_sample()
854        );
855        assert!(
856            !coverage
857                .missing_workspace_source_sample()
858                .contains(&PathBuf::from("src/present.cpp"))
859        );
860
861        let reversed = entries.into_iter().rev().collect::<Vec<_>>();
862        ProjectFile::new(root.clone(), "compile_commands.json")
863            .write(serde_json::to_string(&reversed).expect("reversed database JSON"))
864            .expect("reversed database");
865        let reversed_coverage =
866            CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
867        assert_eq!(coverage, reversed_coverage);
868    }
869
870    #[test]
871    fn missing_workspace_sources_excludes_outside_root_and_ignored_files() {
872        let temp = tempfile::tempdir().expect("workspace root");
873        let root = temp.path().canonicalize().expect("canonical root");
874        let outside = tempfile::tempdir().expect("outside root");
875        let outside_source = outside
876            .path()
877            .canonicalize()
878            .expect("canonical outside root")
879            .join("outside.cpp")
880            .to_string_lossy()
881            .into_owned();
882        ProjectFile::new(root.clone(), "src/present.cpp")
883            .write("int present() { return 0; }")
884            .expect("present source");
885        ProjectFile::new(root.clone(), "ignored.cpp")
886            .write("int ignored() { return 0; }")
887            .expect("ignored source");
888        ProjectFile::new(root.clone(), ".bifrostignore")
889            .write("ignored.cpp\n")
890            .expect("ignore file");
891        let database = serde_json::json!([
892            {
893                "directory": ".",
894                "file": "src/../src/present.cpp",
895                "arguments": ["clang++", "-c", "src/present.cpp"]
896            },
897            {
898                "directory": ".",
899                "file": "ignored.cpp",
900                "arguments": ["clang++", "-c", "ignored.cpp"]
901            },
902            {
903                "directory": ".",
904                "file": outside_source.clone(),
905                "arguments": ["clang++", "-c", outside_source]
906            }
907        ]);
908        ProjectFile::new(root.clone(), "compile_commands.json")
909            .write(database.to_string())
910            .expect("database");
911        let project = FilesystemProject::new(root.clone()).expect("filesystem project");
912        let listed = project
913            .analyzable_files(Language::Cpp)
914            .expect("C++ listing");
915        assert!(
916            listed
917                .iter()
918                .any(|file| file.rel_path() == Path::new("src/present.cpp"))
919        );
920        assert!(
921            !listed
922                .iter()
923                .any(|file| file.rel_path() == Path::new("ignored.cpp"))
924        );
925
926        let coverage = CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
927        assert_eq!(1, coverage.missing_workspace_source_count());
928        assert_eq!(
929            &[PathBuf::from("ignored.cpp")],
930            coverage.missing_workspace_source_sample()
931        );
932    }
933
934    #[test]
935    fn a_quoted_include_checks_the_including_directory_before_external_roots() {
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 External {};\n")
942            .expect("external vendor header");
943        std::fs::write(external_root.join("from-search.hpp"), "class Search {};\n")
944            .expect("external search header");
945        let database = serde_json::json!([{
946            "directory": ".",
947            "file": "src/main.cpp",
948            "arguments": [
949                "clang++",
950                "-I",
951                external_root,
952                "-c",
953                "src/main.cpp"
954            ]
955        }])
956        .to_string();
957        let (_temp, project) = project_with_database(Some(&database));
958        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
959        let source_path = file.abs_path();
960        let source_directory = source_path.parent().expect("source directory");
961        std::fs::create_dir_all(source_directory).expect("source directory");
962        std::fs::write(source_directory.join("vendor.hpp"), "class Local {};\n")
963            .expect("local vendor header");
964        let contexts = CppCompileContexts::load(&project);
965
966        assert_eq!(
967            CppExternalIncludeResolution::Undeclared,
968            contexts.resolve_external_include(
969                &file,
970                &file.abs_path(),
971                std::path::Path::new("vendor.hpp"),
972                true,
973            )
974        );
975        assert!(matches!(
976            contexts.resolve_external_include(
977                &file,
978                &file.abs_path(),
979                std::path::Path::new("from-search.hpp"),
980                true,
981            ),
982            CppExternalIncludeResolution::Declared { .. }
983        ));
984        assert!(matches!(
985            contexts.resolve_external_include(
986                &file,
987                &file.abs_path(),
988                std::path::Path::new("vendor.hpp"),
989                false,
990            ),
991            CppExternalIncludeResolution::Declared { .. }
992        ));
993    }
994
995    #[test]
996    fn configurations_must_agree_on_the_external_header() {
997        let first = tempfile::tempdir().expect("first root");
998        let second = tempfile::tempdir().expect("second root");
999        let first = first.path().canonicalize().expect("canonical first root");
1000        let second = second.path().canonicalize().expect("canonical second root");
1001        std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
1002            .expect("first header");
1003        std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
1004            .expect("second header");
1005        let database = serde_json::json!([
1006            {
1007                "directory": ".",
1008                "file": "src/main.cpp",
1009                "arguments": ["clang++", "-isystem", first, "-c", "src/main.cpp"]
1010            },
1011            {
1012                "directory": ".",
1013                "file": "src/main.cpp",
1014                "arguments": ["clang++", "-isystem", second, "-c", "src/main.cpp"]
1015            }
1016        ])
1017        .to_string();
1018        let (_temp, project) = project_with_database(Some(&database));
1019        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1020
1021        assert_eq!(
1022            CppExternalIncludeResolution::Conflicting,
1023            CppCompileContexts::load(&project)
1024                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1025        );
1026    }
1027
1028    #[test]
1029    fn external_include_cannot_escape_its_declared_root() {
1030        let external = tempfile::tempdir().expect("external root");
1031        let root = external.path().canonicalize().expect("canonical root");
1032        std::fs::create_dir_all(root.join("include")).expect("include directory");
1033        std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
1034        let database = serde_json::json!([{
1035            "directory": ".",
1036            "file": "src/main.cpp",
1037            "arguments": [
1038                "clang++",
1039                "-isystem",
1040                root.join("include"),
1041                "-c",
1042                "src/main.cpp"
1043            ]
1044        }])
1045        .to_string();
1046        let (_temp, project) = project_with_database(Some(&database));
1047        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1048
1049        assert_eq!(
1050            CppExternalIncludeResolution::Undeclared,
1051            CppCompileContexts::load(&project)
1052                .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
1053        );
1054        assert_eq!(
1055            CppExternalIncludeResolution::Undeclared,
1056            CppCompileContexts::load(&project)
1057                .resolve_external_angle_include(&file, &root.join("outside.hpp"))
1058        );
1059    }
1060
1061    #[test]
1062    fn msvc_project_and_system_include_flags_preserve_search_order() {
1063        let (_temp, project) = project_with_database(Some(
1064            r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
1065        ));
1066        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1067        let contexts = CppCompileContexts::load(&project);
1068        let [context] = contexts.contexts_for(&file) else {
1069            panic!("one MSVC compile context");
1070        };
1071
1072        assert_eq!(1, context.project_include_roots.len());
1073        assert_eq!(2, context.system_include_roots.len());
1074    }
1075
1076    #[test]
1077    fn driver_basename_ending_in_plus_plus_selects_cpp_over_a_c_extension() {
1078        let cx = context(&["clang++", "-c", "file.c"]);
1079        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1080
1081        // A driver that does not end in `++` (even a cross-compiler prefix
1082        // form) falls through to extension evidence.
1083        let cx = context(&["arm-none-eabi-gcc", "-c", "file.c"]);
1084        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1085
1086        // Prefixed driver names ending in `++` are still recognized.
1087        let cx = context(&["arm-none-eabi-g++", "-c", "file.h"]);
1088        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.h")));
1089    }
1090
1091    #[test]
1092    fn driver_exe_suffix_is_stripped_before_the_plus_plus_check() {
1093        let cx = context(&["clang++.exe", "-c", "file.c"]);
1094        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1095    }
1096
1097    #[test]
1098    fn std_family_outranks_the_driver_basename() {
1099        // -std outranks the driver: a g++ invocation pinned to a C standard
1100        // compiles as C even though the driver name ends in `++`.
1101        let cx = context(&["g++", "-std=gnu11", "-c", "file.c"]);
1102        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1103
1104        let cx = context(&["gcc", "-std=c++17", "-c", "file.cc"]);
1105        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1106
1107        let cx = context(&["gcc", "-std=c17", "-c", "file.cc"]);
1108        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cc")));
1109
1110        let cx = context(&["gcc", "-std=gnu++20", "-c", "file.c"]);
1111        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1112    }
1113
1114    #[test]
1115    fn explicit_x_split_form_outranks_std_and_driver() {
1116        let cx = context(&["clang", "-x", "c", "-std=c++17", "-c", "file.cpp"]);
1117        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1118    }
1119
1120    #[test]
1121    fn explicit_x_glued_form_is_recognized() {
1122        let cx = context(&["gcc", "-xc++", "-c", "file.c"]);
1123        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1124
1125        let cx = context(&["g++", "-xc", "-c", "file.cpp"]);
1126        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1127    }
1128
1129    #[test]
1130    fn explicit_x_on_a_c_file_overrides_the_extension() {
1131        // The precedence example from the ExecPlan: `-x c++` on a `.c` file
1132        // compiles as C++.
1133        let cx = context(&["gcc", "-x", "c++", "-c", "file.c"]);
1134        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1135    }
1136
1137    #[test]
1138    fn msvc_tc_and_tp_force_the_language_regardless_of_extension() {
1139        let cx = context(&["cl.exe", "/TC", "/c", "file.cpp"]);
1140        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1141
1142        let cx = context(&["cl.exe", "/TP", "/c", "file.c"]);
1143        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1144    }
1145
1146    #[test]
1147    fn missing_or_malformed_database_has_no_context() {
1148        let (_temp, project) = project_with_database(None);
1149        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1150        assert!(
1151            CppCompileContexts::load(&project)
1152                .contexts_for(&file)
1153                .is_empty()
1154        );
1155
1156        let (_temp, project) = project_with_database(Some("not json"));
1157        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1158        assert!(
1159            CppCompileContexts::load(&project)
1160                .contexts_for(&file)
1161                .is_empty()
1162        );
1163    }
1164
1165    #[test]
1166    fn an_explicit_system_root_declares_an_angle_include() {
1167        let (_temp, project) = project_with_database(Some(
1168            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
1169        ));
1170        let header = ProjectFile::new(
1171            project.root_path().to_path_buf(),
1172            "fake-system/include/vector",
1173        );
1174        header
1175            .write("namespace std { class vector {}; }")
1176            .expect("header");
1177        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1178
1179        assert_eq!(
1180            CppExternalIncludeResolution::Declared {
1181                root: project
1182                    .root_path()
1183                    .join("fake-system/include")
1184                    .canonicalize()
1185                    .expect("canonical root"),
1186                header: header.abs_path().canonicalize().expect("canonical header"),
1187            },
1188            CppCompileContexts::load(&project)
1189                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1190        );
1191    }
1192
1193    #[test]
1194    fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
1195        let external = tempfile::tempdir().expect("external root");
1196        let external_root = external
1197            .path()
1198            .canonicalize()
1199            .expect("canonical external root");
1200        std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
1201            .expect("external header");
1202        let database = serde_json::json!([{
1203            "directory": ".",
1204            "file": "src/main.cpp",
1205            "arguments": [
1206                "clang++",
1207                "-I",
1208                external_root,
1209                "-I",
1210                "include",
1211                "-c",
1212                "src/main.cpp"
1213            ]
1214        }])
1215        .to_string();
1216        let (_temp, project) = project_with_database(Some(&database));
1217        ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
1218            .write("class Local {};")
1219            .expect("local header");
1220        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1221        let contexts = CppCompileContexts::load(&project);
1222
1223        assert!(matches!(
1224            contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
1225            CppExternalIncludeResolution::Declared { .. }
1226        ));
1227        assert_eq!(
1228            CppExternalIncludeResolution::Undeclared,
1229            contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
1230        );
1231    }
1232
1233    #[test]
1234    fn default_by_extension_c_is_c_and_everything_else_is_cpp() {
1235        let cx = context(&["cc", "-c", "file.c"]);
1236        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1237
1238        let cx = context(&["cc", "-c", "file.cc"]);
1239        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1240        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.hpp")));
1241        let cx = context(&["cl.exe", "/c", "file.c"]);
1242        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1243
1244        let cx = context(&["cl.exe", "/c", "file.cpp"]);
1245        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cpp")));
1246    }
1247}