cairo_lang_test_plugin/
test_config.rs

1use cairo_lang_defs::plugin::PluginDiagnostic;
2use cairo_lang_syntax::attribute::structured::{Attribute, AttributeArg, AttributeArgVariant};
3use cairo_lang_syntax::node::{TypedStablePtr, TypedSyntaxNode, ast};
4use cairo_lang_utils::byte_array::{BYTE_ARRAY_MAGIC, BYTES_IN_WORD};
5use cairo_lang_utils::{OptionHelper, require};
6use itertools::chain;
7use num_bigint::{BigInt, Sign};
8use num_traits::ToPrimitive;
9use salsa::Database;
10use serde::{Deserialize, Serialize};
11use starknet_types_core::felt::Felt as Felt252;
12
13use super::{AVAILABLE_GAS_ATTR, IGNORE_ATTR, SHOULD_PANIC_ATTR, STATIC_GAS_ARG, TEST_ATTR};
14
15/// Expectation for a panic case.
16#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
17pub enum PanicExpectation {
18    /// Accept any panic value.
19    Any,
20    /// Accept only a panic with this specific vector of felts.
21    Exact(Vec<Felt252>),
22}
23
24/// Expectation for a result of a test.
25#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
26pub enum TestExpectation {
27    /// Running the test should not panic.
28    Success,
29    /// Running the test should result in a panic.
30    Panics(PanicExpectation),
31}
32
33/// The configuration for running a single test.
34#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
35pub struct TestConfig {
36    /// The amount of gas the test requested.
37    pub available_gas: Option<usize>,
38    /// The expected result of the run.
39    pub expectation: TestExpectation,
40    /// Should the test be ignored.
41    pub ignored: bool,
42}
43
44/// Extracts the configuration of a tests from attributes, or returns the diagnostics if the
45/// attributes are set illegally.
46pub fn try_extract_test_config<'db>(
47    db: &'db dyn Database,
48    attrs: &[Attribute<'db>],
49) -> Result<Option<TestConfig>, Vec<PluginDiagnostic<'db>>> {
50    let test_attr = attrs.iter().find(|attr| attr.id.long(db) == TEST_ATTR);
51    let ignore_attr = attrs.iter().find(|attr| attr.id.long(db) == IGNORE_ATTR);
52    let available_gas_attr = attrs.iter().find(|attr| attr.id.long(db) == AVAILABLE_GAS_ATTR);
53    let should_panic_attr = attrs.iter().find(|attr| attr.id.long(db) == SHOULD_PANIC_ATTR);
54    let mut diagnostics = vec![];
55    if let Some(attr) = test_attr {
56        if !attr.args.is_empty() {
57            diagnostics.push(PluginDiagnostic::error(
58                attr.id_stable_ptr.untyped(),
59                "Attribute should not have arguments.".into(),
60            ));
61        }
62    } else {
63        for attr in [ignore_attr, available_gas_attr, should_panic_attr].into_iter().flatten() {
64            diagnostics.push(PluginDiagnostic::error(
65                attr.id_stable_ptr.untyped(),
66                "Attribute should only appear on tests.".into(),
67            ));
68        }
69    }
70    let ignored = if let Some(attr) = ignore_attr {
71        if !attr.args.is_empty() {
72            diagnostics.push(PluginDiagnostic::error(
73                attr.id_stable_ptr.untyped(),
74                "Attribute should not have arguments.".into(),
75            ));
76        }
77        true
78    } else {
79        false
80    };
81    let available_gas = extract_available_gas(available_gas_attr, db, &mut diagnostics);
82    let (should_panic, expected_panic_felts) = if let Some(attr) = should_panic_attr {
83        if attr.args.is_empty() {
84            (true, None)
85        } else {
86            (
87                true,
88                extract_panic_bytes(db, attr).on_none(|| {
89                    diagnostics.push(PluginDiagnostic::error(
90                        attr.args_stable_ptr.untyped(),
91                        "Expected panic must be of the form `expected: <tuple of felt252s and \
92                         strings>` or `expected: \"some string\"` or `expected: <some felt252>`."
93                            .into(),
94                    ));
95                }),
96            )
97        }
98    } else {
99        (false, None)
100    };
101    if !diagnostics.is_empty() {
102        return Err(diagnostics);
103    }
104    Ok(if test_attr.is_none() {
105        None
106    } else {
107        Some(TestConfig {
108            available_gas,
109            expectation: if should_panic {
110                TestExpectation::Panics(if let Some(felts) = expected_panic_felts {
111                    PanicExpectation::Exact(felts)
112                } else {
113                    PanicExpectation::Any
114                })
115            } else {
116                TestExpectation::Success
117            },
118            ignored,
119        })
120    })
121}
122
123/// Extract the available gas from the attribute.
124/// Adds a diagnostic if the attribute is malformed.
125/// Returns `None` if the attribute is "static", or the attribute is malformed.
126fn extract_available_gas<'db>(
127    available_gas_attr: Option<&Attribute<'db>>,
128    db: &'db dyn Database,
129    diagnostics: &mut Vec<PluginDiagnostic<'db>>,
130) -> Option<usize> {
131    let Some(attr) = available_gas_attr else {
132        // If no gas is specified, we assume the reasonably large possible gas, such that infinite
133        // loops will run out of gas.
134        return Some(u32::MAX as usize);
135    };
136    match &attr.args[..] {
137        [AttributeArg { variant: AttributeArgVariant::Unnamed(value), .. }] => match value {
138            ast::Expr::Path(path)
139                if path.as_syntax_node().get_text_without_trivia(db).long(db) == STATIC_GAS_ARG =>
140            {
141                return None;
142            }
143            ast::Expr::Literal(literal) => {
144                literal.numeric_value(db).and_then(|v| v.to_i64()).and_then(|v| v.to_usize())
145            }
146            _ => None,
147        },
148        _ => None,
149    }
150    .on_none(|| {
151        diagnostics.push(PluginDiagnostic::error(
152            attr.args_stable_ptr.untyped(),
153            format!(
154                "Attribute should have a single non-negative literal in `i64` range or \
155                 `{STATIC_GAS_ARG}`."
156            ),
157        ))
158    })
159}
160
161/// Tries to extract the expected panic bytes out of the given `should_panic` attribute.
162/// Assumes the attribute is `should_panic`.
163fn extract_panic_bytes(db: &dyn Database, attr: &Attribute<'_>) -> Option<Vec<Felt252>> {
164    let [AttributeArg { variant: AttributeArgVariant::Named { name, value, .. }, .. }] =
165        &attr.args[..]
166    else {
167        return None;
168    };
169    require(name.text.long(db) == "expected")?;
170
171    match value {
172        ast::Expr::Tuple(panic_exprs) => {
173            let mut panic_bytes = Vec::new();
174            for panic_expr in panic_exprs.expressions(db).elements(db) {
175                match panic_expr {
176                    ast::Expr::Literal(panic_expr) => {
177                        panic_bytes.push(panic_expr.numeric_value(db).unwrap_or_default().into())
178                    }
179                    ast::Expr::ShortString(panic_expr) => {
180                        panic_bytes.push(panic_expr.numeric_value(db).unwrap_or_default().into())
181                    }
182                    ast::Expr::String(panic_expr) => {
183                        panic_bytes.append(&mut extract_string_panic_bytes(&panic_expr, db))
184                    }
185                    _ => return None,
186                }
187            }
188            Some(panic_bytes)
189        }
190        ast::Expr::String(panic_string) => Some(extract_string_panic_bytes(panic_string, db)),
191        ast::Expr::Literal(panic_expr) => {
192            Some(vec![panic_expr.numeric_value(db).unwrap_or_default().into()])
193        }
194        ast::Expr::ShortString(panic_expr) => {
195            Some(vec![panic_expr.numeric_value(db).unwrap_or_default().into()])
196        }
197        _ => None,
198    }
199}
200
201/// Extracts panic bytes from a string.
202fn extract_string_panic_bytes(
203    panic_string: &ast::TerminalString<'_>,
204    db: &dyn Database,
205) -> Vec<Felt252> {
206    let panic_string = panic_string.string_value(db).unwrap();
207    let chunks = panic_string.as_bytes().chunks_exact(BYTES_IN_WORD);
208    let num_full_words = chunks.len().into();
209    let remainder = chunks.remainder();
210    let pending_word_len = remainder.len().into();
211    let full_words = chunks.map(|chunk| BigInt::from_bytes_be(Sign::Plus, chunk).into());
212    let pending_word = BigInt::from_bytes_be(Sign::Plus, remainder).into();
213
214    chain!(
215        [Felt252::from_hex(BYTE_ARRAY_MAGIC).unwrap(), num_full_words],
216        full_words.into_iter(),
217        [pending_word, pending_word_len]
218    )
219    .collect()
220}