cheatah
Source

tests/purrc/lang_features_test.cpp

1// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).
2// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.
3// End-to-end tests for the newer language features: break/continue, elif, match,
4// growable lists (append) + dict mutation, method-call syntax, and string/list
5// slicing with negative indices. Each writes a .purr, compiles it with purrc,
6// runs it under the cheatah runtime, and asserts the exact stdout.
7#include "e2e_harness.hpp"
9TEST(LangFeatures, BreakContinue) {
10 e2e::expect_e2e("lang_break_continue", R"PURR(import io
11let sum = 0
12for i in range(1, 20) {
13 if i == 5 { continue }
14 if i == 10 { break }
15 sum = sum + i
17io.print(sum)
18)PURR",
19 "40\n"); // 1+2+3+4+6+7+8+9
22TEST(LangFeatures, ElifChain) {
23 e2e::expect_e2e("lang_elif", R"PURR(import io
24fn classify(n) {
25 if n < 0 { return "neg" }
26 elif n == 0 { return "zero" }
27 elif n < 10 { return "small" }
28 else { return "big" }
30for n in [-3, 0, 7, 99] {
31 io.print(classify(n))
33)PURR",
34 "neg\nzero\nsmall\nbig\n");
37TEST(LangFeatures, Match) {
38 e2e::expect_e2e("lang_match", R"PURR(import io
39for x in [1, 2, 3] {
40 match x {
41 case 1 { io.print("one") }
42 case 2 { io.print("two") }
43 case _ { io.print("many") }
44 }
46)PURR",
47 "one\ntwo\nmany\n");
50TEST(LangFeatures, CompoundAssignment) {
51 // += -= *= /= on ints, floats, and strings (lowers to the C++ operators).
52 e2e::expect_e2e("lang_compound_assign", R"PURR(import io
53let n = 10
54n += 5
55n -= 3
56n *= 4
57let x = 9.0
58x /= 2.0
59let s = "purr"
60s += "fect"
61io.print(n, x, s)
62)PURR", "48 4.5 purrfect\n");
65TEST(LangFeatures, NdarrayOperators) {
66 // Infix elementwise/scalar arithmetic and in-place compound assignment on
67 // ndarrays: a * 2.0, a + b, a += b, a /= scalar.
68 e2e::expect_e2e("lang_ndarray_operators", R"PURR(import io
69import ndarray
70let a = ndarray.array([1.0, 2.0, 3.0])
71let b = ndarray.array([10.0, 20.0, 30.0])
72io.print(ndarray.to_string(a * 2.0))
73io.print(ndarray.to_string(0.5 * b))
74io.print(ndarray.to_string(a + b))
75a += b
76a /= 11.0
77io.print(ndarray.to_string(a))
78)PURR", "[2, 4, 6]\n[5, 10, 15]\n[11, 22, 33]\n[1, 2, 3]\n");
81TEST(LangFeatures, ParamsPassByReference) {
82 // Parameters bind by reference (Python object semantics): a function that
83 // mutates a struct field, an ndarray element, or appends to a list through
84 // its parameter changes the CALLER'S object. A literal argument still works
85 // (binds as a temporary).
86 e2e::expect_e2e("lang_byref_params", R"PURR(import io
87import ndarray
89struct Counter { hits: int }
91fn bump(c: Counter) {
92 c.hits += 1
95fn scale_in_place(a, factor: float) {
96 a *= factor
99fn push_two(xs: list) {
100 xs.append(2)
103let c = Counter(0)
104bump(c)
105bump(c)
106let a = ndarray.array([1.0, 2.0])
107scale_in_place(a, 10.0)
108let xs = [1]
109push_two(xs)
110io.print(c.hits, ndarray.to_string(a), xs)
111)PURR", "2 [10, 20] [1, 2]\n");
114TEST(LangFeatures, NdarraySubscript) {
115 // x[i] and x[i, j] subscripts on ndarrays: reads, writes, negatives, and the
116 // typed `: ndarray<int>` parameter spelling (in-place updates reach the caller).
117 e2e::expect_e2e("lang_ndarray_subscript", R"PURR(import io
118import ndarray
120fn light_pixel(row : ndarray<int>) {
121 row[0] = 1
124let grid = ndarray.reshape(ndarray.array([1.0, 2.0, 3.0, 4.0]), [2, 2])
125grid[1, 0] = 9.0
126io.print(grid[1, 0], grid[0, 1], grid[-1, -1])
127let row = ndarray.array([0, 0, 0])
128light_pixel(row)
129row[2] = 5
130io.print(ndarray.to_string(row))
131)PURR", "9 2 4\n[1, 0, 5]\n");
134TEST(LangFeatures, MatchWildcardWithAccumulator) {
135 // Regression: a `case _` wildcard has no pattern expression; the dead-let
136 // analysis scanning a match for reads of `acc` must not dereference it.
137 e2e::expect_e2e("lang_match_wildcard_acc", R"PURR(import io
138fn grade(n: int) {
139 let acc = 0.0
140 match n {
141 case 1 {
142 acc += 1.0
143 }
144 case _ {
145 }
146 }
147 return acc
149io.print(grade(1), grade(7))
150)PURR", "1 0\n");
153TEST(LangFeatures, AppendAndDictMutation) {
154 e2e::expect_e2e("lang_append", R"PURR(import io
155let xs: list<int> = []
156append(xs, 1)
157xs.append(2)
158xs.append(3)
159io.print(len(xs), xs[2])
160let counts: dict<str, int> = {}
161counts["x"] = 1
162counts["x"] = counts["x"] + 5
163io.print(counts["x"])
164)PURR",
165 "3 3\n6\n");
168TEST(LangFeatures, MethodPredicates) {
169 e2e::expect_e2e("lang_methods", R"PURR(import io
170io.print("</div>".startswith("</"))
171io.print("hello".endswith("lo"))
172io.print("abcd".contains("bc"))
173)PURR",
174 "True\nTrue\nTrue\n");
177TEST(LangFeatures, StringSlicingAndIndex) {
178 e2e::expect_e2e("lang_slice_str", R"PURR(import io
179let s = "hello world"
180io.print(s[0], s[-1])
181io.print(s[0:5])
182io.print(s[6:])
183io.print(s[:5])
184io.print(s[-5:])
185io.print(s[0] == "h")
186)PURR",
187 "h d\nhello\nworld\nhello\nworld\nTrue\n");
190TEST(LangFeatures, ListSlicingAndIndex) {
191 e2e::expect_e2e("lang_slice_list", R"PURR(import io
192let nums = [10, 20, 30, 40, 50]
193io.print(nums[-1], nums[1])
194let mid = nums[1:4]
195io.print(len(mid), mid[0], mid[2])
196)PURR",
197 "50 20\n3 20 40\n");
200TEST(LangFeatures, ReturnTypeHints) {
201 // Optional Python-style `-> Type` return hints. When present the function lowers with
202 // that concrete C++ return type (the backend enforces it); when absent the return stays
203 // `auto`. Mixed here: int, float, an ndarray, and an untyped function all interoperate.
204 e2e::expect_e2e("lang_return_hints", R"PURR(import io
205import ndarray
206fn add(a : int, b : int) -> int {
207 return a + b
209fn half(x : float) -> float {
210 return x / 2.0
212fn untyped(x) {
213 return x + 1
215fn ones(n : int) -> ndarray<float> {
216 let a = ndarray.zeros([n])
217 for i in range(0, n) {
218 a[i] = 1.0
219 }
220 return a
222io.print(add(2, 3))
223io.print(half(9.0))
224io.print(untyped(41))
225io.print(ndarray.to_string(ones(3)))
226)PURR",
227 "5\n4.5\n42\n[1, 1, 1]\n");
230// Explicit template arguments on a call/construction, including NON-TYPE (integer-literal)
231// args: `f<3>(x)` and the mixed `f<int, 4>(x)`. A top-level cpp{} block supplies the C++
232// template fixtures so the program compiles AND runs; we also assert the emitted C++ carries
233// the literal (and the mapped type) verbatim. Guards the ambiguity with `<` as a comparison:
234// the args only commit when the angle list closes and is immediately followed by `(`.
235TEST(LangFeatures, NonTypeTemplateArgs) {
236 const std::string gen = e2e::expect_e2e_source("lang_nontype_targs", R"PURR(import io
237cpp {
238template <long long N>
239long long times_n(long long x) { return x * N; }
241template <typename T, long long N>
242T scale_n(T x) { return x * static_cast<T>(N); }
244io.print(times_n<3>(7))
245io.print(scale_n<int, 4>(5))
246)PURR",
247 "21\n20\n");
248 // The single non-type arg is spliced verbatim.
249 EXPECT_NE(gen.find("times_n<3>"), std::string::npos)
250 << "expected the non-type template arg `<3>` in the emitted C++";
251 // Mixed: the type arg maps (int -> long long) and the non-type literal passes through.
252 EXPECT_NE(gen.find("scale_n<long long, 4>"), std::string::npos)
253 << "expected mixed `<long long, 4>` type + non-type args in the emitted C++";
256// Compile-time `if constexpr (cond) {…}` — kept C++-style (parenthesised condition) and
257// lowered verbatim to C++ `if constexpr`, so the live branch is chosen at COMPILE time from
258// a constant condition. The constexpr-ness threads down the `else if constexpr` chain. A
259// cpp{} block supplies the constexpr fixtures so the program compiles AND runs; we also
260// assert the emitted C++ carries `if constexpr (` on both arms. Boolean logic (`and`,
261// comparisons) in the condition goes through the ordinary expression path.
262TEST(LangFeatures, IfConstexpr) {
263 const std::string gen = e2e::expect_e2e_source("lang_if_constexpr", R"PURR(import io
264cpp {
265constexpr int kMode = 2;
266constexpr bool kOn = true;
268fn pick() {
269 if constexpr (kMode == 1 and kOn) {
270 return "one"
271 } else if constexpr (kMode == 2) {
272 return "two"
273 } else {
274 return "other"
275 }
277io.print(pick())
278)PURR",
279 "two\n");
280 // The leading `if` lowers to a compile-time branch...
281 const std::size_t first = gen.find("if constexpr (");
282 ASSERT_NE(first, std::string::npos)
283 << "expected `if constexpr (` in the emitted C++";
284 // ...and the `else if constexpr` arm inherits it (a second occurrence).
285 EXPECT_NE(gen.find("if constexpr (", first + 1), std::string::npos)
286 << "the else-if arm should also lower to `if constexpr`";
289// ============================================================================
290// Compile-time `constexpr` family — `constexpr let` / `constexpr fn` /
291// `constexpr match`, plus the AUTO-promotion of `if`/`match` over a known
292// constant, and the `match` -> `switch` vs `if/else-if` smart lowering.
293// These exercise the cases most likely to break the transpiler as it grows.
294// ============================================================================
296// `constexpr let` emits a C++ `constexpr` binding AND marks the name a compile-time
297// constant, so a plain `if` over it AUTO-lowers to `if constexpr`.
298TEST(LangFeatures, ConstexprLetAutoIf) {
299 const std::string gen = e2e::expect_e2e_source("lang_cx_let_autoif", R"PURR(import io
300constexpr let N = 4
301if (N == 4) {
302 io.print("four")
303} else {
304 io.print("other")
306)PURR",
307 "four\n");
308 EXPECT_NE(gen.find("constexpr auto N"), std::string::npos) << "let must emit `constexpr`";
309 EXPECT_NE(gen.find("if constexpr ("), std::string::npos)
310 << "an `if` over a constexpr let must auto-lower to `if constexpr`";
313// Constant-folding initializer + reference to an earlier constexpr let (chained constants).
314TEST(LangFeatures, ConstexprLetArithmeticAndChain) {
315 const std::string gen = e2e::expect_e2e_source("lang_cx_let_chain", R"PURR(import io
316constexpr let A = 2 * 3 + 1
317constexpr let B = A + 5
318if (B == 12) { io.print("ok") } else { io.print("bad") }
319)PURR",
320 "ok\n");
321 EXPECT_NE(gen.find("constexpr auto A"), std::string::npos);
322 EXPECT_NE(gen.find("constexpr auto B"), std::string::npos);
323 EXPECT_NE(gen.find("if constexpr ("), std::string::npos);
326// Explicit type annotation on a constexpr let still emits `constexpr`.
327TEST(LangFeatures, ConstexprLetTyped) {
328 const std::string gen = e2e::expect_e2e_source("lang_cx_let_typed", R"PURR(import io
329constexpr let x: int = 5
330if (x < 10) { io.print("small") } else { io.print("big") }
331)PURR",
332 "small\n");
333 EXPECT_NE(gen.find("constexpr "), std::string::npos) << "typed constexpr let must emit `constexpr`";
334 EXPECT_NE(gen.find("if constexpr ("), std::string::npos);
337// Bool constexpr let drives an `if constexpr` with an `else` arm.
338TEST(LangFeatures, ConstexprLetBool) {
339 e2e::expect_e2e("lang_cx_let_bool", R"PURR(import io
340constexpr let on = true
341if (on) { io.print("on") } else { io.print("off") }
342)PURR",
343 "on\n");
346// A purely-literal condition is itself a constant -> auto `if constexpr`, no `let` needed.
347TEST(LangFeatures, AutoIfLiteralCondition) {
348 const std::string gen = e2e::expect_e2e_source("lang_cx_literal_if", R"PURR(import io
349if (1 + 1 == 2) { io.print("math") } else { io.print("broken") }
350)PURR",
351 "math\n");
352 EXPECT_NE(gen.find("if constexpr ("), std::string::npos);
355// GUARD: a condition over a RUNTIME value (a function parameter) must stay a runtime `if`
356// — never auto-promoted (which would fail to compile, the value isn't constexpr).
357TEST(LangFeatures, RuntimeIfNotPromoted) {
358 const std::string gen = e2e::expect_e2e_source("lang_cx_runtime_if", R"PURR(import io
359fn label(x: int) {
360 if (x == 1) { return "one" } else { return "many" }
362io.print(label(1), label(9))
363)PURR",
364 "one many\n");
365 EXPECT_EQ(gen.find("if constexpr"), std::string::npos)
366 << "an `if` over a runtime value must NOT be promoted to `if constexpr`";
369// `constexpr fn`: a constexpr function whose call folds inside a `constexpr let`, and which
370// is ALSO callable at runtime.
371TEST(LangFeatures, ConstexprFn) {
372 const std::string gen = e2e::expect_e2e_source("lang_cx_fn", R"PURR(import io
373constexpr fn square(x) { return x * x }
374constexpr let r = square(5)
375if (r == 25) { io.print("r25") } else { io.print("no") }
376io.print(square(3))
377)PURR",
378 "r25\n9\n");
379 EXPECT_NE(gen.find("constexpr auto square"), std::string::npos)
380 << "constexpr fn must emit a `constexpr` function";
381 EXPECT_NE(gen.find("if constexpr ("), std::string::npos)
382 << "an `if` over the constexpr-folded result must auto-lower";
385// Explicit `constexpr match` over a constant subject -> compile-time `if constexpr` chain.
386TEST(LangFeatures, ConstexprMatchExplicit) {
387 const std::string gen = e2e::expect_e2e_source("lang_cx_match_explicit", R"PURR(import io
388constexpr let k = 2
389constexpr match k {
390 case 1 { io.print("one") }
391 case 2 { io.print("two") }
392 case _ { io.print("other") }
394)PURR",
395 "two\n");
396 EXPECT_NE(gen.find("if constexpr ("), std::string::npos)
397 << "constexpr match must lower to an `if constexpr` chain";
398 EXPECT_EQ(gen.find("switch ("), std::string::npos)
399 << "constexpr match must NOT lower to a runtime switch";
402// AUTO: a plain `match` over a constant subject with constant case labels also folds to the
403// compile-time `if constexpr` chain (no `constexpr` keyword on the match needed).
404TEST(LangFeatures, MatchAutoConstexpr) {
405 const std::string gen = e2e::expect_e2e_source("lang_cx_match_auto", R"PURR(import io
406constexpr let k = 3
407match k {
408 case 1 { io.print("a") }
409 case 3 { io.print("c") }
410 case _ { io.print("z") }
412)PURR",
413 "c\n");
414 EXPECT_NE(gen.find("if constexpr ("), std::string::npos);
417// A plain `match` on a RUNTIME integer lowers to a real C++ `switch` (default = `_`).
418TEST(LangFeatures, MatchRuntimeIntSwitch) {
419 const std::string gen = e2e::expect_e2e_source("lang_match_switch", R"PURR(import io
420fn classify(n: int) {
421 match n {
422 case 1 { return "a" }
423 case 2 { return "b" }
424 case _ { return "c" }
425 }
427io.print(classify(1), classify(2), classify(9))
428)PURR",
429 "a b c\n");
430 EXPECT_NE(gen.find("switch ("), std::string::npos) << "integral match must lower to a switch";
431 EXPECT_NE(gen.find("default:"), std::string::npos) << "the `_` case must be `default:`";
432 EXPECT_EQ(gen.find("if constexpr"), std::string::npos);
435// GUARD: a `match` on a STRING cannot be a switch (C++ forbids it) — it must fall back to the
436// `==` if/else-if chain. The most important correctness guard of the smart lowering.
437TEST(LangFeatures, MatchStringFallsBackToChain) {
438 const std::string gen = e2e::expect_e2e_source("lang_match_string", R"PURR(import io
439fn code(s) {
440 match s {
441 case "red" { return 1 }
442 case "green" { return 2 }
443 case _ { return 0 }
444 }
446io.print(code("red"), code("green"), code("blue"))
447)PURR",
448 "1 2 0\n");
449 EXPECT_EQ(gen.find("switch ("), std::string::npos)
450 << "a string match must NOT lower to a switch (won't compile)";
453// GUARD: bool case labels are not switch labels here either -> `==` chain.
454TEST(LangFeatures, MatchBoolFallsBackToChain) {
455 const std::string gen = e2e::expect_e2e_source("lang_match_bool", R"PURR(import io
456fn name(b) {
457 match b {
458 case true { return "yes" }
459 case false { return "no" }
460 }
462io.print(name(true), name(false))
463)PURR",
464 "yes no\n");
465 EXPECT_EQ(gen.find("switch ("), std::string::npos);
468// A switch case whose body RETURNS must not emit an unreachable trailing `break` (else
469// -Wunreachable-code/-Werror could reject it). Verifies it compiles and runs.
470TEST(LangFeatures, MatchSwitchCaseReturnsNoUnreachableBreak) {
471 e2e::expect_e2e("lang_match_return", R"PURR(import io
472fn pick(n: int) {
473 match n {
474 case 1 { return 10 }
475 case 2 { return 20 }
476 case _ { return 0 }
477 }
479io.print(pick(1), pick(2), pick(5))
480)PURR",
481 "10 20 0\n");
484// Negative integer case labels are valid switch labels (`case (-1):`).
485TEST(LangFeatures, MatchNegativeIntSwitch) {
486 const std::string gen = e2e::expect_e2e_source("lang_match_negative", R"PURR(import io
487fn sign(n: int) {
488 match n {
489 case -1 { return "neg" }
490 case 0 { return "zero" }
491 case _ { return "pos" }
492 }
494io.print(sign(-1), sign(0), sign(7))
495)PURR",
496 "neg zero pos\n");
497 EXPECT_NE(gen.find("switch ("), std::string::npos);
500// Identifiers that are legal cheatah names but C++ keywords (`delete`, `new`, `default`,
501// `switch`, `template`, `typename`, …) are emitted with a trailing `_` so the generated C++
502// compiles — applied symmetrically at declaration and every use site (fn/method/field/param/
503// var/loop/catch). cheatah's own keyword set is smaller (is_keyword, lexer.cpp), so these
504// stay valid source. String-literal contexts (a struct's printed field label) keep the
505// ORIGINAL spelling, not the escaped one.
506TEST(LangFeatures, CppKeywordIdentifiersAreEscaped) {
507 const std::string gen = e2e::expect_e2e_source("lang_cpp_keyword_escape", R"PURR(import io
508struct Box {
509 new: int
510 default: str
511 fn delete(self) { return self.new }
513fn make(new) {
514 return Box({.new = new, .default = "d"})
516let b = make(5)
517io.print(b.delete())
518io.print(b.default)
519let switch = 3
520for template in range(0, switch) { io.print(template) }
521try { raise "boom" } except typename { io.print(typename) }
522)PURR",
523 "5\nd\n0\n1\n2\nboom\n");
524 // Declarations + use sites escaped: the keyword-named function, field, param, var, and
525 // loop/catch variables all carry the trailing underscore in the emitted C++.
526 EXPECT_NE(gen.find("delete_("), std::string::npos) << "method name not escaped:\n" << gen;
527 EXPECT_NE(gen.find("new_"), std::string::npos) << "field/param name not escaped:\n" << gen;
528 EXPECT_NE(gen.find("switch_"), std::string::npos) << "variable name not escaped:\n" << gen;
529 EXPECT_NE(gen.find("template_"), std::string::npos) << "loop variable not escaped:\n" << gen;
530 // The RAW keyword never appears as a bare C++ identifier (would not compile).
531 EXPECT_EQ(gen.find(" delete("), std::string::npos) << "unescaped `delete(` leaked:\n" << gen;
532 // A field's printed label keeps the ORIGINAL spelling (string literal, not an identifier).
533 EXPECT_NE(gen.find("\"new"), std::string::npos) << "field label should keep raw name:\n" << gen;