redscript-mc 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +86 -21
  3. package/README.zh.md +61 -61
  4. package/dist/src/__tests__/e2e/basic.test.js +25 -0
  5. package/dist/src/__tests__/e2e/coroutine.test.js +22 -0
  6. package/dist/src/__tests__/lsp.test.js +76 -0
  7. package/dist/src/__tests__/mc-integration.test.js +25 -13
  8. package/dist/src/__tests__/mc-syntax.test.js +1 -6
  9. package/dist/src/__tests__/schedule.test.js +105 -0
  10. package/dist/src/__tests__/stdlib-include.test.d.ts +1 -0
  11. package/dist/src/__tests__/stdlib-include.test.js +86 -0
  12. package/dist/src/__tests__/typechecker.test.js +63 -0
  13. package/dist/src/cli.js +10 -3
  14. package/dist/src/compile.d.ts +1 -0
  15. package/dist/src/compile.js +33 -10
  16. package/dist/src/emit/compile.d.ts +2 -0
  17. package/dist/src/emit/compile.js +3 -2
  18. package/dist/src/emit/index.js +3 -1
  19. package/dist/src/lir/lower.js +26 -0
  20. package/dist/src/lsp/server.js +51 -0
  21. package/dist/src/mir/lower.js +341 -12
  22. package/dist/src/mir/types.d.ts +10 -0
  23. package/dist/src/optimizer/copy_prop.js +4 -0
  24. package/dist/src/optimizer/coroutine.d.ts +2 -0
  25. package/dist/src/optimizer/coroutine.js +33 -1
  26. package/dist/src/optimizer/dce.js +7 -1
  27. package/dist/src/optimizer/lir/const_imm.js +1 -1
  28. package/dist/src/optimizer/lir/dead_slot.js +1 -1
  29. package/dist/src/typechecker/index.d.ts +2 -0
  30. package/dist/src/typechecker/index.js +29 -0
  31. package/docs/ROADMAP.md +35 -0
  32. package/editors/vscode/package-lock.json +3 -3
  33. package/editors/vscode/package.json +1 -1
  34. package/editors/vscode/syntaxes/redscript.tmLanguage.json +34 -0
  35. package/examples/coroutine-demo.mcrs +51 -0
  36. package/examples/enum-demo.mcrs +95 -0
  37. package/examples/scheduler-demo.mcrs +59 -0
  38. package/jest.config.js +19 -0
  39. package/package.json +1 -1
  40. package/src/__tests__/e2e/basic.test.ts +27 -0
  41. package/src/__tests__/e2e/coroutine.test.ts +23 -0
  42. package/src/__tests__/fixtures/array-test.mcrs +21 -22
  43. package/src/__tests__/fixtures/counter.mcrs +17 -0
  44. package/src/__tests__/fixtures/foreach-at-test.mcrs +9 -10
  45. package/src/__tests__/lsp.test.ts +89 -0
  46. package/src/__tests__/mc-integration.test.ts +25 -13
  47. package/src/__tests__/mc-syntax.test.ts +1 -7
  48. package/src/__tests__/schedule.test.ts +112 -0
  49. package/src/__tests__/stdlib-include.test.ts +61 -0
  50. package/src/__tests__/typechecker.test.ts +68 -0
  51. package/src/cli.ts +9 -1
  52. package/src/compile.ts +44 -15
  53. package/src/emit/compile.ts +5 -2
  54. package/src/emit/index.ts +3 -1
  55. package/src/lir/lower.ts +27 -0
  56. package/src/lsp/server.ts +55 -0
  57. package/src/mir/lower.ts +355 -9
  58. package/src/mir/types.ts +4 -0
  59. package/src/optimizer/copy_prop.ts +4 -0
  60. package/src/optimizer/coroutine.ts +37 -1
  61. package/src/optimizer/dce.ts +6 -1
  62. package/src/optimizer/lir/const_imm.ts +1 -1
  63. package/src/optimizer/lir/dead_slot.ts +1 -1
  64. package/src/stdlib/timer.mcrs +10 -5
  65. package/src/typechecker/index.ts +39 -0
  66. package/examples/spiral.mcrs +0 -43
  67. package/src/examples/arena.mcrs +0 -44
  68. package/src/examples/counter.mcrs +0 -12
  69. package/src/examples/new_features_demo.mcrs +0 -193
  70. package/src/examples/rpg.mcrs +0 -13
  71. package/src/examples/stdlib_demo.mcrs +0 -181
@@ -10,6 +10,7 @@ import { Parser } from '../parser'
10
10
  import { TypeChecker } from '../typechecker'
11
11
  import { DiagnosticError } from '../diagnostics'
12
12
  import type { Program, FnDecl, TypeNode } from '../ast/types'
