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/list5
// 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"9
TEST(LangFeatures, BreakContinue) {10
e2e::expect_e2e("lang_break_continue", R"PURR(import io11
let sum = 012
for i in range(1, 20) {13
if i == 5 { continue }14
if i == 10 { break }15
sum = sum + i16
}17
io.print(sum)18
)PURR",19
"40\n"); // 1+2+3+4+6+7+8+920
}22
TEST(LangFeatures, ElifChain) {23
e2e::expect_e2e("lang_elif", R"PURR(import io24
fn classify(n) {25
if n < 0 { return "neg" }26
elif n == 0 { return "zero" }27
elif n < 10 { return "small" }28
else { return "big" }29
}30
for n in [-3, 0, 7, 99] {31
io.print(classify(n))32
}33
)PURR",34
"neg\nzero\nsmall\nbig\n");35
}37
TEST(LangFeatures, Match) {38
e2e::expect_e2e("lang_match", R"PURR(import io39
for 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
}45
}46
)PURR",47
"one\ntwo\nmany\n");48
}50
TEST(LangFeatures, CompoundAssignment) {51
// += -= *= /= on ints, floats, and strings (lowers to the C++ operators).52
e2e::expect_e2e("lang_compound_assign", R"PURR(import io53
let n = 1054
n += 555
n -= 356
n *= 457
let x = 9.058
x /= 2.059
let s = "purr"60
s += "fect"61
io.print(n, x, s)62
)PURR", "48 4.5 purrfect\n");63
}65
TEST(LangFeatures, NdarrayOperators) {66
// Infix elementwise/scalar arithmetic and in-place compound assignment on67
// ndarrays: a * 2.0, a + b, a += b, a /= scalar.68
e2e::expect_e2e("lang_ndarray_operators", R"PURR(import io69
import ndarray70
let a = ndarray.array([1.0, 2.0, 3.0])71
let b = ndarray.array([10.0, 20.0, 30.0])72
io.print(ndarray.to_string(a * 2.0))73
io.print(ndarray.to_string(0.5 * b))74
io.print(ndarray.to_string(a + b))75
a += b76
a /= 11.077
io.print(ndarray.to_string(a))78
)PURR", "[2, 4, 6]\n[5, 10, 15]\n[11, 22, 33]\n[1, 2, 3]\n");79
}81
TEST(LangFeatures, ParamsPassByReference) {82
// Parameters bind by reference (Python object semantics): a function that83
// mutates a struct field, an ndarray element, or appends to a list through84
// its parameter changes the CALLER'S object. A literal argument still works85
// (binds as a temporary).86
e2e::expect_e2e("lang_byref_params", R"PURR(import io87
import ndarray89
struct Counter { hits: int }91
fn bump(c: Counter) {92
c.hits += 193
}95
fn scale_in_place(a, factor: float) {96
a *= factor97
}99
fn push_two(xs: list) {100
xs.append(2)101
}103
let c = Counter(0)104
bump(c)105
bump(c)106
let a = ndarray.array([1.0, 2.0])107
scale_in_place(a, 10.0)108
let xs = [1]109
push_two(xs)110
io.print(c.hits, ndarray.to_string(a), xs)111
)PURR", "2 [10, 20] [1, 2]\n");112
}114
TEST(LangFeatures, NdarraySubscript) {115
// x[i] and x[i, j] subscripts on ndarrays: reads, writes, negatives, and the116
// typed `: ndarray<int>` parameter spelling (in-place updates reach the caller).117
e2e::expect_e2e("lang_ndarray_subscript", R"PURR(import io118
import ndarray120
fn light_pixel(row : ndarray<int>) {121
row[0] = 1122
}124
let grid = ndarray.reshape(ndarray.array([1.0, 2.0, 3.0, 4.0]), [2, 2])125
grid[1, 0] = 9.0126
io.print(grid[1, 0], grid[0, 1], grid[-1, -1])127
let row = ndarray.array([0, 0, 0])128
light_pixel(row)129
row[2] = 5130
io.print(ndarray.to_string(row))131
)PURR", "9 2 4\n[1, 0, 5]\n");132
}134
TEST(LangFeatures, MatchWildcardWithAccumulator) {135
// Regression: a `case _` wildcard has no pattern expression; the dead-let136
// analysis scanning a match for reads of `acc` must not dereference it.137
e2e::expect_e2e("lang_match_wildcard_acc", R"PURR(import io138
fn grade(n: int) {139
let acc = 0.0140
match n {141
case 1 {142
acc += 1.0143
}144
case _ {145
}146
}147
return acc148
}149
io.print(grade(1), grade(7))150
)PURR", "1 0\n");151
}153
TEST(LangFeatures, AppendAndDictMutation) {154
e2e::expect_e2e("lang_append", R"PURR(import io155
let xs: list<int> = []156
append(xs, 1)157
xs.append(2)158
xs.append(3)159
io.print(len(xs), xs[2])160
let counts: dict<str, int> = {}161
counts["x"] = 1162
counts["x"] = counts["x"] + 5163
io.print(counts["x"])164
)PURR",165
"3 3\n6\n");166
}168
TEST(LangFeatures, MethodPredicates) {169
e2e::expect_e2e("lang_methods", R"PURR(import io170
io.print("</div>".startswith("</"))171
io.print("hello".endswith("lo"))172
io.print("abcd".contains("bc"))173
)PURR",174
"True\nTrue\nTrue\n");175
}177
TEST(LangFeatures, StringSlicingAndIndex) {178
e2e::expect_e2e("lang_slice_str", R"PURR(import io179
let s = "hello world"180
io.print(s[0], s[-1])181
io.print(s[0:5])182
io.print(s[6:])183
io.print(s[:5])184
io.print(s[-5:])185
io.print(s[0] == "h")186
)PURR",187
"h d\nhello\nworld\nhello\nworld\nTrue\n");188
}190
TEST(LangFeatures, ListSlicingAndIndex) {191
e2e::expect_e2e("lang_slice_list", R"PURR(import io192
let nums = [10, 20, 30, 40, 50]193
io.print(nums[-1], nums[1])194
let mid = nums[1:4]195
io.print(len(mid), mid[0], mid[2])196
)PURR",197
"50 20\n3 20 40\n");198
}200
TEST(LangFeatures, ReturnTypeHints) {201
// Optional Python-style `-> Type` return hints. When present the function lowers with202
// that concrete C++ return type (the backend enforces it); when absent the return stays203
// `auto`. Mixed here: int, float, an ndarray, and an untyped function all interoperate.204
e2e::expect_e2e("lang_return_hints", R"PURR(import io205
import ndarray206
fn add(a : int, b : int) -> int {207
return a + b208
}209
fn half(x : float) -> float {210
return x / 2.0211
}212
fn untyped(x) {213
return x + 1214
}215
fn ones(n : int) -> ndarray<float> {216
let a = ndarray.zeros([n])217
for i in range(0, n) {218
a[i] = 1.0219
}220
return a221
}222
io.print(add(2, 3))223
io.print(half(9.0))224
io.print(untyped(41))225
io.print(ndarray.to_string(ones(3)))226
)PURR",227
"5\n4.5\n42\n[1, 1, 1]\n");228
}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++ carries233
// 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 `(`.235
TEST(LangFeatures, NonTypeTemplateArgs) {236
const std::string gen = e2e::expect_e2e_source("lang_nontype_targs", R"PURR(import io237
cpp {238
template <long long N>239
long long times_n(long long x) { return x * N; }241
template <typename T, long long N>242
T scale_n(T x) { return x * static_cast<T>(N); }243
}244
io.print(times_n<3>(7))245
io.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++";254
}256
// Compile-time `if constexpr (cond) {…}` — kept C++-style (parenthesised condition) and257
// lowered verbatim to C++ `if constexpr`, so the live branch is chosen at COMPILE time from258
// a constant condition. The constexpr-ness threads down the `else if constexpr` chain. A259
// cpp{} block supplies the constexpr fixtures so the program compiles AND runs; we also260
// assert the emitted C++ carries `if constexpr (` on both arms. Boolean logic (`and`,261
// comparisons) in the condition goes through the ordinary expression path.262
TEST(LangFeatures, IfConstexpr) {263
const std::string gen = e2e::expect_e2e_source("lang_if_constexpr", R"PURR(import io264
cpp {265
constexpr int kMode = 2;266
constexpr bool kOn = true;267
}268
fn 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
}276
}277
io.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`";287
}289
// ============================================================================290
// Compile-time `constexpr` family — `constexpr let` / `constexpr fn` /291
// `constexpr match`, plus the AUTO-promotion of `if`/`match` over a known292
// 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-time297
// constant, so a plain `if` over it AUTO-lowers to `if constexpr`.298
TEST(LangFeatures, ConstexprLetAutoIf) {299
const std::string gen = e2e::expect_e2e_source("lang_cx_let_autoif", R"PURR(import io300
constexpr let N = 4301
if (N == 4) {302
io.print("four")303
} else {304
io.print("other")305
}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`";311
}313
// Constant-folding initializer + reference to an earlier constexpr let (chained constants).314
TEST(LangFeatures, ConstexprLetArithmeticAndChain) {315
const std::string gen = e2e::expect_e2e_source("lang_cx_let_chain", R"PURR(import io316
constexpr let A = 2 * 3 + 1317
constexpr let B = A + 5318
if (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);324
}326
// Explicit type annotation on a constexpr let still emits `constexpr`.327
TEST(LangFeatures, ConstexprLetTyped) {328
const std::string gen = e2e::expect_e2e_source("lang_cx_let_typed", R"PURR(import io329
constexpr let x: int = 5330
if (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);335
}337
// Bool constexpr let drives an `if constexpr` with an `else` arm.338
TEST(LangFeatures, ConstexprLetBool) {339
e2e::expect_e2e("lang_cx_let_bool", R"PURR(import io340
constexpr let on = true341
if (on) { io.print("on") } else { io.print("off") }342
)PURR",343
"on\n");344
}346
// A purely-literal condition is itself a constant -> auto `if constexpr`, no `let` needed.347
TEST(LangFeatures, AutoIfLiteralCondition) {348
const std::string gen = e2e::expect_e2e_source("lang_cx_literal_if", R"PURR(import io349
if (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);353
}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).357
TEST(LangFeatures, RuntimeIfNotPromoted) {358
const std::string gen = e2e::expect_e2e_source("lang_cx_runtime_if", R"PURR(import io359
fn label(x: int) {360
if (x == 1) { return "one" } else { return "many" }361
}362
io.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`";367
}369
// `constexpr fn`: a constexpr function whose call folds inside a `constexpr let`, and which370
// is ALSO callable at runtime.371
TEST(LangFeatures, ConstexprFn) {372
const std::string gen = e2e::expect_e2e_source("lang_cx_fn", R"PURR(import io373
constexpr fn square(x) { return x * x }374
constexpr let r = square(5)375
if (r == 25) { io.print("r25") } else { io.print("no") }376
io.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";383
}385
// Explicit `constexpr match` over a constant subject -> compile-time `if constexpr` chain.386
TEST(LangFeatures, ConstexprMatchExplicit) {387
const std::string gen = e2e::expect_e2e_source("lang_cx_match_explicit", R"PURR(import io388
constexpr let k = 2389
constexpr match k {390
case 1 { io.print("one") }391
case 2 { io.print("two") }392
case _ { io.print("other") }393
}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";400
}402
// AUTO: a plain `match` over a constant subject with constant case labels also folds to the403
// compile-time `if constexpr` chain (no `constexpr` keyword on the match needed).404
TEST(LangFeatures, MatchAutoConstexpr) {405
const std::string gen = e2e::expect_e2e_source("lang_cx_match_auto", R"PURR(import io406
constexpr let k = 3407
match k {408
case 1 { io.print("a") }409
case 3 { io.print("c") }410
case _ { io.print("z") }411
}412
)PURR",413
"c\n");414
EXPECT_NE(gen.find("if constexpr ("), std::string::npos);415
}417
// A plain `match` on a RUNTIME integer lowers to a real C++ `switch` (default = `_`).418
TEST(LangFeatures, MatchRuntimeIntSwitch) {419
const std::string gen = e2e::expect_e2e_source("lang_match_switch", R"PURR(import io420
fn classify(n: int) {421
match n {422
case 1 { return "a" }423
case 2 { return "b" }424
case _ { return "c" }425
}426
}427
io.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);433
}435
// GUARD: a `match` on a STRING cannot be a switch (C++ forbids it) — it must fall back to the436
// `==` if/else-if chain. The most important correctness guard of the smart lowering.437
TEST(LangFeatures, MatchStringFallsBackToChain) {438
const std::string gen = e2e::expect_e2e_source("lang_match_string", R"PURR(import io439
fn code(s) {440
match s {441
case "red" { return 1 }442
case "green" { return 2 }443
case _ { return 0 }444
}445
}446
io.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)";451
}453
// GUARD: bool case labels are not switch labels here either -> `==` chain.454
TEST(LangFeatures, MatchBoolFallsBackToChain) {455
const std::string gen = e2e::expect_e2e_source("lang_match_bool", R"PURR(import io456
fn name(b) {457
match b {458
case true { return "yes" }459
case false { return "no" }460
}461
}462
io.print(name(true), name(false))463
)PURR",464
"yes no\n");465
EXPECT_EQ(gen.find("switch ("), std::string::npos);466
}468
// A switch case whose body RETURNS must not emit an unreachable trailing `break` (else469
// -Wunreachable-code/-Werror could reject it). Verifies it compiles and runs.470
TEST(LangFeatures, MatchSwitchCaseReturnsNoUnreachableBreak) {471
e2e::expect_e2e("lang_match_return", R"PURR(import io472
fn pick(n: int) {473
match n {474
case 1 { return 10 }475
case 2 { return 20 }476
case _ { return 0 }477
}478
}479
io.print(pick(1), pick(2), pick(5))480
)PURR",481
"10 20 0\n");482
}484
// Negative integer case labels are valid switch labels (`case (-1):`).485
TEST(LangFeatures, MatchNegativeIntSwitch) {486
const std::string gen = e2e::expect_e2e_source("lang_match_negative", R"PURR(import io487
fn sign(n: int) {488
match n {489
case -1 { return "neg" }490
case 0 { return "zero" }491
case _ { return "pos" }492
}493
}494
io.print(sign(-1), sign(0), sign(7))495
)PURR",496
"neg zero pos\n");497
EXPECT_NE(gen.find("switch ("), std::string::npos);498
}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 these504
// stay valid source. String-literal contexts (a struct's printed field label) keep the505
// ORIGINAL spelling, not the escaped one.506
TEST(LangFeatures, CppKeywordIdentifiersAreEscaped) {507
const std::string gen = e2e::expect_e2e_source("lang_cpp_keyword_escape", R"PURR(import io508
struct Box {509
new: int510
default: str511
fn delete(self) { return self.new }512
}513
fn make(new) {514
return Box({.new = new, .default = "d"})515
}516
let b = make(5)517
io.print(b.delete())518
io.print(b.default)519
let switch = 3520
for template in range(0, switch) { io.print(template) }521
try { 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, and525
// 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;534
}