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    /// Explicit -D/-U flags invalidate the compiler's default builtin state.
41    pub(crate) cplusplus_macro_configured: bool,
42    include_search_roots: Vec<CppIncludeSearchRoot>,
43    /// The invocation's driver basename (`arguments[0]`, minus one trailing
44    /// extension such as `.exe`), used by [`Self::tu_language`] only when
45    /// nothing stronger settles the language.
46    driver_basename: Option<String>,
47    /// Language forced by `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
48    /// The strongest evidence [`Self::tu_language`] considers.
49    explicit_language: Option<CompiledLanguage>,
50    /// Language implied by `-std=<value>`: the C family (`c11`, `gnu17`, ...)
51    /// versus the C++ family (`c++17`, `gnu++20`, ...), told apart by whether
52    /// the value contains `++`.
53    std_language: Option<CompiledLanguage>,
54}
55
56/// The language a compile-database entry says its translation unit is
57/// compiled as. See [`CppCompileContext::tu_language`].
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum CompiledLanguage {
60    C,
61    Cpp,
62}
63
64/// Why one compiler include-search entry exists.
65///
66/// The distinction is semantic. `-isystem` declares an external surface even
67/// when a test places that surface below the temporary workspace root, while an
68/// ordinary `-I` entry is external only when it points outside the workspace.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum CppIncludeSearchRootKind {
71    Project,
72    Quote,
73    System,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct CppIncludeSearchRoot {
78    path: PathBuf,
79    kind: CppIncludeSearchRootKind,
80}
81
82/// What all compile configurations for one source prove about an angle include.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum CppExternalIncludeResolution {
85    /// No compile command names the source file.
86    MissingCompileContext,
87    /// Every configuration agrees that no explicit external root contains it.
88    Undeclared,
89    /// Configurations select different headers, or only some select a header.
90    Conflicting,
91    /// Every configuration selects this exact external header.
92    Declared { root: PathBuf, header: PathBuf },
93}
94
95/// Informational coverage from one compile database against the analyzer's
96/// C++ workspace listing.
97///
98/// A missing source is not a discovery failure. Build systems routinely leave
99/// generated translation units in `compile_commands.json` after a clean or
100/// partial checkout. This report is therefore kept separate from compile
101/// contexts and dependency-discovery completeness. The count is exact over
102/// distinct canonical workspace-relative paths; the sample is deterministic
103/// and bounded by [`MAX_MISSING_WORKSPACE_SOURCE_SAMPLE`].
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct CppCompileDatabaseCoverage {
106    missing_workspace_source_count: usize,
107    missing_workspace_source_sample: Vec<PathBuf>,
108}
109
110impl CppCompileDatabaseCoverage {
111    /// The exact number of distinct in-workspace compile-database source paths
112    /// absent from the supplied analyzer listing.
113    pub fn missing_workspace_source_count(&self) -> usize {
114        self.missing_workspace_source_count
115    }
116
117    /// A sorted, workspace-relative sample of missing source paths.
118    pub fn missing_workspace_source_sample(&self) -> &[PathBuf] {
119        &self.missing_workspace_source_sample
120    }
121}
122
123#[derive(Debug, Default)]
124pub struct CppCompileContexts {
125    by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
126    /// Every distinct source path named by an in-workspace compile-database
127    /// entry, including entries whose compiler arguments are malformed. The
128    /// source identity is retained independently of whether a context can be
129    /// used for include resolution so coverage can report stale generated
130    /// entries without turning them into diagnostics.
131    database_sources: HashSet<PathBuf>,
132}
133
134impl CppCompileContexts {
135    pub fn load(project: &dyn Project) -> Self {
136        Self::load_with_io_error(project, |_| {})
137    }
138
139    /// Keep the legacy best-effort value while its analyzer observes provider
140    /// failures before a persistent memo can install that value (#7311).
141    pub fn load_with_io_error(
142        project: &dyn Project,
143        on_error: impl FnOnce(&std::io::Error),
144    ) -> Self {
145        let workspace_root = canonical_or_normalized_path(project.root());
146        let database_path = workspace_root.join(COMPILATION_DATABASE_PATH);
147        let database = match std::fs::read_to_string(database_path) {
148            Ok(database) => database,
149            Err(error) => {
150                on_error(&error);
151                return Self::default();
152            }
153        };
154        let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
155            return Self::default();
156        };
157
158        let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
159        let mut database_sources = HashSet::default();
160        for entry in entries {
161            let Some(source) = entry.source_path(&workspace_root) else {
162                continue;
163            };
164            let source_identity = canonical_or_normalized_path(&source);
165            if source_identity.strip_prefix(&workspace_root).is_err() {
166                continue;
167            }
168            database_sources.insert(source_identity.clone());
169            let Some(context) = entry.compile_context(&workspace_root) else {
170                continue;
171            };
172            // A build that compiles one file in several configurations records
173            // one entry per configuration. Keeping every distinct one lets the
174            // caller decide per name whether the configurations agree; dropping
175            // them would make "compiled two ways" look like "never compiled".
176            // Entries that parse to the same context are one configuration.
177            let candidates = by_source.entry(source_identity).or_default();
178            if !candidates.contains(&context) {
179                candidates.push(context);
180            }
181        }
182        Self {
183            by_source,
184            database_sources,
185        }
186    }
187
188    /// Compare compile-database source identities with the analyzer's exact
189    /// C++ workspace listing.
190    ///
191    /// The listing is supplied by the caller so discovery can preserve its
192    /// listing-driven scan and avoid a second workspace walk. Both sides are
193    /// compared as canonical [`PathBuf`] identities under `workspace_root`;
194    /// no source text, display path, or string-prefix identity participates.
195    /// Entries outside `workspace_root` are ordinary build inputs and are
196    /// silently excluded.
197    pub fn missing_workspace_sources<'a>(
198        &self,
199        workspace_root: &Path,
200        workspace_files: impl IntoIterator<Item = &'a ProjectFile>,
201    ) -> CppCompileDatabaseCoverage {
202        let workspace_root = canonical_or_normalized_path(workspace_root);
203        let listed_sources = workspace_files
204            .into_iter()
205            .map(|file| canonical_or_normalized_path(&file.abs_path()))
206            .collect::<HashSet<_>>();
207        let mut missing_count = 0usize;
208        let mut sample = BTreeSet::new();
209        for source in &self.database_sources {
210            let Some(relative) = (|| {
211                let relative = source.strip_prefix(&workspace_root).ok()?;
212                (!relative.as_os_str().is_empty() && !listed_sources.contains(source))
213                    .then(|| relative.to_path_buf().normalize())
214            })() else {
215                continue;
216            };
217            missing_count = missing_count.saturating_add(1);
218            sample.insert(relative);
219            if sample.len() > MAX_MISSING_WORKSPACE_SOURCE_SAMPLE {
220                sample.pop_last();
221            }
222        }
223        CppCompileDatabaseCoverage {
224            missing_workspace_source_count: missing_count,
225            missing_workspace_source_sample: sample.into_iter().collect(),
226        }
227    }
228
229    /// Every distinct compile configuration the database records for `file`,
230    /// empty when no entry names it.
231    ///
232    /// Exactly one context is an unambiguous selection. More than one means the
233    /// include closures can differ, so a name is absent only where every
234    /// candidate agrees that it is.
235    pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
236        self.by_source
237            .get(&canonical_or_normalized_path(&file.abs_path()))
238            .map_or(&[], Vec::as_slice)
239    }
240
241    /// Resolve one literal include through explicit external roots in every
242    /// compile configuration for `file`.
243    ///
244    /// This method never probes an implicit compiler sysroot and never executes
245    /// the compiler. A result is declared only when every configuration selects
246    /// the same existing file. This preserves the multi-configuration honesty
247    /// required by C++ diagnostics and external semantic packs.
248    pub fn resolve_external_angle_include(
249        &self,
250        file: &ProjectFile,
251        include: &Path,
252    ) -> CppExternalIncludeResolution {
253        self.resolve_external_include(file, &file.abs_path(), include, false)
254    }
255
256    /// Resolve one literal include using C include search semantics.
257    ///
258    /// A quoted include first checks the directory containing `including_file`,
259    /// then the explicit compiler search roots in command order. An angle
260    /// include skips the including-file directory and quote-only roots. The
261    /// result is declared only when every configuration selects the same
262    /// existing external file.
263    pub fn resolve_external_include(
264        &self,
265        file: &ProjectFile,
266        including_file: &Path,
267        include: &Path,
268        quoted: bool,
269    ) -> CppExternalIncludeResolution {
270        let contexts = self.contexts_for(file);
271        let Some(first_context) = contexts.first() else {
272            return CppExternalIncludeResolution::MissingCompileContext;
273        };
274        let first =
275            first_context.resolve_external_include(file.root(), including_file, include, quoted);
276        if contexts.iter().skip(1).any(|context| {
277            context.resolve_external_include(file.root(), including_file, include, quoted) != first
278        }) {
279            return CppExternalIncludeResolution::Conflicting;
280        }
281        match first {
282            Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
283            None => CppExternalIncludeResolution::Undeclared,
284        }
285    }
286}
287
288impl CppCompileContext {
289    /// The language this compile configuration says `source_path` is
290    /// compiled as, strongest evidence first:
291    ///
292    /// 1. An explicit `-x <lang>` / `-xc` / `-xc++` or MSVC `/TC` / `/TP`.
293    /// 2. A `-std=<value>` family (C when the value has no `++`, C++ when it
294    ///    does).
295    /// 3. The driver basename: `g++`, `clang++`, `c++`, and similar names
296    ///    ending in `++` compile as C++ regardless of extension.
297    /// 4. `source_path`'s extension: exactly `c` (case-sensitive, matching
298    ///    `is_c_source_file`) is C, everything else is C++. This is also the
299    ///    rule `cl.exe` itself uses when neither `/TC` nor `/TP` is given.
300    pub fn tu_language(&self, source_path: &Path) -> CompiledLanguage {
301        if let Some(language) = self.explicit_language {
302            return language;
303        }
304        if let Some(language) = self.std_language {
305            return language;
306        }
307        if self
308            .driver_basename
309            .as_deref()
310            .is_some_and(|driver| driver.ends_with("++"))
311        {
312            return CompiledLanguage::Cpp;
313        }
314        if source_path
315            .extension()
316            .and_then(|extension| extension.to_str())
317            == Some("c")
318        {
319            CompiledLanguage::C
320        } else {
321            CompiledLanguage::Cpp
322        }
323    }
324
325    fn external_include_roots<'a>(
326        &'a self,
327        workspace_root: &'a Path,
328        quoted: bool,
329    ) -> impl Iterator<Item = &'a Path> + 'a {
330        self.include_search_roots.iter().filter_map(move |root| {
331            (quoted || root.kind != CppIncludeSearchRootKind::Quote)
332                .then_some(root)
333                .filter(|root| {
334                    root.kind == CppIncludeSearchRootKind::System
335                        || !root.path.starts_with(workspace_root)
336                })
337                .map(|root| root.path.as_path())
338        })
339    }
340
341    fn resolve_external_include(
342        &self,
343        workspace_root: &Path,
344        including_file: &Path,
345        include: &Path,
346        quoted: bool,
347    ) -> Option<(PathBuf, PathBuf)> {
348        if include.is_absolute()
349            || (!quoted
350                && include
351                    .components()
352                    .any(|component| !matches!(component, Component::Normal(_))))
353            || (quoted
354                && include.components().any(|component| {
355                    matches!(component, Component::RootDir | Component::Prefix(_))
356                }))
357        {
358            return None;
359        }
360
361        let workspace_root = canonical_or_normalized_path(workspace_root);
362        if quoted {
363            let including_directory = including_file.parent()?;
364            if let Ok(candidate) = including_directory.join(include).canonicalize()
365                && candidate.is_file()
366            {
367                // `canonicalize` keeps the Windows verbatim prefix that
368                // `workspace_root` normalizes away; compare in one spelling.
369                if candidate.clone().normalize().starts_with(&workspace_root) {
370                    return None;
371                }
372                if let Some(root) = self
373                    .external_include_roots(&workspace_root, true)
374                    .filter_map(|root| {
375                        let root = root.canonicalize().ok()?;
376                        candidate.starts_with(&root).then_some(root)
377                    })
378                    .next()
379                {
380                    return Some((root, candidate));
381                }
382                return None;
383            }
384        }
385
386        self.external_include_roots(&workspace_root, quoted)
387            .filter_map(|root| {
388                let root = root.canonicalize().ok()?;
389                let candidate = root.join(include).canonicalize().ok()?;
390                (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
391            })
392            .next()
393    }
394}
395
396#[derive(Debug, Deserialize)]
397struct CompilationDatabaseEntry {
398    directory: PathBuf,
399    file: PathBuf,
400    arguments: Option<Vec<String>>,
401    command: Option<String>,
402}
403
404impl CompilationDatabaseEntry {
405    fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
406        absolute_path(
407            &command_directory(workspace_root, &self.directory)?,
408            &self.file,
409        )
410    }
411
412    fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
413        let arguments = match &self.arguments {
414            Some(arguments) if !arguments.is_empty() => arguments.clone(),
415            Some(_) => return None,
416            None => shlex::split(self.command.as_deref()?)?,
417        };
418        parse_compile_arguments(
419            &command_directory(workspace_root, &self.directory)?,
420            &arguments,
421        )
422    }
423}
424
425fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
426    absolute_path(workspace_root, directory)
427}
428
429fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
430    if arguments.is_empty() {
431        return None;
432    }
433
434    let driver_basename = driver_basename(&arguments[0]);
435    let mut project_include_roots = Vec::new();
436    let mut system_include_roots = Vec::new();
437    let mut forced_includes = Vec::new();
438    let mut defined_macros = HashSet::default();
439    let mut cplusplus_macro_configured = false;
440    let mut include_search_roots = Vec::new();
441    let mut explicit_language = None;
442    let mut std_language = None;
443    let mut index = 1;
444    while index < arguments.len() {
445        let argument = &arguments[index];
446        match argument.as_str() {
447            "-x" => {
448                if let Some(language) = language_from_x_value(arguments.get(index + 1)?) {
449                    explicit_language = Some(language);
450                }
451                index += 2;
452            }
453            "/TC" => {
454                explicit_language = Some(CompiledLanguage::C);
455                index += 1;
456            }
457            "/TP" => {
458                explicit_language = Some(CompiledLanguage::Cpp);
459                index += 1;
460            }
461            "-I" | "/I" => {
462                let path = argument_path(directory, arguments.get(index + 1)?)?;
463                project_include_roots.push(path.clone());
464                include_search_roots.push(CppIncludeSearchRoot {
465                    path,
466                    kind: CppIncludeSearchRootKind::Project,
467                });
468                index += 2;
469            }
470            "-iquote" => {
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::Quote,
476                });
477                index += 2;
478            }
479            "-isystem" | "/external:I" | "/imsvc" => {
480                let path = argument_path(directory, arguments.get(index + 1)?)?;
481                system_include_roots.push(path.clone());
482                include_search_roots.push(CppIncludeSearchRoot {
483                    path,
484                    kind: CppIncludeSearchRootKind::System,
485                });
486                index += 2;
487            }
488            "-include" => {
489                forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
490                index += 2;
491            }
492            "-D" => {
493                let name = macro_name(arguments.get(index + 1)?)?;
494                cplusplus_macro_configured |= name == "__cplusplus";
495                defined_macros.insert(name);
496                index += 2;
497            }
498            // The compiler applies -D and -U in command order, so a later -U
499            // removes an earlier -D. An -U proves nothing on its own: a header
500            // can still define the name, so only the surviving defines are
501            // positive facts (#2011).
502            "-U" => {
503                let name = macro_name(arguments.get(index + 1)?)?;
504                cplusplus_macro_configured |= name == "__cplusplus";
505                defined_macros.remove(&name);
506                index += 2;
507            }
508            _ => {
509                if let Some(path) = argument
510                    .strip_prefix("/external:I")
511                    .or_else(|| argument.strip_prefix("/imsvc"))
512                {
513                    let path = argument_path(directory, path)?;
514                    system_include_roots.push(path.clone());
515                    include_search_roots.push(CppIncludeSearchRoot {
516                        path,
517                        kind: CppIncludeSearchRootKind::System,
518                    });
519                } else if let Some(path) = argument
520                    .strip_prefix("-I")
521                    .or_else(|| argument.strip_prefix("/I"))
522                {
523                    let path = argument_path(directory, path)?;
524                    project_include_roots.push(path.clone());
525                    include_search_roots.push(CppIncludeSearchRoot {
526                        path,
527                        kind: CppIncludeSearchRootKind::Project,
528                    });
529                } else if let Some(path) = argument.strip_prefix("-iquote") {
530                    let path = argument_path(directory, path)?;
531                    project_include_roots.push(path.clone());
532                    include_search_roots.push(CppIncludeSearchRoot {
533                        path,
534                        kind: CppIncludeSearchRootKind::Quote,
535                    });
536                } else if let Some(path) = argument.strip_prefix("-isystem") {
537                    let path = argument_path(directory, path)?;
538                    system_include_roots.push(path.clone());
539                    include_search_roots.push(CppIncludeSearchRoot {
540                        path,
541                        kind: CppIncludeSearchRootKind::System,
542                    });
543                } else if let Some(definition) = argument.strip_prefix("-D") {
544                    let name = macro_name(definition)?;
545                    cplusplus_macro_configured |= name == "__cplusplus";
546                    defined_macros.insert(name);
547                } else if let Some(name) = argument.strip_prefix("-U") {
548                    let name = macro_name(name)?;
549                    cplusplus_macro_configured |= name == "__cplusplus";
550                    defined_macros.remove(&name);
551                } else if let Some(value) = argument.strip_prefix("-std=") {
552                    std_language = Some(if value.contains("++") {
553                        CompiledLanguage::Cpp
554                    } else {
555                        CompiledLanguage::C
556                    });
557                } else if let Some(value) = argument.strip_prefix("-x")
558                    && let Some(language) = language_from_x_value(value)
559                {
560                    explicit_language = Some(language);
561                }
562                index += 1;
563            }
564        }
565    }
566
567    Some(CppCompileContext {
568        project_include_roots,
569        system_include_roots,
570        forced_includes,
571        defined_macros,
572        cplusplus_macro_configured,
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=a=b","-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        assert!(!context.defined_macros.contains("FEATURE=a"));
716    }
717
718    #[test]
719    fn quoted_command_entry_is_tokenized_without_executing_it() {
720        let (_temp, project) = project_with_database(Some(
721            r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
722        ));
723        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
724        let contexts = CppCompileContexts::load(&project);
725        let [context] = contexts.contexts_for(&file) else {
726            panic!("one matching context");
727        };
728
729        assert_eq!(
730            vec![project.root_path().join("project include")],
731            context.project_include_roots
732        );
733        assert!(context.defined_macros.contains("NAME"));
734    }
735
736    #[test]
737    #[hardwon("#2011: conditional C visibility ignored command-order macro state")]
738    fn undefine_flags_apply_in_command_order() {
739        let context = context(&[
740            "cc",
741            "-DKEPT",
742            "-DCANCELLED",
743            "-U",
744            "CANCELLED",
745            "-UREVIVED",
746            "-DREVIVED",
747            "-UNEVER_DEFINED",
748        ]);
749        assert!(context.defined_macros.contains("KEPT"));
750        assert!(context.defined_macros.contains("REVIVED"));
751        assert!(!context.defined_macros.contains("CANCELLED"));
752        assert!(!context.defined_macros.contains("NEVER_DEFINED"));
753        assert!(!context.cplusplus_macro_configured);
754        for flags in [
755            vec!["c++", "-U__cplusplus"],
756            vec!["c++", "-U", "__cplusplus"],
757            vec!["c++", "-D__cplusplus=1", "-U__cplusplus"],
758            vec!["c++", "-U__cplusplus", "-D", "__cplusplus=1"],
759        ] {
760            let configured = super::parse_compile_arguments(
761                Path::new("."),
762                &flags
763                    .iter()
764                    .map(|flag| (*flag).to_string())
765                    .collect::<Vec<_>>(),
766            )
767            .expect("compiler flags");
768            assert!(configured.cplusplus_macro_configured, "{flags:?}");
769            assert_eq!(
770                configured.defined_macros.contains("__cplusplus"),
771                flags.last() == Some(&"__cplusplus=1"),
772                "{flags:?}"
773            );
774        }
775    }
776
777    #[test]
778    fn a_file_compiled_in_two_configurations_keeps_both() {
779        let (_temp, project) = project_with_database(Some(
780            r#"[
781                {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
782                {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
783                {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
784            ]"#,
785        ));
786        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
787        let contexts = CppCompileContexts::load(&project);
788        let candidates = contexts.contexts_for(&file);
789
790        // Before #1627 a second entry deleted the first and the file lost its
791        // context entirely, which read downstream as "never compiled".
792        assert_eq!(2, candidates.len());
793        assert!(candidates[0].defined_macros.is_empty());
794        assert!(candidates[1].defined_macros.contains("OTHER"));
795    }
796
797    #[test]
798    fn repeated_identical_entries_are_one_configuration() {
799        let (_temp, project) = project_with_database(Some(
800            r#"[
801                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
802                {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
803            ]"#,
804        ));
805        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
806        let contexts = CppCompileContexts::load(&project);
807
808        // Two entries that parse to the same flags are not a disagreement, so
809        // the selection stays unambiguous.
810        assert_eq!(1, contexts.contexts_for(&file).len());
811    }
812
813    #[test]
814    fn missing_workspace_sources_are_canonical_bounded_and_deterministic() {
815        let (_temp, project) = project_with_database(None);
816        let root = project.root_path().to_path_buf();
817        ProjectFile::new(root.clone(), "src/present.cpp")
818            .write("int present() { return 0; }")
819            .expect("present source");
820        ProjectFile::new(root.clone(), "src/present.c")
821            .write("int present_c(void) { return 0; }")
822            .expect("present C source");
823        ProjectFile::new(root.clone(), "include/present.hin")
824            .write("int generated_declaration(void);")
825            .expect("present C header template");
826
827        let mut entries = vec![serde_json::json!({
828            "directory": ".",
829            "file": "src/./present.cpp",
830            "arguments": ["clang++", "-c", "src/present.cpp"]
831        })];
832        entries.push(serde_json::json!({
833            "directory": ".",
834            "file": "src/present.c",
835            "arguments": ["clang", "-c", "src/present.c"]
836        }));
837        entries.push(serde_json::json!({
838            "directory": ".",
839            "file": "include/present.hin",
840            "arguments": ["clang", "-x", "c-header", "include/present.hin"]
841        }));
842        entries.extend(
843            (0..(super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3)).map(|index| {
844                serde_json::json!({
845                    "directory": ".",
846                    "file": format!("generated/./unit-{index:02}.cpp"),
847                    "arguments": ["clang++", "-c", format!("generated/unit-{index:02}.cpp")]
848                })
849            }),
850        );
851        // This duplicate resolves to the same canonical path and must not
852        // inflate the exact count or change the sample.
853        entries.push(serde_json::json!({
854            "directory": "generated",
855            "file": "../generated/unit-00.cpp",
856            "arguments": ["clang++", "-c", "../generated/unit-00.cpp"]
857        }));
858        ProjectFile::new(root.clone(), "compile_commands.json")
859            .write(serde_json::to_string(&entries).expect("database JSON"))
860            .expect("database");
861
862        let contexts = CppCompileContexts::load(&project);
863        let listed = project
864            .analyzable_files(Language::Cpp)
865            .expect("C++ listing");
866        let coverage = contexts.missing_workspace_sources(&root, &listed);
867
868        assert_eq!(
869            super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3,
870            coverage.missing_workspace_source_count()
871        );
872        assert_eq!(
873            (0..super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE)
874                .map(|index| Path::new("generated").join(format!("unit-{index:02}.cpp")))
875                .collect::<Vec<_>>(),
876            coverage.missing_workspace_source_sample()
877        );
878        assert!(
879            !coverage
880                .missing_workspace_source_sample()
881                .contains(&PathBuf::from("src/present.cpp"))
882        );
883
884        let reversed = entries.into_iter().rev().collect::<Vec<_>>();
885        ProjectFile::new(root.clone(), "compile_commands.json")
886            .write(serde_json::to_string(&reversed).expect("reversed database JSON"))
887            .expect("reversed database");
888        let reversed_coverage =
889            CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
890        assert_eq!(coverage, reversed_coverage);
891    }
892
893    #[test]
894    fn missing_workspace_sources_excludes_outside_root_and_ignored_files() {
895        let temp = tempfile::tempdir().expect("workspace root");
896        let root = temp.path().canonicalize().expect("canonical root");
897        let outside = tempfile::tempdir().expect("outside root");
898        let outside_source = outside
899            .path()
900            .canonicalize()
901            .expect("canonical outside root")
902            .join("outside.cpp")
903            .to_string_lossy()
904            .into_owned();
905        ProjectFile::new(root.clone(), "src/present.cpp")
906            .write("int present() { return 0; }")
907            .expect("present source");
908        ProjectFile::new(root.clone(), "ignored.cpp")
909            .write("int ignored() { return 0; }")
910            .expect("ignored source");
911        ProjectFile::new(root.clone(), ".bifrostignore")
912            .write("ignored.cpp\n")
913            .expect("ignore file");
914        let database = serde_json::json!([
915            {
916                "directory": ".",
917                "file": "src/../src/present.cpp",
918                "arguments": ["clang++", "-c", "src/present.cpp"]
919            },
920            {
921                "directory": ".",
922                "file": "ignored.cpp",
923                "arguments": ["clang++", "-c", "ignored.cpp"]
924            },
925            {
926                "directory": ".",
927                "file": outside_source.clone(),
928                "arguments": ["clang++", "-c", outside_source]
929            }
930        ]);
931        ProjectFile::new(root.clone(), "compile_commands.json")
932            .write(database.to_string())
933            .expect("database");
934        let project = FilesystemProject::new(root.clone()).expect("filesystem project");
935        let listed = project
936            .analyzable_files(Language::Cpp)
937            .expect("C++ listing");
938        assert!(
939            listed
940                .iter()
941                .any(|file| file.rel_path() == Path::new("src/present.cpp"))
942        );
943        assert!(
944            !listed
945                .iter()
946                .any(|file| file.rel_path() == Path::new("ignored.cpp"))
947        );
948
949        let coverage = CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
950        assert_eq!(1, coverage.missing_workspace_source_count());
951        assert_eq!(
952            &[PathBuf::from("ignored.cpp")],
953            coverage.missing_workspace_source_sample()
954        );
955    }
956
957    #[test]
958    fn a_quoted_include_checks_the_including_directory_before_external_roots() {
959        let external = tempfile::tempdir().expect("external root");
960        let external_root = external
961            .path()
962            .canonicalize()
963            .expect("canonical external root");
964        std::fs::write(external_root.join("vendor.hpp"), "class External {};\n")
965            .expect("external vendor header");
966        std::fs::write(external_root.join("from-search.hpp"), "class Search {};\n")
967            .expect("external search header");
968        let database = serde_json::json!([{
969            "directory": ".",
970            "file": "src/main.cpp",
971            "arguments": [
972                "clang++",
973                "-I",
974                external_root,
975                "-c",
976                "src/main.cpp"
977            ]
978        }])
979        .to_string();
980        let (_temp, project) = project_with_database(Some(&database));
981        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
982        let source_path = file.abs_path();
983        let source_directory = source_path.parent().expect("source directory");
984        std::fs::create_dir_all(source_directory).expect("source directory");
985        std::fs::write(source_directory.join("vendor.hpp"), "class Local {};\n")
986            .expect("local vendor header");
987        let contexts = CppCompileContexts::load(&project);
988
989        assert_eq!(
990            CppExternalIncludeResolution::Undeclared,
991            contexts.resolve_external_include(
992                &file,
993                &file.abs_path(),
994                std::path::Path::new("vendor.hpp"),
995                true,
996            )
997        );
998        assert!(matches!(
999            contexts.resolve_external_include(
1000                &file,
1001                &file.abs_path(),
1002                std::path::Path::new("from-search.hpp"),
1003                true,
1004            ),
1005            CppExternalIncludeResolution::Declared { .. }
1006        ));
1007        assert!(matches!(
1008            contexts.resolve_external_include(
1009                &file,
1010                &file.abs_path(),
1011                std::path::Path::new("vendor.hpp"),
1012                false,
1013            ),
1014            CppExternalIncludeResolution::Declared { .. }
1015        ));
1016    }
1017
1018    #[test]
1019    fn configurations_must_agree_on_the_external_header() {
1020        let first = tempfile::tempdir().expect("first root");
1021        let second = tempfile::tempdir().expect("second root");
1022        let first = first.path().canonicalize().expect("canonical first root");
1023        let second = second.path().canonicalize().expect("canonical second root");
1024        std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
1025            .expect("first header");
1026        std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
1027            .expect("second header");
1028        let database = serde_json::json!([
1029            {
1030                "directory": ".",
1031                "file": "src/main.cpp",
1032                "arguments": ["clang++", "-isystem", first, "-c", "src/main.cpp"]
1033            },
1034            {
1035                "directory": ".",
1036                "file": "src/main.cpp",
1037                "arguments": ["clang++", "-isystem", second, "-c", "src/main.cpp"]
1038            }
1039        ])
1040        .to_string();
1041        let (_temp, project) = project_with_database(Some(&database));
1042        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1043
1044        assert_eq!(
1045            CppExternalIncludeResolution::Conflicting,
1046            CppCompileContexts::load(&project)
1047                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1048        );
1049    }
1050
1051    #[test]
1052    fn external_include_cannot_escape_its_declared_root() {
1053        let external = tempfile::tempdir().expect("external root");
1054        let root = external.path().canonicalize().expect("canonical root");
1055        std::fs::create_dir_all(root.join("include")).expect("include directory");
1056        std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
1057        let database = serde_json::json!([{
1058            "directory": ".",
1059            "file": "src/main.cpp",
1060            "arguments": [
1061                "clang++",
1062                "-isystem",
1063                root.join("include"),
1064                "-c",
1065                "src/main.cpp"
1066            ]
1067        }])
1068        .to_string();
1069        let (_temp, project) = project_with_database(Some(&database));
1070        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1071
1072        assert_eq!(
1073            CppExternalIncludeResolution::Undeclared,
1074            CppCompileContexts::load(&project)
1075                .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
1076        );
1077        assert_eq!(
1078            CppExternalIncludeResolution::Undeclared,
1079            CppCompileContexts::load(&project)
1080                .resolve_external_angle_include(&file, &root.join("outside.hpp"))
1081        );
1082    }
1083
1084    #[test]
1085    fn msvc_project_and_system_include_flags_preserve_search_order() {
1086        let (_temp, project) = project_with_database(Some(
1087            r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
1088        ));
1089        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1090        let contexts = CppCompileContexts::load(&project);
1091        let [context] = contexts.contexts_for(&file) else {
1092            panic!("one MSVC compile context");
1093        };
1094
1095        assert_eq!(1, context.project_include_roots.len());
1096        assert_eq!(2, context.system_include_roots.len());
1097    }
1098
1099    #[test]
1100    fn driver_basename_ending_in_plus_plus_selects_cpp_over_a_c_extension() {
1101        let cx = context(&["clang++", "-c", "file.c"]);
1102        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1103
1104        // A driver that does not end in `++` (even a cross-compiler prefix
1105        // form) falls through to extension evidence.
1106        let cx = context(&["arm-none-eabi-gcc", "-c", "file.c"]);
1107        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1108
1109        // Prefixed driver names ending in `++` are still recognized.
1110        let cx = context(&["arm-none-eabi-g++", "-c", "file.h"]);
1111        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.h")));
1112    }
1113
1114    #[test]
1115    fn driver_exe_suffix_is_stripped_before_the_plus_plus_check() {
1116        let cx = context(&["clang++.exe", "-c", "file.c"]);
1117        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1118    }
1119
1120    #[test]
1121    fn std_family_outranks_the_driver_basename() {
1122        // -std outranks the driver: a g++ invocation pinned to a C standard
1123        // compiles as C even though the driver name ends in `++`.
1124        let cx = context(&["g++", "-std=gnu11", "-c", "file.c"]);
1125        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1126
1127        let cx = context(&["gcc", "-std=c++17", "-c", "file.cc"]);
1128        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1129
1130        let cx = context(&["gcc", "-std=c17", "-c", "file.cc"]);
1131        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cc")));
1132
1133        let cx = context(&["gcc", "-std=gnu++20", "-c", "file.c"]);
1134        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1135    }
1136
1137    #[test]
1138    fn explicit_x_split_form_outranks_std_and_driver() {
1139        let cx = context(&["clang", "-x", "c", "-std=c++17", "-c", "file.cpp"]);
1140        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1141    }
1142
1143    #[test]
1144    fn explicit_x_glued_form_is_recognized() {
1145        let cx = context(&["gcc", "-xc++", "-c", "file.c"]);
1146        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1147
1148        let cx = context(&["g++", "-xc", "-c", "file.cpp"]);
1149        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1150    }
1151
1152    #[test]
1153    fn explicit_x_on_a_c_file_overrides_the_extension() {
1154        // The precedence example from the ExecPlan: `-x c++` on a `.c` file
1155        // compiles as C++.
1156        let cx = context(&["gcc", "-x", "c++", "-c", "file.c"]);
1157        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1158    }
1159
1160    #[test]
1161    fn msvc_tc_and_tp_force_the_language_regardless_of_extension() {
1162        let cx = context(&["cl.exe", "/TC", "/c", "file.cpp"]);
1163        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1164
1165        let cx = context(&["cl.exe", "/TP", "/c", "file.c"]);
1166        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1167    }
1168
1169    #[test]
1170    fn missing_or_malformed_database_has_no_context() {
1171        let (_temp, project) = project_with_database(None);
1172        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1173        assert!(
1174            CppCompileContexts::load(&project)
1175                .contexts_for(&file)
1176                .is_empty()
1177        );
1178
1179        let (_temp, project) = project_with_database(Some("not json"));
1180        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1181        assert!(
1182            CppCompileContexts::load(&project)
1183                .contexts_for(&file)
1184                .is_empty()
1185        );
1186    }
1187
1188    #[test]
1189    fn an_explicit_system_root_declares_an_angle_include() {
1190        let (_temp, project) = project_with_database(Some(
1191            r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
1192        ));
1193        let header = ProjectFile::new(
1194            project.root_path().to_path_buf(),
1195            "fake-system/include/vector",
1196        );
1197        header
1198            .write("namespace std { class vector {}; }")
1199            .expect("header");
1200        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1201
1202        assert_eq!(
1203            CppExternalIncludeResolution::Declared {
1204                root: project
1205                    .root_path()
1206                    .join("fake-system/include")
1207                    .canonicalize()
1208                    .expect("canonical root"),
1209                header: header.abs_path().canonicalize().expect("canonical header"),
1210            },
1211            CppCompileContexts::load(&project)
1212                .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1213        );
1214    }
1215
1216    #[test]
1217    fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
1218        let external = tempfile::tempdir().expect("external root");
1219        let external_root = external
1220            .path()
1221            .canonicalize()
1222            .expect("canonical external root");
1223        std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
1224            .expect("external header");
1225        let database = serde_json::json!([{
1226            "directory": ".",
1227            "file": "src/main.cpp",
1228            "arguments": [
1229                "clang++",
1230                "-I",
1231                external_root,
1232                "-I",
1233                "include",
1234                "-c",
1235                "src/main.cpp"
1236            ]
1237        }])
1238        .to_string();
1239        let (_temp, project) = project_with_database(Some(&database));
1240        ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
1241            .write("class Local {};")
1242            .expect("local header");
1243        let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1244        let contexts = CppCompileContexts::load(&project);
1245
1246        assert!(matches!(
1247            contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
1248            CppExternalIncludeResolution::Declared { .. }
1249        ));
1250        assert_eq!(
1251            CppExternalIncludeResolution::Undeclared,
1252            contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
1253        );
1254    }
1255
1256    #[test]
1257    fn default_by_extension_c_is_c_and_everything_else_is_cpp() {
1258        let cx = context(&["cc", "-c", "file.c"]);
1259        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1260
1261        let cx = context(&["cc", "-c", "file.cc"]);
1262        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1263        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.hpp")));
1264        let cx = context(&["cl.exe", "/c", "file.c"]);
1265        assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1266
1267        let cx = context(&["cl.exe", "/c", "file.cpp"]);
1268        assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cpp")));
1269    }
1270
1271    #[test]
1272    #[hardwon("#6929: a Windows verbatim prefix made an in-workspace sibling external")]
1273    fn quoted_sibling_external_header_respects_workspace_boundary() {
1274        // Filesystem identity is the oracle: quoted lookup first chooses a
1275        // sibling; only a sibling outside the workspace belongs to this API.
1276        let outer = tempfile::tempdir().unwrap();
1277        let outer_root = outer.path().canonicalize().unwrap();
1278        let workspace = outer_root.join("workspace");
1279        let external = outer_root.join("external");
1280        std::fs::create_dir_all(&workspace).unwrap();
1281        std::fs::create_dir_all(&external).unwrap();
1282        for directory in [&workspace, &external] {
1283            std::fs::write(directory.join("caller.hpp"), "#include \"sibling.hpp\"\n").unwrap();
1284            std::fs::write(directory.join("sibling.hpp"), "struct S {};\n").unwrap();
1285        }
1286        // A declared root can contain the workspace; it still must not turn
1287        // an in-workspace sibling into an external artifact.
1288        let cx = super::parse_compile_arguments(
1289            &workspace,
1290            &[
1291                "clang++".into(),
1292                "-I".into(),
1293                outer_root.to_string_lossy().into_owned(),
1294            ],
1295        )
1296        .unwrap();
1297        assert_eq!(
1298            cx.resolve_external_include(
1299                &workspace,
1300                &workspace.join("caller.hpp"),
1301                Path::new("sibling.hpp"),
1302                true
1303            ),
1304            None
1305        );
1306        assert_eq!(
1307            cx.resolve_external_include(
1308                &workspace,
1309                &external.join("caller.hpp"),
1310                Path::new("sibling.hpp"),
1311                true
1312            ),
1313            Some((outer_root, external.join("sibling.hpp")))
1314        );
1315    }
1316}