Skip to main content

big_code_analysis/
preproc.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(
8    clippy::enum_glob_use,
9    clippy::if_not_else,
10    clippy::too_many_lines,
11    clippy::wildcard_imports
12)]
13
14use std::collections::{HashMap, HashSet, hash_map};
15use std::path::{Path, PathBuf};
16
17use petgraph::{
18    Direction, algo::kosaraju_scc, graph::NodeIndex, stable_graph::StableGraph, visit::Dfs,
19};
20use serde::{Deserialize, Serialize};
21
22use crate::c_langs_macros::is_specials;
23
24use crate::langs::*;
25use crate::languages::language_preproc::*;
26use crate::node::{Cursor, Node};
27use crate::tools::*;
28use crate::traits::*;
29
30/// A non-fatal diagnostic produced while resolving the C/C++ include
31/// graph in [`fix_includes`].
32///
33/// Resolution is best-effort: self-inclusions, include cycles, paths
34/// that cannot be decoded as UTF-8, and files referenced but never
35/// preprocessed are all reported here rather than written to `stderr`,
36/// so an embedder (e.g. `bca-web`) can capture, suppress, or surface
37/// them as it sees fit. The CLI prints them to `stderr`.
38#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
39pub enum PreprocDiagnostic {
40    /// A file's `#include` resolved back to the file itself; the
41    /// self-edge was skipped.
42    SelfInclusion {
43        /// The file that includes itself.
44        file: PathBuf,
45    },
46    /// A strongly connected component (an include cycle) was collapsed
47    /// into a single replacement node. Carries the member paths.
48    IncludeCycle {
49        /// The files participating in the cycle.
50        members: Vec<String>,
51    },
52    /// A path could not be decoded as UTF-8 and was skipped while
53    /// collapsing an include cycle.
54    NonUtf8CyclePath {
55        /// The lossy rendering of the offending path.
56        path: String,
57    },
58    /// A path could not be decoded as UTF-8 and was skipped while
59    /// recording indirect includes.
60    NonUtf8IndirectInclude {
61        /// The lossy rendering of the offending path.
62        path: String,
63    },
64    /// A file appears in the include graph but was never preprocessed,
65    /// so its own macros and includes are unknown.
66    NotPreprocessed {
67        /// The file referenced but not preprocessed.
68        file: PathBuf,
69    },
70}
71
72impl std::fmt::Display for PreprocDiagnostic {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Self::SelfInclusion { file } => {
76                write!(f, "Warning: possible self inclusion {}", file.display())
77            }
78            Self::IncludeCycle { members } => {
79                writeln!(f, "Warning: possible include cycle:")?;
80                for member in members {
81                    // Explicit quotes preserve whitespace visibility for
82                    // paths that contain spaces — important when the cycle
83                    // warning is the only signal a user gets.
84                    writeln!(f, "  - \"{member}\"")?;
85                }
86                Ok(())
87            }
88            Self::NonUtf8CyclePath { path } => {
89                write!(
90                    f,
91                    "warning: skipping non-UTF-8 path in include cycle: {path}"
92                )
93            }
94            Self::NonUtf8IndirectInclude { path } => write!(
95                f,
96                "warning: skipping non-UTF-8 indirect include path: {path}"
97            ),
98            Self::NotPreprocessed { file } => write!(
99                f,
100                "Warning: included file which has not been preprocessed: {}",
101                file.display()
102            ),
103        }
104    }
105}
106
107/// Preprocessor data of a `C/C++` file.
108#[derive(Debug, Default, Deserialize, Serialize)]
109pub struct PreprocFile {
110    /// The set of include directives explicitly written in a file
111    pub direct_includes: HashSet<String>,
112    /// The set of include directives implicitly imported in a file
113    /// from other files
114    pub indirect_includes: HashSet<String>,
115    /// The set of macros of a file
116    pub macros: HashSet<String>,
117}
118
119/// Preprocessor data of a series of `C/C++` files.
120#[derive(Debug, Default, Deserialize, Serialize)]
121pub struct PreprocResults {
122    /// The preprocessor data of each `C/C++` file
123    pub files: HashMap<PathBuf, PreprocFile>,
124}
125
126impl PreprocFile {
127    /// Builds a new `PreprocFile` whose macro set contains the given
128    /// macro names (and no includes).
129    #[must_use]
130    pub fn new_macros(macros: &[&str]) -> Self {
131        let mut pf = Self::default();
132        for m in macros {
133            pf.macros.insert((*m).to_string());
134        }
135        pf
136    }
137}
138
139/// Returns the macros contained in a `C/C++` file.
140pub fn get_macros<S: ::std::hash::BuildHasher>(
141    file: &Path,
142    files: &HashMap<PathBuf, PreprocFile, S>,
143) -> HashSet<String> {
144    let mut macros = HashSet::new();
145    if let Some(pf) = files.get(file) {
146        for m in &pf.macros {
147            macros.insert(m.clone());
148        }
149        for f in &pf.indirect_includes {
150            if let Some(pf) = files.get(&PathBuf::from(f)) {
151                for m in &pf.macros {
152                    macros.insert(m.clone());
153                }
154            }
155        }
156    }
157    macros
158}
159
160/// The include dependency graph: nodes are file paths, edges point from a
161/// file to each file it directly includes. SCC replacement nodes carry an
162/// empty [`PathBuf`] as their weight.
163type IncludeGraph = StableGraph<PathBuf, i32>;
164
165/// Returns the graph node for `file`, inserting one (and recording it in
166/// `nodes`) on first lookup so that repeat lookups of the same path return a
167/// stable [`NodeIndex`]. The owned-path call site pays one extra clone here,
168/// which is allocation only and never affects output.
169fn ensure_node(
170    g: &mut IncludeGraph,
171    nodes: &mut HashMap<PathBuf, NodeIndex>,
172    file: &Path,
173) -> NodeIndex {
174    match nodes.entry(file.to_path_buf()) {
175        hash_map::Entry::Occupied(l) => *l.get(),
176        hash_map::Entry::Vacant(p) => *p.insert(g.add_node(file.to_path_buf())),
177    }
178}
179
180/// Resolves an `#include` to a single, deterministic target.
181///
182/// [`guess_file`]'s last-resort `min_distance_candidates` fallback can
183/// return several tied candidates (a basename like `config.h` living in
184/// multiple directories). Adding an edge to *every* tied candidate would
185/// leak macros from unrelated files through [`get_macros`] and make the
186/// resolved set depend on `all_files` Vec ordering. We instead pick the
187/// lexicographically smallest path among the ties — a stable, content-
188/// independent tie-break — and document the choice as best-effort.
189fn resolve_single_include<S: ::std::hash::BuildHasher>(
190    file: &Path,
191    include: &str,
192    all_files: &HashMap<String, Vec<PathBuf>, S>,
193) -> Option<PathBuf> {
194    guess_file(file, include, all_files).into_iter().min()
195}
196
197/// Builds the include dependency graph from the preprocessor data: one node
198/// per file, one edge per resolved direct include. Each include resolves to a
199/// single deterministic target (see [`resolve_single_include`]). Self-
200/// inclusions are reported as a diagnostic and skipped rather than added as
201/// self-edges. Returns the graph, the path→node map, and any diagnostics.
202fn build_include_graph<S: ::std::hash::BuildHasher>(
203    files: &HashMap<PathBuf, PreprocFile, S>,
204    all_files: &HashMap<String, Vec<PathBuf>, S>,
205    diagnostics: &mut Vec<PreprocDiagnostic>,
206) -> (IncludeGraph, HashMap<PathBuf, NodeIndex>) {
207    let mut nodes: HashMap<PathBuf, NodeIndex> = HashMap::new();
208    // Since we'll remove strong connected components we need to have a stable graph
209    // in order to use the nodes we've in the nodes HashMap.
210    let mut g = StableGraph::new();
211
212    for (file, pf) in files {
213        let node = ensure_node(&mut g, &mut nodes, file);
214        for i in &pf.direct_includes {
215            let Some(included) = resolve_single_include(file, i, all_files) else {
216                continue;
217            };
218            if &included == file {
219                diagnostics.push(PreprocDiagnostic::SelfInclusion { file: file.clone() });
220                continue;
221            }
222            let included = ensure_node(&mut g, &mut nodes, &included);
223            g.add_edge(node, included, 0);
224        }
225    }
226
227    (g, nodes)
228}
229
230/// Collects the neighbors of `component` in the given `direction` that lie
231/// outside the component, de-duplicated and in first-seen order. Intra-
232/// component edges are excluded so the replacement node only re-wires the
233/// SCC's external boundary. A `Vec` (not a `HashSet`) suffices: SCCs in real
234/// codebases are few and small, so linear `contains` checks stay cheap.
235fn scc_external_neighbors(
236    g: &IncludeGraph,
237    component: &[NodeIndex],
238    direction: Direction,
239) -> Vec<NodeIndex> {
240    let mut neighbors = Vec::new();
241    for c in component {
242        for n in g.neighbors_directed(*c, direction) {
243            if !component.contains(&n) && !neighbors.contains(&n) {
244                neighbors.push(n);
245            }
246        }
247    }
248    neighbors
249}
250
251/// Replaces every strongly connected component (an include cycle) with a
252/// single replacement node carrying an empty path, re-wiring the component's
253/// external incoming/outgoing edges onto it and rewriting the `nodes` map so
254/// each member path now resolves to the replacement. Returns a map from each
255/// replacement node to the set of member paths it stands in for.
256fn collapse_scc(
257    g: &mut IncludeGraph,
258    nodes: &mut HashMap<PathBuf, NodeIndex>,
259    diagnostics: &mut Vec<PreprocDiagnostic>,
260) -> HashMap<NodeIndex, HashSet<String>> {
261    // In order to walk in the graph without issues due to cycles
262    // we replace strong connected components by a unique node
263    // All the paths in a scc finally represents a kind of unique file containing
264    // all the files in the scc.
265    let mut scc = kosaraju_scc(&*g);
266    let mut scc_map: HashMap<NodeIndex, HashSet<String>> = HashMap::new();
267    for component in &mut scc {
268        if component.len() > 1 {
269            // External boundaries must be captured before the replacement node
270            // is added, so the new node is never mistaken for an external
271            // neighbor.
272            let incoming = scc_external_neighbors(g, component, Direction::Incoming);
273            let outgoing = scc_external_neighbors(g, component, Direction::Outgoing);
274            let mut paths = HashSet::new();
275
276            let replacement = g.add_node(PathBuf::from(""));
277            for i in incoming {
278                g.add_edge(i, replacement, 0);
279            }
280            for o in outgoing {
281                g.add_edge(replacement, o, 0);
282            }
283            for c in component.drain(..) {
284                let path = g
285                    .remove_node(c)
286                    .expect("invariant: SCC component node must exist in graph");
287                if let Some(s) = path.to_str() {
288                    paths.insert(s.to_string());
289                } else {
290                    diagnostics.push(PreprocDiagnostic::NonUtf8CyclePath {
291                        path: path.display().to_string(),
292                    });
293                }
294                *nodes
295                    .get_mut(&path)
296                    .expect("invariant: every graph node must have a nodes map entry") =
297                    replacement;
298            }
299
300            // A `HashSet` iterates in an unspecified order; sort the member
301            // list so the emitted diagnostic is deterministic across runs.
302            let mut members: Vec<String> = paths.iter().cloned().collect();
303            members.sort_unstable();
304            diagnostics.push(PreprocDiagnostic::IncludeCycle { members });
305
306            scc_map.insert(replacement, paths);
307        }
308    }
309    scc_map
310}
311
312/// Walks the include graph from every file's node and records the transitive
313/// closure of reachable includes into that file's `indirect_includes`. An
314/// SCC replacement node (empty path) contributes every member path it stands
315/// in for. Files reachable only through the graph but never preprocessed are
316/// warned about.
317fn record_indirect_includes<S: ::std::hash::BuildHasher>(
318    files: &mut HashMap<PathBuf, PreprocFile, S>,
319    g: &IncludeGraph,
320    nodes: &HashMap<PathBuf, NodeIndex>,
321    scc_map: &HashMap<NodeIndex, HashSet<String>>,
322    diagnostics: &mut Vec<PreprocDiagnostic>,
323) {
324    for (path, start) in nodes {
325        let Some(pf) = files.get_mut(path) else {
326            diagnostics.push(PreprocDiagnostic::NotPreprocessed { file: path.clone() });
327            continue;
328        };
329        accumulate_reachable_includes(g, *start, scc_map, &mut pf.indirect_includes, diagnostics);
330    }
331}
332
333/// Walk the include graph from `start`, inserting the transitive closure of
334/// reachable include paths into `x_inc`. An SCC replacement node (empty path)
335/// contributes every member path it stands in for; a non-UTF-8 path is
336/// reported and skipped. Factored out of [`record_indirect_includes`] so the
337/// per-file accumulation reads as one step rather than three nested loops.
338fn accumulate_reachable_includes(
339    g: &IncludeGraph,
340    start: NodeIndex,
341    scc_map: &HashMap<NodeIndex, HashSet<String>>,
342    x_inc: &mut HashSet<String>,
343    diagnostics: &mut Vec<PreprocDiagnostic>,
344) {
345    let mut dfs = Dfs::new(g, start);
346    while let Some(node) = dfs.next(g) {
347        let w = g
348            .node_weight(node)
349            .expect("invariant: DFS-visited node must have weight in graph");
350        if w == &PathBuf::from("") {
351            let paths = scc_map.get(&node).expect(
352                "every empty-path node is an SCC replacement and must have a scc_map entry",
353            );
354            x_inc.extend(paths.iter().cloned());
355        } else if let Some(s) = w.to_str() {
356            x_inc.insert(s.to_string());
357        } else {
358            diagnostics.push(PreprocDiagnostic::NonUtf8IndirectInclude {
359                path: w.display().to_string(),
360            });
361        }
362    }
363}
364
365/// Constructs a dependency graph of the include directives
366/// in a `C/C++` file.
367///
368/// The dependency graph is built using both preprocessor data and not
369/// extracted from the considered `C/C++` files.
370///
371/// Best-effort include resolution emits non-fatal
372/// [`PreprocDiagnostic`]s (self-inclusions, include cycles, non-UTF-8
373/// paths, files referenced but never preprocessed) as the returned
374/// `Vec` rather than writing to `stderr`, so an embedder can capture or
375/// suppress them. The CLI prints them to `stderr`; callers that do not
376/// care may discard the result.
377///
378/// # Panics
379///
380/// Panics if any of the lockstep invariants between the include graph
381/// `g`, the `nodes` map, and the `scc_map` is violated at runtime —
382/// specifically: an SCC component node missing from the graph, a graph
383/// node weight without a `nodes` map entry, a DFS-visited node without
384/// a stored weight, or an empty-path replacement node without a
385/// `scc_map` entry. These data structures are built in lockstep by
386/// this function, so all four conditions represent unrecoverable
387/// programmer errors rather than reachable input failures.
388pub fn fix_includes<S: ::std::hash::BuildHasher>(
389    files: &mut HashMap<PathBuf, PreprocFile, S>,
390    all_files: &HashMap<String, Vec<PathBuf>, S>,
391) -> Vec<PreprocDiagnostic> {
392    let mut diagnostics = Vec::new();
393    let (mut g, mut nodes) = build_include_graph(files, all_files, &mut diagnostics);
394    let scc_map = collapse_scc(&mut g, &mut nodes, &mut diagnostics);
395    record_indirect_includes(files, &g, &nodes, &scc_map, &mut diagnostics);
396    diagnostics
397}
398
399/// Strips the surrounding double quotes from an `#include` `string_literal`
400/// spanning `code[start..end]` and trims leading/trailing whitespace from the
401/// enclosed path.
402///
403/// Returns `None` for any malformed span that cannot hold both quote bytes.
404/// Tree-sitter's error recovery can emit a `string_literal` shorter than the
405/// two surrounding quotes (e.g. a truncated `#include "` with no closing
406/// quote), so the byte span is validated *before* slicing — `end < start + 2`
407/// would otherwise produce a reversed `start + 1..end - 1` range and panic
408/// (issue #432). An empty (`""`), whitespace-only, or non-UTF-8 payload also
409/// yields `None`.
410fn strip_include_quotes(code: &[u8], start: usize, end: usize) -> Option<&str> {
411    // A valid quoted literal needs at least the opening and closing quote.
412    const MIN_QUOTED_LEN: usize = 2;
413    if end < start + MIN_QUOTED_LEN {
414        return None;
415    }
416
417    let inner = &code[start + 1..end - 1];
418    let first = inner.iter().position(|&c| c != b' ' && c != b'\t')?;
419    let last = inner.iter().rposition(|&c| c != b' ' && c != b'\t')?;
420    std::str::from_utf8(&inner[first..=last]).ok()
421}
422
423/// Extracts preprocessor data from a `C/C++` source buffer and inserts
424/// it into a [`PreprocResults`] object.
425///
426/// Builds the preprocessor parse internally, so callers supply the raw
427/// `source` and need not name the parser type. `path` keys the
428/// per-file results.
429pub fn preprocess(source: Vec<u8>, path: &Path, results: &mut PreprocResults) {
430    preprocess_with_parser(&PreprocParser::new(source, path, None), path, results);
431}
432
433/// Walk an already-built [`PreprocParser`] tree, accumulating its
434/// preprocessor data into `results`. Internal core shared by the public
435/// [`preprocess`] seam and the crate's own preprocessor tests.
436pub(crate) fn preprocess_with_parser(
437    parser: &PreprocParser,
438    path: &Path,
439    results: &mut PreprocResults,
440) {
441    let node = parser.root();
442    let mut cursor = node.cursor();
443    let code = parser.code();
444    let mut file_result = PreprocFile::default();
445
446    // The stack-based walk visits siblings in reverse source order, so a
447    // `#define FOO` / `#undef FOO` pair would be observed undef-first.
448    // Collect each directive with its byte offset and replay in source
449    // order afterwards, so `#undef` removes a macro a *preceding*
450    // `#define` introduced — and a `#define` that follows a `#undef`
451    // re-introduces it (issue #705).
452    let mut macro_events: Vec<(usize, MacroEvent)> = Vec::new();
453
454    let mut stack = vec![node];
455    while let Some(node) = stack.pop() {
456        push_children(&mut cursor, &node, &mut stack);
457        classify_preproc_node(
458            &mut cursor,
459            &node,
460            code,
461            &mut file_result,
462            &mut macro_events,
463        );
464    }
465
466    apply_macro_events(macro_events, &mut file_result);
467
468    results.files.insert(path.to_path_buf(), file_result);
469}
470
471/// Push `node`'s children onto `stack` for the stack-based DFS in
472/// [`preprocess_with_parser`]. Children are pushed in source order so they
473/// pop in reverse; directive order is recovered from byte offsets in
474/// [`apply_macro_events`], so visit order does not affect the result.
475fn push_children<'a>(cursor: &mut Cursor<'a>, node: &Node<'a>, stack: &mut Vec<Node<'a>>) {
476    cursor.reset(node);
477    if cursor.goto_first_child() {
478        loop {
479            stack.push(cursor.node());
480            if !cursor.goto_next_sibling() {
481                break;
482            }
483        }
484    }
485}
486
487/// Classify one node from the [`preprocess_with_parser`] walk: a
488/// `#define`/`#undef` is captured as a [`MacroEvent`] tagged with its byte
489/// offset (replayed in source order later), and a quoted `#include` is
490/// recorded directly into `file_result`. All other nodes are ignored.
491///
492/// Takes the walk's shared `cursor` by `&mut` and `reset`s it to reach the
493/// directive's first child, rather than allocating a fresh cursor per node —
494/// the caller is done with `cursor` by the time this runs.
495fn classify_preproc_node<'a>(
496    cursor: &mut Cursor<'a>,
497    node: &Node<'a>,
498    code: &'a [u8],
499    file_result: &mut PreprocFile,
500    macro_events: &mut Vec<(usize, MacroEvent)>,
501) {
502    let id = Preproc::from(node.kind_id());
503    match id {
504        Preproc::Define | Preproc::Undef => {
505            cursor.reset(node);
506            cursor.goto_first_child();
507            let identifier = cursor.node();
508            if identifier.kind_id() == Preproc::Identifier
509                && let Some(macro_text) = identifier.utf8_text(code)
510                && !is_specials(macro_text)
511            {
512                // `#undef` un-defines: a macro is in the final set only if
513                // its last directive was a `#define`.
514                let event = if id == Preproc::Undef {
515                    MacroEvent::Undef(macro_text.to_string())
516                } else {
517                    MacroEvent::Define(macro_text.to_string())
518                };
519                macro_events.push((identifier.start_byte(), event));
520            }
521        }
522        Preproc::PreprocInclude => {
523            cursor.reset(node);
524            cursor.goto_first_child();
525            let file = cursor.node();
526            if file.kind_id() == Preproc::StringLiteral
527                && let Some(include) =
528                    strip_include_quotes(code, file.start_byte(), file.end_byte())
529            {
530                file_result.direct_includes.insert(include.to_string());
531            }
532        }
533        _ => {}
534    }
535}
536
537/// Replay collected `#define`/`#undef` directives in source order so the
538/// final macro set reflects the last directive seen for each name (issue
539/// #705). A stable sort on the byte offset preserves the (already unique)
540/// directive order; ties cannot occur because each identifier starts at a
541/// distinct byte.
542fn apply_macro_events(mut macro_events: Vec<(usize, MacroEvent)>, file_result: &mut PreprocFile) {
543    macro_events.sort_by_key(|(offset, _)| *offset);
544    for (_, event) in macro_events {
545        match event {
546            MacroEvent::Define(name) => {
547                file_result.macros.insert(name);
548            }
549            MacroEvent::Undef(name) => {
550                file_result.macros.remove(&name);
551            }
552        }
553    }
554}
555
556/// A single `#define`/`#undef` directive captured during the AST walk,
557/// replayed in source order so `#undef` removes a previously defined
558/// macro (issue #705).
559enum MacroEvent {
560    /// `#define NAME` — adds NAME to the file's macro set.
561    Define(String),
562    /// `#undef NAME` — removes NAME from the file's macro set.
563    Undef(String),
564}
565
566#[cfg(test)]
567#[allow(
568    clippy::float_cmp,
569    clippy::cast_precision_loss,
570    clippy::cast_possible_truncation,
571    clippy::cast_sign_loss,
572    clippy::similar_names,
573    clippy::doc_markdown,
574    clippy::needless_raw_string_hashes,
575    clippy::too_many_lines
576)]
577mod tests {
578    use super::*;
579
580    fn parse(source: &str) -> PreprocParser {
581        PreprocParser::new(source.as_bytes().to_vec(), &PathBuf::from("test.h"), None)
582    }
583
584    /// Empty include strings (`#include ""`) must not panic — earlier
585    /// implementations called `unwrap()` on `position`/`rposition` of the
586    /// trimmed slice, which returns `None` for an all-whitespace or empty
587    /// payload.
588    #[test]
589    fn preprocess_empty_include_does_not_panic() {
590        let parser = parse("#include \"\"\n");
591        let mut results = PreprocResults::default();
592        preprocess_with_parser(&parser, &PathBuf::from("test.h"), &mut results);
593        let pf = results
594            .files
595            .get(&PathBuf::from("test.h"))
596            .expect("file entry must be inserted");
597        assert!(pf.direct_includes.is_empty());
598    }
599
600    /// Whitespace-only include strings (`#include "   "`) must not panic —
601    /// `position` returns `None` because no non-whitespace byte exists.
602    #[test]
603    fn preprocess_whitespace_only_include_does_not_panic() {
604        let parser = parse("#include \"   \"\n");
605        let mut results = PreprocResults::default();
606        preprocess_with_parser(&parser, &PathBuf::from("test.h"), &mut results);
607        let pf = results
608            .files
609            .get(&PathBuf::from("test.h"))
610            .expect("file entry must be inserted");
611        assert!(pf.direct_includes.is_empty());
612    }
613
614    /// A well-formed include is still recorded with surrounding whitespace
615    /// stripped.
616    #[test]
617    fn preprocess_valid_include_is_recorded() {
618        let parser = parse("#include \"  foo.h  \"\n");
619        let mut results = PreprocResults::default();
620        preprocess_with_parser(&parser, &PathBuf::from("test.h"), &mut results);
621        let pf = results
622            .files
623            .get(&PathBuf::from("test.h"))
624            .expect("file entry must be inserted");
625        assert!(pf.direct_includes.contains("foo.h"));
626    }
627
628    /// `#define` of a normal identifier records the macro name.
629    #[test]
630    fn preprocess_define_records_macro() {
631        let parser = parse("#define FOO 1\n");
632        let mut results = PreprocResults::default();
633        preprocess_with_parser(&parser, &PathBuf::from("test.h"), &mut results);
634        let pf = results
635            .files
636            .get(&PathBuf::from("test.h"))
637            .expect("file entry must be inserted");
638        assert!(pf.macros.contains("FOO"));
639    }
640
641    fn macros_of(source: &str) -> HashSet<String> {
642        let parser = parse(source);
643        let mut results = PreprocResults::default();
644        preprocess_with_parser(&parser, &PathBuf::from("test.h"), &mut results);
645        results
646            .files
647            .get(&PathBuf::from("test.h"))
648            .expect("file entry must be inserted")
649            .macros
650            .clone()
651    }
652
653    /// Regression for #705: `#undef FOO` after `#define FOO` must REMOVE
654    /// FOO from the macro set — the pre-fix code shared a `Define | Undef`
655    /// arm that inserted the identifier for both, leaving `#undef FOO`
656    /// recording FOO as *defined*.
657    #[test]
658    fn preprocess_undef_removes_defined_macro() {
659        let macros = macros_of("#define FOO 1\n#undef FOO\n");
660        assert!(
661            !macros.contains("FOO"),
662            "#undef FOO must un-define FOO; got {macros:?}"
663        );
664    }
665
666    /// `#undef` of a macro that was never defined is a no-op (and must not
667    /// leave the name recorded as defined).
668    #[test]
669    fn preprocess_undef_of_never_defined_is_noop() {
670        let macros = macros_of("#undef NEVER_DEFINED\n");
671        assert!(!macros.contains("NEVER_DEFINED"));
672    }
673
674    /// Regression for #705's source-order replay: a `#define` that follows a
675    /// `#undef` in source order re-introduces the macro. The AST walk visits
676    /// siblings in *reverse* source order, so the raw encounter order is
677    /// `define` then `undef` (which would drop FOO); only the byte-offset
678    /// re-sort in `apply_macro_events` recovers the correct `undef` → `define`
679    /// order. The fixture is deliberately asymmetric (undef first, define
680    /// last) so a missing or reversed sort flips the result — a `define`
681    /// … `undef` … `define` sequence ends on a `define` either way and would
682    /// not exercise the ordering at all.
683    #[test]
684    fn preprocess_define_after_undef_reintroduces_in_source_order() {
685        let macros = macros_of("#undef FOO\n#define FOO 1\n");
686        assert!(
687            macros.contains("FOO"),
688            "the trailing source-order #define must win; got {macros:?}"
689        );
690    }
691
692    /// `#undef` removes only the named macro; unrelated defines survive.
693    #[test]
694    fn preprocess_undef_leaves_other_macros() {
695        let macros = macros_of("#define FOO 1\n#define BAR 2\n#undef FOO\n");
696        assert!(!macros.contains("FOO"));
697        assert!(macros.contains("BAR"));
698    }
699
700    /// `classify_preproc_node` drops `#define`s of compiler/type "special"
701    /// tokens (the `is_specials` filter — `size_t`, `NULL`, keywords, …) so
702    /// they never pollute the recorded macro set, while an ordinary macro on
703    /// an adjacent line is still recorded. Pins the `is_specials` guard that
704    /// the #736 refactor moved out of the inline walk and into the helper.
705    #[test]
706    fn preprocess_define_of_special_token_is_skipped() {
707        let macros = macros_of("#define size_t unsigned\n#define APP_FLAG 1\n");
708        assert!(
709            !macros.contains("size_t"),
710            "special token `size_t` must be filtered out; got {macros:?}"
711        );
712        assert!(
713            macros.contains("APP_FLAG"),
714            "an ordinary adjacent macro must still be recorded; got {macros:?}"
715        );
716    }
717
718    /// Regression for #705 (ambiguous include fan-out): when an `#include`
719    /// basename resolves to several tied candidates, exactly ONE edge is
720    /// added — the lexicographically smallest path — so macros do not leak
721    /// from unrelated same-named files via `get_macros`, and the result is
722    /// independent of `all_files` Vec ordering.
723    #[test]
724    fn ambiguous_include_resolves_to_single_deterministic_candidate() {
725        // `main.c` includes `config.h`, which exists in two sibling
726        // directories equidistant from the includer; neither resolution
727        // heuristic disambiguates, so the min-distance fallback ties and
728        // would otherwise return both candidates.
729        let includer = PathBuf::from("proj/src/main.c");
730        let cfg_a = PathBuf::from("proj/aaa/config.h");
731        let cfg_b = PathBuf::from("proj/zzz/config.h");
732
733        let mut files: HashMap<PathBuf, PreprocFile> = HashMap::new();
734        let mut main = PreprocFile::default();
735        main.direct_includes.insert("config.h".to_string());
736        files.insert(includer.clone(), main);
737        files.insert(cfg_a.clone(), PreprocFile::new_macros(&["FROM_A"]));
738        files.insert(cfg_b.clone(), PreprocFile::new_macros(&["FROM_B"]));
739
740        // Reversed Vec order proves the tie-break does not depend on it.
741        let mut all_files: HashMap<String, Vec<PathBuf>> = HashMap::new();
742        all_files.insert("config.h".to_string(), vec![cfg_b.clone(), cfg_a.clone()]);
743        all_files.insert("main.c".to_string(), vec![includer.clone()]);
744
745        let diagnostics = fix_includes(&mut files, &all_files);
746        assert!(
747            diagnostics.is_empty(),
748            "no diagnostics expected for a clean ambiguous resolve; got {diagnostics:?}"
749        );
750
751        let main = files.get(&includer).expect("main.c retained");
752        // Exactly the lexicographically smallest candidate
753        // (`proj/aaa/config.h`) is wired in; the sibling does not leak.
754        assert!(main.indirect_includes.contains("proj/aaa/config.h"));
755        assert!(!main.indirect_includes.contains("proj/zzz/config.h"));
756
757        let macros = get_macros(&includer, &files);
758        assert!(macros.contains("FROM_A"));
759        assert!(
760            !macros.contains("FROM_B"),
761            "macros from the unselected candidate must not leak; got {macros:?}"
762        );
763    }
764
765    /// A `#include` that resolves back to the including file is reported as
766    /// a `SelfInclusion` diagnostic (not written to stderr) and adds no
767    /// self-edge.
768    #[test]
769    fn self_inclusion_is_reported_as_diagnostic() {
770        let self_path = PathBuf::from("a.h");
771        let mut files: HashMap<PathBuf, PreprocFile> = HashMap::new();
772        let mut a = PreprocFile::default();
773        a.direct_includes.insert("a.h".to_string());
774        files.insert(self_path.clone(), a);
775
776        let mut all_files: HashMap<String, Vec<PathBuf>> = HashMap::new();
777        all_files.insert("a.h".to_string(), vec![self_path.clone()]);
778
779        let diagnostics = fix_includes(&mut files, &all_files);
780        assert_eq!(
781            diagnostics,
782            vec![PreprocDiagnostic::SelfInclusion {
783                file: self_path.clone(),
784            }]
785        );
786    }
787
788    /// `fix_includes` collapses a 2-file include cycle into one SCC replacement
789    /// node and propagates every member of that SCC into the `indirect_includes`
790    /// of *both* files symmetrically. Also exercises the `let-else` /
791    /// `expect`-with-invariant paths added in the panic-safety refactor (#72).
792    #[test]
793    fn fix_includes_handles_simple_cycle() {
794        let mut files: HashMap<PathBuf, PreprocFile> = HashMap::new();
795        let mut a = PreprocFile::default();
796        a.direct_includes.insert("b.h".to_string());
797        let mut b = PreprocFile::default();
798        b.direct_includes.insert("a.h".to_string());
799        files.insert(PathBuf::from("a.h"), a);
800        files.insert(PathBuf::from("b.h"), b);
801
802        let mut all_files: HashMap<String, Vec<PathBuf>> = HashMap::new();
803        all_files.insert("a.h".to_string(), vec![PathBuf::from("a.h")]);
804        all_files.insert("b.h".to_string(), vec![PathBuf::from("b.h")]);
805
806        let diagnostics = fix_includes(&mut files, &all_files);
807
808        // The cycle is reported as a single, deterministic diagnostic
809        // (members sorted) rather than written to stderr.
810        assert_eq!(
811            diagnostics,
812            vec![PreprocDiagnostic::IncludeCycle {
813                members: vec!["a.h".to_string(), "b.h".to_string()],
814            }]
815        );
816
817        // After resolving the cycle each file's indirect_includes should
818        // contain both members of the SCC.
819        let a = files
820            .get(&PathBuf::from("a.h"))
821            .expect("a.h must be retained");
822        assert!(a.indirect_includes.contains("a.h"));
823        assert!(a.indirect_includes.contains("b.h"));
824
825        let b = files
826            .get(&PathBuf::from("b.h"))
827            .expect("b.h must be retained");
828        assert!(b.indirect_includes.contains("a.h"));
829        assert!(b.indirect_includes.contains("b.h"));
830    }
831
832    /// `ensure_node` must return the same `NodeIndex` for a repeated path
833    /// lookup and must not add a second graph node — the include-graph build
834    /// relies on this to coalesce a file referenced from multiple includes.
835    #[test]
836    fn ensure_node_returns_stable_index_on_repeat() {
837        let mut g: IncludeGraph = StableGraph::new();
838        let mut nodes: HashMap<PathBuf, NodeIndex> = HashMap::new();
839        let p = PathBuf::from("a.h");
840
841        let first = ensure_node(&mut g, &mut nodes, &p);
842        let second = ensure_node(&mut g, &mut nodes, &p);
843
844        assert_eq!(first, second);
845        assert_eq!(g.node_count(), 1);
846        assert_eq!(nodes.len(), 1);
847    }
848
849    /// `scc_external_neighbors` must (a) exclude intra-component nodes so the
850    /// replacement node only re-wires the cycle's external boundary, and (b)
851    /// de-duplicate a node reachable from multiple component members. Here the
852    /// component `{a, b}` has one external predecessor `x` (pointing into both)
853    /// and one external successor `y` (pointed to by both); each must appear
854    /// exactly once and neither `a` nor `b` may leak in.
855    #[test]
856    fn scc_external_neighbors_dedups_and_excludes_intra_component() {
857        let mut graph: IncludeGraph = StableGraph::new();
858        let member_a = graph.add_node(PathBuf::from("a.h"));
859        let member_b = graph.add_node(PathBuf::from("b.h"));
860        let pred = graph.add_node(PathBuf::from("x.h"));
861        let succ = graph.add_node(PathBuf::from("y.h"));
862        // Intra-component cycle member_a <-> member_b.
863        graph.add_edge(member_a, member_b, 0);
864        graph.add_edge(member_b, member_a, 0);
865        // `pred` points into both members (dedup on the incoming side).
866        graph.add_edge(pred, member_a, 0);
867        graph.add_edge(pred, member_b, 0);
868        // Both members point out to `succ` (dedup on the outgoing side).
869        graph.add_edge(member_a, succ, 0);
870        graph.add_edge(member_b, succ, 0);
871
872        let component = vec![member_a, member_b];
873        let incoming = scc_external_neighbors(&graph, &component, Direction::Incoming);
874        let outgoing = scc_external_neighbors(&graph, &component, Direction::Outgoing);
875
876        assert_eq!(incoming, vec![pred]);
877        assert_eq!(outgoing, vec![succ]);
878    }
879
880    /// Regression for #432: a `string_literal` span shorter than the two
881    /// surrounding quote bytes must not panic. Tree-sitter error recovery on a
882    /// truncated `#include "` (no closing quote) can yield such a node; the
883    /// pre-fix code sliced `code[start + 1..end - 1]` unconditionally, which
884    /// builds a reversed range and panics for `end < start + 2`.
885    ///
886    /// Exercised directly against the byte-span helper so the reversed-range
887    /// path is genuinely hit regardless of what the current pinned grammar
888    /// emits — reverting the `end < start + 2` guard makes the len-0 and len-1
889    /// cases panic with `slice index starts at .. but ends at ..`.
890    #[test]
891    fn strip_include_quotes_rejects_too_short_spans() {
892        let code = b"#include \"\"";
893        // Length 0 (empty span) and length 1 (just an opening quote) cannot
894        // hold both quotes and must be rejected before slicing.
895        assert_eq!(strip_include_quotes(code, 9, 9), None);
896        assert_eq!(strip_include_quotes(code, 9, 10), None);
897    }
898
899    /// The helper still trims and accepts well-formed spans, and rejects
900    /// empty/whitespace-only payloads via the existing `position`/`rposition`
901    /// guards rather than panicking.
902    #[test]
903    fn strip_include_quotes_handles_valid_and_empty_payloads() {
904        // `"  foo.h  "` -> trimmed to `foo.h`.
905        let code = b"#include \"  foo.h  \"";
906        assert_eq!(strip_include_quotes(code, 9, code.len()), Some("foo.h"));
907        // `""` (length 2) -> empty payload -> None.
908        let code = b"#include \"\"";
909        assert_eq!(strip_include_quotes(code, 9, 11), None);
910        // `"   "` -> whitespace-only -> None.
911        let code = b"#include \"   \"";
912        assert_eq!(strip_include_quotes(code, 9, 14), None);
913    }
914
915    /// End-to-end: a truncated `#include "` with no closing quote must not
916    /// panic the preprocessor pass (issue #432). The file entry is still
917    /// inserted with no recorded include.
918    #[test]
919    fn preprocess_truncated_include_does_not_panic() {
920        let parser = parse("#include \"\n");
921        let mut results = PreprocResults::default();
922        preprocess_with_parser(&parser, &PathBuf::from("test.h"), &mut results);
923        let pf = results
924            .files
925            .get(&PathBuf::from("test.h"))
926            .expect("file entry must be inserted");
927        assert!(pf.direct_includes.is_empty());
928    }
929}