redscript-mc 1.1.0 → 1.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 (83) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +53 -10
  3. package/README.zh.md +53 -10
  4. package/dist/__tests__/cli.test.js +138 -0
  5. package/dist/__tests__/codegen.test.js +25 -0
  6. package/dist/__tests__/dce.test.d.ts +1 -0
  7. package/dist/__tests__/dce.test.js +137 -0
  8. package/dist/__tests__/e2e.test.js +190 -12
  9. package/dist/__tests__/lexer.test.js +31 -4
  10. package/dist/__tests__/lowering.test.js +172 -9
  11. package/dist/__tests__/mc-integration.test.js +145 -51
  12. package/dist/__tests__/mc-syntax.test.js +12 -0
  13. package/dist/__tests__/optimizer-advanced.test.js +3 -3
  14. package/dist/__tests__/parser.test.js +90 -0
  15. package/dist/__tests__/runtime.test.js +21 -8
  16. package/dist/__tests__/typechecker.test.js +188 -0
  17. package/dist/ast/types.d.ts +42 -3
  18. package/dist/cli.js +15 -10
  19. package/dist/codegen/mcfunction/index.js +30 -1
  20. package/dist/codegen/structure/index.d.ts +4 -1
  21. package/dist/codegen/structure/index.js +29 -2
  22. package/dist/compile.d.ts +11 -0
  23. package/dist/compile.js +40 -6
  24. package/dist/events/types.d.ts +35 -0
  25. package/dist/events/types.js +59 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +7 -3
  28. package/dist/ir/types.d.ts +4 -0
  29. package/dist/lexer/index.d.ts +2 -1
  30. package/dist/lexer/index.js +91 -1
  31. package/dist/lowering/index.d.ts +32 -1
  32. package/dist/lowering/index.js +476 -16
  33. package/dist/optimizer/dce.d.ts +23 -0
  34. package/dist/optimizer/dce.js +591 -0
  35. package/dist/parser/index.d.ts +4 -0
  36. package/dist/parser/index.js +160 -26
  37. package/dist/typechecker/index.d.ts +19 -0
  38. package/dist/typechecker/index.js +392 -17
  39. package/docs/ARCHITECTURE.zh.md +1088 -0
  40. package/docs/ENTITY_TYPE_SYSTEM.md +242 -0
  41. package/editors/vscode/.vscodeignore +3 -0
  42. package/editors/vscode/CHANGELOG.md +9 -0
  43. package/editors/vscode/icon.png +0 -0
  44. package/editors/vscode/out/extension.js +1144 -72
  45. package/editors/vscode/package-lock.json +2 -2
  46. package/editors/vscode/package.json +1 -1
  47. package/editors/vscode/syntaxes/redscript.tmLanguage.json +6 -2
  48. package/examples/spiral.mcrs +79 -0
  49. package/logo.png +0 -0
  50. package/package.json +1 -1
  51. package/src/__tests__/cli.test.ts +166 -0
  52. package/src/__tests__/codegen.test.ts +27 -0
  53. package/src/__tests__/dce.test.ts +129 -0
  54. package/src/__tests__/e2e.test.ts +201 -12
  55. package/src/__tests__/fixtures/event-test.mcrs +13 -0
  56. package/src/__tests__/fixtures/impl-test.mcrs +46 -0
  57. package/src/__tests__/fixtures/interval-test.mcrs +11 -0
  58. package/src/__tests__/fixtures/is-check-test.mcrs +20 -0
  59. package/src/__tests__/fixtures/timeout-test.mcrs +7 -0
  60. package/src/__tests__/lexer.test.ts +35 -4
  61. package/src/__tests__/lowering.test.ts +187 -9
  62. package/src/__tests__/mc-integration.test.ts +166 -51
  63. package/src/__tests__/mc-syntax.test.ts +14 -0
  64. package/src/__tests__/optimizer-advanced.test.ts +3 -3
  65. package/src/__tests__/parser.test.ts +102 -5
  66. package/src/__tests__/runtime.test.ts +24 -8
  67. package/src/__tests__/typechecker.test.ts +204 -0
  68. package/src/ast/types.ts +39 -2
  69. package/src/cli.ts +24 -10
  70. package/src/codegen/mcfunction/index.ts +31 -1
  71. package/src/codegen/structure/index.ts +40 -2
  72. package/src/compile.ts +59 -7
  73. package/src/events/types.ts +69 -0
  74. package/src/index.ts +9 -4
  75. package/src/ir/types.ts +4 -0
  76. package/src/lexer/index.ts +105 -2
  77. package/src/lowering/index.ts +566 -18
  78. package/src/optimizer/dce.ts +618 -0
  79. package/src/parser/index.ts +187 -29
  80. package/src/stdlib/README.md +34 -4
  81. package/src/stdlib/tags.mcrs +951 -0
  82. package/src/stdlib/timer.mcrs +54 -33
  83. package/src/typechecker/index.ts +469 -18
