Skip to main content

_diffctx/edges/semantic/
c_family.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6use rustc_hash::{FxHashMap, FxHashSet};
7
8use crate::config::edge_weights::{C_FAMILY_SEMANTIC, SEMANTIC_DISCOVERY};
9use crate::config::extensions::C_FAMILY_EXTENSIONS;
10use crate::config::weights::EDGE_WEIGHTS;
11use crate::types::{Fragment, FragmentId};
12
13use super::super::EdgeDict;
14use super::super::base::{self, EdgeBuilder, add_edge};
15
16fn is_c_family(path: &Path) -> bool {
17    let ext = base::file_ext(path);
18    C_FAMILY_EXTENSIONS.contains(ext.as_str())
19}
20
21static HEADER_EXTENSIONS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
22    [".h", ".hpp", ".hh", ".hxx", ".h++"]
23        .iter()
24        .copied()
25        .collect()
26});
27
28static IMPL_EXTENSIONS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
29    [".c", ".cpp", ".cc", ".cxx", ".c++", ".m", ".mm"]
30        .iter()
31        .copied()
32        .collect()
33});
34
35static INCLUDE_RE: Lazy<Regex> =
36    Lazy::new(|| Regex::new(r#"(?m)^\s*#\s*(?:include|import)\s*[<"]([^>"]+)[>"]"#).unwrap());
37static FUNC_CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b(\w+)\s*\(").unwrap());
38static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w*)\b").unwrap());
39static FUNC_DEF_RE: Lazy<Regex> =
40    Lazy::new(|| Regex::new(r"(?m)^\s*(?:[\w*&]+\s+)+(\w+)\s*\(").unwrap());
41static TYPE_DEF_RE: Lazy<Regex> =
42    Lazy::new(|| Regex::new(r"(?m)^\s*(?:class|struct|enum|union|typedef)\s+([A-Z]\w*)").unwrap());
43static INHERITANCE_RE: Lazy<Regex> = Lazy::new(|| {
44    Regex::new(r"(?:class|struct)\s+(\w+)\s*:\s*(?:public|protected|private)?\s*(\w+)").unwrap()
45});
46
47static C_KEYWORDS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
48    [
49        "if",
50        "for",
51        "while",
52        "switch",
53        "case",
54        "return",
55        "sizeof",
56        "typeof",
57        "alignof",
58        "static_assert",
59        "do",
60        "else",
61        "goto",
62        "break",
63        "continue",
64        "default",
65        "register",
66        "volatile",
67        "extern",
68        "typedef",
69        "auto",
70        "inline",
71        "restrict",
72        "noexcept",
73        "decltype",
74        "nullptr",
75        "throw",
76        "try",
77        "catch",
78        "delete",
79        "new",
80        "template",
81        "namespace",
82        "using",
83        "operator",
84    ]
85    .iter()
86    .copied()
87    .collect()
88});
89
90static C_COMMON_MACROS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
91    [
92        "NULL", "TRUE", "FALSE", "BOOL", "DWORD", "HANDLE", "VOID", "HRESULT", "LPCTSTR", "LPCSTR",
93        "LPWSTR", "INT", "UINT", "LONG", "ULONG", "WORD", "BYTE", "CHAR", "SHORT", "EOF",
94        "SIZE_MAX", "INT_MAX", "INT_MIN",
95    ]
96    .iter()
97    .copied()
98    .collect()
99});
100
101fn extract_includes(content: &str) -> FxHashSet<String> {
102    let mut includes = FxHashSet::default();
103    for cap in INCLUDE_RE.captures_iter(content) {
104        let header = cap[1].to_string();
105        if header.contains('/') {
106            includes.insert(header.split('/').next_back().unwrap().to_string());
107        }
108        includes.insert(header);
109    }
110    includes
111}
112
113fn extract_definitions(content: &str) -> (FxHashSet<String>, FxHashSet<String>) {
114    let functions: FxHashSet<String> = FUNC_DEF_RE
115        .captures_iter(content)
116        .map(|c| c[1].to_string())
117        .filter(|n| {
118            !C_KEYWORDS.contains(n.as_str()) && n.len() > SEMANTIC_DISCOVERY.min_identifier_length
119        })
120        .collect();
121    let types: FxHashSet<String> = TYPE_DEF_RE
122        .captures_iter(content)
123        .map(|c| c[1].to_string())
124        .collect();
125    (functions, types)
126}
127
128fn extract_references(
129    content: &str,
130    own_defs: &FxHashSet<String>,
131) -> (FxHashSet<String>, FxHashSet<String>) {
132    let calls: FxHashSet<String> = FUNC_CALL_RE
133        .captures_iter(content)
134        .map(|c| c[1].to_string())
135        .filter(|n| {
136            !C_KEYWORDS.contains(n.as_str())
137                && !own_defs.contains(n)
138                && !n.starts_with('_')
139                && n.len() > SEMANTIC_DISCOVERY.min_identifier_length
140        })
141        .collect();
142    let type_refs: FxHashSet<String> = TYPE_REF_RE
143        .captures_iter(content)
144        .map(|c| c[1].to_string())
145        .filter(|n| {
146            !C_COMMON_MACROS.contains(n.as_str())
147                && !own_defs.contains(n)
148                && n.len() > SEMANTIC_DISCOVERY.min_identifier_length
149        })
150        .collect();
151    (calls, type_refs)
152}
153
154pub struct CFamilyEdgeBuilder;
155
156impl EdgeBuilder for CFamilyEdgeBuilder {
157    fn build(&self, fragments: &[Fragment], _repo_root: Option<&Path>) -> EdgeDict {
158        let c_frags: Vec<&Fragment> = fragments
159            .iter()
160            .filter(|f| is_c_family(Path::new(f.path())))
161            .collect();
162        if c_frags.is_empty() {
163            return FxHashMap::default();
164        }
165
166        let include_weight = EDGE_WEIGHTS["c_include"].forward;
167        let call_weight = EDGE_WEIGHTS["c_call"].forward;
168        let type_weight = EDGE_WEIGHTS["c_type"].forward;
169        let inheritance_weight = EDGE_WEIGHTS["c_inheritance"].forward;
170        let reverse_factor = EDGE_WEIGHTS["c_include"].reverse_factor;
171        let base_weight = C_FAMILY_SEMANTIC.base_weight;
172
173        // An include names a file, so its edge lands on the file's
174        // representative fragment (base::file_representatives — the sibling
175        // builder's long-standing semantics); the containment star carries the
176        // mass to the rest of the file. Buckets therefore hold file paths, and
177        // ambiguity is measured in files: a basename repeated across the tree
178        // is what makes an include unresolvable.
179        let owned: Vec<Fragment> = c_frags.iter().map(|f| (*f).clone()).collect();
180        let reps = super::super::base::file_representatives(&owned);
181        drop(owned);
182        let mut header_to_files: FxHashMap<String, Vec<Arc<str>>> = FxHashMap::default();
183        let mut func_defs_map: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
184        let mut func_def_files: FxHashMap<String, FxHashSet<Arc<str>>> = FxHashMap::default();
185        let mut type_defs_map: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
186        let mut type_def_files: FxHashMap<String, FxHashSet<Arc<str>>> = FxHashMap::default();
187        let mut frag_own_defs: FxHashMap<FragmentId, FxHashSet<String>> = FxHashMap::default();
188
189        let push_file_key =
190            |map: &mut FxHashMap<String, Vec<Arc<str>>>, key: String, f: &Fragment| {
191                let bucket = map.entry(key).or_default();
192                if !bucket.contains(&f.id.path) {
193                    bucket.push(f.id.path.clone());
194                }
195            };
196
197        for f in &c_frags {
198            let path = Path::new(f.path());
199            let name = path
200                .file_name()
201                .map(|n| n.to_string_lossy().to_string())
202                .unwrap_or_default();
203            let stem = path
204                .file_stem()
205                .map(|s| s.to_string_lossy().to_string())
206                .unwrap_or_default();
207
208            push_file_key(&mut header_to_files, name, f);
209            if !stem.is_empty() {
210                push_file_key(&mut header_to_files, format!("{stem}.h"), f);
211                push_file_key(&mut header_to_files, format!("{stem}.hpp"), f);
212            }
213
214            let (functions, types) = extract_definitions(&f.content);
215            let mut own_defs = FxHashSet::default();
216            for func in &functions {
217                func_defs_map
218                    .entry(func.clone())
219                    .or_default()
220                    .push(f.id.clone());
221                func_def_files
222                    .entry(func.clone())
223                    .or_default()
224                    .insert(f.id.path.clone());
225                own_defs.insert(func.clone());
226            }
227            for t in &types {
228                type_defs_map
229                    .entry(t.clone())
230                    .or_default()
231                    .push(f.id.clone());
232                type_def_files
233                    .entry(t.clone())
234                    .or_default()
235                    .insert(f.id.path.clone());
236                own_defs.insert(t.clone());
237            }
238            frag_own_defs.insert(f.id.clone(), own_defs);
239        }
240
241        let max_files = C_FAMILY_SEMANTIC.max_files_per_name;
242        let unambiguous = |files: Option<&FxHashSet<Arc<str>>>| {
243            files.map(|s| s.len() <= max_files).unwrap_or(true)
244        };
245
246        let mut edges: EdgeDict = FxHashMap::default();
247
248        for f in &c_frags {
249            for inc in extract_includes(&f.content) {
250                let inc_name = if inc.contains('/') {
251                    inc.split('/').next_back().unwrap().to_string()
252                } else {
253                    inc.clone()
254                };
255                let Some(candidates) = header_to_files.get(&inc_name) else {
256                    continue;
257                };
258                // `#include "common/buffer/buffer_impl.h"` names one file; a
259                // candidate qualifies only if its path actually ends with the
260                // include's components. A bare `#include "config.h"` cannot be
261                // disambiguated that way, so it falls back to the basename
262                // bucket — bounded below, since envoy carries 256 `config.h`
263                // and linking to all of them is noise at quadratic cost.
264                let suffix = format!("/{inc}");
265                let matched: Vec<&Arc<str>> = if inc.contains('/') {
266                    candidates
267                        .iter()
268                        .filter(|p| p.as_ref() == inc || p.ends_with(&suffix))
269                        .collect()
270                } else {
271                    candidates.iter().collect()
272                };
273                if matched.is_empty() || matched.len() > max_files {
274                    continue;
275                }
276                for path in matched {
277                    let Some(rep) = reps.get(path.as_ref()) else {
278                        continue;
279                    };
280                    if rep != &f.id {
281                        add_edge(&mut edges, &f.id, rep, include_weight, reverse_factor);
282                    }
283                }
284            }
285
286            let own_defs = frag_own_defs.get(&f.id).cloned().unwrap_or_default();
287            let (calls, type_refs) = extract_references(&f.content, &own_defs);
288
289            for call in &calls {
290                if !unambiguous(func_def_files.get(call)) {
291                    continue;
292                }
293                for def_id in func_defs_map.get(call).unwrap_or(&vec![]) {
294                    if def_id != &f.id {
295                        add_edge(&mut edges, &f.id, def_id, call_weight, reverse_factor);
296                    }
297                }
298            }
299
300            for t in &type_refs {
301                if !unambiguous(type_def_files.get(t)) {
302                    continue;
303                }
304                for def_id in type_defs_map.get(t).unwrap_or(&vec![]) {
305                    if def_id != &f.id {
306                        add_edge(&mut edges, &f.id, def_id, type_weight, reverse_factor);
307                    }
308                }
309            }
310
311            for cap in INHERITANCE_RE.captures_iter(&f.content) {
312                let base = cap[2].to_string();
313                if !unambiguous(type_def_files.get(&base)) {
314                    continue;
315                }
316                for def_id in type_defs_map.get(&base).unwrap_or(&vec![]) {
317                    if def_id != &f.id {
318                        add_edge(
319                            &mut edges,
320                            &f.id,
321                            def_id,
322                            inheritance_weight,
323                            reverse_factor,
324                        );
325                    }
326                }
327            }
328        }
329
330        // Header/impl pairing is scoped to the directory, the same call made
331        // for bare-stem discovery in c6694261: co-location is the rule's
332        // justification, and applied tree-wide it degenerates on exactly the
333        // stems real projects repeat most (envoy: 520 files with stem
334        // `config`, whose fragment-level cross product alone was tens of
335        // millions of edges).
336        let mut by_stem: FxHashMap<(String, String), Vec<&str>> = FxHashMap::default();
337        for f in &c_frags {
338            let path = Path::new(f.path());
339            let stem = path
340                .file_stem()
341                .map(|s| s.to_string_lossy().to_lowercase())
342                .unwrap_or_default();
343            let dir = path
344                .parent()
345                .map(|d| d.to_string_lossy().to_string())
346                .unwrap_or_default();
347            let bucket = by_stem.entry((dir, stem)).or_default();
348            if !bucket.contains(&f.path()) {
349                bucket.push(f.path());
350            }
351        }
352
353        // A header/impl pair is a relation between two files; representatives
354        // carry it, the containment star spreads it. Pairing every fragment
355        // with every fragment restated the same fact quadratically.
356        for (_key, files) in &by_stem {
357            if files.len() < 2 {
358                continue;
359            }
360            let headers: Vec<&&str> = files
361                .iter()
362                .filter(|p| HEADER_EXTENSIONS.contains(base::file_ext(Path::new(**p)).as_str()))
363                .collect();
364            let impls: Vec<&&str> = files
365                .iter()
366                .filter(|p| IMPL_EXTENSIONS.contains(base::file_ext(Path::new(**p)).as_str()))
367                .collect();
368            for h in &headers {
369                for imp in &impls {
370                    if let (Some(hr), Some(ir)) = (reps.get(**h), reps.get(**imp)) {
371                        add_edge(&mut edges, hr, ir, base_weight, reverse_factor);
372                    }
373                }
374            }
375        }
376
377        edges
378    }
379
380    fn discover_related_files(
381        &self,
382        changed: &[PathBuf],
383        candidates: &[PathBuf],
384        _repo_root: Option<&Path>,
385        _file_cache: Option<&FxHashMap<PathBuf, String>>,
386    ) -> Vec<PathBuf> {
387        let c_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_c_family(f)).collect();
388        if c_changed.is_empty() {
389            return vec![];
390        }
391
392        let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
393        let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
394        let mut frontier: Vec<PathBuf> = c_changed.iter().map(|f| (*f).clone()).collect();
395
396        for _ in 0..SEMANTIC_DISCOVERY.max_depth {
397            let mut hop_found: Vec<PathBuf> = Vec::new();
398
399            let mut included_headers: FxHashSet<String> = FxHashSet::default();
400            for f in &frontier {
401                if let Ok(content) = std::fs::read_to_string(f) {
402                    included_headers.extend(extract_includes(&content));
403                }
404            }
405
406            let mut changed_names: FxHashSet<String> = FxHashSet::default();
407            for f in &frontier {
408                if let Some(name) = f.file_name() {
409                    changed_names.insert(name.to_string_lossy().to_string());
410                }
411                if let Some(stem) = f.file_stem() {
412                    let s = stem.to_string_lossy().to_string();
413                    changed_names.insert(format!("{}.h", s));
414                    changed_names.insert(format!("{}.hpp", s));
415                }
416            }
417
418            for candidate in candidates {
419                if changed_set.contains(candidate)
420                    || discovered.contains(candidate)
421                    || !is_c_family(candidate)
422                {
423                    continue;
424                }
425                let cand_name = candidate
426                    .file_name()
427                    .map(|n| n.to_string_lossy().to_string())
428                    .unwrap_or_default();
429                if included_headers.contains(&cand_name) {
430                    hop_found.push(candidate.clone());
431                    continue;
432                }
433                if let Ok(content) = std::fs::read_to_string(candidate) {
434                    let cand_includes = extract_includes(&content);
435                    for inc in &cand_includes {
436                        let inc_name = if inc.contains('/') {
437                            inc.split('/').next_back().unwrap().to_string()
438                        } else {
439                            inc.clone()
440                        };
441                        if changed_names.contains(&inc_name) {
442                            hop_found.push(candidate.clone());
443                            break;
444                        }
445                    }
446                }
447            }
448
449            let new_files: Vec<PathBuf> = hop_found
450                .into_iter()
451                .filter(|f| !discovered.contains(f))
452                .collect();
453            if new_files.is_empty() {
454                break;
455            }
456            discovered.extend(new_files.iter().cloned());
457            frontier = new_files;
458        }
459
460        let mut result: Vec<PathBuf> = discovered.into_iter().collect();
461        result.sort();
462        result
463    }
464}