Skip to main content

codehelion_frontend_c/
units.rs

1//! Coarse unit-boundary detection over a C-family token stream.
2//!
3//! Boundaries are the clone-report anchors: function definitions, methods
4//! (functions inside a record body), record bodies (`struct`/`union`/`class`)
5//! and, for C++, block-bodied lambdas. Detection is a delimiter-matching
6//! heuristic over tokens, not a parse; in Fast mode no syntax tree is built
7//! and the preprocessor never runs.
8//!
9//! A function definition is recognised from its body brace: walking backwards
10//! over signature-trailer tokens (`const`, `noexcept`, a trailing return
11//! type, ...) must reach the parameter list's `)`, whose matching `(` must be
12//! preceded by the function's name. Constructor initialiser lists are walked
13//! through entry by entry. The heuristic is deliberately conservative:
14//! constructs a lexer cannot classify with confidence (K&R definitions,
15//! function-try-blocks, functions returning function pointers) are left
16//! un-anchored rather than risking spurious units.
17
18use std::collections::HashMap;
19
20use codehelion_core::frontend::{
21    Diagnostic, DiagnosticKind, SourceSpan, Token, TokenKind, Unit, UnitKind,
22};
23
24use crate::dialect::Dialect;
25
26/// Keywords that may appear between a parameter list and the body brace
27/// (qualifiers and trailing-return-type material).
28const TRAILER_KEYWORDS: &[&str] = &[
29    "const", "volatile", "noexcept", "throw", "mutable", "auto", "decltype", "unsigned", "signed",
30    "long", "short", "int", "char", "float", "double", "bool", "void", "typename", "restrict",
31    "_Atomic",
32];
33
34/// Punctuation that may appear between a parameter list and the body brace.
35const TRAILER_PUNCT: &[&str] = &["::", "<", ">", ">>", "*", "&", "&&", "->"];
36
37/// Tokens that may immediately precede a lambda's `[` (expression position).
38const LAMBDA_PRECEDER_PUNCT: &[&str] = &[
39    "=", "(", ",", "{", ";", ":", "?", "&&", "||", "!", "<", ">", "<<", ">>", "+", "-", "*", "/",
40    "%",
41];
42
43/// Keywords that may immediately precede a lambda's `[`.
44const LAMBDA_PRECEDER_KEYWORDS: &[&str] = &[
45    "return",
46    "co_return",
47    "co_yield",
48    "co_await",
49    "case",
50    "else",
51    "do",
52];
53
54/// Keywords whose parenthesised group belongs to the signature trailer
55/// (`noexcept(...)`, `throw()`, `decltype(...)`), not to the parameter list.
56const TRAILER_GROUP_KEYWORDS: &[&str] = &["noexcept", "throw", "decltype"];
57
58/// Keywords allowed inside a lambda's forward trailer (specifiers and
59/// trailing-return-type material between `)` and `{`).
60const LAMBDA_TRAILER_KEYWORDS: &[&str] = &[
61    "mutable",
62    "noexcept",
63    "constexpr",
64    "consteval",
65    "static",
66    "throw",
67    "decltype",
68    "auto",
69    "const",
70    "unsigned",
71    "signed",
72    "long",
73    "short",
74    "int",
75    "char",
76    "float",
77    "double",
78    "bool",
79    "void",
80    "typename",
81];
82
83/// Maximum tokens one record-declaration lookahead may inspect before
84/// declining an uncertain boundary. This keeps malformed declarations from
85/// making every record keyword scan the rest of the file.
86const MAX_DECLARATION_LOOKAHEAD: usize = 256;
87
88/// Matched delimiter pairs of every kind (`()`, `{}`, `[]`), in both
89/// directions.
90struct DelimPairs {
91    /// Opening token index -> closing token index.
92    close_of: HashMap<usize, usize>,
93    /// Closing token index -> opening token index.
94    open_of: HashMap<usize, usize>,
95}
96
97fn delim_pairs(tokens: &[Token]) -> DelimPairs {
98    let mut close_of = HashMap::new();
99    let mut open_of = HashMap::new();
100    let mut parens = Vec::new();
101    let mut braces = Vec::new();
102    let mut brackets = Vec::new();
103    for (i, token) in tokens.iter().enumerate() {
104        if token.kind != TokenKind::Punctuation {
105            continue;
106        }
107        let (stack, closing) = match token.text.as_str() {
108            "(" | "{" | "[" => {
109                match token.text.as_str() {
110                    "(" => parens.push(i),
111                    "{" => braces.push(i),
112                    _ => brackets.push(i),
113                }
114                continue;
115            }
116            ")" => (&mut parens, i),
117            "}" => (&mut braces, i),
118            "]" => (&mut brackets, i),
119            _ => continue,
120        };
121        if let Some(open) = stack.pop() {
122            close_of.insert(open, closing);
123            open_of.insert(closing, open);
124        }
125    }
126    DelimPairs { close_of, open_of }
127}
128
129/// Detect unit boundaries and recoverable boundary errors under `dialect`.
130#[must_use]
131pub fn detect(tokens: &[Token], dialect: &Dialect) -> (Vec<Unit>, Vec<Diagnostic>) {
132    let pairs = delim_pairs(tokens);
133    let records = record_units(tokens, &pairs, dialect);
134
135    let mut units = records.clone();
136    let mut diagnostics = Vec::new();
137    for (i, token) in tokens.iter().enumerate() {
138        if token.kind == TokenKind::Punctuation && token.text == "{" {
139            if let Some(result) = function_unit(tokens, &pairs, &records, i) {
140                match result {
141                    Ok(unit) => units.push(unit),
142                    Err(span) => diagnostics.push(Diagnostic {
143                        kind: DiagnosticKind::UnmatchedDelimiter,
144                        span,
145                    }),
146                }
147            }
148        }
149        if dialect.lambdas && token.kind == TokenKind::Punctuation && token.text == "[" {
150            if let Some(unit) = lambda_unit(tokens, &pairs, i) {
151                units.push(unit);
152            }
153        }
154    }
155
156    units.sort_by_key(|u| (u.token_start, u.token_end));
157    (units, diagnostics)
158}
159
160/// Record bodies: `struct`/`union` (and for C++ `class`) definitions.
161fn record_units(tokens: &[Token], pairs: &DelimPairs, dialect: &Dialect) -> Vec<Unit> {
162    let mut out = Vec::new();
163    for (i, token) in tokens.iter().enumerate() {
164        if token.kind != TokenKind::Keyword
165            || !dialect.record_keywords.contains(&token.text.as_str())
166        {
167            continue;
168        }
169        if let Some(prev) = i.checked_sub(1).map(|p| &tokens[p]) {
170            // `template<class T>` parameters and `enum class` are not records.
171            if prev.kind == TokenKind::Punctuation && matches!(prev.text.as_str(), "<" | ",") {
172                continue;
173            }
174            if prev.kind == TokenKind::Keyword && prev.text == "enum" {
175                continue;
176            }
177        }
178        let Some(open) = record_body_open(tokens, i + 1) else {
179            continue;
180        };
181        let Some(&close) = pairs.close_of.get(&open) else {
182            continue;
183        };
184        let name = tokens[i + 1..open]
185            .iter()
186            .find(|t| t.kind == TokenKind::Identifier)
187            .map(|t| t.text.to_string());
188        out.push(Unit {
189            kind: UnitKind::Record,
190            name,
191            token_start: i,
192            token_end: close + 1,
193            span: span_of(tokens, i, close),
194        });
195    }
196    out
197}
198
199/// Index of the `{` opening a record body declared at `from`, if any.
200///
201/// A `;` means a forward declaration or a variable of record type; a paren
202/// means the keyword is part of a declarator or expression (`struct Foo
203/// *make(void)`, `sizeof(struct S)`), not a definition.
204fn record_body_open(tokens: &[Token], from: usize) -> Option<usize> {
205    for (offset, token) in tokens[from..]
206        .iter()
207        .take(MAX_DECLARATION_LOOKAHEAD)
208        .enumerate()
209    {
210        if token.kind == TokenKind::Punctuation {
211            match token.text.as_str() {
212                "{" => return Some(from + offset),
213                ";" | "(" | ")" | "=" => return None,
214                _ => {}
215            }
216        }
217    }
218    None
219}
220
221/// Try to recognise the `{` at `body_open` as a function body.
222fn function_unit(
223    tokens: &[Token],
224    pairs: &DelimPairs,
225    records: &[Unit],
226    body_open: usize,
227) -> Option<Result<Unit, SourceSpan>> {
228    // Walk backwards over the signature trailer to the parameter list.
229    let mut j = body_open.checked_sub(1)?;
230    for _ in 0..64 {
231        let token = &tokens[j];
232        match token.kind {
233            TokenKind::Identifier => j = j.checked_sub(1)?,
234            TokenKind::Keyword if TRAILER_KEYWORDS.contains(&token.text.as_str()) => {
235                j = j.checked_sub(1)?;
236            }
237            TokenKind::Punctuation if TRAILER_PUNCT.contains(&token.text.as_str()) => {
238                j = j.checked_sub(1)?;
239            }
240            TokenKind::Punctuation if token.text == ")" => {
241                let &open = pairs.open_of.get(&j)?;
242                // `noexcept(...)` / `throw()` / `decltype(...)` groups belong
243                // to the trailer; skip them whole.
244                if let Some(before) = open.checked_sub(1) {
245                    let b = &tokens[before];
246                    if b.kind == TokenKind::Keyword
247                        && TRAILER_GROUP_KEYWORDS.contains(&b.text.as_str())
248                    {
249                        j = before.checked_sub(1)?;
250                        continue;
251                    }
252                }
253                return resolve_signature(tokens, pairs, records, j, body_open);
254            }
255            // A `}` here can only be the last constructor-initialiser entry
256            // (`: a(x), b{y} {`); the resolver walks the entries.
257            TokenKind::Punctuation if token.text == "}" => {
258                return resolve_signature(tokens, pairs, records, j, body_open);
259            }
260            _ => return None,
261        }
262    }
263    None
264}
265
266/// Resolve the group closing at `close` back to the parameter list and the
267/// function name, walking constructor-initialiser entries in between.
268fn resolve_signature(
269    tokens: &[Token],
270    pairs: &DelimPairs,
271    records: &[Unit],
272    close: usize,
273    body_open: usize,
274) -> Option<Result<Unit, SourceSpan>> {
275    let mut close = close;
276    for _ in 0..32 {
277        let &open = pairs.open_of.get(&close)?;
278        let name_i = open.checked_sub(1)?;
279        let name_token = &tokens[name_i];
280
281        if name_token.kind == TokenKind::Identifier {
282            if let Some(sep_i) = name_i.checked_sub(1) {
283                let sep = &tokens[sep_i];
284                if sep.kind == TokenKind::Punctuation && matches!(sep.text.as_str(), ":" | ",") {
285                    // A constructor-initialiser entry (`name(...)` or
286                    // `name{...}`); the group before the separator is the
287                    // previous entry or the parameter list itself.
288                    let prev_i = sep_i.checked_sub(1)?;
289                    let prev = &tokens[prev_i];
290                    if prev.kind == TokenKind::Punctuation
291                        && matches!(prev.text.as_str(), ")" | "}")
292                    {
293                        close = prev_i;
294                        continue;
295                    }
296                    return None;
297                }
298            }
299            // The parameter list itself must be parenthesised.
300            if tokens[close].text != ")" {
301                return None;
302            }
303            // A bare `MACRO(args) { ... }` is a block-bodied macro
304            // invocation, not a C/C++ function definition. Apply this only
305            // after walking constructor-initialiser entries back to the
306            // actual parameter list.
307            let inside_record = records
308                .iter()
309                .any(|record| record.token_start < name_i && name_i < record.token_end);
310            if !inside_record && !has_declaration_prefix(tokens, name_i) {
311                return None;
312            }
313            let tilde = name_i
314                .checked_sub(1)
315                .is_some_and(|p| tokens[p].kind == TokenKind::Punctuation && tokens[p].text == "~");
316            let unit_start = if tilde { name_i - 1 } else { name_i };
317            return Some(make_function(
318                tokens,
319                pairs,
320                records,
321                unit_start,
322                name_i,
323                name_token.text.to_string(),
324                body_open,
325            ));
326        }
327
328        // Operator overloads: `operator` plus symbol tokens directly before
329        // the parameter list (`operator+`, `operator()`, `operator bool`).
330        if tokens[close].text == ")" {
331            for back in 1..=3 {
332                let Some(k) = open.checked_sub(back) else {
333                    break;
334                };
335                if tokens[k].kind == TokenKind::Keyword && tokens[k].text == "operator" {
336                    return Some(make_function(
337                        tokens,
338                        pairs,
339                        records,
340                        k,
341                        k,
342                        "operator".to_string(),
343                        body_open,
344                    ));
345                }
346            }
347        }
348        return None;
349    }
350    None
351}
352
353/// Whether tokens before a candidate name can introduce a function declarator.
354///
355/// This deliberately only rejects the unqualified, bare identifier form. It
356/// keeps user-defined return types, pointer/reference declarators, qualified
357/// C++ names and destructors available to the conservative boundary finder.
358fn has_declaration_prefix(tokens: &[Token], name_i: usize) -> bool {
359    name_i.checked_sub(1).is_some_and(|previous| {
360        let token = &tokens[previous];
361        matches!(token.kind, TokenKind::Identifier | TokenKind::Keyword)
362            || (token.kind == TokenKind::Punctuation
363                && matches!(token.text.as_str(), "*" | "&" | "&&" | "::" | "~"))
364    })
365}
366
367fn make_function(
368    tokens: &[Token],
369    pairs: &DelimPairs,
370    records: &[Unit],
371    unit_start: usize,
372    name_i: usize,
373    name: String,
374    body_open: usize,
375) -> Result<Unit, SourceSpan> {
376    let end = pairs.close_of.get(&body_open).copied().ok_or_else(|| {
377        // Do not turn a malformed body into a function that extends to EOF.
378        // The caller records this at the Fast frontend's ordinary diagnostic
379        // boundary, where scan summaries already count recovery events.
380        span_of(tokens, body_open, body_open)
381    })?;
382    let inside_record = records
383        .iter()
384        .any(|r| r.token_start < name_i && name_i < r.token_end);
385    let kind = if inside_record {
386        UnitKind::Method
387    } else {
388        UnitKind::Function
389    };
390    Ok(Unit {
391        kind,
392        name: Some(name),
393        token_start: unit_start,
394        token_end: end + 1,
395        span: span_of(tokens, unit_start, end),
396    })
397}
398
399/// Try to recognise the `[` at `i` as the start of a block-bodied lambda.
400fn lambda_unit(tokens: &[Token], pairs: &DelimPairs, i: usize) -> Option<Unit> {
401    if let Some(prev) = i.checked_sub(1).map(|p| &tokens[p]) {
402        let allowed = match prev.kind {
403            TokenKind::Punctuation => LAMBDA_PRECEDER_PUNCT.contains(&prev.text.as_str()),
404            TokenKind::Keyword => LAMBDA_PRECEDER_KEYWORDS.contains(&prev.text.as_str()),
405            _ => false,
406        };
407        if !allowed {
408            return None;
409        }
410    }
411    let &capture_close = pairs.close_of.get(&i)?;
412
413    // Optional parameter list.
414    let mut k = capture_close + 1;
415    if tokens.get(k).is_some_and(|t| t.text == "(") {
416        k = pairs.close_of.get(&k)? + 1;
417    }
418
419    // Forward trailer: specifiers and a trailing return type, then the body.
420    for _ in 0..32 {
421        let token = tokens.get(k)?;
422        match token.kind {
423            TokenKind::Punctuation if token.text == "{" => {
424                let &close = pairs.close_of.get(&k)?;
425                return Some(Unit {
426                    kind: UnitKind::Closure,
427                    name: None,
428                    token_start: i,
429                    token_end: close + 1,
430                    span: span_of(tokens, i, close),
431                });
432            }
433            TokenKind::Punctuation if TRAILER_PUNCT.contains(&token.text.as_str()) => k += 1,
434            TokenKind::Punctuation if token.text == "(" => {
435                // `noexcept(...)` or a parenthesised return-type part.
436                k = pairs.close_of.get(&k)? + 1;
437            }
438            TokenKind::Identifier => k += 1,
439            TokenKind::Keyword if LAMBDA_TRAILER_KEYWORDS.contains(&token.text.as_str()) => k += 1,
440            _ => return None,
441        }
442    }
443    None
444}
445
446/// Build a reporting span covering the inclusive token range `[start, end]`.
447fn span_of(tokens: &[Token], start: usize, end: usize) -> SourceSpan {
448    let first = tokens[start].span;
449    let last = tokens[end].span;
450    SourceSpan {
451        start_byte: first.start_byte,
452        end_byte: last.end_byte,
453        start_line: first.start_line,
454        start_column: first.start_column,
455    }
456}
457
458#[cfg(test)]
459#[allow(clippy::expect_used, clippy::unwrap_used)]
460mod tests {
461    use super::*;
462    use crate::dialect;
463    use crate::lexer::lex;
464
465    fn units_of(source: &str) -> Vec<Unit> {
466        detect(&lex(source, &dialect::C).0, &dialect::C).0
467    }
468
469    #[test]
470    fn detects_a_free_function() {
471        let units = units_of("int add(int a, int b) { return a + b; }");
472        assert_eq!(units.len(), 1);
473        assert_eq!(units[0].kind, UnitKind::Function);
474        assert_eq!(units[0].name.as_deref(), Some("add"));
475    }
476
477    #[test]
478    fn prototypes_are_not_units() {
479        assert!(units_of("int add(int a, int b);").is_empty());
480        assert!(units_of("extern void log_msg(const char *fmt, ...);").is_empty());
481    }
482
483    #[test]
484    fn control_flow_braces_are_not_functions() {
485        let src = "void f(int n) { if (n) { g(); } while (n--) { h(); } \
486                   for (;;) { break; } switch (n) { default: break; } do { i(); } while (0); }";
487        let units = units_of(src);
488        assert_eq!(units.len(), 1, "only `f` itself: {units:#?}");
489        assert_eq!(units[0].name.as_deref(), Some("f"));
490    }
491
492    #[test]
493    fn pointer_returning_and_static_functions_are_detected() {
494        let units = units_of("static const char *dup(const char *s) { return s; }");
495        assert_eq!(units.len(), 1);
496        assert_eq!(units[0].kind, UnitKind::Function);
497        assert_eq!(units[0].name.as_deref(), Some("dup"));
498    }
499
500    #[test]
501    fn struct_definitions_are_records_but_declarators_are_not() {
502        let units = units_of("struct point { int x; int y; };");
503        assert_eq!(units.len(), 1);
504        assert_eq!(units[0].kind, UnitKind::Record);
505        assert_eq!(units[0].name.as_deref(), Some("point"));
506
507        // `struct` in a return type must not produce a record unit.
508        let units = units_of("struct point *make(void) { return 0; }");
509        assert_eq!(units.len(), 1, "{units:#?}");
510        assert_eq!(units[0].kind, UnitKind::Function);
511        assert_eq!(units[0].name.as_deref(), Some("make"));
512    }
513
514    #[test]
515    fn anonymous_typedef_struct_is_a_record_without_a_name() {
516        let units = units_of("typedef struct { int a; } pair;");
517        assert_eq!(units.len(), 1);
518        assert_eq!(units[0].kind, UnitKind::Record);
519        // The first identifier before the brace names the record; an
520        // anonymous struct has none.
521        assert_eq!(units[0].name, None);
522    }
523
524    #[test]
525    fn function_like_macro_bodies_do_not_produce_units() {
526        // The whole definition is a directive, dropped before unit detection.
527        let units = units_of("#define ADD(a, b) ((a) + (b))\n");
528        assert!(units.is_empty());
529    }
530
531    #[test]
532    fn block_bodied_macro_invocations_are_not_function_units() {
533        for invocation in [
534            "TEST_F(QueueTest, Pushes) { ASSERT_TRUE(1); }",
535            "list_for_each(node, head) { visit(node); }",
536            "TAILQ_FOREACH(entry, queue, links) { consume(entry); }",
537        ] {
538            assert!(units_of(invocation).is_empty(), "{invocation}");
539        }
540    }
541
542    #[test]
543    fn initializer_braces_are_not_functions() {
544        assert!(units_of("int a[] = {1, 2, 3};").is_empty());
545        assert!(
546            units_of("struct p q = {1, 2};")
547                .iter()
548                .all(|u| u.kind != UnitKind::Function)
549        );
550    }
551
552    #[test]
553    fn a_units_token_range_covers_its_body() {
554        let src = "int f(void) { return 1; }";
555        let tokens = lex(src, &dialect::C).0;
556        let (units, diagnostics) = detect(&tokens, &dialect::C);
557        assert!(diagnostics.is_empty());
558        let f = &units[0];
559        assert_eq!(tokens[f.token_end - 1].text, "}");
560        assert_eq!(tokens[f.token_start].text, "f");
561    }
562
563    #[test]
564    fn record_declaration_lookahead_is_bounded() {
565        let source = format!(
566            "struct {} {{ int value; }};",
567            "field ".repeat(MAX_DECLARATION_LOOKAHEAD)
568        );
569        let tokens = lex(&source, &dialect::C).0;
570        assert_eq!(record_body_open(&tokens, 1), None);
571    }
572
573    #[test]
574    fn an_unclosed_function_body_is_not_stretched_to_end_of_file() {
575        let tokens = lex("int tail(void) { int value = 1;", &dialect::C).0;
576        let (units, diagnostics) = detect(&tokens, &dialect::C);
577
578        assert!(units.is_empty());
579        assert_eq!(diagnostics.len(), 1);
580        assert_eq!(diagnostics[0].kind, DiagnosticKind::UnmatchedDelimiter);
581    }
582}