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        // Representatives keyed off the full slice: is_c_family is per-path, so
180        // the map restricted to C-family paths is identical, and only C-family
181        // paths are ever looked up.
182        let reps = super::super::base::file_representatives(fragments);
183        let mut header_to_files: FxHashMap<String, (Vec<Arc<str>>, FxHashSet<Arc<str>>)> =
184            FxHashMap::default();
185        let mut func_defs_map: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
186        let mut func_def_files: FxHashMap<String, FxHashSet<Arc<str>>> = FxHashMap::default();
187        let mut type_defs_map: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
188        let mut type_def_files: FxHashMap<String, FxHashSet<Arc<str>>> = FxHashMap::default();
189        let mut frag_own_defs: FxHashMap<FragmentId, FxHashSet<String>> = FxHashMap::default();
190
191        // Order-preserving dedup: the Vec keeps insertion order (edge emission
192        // order depends on it), the set makes membership O(1) instead of a
193        // linear rescan per fragment of every file sharing the key.
194        let push_file_key = |map: &mut FxHashMap<String, (Vec<Arc<str>>, FxHashSet<Arc<str>>)>,
195                             key: String,
196                             f: &Fragment| {
197            let (order, seen) = map.entry(key).or_default();
198            if seen.insert(f.id.path.clone()) {
199                order.push(f.id.path.clone());
200            }
201        };
202
203        for f in &c_frags {
204            let path = Path::new(f.path());
205            let name = path
206                .file_name()
207                .map(|n| n.to_string_lossy().to_string())
208                .unwrap_or_default();
209            let stem = path
210                .file_stem()
211                .map(|s| s.to_string_lossy().to_string())
212                .unwrap_or_default();
213
214            push_file_key(&mut header_to_files, name, f);
215            if !stem.is_empty() {
216                push_file_key(&mut header_to_files, format!("{stem}.h"), f);
217                push_file_key(&mut header_to_files, format!("{stem}.hpp"), f);
218            }
219
220            let (functions, types) = extract_definitions(&f.content);
221            let mut own_defs = FxHashSet::default();
222            for func in &functions {
223                func_defs_map
224                    .entry(func.clone())
225                    .or_default()
226                    .push(f.id.clone());
227                func_def_files
228                    .entry(func.clone())
229                    .or_default()
230                    .insert(f.id.path.clone());
231                own_defs.insert(func.clone());
232            }
233            for t in &types {
234                type_defs_map
235                    .entry(t.clone())
236                    .or_default()
237                    .push(f.id.clone());
238                type_def_files
239                    .entry(t.clone())
240                    .or_default()
241                    .insert(f.id.path.clone());
242                own_defs.insert(t.clone());
243            }
244            frag_own_defs.insert(f.id.clone(), own_defs);
245        }
246
247        let max_files = C_FAMILY_SEMANTIC.max_files_per_name;
248        let unambiguous = |files: Option<&FxHashSet<Arc<str>>>| {
249            files.map(|s| s.len() <= max_files).unwrap_or(true)
250        };
251
252        let mut edges: EdgeDict = FxHashMap::default();
253
254        for (i, f) in c_frags.iter().enumerate() {
255            // The envoy shape (520 files sharing one stem) made a single
256            // c_family build outrun the whole timeout; the between-builders
257            // check cannot interrupt it, so poll inside the loop (#210).
258            crate::deadline::check_current_every(i, 256, "edge construction (c_family)");
259            for inc in extract_includes(&f.content) {
260                let inc_name = if inc.contains('/') {
261                    inc.split('/').next_back().unwrap().to_string()
262                } else {
263                    inc.clone()
264                };
265                let Some((candidates, _)) = header_to_files.get(&inc_name) else {
266                    continue;
267                };
268                // `#include "common/buffer/buffer_impl.h"` names one file; a
269                // candidate qualifies only if its path actually ends with the
270                // include's components. A bare `#include "config.h"` cannot be
271                // disambiguated that way, so it falls back to the basename
272                // bucket — bounded below, since envoy carries 256 `config.h`
273                // and linking to all of them is noise at quadratic cost.
274                let suffix = format!("/{inc}");
275                let matched: Vec<&Arc<str>> = if inc.contains('/') {
276                    candidates
277                        .iter()
278                        .filter(|p| p.as_ref() == inc || p.ends_with(&suffix))
279                        .collect()
280                } else {
281                    candidates.iter().collect()
282                };
283                if matched.is_empty() || matched.len() > max_files {
284                    continue;
285                }
286                for path in matched {
287                    let Some(rep) = reps.get(path.as_ref()) else {
288                        continue;
289                    };
290                    if rep != &f.id {
291                        add_edge(&mut edges, &f.id, rep, include_weight, reverse_factor);
292                    }
293                }
294            }
295
296            let own_defs = frag_own_defs.get(&f.id).cloned().unwrap_or_default();
297            let (calls, type_refs) = extract_references(&f.content, &own_defs);
298
299            for call in &calls {
300                if !unambiguous(func_def_files.get(call)) {
301                    continue;
302                }
303                for def_id in func_defs_map.get(call).unwrap_or(&vec![]) {
304                    if def_id != &f.id {
305                        add_edge(&mut edges, &f.id, def_id, call_weight, reverse_factor);
306                    }
307                }
308            }
309
310            for t in &type_refs {
311                if !unambiguous(type_def_files.get(t)) {
312                    continue;
313                }
314                for def_id in type_defs_map.get(t).unwrap_or(&vec![]) {
315                    if def_id != &f.id {
316                        add_edge(&mut edges, &f.id, def_id, type_weight, reverse_factor);
317                    }
318                }
319            }
320
321            for cap in INHERITANCE_RE.captures_iter(&f.content) {
322                let base = cap[2].to_string();
323                if !unambiguous(type_def_files.get(&base)) {
324                    continue;
325                }
326                for def_id in type_defs_map.get(&base).unwrap_or(&vec![]) {
327                    if def_id != &f.id {
328                        add_edge(
329                            &mut edges,
330                            &f.id,
331                            def_id,
332                            inheritance_weight,
333                            reverse_factor,
334                        );
335                    }
336                }
337            }
338        }
339
340        // Header/impl pairing is scoped to the directory, the same call made
341        // for bare-stem discovery in c6694261: co-location is the rule's
342        // justification, and applied tree-wide it degenerates on exactly the
343        // stems real projects repeat most (envoy: 520 files with stem
344        // `config`, whose fragment-level cross product alone was tens of
345        // millions of edges).
346        let mut by_stem: FxHashMap<(String, String), (Vec<&str>, FxHashSet<&str>)> =
347            FxHashMap::default();
348        for f in &c_frags {
349            let path = Path::new(f.path());
350            let stem = path
351                .file_stem()
352                .map(|s| s.to_string_lossy().to_lowercase())
353                .unwrap_or_default();
354            let dir = path
355                .parent()
356                .map(|d| d.to_string_lossy().to_string())
357                .unwrap_or_default();
358            let (order, seen) = by_stem.entry((dir, stem)).or_default();
359            if seen.insert(f.path()) {
360                order.push(f.path());
361            }
362        }
363
364        // A header/impl pair is a relation between two files; representatives
365        // carry it, the containment star spreads it. Pairing every fragment
366        // with every fragment restated the same fact quadratically.
367        for (i, (_key, (files, _))) in by_stem.iter().enumerate() {
368            crate::deadline::check_current_every(i, 256, "edge construction (c_family pairing)");
369            if files.len() < 2 {
370                continue;
371            }
372            let headers: Vec<&&str> = files
373                .iter()
374                .filter(|p| HEADER_EXTENSIONS.contains(base::file_ext(Path::new(**p)).as_str()))
375                .collect();
376            let impls: Vec<&&str> = files
377                .iter()
378                .filter(|p| IMPL_EXTENSIONS.contains(base::file_ext(Path::new(**p)).as_str()))
379                .collect();
380            for h in &headers {
381                for imp in &impls {
382                    if let (Some(hr), Some(ir)) = (reps.get(**h), reps.get(**imp)) {
383                        add_edge(&mut edges, hr, ir, base_weight, reverse_factor);
384                    }
385                }
386            }
387        }
388
389        edges
390    }
391
392    fn discover_related_files(
393        &self,
394        changed: &[PathBuf],
395        candidates: &[PathBuf],
396        _repo_root: Option<&Path>,
397        _file_cache: Option<&FxHashMap<PathBuf, String>>,
398    ) -> Vec<PathBuf> {
399        let c_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_c_family(f)).collect();
400        if c_changed.is_empty() {
401            return vec![];
402        }
403
404        let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
405        let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
406        let mut frontier: Vec<PathBuf> = c_changed.iter().map(|f| (*f).clone()).collect();
407
408        for _ in 0..SEMANTIC_DISCOVERY.max_depth {
409            let mut hop_found: Vec<PathBuf> = Vec::new();
410
411            let mut included_headers: FxHashSet<String> = FxHashSet::default();
412            for f in &frontier {
413                if let Ok(content) = std::fs::read_to_string(f) {
414                    included_headers.extend(extract_includes(&content));
415                }
416            }
417
418            let mut changed_names: FxHashSet<String> = FxHashSet::default();
419            for f in &frontier {
420                if let Some(name) = f.file_name() {
421                    changed_names.insert(name.to_string_lossy().to_string());
422                }
423                if let Some(stem) = f.file_stem() {
424                    let s = stem.to_string_lossy().to_string();
425                    changed_names.insert(format!("{}.h", s));
426                    changed_names.insert(format!("{}.hpp", s));
427                }
428            }
429
430            for candidate in candidates {
431                if changed_set.contains(candidate)
432                    || discovered.contains(candidate)
433                    || !is_c_family(candidate)
434                {
435                    continue;
436                }
437                let cand_name = candidate
438                    .file_name()
439                    .map(|n| n.to_string_lossy().to_string())
440                    .unwrap_or_default();
441                if included_headers.contains(&cand_name) {
442                    hop_found.push(candidate.clone());
443                    continue;
444                }
445                if let Ok(content) = std::fs::read_to_string(candidate) {
446                    let cand_includes = extract_includes(&content);
447                    for inc in &cand_includes {
448                        let inc_name = if inc.contains('/') {
449                            inc.split('/').next_back().unwrap().to_string()
450                        } else {
451                            inc.clone()
452                        };
453                        if changed_names.contains(&inc_name) {
454                            hop_found.push(candidate.clone());
455                            break;
456                        }
457                    }
458                }
459            }
460
461            let new_files: Vec<PathBuf> = hop_found
462                .into_iter()
463                .filter(|f| !discovered.contains(f))
464                .collect();
465            if new_files.is_empty() {
466                break;
467            }
468            discovered.extend(new_files.iter().cloned());
469            frontier = new_files;
470        }
471
472        let mut result: Vec<PathBuf> = discovered.into_iter().collect();
473        result.sort();
474        result
475    }
476}