codehelion_core/conditional.rs
1//! Which arm of a preprocessor conditional a unit sits in.
2//!
3//! C and C++ sources are parsed unexpanded, so both arms of an `#if` are in
4//! the IR at once. That is deliberate — the mode resolves no build conditions,
5//! and dropping the arm a default configuration would not take would silently
6//! hide code. It has a consequence for clone detection: the two arms of
7//!
8//! ```c
9//! #ifdef _WIN32
10//! void sleep_ms(int ms) { Sleep(ms); }
11//! #else
12//! void sleep_ms(int ms) { usleep(ms * 1000); }
13//! #endif
14//! ```
15//!
16//! measure as near-identical, and a report that calls them duplicates is
17//! telling the reader to remove one. They cannot: exactly one of them exists
18//! in any build, and which one is a build condition this mode does not
19//! resolve. Reporting the pair would also put two build variants in one
20//! finding, which the analysis does not do anywhere else.
21//!
22//! So the relation is recorded here and the pair is dropped before
23//! verification, exactly as a unit nested inside another is: not because the
24//! finding would rank badly, but because it is not a statement about any one
25//! program.
26//!
27//! # What this does not claim
28//!
29//! Only *syntactic* exclusion is recognised: two units under arms of the same
30//! conditional. Two units guarded by separate `#if`s that happen to be
31//! mutually exclusive — `#ifdef A` here and `#ifndef A` there — are not
32//! related by this, because relating them means evaluating the conditions, and
33//! the conditions are what this mode does not have.
34//!
35//! # Only as good as the parse
36//!
37//! The arms are read off the tree, so a conditional the parser could not
38//! follow has no trustworthy arms. That is not hypothetical: a C++ header
39//! parsed by the C grammar — which happens whenever a project puts C++ in `.h`
40//! and the header policy says C — recovers into a shape where one `#if`
41//! swallows the rest of the file and its `#else` holds everything after it.
42//! Measured on one such header, that turned ten genuine arm pairs into eight
43//! hundred.
44//!
45//! Dropping a pair hides a finding, so the mistake is not symmetric: a missed
46//! exclusion costs a noisy line in a report, an invented one costs a clone
47//! nobody will ever see. A conditional is therefore only believed when the
48//! parser stumbled nowhere inside it; one that encloses an error region still
49//! nests, but relates none of the units under it.
50//!
51//! The judgement is per conditional rather than per file because error
52//! recovery is not local to what broke. A single unparsable construct puts an
53//! error region in the file, and a header whose include guard encloses
54//! everything gets one spanning the whole of it, which says nothing about the
55//! `#if` twenty lines further down. Measured across three C++ projects,
56//! believing a whole file only when it is error-free left 69% to 77% of the
57//! arms the parser had in fact read cleanly unused.
58
59use crate::ir::{IrNode, Shape};
60
61/// One conditional a unit is inside, and which of its arms.
62///
63/// Arms are numbered in source order: the `#if` is 0, the first `#elif` is 1,
64/// and so on to the `#else`.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66struct Arm {
67 /// Identifies the conditional, unique across the whole analysed corpus.
68 conditional: u32,
69 /// Which arm of it, in source order. Stays 0 for every arm of a
70 /// conditional the parser stumbled inside, so that none of them is taken
71 /// to differ from another.
72 index: u32,
73 /// Whether the parse of this conditional is worth believing.
74 believed: bool,
75 /// Whether this arm can be reached without evaluating an unknown
76 /// condition. A literal `#if 0` is not source code any build can hold.
77 reachable: bool,
78}
79
80/// The conditionals enclosing a unit, outermost first.
81///
82/// Empty for the overwhelming majority of units, which sit under no
83/// conditional at all, and empty for every Rust unit.
84#[derive(Debug, Clone, Default, PartialEq, Eq)]
85pub struct ArmPath {
86 arms: Vec<Arm>,
87}
88
89/// A literal condition that lexical preprocessing can establish without
90/// expanding macros or evaluating an expression.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum StaticCondition {
93 /// The condition is literally false.
94 False,
95 /// The condition is literally true.
96 True,
97 /// Evaluating the condition would require preprocessing context.
98 Unknown,
99}
100
101/// Tracks the lexical arm active at each token in a C-family source file.
102///
103/// This deliberately recognises only directive nesting and literal `0` / `1`
104/// conditions. It neither expands macros nor chooses an unknown arm; callers
105/// use the resulting paths solely to avoid comparing two arms that cannot
106/// coexist in one build.
107#[derive(Debug, Default)]
108pub struct ArmTracker {
109 path: ArmPath,
110 definitely_taken: Vec<bool>,
111 next: u32,
112}
113
114impl ArmTracker {
115 /// Start a preprocessor conditional and enter its first arm.
116 pub fn begin(&mut self, condition: StaticCondition) {
117 let reachable = condition != StaticCondition::False;
118 self.path.arms.push(Arm {
119 conditional: self.next,
120 index: 0,
121 believed: true,
122 reachable,
123 });
124 self.next = self.next.wrapping_add(1);
125 self.definitely_taken
126 .push(condition == StaticCondition::True);
127 }
128
129 /// Advance to an `#elif` or `#else` arm.
130 ///
131 /// A branch after a literal true arm is unreachable. Otherwise a literal
132 /// false `#elif` is unreachable while an unknown condition remains
133 /// possible, which is the conservative result without preprocessing.
134 pub fn next_arm(&mut self, condition: StaticCondition) {
135 let (Some(arm), Some(taken)) =
136 (self.path.arms.last_mut(), self.definitely_taken.last_mut())
137 else {
138 return;
139 };
140 arm.index = arm.index.saturating_add(1);
141 arm.reachable = !*taken && condition != StaticCondition::False;
142 if condition == StaticCondition::True {
143 *taken = true;
144 }
145 }
146
147 /// Leave the innermost preprocessor conditional.
148 pub fn end(&mut self) {
149 let _ = self.path.arms.pop();
150 let _ = self.definitely_taken.pop();
151 }
152
153 /// Return the arm path active for the next lexical token.
154 #[must_use]
155 pub fn current(&self) -> ArmPath {
156 self.path.clone()
157 }
158}
159
160impl ArmPath {
161 /// The path that applies inside `node`, or `None` when `node` leaves it
162 /// unchanged — which is every node but a conditional's own.
163 ///
164 /// `next` hands out conditional identifiers and must be shared across
165 /// every file in a run, so that two files' conditionals never collide.
166 ///
167 /// Arms nest rather than sit side by side: the grammar puts a `#elif` and
168 /// its `#else` inside the arm they follow, so entering one continues the
169 /// conditional already open instead of starting another. A conditional the
170 /// parser stumbled inside is entered too — it has to be, or a `#else`
171 /// under it would advance the arm of the conditional above — but it is
172 /// entered unbelieved, and none of its arms is distinguished from another.
173 #[must_use]
174 pub fn descend(&self, node: &IrNode, next: &mut u32) -> Option<Self> {
175 let Shape::Native(kind) = &node.shape else {
176 return None;
177 };
178 let mut arms = self.arms.clone();
179 match &**kind {
180 "preproc_if" | "preproc_ifdef" => {
181 arms.push(Arm {
182 conditional: *next,
183 index: 0,
184 believed: !stumbled_inside(node),
185 reachable: true,
186 });
187 *next = next.wrapping_add(1);
188 }
189 "preproc_elif" | "preproc_elifdef" | "preproc_elifndef" | "preproc_else" => {
190 // A branch keyword outside any conditional is malformed input
191 // the error-tolerant parser still hands over; there is no arm
192 // to advance, so the path stays as it was.
193 let arm = arms.last_mut()?;
194 if !arm.believed {
195 return None;
196 }
197 arm.index += 1;
198 }
199 _ => return None,
200 }
201 Some(Self { arms })
202 }
203
204 /// Whether the two units can never both be part of one build.
205 ///
206 /// True when the paths agree down to some conditional and then take
207 /// different arms of it. Diverging on *different* conditionals says
208 /// nothing: those are two independent guards, and both can hold. An
209 /// unbelieved conditional never separates anything: its arms all carry the
210 /// same index, so two units under it agree there and the comparison
211 /// continues into whatever nests below.
212 #[must_use]
213 pub fn excludes(&self, other: &Self) -> bool {
214 self.arms
215 .iter()
216 .zip(&other.arms)
217 .find(|(a, b)| a != b)
218 .is_some_and(|(a, b)| a.believed && b.believed && a.conditional == b.conditional)
219 }
220
221 /// Whether this path contains code no build can reach without evaluating
222 /// an unknown condition.
223 #[must_use]
224 pub fn is_unreachable(&self) -> bool {
225 self.arms.iter().any(|arm| !arm.reachable)
226 }
227}
228
229/// Whether the parser stumbled anywhere inside `node`.
230///
231/// Recovered or not: the question here is whether the tree under this
232/// conditional is the shape the source has, and a region the parser had to
233/// recover from is a region whose arm boundaries it may have placed wrong.
234/// That is a different question from how much code a parse lost, which
235/// [`SyntaxIrFile::unaccounted_tokens`](crate::ir::SyntaxIrFile::unaccounted_tokens)
236/// answers and which error regions measure badly.
237fn stumbled_inside(node: &IrNode) -> bool {
238 let mut stumbled = false;
239 node.walk(&mut |inner| stumbled |= matches!(inner.shape, Shape::Error));
240 stumbled
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used)]
245mod tests {
246 use super::*;
247 use crate::frontend::Lexeme;
248 use crate::ir::ByteRange;
249
250 fn node(shape: Shape, children: Vec<IrNode>) -> IrNode {
251 IrNode {
252 shape,
253 name: None,
254 token_start: 0,
255 token_end: 0,
256 range: ByteRange { start: 0, end: 0 },
257 children,
258 }
259 }
260
261 /// A conditional the parser read without stumbling.
262 fn native(kind: &str) -> IrNode {
263 node(Shape::Native(Lexeme::from(kind)), Vec::new())
264 }
265
266 /// The same, with an error region somewhere inside it.
267 fn broken(kind: &str) -> IrNode {
268 node(
269 Shape::Native(Lexeme::from(kind)),
270 vec![node(Shape::Error, Vec::new())],
271 )
272 }
273
274 /// Walk a chain of shapes from the file root, returning the path inside
275 /// the last one.
276 fn path(kinds: &[&str]) -> ArmPath {
277 let mut next = 0;
278 let mut here = ArmPath::default();
279 for kind in kinds {
280 if let Some(descended) = here.descend(&native(kind), &mut next) {
281 here = descended;
282 }
283 }
284 here
285 }
286
287 #[test]
288 fn a_shape_that_is_not_a_conditional_changes_nothing() {
289 let mut next = 0;
290 assert_eq!(
291 ArmPath::default().descend(&node(Shape::Function, Vec::new()), &mut next),
292 None
293 );
294 assert_eq!(
295 ArmPath::default().descend(&native("goto_statement"), &mut next),
296 None
297 );
298 assert_eq!(next, 0, "no identifier is spent on an ordinary shape");
299 }
300
301 #[test]
302 fn the_two_arms_of_one_conditional_exclude_each_other() {
303 let mut next = 0;
304 let root = ArmPath::default();
305 let taken = root.descend(&native("preproc_ifdef"), &mut next).unwrap();
306 let otherwise = taken.descend(&native("preproc_else"), &mut next).unwrap();
307 assert!(taken.excludes(&otherwise));
308 assert!(otherwise.excludes(&taken));
309 }
310
311 #[test]
312 fn every_arm_of_a_chain_excludes_every_other() {
313 let mut next = 0;
314 let root = ArmPath::default();
315 let first = root.descend(&native("preproc_if"), &mut next).unwrap();
316 let second = first.descend(&native("preproc_elif"), &mut next).unwrap();
317 let third = second.descend(&native("preproc_else"), &mut next).unwrap();
318 for (a, b) in [(&first, &second), (&first, &third), (&second, &third)] {
319 assert!(a.excludes(b));
320 assert!(b.excludes(a));
321 }
322 }
323
324 #[test]
325 fn a_unit_outside_every_conditional_excludes_nothing() {
326 let outside = ArmPath::default();
327 let guarded = path(&["preproc_ifdef"]);
328 assert!(!outside.excludes(&guarded));
329 assert!(!guarded.excludes(&outside));
330 assert!(!outside.excludes(&ArmPath::default()));
331 }
332
333 #[test]
334 fn two_separate_conditionals_do_not_exclude_each_other() {
335 // `#ifdef A ... #endif` and `#ifdef B ... #endif` side by side. Both
336 // can hold, and deciding otherwise means reading the conditions.
337 let mut next = 0;
338 let root = ArmPath::default();
339 let here = root.descend(&native("preproc_ifdef"), &mut next).unwrap();
340 let there = root.descend(&native("preproc_ifdef"), &mut next).unwrap();
341 assert!(!here.excludes(&there));
342 assert!(!there.excludes(&here));
343 }
344
345 #[test]
346 fn exclusion_survives_further_nesting() {
347 // One arm holds a nested conditional; a unit deep inside it is still
348 // excluded by anything under the sibling arm.
349 let mut next = 0;
350 let root = ArmPath::default();
351 let taken = root.descend(&native("preproc_if"), &mut next).unwrap();
352 let deep = taken.descend(&native("preproc_ifdef"), &mut next).unwrap();
353 let otherwise = taken.descend(&native("preproc_else"), &mut next).unwrap();
354 assert!(deep.excludes(&otherwise));
355 assert!(otherwise.excludes(&deep));
356 // But not by something under the same arm as it.
357 assert!(!deep.excludes(&taken));
358 }
359
360 #[test]
361 fn a_branch_keyword_with_no_conditional_open_is_survivable() {
362 // The parser is error-tolerant, so a stray `#else` reaches here.
363 let mut next = 0;
364 assert_eq!(
365 ArmPath::default().descend(&native("preproc_else"), &mut next),
366 None
367 );
368 }
369
370 #[test]
371 fn a_conditional_the_parser_stumbled_inside_relates_nothing() {
372 let mut next = 0;
373 let root = ArmPath::default();
374 let taken = root.descend(&broken("preproc_if"), &mut next).unwrap();
375 let otherwise = taken.descend(&native("preproc_else"), &mut next);
376 // The arms are not told apart, so nothing under this conditional is
377 // taken to rule anything else out.
378 assert_eq!(otherwise, None);
379 assert!(!taken.excludes(&root));
380 assert!(!root.excludes(&taken));
381 }
382
383 #[test]
384 fn a_sound_conditional_inside_a_broken_one_still_relates_its_own_arms() {
385 // The outer `#if` is unreadable, which says nothing about an inner one
386 // the parser followed. Entering the outer one is still necessary: the
387 // inner arms must not be mistaken for the outer's.
388 let mut next = 0;
389 let outer = ArmPath::default()
390 .descend(&broken("preproc_if"), &mut next)
391 .unwrap();
392 let inner = outer.descend(&native("preproc_ifdef"), &mut next).unwrap();
393 let otherwise = inner.descend(&native("preproc_else"), &mut next).unwrap();
394 assert!(inner.excludes(&otherwise));
395 assert!(!inner.excludes(&outer));
396 }
397
398 #[test]
399 fn an_else_under_a_broken_conditional_leaves_the_sound_one_above_alone() {
400 // Without the unbelieved level in between, this `#else` would advance
401 // the arm of the conditional above it and invent an exclusion.
402 let mut next = 0;
403 let outer = ArmPath::default()
404 .descend(&native("preproc_if"), &mut next)
405 .unwrap();
406 let broken_inner = outer.descend(&broken("preproc_if"), &mut next).unwrap();
407 assert_eq!(
408 broken_inner.descend(&native("preproc_else"), &mut next),
409 None
410 );
411 assert!(!broken_inner.excludes(&outer));
412 }
413
414 #[test]
415 fn an_error_beside_a_conditional_does_not_touch_it() {
416 // Error regions are routine — one bad construct anywhere in a header
417 // puts one in the file — so soundness is asked of the conditional
418 // itself, not of everything around it.
419 let mut next = 0;
420 let file = node(
421 Shape::Impl,
422 vec![node(Shape::Error, Vec::new()), native("preproc_if")],
423 );
424 let opener = file.children.last().unwrap();
425 let taken = ArmPath::default().descend(opener, &mut next).unwrap();
426 let otherwise = taken.descend(&native("preproc_else"), &mut next).unwrap();
427 assert!(taken.excludes(&otherwise));
428 }
429}