13
+ import { BUILTIN_METADATA } from '../builtins/metadata'
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Helpers mirrored from lsp/server.ts (tested independently)
@@ -255,6 +256,94 @@ fn anotherFn(x: int): int { return x; }
255
256
  })
256
257
  })
257
258
 
259
+ // ---------------------------------------------------------------------------
260
+ // Hover — builtin functions
261
+ // ---------------------------------------------------------------------------
262
+
263
+ const DECORATOR_DOCS: Record<string, string> = {
264
+ tick: 'Runs every MC game tick (~20 Hz). No arguments.',
265
+ load: 'Runs on `/reload`. Use for initialization logic.',
266
+ coroutine: 'Splits loops into tick-spread continuations. Arg: `batch=N` (steps per tick, default 1).',
267
+ schedule: 'Schedules the function to run after N ticks. Arg: `ticks=N`.',
268
+ on_trigger: 'Runs when a trigger scoreboard objective is set by a player. Arg: trigger name.',
269
+ keep: 'Prevents the compiler from dead-code-eliminating this function.',
270
+ on: 'Generic event handler decorator.',
271
+ on_advancement: 'Runs when a player earns an advancement. Arg: advancement id.',
272
+ on_craft: 'Runs when a player crafts an item. Arg: item id.',
273
+ on_death: 'Runs when a player dies.',
274
+ on_join_team: 'Runs when a player joins a team. Arg: team name.',
275
+ on_login: 'Runs when a player logs in.',
276
+ }
277
+
278
+ describe('LSP hover — builtin functions', () => {
279
+ it('has metadata for say', () => {
280
+ const b = BUILTIN_METADATA['say']
281
+ expect(b).toBeDefined()
282
+ expect(b.params.length).toBeGreaterThan(0)
283
+ expect(b.returns).toBe('void')
284
+ expect(b.doc).toBeTruthy()
285
+ })
286
+
287
+ it('has metadata for kill', () => {
288
+ const b = BUILTIN_METADATA['kill']
289
+ expect(b).toBeDefined()
290
+ expect(b.returns).toBe('void')
291
+ })
292
+
293
+ it('has metadata for tellraw', () => {
294
+ const b = BUILTIN_METADATA['tellraw']
295
+ expect(b).toBeDefined()
296
+ const paramStr = b.params.map(p => `${p.name}: ${p.type}${p.required ? '' : '?'}`).join(', ')
297
+ const sig = `fn ${b.name}(${paramStr}): ${b.returns}`
298
+ expect(sig).toMatch(/^fn tellraw/)
299
+ expect(sig).toContain('target')
300
+ })
301
+
302
+ it('formats builtin hover markdown', () => {
303
+ const b = BUILTIN_METADATA['particle']
304
+ expect(b).toBeDefined()
305
+ const paramStr = b.params.map(p => `${p.name}: ${p.type}${p.required ? '' : '?'}`).join(', ')
306
+ const sig = `fn ${b.name}(${paramStr}): ${b.returns}`
307
+ const markdown = `\`\`\`redscript\n${sig}\n\`\`\`\n${b.doc}`
308
+ expect(markdown).toContain('```redscript')
309
+ expect(markdown).toContain('fn particle')
310
+ expect(markdown).toContain(b.doc)
311
+ })
312
+ })
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Hover — decorators
316
+ // ---------------------------------------------------------------------------
317
+
318
+ describe('LSP hover — decorators', () => {
319
+ it('has docs for @tick', () => {
320
+ expect(DECORATOR_DOCS['tick']).toContain('tick')
321
+ })
322
+
323
+ it('has docs for @load', () => {
324
+ expect(DECORATOR_DOCS['load']).toContain('reload')
325
+ })
326
+
327
+ it('has docs for @coroutine', () => {
328
+ expect(DECORATOR_DOCS['coroutine']).toContain('batch')
329
+ })
330
+
331
+ it('has docs for @schedule', () => {
332
+ expect(DECORATOR_DOCS['schedule']).toContain('ticks')
333
+ })
334
+
335
+ it('has docs for @on_trigger', () => {
336
+ expect(DECORATOR_DOCS['on_trigger']).toContain('trigger')
337
+ })
338
+
339
+ it('formats decorator hover markdown', () => {
340
+ const name = 'tick'
341
+ const doc = DECORATOR_DOCS[name]
342
+ const markdown = `**@${name}** — ${doc}`
343
+ expect(markdown).toBe(`**@tick** — ${DECORATOR_DOCS['tick']}`)
344
+ })
345
+ })
346
+
258
347
  // ---------------------------------------------------------------------------
259
348
  // Server module import (smoke test — does not start stdio)
