Skip to main content

brokk_bifrost_cpp/
compile_context.rs

1//! `compile_commands.json` ingestion.
2//!
3//! `analyzer/cpp/mod.rs` keeps the `OnceLock<CppCompileContexts>` that memoizes
4//! [`CppCompileContexts::load`] per analyzer generation; the database format and
5//! the argument grammar are here.
6
7use brokk_bifrost_core::analyzer::ProjectFile;
8use brokk_bifrost_core::analyzer::project::Project;
9use brokk_bifrost_core::hash::{HashMap, HashSet};
10use brokk_bifrost_core::path_normalization::NormalizePath;
11use serde::Deserialize;
12use std::path::{Component, Path, PathBuf};
13
14const COMPILATION_DATABASE_PATH: &str = "compile_commands.json";
15
16/// Whether `file` is the workspace compilation database consumed by
17/// [`CppCompileContexts::load`].
18pub fn is_cpp_compile_context_input(file: &ProjectFile) -> bool {
19    file.rel_path() == Path::new(COMPILATION_DATABASE_PATH)
20}
21
22/// The compiler configuration Bifrost can safely use for one source file.
23///
24/// This is deliberately narrower than a compiler invocation. It records only
25/// context that later semantic diagnostics need and never executes `command`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CppCompileContext {
28    pub project_include_roots: Vec<PathBuf>,
29    pub system_include_roots: Vec<PathBuf>,
30    pub forced_includes: Vec<PathBuf>,
31    pub defined_macros: HashSet<String>,
32    include_search_roots: Vec<CppIncludeSearchRoot>,
33    /// The invocation's driver basename (`arguments[0]`, minus one trailing
34    /// extension such as `.exe`), used by [`Self::tu_language`] only when
35    /// nothing stronger settles the language.
36    driver_basename: Option<String>,
37    /// Language forced by `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
38    /// The strongest evidence [`Self::tu_language`] considers.
39    explicit_language: Option<CompiledLanguage>,
40    /// Language implied by `-std=<value>`: the C family (`c11`, `gnu17`, ...)
41    /// versus the C++ family (`c++17`, `gnu++20`, ...), told apart by whether
42    /// the value contains `++`.
43    std_language: Option<CompiledLanguage>,
44}
45
46/// The language a compile-database entry says its translation unit is
47/// compiled as. See [`CppCompileContext::tu_language`].
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CompiledLanguage {
50    C,
51    Cpp,
52}
53
54/// Why one compiler include-search entry exists.
55///
56/// The distinction is semantic. `-isystem` declares an external surface even
57/// when a test places that surface below the temporary workspace root, while an
58/// ordinary `-I` entry is external only when it points outside the workspace.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum CppIncludeSearchRootKind {
61    Project,
62    Quote,
63    System,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67struct CppIncludeSearchRoot {
68    path: PathBuf,
69    kind: CppIncludeSearchRootKind,
70}
71
72/// What all compile configurations for one source prove about an angle include.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum CppExternalIncludeResolution {
75    /// No compile command names the source file.
76    MissingCompileContext,
77    /// Every configuration agrees that no explicit external root contains it.
78    Undeclared,
79    /// Configurations select different headers, or only some select a header.
80    Conflicting,
81    /// Every configuration selects this exact external header.
82    Declared { root: PathBuf, header: PathBuf },
83}
84
85#[derive(Debug, Default)]
86pub struct CppCompileContexts {
87    by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
88}
89
90impl CppCompileContexts {
91    pub fn load(project: &dyn Project) -> Self {
92        let database_path = project.root().join(COMPILATION_DATABASE_PATH);
93        let Ok(database) = std::fs::read_to_string(database_path) else {
94            return Self::default();
95        };
96        let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
97            return Self::default();
98        };
99
100        let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
101        for entry in entries {
102            let Some(source) = entry.source_path(project.root()) else {
103                continue;
104            };
105            if !source.starts_with(project.root()) {
106                continue;
107            }
108            let Some(context) = entry.compile_context(project.root()) else {
109                continue;
110            };
111            // A build that compiles one file in several configurations records
112            // one entry per configuration. Keeping every distinct one lets the
113            // caller decide per name whether the configurations agree; dropping
114            // them would make "compiled two ways" look like "never compiled".
115            // Entries that parse to the same context are one configuration.
116            let candidates = by_source.entry(source).or_default();
117            if !candidates.contains(&context) {
118                candidates.push(context);
119            }
120        }
121        Self { by_source }
122    }
123
124    /// Every distinct compile configuration the database records for `file`,
125    /// empty when no entry names it.
126    ///
127    /// Exactly one context is an unambiguous selection. More than one means the
128    /// include closures can differ, so a name is absent only where every
129    /// candidate agrees that it is.
130    pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
131        self.by_source
132            .get(&file.abs_path().normalize())
133            .map_or(&[], Vec::as_slice)
134    }
135
136    /// Resolve one angle include through explicit external roots in every
137    /// compile configuration for `file`.
138    ///
139    /// This method never probes an implicit compiler sysroot and never executes
140    /// the compiler. A result is declared only when every configuration selects
141    /// the same existing file. This preserves the multi-configuration honesty
142    /// required by C++ diagnostics and external semantic packs.
143    pub fn resolve_external_angle_include(
144        &self,
145        file: &ProjectFile,
146        include: &Path,
147    ) -> CppExternalIncludeResolution {
148        let contexts = self.contexts_for(file);
149        let Some(first_context) = contexts.first() else {
150            return CppExternalIncludeResolution::MissingCompileContext;
151        };
152        let first = first_context.resolve_external_angle_include(file.root(), include);
153        if contexts
154            .iter()
155            .skip(1)
156            .any(|context| context.resolve_external_angle_include(file.root(), include) != first)
157        {
158            return CppExternalIncludeResolution::Conflicting;
159        }
160        match first {
161            Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
162            None => CppExternalIncludeResolution::Undeclared,
163        }
164    }
165
166    /// Every distinct explicit root that can supply an external angle include.
167    ///
168    /// The result is sorted for deterministic dependency discovery. It is a
169    /// source-set inventory, not proof that every compile configuration reaches
170    /// every root; per-reference resolution must still use
171    /// [`Self::resolve_external_angle_include`].
172    pub fn external_angle_include_roots(&self, workspace_root: &Path) -> Vec<PathBuf> {
173        let mut roots = self
174            .by_source
175            .values()
176            .flatten()
177            .flat_map(|context| context.external_angle_include_roots(workspace_root))
178            .map(Path::to_path_buf)
179            .collect::<Vec<_>>();
180        roots.sort();
181        roots.dedup();
182        roots
183    }
184}
185
186impl CppCompileContext {
187    /// The language this compile configuration says `source_path` is
188    /// compiled as, strongest evidence first:
189    ///
190    /// 1. An explicit `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
191    /// 2. A `-std=<value>` family (C when the value has no `++`, C++ when it
192    ///    does).
193    /// 3. The driver basename: `g++`, `clang++`, `c++`, and similar names
194    ///    ending in `++` compile as C++ regardless of extension.
195    /// 4. `source_path`'s extension: exactly `c` (case-sensitive, matching
196    ///    `is_c_source_file`) is C, everything else is C++. This is also the
197    ///    rule `cl.exe` itself uses when neither `/TC` nor `/TP` is given.
198    pub fn tu_language(&self, source_path: &Path) -> CompiledLanguage {
199        if let Some(language) = self.explicit_language {
200            return language;
201        }
202        if let Some(language) = self.std_language {
203            return language;
204        }
205        if self
206            .driver_basename
207            .as_deref()
208            .is_some_and(|driver| driver.ends_with("++"))
209        {
210            return CompiledLanguage::Cpp;
211        }
212        if source_path
213            .extension()
214            .and_then(|extension| extension.to_str())
215            == Some("c")
216        {
217            CompiledLanguage::C
218        } else {
219            CompiledLanguage::Cpp
220        }
221    }
222
223    /// Explicit external roots in compiler search order for angle includes.
224    pub fn external_angle_include_roots<'a>(
225        &'a self,
226        workspace_root: &'a Path,
227    ) -> impl Iterator<Item = &'a Path> + 'a {
228        self.include_search_roots.iter().filter_map(move |root| {
229            (root.kind != CppIncludeSearchRootKind::Quote
230                && (root.kind == CppIncludeSearchRootKind::System
231                    || !root.path.starts_with(workspace_root)))
232            .then_some(root.path.as_path())
233        })
234    }
235
236    fn resolve_external_angle_include(
237        &self,
238        workspace_root: &Path,
239        include: &Path,
240    ) -> Option<(PathBuf, PathBuf)> {
241        if include.is_absolute()
242            || include
243                .components()
244                .any(|component| !matches!(component, Component::Normal(_)))
245        {
246            return None;
247        }
248        self.external_angle_include_roots(workspace_root)
249            .filter_map(|root| {
250                let root = root.canonicalize().ok()?;
251                let candidate = root.join(include).canonicalize().ok()?;
252                (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
253            })
254            .next()
255    }
256}
257
258#[derive(Debug, Deserialize)]
259struct CompilationDatabaseEntry {
260    directory: PathBuf,
261    file: PathBuf,
262    arguments: Option<Vec<String>>,
263    command: Option<String>,
264}
265
266impl CompilationDatabaseEntry {
267    fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
268        absolute_path(
269            &command_directory(workspace_root, &self.directory)?,
270            &self.file,
271        )
272    }
273
274    fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
275        let arguments = match &self.arguments {
276            Some(arguments) if !arguments.is_empty() => arguments.clone(),
277            Some(_) => return None,
278            None => shlex::split(self.command.as_deref()?)?,
279        };
280        parse_compile_arguments(
281            &command_directory(workspace_root, &self.directory)?,
282            &arguments,
283        )
284    }
285}
286
287fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
288    absolute_path(workspace_root, directory)
289}
290
291fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
292    if arguments.is_empty() {
293        return None;
294    }
295
296    let driver_basename = driver_basename(&arguments[0]);
297    let mut project_include_roots = Vec::new();
298    let mut system_include_roots = Vec::new();
299    let mut forced_includes = Vec::new();
300    let mut defined_macros = HashSet::default();
301    let mut include_search_roots = Vec::new();
302    let mut explicit_language = None;
303    let mut std_language = None;
304    let mut index = 1;
305    while index < arguments.len() {
306        let argument = &arguments[index];
307        match argument.as_str() {
308            "-x" => {
309                if let Some(language) = language_from_x_value(arguments.get(index + 1)?) {
310                    explicit_language = Some(language);
311                }
312                index += 2;
313            }
314            "/TC" => {
315                explicit_language = Some(CompiledLanguage::C);
316                index += 1;
317            }
318            "/TP" => {
319                explicit_language = Some(CompiledLanguage::Cpp);
320                index += 1;
321            }
322            "-I" | "/I" => {
323                let path = argument_path(directory, arguments.get(index + 1)?)?;
324                project_include_roots.push(path.clone());
325                include_search_roots.push(CppIncludeSearchRoot {
326                    path,
327                    kind: CppIncludeSearchRootKind::Project,
328                });
329                index += 2;
330            }
331            "-iquote" => {
332                let path = argument_path(directory, arguments.get(index + 1)?)?;
333                project_include_roots.push(path.clone());
334                include_search_roots.push(CppIncludeSearchRoot {
335                    path,
336                    kind: CppIncludeSearchRootKind::Quote,
337                });
338                index += 2;
339            }
340            "-isystem" | "/external:I" | "/imsvc" => {
341                let path = argument_path(directory, arguments.get(index + 1)?)?;
342                system_include_roots.push(path.clone());
343                include_search_roots.push(CppIncludeSearchRoot {
344                    path,
345                    kind: CppIncludeSearchRootKind::System,
346                });
347                index += 2;
348            }
349            "-include" => {
350                forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
351                index += 2;
352            }
353            "-D" => {
354                defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
355                index += 2;
356            }
357            // The compiler applies -D and -U in command order, so a later -U
358            // removes an earlier -D. An -U proves nothing on its own: a header
359            // can still define the name, so only the surviving defines are
360            // positive facts (#2011).
361            "-U" => {
362                defined_macros.remove(&macro_name(arguments.get(index + 1)?)?);
363                index += 2;
364            }
365            _ => {
366                if let Some(path) = argument
367                    .strip_prefix("/external:I")
368                    .or_else(|| argument.strip_prefix("/imsvc"))
369                {
370                    let path = argument_path(directory, path)?;
371                    system_include_roots.push(path.clone());
372                    include_search_roots.push(CppIncludeSearchRoot {
373                        path,
374                        kind: CppIncludeSearchRootKind::System,
375                    });
376                } else if let Some(path) = argument
377                    .strip_prefix("-I")
378                    .or_else(|| argument.strip_prefix("/I"))
379                {
380                    let path = argument_path(directory, path)?;
381                    project_include_roots.push(path.clone());
382                    include_search_roots.push(CppIncludeSearchRoot {
383                        path,
384                        kind: CppIncludeSearchRootKind::Project,
385                    });
386                } else if let Some(path) = argument.strip_prefix("-iquote") {
387                    let path = argument_path(directory, path)?;
388                    project_include_roots.push(path.clone());
389                    include_search_roots.push(CppIncludeSearchRoot {
390                        path,
391                        kind: CppIncludeSearchRootKind::Quote,
392                    });
393                } else if let Some(path) = argument.strip_prefix("-isystem") {
394                    let path = argument_path(directory, path)?;
395                    system_include_roots.push(path.clone());
396                    include_search_roots.push(CppIncludeSearchRoot {
397                        path,
398                        kind: CppIncludeSearchRootKind::System,
399                    });
400                } else if let Some(definition) = argument.strip_prefix("-D") {
401                    defined_macros.insert(macro_name(definition)?);
402                } else if let Some(name) = argument.strip_prefix("-U") {
403                    defined_macros.remove(&macro_name(name)?);
404                } else if let Some(value) = argument.strip_prefix("-std=") {
405                    std_language = Some(if value.contains("++") {
406                        CompiledLanguage::Cpp
407                    } else {
408                        CompiledLanguage::C
409                    });
410                } else if let Some(value) = argument.strip_prefix("-x")
411                    && let Some(language) = language_from_x_value(value)
412                {
413                    explicit_language = Some(language);
414                }
415                index += 1;
416            }
417        }
418    }
419
420    Some(CppCompileContext {
421        project_include_roots,
422        system_include_roots,
423        forced_includes,
424        defined_macros,
425        include_search_roots,
426        driver_basename,
427        explicit_language,
428        std_language,
429    })
430}
431
432/// The driver invocation's basename, with one trailing extension (`.exe` on
433/// Windows) stripped. `-c++`-style drivers such as `g++`, `clang++`, and
434/// `arm-none-eabi-g++` keep their identifying `++` suffix; `cl.exe` reduces to
435/// `cl`, which decides nothing by itself.
436fn driver_basename(driver: &str) -> Option<String> {
437    Path::new(driver)
438        .file_stem()
439        .and_then(|stem| stem.to_str())
440        .map(str::to_owned)
441}
442
443/// The C/C++ family named by an `-x` value, split (`-x c++`) or glued
444/// (`-xc++`) alike. `None` for a value this evidence hierarchy does not
445/// classify, such as `-x assembler`.
446fn language_from_x_value(value: &str) -> Option<CompiledLanguage> {
447    let family = value
448        .strip_suffix("-header")
449        .or_else(|| value.strip_suffix("-cpp-output"))
450        .unwrap_or(value);
451    match family {
452        "c" => Some(CompiledLanguage::C),
453        "c++" => Some(CompiledLanguage::Cpp),
454        _ => None,
455    }
456}
457
458fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
459    if raw.is_empty() {
460        return None;
461    }
462    absolute_path(directory, Path::new(raw))
463}
464
465fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
466    let path = if path.is_absolute() {
467        path.to_path_buf()
468    } else {
469        directory.join(path)
470    }
471    .normalize();
472    path.is_absolute().then_some(path)
473}
474
475fn macro_name(definition: &str) -> Option<String> {
476    let end = definition.find('=').unwrap_or(definition.len());
477    let name = &definition[..end];
478    (!name.is_empty()).then(|| name.to_string())
479}
480
481#[cfg(test)]
482mod tests {
483    use super::{CompiledLanguage, CppCompileContexts, CppExternalIncludeResolution};
484    use brokk_bifrost_core::analyzer::project::TestProject;
485    use brokk_bifrost_core::analyzer::{Language, ProjectFile};
486    use std::path::Path;
487
488    /// Parses one argument vector into a compile context the same way
489    /// [`super::CompilationDatabaseEntry::compile_context`] does, without a
490    /// project or a `compile_commands.json` fixture.
491    fn context(arguments: &[&str]) -> super::CppCompileContext {
492        let arguments = arguments
493            .iter()
494            .map(|argument| argument.to_string())
495            .collect::<Vec<_>>();
496        super::parse_compile_arguments(Path::new("/workspace"), &arguments)
497            .expect("non-empty argument vector parses")
498    }
499
500    fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
501        let temp = tempfile::tempdir().expect("temp dir");
502        let root = temp.path().canonicalize().expect("canonical root");
503        ProjectFile::new(root.clone(), "src/main.cpp")
504            .write("int main() { return 0; }")
505            .expect("source");
506        if let Some(database) = database {
507            ProjectFile::new(root.clone(), "compile_commands.json")
508                .write(database)
509                .expect("database");
510        }
511        (temp, TestProject::new(root, Language::Cpp))
512    }
513
514    #[test]
515    fn missing_or_malformed_database_has_no_context() {
516        let (_temp, project) = project_with_database(None);
517        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
518        assert!(
519            CppCompileContexts::load(&project)
520                .contexts_for(&file)
521                .is_empty()
522        );
523
524        let (_temp, project) = project_with_database(Some("not json"));
525        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
526        assert!(
527            CppCompileContexts::load(&project)
528                .contexts_for(&file)
529                .is_empty()
530        );
531    }
532
533    #[test]
534    fn arguments_entry_collects_include_paths_and_macro_names() {
535        let (_temp, project) = project_with_database(Some(
536            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
537        ));
538        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
539        let contexts = CppCompileContexts::load(&project);
540        let [context] = contexts.contexts_for(&file) else {
541            panic!("one matching context");
542        };
543
544        assert_eq!(
545            vec![
546                project.root_path().join("include"),
547                project.root_path().join("quotes"),
548            ],
549            context.project_include_roots
550        );
551        assert_eq!(
552            vec![project.root_path().join("system-include")],
553            context.system_include_roots
554        );
555        assert!(context.defined_macros.contains("DEBUG"));
556        assert!(context.defined_macros.contains("FEATURE"));
557    }
558
559    #[test]
560    fn quoted_command_entry_is_tokenized_without_executing_it() {
561        let (_temp, project) = project_with_database(Some(
562            r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
563        ));
564        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
565        let contexts = CppCompileContexts::load(&project);
566        let [context] = contexts.contexts_for(&file) else {
567            panic!("one matching context");
568        };
569
570        assert_eq!(
571            vec![project.root_path().join("project include")],
572            context.project_include_roots
573        );
574        assert!(context.defined_macros.contains("NAME"));
575    }
576
577    #[test]
578    fn undefine_flags_apply_in_command_order() {
579        let context = context(&[
580            "cc",
581            "-DKEPT",
582            "-DCANCELLED",
583            "-U",
584            "CANCELLED",
585            "-UREVIVED",
586            "-DREVIVED",
587            "-UNEVER_DEFINED",
588        ]);
589        assert!(context.defined_macros.contains("KEPT"));
590        assert!(context.defined_macros.contains("REVIVED"));
591        assert!(!context.defined_macros.contains("CANCELLED"));
592        assert!(!context.defined_macros.contains("NEVER_DEFINED"));
593    }
594
595    #[test]
596    fn a_file_compiled_in_two_configurations_keeps_both() {
597        let (_temp, project) = project_with_database(Some(
598            r#"[
599                {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
600                {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
601                {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
602            ]"#,
603        ));
604        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
605        let contexts = CppCompileContexts::load(&project);
606        let candidates = contexts.contexts_for(&file);
607
608        // Before #1627 a second entry deleted the first and the file lost its
609        // context entirely, which read downstream as "never compiled".
610        assert_eq!(2, candidates.len());
611        assert!(candidates[0].defined_macros.is_empty());
612        assert!(candidates[1].defined_macros.contains("OTHER"));
613    }
614
615    #[test]
616    fn repeated_identical_entries_are_one_configuration() {
617        let (_temp, project) = project_with_database(Some(
618            r#"[
619                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
620                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
621            ]"#,
622        ));
623        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
624        let contexts = CppCompileContexts::load(&project);
625
626        // Two entries that parse to the same flags are not a disagreement, so
627        // the selection stays unambiguous.
628        assert_eq!(1, contexts.contexts_for(&file).len());
629    }
630
631    #[test]
632    fn an_unmatched_file_has_no_context() {
633        let (_temp, project) = project_with_database(Some(
634            r#"[{"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}]"#,
635        ));
636        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
637        assert!(
638            CppCompileContexts::load(&project)
639                .contexts_for(&file)
640                .is_empty()
641        );
642    }
643
644    #[test]
645    fn an_explicit_system_root_declares_an_angle_include() {
646        let (_temp, project) = project_with_database(Some(
647            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
648        ));
649        let header = ProjectFile::new(
650            project.root_path().to_path_buf(),
651            "fake-system/include/vector",
652        );
653        header
654            .write("namespace std { class vector {}; }")
655            .expect("header");
656        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
657
658        assert_eq!(
659            CppExternalIncludeResolution::Declared {
660                root: project
661                    .root_path()
662                    .join("fake-system/include")
663                    .canonicalize()
664                    .expect("canonical root"),
665                header: header.abs_path().canonicalize().expect("canonical header"),
666            },
667            CppCompileContexts::load(&project)
668                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
669        );
670    }
671
672    #[test]
673    fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
674        let external = tempfile::tempdir().expect("external root");
675        let external_root = external
676            .path()
677            .canonicalize()
678            .expect("canonical external root");
679        std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
680            .expect("external header");
681        let database = format!(
682            r#"[{{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","{}","-I","include","-c","src/main.cpp"]}}]"#,
683            external_root.display()
684        );
685        let (_temp, project) = project_with_database(Some(&database));
686        ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
687            .write("class Local {};")
688            .expect("local header");
689        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
690        let contexts = CppCompileContexts::load(&project);
691
692        assert!(matches!(
693            contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
694            CppExternalIncludeResolution::Declared { .. }
695        ));
696        assert_eq!(
697            CppExternalIncludeResolution::Undeclared,
698            contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
699        );
700    }
701
702    #[test]
703    fn configurations_must_agree_on_the_external_header() {
704        let first = tempfile::tempdir().expect("first root");
705        let second = tempfile::tempdir().expect("second root");
706        let first = first.path().canonicalize().expect("canonical first root");
707        let second = second.path().canonicalize().expect("canonical second root");
708        std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
709            .expect("first header");
710        std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
711            .expect("second header");
712        let database = format!(
713            r#"[
714                {{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}},
715                {{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}}
716            ]"#,
717            first.display(),
718            second.display()
719        );
720        let (_temp, project) = project_with_database(Some(&database));
721        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
722
723        assert_eq!(
724            CppExternalIncludeResolution::Conflicting,
725            CppCompileContexts::load(&project)
726                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
727        );
728    }
729
730    #[test]
731    fn external_include_cannot_escape_its_declared_root() {
732        let external = tempfile::tempdir().expect("external root");
733        let root = external.path().canonicalize().expect("canonical root");
734        std::fs::create_dir_all(root.join("include")).expect("include directory");
735        std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
736        let database = format!(
737            r#"[{{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}}]"#,
738            root.join("include").display()
739        );
740        let (_temp, project) = project_with_database(Some(&database));
741        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
742
743        assert_eq!(
744            CppExternalIncludeResolution::Undeclared,
745            CppCompileContexts::load(&project)
746                .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
747        );
748        assert_eq!(
749            CppExternalIncludeResolution::Undeclared,
750            CppCompileContexts::load(&project)
751                .resolve_external_angle_include(&file, &root.join("outside.hpp"))
752        );
753    }
754
755    #[test]
756    fn msvc_project_and_system_include_flags_preserve_search_order() {
757        let (_temp, project) = project_with_database(Some(
758            r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
759        ));
760        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
761        let contexts = CppCompileContexts::load(&project);
762        let [context] = contexts.contexts_for(&file) else {
763            panic!("one MSVC compile context");
764        };
765
766        assert_eq!(1, context.project_include_roots.len());
767        assert_eq!(2, context.system_include_roots.len());
768    }
769
770    #[test]
771    fn default_by_extension_c_is_c_and_everything_else_is_cpp() {
772        let cx = context(&["cc", "-c", "file.c"]);
773        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
774
775        let cx = context(&["cc", "-c", "file.cc"]);
776        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
777        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.hpp")));
778    }
779
780    #[test]
781    fn driver_basename_ending_in_plus_plus_selects_cpp_over_a_c_extension() {
782        let cx = context(&["clang++", "-c", "file.c"]);
783        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
784
785        // A driver that does not end in `++` (even a cross-compiler prefix
786        // form) falls through to extension evidence.
787        let cx = context(&["arm-none-eabi-gcc", "-c", "file.c"]);
788        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
789
790        // Prefixed driver names ending in `++` are still recognized.
791        let cx = context(&["arm-none-eabi-g++", "-c", "file.h"]);
792        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.h")));
793    }
794
795    #[test]
796    fn driver_exe_suffix_is_stripped_before_the_plus_plus_check() {
797        let cx = context(&["clang++.exe", "-c", "file.c"]);
798        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
799    }
800
801    #[test]
802    fn std_family_outranks_the_driver_basename() {
803        // -std outranks the driver: a g++ invocation pinned to a C standard
804        // compiles as C even though the driver name ends in `++`.
805        let cx = context(&["g++", "-std=gnu11", "-c", "file.c"]);
806        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
807
808        let cx = context(&["gcc", "-std=c++17", "-c", "file.cc"]);
809        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
810
811        let cx = context(&["gcc", "-std=c17", "-c", "file.cc"]);
812        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cc")));
813
814        let cx = context(&["gcc", "-std=gnu++20", "-c", "file.c"]);
815        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
816    }
817
818    #[test]
819    fn explicit_x_split_form_outranks_std_and_driver() {
820        let cx = context(&["clang", "-x", "c", "-std=c++17", "-c", "file.cpp"]);
821        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
822    }
823
824    #[test]
825    fn explicit_x_glued_form_is_recognized() {
826        let cx = context(&["gcc", "-xc++", "-c", "file.c"]);
827        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
828
829        let cx = context(&["g++", "-xc", "-c", "file.cpp"]);
830        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
831    }
832
833    #[test]
834    fn explicit_x_on_a_c_file_overrides_the_extension() {
835        // The precedence example from the ExecPlan: `-x c++` on a `.c` file
836        // compiles as C++.
837        let cx = context(&["gcc", "-x", "c++", "-c", "file.c"]);
838        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
839    }
840
841    #[test]
842    fn unrecognized_x_value_is_ignored() {
843        let cx = context(&["gcc", "-x", "assembler", "-c", "file.s"]);
844        // No C/C++ evidence at all in this configuration, so the extension
845        // (not "c") decides.
846        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.s")));
847    }
848
849    #[test]
850    fn msvc_tc_and_tp_force_the_language_regardless_of_extension() {
851        let cx = context(&["cl.exe", "/TC", "/c", "file.cpp"]);
852        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
853
854        let cx = context(&["cl.exe", "/TP", "/c", "file.c"]);
855        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
856    }
857
858    #[test]
859    fn cl_exe_without_tc_or_tp_decides_by_extension() {
860        let cx = context(&["cl.exe", "/c", "file.c"]);
861        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
862
863        let cx = context(&["cl.exe", "/c", "file.cpp"]);
864        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cpp")));
865    }
866}