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.
- package/CHANGELOG.md +11 -0
- package/README.md +86 -21
- package/README.zh.md +61 -61
- package/dist/src/__tests__/e2e/basic.test.js +25 -0
- package/dist/src/__tests__/e2e/coroutine.test.js +22 -0
- package/dist/src/__tests__/lsp.test.js +76 -0
- package/dist/src/__tests__/mc-integration.test.js +25 -13
- package/dist/src/__tests__/mc-syntax.test.js +1 -6
- package/dist/src/__tests__/schedule.test.js +105 -0
- package/dist/src/__tests__/stdlib-include.test.d.ts +1 -0
- package/dist/src/__tests__/stdlib-include.test.js +86 -0
- package/dist/src/__tests__/typechecker.test.js +63 -0
- package/dist/src/cli.js +10 -3
- package/dist/src/compile.d.ts +1 -0
- package/dist/src/compile.js +33 -10
- package/dist/src/emit/compile.d.ts +2 -0
- package/dist/src/emit/compile.js +3 -2
- package/dist/src/emit/index.js +3 -1
- package/dist/src/lir/lower.js +26 -0
- package/dist/src/lsp/server.js +51 -0
- package/dist/src/mir/lower.js +341 -12
- package/dist/src/mir/types.d.ts +10 -0
- package/dist/src/optimizer/copy_prop.js +4 -0
- package/dist/src/optimizer/coroutine.d.ts +2 -0
- package/dist/src/optimizer/coroutine.js +33 -1
- package/dist/src/optimizer/dce.js +7 -1
- package/dist/src/optimizer/lir/const_imm.js +1 -1
- package/dist/src/optimizer/lir/dead_slot.js +1 -1
- package/dist/src/typechecker/index.d.ts +2 -0
- package/dist/src/typechecker/index.js +29 -0
- package/docs/ROADMAP.md +35 -0
- package/editors/vscode/package-lock.json +3 -3
- package/editors/vscode/package.json +1 -1
- package/editors/vscode/syntaxes/redscript.tmLanguage.json +34 -0
- package/examples/coroutine-demo.mcrs +51 -0
- package/examples/enum-demo.mcrs +95 -0
- package/examples/scheduler-demo.mcrs +59 -0
- package/jest.config.js +19 -0
- package/package.json +1 -1
- package/src/__tests__/e2e/basic.test.ts +27 -0
- package/src/__tests__/e2e/coroutine.test.ts +23 -0
- package/src/__tests__/fixtures/array-test.mcrs +21 -22
- package/src/__tests__/fixtures/counter.mcrs +17 -0
- package/src/__tests__/fixtures/foreach-at-test.mcrs +9 -10
- package/src/__tests__/lsp.test.ts +89 -0
- package/src/__tests__/mc-integration.test.ts +25 -13
- package/src/__tests__/mc-syntax.test.ts +1 -7
- package/src/__tests__/schedule.test.ts +112 -0
- package/src/__tests__/stdlib-include.test.ts +61 -0
- package/src/__tests__/typechecker.test.ts +68 -0
- package/src/cli.ts +9 -1
- package/src/compile.ts +44 -15
- package/src/emit/compile.ts +5 -2
- package/src/emit/index.ts +3 -1
- package/src/lir/lower.ts +27 -0
- package/src/lsp/server.ts +55 -0
- package/src/mir/lower.ts +355 -9
- package/src/mir/types.ts +4 -0
- package/src/optimizer/copy_prop.ts +4 -0
- package/src/optimizer/coroutine.ts +37 -1
- package/src/optimizer/dce.ts +6 -1
- package/src/optimizer/lir/const_imm.ts +1 -1
- package/src/optimizer/lir/dead_slot.ts +1 -1
- package/src/stdlib/timer.mcrs +10 -5
- package/src/typechecker/index.ts +39 -0
- package/examples/spiral.mcrs +0 -43
- package/src/examples/arena.mcrs +0 -44
- package/src/examples/counter.mcrs +0 -12
- package/src/examples/new_features_demo.mcrs +0 -193
- package/src/examples/rpg.mcrs +0 -13
- 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
|
-
// ──
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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
|
|
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 = ['
|
|
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 =
|
|
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
|
-
|
|
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
|
package/src/emit/compile.ts
CHANGED
|
@@ -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
|
|
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++) {
|