Skip to content

Commit 2b87944

Browse files
committed
fix(extract): resolve .h headers as C++ in C++ projects
- Auto-detect C vs C++ from project layout and header content - Add .codegraph/config.toml with headers = auto|c|cpp override - Document behavior in README; fixes #7
1 parent 3a42e5b commit 2b87944

11 files changed

Lines changed: 398 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,31 @@ A `.codegraph/` directory is created next to your project:
200200
```
201201
.codegraph/
202202
db.sqlite SQLite v1 (WAL mode, FTS5)
203+
config.toml Language overrides (see below)
203204
.gitignore Pre-filled so the index is never committed
204205
version Codegraph version that created the directory
205206
```
206207

207208
Add a `.codegraphignore` file at the workspace root to exclude additional
208209
paths beyond your `.gitignore`. Same syntax.
209210

211+
### C vs C++ headers (`.h`)
212+
213+
By default, `.h` files are resolved automatically:
214+
215+
- **C++ project** (`.cpp`/`.hpp` present, no `.c`) → parsed as C++
216+
- **C project** (`.c` present, no C++ sources) → parsed as C
217+
- **Mixed C/C++** → each `.h` is inspected for C++ syntax (`namespace`, `class`, `template`, …)
218+
219+
Override in `.codegraph/config.toml`:
220+
221+
```toml
222+
[languages]
223+
headers = "auto" # "auto" (default), "c", or "cpp"
224+
```
225+
226+
After changing this setting, run `codegraph index` to re-index headers.
227+
210228
## Why Rust?
211229

212230
This project is a from-scratch Rust rewrite of the previous TypeScript

crates/codegraph-extract/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ ignore = { workspace = true }
3333
rayon = { workspace = true }
3434
camino = { workspace = true }
3535
tracing = { workspace = true }
36+
serde = { workspace = true }
37+
toml = "0.8"
3638

3739
[dev-dependencies]
3840
tempfile = "3"
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use camino::Utf8Path;
2+
use serde::Deserialize;
3+
use std::fs;
4+
5+
/// How `.h` header files should be parsed when both C and C++ extractors are available.
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7+
pub enum HeaderLanguage {
8+
/// Detect from project layout and file content.
9+
#[default]
10+
Auto,
11+
C,
12+
Cpp,
13+
}
14+
15+
#[derive(Debug, Default, Deserialize)]
16+
struct ConfigFile {
17+
#[serde(default)]
18+
languages: LanguagesSection,
19+
}
20+
21+
#[derive(Debug, Default, Deserialize)]
22+
struct LanguagesSection {
23+
/// `"auto"`, `"c"`, or `"cpp"`.
24+
#[serde(default)]
25+
headers: Option<String>,
26+
}
27+
28+
/// Project-level extraction settings (`.codegraph/config.toml`).
29+
#[derive(Debug, Clone, Default)]
30+
pub struct ExtractConfig {
31+
pub header_language: HeaderLanguage,
32+
}
33+
34+
impl ExtractConfig {
35+
pub fn load(root: &Utf8Path) -> Self {
36+
let path = root.join(".codegraph").join("config.toml");
37+
Self::load_from(&path)
38+
}
39+
40+
pub fn load_from(path: &Utf8Path) -> Self {
41+
let Ok(text) = fs::read_to_string(path.as_std_path()) else {
42+
return Self::default();
43+
};
44+
let Ok(file) = toml::from_str::<ConfigFile>(&text) else {
45+
return Self::default();
46+
};
47+
Self {
48+
header_language: parse_header_language(file.languages.headers.as_deref()),
49+
}
50+
}
51+
}
52+
53+
fn parse_header_language(raw: Option<&str>) -> HeaderLanguage {
54+
match raw.unwrap_or("auto").trim().to_ascii_lowercase().as_str() {
55+
"c" => HeaderLanguage::C,
56+
"cpp" | "c++" | "cxx" => HeaderLanguage::Cpp,
57+
_ => HeaderLanguage::Auto,
58+
}
59+
}
60+
61+
/// Default `config.toml` written on `codegraph init`.
62+
pub const DEFAULT_CONFIG_TOML: &str = r#"# CodeGraph project configuration
63+
# See https://github.com/Cleboost/codegraph-rs
64+
65+
[languages]
66+
# How to parse .h header files: "auto", "c", or "cpp".
67+
# "auto" detects C++ projects from .cpp/.hpp files and C++ syntax in headers.
68+
headers = "auto"
69+
"#;
70+
71+
/// Quick project scan: returns a hint when the tree is clearly C-only or C++-only.
72+
pub fn detect_project_header_hint(root: &Utf8Path) -> Option<HeaderLanguage> {
73+
let mut c_files = 0u32;
74+
let mut cpp_files = 0u32;
75+
76+
let walker = ignore::WalkBuilder::new(root)
77+
.hidden(true)
78+
.git_ignore(true)
79+
.git_exclude(true)
80+
.parents(true)
81+
.add_custom_ignore_filename(".codegraphignore")
82+
.build();
83+
84+
for entry in walker.flatten() {
85+
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
86+
continue;
87+
}
88+
let Some(ext) = entry.path().extension().and_then(|s| s.to_str()) else {
89+
continue;
90+
};
91+
match ext {
92+
"c" => c_files += 1,
93+
"cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => cpp_files += 1,
94+
_ => {}
95+
}
96+
}
97+
98+
if cpp_files > 0 && c_files == 0 {
99+
Some(HeaderLanguage::Cpp)
100+
} else if c_files > 0 && cpp_files == 0 {
101+
Some(HeaderLanguage::C)
102+
} else {
103+
None
104+
}
105+
}
106+
107+
/// Heuristic: does this header look like C++ from its source text?
108+
pub fn is_cpp_header(source: &str) -> bool {
109+
let sample = &source[..source.len().min(8192)];
110+
const MARKERS: &[&str] = &[
111+
"namespace ",
112+
"class ",
113+
"template ",
114+
"typename ",
115+
"constexpr ",
116+
"noexcept",
117+
"public:",
118+
"private:",
119+
"protected:",
120+
"operator ",
121+
"std::",
122+
"extern \"C\"",
123+
"using ",
124+
"::",
125+
];
126+
MARKERS.iter().any(|m| sample.contains(m))
127+
}
128+
129+
#[cfg(test)]
130+
mod tests {
131+
use super::*;
132+
133+
#[test]
134+
fn parse_config_headers() {
135+
let cfg = toml::from_str::<ConfigFile>(
136+
r#"
137+
[languages]
138+
headers = "cpp"
139+
"#,
140+
)
141+
.unwrap();
142+
assert_eq!(
143+
parse_header_language(cfg.languages.headers.as_deref()),
144+
HeaderLanguage::Cpp
145+
);
146+
}
147+
148+
#[test]
149+
fn sniff_cpp_header() {
150+
assert!(is_cpp_header(
151+
"#pragma once\nnamespace tnl { class String {}; }\n"
152+
));
153+
assert!(!is_cpp_header(
154+
"#ifndef FOO_H\n#define FOO_H\nstruct foo { int x; };\n#endif\n"
155+
));
156+
}
157+
}

