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