260
349
  // ---------------------------------------------------------------------------
@@ -82,13 +82,25 @@ beforeAll(async () => {
82
82
  return
83
83
  }
84
84
 
85
- // ── Write fixtures + use safe reloadData (no /reload confirm) ───────
86
- // counter.mcrs
87
- if (fs.existsSync(path.join(__dirname, '../examples/counter.mcrs'))) {
88
- writeFixture(fs.readFileSync(path.join(__dirname, '../examples/counter.mcrs'), 'utf-8'), 'counter')
85
+ // ── Clear stale minecraft tag files before writing fixtures ──────────
86
+ for (const tagFile of ['data/minecraft/tags/function/tick.json', 'data/minecraft/tags/function/load.json',
87
+ 'data/minecraft/tags/functions/tick.json', 'data/minecraft/tags/functions/load.json']) {
88
+ const p = path.join(DATAPACK_DIR, tagFile)
89
+ if (fs.existsSync(p)) fs.writeFileSync(p, JSON.stringify({ values: [] }, null, 2))
89
90
  }
90
- if (fs.existsSync(path.join(__dirname, '../examples/world_manager.mcrs'))) {
91
- writeFixture(fs.readFileSync(path.join(__dirname, '../examples/world_manager.mcrs'), 'utf-8'), 'world_manager')
91
+
92
+ // ── Write fixtures + use safe reloadData (no /reload confirm) ───────
93
+ // counter.mcrs (use fixtures if examples was removed)
94
+ const counterSrc = fs.existsSync(path.join(__dirname, '../examples/counter.mcrs'))
95
+ ? fs.readFileSync(path.join(__dirname, '../examples/counter.mcrs'), 'utf-8')
96
+ : fs.readFileSync(path.join(__dirname, 'fixtures/counter.mcrs'), 'utf-8')
97
+ writeFixture(counterSrc, 'counter')
98
+ // world_manager.mcrs
99
+ const wmPath = fs.existsSync(path.join(__dirname, '../examples/world_manager.mcrs'))
100
+ ? path.join(__dirname, '../examples/world_manager.mcrs')
101
+ : path.join(__dirname, '../src/examples/world_manager.mcrs')
102
+ if (fs.existsSync(wmPath)) {
103
+ writeFixture(fs.readFileSync(wmPath, 'utf-8'), 'world_manager')
92
104
  }
93
105
  writeFixture(`
94
106
  @tick
@@ -402,9 +414,9 @@ describe('MC Integration Tests', () => {
402
414
 
403
415
  await mc.fullReset({ clearArea: false, killEntities: true, resetScoreboards: false })
404
416
 
405
- await mc.command('/summon minecraft:armor_stand 0 65 0')
406
- await mc.command('/summon minecraft:armor_stand 2 65 0')
407
- await mc.command('/summon minecraft:armor_stand 4 65 0')
417
+ await mc.command('/summon minecraft:armor_stand 0 65 0 {NoGravity:1b}')
418
+ await mc.command('/summon minecraft:armor_stand 2 65 0 {NoGravity:1b}')
419
+ await mc.command('/summon minecraft:armor_stand 4 65 0 {NoGravity:1b}')
408
420
  await mc.ticks(5)
409
421
 
410
422
  const stands = await mc.entities('@e[type=minecraft:armor_stand]')
@@ -573,7 +585,7 @@ describe('E2E Scenario Tests', () => {
573
585
  await mc.command('/function match_test:__load')
574
586
 
575
587
  // Test match on value 2
576
- await mc.command('/scoreboard players set $p0 rs 2')
588
+ await mc.command('/scoreboard players set $p0 __match_test 2')
577
589
  await mc.command('/function match_test:classify')
578
590
  await mc.ticks(5)
579
591
  let out = await mc.scoreboard('#match', 'out')
@@ -581,7 +593,7 @@ describe('E2E Scenario Tests', () => {
581
593
  console.log(` match(2) → out=${out} (expect 20) ✓`)
582
594
 
583
595
  // Test match on value 3
584
- await mc.command('/scoreboard players set $p0 rs 3')
596
+ await mc.command('/scoreboard players set $p0 __match_test 3')
585
597
  await mc.command('/function match_test:classify')
586
598
  await mc.ticks(5)
587
599
  out = await mc.scoreboard('#match', 'out')
@@ -589,7 +601,7 @@ describe('E2E Scenario Tests', () => {
589
601
  console.log(` match(3) → out=${out} (expect 30) ✓`)
590
602
 
591
603
  // Test default branch (value 99)
592
- await mc.command('/scoreboard players set $p0 rs 99')
604
+ await mc.command('/scoreboard players set $p0 __match_test 99')
593
605
  await mc.command('/function match_test:classify')
594
606
  await mc.ticks(5)
595
607
  out = await mc.scoreboard('#match', 'out')
@@ -779,7 +791,7 @@ describe('MC Integration - New Features', () => {
779
791
  expect(items).toBe(1) // 1 item matched
780
792
 
781
793
  await mc.command('/function is_check_test:cleanup').catch(() => {})
782
- })
794
+ }, 30000) // extended timeout: entity spawn + reload can take >5 s
783
795
 
784
796
  test('event-test.mcrs: @on(PlayerDeath) compiles and loads', async () => {
785
797
  if (!serverOnline) return
@@ -5,7 +5,7 @@ import { compile } from '../compile'
5
5
  import { MCCommandValidator } from '../mc-validator'
6
6
 
7
7
  const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'mc-commands-1.21.4.json')
8
- const EXAMPLES = ['counter', 'arena', 'shop', 'quiz', 'turret']
8
+ const EXAMPLES = ['shop', 'quiz', 'turret']
9
9
 
10
10
  function getCommands(source: string, namespace = 'test'): string[] {
11
11
  const result = compile(source, { namespace })
@@ -32,12 +32,6 @@ function validateSource(
32
32
  describe('MC Command Syntax Validation', () => {
33
33
  const validator = new MCCommandValidator(FIXTURE_PATH)
34
34
 
35
- test('counter example generates valid MC commands', () => {
36
- const src = fs.readFileSync(path.join(__dirname, '..', 'examples', 'counter.mcrs'), 'utf-8')
37
- const errors = validateSource(validator, src, 'counter')
38
- expect(errors).toHaveLength(0)
39
- })
40
-
41
35
  EXAMPLES.forEach(name => {
42
36
  test(`${name}.mcrs generates valid MC commands`, () => {
43
37
  const src = fs.readFileSync(path.join(__dirname, '..', 'examples', `${name}.mcrs`), 'utf-8')
@@ -103,3 +103,115 @@ describe('@schedule decorator', () => {
103
103
  expect(startFn).toContain('function test:_schedule_after_one_second')
104
104
  })
105
105
  })
106
+
107
+ describe('setTimeout / setInterval codegen', () => {
108
+ test('setTimeout lifts lambda to __timeout_callback_0 and schedules it', () => {
109
+ const source = `
110
+ fn start() {
111
+ setTimeout(20, () => {
112
+ say("later");
113
+ });
114
+ }
115
+ `
116
+ const result = compile(source, { namespace: 'ns' })
117
+ const startFn = getFile(result.files, 'start.mcfunction')
118
+ const cbFn = getFile(result.files, '__timeout_callback_0.mcfunction')
119
+ expect(startFn).toContain('schedule function ns:__timeout_callback_0 20t')
120
+ expect(cbFn).toBeDefined()
121
+ expect(cbFn).toContain('say later')
122
+ })
123
+
124
+ test('setInterval lambda reschedules itself at the end', () => {
125
+ const source = `
126
+ fn start() {
127
+ setInterval(10, () => {
128
+ say("tick");
129
+ });
130
+ }
131
+ `
132
+ const result = compile(source, { namespace: 'ns' })
133
+ const cbFn = getFile(result.files, '__timeout_callback_0.mcfunction')
134
+ expect(cbFn).toBeDefined()
135
+ expect(cbFn).toContain('schedule function ns:__timeout_callback_0 10t')
136
+ })
137
+
138
+ test('multiple setTimeout calls get unique callback names', () => {
139
+ const source = `
140
+ fn start() {
141
+ setTimeout(10, () => { say("a"); });
142
+ setTimeout(20, () => { say("b"); });
143
+ }
144
+ `
145
+ const result = compile(source, { namespace: 'ns' })
146
+ const cb0 = getFile(result.files, '__timeout_callback_0.mcfunction')
147
+ const cb1 = getFile(result.files, '__timeout_callback_1.mcfunction')
148
+ expect(cb0).toBeDefined()
149
+ expect(cb1).toBeDefined()
150
+ expect(cb0).toContain('say a')
151
+ expect(cb1).toContain('say b')
152
+ })
153
+ })
154
+
155
+ const TIMER_STRUCT = `
156
+ struct Timer {
157
+ _id: int,
158
+ _duration: int
159
+ }
160
+ impl Timer {
161
+ fn new(duration: int) -> Timer {
162
+ return { _id: 0, _duration: duration };
163
+ }
164
+ fn start(self) {}
165
+ fn pause(self) {}
166
+ fn reset(self) {}
167
+ fn tick(self) {}
168
+ fn done(self) -> bool { return false; }
169
+ fn elapsed(self) -> int { return 0; }
170
+ }
171
+ `
172
+
173
+ describe('Timer static allocation codegen', () => {
174
+ test('Timer::new() initializes unique scoreboard slots', () => {
175
+ const source = TIMER_STRUCT + `
176
+ fn init() {
177
+ let t: Timer = Timer::new(20);
178
+ }
179
+ `
180
+ const result = compile(source, { namespace: 'ns' })
181
+ const initFn = getFile(result.files, 'init.mcfunction')
182
+ expect(initFn).toContain('scoreboard players set __timer_0_ticks ns 0')
183
+ expect(initFn).toContain('scoreboard players set __timer_0_active ns 0')
184
+ })
185
+
186
+ test('Timer.start() inlines to scoreboard set active=1', () => {
187
+ const source = TIMER_STRUCT + `
188
+ fn init() {
189
+ let t: Timer = Timer::new(20);
190
+ t.start();
191
+ }
192
+ `
193
+ const result = compile(source, { namespace: 'ns' })
194
+ const initFn = getFile(result.files, 'init.mcfunction')
195
+ expect(initFn).toContain('scoreboard players set __timer_0_active ns 1')
196
+ expect(initFn).not.toContain('function ns:timer/start')
197
+ })
198
+
199
+ test('two Timer::new() calls get distinct IDs', () => {
200
+ const source = TIMER_STRUCT + `
201
+ fn init() {
202
+ let t0: Timer = Timer::new(10);
203
+ let t1: Timer = Timer::new(20);
204
+ t0.start();
205
+ t1.start();
206
+ }
207
+ `
208
+ const result = compile(source, { namespace: 'ns' })
209
+ const initFn = getFile(result.files, 'init.mcfunction')
210
+ // Both timers initialized
211
+ expect(initFn).toContain('__timer_0_ticks')
212
+ expect(initFn).toContain('__timer_1_ticks')
213
+ // Both started with unique slot names
214
+ expect(initFn).toContain('scoreboard players set __timer_0_active ns 1')
215
+ expect(initFn).toContain('scoreboard players set __timer_1_active ns 1')
216
+ })
217
+ })
@@ -0,0 +1,61 @@
1
+ import * as path from 'path'
2
+ import * as fs from 'fs'
3
+ import * as os from 'os'
4
+ import { compile } from '../emit/compile'
5
+
6
+ describe('stdlib include path', () => {
7
+ it('import "stdlib/math" resolves to the stdlib math module', () => {
8
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
9
+ const mainPath = path.join(tempDir, 'main.mcrs')
10
+ fs.writeFileSync(mainPath, 'import "stdlib/math";\nfn main() { let x: int = abs(-5); }\n')
11
+ const source = fs.readFileSync(mainPath, 'utf-8')
12
+
13
+ const result = compile(source, { namespace: 'test', filePath: mainPath })
14
+ expect(result.files.some(f => f.path.includes('abs'))).toBe(true)
15
+ })
16
+
17
+ it('import "stdlib/math.mcrs" also resolves (explicit extension)', () => {
18
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
19
+ const mainPath = path.join(tempDir, 'main.mcrs')
20
+ fs.writeFileSync(mainPath, 'import "stdlib/math.mcrs";\nfn main() { let x: int = abs(-5); }\n')
21
+ const source = fs.readFileSync(mainPath, 'utf-8')
22
+
23
+ const result = compile(source, { namespace: 'test', filePath: mainPath })
24
+ expect(result.files.some(f => f.path.includes('abs'))).toBe(true)
25
+ })
26
+
27
+ it('import "stdlib/vec" resolves to the stdlib vec module', () => {
28
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
29
+ const mainPath = path.join(tempDir, 'main.mcrs')
30
+ fs.writeFileSync(mainPath, 'import "stdlib/vec";\nfn main() { let d: int = dot2d(1, 2, 3, 4); }\n')
31
+ const source = fs.readFileSync(mainPath, 'utf-8')
32
+
33
+ const result = compile(source, { namespace: 'test', filePath: mainPath })
34
+ expect(result.files.some(f => f.path.includes('dot2d'))).toBe(true)
35
+ })
36
+
37
+ it('non-existent stdlib module gives a clear error', () => {
38
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-stdlib-'))
39
+ const mainPath = path.join(tempDir, 'main.mcrs')
40
+ fs.writeFileSync(mainPath, 'import "stdlib/nonexistent";\nfn main() {}\n')
41
+ const source = fs.readFileSync(mainPath, 'utf-8')
42
+
43
+ expect(() => compile(source, { namespace: 'test', filePath: mainPath }))
44
+ .toThrow(/Cannot import/)
45
+ })
46
+
47
+ it('--include flag allows importing from custom directory', () => {
48
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-include-'))
49
+ const libDir = path.join(tempDir, 'mylibs')
50
+ fs.mkdirSync(libDir)
51
+ const mainPath = path.join(tempDir, 'main.mcrs')
52
+ const libPath = path.join(libDir, 'helpers.mcrs')
53
+
54
+ fs.writeFileSync(libPath, 'fn triple(x: int) -> int { return x + x + x; }\n')
55
+ fs.writeFileSync(mainPath, 'import "helpers";\nfn main() { let x: int = triple(3); }\n')
56
+ const source = fs.readFileSync(mainPath, 'utf-8')
57
+
58
+ const result = compile(source, { namespace: 'test', filePath: mainPath, includeDirs: [libDir] })
59
+ expect(result.files.some(f => f.path.includes('triple'))).toBe(true)
60
+ })
61
+ })
@@ -270,6 +270,74 @@ fn test() {
270
270
  expect(errors[0].message).toContain('Return type mismatch: expected void, got int')
271
271
  })
272
272
 
273
+ it('rejects setTimeout inside a loop', () => {
274
+ const errors = typeCheck(`
275
+ fn test() {
276
+ while (true) {
277
+ setTimeout(20, () => { say("x"); });
278
+ }
279
+ }
280
+ `)
281
+ expect(errors.length).toBeGreaterThan(0)
282
+ expect(errors[0].message).toContain('cannot be called inside a loop')
283
+ })
284
+
285
+ it('rejects setTimeout inside an if body', () => {
286
+ const errors = typeCheck(`
287
+ fn test() {
288
+ if (true) {
289
+ setTimeout(20, () => { say("x"); });
290
+ }
291
+ }
292
+ `)
293
+ expect(errors.length).toBeGreaterThan(0)
294
+ expect(errors[0].message).toContain('cannot be called inside an if/else body')
295
+ })
296
+
297
+ it('rejects setInterval inside a loop', () => {
298
+ const errors = typeCheck(`
299
+ fn test() {
300
+ while (true) {
301
+ setInterval(20, () => { say("x"); });
302
+ }
303
+ }
304
+ `)
305
+ expect(errors.length).toBeGreaterThan(0)
306
+ expect(errors[0].message).toContain('cannot be called inside a loop')
307
+ })
308
+
309
+ it('rejects Timer::new() inside a loop', () => {
310
+ const errors = typeCheck(`
311
+ struct Timer { _id: int, _duration: int }
312
+ impl Timer {
313
+ fn new(duration: int) -> Timer { return { _id: 0, _duration: duration }; }
314
+ }
315
+ fn test() {
316
+ while (true) {
317
+ let t: Timer = Timer::new(10);
318
+ }
319
+ }
320
+ `)
321
+ expect(errors.length).toBeGreaterThan(0)
322
+ expect(errors[0].message).toContain('Timer::new() cannot be called inside a loop')
323
+ })
324
+
325
+ it('rejects Timer::new() inside an if body', () => {
326
+ const errors = typeCheck(`
327
+ struct Timer { _id: int, _duration: int }
328
+ impl Timer {
329
+ fn new(duration: int) -> Timer { return { _id: 0, _duration: duration }; }
330
+ }
331
+ fn test() {
332
+ if (true) {
333
+ let t: Timer = Timer::new(10);
334
+ }
335
+ }
336
+ `)
337
+ expect(errors.length).toBeGreaterThan(0)
338
+ expect(errors[0].message).toContain('Timer::new() cannot be called inside an if/else body')
339
+ })
340
+
273
341
  it('allows impl instance methods with inferred self type', () => {
274
342
  const errors = typeCheck(`
275
343
  struct Timer { duration: int }
package/src/cli.ts CHANGED
@@ -57,6 +57,7 @@ Options:
57
57
  --mc-version <ver> Target Minecraft version (default: 1.21). Affects codegen features.
58
58
  e.g. --mc-version 1.20.2, --mc-version 1.19
59
59
  --lenient Treat type errors as warnings instead of blocking compilation
60
+ --include <dir> Add a directory to the import search path (repeatable)
60
61
  -h, --help Show this help message
61
62
  `)
62
63
  }
@@ -161,6 +162,7 @@ function parseArgs(args: string[]): {
161
162
  sourceMap?: boolean
162
163
  mcVersionStr?: string
163
164
  lenient?: boolean
165
+ includeDirs?: string[]
164
166
  } {
165
167
  const result: ReturnType<typeof parseArgs> = {}
166
168
  let i = 0
@@ -189,6 +191,10 @@ function parseArgs(args: string[]): {
189
191
  } else if (arg === '--lenient') {
190
192
  result.lenient = true
191
193
  i++
194
+ } else if (arg === '--include') {
195
+ if (!result.includeDirs) result.includeDirs = []
196
+ result.includeDirs.push(args[++i])
197
+ i++
192
198
  } else if (!result.command) {
193
199
  result.command = arg
194
200
  i++
@@ -216,6 +222,7 @@ function compileCommand(
216
222
  sourceMap = false,
217
223
  mcVersionStr?: string,
218
224
  lenient = false,
225
+ includeDirs?: string[],
219
226
  ): void {
220
227
  // Read source file
221
228
  if (!fs.existsSync(file)) {
@@ -236,7 +243,7 @@ function compileCommand(
236
243
  const source = fs.readFileSync(file, 'utf-8')
237
244
 
238
245
  try {
239
- const result = compile(source, { namespace, filePath: file, generateSourceMap: sourceMap, mcVersion, lenient })
246
+ const result = compile(source, { namespace, filePath: file, generateSourceMap: sourceMap, mcVersion, lenient, includeDirs })
240
247
 
241
248
  for (const w of result.warnings) {
242
249
  console.error(`Warning: ${w}`)
@@ -435,6 +442,7 @@ async function main(): Promise<void> {
435
442
  parsed.sourceMap,
436
443
  parsed.mcVersionStr,
437
444
  parsed.lenient,
445
+ parsed.includeDirs,
438
446
  )
439
447
  }
440
448
  break
package/src/compile.ts CHANGED
@@ -69,6 +69,37 @@ function isLibrarySource(source: string): boolean {
69
69
  interface PreprocessOptions {
70
70
  filePath?: string
71
71
  seen?: Set<string>
72
+ includeDirs?: string[]
73
+ }
74
+
75
+ /** Resolve an import specifier to an absolute file path, trying multiple locations. */
76
+ function resolveImportPath(
77
+ spec: string,
78
+ fromFile: string,
79
+ includeDirs: string[]
80
+ ): string | null {
81
+ const candidates = spec.endsWith('.mcrs') ? [spec] : [spec, spec + '.mcrs']
82
+
83
+ for (const candidate of candidates) {
84
+ // 1. Relative to the importing file
85
+ const rel = path.resolve(path.dirname(fromFile), candidate)
86
+ if (fs.existsSync(rel)) return rel
87
+
88
+ // 2. stdlib directory (package root / src / stdlib)
89
+ // Strip leading 'stdlib/' prefix so `import "stdlib/math"` resolves to
90
+ // <stdlibDir>/math.mcrs rather than <stdlibDir>/stdlib/math.mcrs.
91
+ const stdlibDir = path.resolve(__dirname, '..', 'src', 'stdlib')
92
+ const stdlibCandidate = candidate.replace(/^stdlib\//, '')
93
+ const stdlib = path.resolve(stdlibDir, stdlibCandidate)
94
+ if (fs.existsSync(stdlib)) return stdlib
95
+
96
+ // 3. Extra include dirs
97
+ for (const dir of includeDirs) {
98
+ const extra = path.resolve(dir, candidate)
99
+ if (fs.existsSync(extra)) return extra
100
+ }
101
+ }
102
+ return null
72
103
  }
73
104
 
74
105
  function countLines(source: string): number {
@@ -86,6 +117,7 @@ function offsetRanges(ranges: SourceRange[], lineOffset: number): SourceRange[]
86
117
  export function preprocessSourceWithMetadata(source: string, options: PreprocessOptions = {}): PreprocessedSource {
87
118
  const { filePath } = options
88
119
  const seen = options.seen ?? new Set<string>()
120
+ const includeDirs = options.includeDirs ?? []
89
121
 
90
122
  if (filePath) {
91
123
  seen.add(path.resolve(filePath))
@@ -113,31 +145,28 @@ export function preprocessSourceWithMetadata(source: string, options: Preprocess
113
145
  )
114
146
  }
115
147
 
116
- const importPath = path.resolve(path.dirname(filePath), match[1])
148
+ const importPath = resolveImportPath(match[1], filePath, includeDirs)
149
+ if (!importPath) {
150
+ throw new DiagnosticError(
151
+ 'ParseError',
152
+ `Cannot import '${match[1]}'`,
153
+ { file: filePath, line: i + 1, col: 1 },
154
+ lines
155
+ )
156
+ }
117
157
  if (!seen.has(importPath)) {
118
158
  seen.add(importPath)
119
- let importedSource: string
120
-
121
- try {
122
- importedSource = fs.readFileSync(importPath, 'utf-8')
123
- } catch {
124
- throw new DiagnosticError(
125
- 'ParseError',
126
- `Cannot import '${match[1]}'`,
127
- { file: filePath, line: i + 1, col: 1 },
128
- lines
129
- )
130
- }
159
+ const importedSource = fs.readFileSync(importPath, 'utf-8')
131
160
 
132
161
  if (isLibrarySource(importedSource)) {
133
162
  // Library file: parse separately so its functions are DCE-eligible.
134
163
  // Also collect any transitive library imports inside it.
135
- const nested = preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen })
164
+ const nested = preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen, includeDirs })
136
165
  libraryImports.push({ source: importedSource, filePath: importPath })
137
166
  // Propagate transitive library imports (e.g. math.mcrs imports vec.mcrs)
138
167
  if (nested.libraryImports) libraryImports.push(...nested.libraryImports)
139
168
  } else {
140
- imports.push(preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen }))
169
+ imports.push(preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen, includeDirs }))
141
170
  }
142
171
  }
143
172
  continue
@@ -34,6 +34,8 @@ export interface CompileOptions {
34
34
  * Use for gradual migration or testing with existing codebases that have type errors.
35
35
  */
36
36
  lenient?: boolean
37
+ /** Extra directories to search when resolving imports (in addition to relative and stdlib). */
38
+ includeDirs?: string[]
37
39
  }
38
40
 
39
41
  export interface CompileResult {
@@ -44,11 +46,11 @@ export interface CompileResult {
44
46
  }
45
47
 
46
48
  export function compile(source: string, options: CompileOptions = {}): CompileResult {
47
- const { namespace = 'redscript', filePath, generateSourceMap = false, mcVersion = DEFAULT_MC_VERSION, lenient = false } = options
49
+ const { namespace = 'redscript', filePath, generateSourceMap = false, mcVersion = DEFAULT_MC_VERSION, lenient = false, includeDirs } = options
48
50
  const warnings: string[] = []
49
51
 
50
52
  // Preprocess: resolve import directives, merge imported sources
51
- const preprocessed = preprocessSourceWithMetadata(source, { filePath })
53
+ const preprocessed = preprocessSourceWithMetadata(source, { filePath, includeDirs })
52
54
  const processedSource = preprocessed.source
53
55
 
54
56
  // Stage 1: Lex + Parse → AST
@@ -144,6 +146,7 @@ export function compile(source: string, options: CompileOptions = {}): CompileRe
144
146
  const coroResult = coroutineTransform(mirOpt, coroutineInfos)
145
147
  const mirFinal = coroResult.module
146
148
  tickFunctions.push(...coroResult.generatedTickFunctions)
149
+ warnings.push(...coroResult.warnings)
147
150
 
148
151
  // Stage 5: MIR → LIR
149
152
  const lir = lowerToLIR(mirFinal)
package/src/emit/index.ts CHANGED
@@ -208,7 +208,9 @@ function emitInstr(instr: LIRInstr, ns: string, obj: string, mcVersion: McVersio
208
208
 
209
209
  case 'call_context': {
210
210
  const subcmds = instr.subcommands.map(emitSubcmd).join(' ')
211
- return `execute ${subcmds} run function ${instr.fn}`
211
+ return subcmds
212
+ ? `execute ${subcmds} run function ${instr.fn}`
213
+ : `function ${instr.fn}`
212
214
  }
213
215
 
214
216
  case 'return_value':
package/src/lir/lower.ts CHANGED
@@ -334,6 +334,33 @@ function lowerInstrInner(
334
334
  break
335
335
  }
336
336
 
337
+ case 'score_read': {
338
+ // execute store result score $dst __obj run scoreboard players get <player> <obj>
339
+ const dst = ctx.slot(instr.dst)
340
+ instrs.push({
341
+ kind: 'store_cmd_to_score',
342
+ dst,
343
+ cmd: { kind: 'raw', cmd: `scoreboard players get ${instr.player} ${instr.obj}` },
344
+ })
345
+ break
346
+ }
347
+
348
+ case 'score_write': {
349
+ // Write a value to a vanilla MC scoreboard objective
350
+ if (instr.src.kind === 'const') {
351
+ instrs.push({ kind: 'raw', cmd: `scoreboard players set ${instr.player} ${instr.obj} ${instr.src.value}` })
352
+ } else {
353
+ // execute store result score <player> <obj> run scoreboard players get $src __ns
354
+ const srcSlot = operandToSlot(instr.src, ctx, instrs)
355
+ instrs.push({
356
+ kind: 'store_cmd_to_score',
357
+ dst: { player: instr.player, obj: instr.obj },
358
+ cmd: { kind: 'raw', cmd: `scoreboard players get ${srcSlot.player} ${srcSlot.obj}` },
359
+ })
360
+ }
361
+ break
362
+ }
363
+
337
364
  case 'call': {
338
365
  // Set parameter slots $p0, $p1, ...
339
366
  for (let i = 0; i < instr.args.length; i++) {