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