@@ -18,6 +18,7 @@
18
18
 
19
19
  import type { IRBlock, IRFunction, IRModule, Operand, Terminator } from '../../ir/types'
20
20
  import { optimizeCommandFunctions, type OptimizationStats, createEmptyOptimizationStats, mergeOptimizationStats } from '../../optimizer/commands'
21
+ import { EVENT_TYPES, isEventTypeName, type EventTypeName } from '../../events/types'
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Utilities
@@ -270,6 +271,10 @@ export function generateDatapackWithStats(
270
271
  // Collect all trigger handlers
271
272
  const triggerHandlers = module.functions.filter(fn => fn.isTriggerHandler && fn.triggerName)
272
273
  const triggerNames = new Set(triggerHandlers.map(fn => fn.triggerName!))
274
+ const eventHandlers = module.functions.filter((fn): fn is IRFunction & { eventHandler: { eventType: EventTypeName; tag: string } } =>
275
+ !!fn.eventHandler && isEventTypeName(fn.eventHandler.eventType)
276
+ )
277
+ const eventTypes = new Set<EventTypeName>(eventHandlers.map(fn => fn.eventHandler.eventType))
273
278
 
274
279
  // Collect all tick functions
275
280
  const tickFunctionNames: string[] = []
@@ -302,6 +307,19 @@ export function generateDatapackWithStats(
302
307
  loadLines.push(`scoreboard players enable @a ${triggerName}`)
303
308
  }
304
309
 
310
+ for (const eventType of eventTypes) {
311
+ const detection = EVENT_TYPES[eventType].detection
312
+ if (eventType === 'PlayerDeath') {
313
+ loadLines.push('scoreboard objectives add rs.deaths deathCount')
314
+ } else if (eventType === 'EntityKill') {
315
+ loadLines.push('scoreboard objectives add rs.kills totalKillCount')
316
+ } else if (eventType === 'ItemUse') {
317
+ loadLines.push('# ItemUse detection requires a project-specific objective/tag setup')
318
+ } else if (detection === 'tag' || detection === 'advancement') {
319
+ loadLines.push(`# ${eventType} detection expects tag ${EVENT_TYPES[eventType].tag} to be set externally`)
320
+ }
321
+ }
322
+
305
323
  // Generate trigger dispatch functions
306
324
  for (const triggerName of triggerNames) {
307
325
  const handlers = triggerHandlers.filter(fn => fn.triggerName === triggerName)
@@ -391,8 +409,20 @@ export function generateDatapackWithStats(
391
409
  }
392
410
  }
393
411
 
412
+ if (eventHandlers.length > 0) {
413
+ tickLines.push('# Event checks')
414
+ for (const eventType of eventTypes) {
415
+ const tag = EVENT_TYPES[eventType].tag
416
+ const handlers = eventHandlers.filter(fn => fn.eventHandler?.eventType === eventType)
417
+ for (const handler of handlers) {
418
+ tickLines.push(`execute as @a[tag=${tag}] run function ${ns}:${handler.name}`)
419
+ }
420
+ tickLines.push(`tag @a[tag=${tag}] remove ${tag}`)
421
+ }
422
+ }
423
+
394
424
  // Only generate __tick if there's something to run
395
- if (tickFunctionNames.length > 0 || triggerNames.size > 0) {
425
+ if (tickFunctionNames.length > 0 || triggerNames.size > 0 || eventHandlers.length > 0) {
396
426
  files.push({
397
427
  path: `data/${ns}/function/__tick.mcfunction`,
398
428
  content: tickLines.join('\n'),
@@ -5,9 +5,11 @@ import { nbt, TagType, writeNbt, type CompoundTag, type NbtTag } from '../../nbt
5
5
  import { createEmptyOptimizationStats, mergeOptimizationStats, type OptimizationStats } from '../../optimizer/commands'
6
6
  import { optimizeWithStats } from '../../optimizer/passes'
7
7
  import { optimizeForStructure, optimizeForStructureWithStats } from '../../optimizer/structure'
8
+ import { eliminateDeadCode } from '../../optimizer/dce'
8
9
  import { preprocessSource } from '../../compile'
9
10
  import type { IRCommand, IRFunction, IRModule } from '../../ir/types'
10
11
  import type { DatapackFile } from '../mcfunction'
12
+ import { EVENT_TYPES, isEventTypeName, type EventTypeName } from '../../events/types'
11
13
 
12
14
  const DATA_VERSION = 3953
13
15
  const MAX_WIDTH = 16
@@ -50,6 +52,10 @@ export interface StructureCompileResult {
50
52
  stats?: OptimizationStats
51
53
  }
52
54
 
55
+ export interface StructureCompileOptions {
56
+ dce?: boolean
57
+ }
58
+
53
59
  function escapeJsonString(value: string): string {
54
60
  return JSON.stringify(value).slice(1, -1)
55
61
  }
@@ -87,6 +93,10 @@ function collectCommandEntriesFromModule(module: IRModule): CommandEntry[] {
87
93
  const entries: CommandEntry[] = []
88
94
  const triggerHandlers = module.functions.filter(fn => fn.isTriggerHandler && fn.triggerName)
89
95
  const triggerNames = new Set(triggerHandlers.map(fn => fn.triggerName!))
96
+ const eventHandlers = module.functions.filter((fn): fn is IRFunction & { eventHandler: { eventType: EventTypeName; tag: string } } =>
97
+ !!fn.eventHandler && isEventTypeName(fn.eventHandler.eventType)
98
+ )
99
+ const eventTypes = new Set<EventTypeName>(eventHandlers.map(fn => fn.eventHandler.eventType))
90
100
  const loadCommands = [
91
101
  `scoreboard objectives add ${OBJ} dummy`,
92
102
  ...module.globals.map(g => `scoreboard players set ${varRef(g.name)} ${OBJ} ${g.init}`),
@@ -99,6 +109,14 @@ function collectCommandEntriesFromModule(module: IRModule): CommandEntry[] {
99
109
  ).map(constSetup),
100
110
  ]
101
111
 
112
+ for (const eventType of eventTypes) {
113
+ if (eventType === 'PlayerDeath') {
114
+ loadCommands.push('scoreboard objectives add rs.deaths deathCount')
115
+ } else if (eventType === 'EntityKill') {
116
+ loadCommands.push('scoreboard objectives add rs.kills totalKillCount')
117
+ }
118
+ }
119
+
102
120
  // Call @load functions from __load
103
121
  for (const fn of module.functions) {
104
122
  if (fn.isLoadInit) {
@@ -146,6 +164,20 @@ function collectCommandEntriesFromModule(module: IRModule): CommandEntry[] {
146
164
  })
147
165
  }
148
166
  }
167
+ if (eventHandlers.length > 0) {
168
+ for (const eventType of eventTypes) {
169
+ const tag = EVENT_TYPES[eventType].tag
170
+ const handlers = eventHandlers.filter(fn => fn.eventHandler?.eventType === eventType)
171
+ for (const handler of handlers) {
172
+ tickCommands.push({
173
+ cmd: `execute as @a[tag=${tag}] run function ${module.namespace}:${handler.name}`,
174
+ })
175
+ }
176
+ tickCommands.push({
177
+ cmd: `tag @a[tag=${tag}] remove ${tag}`,
178
+ })
179
+ }
180
+ }
149
181
  if (tickCommands.length > 0) {
150
182
  sections.push({
151
183
  name: '__tick',
@@ -288,10 +320,16 @@ export function generateStructure(input: IRModule | DatapackFile[]): StructureCo
288
320
  }
289
321
  }
290
322
 
291
- export function compileToStructure(source: string, namespace: string, filePath?: string): StructureCompileResult {
323
+ export function compileToStructure(
324
+ source: string,
325
+ namespace: string,
326
+ filePath?: string,
327
+ options: StructureCompileOptions = {}
328
+ ): StructureCompileResult {
292
329
  const preprocessedSource = preprocessSource(source, { filePath })
293
330
  const tokens = new Lexer(preprocessedSource, filePath).tokenize()
294
- const ast = new Parser(tokens, preprocessedSource, filePath).parse(namespace)
331
+ const parsedAst = new Parser(tokens, preprocessedSource, filePath).parse(namespace)
332
+ const ast = options.dce ?? true ? eliminateDeadCode(parsedAst) : parsedAst
295
333
  const ir = new Lowering(namespace).lower(ast)
296
334
  const stats = createEmptyOptimizationStats()
297
335
  const optimizedIRFunctions = ir.functions.map(fn => {
package/src/compile.ts CHANGED
@@ -11,6 +11,7 @@ import { Lexer } from './lexer'
11
11
  import { Parser } from './parser'
12
12
  import { Lowering } from './lowering'
13
13
  import { optimize } from './optimizer/passes'
14
+ import { eliminateDeadCode } from './optimizer/dce'
14
15
  import { generateDatapackWithStats, DatapackFile } from './codegen/mcfunction'
15
16
  import { DiagnosticError, formatError, parseErrorMessage } from './diagnostics'
16
17
  import type { IRModule } from './ir/types'
@@ -24,6 +25,7 @@ export interface CompileOptions {
24
25
  namespace?: string
25
26
  filePath?: string
26
27
  optimize?: boolean
28
+ dce?: boolean
27
29
  }
28
30
 
29
31
  // ---------------------------------------------------------------------------
@@ -39,6 +41,17 @@ export interface CompileResult {
39
41
  error?: DiagnosticError
40
42
  }
41
43
 
44
+ export interface SourceRange {
45
+ startLine: number
46
+ endLine: number
47
+ filePath: string
48
+ }
49
+
50
+ export interface PreprocessedSource {
51
+ source: string
52
+ ranges: SourceRange[]
53
+ }
54
+
42
55
  const IMPORT_RE = /^\s*import\s+"([^"]+)"\s*;?\s*$/
43
56
 
44
57
  interface PreprocessOptions {
@@ -46,7 +59,19 @@ interface PreprocessOptions {
46
59
  seen?: Set<string>
47
60
  }
48
61
 
49
- export function preprocessSource(source: string, options: PreprocessOptions = {}): string {
62
+ function countLines(source: string): number {
63
+ return source === '' ? 0 : source.split('\n').length
64
+ }
65
+
66
+ function offsetRanges(ranges: SourceRange[], lineOffset: number): SourceRange[] {
67
+ return ranges.map(range => ({
68
+ startLine: range.startLine + lineOffset,
69
+ endLine: range.endLine + lineOffset,
70
+ filePath: range.filePath,
71
+ }))
72
+ }
73
+
74
+ export function preprocessSourceWithMetadata(source: string, options: PreprocessOptions = {}): PreprocessedSource {
50
75
  const { filePath } = options
51
76
  const seen = options.seen ?? new Set<string>()
52
77
 
@@ -55,7 +80,7 @@ export function preprocessSource(source: string, options: PreprocessOptions = {}
55
80
  }
56
81
 
57
82
  const lines = source.split('\n')
58
- const imports: string[] = []
83
+ const imports: PreprocessedSource[] = []
59
84
  const bodyLines: string[] = []
60
85
  let parsingHeader = true
61
86
 
@@ -90,7 +115,7 @@ export function preprocessSource(source: string, options: PreprocessOptions = {}
90
115
  )
91
116
  }
92
117
 
93
- imports.push(preprocessSource(importedSource, { filePath: importPath, seen }))
118
+ imports.push(preprocessSourceWithMetadata(importedSource, { filePath: importPath, seen }))
94
119
  }
95
120
  continue
96
121
  }
@@ -104,7 +129,31 @@ export function preprocessSource(source: string, options: PreprocessOptions = {}
104
129
  bodyLines.push(line)
105
130
  }
106
131
 
107
- return [...imports, bodyLines.join('\n')].filter(Boolean).join('\n')
132
+ const body = bodyLines.join('\n')
133
+ const parts = [...imports.map(entry => entry.source), body].filter(Boolean)
134
+ const combined = parts.join('\n')
135
+
136
+ const ranges: SourceRange[] = []
137
+ let lineOffset = 0
138
+
139
+ for (const entry of imports) {
140
+ ranges.push(...offsetRanges(entry.ranges, lineOffset))
141
+ lineOffset += countLines(entry.source)
142
+ }
143
+
144
+ if (filePath && body) {
145
+ ranges.push({
146
+ startLine: lineOffset + 1,
147
+ endLine: lineOffset + countLines(body),
148
+ filePath: path.resolve(filePath),
149
+ })
150
+ }
151
+
152
+ return { source: combined, ranges }
153
+ }
154
+
155
+ export function preprocessSource(source: string, options: PreprocessOptions = {}): string {
156
+ return preprocessSourceWithMetadata(source, options).source
108
157
  }
109
158
 
110
159
  // ---------------------------------------------------------------------------
@@ -113,20 +162,23 @@ export function preprocessSource(source: string, options: PreprocessOptions = {}
113
162
 
114
163
  export function compile(source: string, options: CompileOptions = {}): CompileResult {
115
164
  const { namespace = 'redscript', filePath, optimize: shouldOptimize = true } = options
165
+ const shouldRunDce = options.dce ?? shouldOptimize
116
166
  let sourceLines = source.split('\n')
117
167
 
118
168
  try {
119
- const preprocessedSource = preprocessSource(source, { filePath })
169
+ const preprocessed = preprocessSourceWithMetadata(source, { filePath })
170
+ const preprocessedSource = preprocessed.source
120
171
  sourceLines = preprocessedSource.split('\n')
121
172
 
122
173
  // Lexing
123
174
  const tokens = new Lexer(preprocessedSource, filePath).tokenize()
124
175
 
125
176
  // Parsing
126
- const ast = new Parser(tokens, preprocessedSource, filePath).parse(namespace)
177
+ const parsedAst = new Parser(tokens, preprocessedSource, filePath).parse(namespace)
178
+ const ast = shouldRunDce ? eliminateDeadCode(parsedAst) : parsedAst
127
179
 
128
180
  // Lowering
129
- const ir = new Lowering(namespace).lower(ast)
181
+ const ir = new Lowering(namespace, preprocessed.ranges).lower(ast)
130
182
 
131
183
  // Optimization
132
184
  const optimized: IRModule = shouldOptimize
@@ -0,0 +1,69 @@
1
+ import type { TypeNode } from '../ast/types'
2
+
3
+ export const EVENT_TYPES = {
4
+ PlayerDeath: {
5
+ tag: 'rs.just_died',
6
+ params: ['player: Player'],
7
+ detection: 'scoreboard',
8
+ },
9
+ PlayerJoin: {
10
+ tag: 'rs.just_joined',
11
+ params: ['player: Player'],
12
+ detection: 'tag',
13
+ },
14
+ BlockBreak: {
15
+ tag: 'rs.just_broke_block',
16
+ params: ['player: Player', 'block: string'],
17
+ detection: 'advancement',
18
+ },
19
+ EntityKill: {
20
+ tag: 'rs.just_killed',
21
+ params: ['player: Player'],
22
+ detection: 'scoreboard',
23
+ },
24
+ ItemUse: {
25
+ tag: 'rs.just_used_item',
26
+ params: ['player: Player'],
27
+ detection: 'scoreboard',
28
+ },
29
+ } as const
30
+
31
+ export type EventTypeName = keyof typeof EVENT_TYPES
32
+
33
+ export interface EventParamSpec {
34
+ name: string
35
+ type: TypeNode
36
+ }
37
+
38
+ export function isEventTypeName(value: string): value is EventTypeName {
39
+ return value in EVENT_TYPES
40
+ }
41
+
42
+ export function getEventParamSpecs(eventType: EventTypeName): EventParamSpec[] {
43
+ return EVENT_TYPES[eventType].params.map(parseEventParam)
44
+ }
45
+
46
+ function parseEventParam(spec: string): EventParamSpec {
47
+ const match = spec.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)$/)
48
+ if (!match) {
49
+ throw new Error(`Invalid event parameter spec: ${spec}`)
50
+ }
51
+
52
+ const [, name, typeName] = match
53
+ return {
54
+ name,
55
+ type: toTypeNode(typeName),
56
+ }
57
+ }
58
+
59
+ function toTypeNode(typeName: string): TypeNode {
60
+ if (typeName === 'Player') {
61
+ return { kind: 'entity', entityType: 'Player' }
62
+ }
63
+
64
+ if (typeName === 'string' || typeName === 'int' || typeName === 'bool' || typeName === 'float' || typeName === 'void' || typeName === 'BlockPos' || typeName === 'byte' || typeName === 'short' || typeName === 'long' || typeName === 'double') {
65
+ return { kind: 'named', name: typeName }
66
+ }
67
+
68
+ return { kind: 'struct', name: typeName }
69
+ }
package/src/index.ts CHANGED
@@ -14,12 +14,13 @@ import {
14
14
  copyPropagation,
15
15
  deadCodeEliminationWithStats,
16
16
  } from './optimizer/passes'
17
+ import { eliminateDeadCode } from './optimizer/dce'
17
18
  import {
18
19
  countMcfunctionCommands,
19
20
  generateDatapackWithStats,
20
21
  DatapackFile,
21
22
  } from './codegen/mcfunction'
22
- import { preprocessSource } from './compile'
23
+ import { preprocessSource, preprocessSourceWithMetadata } from './compile'
23
24
  import type { IRModule } from './ir/types'
24
25
  import type { Program } from './ast/types'
25
26
  import type { DiagnosticError } from './diagnostics'
@@ -30,6 +31,7 @@ export interface CompileOptions {
30
31
  optimize?: boolean
31
32
  typeCheck?: boolean
32
33
  filePath?: string
34
+ dce?: boolean
33
35
  }
34
36
 
35
37
  export interface CompileResult {
@@ -53,14 +55,17 @@ export function compile(source: string, options: CompileOptions = {}): CompileRe
53
55
  const namespace = options.namespace ?? 'redscript'
54
56
  const shouldOptimize = options.optimize ?? true
55
57
  const shouldTypeCheck = options.typeCheck ?? true
58
+ const shouldRunDce = options.dce ?? shouldOptimize
56
59
  const filePath = options.filePath
57
- const preprocessedSource = preprocessSource(source, { filePath })
60
+ const preprocessed = preprocessSourceWithMetadata(source, { filePath })
61
+ const preprocessedSource = preprocessed.source
58
62
 
59
63
  // Lexing
60
64
  const tokens = new Lexer(preprocessedSource, filePath).tokenize()
61
65
 
62
66
  // Parsing
63
- const ast = new Parser(tokens, preprocessedSource, filePath).parse(namespace)
67
+ const parsedAst = new Parser(tokens, preprocessedSource, filePath).parse(namespace)
68
+ const ast = shouldRunDce ? eliminateDeadCode(parsedAst) : parsedAst
64
69
 
65
70
  // Type checking (warn mode - collect errors but don't block)
66
71
  let typeErrors: DiagnosticError[] | undefined
@@ -70,7 +75,7 @@ export function compile(source: string, options: CompileOptions = {}): CompileRe
70
75
  }
71
76
 
72
77
  // Lowering to IR
73
- const lowering = new Lowering(namespace)
78
+ const lowering = new Lowering(namespace, preprocessed.ranges)
74
79
  const ir = lowering.lower(ast)
75
80
 
76
81
  let optimizedIR: IRModule = ir
package/src/ir/types.ts CHANGED
@@ -107,6 +107,10 @@ export interface IRFunction {
107
107
  kind: 'advancement' | 'craft' | 'death' | 'login' | 'join_team'
108
108
  value?: string
109
109
  }
110
+ eventHandler?: {
111
+ eventType: string
112
+ tag: string
113
+ }
110
114
  }
111
115
 
112
116
  // ---------------------------------------------------------------------------
@@ -15,7 +15,7 @@ import { DiagnosticError } from '../diagnostics'
15
15
  export type TokenKind =
16
16
  // Keywords
17
17
  | 'fn' | 'let' | 'const' | 'if' | 'else' | 'while' | 'for' | 'foreach' | 'match'
18
- | 'return' | 'as' | 'at' | 'in' | 'struct' | 'enum' | 'trigger' | 'namespace'
18
+ | 'return' | 'as' | 'at' | 'in' | 'is' | 'struct' | 'impl' | 'enum' | 'trigger' | 'namespace'
19
19
  | 'execute' | 'run' | 'unless'
20
20
  // Types
21
21
  | 'int' | 'bool' | 'float' | 'string' | 'void'
@@ -34,7 +34,10 @@ export type TokenKind =
34
34
  | 'long_lit' // 1000L
35
35
  | 'double_lit' // 3.14d
36
36
  | 'string_lit' // "hello"
37
+ | 'f_string' // f"hello {name}"
37
38
  | 'range_lit' // ..5 1.. 1..10
39
+ | 'rel_coord' // ~ ~5 ~-3 (relative coordinate)
40
+ | 'local_coord' // ^ ^5 ^-3 (local/facing coordinate)
38
41
  // Operators
39
42
  | '+' | '-' | '*' | '/' | '%'
40
43
  | '~' | '^'
@@ -75,7 +78,9 @@ const KEYWORDS: Record<string, TokenKind> = {
75
78
  as: 'as',
76
79
  at: 'at',
77
80
  in: 'in',
81
+ is: 'is',
78
82
  struct: 'struct',
83
+ impl: 'impl',
79
84
  enum: 'enum',
80
85
  trigger: 'trigger',
81
86
  namespace: 'namespace',
@@ -273,8 +278,52 @@ export class Lexer {
273
278
  return
274
279
  }
275
280
 
281
+ // Relative coordinate: ~ or ~5 or ~-3 or ~0.5
282
+ if (char === '~') {
283
+ let value = '~'
284
+ // Check for optional sign
285
+ if (this.peek() === '-' || this.peek() === '+') {
286
+ value += this.advance()
287
+ }
288
+ // Check for number
289
+ while (/[0-9]/.test(this.peek())) {
290
+ value += this.advance()
291
+ }
292
+ // Check for decimal part
293
+ if (this.peek() === '.' && /[0-9]/.test(this.peek(1))) {
294
+ value += this.advance() // .
295
+ while (/[0-9]/.test(this.peek())) {
296
+ value += this.advance()
297
+ }
298
+ }
299
+ this.addToken('rel_coord', value, startLine, startCol)
300
+ return
301
+ }
302
+
303
+ // Local coordinate: ^ or ^5 or ^-3 or ^0.5
304
+ if (char === '^') {
305
+ let value = '^'
306
+ // Check for optional sign
307
+ if (this.peek() === '-' || this.peek() === '+') {
308
+ value += this.advance()
309
+ }
310
+ // Check for number
311
+ while (/[0-9]/.test(this.peek())) {
312
+ value += this.advance()
313
+ }
314
+ // Check for decimal part
315
+ if (this.peek() === '.' && /[0-9]/.test(this.peek(1))) {
316
+ value += this.advance() // .
317
+ while (/[0-9]/.test(this.peek())) {
318
+ value += this.advance()
319
+ }
320
+ }
321
+ this.addToken('local_coord', value, startLine, startCol)
322
+ return
323
+ }
324
+
276
325
  // Single-character operators and delimiters
277
- const singleChar: TokenKind[] = ['+', '-', '*', '/', '%', '~', '^', '<', '>', '!', '=',
326
+ const singleChar: TokenKind[] = ['+', '-', '*', '/', '%', '<', '>', '!', '=',
278
327
  '{', '}', '(', ')', '[', ']', ',', ';', ':', '.']
279
328
  if (singleChar.includes(char as TokenKind)) {
280
329
  this.addToken(char as TokenKind, char, startLine, startCol)
@@ -287,6 +336,13 @@ export class Lexer {
287
336
  return
288
337
  }
289
338
 
339
+ // f-string literal
340
+ if (char === 'f' && this.peek() === '"') {
341
+ this.advance()
342
+ this.scanFString(startLine, startCol)
343
+ return
344
+ }
345
+
290
346
  // String literal
291
347
  if (char === '"') {
292
348
  this.scanString(startLine, startCol)
@@ -430,6 +486,53 @@ export class Lexer {
430
486
  this.addToken('string_lit', value, startLine, startCol)
431
487
  }
432
488
 
489
+ private scanFString(startLine: number, startCol: number): void {
490
+ let value = ''
491
+ let interpolationDepth = 0
492
+ let interpolationString = false
493
+
494
+ while (!this.isAtEnd()) {
495
+ if (interpolationDepth === 0 && this.peek() === '"') {
496
+ break
497
+ }
498
+
499
+ if (this.peek() === '\\' && this.peek(1) === '"') {
500
+ this.advance()
501
+ value += this.advance()
502
+ continue
503
+ }
504
+
505
+ if (interpolationDepth === 0 && this.peek() === '{') {
506
+ value += this.advance()
507
+ interpolationDepth = 1
508
+ interpolationString = false
509
+ continue
510
+ }
511
+
512
+ const char = this.advance()
513
+ value += char
514
+
515
+ if (interpolationDepth === 0) continue
516
+
517
+ if (char === '"' && this.source[this.pos - 2] !== '\\') {
518
+ interpolationString = !interpolationString
519
+ continue
520
+ }
521
+
522
+ if (interpolationString) continue
523
+
524
+ if (char === '{') interpolationDepth++
525
+ if (char === '}') interpolationDepth--
526
+ }
527
+
528
+ if (this.isAtEnd()) {
529
+ this.error('Unterminated f-string', startLine, startCol)
530
+ }
531
+
532
+ this.advance() // closing quote
533
+ this.addToken('f_string', value, startLine, startCol)
534
+ }
535
+
433
536
  private scanNumber(firstChar: string, startLine: number, startCol: number): void {
434
537
  let value = firstChar
435
538