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,
19    algo::{kosaraju_scc, toposort},
20    graph::NodeIndex,
21    stable_graph::StableGraph,
22    visit::{Dfs, NodeIndexable},
23};
24use serde::{Deserialize, Serialize};
25
26use crate::c_langs_macros::is_specials;
27
28use crate::langs::*;
29use crate::languages::language_preproc::*;
30use crate::node::{Cursor, Node};
31use crate::tools::*;
32use crate::traits::*;
33
34/// A non-fatal diagnostic produced while resolving the C/C++ include
35/// graph in [`fix_includes`].
36///
37/// Resolution is best-effort: self-inclusions, include cycles, paths
38/// that cannot be decoded as UTF-8, and files referenced but never
39/// preprocessed are all reported here rather than written to `stderr`,
40/// so an embedder (e.g. `bca-web`) can capture, suppress, or surface
41/// them as it sees fit. The CLI prints them to `stderr` through its
42/// `warning:` helper.
43///
44/// [`Display`](std::fmt::Display) renders the bare message with no
45/// severity prefix and no trailing newline; prefixing is the presenting
46/// layer's job, so the prefix is written in exactly one place per crate
47/// (#1199).
48// `Ord` exists so [`fix_includes`] can return a stable sequence; see the
49// sort there for why. The derived order is by variant declaration first,
50// then by field, which groups a run's diagnostics by kind and orders each
51// kind by path — the shape a reader scanning `bca preproc` warnings wants.
52// It is an output-ordering aid, not a severity ranking.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
54pub enum PreprocDiagnostic {
55    /// A file's `#include` resolved back to the file itself; the
56    /// self-edge was skipped.
57    SelfInclusion {
58        /// The file that includes itself.
59        file: PathBuf,
60    },
61    /// A strongly connected component (an include cycle) was collapsed
62    /// into a single replacement node. Carries the member paths.
63    IncludeCycle {
64        /// The files participating in the cycle.
65        members: Vec<String>,
66    },
67    /// A path could not be decoded as UTF-8 and was skipped while
68    /// collapsing an include cycle.
69    NonUtf8CyclePath {
70        /// The lossy rendering of the offending path.
71        path: String,
72    },
73    /// A path could not be decoded as UTF-8 and was skipped while
74    /// recording indirect includes.
75    NonUtf8IndirectInclude {
76        /// The lossy rendering of the offending path.
77        path: String,
78    },
79    /// A file appears in the include graph but was never preprocessed,
80    /// so its own macros and includes are unknown.
81    NotPreprocessed {
82        /// The file referenced but not preprocessed.
83        file: PathBuf,
84    },
85}
86
87impl std::fmt::Display for PreprocDiagnostic {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::SelfInclusion { file } => {
91                write!(f, "possible self inclusion {}", file.display())
92            }
93            Self::IncludeCycle { members } => {
94                write!(f, "possible include cycle:")?;
95                for member in members {
96                    // Explicit quotes preserve whitespace visibility for
97                    // paths that contain spaces — important when the cycle
98                    // warning is the only signal a user gets.
99                    //
100                    // The newline leads rather than trails so the rendered
101                    // block carries no trailing newline of its own: a
102                    // `Display` that ends in one stacks with the caller's
103                    // `println!`/`warn` and prints a stray blank line
104                    // (#1199).
105                    write!(f, "\n  - \"{member}\"")?;
106                }
107                Ok(())
108            }
109            Self::NonUtf8CyclePath { path } => {
110                write!(f, "skipping non-UTF-8 path in include cycle: {path}")
111            }
112            Self::NonUtf8IndirectInclude { path } => {
113                write!(f, "skipping non-UTF-8 indirect include path: {path}")
114            }
115            Self::NotPreprocessed { file } => write!(
116                f,
117                "included file which has not been preprocessed: {}",
118                file.display()
119            ),
120        }
121    }
122}
123
124/// Preprocessor data of a `C/C++` file.
125#[derive(Debug, Default, Deserialize, Serialize)]
126pub struct PreprocFile {
127    /// The set of include directives explicitly written in a file
128    pub direct_includes: HashSet<String>,
129    /// The set of include directives implicitly imported in a file
130    /// from other files
131    pub indirect_includes: HashSet<String>,
132    /// The set of macros of a file
133    pub macros: HashSet<String>,
134}
135
136/// Preprocessor data of a series of `C/C++` files.
137#[derive(Debug, Default, Deserialize, Serialize)]
138pub struct PreprocResults {
139    /// The preprocessor data of each `C/C++` file
140    pub files: HashMap<PathBuf, PreprocFile>,
141}
142
143impl PreprocFile {
144    /// Builds a new `PreprocFile` whose macro set contains the given
145    /// macro names (and no includes).
146    #[must_use]
147    pub fn new_macros(macros: &[&str]) -> Self {
148        let mut pf = Self::default();
149        for m in macros {
150            pf.macros.insert((*m).to_string());
151        }
152        pf
153    }
154}
155
156crate::observation::counter!(owned_macro_sets);
157
158/// Returns the macros contained in a `C/C++` file.
159pub fn get_macros<S: ::std::hash::BuildHasher>(
160    file: &Path,
161    files: &HashMap<PathBuf, PreprocFile, S>,
162) -> HashSet<String> {
163    // Counts owned copies of a file's visible macro set. `Parser::new` used
164    // to build one per C/C++ file parsed and then only ever ask it
165    // `contains`; it now borrows through [`visible_macros`], so this stays
166    // at zero across a parse — which a parse's output cannot show.
167    owned_macro_sets::record();
168    visible_macros(file, files)
169        .into_iter()
170        .map(ToOwned::to_owned)
171        .collect()
172}
173
174/// Every macro name visible to `file` — its own `#define`s plus those of
175/// every header it transitively includes — borrowed from `files`.
176///
177/// The crate-internal form of [`get_macros`], which owns the same names
178/// only because its `HashSet<String>` return type is published. Callers
179/// inside the crate just probe the result with `contains`, so borrowing
180/// suffices (issue #1107). Still a *merged* set and not a list of
181/// per-file sets to probe in turn, because it is consulted once per
182/// identifier run in the whole translation unit: an `O(headers)` probe
183/// would cost far more than the one-off merge it saves.
184pub(crate) fn visible_macros<'a, S: ::std::hash::BuildHasher>(
185    file: &Path,
186    files: &'a HashMap<PathBuf, PreprocFile, S>,
187) -> HashSet<&'a str> {
188    let mut macros = HashSet::new();
189    let Some(pf) = files.get(file) else {
190        return macros;
191    };
192    macros.extend(pf.macros.iter().map(String::as_str));
193    for include in &pf.indirect_includes {
194        // `Path::new` re-types the borrowed `str` in place; the
195        // `PathBuf::from` it replaced allocated once per indirect
196        // include, on every parse, purely to bridge to this map's key
197        // type.
198        if let Some(included) = files.get(Path::new(include)) {
199            macros.extend(included.macros.iter().map(String::as_str));
200        }
201    }
202    macros
203}
204
205/// The include dependency graph: nodes are file paths, edges point from a
206/// file to each file it directly includes. SCC replacement nodes carry an
207/// empty [`PathBuf`] as their weight.
208type IncludeGraph = StableGraph<PathBuf, i32>;
209
210/// Returns the graph node for `file`, inserting one (and recording it in
211/// `nodes`) on first lookup so that repeat lookups of the same path return a
212/// stable [`NodeIndex`]. The owned-path call site pays one extra clone here,
213/// which is allocation only and never affects output.
214fn ensure_node(
215    g: &mut IncludeGraph,
216    nodes: &mut HashMap<PathBuf, NodeIndex>,
217    file: &Path,
218) -> NodeIndex {
219    match nodes.entry(file.to_path_buf()) {
220        hash_map::Entry::Occupied(l) => *l.get(),
221        hash_map::Entry::Vacant(p) => *p.insert(g.add_node(file.to_path_buf())),
222    }
223}
224
225/// Resolves an `#include` to a single, deterministic target.
226///
227/// [`guess_file`]'s last-resort `min_distance_candidates` fallback can
228/// return several tied candidates (a basename like `config.h` living in
229/// multiple directories). Adding an edge to *every* tied candidate would
230/// leak macros from unrelated files through [`get_macros`] and make the
231/// resolved set depend on `all_files` Vec ordering. We instead pick the
232/// lexicographically smallest path among the ties — a stable, content-
233/// independent tie-break — and document the choice as best-effort.
234fn resolve_single_include<S: ::std::hash::BuildHasher>(
235    file: &Path,
236    include: &str,
237    all_files: &HashMap<String, Vec<PathBuf>, S>,
238) -> Option<PathBuf> {
239    guess_file(file, include, all_files).into_iter().min()
240}
241
242/// Builds the include dependency graph from the preprocessor data: one node
243/// per file, one edge per resolved direct include. Each include resolves to a
244/// single deterministic target (see [`resolve_single_include`]). Self-
245/// inclusions are reported as a diagnostic and skipped rather than added as
246/// self-edges. Returns the graph, the path→node map, and any diagnostics.
247fn build_include_graph<S: ::std::hash::BuildHasher>(
248    files: &HashMap<PathBuf, PreprocFile, S>,
249    all_files: &HashMap<String, Vec<PathBuf>, S>,
250    diagnostics: &mut Vec<PreprocDiagnostic>,
251) -> (IncludeGraph, HashMap<PathBuf, NodeIndex>) {
252    let mut nodes: HashMap<PathBuf, NodeIndex> = HashMap::new();
253    // Since we'll remove strong connected components we need to have a stable graph
254    // in order to use the nodes we've in the nodes HashMap.
255    let mut g = StableGraph::new();
256
257    for (file, pf) in files {
258        let node = ensure_node(&mut g, &mut nodes, file);
259        for i in &pf.direct_includes {
260            let Some(included) = resolve_single_include(file, i, all_files) else {
261                continue;
262            };
263            if &included == file {
264                diagnostics.push(PreprocDiagnostic::SelfInclusion { file: file.clone() });
265                continue;
266            }
267            let included = ensure_node(&mut g, &mut nodes, &included);
268            g.add_edge(node, included, 0);
269        }
270    }
271
272    (g, nodes)
273}
274
275/// Collects the neighbors of `component` in the given `direction` that lie
276/// outside the component, de-duplicated and in first-seen order. Intra-
277/// component edges are excluded so the replacement node only re-wires the
278/// SCC's external boundary. A `Vec` (not a `HashSet`) suffices: SCCs in real
279/// codebases are few and small, so linear `contains` checks stay cheap.
280fn scc_external_neighbors(
281    g: &IncludeGraph,
282    component: &[NodeIndex],
283    direction: Direction,
284) -> Vec<NodeIndex> {
285    let mut neighbors = Vec::new();
286    for c in component {
287        for n in g.neighbors_directed(*c, direction) {
288            if !component.contains(&n) && !neighbors.contains(&n) {
289                neighbors.push(n);
290            }
291        }
292    }
293    neighbors
294}
295
296/// Replaces every strongly connected component (an include cycle) with a
297/// single replacement node carrying an empty path, re-wiring the component's
298/// external incoming/outgoing edges onto it and rewriting the `nodes` map so
299/// each member path now resolves to the replacement. Returns a map from each
300/// replacement node to the set of member paths it stands in for.
301fn collapse_scc(
302    g: &mut IncludeGraph,
303    nodes: &mut HashMap<PathBuf, NodeIndex>,
304    diagnostics: &mut Vec<PreprocDiagnostic>,
305) -> HashMap<NodeIndex, HashSet<String>> {
306    // In order to walk in the graph without issues due to cycles
307    // we replace strong connected components by a unique node
308    // All the paths in a scc finally represents a kind of unique file containing
309    // all the files in the scc.
310    let mut scc = kosaraju_scc(&*g);
311    let mut scc_map: HashMap<NodeIndex, HashSet<String>> = HashMap::new();
312    for component in &mut scc {
313        // A single-node "component" is not a cycle and needs no replacement.
314        if component.len() > 1 {
315            let (replacement, paths) = collapse_one_component(g, nodes, diagnostics, component);
316            scc_map.insert(replacement, paths);
317        }
318    }
319    scc_map
320}
321
322/// Replace one strongly connected component with a single empty-path node,
323/// re-wiring its external edges and repointing every member's `nodes` entry at
324/// the replacement. Returns the replacement node and its member paths.
325///
326/// Split out of [`collapse_scc`] because that function's whole body was this
327/// operation nested inside a `for` and an `if`; naming the per-component step
328/// leaves the caller reading as "for each cycle, collapse it".
329fn collapse_one_component(
330    g: &mut IncludeGraph,
331    nodes: &mut HashMap<PathBuf, NodeIndex>,
332    diagnostics: &mut Vec<PreprocDiagnostic>,
333    component: &mut Vec<NodeIndex>,
334) -> (NodeIndex, HashSet<String>) {
335    // External boundaries must be captured before the replacement node is
336    // added, so the new node is never mistaken for an external neighbor.
337    let incoming = scc_external_neighbors(g, component, Direction::Incoming);
338    let outgoing = scc_external_neighbors(g, component, Direction::Outgoing);
339    let mut paths = HashSet::new();
340
341    let replacement = g.add_node(PathBuf::from(""));
342    for i in incoming {
343        g.add_edge(i, replacement, 0);
344    }
345    for o in outgoing {
346        g.add_edge(replacement, o, 0);
347    }
348    for c in component.drain(..) {
349        let path = g
350            .remove_node(c)
351            .expect("invariant: SCC component node must exist in graph");
352        if let Some(s) = path.to_str() {
353            paths.insert(s.to_string());
354        } else {
355            diagnostics.push(PreprocDiagnostic::NonUtf8CyclePath {
356                path: path.display().to_string(),
357            });
358        }
359        *nodes
360            .get_mut(&path)
361            .expect("invariant: every graph node must have a nodes map entry") = replacement;
362    }
363
364    // A `HashSet` iterates in an unspecified order; sort the member list so
365    // the emitted diagnostic is deterministic across runs.
366    let mut members: Vec<String> = paths.iter().cloned().collect();
367    members.sort_unstable();
368    diagnostics.push(PreprocDiagnostic::IncludeCycle { members });
369
370    (replacement, paths)
371}
372
373crate::observation::counter!(include_graph_walks);
374
375/// What one include-graph node contributes to every closure that reaches it.
376enum NodeContribution<'a> {
377    /// A decodable path, inserted into the reaching file's
378    /// `indirect_includes`.
379    Path(&'a str),
380    /// A path that is not valid UTF-8: it cannot go into the `String`-keyed
381    /// set, so every file reaching it reports it instead — the same
382    /// once-per-(file, node) multiplicity the per-file walk produced.
383    NonUtf8(&'a Path),
384}
385
386/// Every node's transitive closure over the cycle-free include graph.
387///
388/// `closures[node.index()]` holds sorted, de-duplicated indices into
389/// `entries` for everything reachable from that node, the node itself
390/// included. They are filled in reverse topological order, so each node
391/// merges its successors' finished closures instead of re-walking the graph;
392/// the per-file [`Dfs`] this replaced ran once per file and re-visited every
393/// shared header once per file that reached it (issue #1107).
394struct IncludeClosures<'a> {
395    entries: Vec<NodeContribution<'a>>,
396    closures: Vec<Vec<usize>>,
397}
398
399impl IncludeClosures<'_> {
400    /// Writes the closure of `start` into `x_inc`, reporting the
401    /// undecodable paths it reaches rather than inserting them.
402    fn materialize(
403        &self,
404        start: NodeIndex,
405        x_inc: &mut HashSet<String>,
406        diagnostics: &mut Vec<PreprocDiagnostic>,
407    ) {
408        let Some(ids) = self.closures.get(start.index()) else {
409            return;
410        };
411        // The closure size is known up front, which the walk it replaced
412        // could not know: sizing once skips the repeated rehash a set
413        // growing from empty to ~70 entries would pay.
414        x_inc.reserve(ids.len());
415        for entry in ids.iter().filter_map(|&id| self.entries.get(id)) {
416            match entry {
417                NodeContribution::Path(path) => {
418                    x_inc.insert((*path).to_string());
419                }
420                NodeContribution::NonUtf8(path) => {
421                    diagnostics.push(PreprocDiagnostic::NonUtf8IndirectInclude {
422                        path: path.display().to_string(),
423                    });
424                }
425            }
426        }
427    }
428}
429
430/// Merges two sorted, individually de-duplicated id slices into `out`,
431/// replacing whatever it held — the fold below reuses one scratch
432/// buffer, and appending to a stale one would emit a union that is
433/// neither sorted nor a closure. A linear merge rather than `extend` +
434/// `sort` + `dedup` because the sort's log factor would land on the
435/// *closure* size — exactly what grows on a deep include chain.
436fn merge_sorted_ids(a: &[usize], b: &[usize], out: &mut Vec<usize>) {
437    out.clear();
438    out.reserve(a.len() + b.len());
439    let (mut i, mut j) = (0, 0);
440    while let (Some(&left), Some(&right)) = (a.get(i), b.get(j)) {
441        // The smaller head is emitted once; an equal pair advances both
442        // cursors, which is what de-duplicates across the two inputs.
443        out.push(left.min(right));
444        if left <= right {
445            i += 1;
446        }
447        if right <= left {
448            j += 1;
449        }
450    }
451    out.extend_from_slice(&a[i..]);
452    out.extend_from_slice(&b[j..]);
453}
454
455/// Indexes each node's own contribution as a contiguous range of `entries`.
456/// An SCC replacement node (empty path) stands in for every member of the
457/// cycle and so contributes one entry per member.
458fn index_node_contributions<'a>(
459    g: &'a IncludeGraph,
460    scc_map: &'a HashMap<NodeIndex, HashSet<String>>,
461) -> (Vec<NodeContribution<'a>>, Vec<std::ops::Range<usize>>) {
462    let mut entries = Vec::with_capacity(g.node_count());
463    let mut own = vec![0..0; g.node_bound()];
464    for node in g.node_indices() {
465        let start = entries.len();
466        match g.node_weight(node) {
467            Some(weight) if weight.as_os_str().is_empty() => {
468                if let Some(paths) = scc_map.get(&node) {
469                    entries.extend(paths.iter().map(|p| NodeContribution::Path(p)));
470                }
471            }
472            Some(weight) => entries.push(
473                weight
474                    .to_str()
475                    .map_or_else(|| NodeContribution::NonUtf8(weight), NodeContribution::Path),
476            ),
477            None => {}
478        }
479        own[node.index()] = start..entries.len();
480    }
481    (entries, own)
482}
483
484/// Computes every node's closure in one reverse-topological pass.
485///
486/// Returns `None` when the graph still holds a cycle — which [`collapse_scc`]
487/// has removed by construction, since a node both entering and leaving a
488/// component would itself belong to it. Reporting rather than asserting keeps
489/// a violated assumption a slowdown (the caller falls back to the per-file
490/// walk) rather than a panic or a wrong closure.
491fn compute_include_closures<'a>(
492    g: &'a IncludeGraph,
493    scc_map: &'a HashMap<NodeIndex, HashSet<String>>,
494) -> Option<IncludeClosures<'a>> {
495    let order = toposort(g, None).ok()?;
496    include_graph_walks::record();
497
498    let (entries, own) = index_node_contributions(g, scc_map);
499    let mut closures: Vec<Vec<usize>> = vec![Vec::new(); g.node_bound()];
500    let mut merged = Vec::new();
501    // Reverse topological order: every successor's closure is final by the
502    // time the node that reaches it is merged.
503    for node in order.into_iter().rev() {
504        // A contiguous range is already sorted and unique.
505        let mut acc: Vec<usize> = own[node.index()].clone().collect();
506        for succ in g.neighbors_directed(node, Direction::Outgoing) {
507            merge_sorted_ids(&acc, &closures[succ.index()], &mut merged);
508            std::mem::swap(&mut acc, &mut merged);
509        }
510        closures[node.index()] = acc;
511    }
512    Some(IncludeClosures { entries, closures })
513}
514
515/// Records into every file's `indirect_includes` the transitive closure of
516/// includes reachable from its node. An SCC replacement node (empty path)
517/// contributes every member path it stands in for. Files reachable only
518/// through the graph but never preprocessed are warned about.
519fn record_indirect_includes<S: ::std::hash::BuildHasher>(
520    files: &mut HashMap<PathBuf, PreprocFile, S>,
521    g: &IncludeGraph,
522    nodes: &HashMap<PathBuf, NodeIndex>,
523    scc_map: &HashMap<NodeIndex, HashSet<String>>,
524    diagnostics: &mut Vec<PreprocDiagnostic>,
525) {
526    let precomputed = compute_include_closures(g, scc_map);
527    for (path, start) in nodes {
528        let Some(pf) = files.get_mut(path) else {
529            diagnostics.push(PreprocDiagnostic::NotPreprocessed { file: path.clone() });
530            continue;
531        };
532        if let Some(closures) = &precomputed {
533            closures.materialize(*start, &mut pf.indirect_includes, diagnostics);
534        } else {
535            // Unreachable, hence untested: `collapse_scc` leaves the
536            // graph acyclic. `assert_closures` pins the two agree.
537            accumulate_reachable_includes(
538                g,
539                *start,
540                scc_map,
541                &mut pf.indirect_includes,
542                diagnostics,
543            );
544        }
545    }
546}
547
548/// Walk the include graph from `start`, inserting the transitive closure of
549/// reachable include paths into `x_inc`. An SCC replacement node (empty path)
550/// contributes every member path it stands in for; a non-UTF-8 path is
551/// reported and skipped.
552///
553/// The fallback for a graph [`compute_include_closures`] could not order,
554/// which is why it re-derives the same closure one file at a time.
555fn accumulate_reachable_includes(
556    g: &IncludeGraph,
557    start: NodeIndex,
558    scc_map: &HashMap<NodeIndex, HashSet<String>>,
559    x_inc: &mut HashSet<String>,
560    diagnostics: &mut Vec<PreprocDiagnostic>,
561) {
562    include_graph_walks::record();
563    let mut dfs = Dfs::new(g, start);
564    while let Some(node) = dfs.next(g) {
565        let w = g
566            .node_weight(node)
567            .expect("invariant: DFS-visited node must have weight in graph");
568        if w.as_os_str().is_empty() {
569            let paths = scc_map.get(&node).expect(
570                "every empty-path node is an SCC replacement and must have a scc_map entry",
571            );
572            x_inc.extend(paths.iter().cloned());
573        } else if let Some(s) = w.to_str() {
574            x_inc.insert(s.to_string());
575        } else {
576            diagnostics.push(PreprocDiagnostic::NonUtf8IndirectInclude {
577                path: w.display().to_string(),
578            });
579        }
580    }
581}
582
583/// Constructs a dependency graph of the include directives
584/// in a `C/C++` file.
585///
586/// The dependency graph is built using both preprocessor data and not
587/// extracted from the considered `C/C++` files.
588///
589/// Best-effort include resolution emits non-fatal
590/// [`PreprocDiagnostic`]s (self-inclusions, include cycles, non-UTF-8
591/// paths, files referenced but never preprocessed) as the returned
592/// `Vec` rather than writing to `stderr`, so an embedder can capture or
593/// suppress them. The CLI prints them to `stderr`; callers that do not
594/// care may discard the result.
595///
596/// # Panics
597///
598/// Panics if any of the lockstep invariants between the include graph
599/// `g`, the `nodes` map, and the `scc_map` is violated at runtime —
600/// specifically: an SCC component node missing from the graph, a graph
601/// node weight without a `nodes` map entry, a DFS-visited node without a
602/// stored weight, or an empty-path replacement node without a `scc_map`
603/// entry. These are built in lockstep here, so all four are unrecoverable
604/// programmer errors rather than reachable input failures. The last two
605/// live on the per-file fallback walk, which now runs only when the
606/// precomputed include closures could not order the graph.
607pub fn fix_includes<S: ::std::hash::BuildHasher>(
608    files: &mut HashMap<PathBuf, PreprocFile, S>,
609    all_files: &HashMap<String, Vec<PathBuf>, S>,
610) -> Vec<PreprocDiagnostic> {
611    let mut diagnostics = Vec::new();
612    let (mut g, mut nodes) = build_include_graph(files, all_files, &mut diagnostics);
613    let scc_map = collapse_scc(&mut g, &mut nodes, &mut diagnostics);
614    record_indirect_includes(files, &g, &nodes, &scc_map, &mut diagnostics);
615    // Both producers push while iterating a `HashMap` — `files` in
616    // `build_include_graph`, `nodes` in `record_indirect_includes` — so
617    // without this the *sequence* varies run to run for identical input
618    // even though its content does not. Measured before the sort: 40
619    // distinct orders across 40 runs of one 8-file input, one distinct
620    // set. The CLI prints this Vec straight to stderr, so that surfaced
621    // as `bca preproc` emitting the same warnings in a different order
622    // every time — the #1091 class.
623    //
624    // Note the inner half of this was already fixed: `IncludeCycle`
625    // sorts its own member list "so the emitted diagnostic is
626    // deterministic across runs". Only the outer sequence was left.
627    // `sort_unstable` rather than `sort`: `Ord` and `Eq` are derived from
628    // the same fields, so two diagnostics that compare equal *are* equal
629    // and their relative order is unobservable. That makes the stable
630    // sort's temporary allocation pure cost.
631    diagnostics.sort_unstable();
632    diagnostics
633}
634
635/// Strips the surrounding double quotes from an `#include` `string_literal`
636/// spanning `code[start..end]` and trims leading/trailing whitespace from the
637/// enclosed path.
638///
639/// Returns `None` for any malformed span that cannot hold both quote bytes.
640/// Tree-sitter's error recovery can emit a `string_literal` shorter than the
641/// two surrounding quotes (e.g. a truncated `#include "` with no closing
642/// quote), so the byte span is validated *before* slicing — `end < start + 2`
643/// would otherwise produce a reversed `start + 1..end - 1` range and panic
644/// (issue #432). An empty (`""`), whitespace-only, or non-UTF-8 payload also
645/// yields `None`.
646fn strip_include_quotes(code: &[u8], start: usize, end: usize) -> Option<&str> {
647    // A valid quoted literal needs at least the opening and closing quote.
648    const MIN_QUOTED_LEN: usize = 2;
649    if end < start + MIN_QUOTED_LEN {
650        return None;
651    }
652
653    let inner = &code[start + 1..end - 1];
654    let first = inner.iter().position(|&c| c != b' ' && c != b'\t')?;
655    let last = inner.iter().rposition(|&c| c != b' ' && c != b'\t')?;
656    std::str::from_utf8(&inner[first..=last]).ok()
657}
658
659/// Extracts preprocessor data from a `C/C++` source buffer and inserts
660/// it into a [`PreprocResults`] object.
661///
662/// Builds the preprocessor parse internally, so callers supply the raw
663/// `source` and need not name the parser type. `path` keys the
664/// per-file results.
665pub fn preprocess(source: Vec<u8>, path: &Path, results: &mut PreprocResults) {
666    preprocess_with_parser(&PreprocParser::new(source, path, None), path, results);
667}
668
669/// Walk an already-built [`PreprocParser`] tree, accumulating its
670/// preprocessor data into `results`. Internal core shared by the public
671/// [`preprocess`] seam and the crate's own preprocessor tests.
672pub(crate) fn preprocess_with_parser(
673    parser: &PreprocParser,
674    path: &Path,
675    results: &mut PreprocResults,
676) {
677    let node = parser.root();
678    let mut cursor = node.cursor();
679    let code = parser.code();
680    let mut file_result = PreprocFile::default();
681
682    // The stack-based walk visits siblings in reverse source order, so a
683    // `#define FOO` / `#undef FOO` pair would be observed undef-first.
684    // Collect each directive with its byte offset and replay in source
685    // order afterwards, so `#undef` removes a macro a *preceding*
686    // `#define` introduced — and a `#define` that follows a `#undef`
687    // re-introduces it (issue #705).
688    let mut macro_events: Vec<(usize, MacroEvent)> = Vec::new();
689
690    let mut stack = vec![node];
691    while let Some(node) = stack.pop() {
692        push_children(&mut cursor, &node, &mut stack);
693        classify_preproc_node(
694            &mut cursor,
695            &node,
696            code,
697            &mut file_result,
698            &mut macro_events,
699        );
700    }
701
702    apply_macro_events(macro_events, &mut file_result);
703
704    results.files.insert(path.to_path_buf(), file_result);
705}
706
707/// Push `node`'s children onto `stack` for the stack-based DFS in
708/// [`preprocess_with_parser`]. Children are pushed in source order so they
709/// pop in reverse; directive order is recovered from byte offsets in
710/// [`apply_macro_events`], so visit order does not affect the result.
711fn push_children<'a>(cursor: &mut Cursor<'a>, node: &Node<'a>, stack: &mut Vec<Node<'a>>) {
712    // No reversal, unlike the metric walk's namesake: directives are
713    // collected with their byte offsets and replayed in source order
714    // afterwards (see `macro_events`), so visit order does not matter
715    // here and imposing one would imply a guarantee nothing relies on.
716    stack.extend(node.children_with(cursor));
717}
718
719/// Classify one node from the [`preprocess_with_parser`] walk: a
720/// `#define`/`#undef` is captured as a [`MacroEvent`] tagged with its byte
721/// offset (replayed in source order later), and a quoted `#include` is
722/// recorded directly into `file_result`. All other nodes are ignored.
723///
724/// Takes the walk's shared `cursor` by `&mut` and `reset`s it to reach the
725/// directive's first child, rather than allocating a fresh cursor per node —
726/// the caller is done with `cursor` by the time this runs.
727fn classify_preproc_node<'a>(
728    cursor: &mut Cursor<'a>,
729    node: &Node<'a>,
730    code: &'a [u8],
731    file_result: &mut PreprocFile,
732    macro_events: &mut Vec<(usize, MacroEvent)>,
733) {
734    let id = Preproc::from(node.kind_id());
735    match id {
736        Preproc::Define | Preproc::Undef => {
737            cursor.reset(node);
738            cursor.goto_first_child();
739            let identifier = cursor.node();
740            if identifier.kind_id() == Preproc::Identifier
741                && let Some(macro_text) = identifier.utf8_text(code)
742                && !is_specials(macro_text)
743            {
744                // `#undef` un-defines: a macro is in the final set only if
745                // its last directive was a `#define`.
746                let event = if id == Preproc::Undef {
747                    MacroEvent::Undef(macro_text.to_string())
748                } else {
749                    MacroEvent::Define(macro_text.to_string())
750                };
751                macro_events.push((identifier.start_byte(), event));
752            }
753        }
754        Preproc::PreprocInclude => {
755            cursor.reset(node);
756            cursor.goto_first_child();
757            let file = cursor.node();
758            if file.kind_id() == Preproc::StringLiteral
759                && let Some(include) =
760                    strip_include_quotes(code, file.start_byte(), file.end_byte())
761            {
762                file_result.direct_includes.insert(include.to_string());
763            }
764        }
765        _ => {}
766    }
767}
768
769/// Replay collected `#define`/`#undef` directives in source order so the
770/// final macro set reflects the last directive seen for each name (issue
771/// #705). A stable sort on the byte offset preserves the (already unique)
772/// directive order; ties cannot occur because each identifier starts at a
773/// distinct byte.
774fn apply_macro_events(mut macro_events: Vec<(usize, MacroEvent)>, file_result: &mut PreprocFile) {
775    macro_events.sort_by_key(|(offset, _)| *offset);
776    for (_, event) in macro_events {
777        match event {
778            MacroEvent::Define(name) => {
779                file_result.macros.insert(name);
780            }
781            MacroEvent::Undef(name) => {
782                file_result.macros.remove(&name);
783            }
784        }
785    }
786}
787
788/// A single `#define`/`#undef` directive captured during the AST walk,
789/// replayed in source order so `#undef` removes a previously defined
790/// macro (issue #705).
791enum MacroEvent {
792    /// `#define NAME` — adds NAME to the file's macro set.
793    Define(String),
794    /// `#undef NAME` — removes NAME from the file's macro set.
795    Undef(String),
796}
797
798#[cfg(test)]
799#[path = "preproc_tests.rs"]
800mod tests;