crates/codegraph-extract/src/languages/c.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ fn import_path(n: &Node, src: &[u8]) -> Option<String> {
2222

2323
pub static SPEC: LangSpec = LangSpec {
2424
language_name: "c",
25-
extensions: &["c", "h"],
25+
extensions: &["c"],
2626
ts_language,
2727
decls: &[
2828
("function_definition", NodeKind::Function),

crates/codegraph-extract/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
//! Tree-sitter extraction orchestrator + per-language extractors.
22
3+
pub mod config;
34
pub mod languages;
45
mod orchestrator;
56
mod walker;
67

8+
pub use config::{ExtractConfig, HeaderLanguage, DEFAULT_CONFIG_TOML};
79
pub use orchestrator::{ExtractStats, Orchestrator};
810

911
use codegraph_core::{Error, NodeKind, Result};

crates/codegraph-extract/src/orchestrator.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::config::ExtractConfig;
12
use crate::{walker, ExtractResult, Extractor};
23
use camino::{Utf8Path, Utf8PathBuf};
34
use codegraph_core::Result;
@@ -36,7 +37,8 @@ impl Orchestrator {
3637
}
3738

3839
pub fn sync(&self, root: &Utf8Path, db: &Db) -> Result<ExtractStats> {
39-
let files = walker::walk(root, &self.extractors);
40+
let config = ExtractConfig::load(root);
41+
let files = walker::walk(root, &self.extractors, &config);
4042
let results: Vec<_> = files.par_iter().map(|fm| parse_one(fm, db)).collect();
4143
let mut parsed = Vec::with_capacity(results.len());
4244
let mut skipped = 0u64;
@@ -55,8 +57,10 @@ impl Orchestrator {
5557
/// Sync only the given paths instead of walking the whole tree. Used by the
5658
/// watcher so that a burst of filesystem events costs O(changed files),
5759
/// not O(repo size).
58-
pub fn sync_paths(&self, db: &Db, paths: &[Utf8PathBuf]) -> Result<ExtractStats> {
60+
pub fn sync_paths(&self, root: &Utf8Path, db: &Db, paths: &[Utf8PathBuf]) -> Result<ExtractStats> {
61+
let config = ExtractConfig::load(root);
5962
let ext_map = walker::build_ext_map(&self.extractors);
63+
let opts = walker::walk_options(&self.extractors, &config, root);
6064
let mut matches = Vec::new();
6165
for p in paths {
6266
if !p.as_std_path().is_file() {
@@ -68,7 +72,7 @@ impl Orchestrator {
6872
}
6973
continue;
7074
}
71-
if let Some(extractor) = walker::match_extractor(p, &ext_map) {
75+
if let Some(extractor) = walker::match_extractor(p, &ext_map, &opts) {
7276
matches.push(walker::FileMatch {
7377
path: p.clone(),
7478
extractor,

0 commit comments

Comments
 (0)