Skip to main content

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}