Skip to main content

brokk_bifrost_cpp/
raii.rs

1//! Provable temporary-free classification of C++ call expressions.
2//!
3//! The C++ lowering marks a callable as having RAII boundaries when its body
4//! contains any call expression, because a call can materialize a class-typed
5//! temporary whose destructor runs at the end of the full expression. That
6//! over-approximation is honest for unknown callees, but it is refutable for
7//! an exact local free-function call whose signature proves that neither the
8//! returned value nor any parameter conversion can materialize an automatic
9//! object that needs destruction (#1984, under #1951).
10//!
11//! [`CppTemporaryFreeCallIndex`] holds the per-file proof. It stays strictly
12//! conservative: any preprocessor content, any second declaration of the
13//! name, any non-callee use of the name (function pointers, address-taking,
14//! shadowing locals, member declarations), templates, overloads, virtual or
15//! member call syntax, default arguments, variadic parameters, and non-trivial
16//! argument expressions all keep the call unproven, so the RAII gap stays.
17
18use tree_sitter::Node;
19
20use crate::declarations::node_text;
21use brokk_bifrost_core::hash::{HashMap, HashSet};
22
23/// Base type specifiers that name provably trivially destructible types.
24/// `type_identifier` names (class types, aliases) stay unproven.
25const TRIVIAL_TYPE_KINDS: &[&str] = &["primitive_type", "sized_type_specifier"];
26
27/// Per-name evidence collected from one translation unit.
28#[derive(Default)]
29struct NameFacts {
30    /// Count of top-level free-function declarations of this name.
31    declarations: usize,
32    /// Whether the single recorded declaration has a provably temporary-free
33    /// signature. Meaningful only while `declarations == 1`.
34    trivial_signature: bool,
35    /// A use of the name outside a callee or its own declarator name
36    /// position: the name may denote something other than the recorded free
37    /// function at some call site.
38    poisoned: bool,
39}
40
41/// The per-file index answering whether a call expression provably
42/// materializes no automatic object that needs destruction.
43pub struct CppTemporaryFreeCallIndex<'a> {
44    source: &'a str,
45    /// `None` when the file itself is unprovable (any preprocessor content
46    /// can introduce declarations this index cannot see).
47    facts: Option<HashMap<&'a str, NameFacts>>,
48    /// Named nodes visited while building, for work accounting.
49    visited_nodes: usize,
50}
51
52impl<'a> CppTemporaryFreeCallIndex<'a> {
53    /// Build the index from a parsed C++ translation unit.
54    pub fn build(source: &'a str, root: Node<'a>) -> Self {
55        let mut facts: HashMap<&'a str, NameFacts> = HashMap::default();
56        let mut declaration_names: HashSet<usize> = HashSet::default();
57        let mut provable_file = true;
58        let mut visited_nodes = 0usize;
59        let mut stack = vec![root];
60        while let Some(node) = stack.pop() {
61            visited_nodes += 1;
62            let kind = node.kind();
63            if kind.starts_with("preproc_") {
64                // An include or macro can declare overloads and names this
65                // index cannot see; nothing in the file stays provable.
66                provable_file = false;
67            }
68            if let Some(candidate) = free_function_candidate(node) {
69                declaration_names.insert(candidate.name.id());
70                let name = node_text(candidate.name, source);
71                let entry = facts.entry(name).or_default();
72                entry.declarations += 1;
73                entry.trivial_signature = candidate.trivial_signature;
74            }
75            if matches!(
76                kind,
77                "identifier" | "field_identifier" | "type_identifier" | "namespace_identifier"
78            ) && !declaration_names.contains(&node.id())
79                && !(kind == "identifier" && is_callee_position(node))
80            {
81                facts.entry(node_text(node, source)).or_default().poisoned = true;
82            }
83            let mut cursor = node.walk();
84            stack.extend(node.named_children(&mut cursor));
85        }
86        Self {
87            source,
88            facts: provable_file.then_some(facts),
89            visited_nodes,
90        }
91    }
92
93    /// Named nodes visited while building, for lowering work accounting.
94    pub fn visited_nodes(&self) -> usize {
95        self.visited_nodes
96    }
97
98    /// Whether `call` provably materializes no automatic object that needs
99    /// destruction: the callee is one exact, unshadowed, non-template local
100    /// free function whose return and parameter types are provably trivially
101    /// destructible, and every argument is a trivially shaped expression.
102    /// Nested call arguments are accepted here because the caller's scan
103    /// classifies each nested call expression on its own.
104    pub fn call_is_provably_temporary_free(&self, call: Node<'_>) -> bool {
105        debug_assert_eq!(call.kind(), "call_expression");
106        let Some(facts) = &self.facts else {
107            return false;
108        };
109        let Some(callee) = call.child_by_field_name("function") else {
110            return false;
111        };
112        if callee.kind() != "identifier" {
113            return false;
114        }
115        let Some(name) = facts.get(node_text(callee, self.source)) else {
116            return false;
117        };
118        if name.declarations != 1 || !name.trivial_signature || name.poisoned {
119            return false;
120        }
121        let Some(arguments) = call.child_by_field_name("arguments") else {
122            return false;
123        };
124        let mut cursor = arguments.walk();
125        arguments
126            .named_children(&mut cursor)
127            .all(argument_is_trivially_shaped)
128    }
129}
130
131/// A top-level free-function declaration and whether its signature proves
132/// temporary-free calls.
133struct FreeFunctionCandidate<'a> {
134    name: Node<'a>,
135    trivial_signature: bool,
136}
137
138fn free_function_candidate(node: Node<'_>) -> Option<FreeFunctionCandidate<'_>> {
139    if !matches!(node.kind(), "function_definition" | "declaration") {
140        return None;
141    }
142    // Only translation-unit scope: a template_declaration, class, or
143    // namespace parent leaves the name to the poisoning walk.
144    if node.parent()?.kind() != "translation_unit" {
145        return None;
146    }
147    let type_node = node.child_by_field_name("type")?;
148    let mut declarator = node.child_by_field_name("declarator")?;
149    let mut indirect_return = false;
150    while matches!(
151        declarator.kind(),
152        "pointer_declarator" | "reference_declarator"
153    ) {
154        indirect_return = true;
155        declarator = declarator.child_by_field_name("declarator")?;
156    }
157    if declarator.kind() != "function_declarator" {
158        return None;
159    }
160    let name = declarator.child_by_field_name("declarator")?;
161    if name.kind() != "identifier" {
162        return None;
163    }
164    // A pointer or reference return is trivially destructible regardless of
165    // the pointee; otherwise the written base type must prove it.
166    let trivial_return = indirect_return || TRIVIAL_TYPE_KINDS.contains(&type_node.kind());
167    let trivial_signature = trivial_return
168        && declarator
169            .child_by_field_name("parameters")
170            .is_some_and(|parameters| {
171                let mut cursor = parameters.walk();
172                parameters
173                    .named_children(&mut cursor)
174                    .all(parameter_is_provably_trivial)
175            });
176    Some(FreeFunctionCandidate {
177        name,
178        trivial_signature,
179    })
180}
181
182/// A parameter that provably cannot bind a class-typed temporary: a written
183/// trivially destructible base type, or a pointer at some indirection level.
184/// Default arguments and variadic parameters stay unproven.
185fn parameter_is_provably_trivial(parameter: Node<'_>) -> bool {
186    if parameter.kind() != "parameter_declaration" {
187        return false;
188    }
189    let Some(type_node) = parameter.child_by_field_name("type") else {
190        return false;
191    };
192    if TRIVIAL_TYPE_KINDS.contains(&type_node.kind()) {
193        return true;
194    }
195    // A pointer parameter only ever binds a pointer value; any conversion
196    // from a class argument yields a pointer prvalue, never a class
197    // temporary. A plain reference to a class type can bind a converted
198    // temporary, so it stays unproven.
199    parameter
200        .child_by_field_name("declarator")
201        .is_some_and(declarator_contains_pointer)
202}
203
204fn declarator_contains_pointer(declarator: Node<'_>) -> bool {
205    let mut stack = vec![declarator];
206    while let Some(node) = stack.pop() {
207        if matches!(
208            node.kind(),
209            "pointer_declarator" | "abstract_pointer_declarator"
210        ) {
211            return true;
212        }
213        let mut cursor = node.walk();
214        stack.extend(node.named_children(&mut cursor));
215    }
216    false
217}
218
219fn is_callee_position(node: Node<'_>) -> bool {
220    node.parent().is_some_and(|parent| {
221        parent.kind() == "call_expression"
222            && parent
223                .child_by_field_name("function")
224                .is_some_and(|function| function.id() == node.id())
225    })
226}
227
228/// Argument shapes that cannot materialize a class-typed temporary given a
229/// provably trivial parameter list: names, literals, and nested calls (which
230/// the caller's scan classifies independently).
231fn argument_is_trivially_shaped(argument: Node<'_>) -> bool {
232    let mut node = argument;
233    loop {
234        match node.kind() {
235            "parenthesized_expression" => {
236                let mut cursor = node.walk();
237                let mut children = node.named_children(&mut cursor);
238                let (Some(inner), None) = (children.next(), children.next()) else {
239                    return false;
240                };
241                node = inner;
242            }
243            "identifier"
244            | "number_literal"
245            | "char_literal"
246            | "string_literal"
247            | "concatenated_string"
248            | "true"
249            | "false"
250            | "null"
251            | "nullptr" => return true,
252            "call_expression" => return true,
253            _ => return false,
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use tree_sitter::{Parser, Tree};
262
263    fn parse_cpp(source: &str) -> Tree {
264        let mut parser = Parser::new();
265        parser
266            .set_language(&tree_sitter_cpp::LANGUAGE.into())
267            .expect("cpp language");
268        parser.parse(source, None).expect("cpp tree")
269    }
270
271    /// Every `call_expression` in `source`, paired with its provability.
272    fn classified_calls(source: &str) -> Vec<(String, bool)> {
273        let tree = parse_cpp(source);
274        let index = CppTemporaryFreeCallIndex::build(source, tree.root_node());
275        let mut calls = Vec::new();
276        let mut stack = vec![tree.root_node()];
277        while let Some(node) = stack.pop() {
278            if node.kind() == "call_expression" {
279                calls.push((
280                    node_text(node, source).to_string(),
281                    index.call_is_provably_temporary_free(node),
282                ));
283            }
284            let mut cursor = node.walk();
285            stack.extend(node.named_children(&mut cursor));
286        }
287        calls.sort();
288        calls
289    }
290
291    fn assert_calls(source: &str, expected: &[(&str, bool)]) {
292        let mut expected = expected
293            .iter()
294            .map(|(text, provable)| (text.to_string(), *provable))
295            .collect::<Vec<_>>();
296        expected.sort();
297        assert_eq!(classified_calls(source), expected);
298    }
299
300    /// The #1951 balanced positive: exact local free-function calls with
301    /// provably trivial signatures are temporary-free.
302    #[test]
303    fn exact_local_free_function_calls_are_provable() {
304        assert_calls(
305            r#"
306const char *dfb_source() {
307    return "tainted";
308}
309
310void dfb_sink(const char *value) {}
311
312void run() {
313    dfb_sink(dfb_source());
314}
315"#,
316            &[("dfb_source()", true), ("dfb_sink(dfb_source())", true)],
317        );
318    }
319
320    /// The #1951 balanced negative keeps both calls provable: a discarded
321    /// trivial result and a literal argument.
322    #[test]
323    fn discarded_result_and_literal_argument_are_provable() {
324        assert_calls(
325            r#"
326const char *dfb_source() {
327    return "tainted";
328}
329
330void dfb_sink(const char *value) {}
331
332void run() {
333    dfb_source();
334    dfb_sink("clean");
335}
336"#,
337            &[("dfb_source()", true), ("dfb_sink(\"clean\")", true)],
338        );
339    }
340
341    /// A prototype without a body still proves the signature.
342    #[test]
343    fn local_prototype_is_provable() {
344        assert_calls(
345            r#"
346const char *dfb_source();
347
348void run() {
349    dfb_source();
350}
351"#,
352            &[("dfb_source()", true)],
353        );
354    }
355
356    /// Near miss: overloaded functions stay unproven even when every
357    /// overload is trivially typed.
358    #[test]
359    fn overloaded_functions_stay_unproven() {
360        assert_calls(
361            r#"
362const char *dfb_source() { return "a"; }
363const char *dfb_source(int selector) { return "b"; }
364
365void run() {
366    dfb_source();
367}
368"#,
369            &[("dfb_source()", false)],
370        );
371    }
372
373    /// Near miss: a call through a function pointer stays unproven; the
374    /// pointer declaration is a non-callee use of the name.
375    #[test]
376    fn function_pointer_calls_stay_unproven() {
377        assert_calls(
378            r#"
379const char *real_source() { return "a"; }
380
381void run() {
382    const char *(*dfb_source)() = real_source;
383    dfb_source();
384}
385"#,
386            &[("dfb_source()", false)],
387        );
388    }
389
390    /// Near miss: member call syntax stays unproven, so virtual dispatch
391    /// keeps its RAII gap.
392    #[test]
393    fn virtual_member_calls_stay_unproven() {
394        assert_calls(
395            r#"
396struct Producer {
397    virtual const char *dfb_source() { return "a"; }
398};
399
400void run(Producer *producer) {
401    producer->dfb_source();
402}
403"#,
404            &[("producer->dfb_source()", false)],
405        );
406    }
407
408    /// Near miss: an unqualified call that a same-named member could
409    /// capture stays unproven; the member declaration poisons the name.
410    #[test]
411    fn member_declaration_poisons_unqualified_calls() {
412        assert_calls(
413            r#"
414const char *dfb_source() { return "a"; }
415
416struct Wrapper {
417    const char *dfb_source() { return "b"; }
418    const char *read() { return dfb_source(); }
419};
420"#,
421            &[("dfb_source()", false)],
422        );
423    }
424
425    /// Near miss: template functions stay unproven, with or without
426    /// explicit template arguments.
427    #[test]
428    fn template_functions_stay_unproven() {
429        assert_calls(
430            r#"
431template <typename T>
432T dfb_source() { return T(); }
433
434void run() {
435    dfb_source<const char *>();
436}
437"#,
438            &[("dfb_source<const char *>()", false), ("T()", false)],
439        );
440    }
441
442    /// Near miss: a class return type may construct a destructible
443    /// temporary; the written base type is not provably trivial.
444    #[test]
445    fn class_return_types_stay_unproven() {
446        assert_calls(
447            r#"
448struct Token {};
449Token dfb_source() { return Token{}; }
450
451void run() {
452    dfb_source();
453}
454"#,
455            &[("dfb_source()", false)],
456        );
457    }
458
459    /// Near miss: a const reference parameter of class type can bind a
460    /// converted temporary.
461    #[test]
462    fn class_reference_parameters_stay_unproven() {
463        assert_calls(
464            r#"
465struct Token {};
466void dfb_sink(const Token &value) {}
467
468void run() {
469    dfb_sink(Token{});
470}
471"#,
472            &[("dfb_sink(Token{})", false)],
473        );
474    }
475
476    /// Near miss: default arguments can evaluate arbitrary expressions.
477    #[test]
478    fn default_arguments_stay_unproven() {
479        assert_calls(
480            r#"
481void dfb_sink(const char *value = "d") {}
482
483void run() {
484    dfb_sink();
485}
486"#,
487            &[("dfb_sink()", false)],
488        );
489    }
490
491    /// Near miss: a non-trivial argument expression can materialize a
492    /// class-typed temporary via overloaded operators, even when the
493    /// callee's parameters are trivially typed.
494    #[test]
495    fn complex_argument_expressions_stay_unproven() {
496        assert_calls(
497            r#"
498void dfb_sink(const char *value) {}
499
500void run(const char *left) {
501    dfb_sink(left + 1);
502}
503"#,
504            &[("dfb_sink(left + 1)", false)],
505        );
506    }
507
508    /// Near miss: any preprocessor content can introduce declarations this
509    /// index cannot see, so nothing in the file is provable.
510    #[test]
511    fn preprocessor_content_makes_the_file_unprovable() {
512        assert_calls(
513            r#"
514#include <string>
515
516const char *dfb_source() { return "a"; }
517
518void run() {
519    dfb_source();
520}
521"#,
522            &[("dfb_source()", false)],
523        );
524    }
525
526    /// Near miss: taking the function's address is a non-callee use, so a
527    /// pointer alias can no longer be told apart from the direct call.
528    #[test]
529    fn address_taken_names_stay_unproven() {
530        assert_calls(
531            r#"
532const char *dfb_source() { return "a"; }
533
534void keep(const char *(*pointer)()) {}
535
536void run() {
537    keep(&dfb_source);
538    dfb_source();
539}
540"#,
541            &[("dfb_source()", false), ("keep(&dfb_source)", false)],
542        );
543    }
544
545    /// Pointer returns and pointer parameters are trivial regardless of the
546    /// pointee type; nested parentheses stay transparent.
547    #[test]
548    fn pointer_indirection_over_class_types_is_provable() {
549        assert_calls(
550            r#"
551struct Token;
552Token *dfb_source() { return nullptr; }
553
554void dfb_sink(Token *value) {}
555
556void run() {
557    dfb_sink((dfb_source()));
558}
559"#,
560            &[("dfb_source()", true), ("dfb_sink((dfb_source()))", true)],
561        );
562    }
563}