|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// ParserTests.res — P0 validation: parse all examples, verify AST structure. |
| 5 | + |
| 6 | +open Lexer |
| 7 | +open Ast |
| 8 | +open Parser |
| 9 | + |
| 10 | +// ============================================================================ |
| 11 | +// Test infrastructure |
| 12 | +// ============================================================================ |
| 13 | + |
| 14 | +let passCount = ref(0) |
| 15 | +let failCount = ref(0) |
| 16 | + |
| 17 | +let test = (name: string, f: unit => unit) => { |
| 18 | + try { |
| 19 | + f() |
| 20 | + passCount := passCount.contents + 1 |
| 21 | + Console.log(` ✓ ${name}`) |
| 22 | + } catch { |
| 23 | + | exn => |
| 24 | + failCount := failCount.contents + 1 |
| 25 | + Console.error(` ✗ ${name}: ${exn->Exn.message->Option.getOr("unknown error")}`) |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +let assertEqual = (a, b, msg) => { |
| 30 | + if a != b { |
| 31 | + Exn.raiseError(msg) |
| 32 | + } |
| 33 | +} |
| 34 | + |
| 35 | +let assertTrue = (v, msg) => { |
| 36 | + if !v { |
| 37 | + Exn.raiseError(msg) |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +let assertOk = result => { |
| 42 | + switch result { |
| 43 | + | Ok(v) => v |
| 44 | + | Error(err: parseError) => |
| 45 | + Exn.raiseError( |
| 46 | + `Parse failed at ${err.loc.line->Int.toString}:${err.loc.col->Int.toString}: ${err.message}`, |
| 47 | + ) |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +// ============================================================================ |
| 52 | +// Test inputs |
| 53 | +// ============================================================================ |
| 54 | + |
| 55 | +let exampleMinimal = ` |
| 56 | +region Empty { |
| 57 | + value: i32, |
| 58 | +} |
| 59 | +` |
| 60 | + |
| 61 | +let exampleEmpty = `` |
| 62 | + |
| 63 | +let exampleCommentsOnly = `// This file has only comments |
| 64 | +// No declarations at all |
| 65 | +` |
| 66 | + |
| 67 | +let example01 = ` |
| 68 | +region Vec2 { |
| 69 | + x: f32, |
| 70 | + y: f32, |
| 71 | +} |
| 72 | +
|
| 73 | +region Players[100] { |
| 74 | + hp: i32 where 0 <= hp <= 9999, |
| 75 | + pos: @Vec2, |
| 76 | + name_ptr: ptr<u8>, |
| 77 | + target: opt<@Players>, |
| 78 | +} |
| 79 | +
|
| 80 | +fn move_player( |
| 81 | + players: &mut region<Players>, |
| 82 | + idx: i32, |
| 83 | + dx: f32, |
| 84 | + dy: f32 |
| 85 | +) -> i32 |
| 86 | + effects ReadRegion(Players), WriteRegion(Players), ReadRegion(Vec2), WriteRegion(Vec2) |
| 87 | +{ |
| 88 | + region.get $players[idx] .hp -> current_hp; |
| 89 | + if current_hp <= 0 { |
| 90 | + return 0; |
| 91 | + } |
| 92 | + region.get $players[idx] .pos.x -> old_x; |
| 93 | + region.set $players[idx] .pos.x, old_x + dx; |
| 94 | + return 1; |
| 95 | +} |
| 96 | +` |
| 97 | + |
| 98 | +// ============================================================================ |
| 99 | +// Lexer Tests |
| 100 | +// ============================================================================ |
| 101 | + |
| 102 | +Console.log("--- Lexer Tests ---") |
| 103 | + |
| 104 | +test("tokenize minimal region", () => { |
| 105 | + let tokens = Lexer.tokenize(exampleMinimal) |
| 106 | + assertTrue(tokens->Array.length > 0, "Should produce tokens") |
| 107 | + let first = tokens->Array.getUnsafe(0) |
| 108 | + assertEqual(first.value, Region, "First token should be Region keyword") |
| 109 | +}) |
| 110 | + |
| 111 | +test("tokenize empty string", () => { |
| 112 | + let tokens = Lexer.tokenize(exampleEmpty) |
| 113 | + assertTrue(tokens->Array.length >= 0, "Should not crash on empty input") |
| 114 | +}) |
| 115 | + |
| 116 | +test("tokenize comments only", () => { |
| 117 | + let tokens = Lexer.tokenize(exampleCommentsOnly) |
| 118 | + assertTrue(tokens->Array.length >= 0, "Should handle comments-only input") |
| 119 | +}) |
| 120 | + |
| 121 | +test("tokenize region.get compound token", () => { |
| 122 | + let tokens = Lexer.tokenize("region.get $players .hp -> val;") |
| 123 | + let hasRegionGet = tokens->Array.some(t => t.value == RegionGet) |
| 124 | + assertTrue(hasRegionGet, "Should tokenize region.get as compound token") |
| 125 | +}) |
| 126 | + |
| 127 | +test("tokenize region.set compound token", () => { |
| 128 | + let tokens = Lexer.tokenize("region.set $players .hp, 100;") |
| 129 | + let hasRegionSet = tokens->Array.some(t => t.value == RegionSet) |
| 130 | + assertTrue(hasRegionSet, "Should tokenize region.set as compound token") |
| 131 | +}) |
| 132 | + |
| 133 | +test("tokenize pointer types", () => { |
| 134 | + let tokens = Lexer.tokenize("ptr<u8>") |
| 135 | + let hasPtr = tokens->Array.some(t => t.value == Ptr) |
| 136 | + assertTrue(hasPtr, "Should tokenize ptr keyword") |
| 137 | +}) |
| 138 | + |
| 139 | +test("tokenize opt type", () => { |
| 140 | + let tokens = Lexer.tokenize("opt<@Players>") |
| 141 | + let hasOpt = tokens->Array.some(t => t.value == Opt) |
| 142 | + assertTrue(hasOpt, "Should tokenize opt keyword") |
| 143 | +}) |
| 144 | + |
| 145 | +test("tokenize effect keywords", () => { |
| 146 | + let tokens = Lexer.tokenize("effects ReadRegion(Players), WriteRegion(Players)") |
| 147 | + let hasEffects = tokens->Array.some(t => t.value == Effects) |
| 148 | + let hasRead = tokens->Array.some(t => t.value == EffReadRegion) |
| 149 | + let hasWrite = tokens->Array.some(t => t.value == EffWriteRegion) |
| 150 | + assertTrue(hasEffects, "Should tokenize effects keyword") |
| 151 | + assertTrue(hasRead, "Should tokenize ReadRegion") |
| 152 | + assertTrue(hasWrite, "Should tokenize WriteRegion") |
| 153 | +}) |
| 154 | + |
| 155 | +test("tokenize example01 without error", () => { |
| 156 | + let tokens = Lexer.tokenize(example01) |
| 157 | + assertTrue(tokens->Array.length > 50, "Example01 should produce many tokens") |
| 158 | +}) |
| 159 | + |
| 160 | +// ============================================================================ |
| 161 | +// Parser Tests |
| 162 | +// ============================================================================ |
| 163 | + |
| 164 | +Console.log("--- Parser Tests ---") |
| 165 | + |
| 166 | +test("parse minimal region", () => { |
| 167 | + let module_ = parseModule(exampleMinimal)->assertOk |
| 168 | + assertEqual(module_.declarations->Array.length, 1, "Should have 1 declaration") |
| 169 | + let decl = module_.declarations->Array.getUnsafe(0) |
| 170 | + switch decl.value { |
| 171 | + | RegionDecl(r) => |
| 172 | + assertEqual(r.name, "Empty", "Region name should be Empty") |
| 173 | + assertEqual(r.fields->Array.length, 1, "Should have 1 field") |
| 174 | + | _ => Exn.raiseError("Expected RegionDecl") |
| 175 | + } |
| 176 | +}) |
| 177 | + |
| 178 | +test("parse empty file", () => { |
| 179 | + let result = parseModule(exampleEmpty) |
| 180 | + switch result { |
| 181 | + | Ok(m) => assertEqual(m.declarations->Array.length, 0, "Empty file should have 0 decls") |
| 182 | + | Error(_) => () // Parse error on empty is also acceptable |
| 183 | + } |
| 184 | +}) |
| 185 | + |
| 186 | +test("parse comments-only file", () => { |
| 187 | + let result = parseModule(exampleCommentsOnly) |
| 188 | + switch result { |
| 189 | + | Ok(m) => assertEqual(m.declarations->Array.length, 0, "Comments only should have 0 decls") |
| 190 | + | Error(_) => () |
| 191 | + } |
| 192 | +}) |
| 193 | + |
| 194 | +test("parse region with constraint", () => { |
| 195 | + let src = ` |
| 196 | +region Health[100] { |
| 197 | + hp: i32 where 0 <= hp <= 9999, |
| 198 | + max_hp: i32, |
| 199 | +} |
| 200 | +` |
| 201 | + let module_ = parseModule(src)->assertOk |
| 202 | + assertEqual(module_.declarations->Array.length, 1, "Should have 1 declaration") |
| 203 | + let decl = module_.declarations->Array.getUnsafe(0) |
| 204 | + switch decl.value { |
| 205 | + | RegionDecl(r) => |
| 206 | + assertEqual(r.name, "Health", "Region name should be Health") |
| 207 | + assertTrue(r.instanceCount->Option.isSome, "Should have instance count") |
| 208 | + assertEqual(r.fields->Array.length, 2, "Should have 2 fields") |
| 209 | + | _ => Exn.raiseError("Expected RegionDecl") |
| 210 | + } |
| 211 | +}) |
| 212 | + |
| 213 | +test("parse function with effects", () => { |
| 214 | + let src = ` |
| 215 | +fn read_hp( |
| 216 | + players: ®ion<Players>, |
| 217 | + idx: i32 |
| 218 | +) -> i32 |
| 219 | + effects ReadRegion(Players) |
| 220 | +{ |
| 221 | + region.get $players[idx] .hp -> val; |
| 222 | + return val; |
| 223 | +} |
| 224 | +` |
| 225 | + let module_ = parseModule(src)->assertOk |
| 226 | + assertEqual(module_.declarations->Array.length, 1, "Should have 1 declaration") |
| 227 | + let decl = module_.declarations->Array.getUnsafe(0) |
| 228 | + switch decl.value { |
| 229 | + | FunctionDecl(f) => |
| 230 | + assertEqual(f.name, "read_hp", "Function name should be read_hp") |
| 231 | + assertTrue(f.effects->Array.length >= 1, "Should have at least 1 effect") |
| 232 | + assertTrue(f.body->Array.length >= 1, "Should have body statements") |
| 233 | + | _ => Exn.raiseError("Expected FunctionDecl") |
| 234 | + } |
| 235 | +}) |
| 236 | + |
| 237 | +test("parse example01 (single module)", () => { |
| 238 | + let module_ = parseModule(example01)->assertOk |
| 239 | + // Should have: Vec2 region, Players region, move_player function |
| 240 | + assertTrue( |
| 241 | + module_.declarations->Array.length >= 3, |
| 242 | + "Example01 should have at least 3 declarations", |
| 243 | + ) |
| 244 | +}) |
| 245 | + |
| 246 | +test("region fields have correct types", () => { |
| 247 | + let src = ` |
| 248 | +region TestTypes { |
| 249 | + a: i32, |
| 250 | + b: f64, |
| 251 | + c: u8, |
| 252 | + d: ptr<i32>, |
| 253 | +} |
| 254 | +` |
| 255 | + let module_ = parseModule(src)->assertOk |
| 256 | + let decl = module_.declarations->Array.getUnsafe(0) |
| 257 | + switch decl.value { |
| 258 | + | RegionDecl(r) => |
| 259 | + assertEqual(r.fields->Array.length, 4, "Should have 4 fields") |
| 260 | + assertEqual(r.fields->Array.getUnsafe(0).name, "a", "First field should be 'a'") |
| 261 | + | _ => Exn.raiseError("Expected RegionDecl") |
| 262 | + } |
| 263 | +}) |
| 264 | + |
| 265 | +test("parse memory declaration", () => { |
| 266 | + let src = ` |
| 267 | +memory Main { |
| 268 | + pages: 256, |
| 269 | + max_pages: 1024, |
| 270 | +} |
| 271 | +` |
| 272 | + let module_ = parseModule(src)->assertOk |
| 273 | + assertEqual(module_.declarations->Array.length, 1, "Should have 1 declaration") |
| 274 | + let decl = module_.declarations->Array.getUnsafe(0) |
| 275 | + switch decl.value { |
| 276 | + | MemoryDecl(_) => () |
| 277 | + | _ => Exn.raiseError("Expected MemoryDecl") |
| 278 | + } |
| 279 | +}) |
| 280 | + |
| 281 | +// ============================================================================ |
| 282 | +// Summary |
| 283 | +// ============================================================================ |
| 284 | + |
| 285 | +Console.log("") |
| 286 | +Console.log( |
| 287 | + `--- Results: ${passCount.contents->Int.toString} passed, ${failCount.contents->Int.toString} failed ---`, |
| 288 | +) |
| 289 | +if failCount.contents > 0 { |
| 290 | + Process.exit(1) |
| 291 | +} |
0 commit comments