diff --git a/crates/ruff_cpp_spo/src/events.rs b/crates/ruff_cpp_spo/src/events.rs
index 0c38982ab2062..27c21316af6ab 100644
--- a/crates/ruff_cpp_spo/src/events.rs
+++ b/crates/ruff_cpp_spo/src/events.rs
@@ -45,10 +45,12 @@
//!
//! There is **no CFG** in libclang's C API. `control` therefore carries only
//! structurally-certain relations (which construct a condition belongs to,
-//! which arm a branch is, and a loop's back edge). Successor/predecessor
-//! edges are not available and are never fabricated. Every event also carries
-//! its source byte `anchor`, so lexical order stays checkable against
-//! traversal order rather than being silently conflated with it.
+//! which arm a branch is, a loop's back edge, and — for `for` headers whose
+//! own tokens spell exactly two semicolons — which of init/cond/inc a header
+//! child belongs to). Successor/predecessor edges are not available and are
+//! never fabricated. Every event also carries its source byte `anchor`, so
+//! lexical order stays checkable against traversal order rather than being
+//! silently conflated with it.
use std::collections::BTreeMap;
use std::fmt;
@@ -390,6 +392,88 @@ impl<'s> Walk<'s> {
.unwrap_or(0)
}
+ /// A `for` statement's HEADER tokens — the range from the `for` keyword
+ /// to the body's first token, never the body itself (tokenizing the whole
+ /// `ForStmt` extent re-lexes every nested loop's body once per ancestor,
+ /// quadratic in nesting depth) — walked with a paren-depth counter
+ /// starting at 0 (the header's own `(` takes it to 1) and a brace-depth
+ /// counter, to find the byte offsets of the two `;` tokens that separate
+ /// init/cond/inc. Only `Punctuation`-kind tokens are inspected, so a `;`
+ /// lexed inside a string or character literal (always one token, never
+ /// split) can never be mistaken for a header separator; a `;` inside a
+ /// braced expression in the header (a lambda body in `init`) is a
+ /// statement terminator at brace depth ≥ 1 and is skipped; a nested
+ /// call's own parens in `cond` or `inc` (`pred(i)`) never confuse the
+ /// paren count — the walk stops at the `)` that returns it to 0.
+ ///
+ /// Both endpoints are materialised as FILE locations in the `for`
+ /// keyword's own file (`File::get_offset_location`), never reused as the
+ /// raw cursor locations. A body that is itself a macro invocation
+ /// (`for (...) LOG(ERROR) << x;`) carries a macro-expansion location, and
+ /// a range that ends in one is degenerate: libclang tokenizes it to a
+ /// single token, so every slot tag on an otherwise ordinary header is
+ /// silently dropped. That false negative untagged 32 real loops across
+ /// re2 and miniz before it was measured.
+ ///
+ /// Materialising the range is also what rejects a loop emitted whole by a
+ /// function-like macro (`#define LOOP(I, C, N) for (I; C; N)`): the range
+ /// then spans the CALL SITE (`LOOP(int i = 0, i < n_, ++i)`), whose tokens
+ /// carry no `;` at header depth at all, so the count is not two and the
+ /// header is rejected outright rather than tagged wrongly. Reading each
+ /// separator's own file location instead would read the macro
+ /// DEFINITION's offsets and file all three slots under `for_inc`.
+ ///
+ /// `Some((s1, s2))` only when EXACTLY two such semicolons are found: a
+ /// `for(init; cond; inc)` header, whichever parts are elided. Anything
+ /// else (a macro-spelled header, a tokenize failure) yields `None` —
+ /// callers must never guess a slot from a partial count.
+ fn for_header_semicolons(e: &Entity<'_>) -> Option<(u32, u32)> {
+ let range = e.get_range()?;
+ let body = e.get_children().into_iter().last()?;
+ let head = range.get_start().get_file_location();
+ let tail = body.get_range()?.get_start().get_file_location();
+ let file = head.file?;
+ // Well-formedness of the range handed to libclang, not a behavioural
+ // guard: no fixture reaches it, because a header that would violate it
+ // (both `for` and body emitted by one macro, so the two cursor extents
+ // normalise to the same expansion offset) already fails the
+ // exactly-two-semicolons rule below.
+ if tail.file != Some(file) || tail.offset <= head.offset {
+ return None;
+ }
+ let header = clang::source::SourceRange::new(
+ file.get_offset_location(head.offset),
+ file.get_offset_location(tail.offset),
+ );
+ let mut paren = 0i32;
+ let mut brace = 0i32;
+ let mut semis = Vec::new();
+ for t in &header.tokenize() {
+ if t.get_kind() != clang::token::TokenKind::Punctuation {
+ continue;
+ }
+ match t.get_spelling().as_str() {
+ "(" => paren += 1,
+ ")" => {
+ paren -= 1;
+ if paren == 0 && brace == 0 {
+ break;
+ }
+ }
+ "{" => brace += 1,
+ "}" => brace -= 1,
+ ";" if paren == 1 && brace == 0 => {
+ semis.push(t.get_location().get_file_location().offset);
+ }
+ _ => {}
+ }
+ }
+ match semis[..] {
+ [s1, s2] => Some((s1, s2)),
+ _ => None,
+ }
+ }
+
fn push(&mut self, kind: EventKind, e: &Entity<'_>) -> usize {
let seq = u32::try_from(self.events.len()).unwrap_or(u32::MAX);
self.events.push(OreEvent {
@@ -505,6 +589,17 @@ impl<'s> Walk<'s> {
_ => (ScopeKind::RangeFor, "rangefor"),
};
let is_do = sk == ScopeKind::Do;
+ // For `for` ONLY, the header's parts ARE now individually
+ // distinguished — via real tokens, not a guess. See
+ // `for_header_semicolons`: `Some((s1, s2))` gives the two
+ // semicolon offsets a `for(init; cond; inc)` header spells;
+ // `None` (a macro-spelled header, or tokenize failure) tags
+ // nothing, leaving this arm's behaviour byte-identical.
+ // `while` / `do` / range-`for` headers are UNCHANGED — their
+ // parts stay undistinguished exactly as before.
+ let for_slots = (sk == ScopeKind::For)
+ .then(|| Self::for_header_semicolons(e))
+ .flatten();
self.scoped(sk, e, |w| {
// libclang labels no sub-part of a loop header, and a
// `for(;;)` simply has fewer children — so for `for` /
@@ -514,9 +609,15 @@ impl<'s> Walk<'s> {
// FIRST and its condition is evaluated after the body, so
// treating the last child as the body would walk the
// condition as a statement and open a spurious Block
- // around the real one. The header's parts are not
- // individually distinguished; `anchor` keeps their
- // source positions.
+ // around the real one. `anchor` keeps every head child's
+ // source position regardless of loop shape; for `for`
+ // specifically, `for_slots` (computed above, once) places
+ // each head child in `for_init` / `for_cond` / `for_inc`
+ // by comparing that anchor against the header's real
+ // semicolon offsets — additive: it only ever fills an
+ // event's `control` when that event's `control` is still
+ // `None`, so a nested scope marker, branch tag, or
+ // `back_edge` an inner walk already stamped is untouched.
let ch = e.get_children();
if is_do {
if let Some((body, rest)) = ch.split_first() {
@@ -530,7 +631,22 @@ impl<'s> Walk<'s> {
} else {
let (head, body) = ch.split_at(ch.len().saturating_sub(1));
for c in head {
+ let before = w.events.len();
w.node(c);
+ if let Some((s1, s2)) = for_slots {
+ let slot = if Self::anchor(c) < s1 {
+ "for_init"
+ } else if Self::anchor(c) < s2 {
+ "for_cond"
+ } else {
+ "for_inc"
+ };
+ for ev in &mut w.events[before..] {
+ if ev.control.is_none() {
+ ev.control = Some(slot.to_string());
+ }
+ }
+ }
}
let i = w.push(EventKind::Condition, e);
w.events[i].control = Some(tag.to_string());
@@ -1529,4 +1645,402 @@ void C::f(B* p) { p->v(); p->nv(); }",
"and the non-virtual one distinguished: {calls:?}"
);
}
+
+ /// Fixture for the `for` header's slot tags. One class, ten methods —
+ /// every header shape the semicolon rule must handle, plus the two loop
+ /// kinds (`while`, range-`for`) it must never touch.
+ const FOR_SLOTS: &str = r#"
+struct S {
+ int n_; int acc_; int arr_[8];
+ void full() { for (int i = 0; i < n_; ++i) { acc_ += arr_[i]; } }
+ void bare() { for (;;) { if (acc_ > n_) break; acc_++; } }
+ void no_cond(){ for (int i = 0;; ++i) { if (i > n_) break; acc_ += i; } }
+ void no_inc() { for (int i = 0; i < n_;) { acc_ += i; i += 2; } }
+ void comma() { for (int i = 0, j = n_; i < j; ++i, --j) { acc_ += arr_[i]; } }
+ bool pred(int x) const { return x < n_; }
+ void call_cond() { for (int i = 0; pred(i); ++i) { acc_ += i; } }
+ void str_semi() { const char* s = "a;b"; for (int i = 0; s[i] != ';'; ++i) { acc_++; } }
+ void wloop() { int i = 0; while (i < n_) { acc_ += i; ++i; } }
+ void rloop() { for (int v : arr_) { acc_ += v; } }
+};"#;
+
+ /// The `subj`'s interned name, resolved through `syms` — the same
+ /// join every other test here does by hand.
+ fn subj_name<'a>(syms: &'a Symbols, e: &OreEvent) -> Option<&'a str> {
+ e.subj
+ .as_deref()
+ .and_then(|id| syms.rows().find(|r| r.0 == id))
+ .map(|r| r.3)
+ }
+
+ /// `full`: a complete `for(init; cond; inc)` header. Every part is
+ /// present, so this is the one fixture where all three slots — and the
+ /// synthetic `Condition` marker's position relative to them — can be
+ /// checked at once.
+ #[test]
+ fn for_full_header_slots_are_tagged_from_real_tokens() {
+ let (ms, syms) = ore("for_full", FOR_SLOTS);
+ let m = find(&ms, "S.full()");
+
+ // init: the Decl for `i`, and nothing else.
+ let init: Vec<&OreEvent> = m
+ .events
+ .iter()
+ .filter(|e| e.control.as_deref() == Some("for_init"))
+ .collect();
+ assert_eq!(init.len(), 1, "exactly the Decl for i: {init:?}");
+ assert_eq!(init[0].kind, EventKind::Decl);
+ assert_eq!(subj_name(&syms, init[0]), Some("i"));
+
+ // cond: the Reads of `i` and `n_` in `i < n_`.
+ let cond: Vec<&OreEvent> = m
+ .events
+ .iter()
+ .filter(|e| e.control.as_deref() == Some("for_cond"))
+ .collect();
+ for e in &cond {
+ assert_eq!(e.kind, EventKind::Read, "cond here is two plain reads");
+ }
+ let mut cond_names: Vec