use brokk_bifrost_core::analyzer::ProjectFile;
use brokk_bifrost_core::analyzer::model::{ImportInfo, Language};
use brokk_bifrost_core::analyzer::project::Project;
use brokk_bifrost_core::hash::{HashMap, HashSet};
use brokk_bifrost_core::path_normalization::NormalizePath;
use brokk_bifrost_core::path_utils::path_suffix_key;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tree_sitter::Node;
use crate::source_facts::CppFileSourceFacts;
const CPP_HEADER_EXTENSIONS: &[&str] = &["h", "hin", "hpp", "hh", "hxx"];
pub fn is_cpp_translation_unit(file: &ProjectFile) -> bool {
file.rel_path()
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.is_some_and(|extension| {
Language::Cpp.extensions().contains(&extension.as_str())
&& !CPP_HEADER_EXTENSIONS.contains(&extension.as_str())
})
}
pub struct IncludeTargetIndex {
by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
by_file_name: HashMap<String, Vec<ProjectFile>>,
}
pub fn canonical_include_paths(facts: &CppFileSourceFacts) -> Vec<String> {
facts
.facts
.includes
.iter()
.map(|include| include.path.clone())
.collect()
}
pub fn canonical_include_statements(facts: &CppFileSourceFacts) -> Vec<String> {
facts
.facts
.includes
.iter()
.map(|include| canonical_include_statement(&include.path, include.quoted))
.collect()
}
pub fn canonical_include_statement(path: &str, quoted: bool) -> String {
if quoted {
format!("#include \"{path}\"")
} else {
format!("#include <{path}>")
}
}
impl IncludeTargetIndex {
pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
for file in files {
by_rel_path
.entry(file.rel_path().to_path_buf())
.or_default()
.push(file.clone());
if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
by_file_name
.entry(file_name.to_string())
.or_default()
.push(file.clone());
}
}
Self {
by_rel_path,
by_file_name,
}
}
pub fn workspace_relative_paths(&self) -> Vec<String> {
let mut paths = self
.by_rel_path
.keys()
.map(|path| brokk_bifrost_core::path_utils::workspace_relative_slash_path(path))
.collect::<Vec<_>>();
paths.sort_unstable();
paths
}
pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
let include_path = Path::new(include);
let mut matched = HashSet::default();
let mut resolved = Vec::new();
if let Some(targets) = self.by_rel_path.get(include_path) {
for target in targets {
if matched.insert(target.clone()) {
resolved.push(target.clone());
}
}
}
for suffix in string_suffixes(include) {
if let Some(targets) = self.by_file_name.get(suffix) {
for target in targets {
if matched.insert(target.clone()) {
resolved.push(target.clone());
}
}
}
}
resolved
}
pub fn names_indexed_file(&self, include: &str) -> bool {
Path::new(include)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| self.by_file_name.contains_key(name))
}
fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
let mut matched = HashSet::default();
let mut resolved = Vec::new();
for rel_path in direct_include_candidate_paths(source_file.root(), source_file, include) {
self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
}
resolved
}
fn extend_rel_path(
&self,
rel_path: &Path,
matched: &mut HashSet<ProjectFile>,
out: &mut Vec<ProjectFile>,
) {
if let Some(targets) = self.by_rel_path.get(rel_path) {
for target in targets {
if matched.insert(target.clone()) {
out.push(target.clone());
}
}
}
}
fn resolve_unique_fallback(
&self,
source_file: &ProjectFile,
include: &str,
) -> Vec<ProjectFile> {
let include_path = Path::new(include);
let indexed = self.resolve_indexed(include);
let matches: Vec<_> = indexed
.into_iter()
.filter(|file| {
if include_path.components().count() > 1 {
file.rel_path().ends_with(include_path)
} else {
file.rel_path()
.file_name()
.is_some_and(|name| name == include_path)
}
})
.collect();
if matches.len() == 1 {
return matches;
}
let source_reachable = matches
.into_iter()
.filter(|file| {
(0..include_path.components().count())
.try_fold(file.rel_path(), |path, _| path.parent())
.is_some_and(|root| source_file.rel_path().starts_with(root))
})
.collect::<Vec<_>>();
if source_reachable.len() == 1 {
return source_reachable;
}
Vec::new()
}
fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
let include_path = Path::new(include);
if include_path.components().count() <= 1 {
return Vec::new();
}
let Some(file_name) = include_path.file_name() else {
return Vec::new();
};
let matches = self
.resolve_indexed(include)
.into_iter()
.filter(|file| file.rel_path().file_name() == Some(file_name))
.collect::<Vec<_>>();
if matches.len() == 1 {
matches
} else {
Vec::new()
}
}
}
fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
value.char_indices().map(|(index, _)| &value[index..])
}
fn parse_quoted_include(statement: &str) -> Option<String> {
let tree = crate::diagnostics::parse_cpp_tree(statement);
let mut cursor = tree.root_node().walk();
tree.root_node()
.named_children(&mut cursor)
.find_map(|node| {
let (path, quoted) = include_path_from_node(node, statement)?;
quoted.then(|| path.to_owned())
})
}
pub fn resolve_include_targets(
project: &dyn Project,
source_file: &ProjectFile,
include: &str,
) -> Vec<ProjectFile> {
let mut candidates = Vec::new();
let source_root = project.root().to_path_buf();
for rel_path in direct_include_candidate_paths(project.root(), source_file, include) {
let file = ProjectFile::new(source_root.clone(), rel_path);
if file.exists() {
candidates.push(file);
}
}
candidates.sort();
candidates.dedup();
candidates
}
fn direct_include_candidate_paths(
root: &Path,
source_file: &ProjectFile,
include: &str,
) -> Vec<PathBuf> {
let include_path = Path::new(include);
if include_path.is_absolute() {
return project_relative_include_path(root, include_path)
.into_iter()
.collect();
}
vec![
source_file.parent().join(include_path),
include_path.to_path_buf(),
]
}
pub fn resolve_include_targets_with_index(
source_file: &ProjectFile,
include: &str,
include_targets: &IncludeTargetIndex,
) -> Vec<ProjectFile> {
let mut candidates = include_targets.resolve_direct(source_file, include);
if !candidates.is_empty() {
return candidates;
}
if Path::new(include).is_absolute() {
return candidates;
}
candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
if candidates.is_empty()
&& let Some(template) = header_template_include_spelling(include)
{
candidates = include_targets.resolve_direct(source_file, &template);
if candidates.is_empty() {
candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
}
}
if candidates.is_empty() {
candidates = include_targets.resolve_unique_basename_alias(include);
}
candidates
}
fn header_template_include_spelling(include: &str) -> Option<String> {
let path = Path::new(include);
(path.extension() == Some(std::ffi::OsStr::new("h")))
.then(|| path.with_extension("hin").to_string_lossy().into_owned())
}
fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
let canonical_root = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf())
.normalize();
let canonical_include = include_path
.canonicalize()
.unwrap_or_else(|_| include_path.to_path_buf())
.normalize();
strip_path_prefix(&canonical_include, &canonical_root).or_else(|| {
strip_path_prefix(
&include_path.to_path_buf().normalize(),
&project_root.to_path_buf().normalize(),
)
})
}
pub fn included_claimable_files(
sources: &[(ProjectFile, Vec<ImportInfo>)],
claimable: &BTreeSet<ProjectFile>,
) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
if claimable.is_empty() || sources.is_empty() {
return edges;
}
let index = IncludeTargetIndex::build(claimable.iter());
for (source_file, imports) in sources {
let mut targets = BTreeSet::new();
for include in imports
.iter()
.filter_map(|import| parse_quoted_include(&import.raw_snippet))
{
targets.extend(resolve_include_targets_with_index(
source_file,
&include,
&index,
));
}
if !targets.is_empty() {
edges.insert(source_file.clone(), targets);
}
}
edges
}
pub fn claimable_include_demand(
sources: &[(ProjectFile, Vec<ImportInfo>)],
) -> HashMap<ProjectFile, BTreeSet<String>> {
let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
for (source_file, imports) in sources {
let mut keys = BTreeSet::new();
for include in imports
.iter()
.filter_map(|import| parse_quoted_include(&import.raw_snippet))
{
let template = header_template_include_spelling(&include);
for spelling in std::iter::once(include).chain(template) {
collect_include_demand_keys(source_file, &spelling, &mut keys);
}
}
if !keys.is_empty() {
demand.insert(source_file.clone(), keys);
}
}
demand
}
fn collect_include_demand_keys(
source_file: &ProjectFile,
include: &str,
keys: &mut BTreeSet<String>,
) {
let include_path = Path::new(include);
let claimable_spelling = include_path
.extension()
.and_then(|extension| extension.to_str())
.is_none_or(|extension| !Language::is_source_extension(extension));
if !claimable_spelling {
return;
}
if include_path.is_absolute() {
if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
&& let Some(key) = path_suffix_key(&rel_path)
{
keys.insert(key);
}
return;
}
if let Some(key) = path_suffix_key(include_path) {
keys.insert(key);
}
}
pub(crate) fn include_path_literal_value<'a>(path: Node<'_>, source: &'a str) -> Option<&'a str> {
let text = source.get(path.byte_range())?;
match path.kind() {
"string_literal" => text.strip_prefix('"')?.strip_suffix('"'),
"system_lib_string" => text.strip_prefix('<')?.strip_suffix('>'),
_ => None,
}
}
pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
if node.kind() != "preproc_include" {
return None;
}
let path = node.child_by_field_name("path")?;
let quoted = path.kind() == "string_literal";
let value = include_path_literal_value(path, source).filter(|path| !path.is_empty())?;
Some((value, quoted))
}
pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
let mut identifiers = BTreeSet::new();
let mut stack = vec![node];
while let Some(current) = stack.pop() {
if matches!(
current.kind(),
"identifier" | "type_identifier" | "namespace_identifier"
) && let Some(text) = source.get(current.byte_range())
&& text
.chars()
.next()
.is_some_and(|character| character.is_ascii_uppercase())
{
identifiers.insert(text.to_owned());
}
let mut cursor = current.walk();
let children = current.named_children(&mut cursor).collect::<Vec<_>>();
stack.extend(children.into_iter().rev());
}
identifiers
}
pub fn receiver_query_supported(file: &ProjectFile) -> bool {
file.rel_path()
.extension()
.and_then(|extension| extension.to_str())
!= Some("c")
}
#[cfg(windows)]
fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
let mut path_components = path.components();
for root_component in root.components() {
let path_component = path_components.next()?;
if !path_component
.as_os_str()
.eq_ignore_ascii_case(root_component.as_os_str())
{
return None;
}
}
Some(path_components.as_path().to_path_buf())
}
#[cfg(not(windows))]
fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
path.strip_prefix(root).ok().map(Path::to_path_buf)
}
#[cfg(test)]
mod tests {
use super::*;
use brokk_bifrost_core::test_support::write_file;
use tempfile::TempDir;
#[test]
#[brokk_bifrost_test_macros::hardwon(
"e3c20990a105: quicksilver round 4 on brokk-bifrost-cpp: angle include comments fabricated quoted claim paths"
)]
fn quoted_include_uses_the_directive_path_node() {
assert_eq!(
parse_quoted_include("#include <fields.inc> // \"bogus.inc\""),
None
);
assert_eq!(
parse_quoted_include("#include /* \"bogus.inc\" */ \"real.inc\""),
Some("real.inc".to_string())
);
assert_eq!(parse_quoted_include("// #include \"bogus.inc\""), None);
}
#[test]
#[brokk_bifrost_test_macros::hardwon(
"4b224ec22cdb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
)]
fn indexed_include_resolution_uses_unique_suffix_fallback() {
let temp = TempDir::new().unwrap();
let root = temp.path().canonicalize().unwrap();
let source = write_file(&root, "src/lib.c", "");
let target = write_file(&root, "include/git2/sys/credential.h", "");
let duplicate = write_file(&root, "vendor/credential.h", "");
let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
assert_eq!(resolved, vec![target]);
let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
assert!(ambiguous.is_empty());
}
#[test]
#[brokk_bifrost_test_macros::hardwon(
"cf45b06757c2: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
)]
fn indexed_include_resolution_prefers_unique_source_reachable_root() {
let temp = TempDir::new().unwrap();
let root = temp.path().canonicalize().unwrap();
let source = write_file(&root, "src/config/parse.c", "");
let target = write_file(&root, "src/config/parse.h", "");
let nested_decoy = write_file(&root, "src/build/config/parse.h", "");
let unrelated_source = write_file(&root, "app/main.c", "");
let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
assert_eq!(resolved, vec![target.clone()]);
let unrelated =
resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
assert!(unrelated.is_empty());
let ambiguous_source = write_file(&root, "src/config/deeper/main.c", "");
let second_reachable = write_file(&root, "src/config/config/parse.h", "");
let ambiguous_index = IncludeTargetIndex::build([
&ambiguous_source,
&target,
&nested_decoy,
&second_reachable,
]);
let ambiguous = resolve_include_targets_with_index(
&ambiguous_source,
"config/parse.h",
&ambiguous_index,
);
assert!(ambiguous.is_empty());
}
#[test]
#[brokk_bifrost_test_macros::hardwon(
"d8cf28cfbabb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
)]
fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
let temp = TempDir::new().unwrap();
let root = temp.path().canonicalize().unwrap();
let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp", "");
let target = write_file(&root, "src/lib/asn1/Widget.h", "");
let index = IncludeTargetIndex::build([&source, &target]);
assert_eq!(
resolve_include_targets_with_index(&source, "botan/Widget.h", &index),
vec![target.clone()]
);
let duplicate = write_file(&root, "vendor/Widget.h", "");
let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
assert!(
resolve_include_targets_with_index(&source, "botan/Widget.h", &ambiguous).is_empty()
);
}
#[test]
#[brokk_bifrost_test_macros::hardwon(
"53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
)]
fn unresolved_h_include_falls_back_to_hin_template() {
let temp = TempDir::new().unwrap();
let root = temp.path().canonicalize().unwrap();
let stub = write_file(&root, "src/include/krb5.h", "");
let template = write_file(&root, "src/include/krb5/krb5.hin", "");
let index = IncludeTargetIndex::build([&stub, &template]);
let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
assert_eq!(resolved, vec![template]);
}
#[test]
#[brokk_bifrost_test_macros::hardwon(
"53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
)]
fn real_header_wins_over_hin_template() {
let temp = TempDir::new().unwrap();
let root = temp.path().canonicalize().unwrap();
let stub = write_file(&root, "src/include/krb5.h", "");
let generated = write_file(&root, "src/include/krb5/krb5.h", "");
let template = write_file(&root, "src/include/krb5/krb5.hin", "");
let index = IncludeTargetIndex::build([&stub, &generated, &template]);
let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
assert_eq!(resolved, vec![generated]);
}
#[test]
fn include_literal_grammar_boundary() {
for (source, kind, quoted) in [
("#include \"fields.inc\"\n", "string_literal", true),
("#include <fields.inc>\n", "system_lib_string", false),
] {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_cpp::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
assert!(!tree.root_node().has_error());
let directive = tree.root_node().named_child(0).unwrap();
assert_eq!(directive.kind(), "preproc_include");
let path = directive.child_by_field_name("path").unwrap();
assert_eq!((path.kind(), path.byte_range()), (kind, 9..21));
assert_eq!(
include_path_from_node(directive, source),
Some(("fields.inc", quoted))
);
}
}
}