Skip to main content

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