/home/noah/src/ruchy/src/testing/properties.rs
Line | Count | Source |
1 | | //! Property-based tests for verifying compiler invariants |
2 | | |
3 | | #![allow(clippy::unnecessary_wraps)] // Property tests often need Result for the test framework |
4 | | |
5 | | use crate::backend::Transpiler; |
6 | | use crate::frontend::ast::{BinaryOp, Expr, ExprKind, Literal, StringPart, UnaryOp}; |
7 | | use crate::frontend::{Parser, RecoveryParser}; |
8 | | #[allow(unused_imports)] |
9 | | use crate::testing::generators::{arb_expr, arb_well_typed_expr}; |
10 | | use proptest::prelude::*; |
11 | | use proptest::test_runner::TestCaseError; |
12 | | |
13 | | /// Property: Parser should never panic on any input |
14 | | /// |
15 | | /// # Errors |
16 | | /// |
17 | | /// Returns an error if the property test fails |
18 | 33 | pub fn prop_parser_never_panics(input: &str) -> Result<(), TestCaseError> { |
19 | 33 | let mut parser = Parser::new(input); |
20 | | // Parser should either succeed or return an error, never panic |
21 | 33 | let _ = parser.parse(); |
22 | 33 | Ok(()) |
23 | 33 | } |
24 | | |
25 | | /// Property: Recovery parser should always produce some AST |
26 | | /// |
27 | | /// # Errors |
28 | | /// |
29 | | /// Returns an error if the property test fails |
30 | 33 | pub fn prop_recovery_parser_always_produces_ast(input: &str) -> Result<(), TestCaseError> { |
31 | 33 | let mut parser = RecoveryParser::new(input); |
32 | 33 | let result = parser.parse_with_recovery(); |
33 | | |
34 | | // For non-empty input, we should get some AST or errors |
35 | 33 | if !input.trim().is_empty() { |
36 | 33 | prop_assert!( |
37 | 33 | result.ast.is_some() || !result.errors.is_empty()0 , |
38 | 0 | "Recovery parser should produce AST or errors for non-empty input" |
39 | | ); |
40 | 0 | } |
41 | 33 | Ok(()) |
42 | 33 | } |
43 | | |
44 | | /// Property: Transpilation preserves expression structure |
45 | | /// |
46 | | /// # Errors |
47 | | /// |
48 | | /// Returns an error if the property test fails |
49 | 33 | pub fn prop_transpilation_preserves_structure(expr: &Expr) -> Result<(), TestCaseError> { |
50 | 33 | let transpiler = Transpiler::new(); |
51 | | |
52 | | // Transpilation should either succeed or fail cleanly |
53 | 33 | if let Ok(rust_code) = transpiler.transpile(expr) { |
54 | | // The generated Rust code should not be empty |
55 | 33 | let code_str = rust_code.to_string(); |
56 | 33 | prop_assert!(!code_str.is_empty(), "Transpiled code should not be empty"0 ); |
57 | 0 | } else { |
58 | 0 | // Transpilation errors are acceptable for some ASTs |
59 | 0 | } |
60 | 33 | Ok(()) |
61 | 33 | } |
62 | | |
63 | | /// Property: String interpolation transpiles correctly |
64 | | /// |
65 | | /// # Errors |
66 | | /// |
67 | | /// Returns an error if the property test fails |
68 | 0 | pub fn prop_string_interpolation_transpiles(parts: &[StringPart]) -> Result<(), TestCaseError> { |
69 | 0 | let transpiler = Transpiler::new(); |
70 | 0 | let result = transpiler.transpile_string_interpolation(parts); |
71 | | |
72 | | // Should either succeed or fail cleanly, never panic |
73 | 0 | if let Ok(tokens) = result { |
74 | 0 | let code = tokens.to_string(); |
75 | | // Should either be a format! call or a simple string literal |
76 | 0 | prop_assert!( |
77 | 0 | code.contains("format!") || code.starts_with('"') || code == "()", |
78 | 0 | "String interpolation should produce format! call or string literal" |
79 | | ); |
80 | 0 | } |
81 | | // Transpilation errors are acceptable for malformed parts |
82 | 0 | Ok(()) |
83 | 0 | } |
84 | | |
85 | | /// Property: Parse-print roundtrip |
86 | | /// |
87 | | /// # Errors |
88 | | /// |
89 | | /// Returns an error if the property test fails |
90 | 33 | pub fn prop_parse_print_roundtrip(expr: &Expr) -> Result<(), TestCaseError> { |
91 | | // This would require a pretty-printer, which we'll implement later |
92 | | // For now, just check that we can transpile and the result is valid |
93 | 33 | let transpiler = Transpiler::new(); |
94 | 33 | if let Ok(rust_code) = transpiler.transpile(expr) { |
95 | | // Check that the Rust code contains expected elements based on expr type |
96 | 33 | let code_str = rust_code.to_string(); |
97 | | |
98 | 8 | match &expr.kind { |
99 | 1 | ExprKind::Literal(Literal::Integer(n)) => { |
100 | | // Integer literals are transpiled with type suffixes (e.g., "42 i32") |
101 | 1 | prop_assert!( |
102 | 1 | code_str.contains(&n.to_string()), |
103 | 0 | "Integer literal {n} not found in transpiled code" |
104 | | ); |
105 | | } |
106 | 1 | ExprKind::Literal(Literal::Bool(b)) => { |
107 | 1 | prop_assert!( |
108 | 1 | code_str.contains(&b.to_string()), |
109 | 0 | "Bool literal {b} not found in transpiled code" |
110 | | ); |
111 | | } |
112 | | ExprKind::Binary { |
113 | | op: BinaryOp::Add, .. |
114 | | } => { |
115 | 11 | prop_assert!( |
116 | 11 | code_str.contains('+'), |
117 | 0 | "Addition operator not found in transpiled code" |
118 | | ); |
119 | | } |
120 | 20 | _ => { |
121 | 20 | // Other cases are more complex to verify |
122 | 20 | } |
123 | | } |
124 | 0 | } |
125 | 33 | Ok(()) |
126 | 33 | } |
127 | | |
128 | | /// Property: Well-typed expressions should always transpile successfully |
129 | | /// |
130 | | /// # Errors |
131 | | /// |
132 | | /// Returns an error if the property test fails |
133 | 33 | pub fn prop_well_typed_always_transpiles(expr: &Expr) -> Result<(), TestCaseError> { |
134 | 33 | let transpiler = Transpiler::new(); |
135 | | |
136 | | // Check if this is a simple, well-typed expression |
137 | 33 | if is_well_typed(expr) { |
138 | 33 | match transpiler.transpile(expr) { |
139 | 33 | Ok(_) => Ok(()), |
140 | 0 | Err(e) => { |
141 | 0 | prop_assert!( |
142 | 0 | false, |
143 | 0 | "Well-typed expression failed to transpile: {:?}\nError: {}", |
144 | | expr, |
145 | | e |
146 | | ); |
147 | 0 | Ok(()) |
148 | | } |
149 | | } |
150 | | } else { |
151 | | // Complex expressions may fail, which is acceptable |
152 | 0 | Ok(()) |
153 | | } |
154 | 33 | } |
155 | | |
156 | | /// Property: Error recovery should handle truncated input gracefully |
157 | | /// |
158 | | /// # Errors |
159 | | /// |
160 | | /// Returns an error if the property test fails |
161 | 33 | pub fn prop_recovery_handles_truncation(input: &str) -> Result<(), TestCaseError> { |
162 | 33 | if input.is_empty() { |
163 | 0 | return Ok(()); |
164 | 33 | } |
165 | | |
166 | | // Try parsing truncated versions of the input |
167 | 538 | for i in 0..input33 .len33 () { |
168 | 538 | let truncated = &input[..i]; |
169 | 538 | let mut parser = RecoveryParser::new(truncated); |
170 | 538 | let result = parser.parse_with_recovery(); |
171 | | |
172 | | // Should not panic, and should produce something |
173 | 538 | if !truncated.trim().is_empty() { |
174 | 501 | prop_assert!( |
175 | 501 | result.ast.is_some() || !result.errors.is_empty()0 , |
176 | 0 | "Recovery parser should handle truncated input at position {i}" |
177 | | ); |
178 | 37 | } |
179 | | } |
180 | 33 | Ok(()) |
181 | 33 | } |
182 | | |
183 | | /// Helper to check if an expression is well-typed (simplified) |
184 | 33 | fn is_well_typed(expr: &Expr) -> bool { |
185 | 33 | match &expr.kind { |
186 | 20 | ExprKind::Literal(_) | ExprKind::Identifier(_) => true, |
187 | 13 | ExprKind::Binary { left, right, op } => { |
188 | 13 | match op { |
189 | | BinaryOp::Add | BinaryOp::Subtract | BinaryOp::Multiply | BinaryOp::Divide => { |
190 | 13 | is_numeric(left) && is_numeric(right) |
191 | | } |
192 | 0 | BinaryOp::And | BinaryOp::Or => is_boolean(left) && is_boolean(right), |
193 | | BinaryOp::Equal | BinaryOp::NotEqual => { |
194 | | // Equality can work on many types |
195 | 0 | is_well_typed(left) && is_well_typed(right) |
196 | | } |
197 | 0 | _ => is_well_typed(left) && is_well_typed(right), |
198 | | } |
199 | | } |
200 | 0 | ExprKind::Unary { operand, op } => match op { |
201 | 0 | UnaryOp::Not => is_boolean(operand), |
202 | 0 | UnaryOp::Negate | UnaryOp::BitwiseNot => is_numeric(operand), |
203 | 0 | UnaryOp::Reference => true, // Reference can be applied to any type |
204 | | }, |
205 | | ExprKind::If { |
206 | 0 | condition, |
207 | 0 | then_branch, |
208 | 0 | else_branch, |
209 | | } => { |
210 | 0 | is_boolean(condition) |
211 | 0 | && is_well_typed(then_branch) |
212 | 0 | && else_branch.as_ref().is_none_or(|e| is_well_typed(e)) |
213 | | } |
214 | 0 | _ => false, // Conservative for complex expressions |
215 | | } |
216 | 33 | } |
217 | | |
218 | 26 | fn is_numeric(expr: &Expr) -> bool { |
219 | 0 | matches!( |
220 | 26 | &expr.kind, |
221 | | ExprKind::Literal(Literal::Integer(_) | Literal::Float(_)) |
222 | | ) |
223 | 26 | } |
224 | | |
225 | 0 | fn is_boolean(expr: &Expr) -> bool { |
226 | 0 | matches!(&expr.kind, ExprKind::Literal(Literal::Bool(_))) |
227 | 0 | } |
228 | | |
229 | | #[cfg(test)] |
230 | | #[allow(clippy::unwrap_used, clippy::panic)] |
231 | | mod tests { |
232 | | use super::*; |
233 | | |
234 | | proptest! { |
235 | | #[test] |
236 | | fn test_parser_never_panics(input in ".*") { |
237 | | prop_parser_never_panics(&input)?; |
238 | | } |
239 | | |
240 | | #[test] |
241 | | fn test_recovery_parser_always_produces_ast(input in ".*") { |
242 | | prop_recovery_parser_always_produces_ast(&input)?; |
243 | | } |
244 | | |
245 | | #[test] |
246 | | fn test_transpilation_preserves_structure(expr in arb_expr()) { |
247 | | prop_transpilation_preserves_structure(&expr)?; |
248 | | } |
249 | | |
250 | | #[test] |
251 | | fn test_well_typed_always_transpiles(expr in arb_well_typed_expr()) { |
252 | | prop_well_typed_always_transpiles(&expr)?; |
253 | | } |
254 | | |
255 | | #[test] |
256 | | fn test_recovery_handles_truncation(input in "[a-zA-Z0-9 +\\-*/()]+") { |
257 | | prop_recovery_handles_truncation(&input)?; |
258 | | } |
259 | | |
260 | | #[test] |
261 | | fn test_parse_print_roundtrip(expr in arb_well_typed_expr()) { |
262 | | prop_parse_print_roundtrip(&expr)?; |
263 | | } |
264 | | } |
265 | | |
266 | | #[test] |
267 | | #[ignore = "This test hangs due to infinite loop in recovery parser"] |
268 | 0 | fn test_specific_recovery_cases() { |
269 | | // Test specific error recovery scenarios |
270 | 0 | let cases = vec![ |
271 | 0 | ("let x =", "Missing value in let binding"), |
272 | 0 | ("if x >", "Missing right operand"), |
273 | 0 | ("fun foo(", "Missing closing paren"), |
274 | 0 | ("[1, 2,", "Missing closing bracket"), |
275 | 0 | ("1 + + 2", "Double operator"), |
276 | | ]; |
277 | | |
278 | 0 | for (input, _description) in cases { |
279 | 0 | let mut parser = RecoveryParser::new(input); |
280 | 0 | let result = parser.parse_with_recovery(); |
281 | | |
282 | 0 | assert!( |
283 | 0 | result.ast.is_some() || !result.errors.is_empty(), |
284 | 0 | "Failed to handle: {input}" |
285 | | ); |
286 | | } |
287 | 0 | } |
288 | | } |