Skip to main content

brokk_bifrost_cpp/
compile_context.rs

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