lt-script 1.0.1 → 1.0.5
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/README.md +548 -15
- package/dist/cli/ltc.js +44 -34
- package/dist/cli/utils.d.ts +40 -0
- package/dist/cli/utils.js +40 -0
- package/dist/compiler/codegen/LuaEmitter.js +20 -3
- package/dist/compiler/lexer/Lexer.js +7 -2
- package/dist/compiler/lexer/Token.d.ts +2 -1
- package/dist/compiler/lexer/Token.js +6 -2
- package/dist/compiler/parser/AST.d.ts +18 -0
- package/dist/compiler/parser/AST.js +3 -0
- package/dist/compiler/parser/Parser.d.ts +6 -0
- package/dist/compiler/parser/Parser.js +216 -31
- package/dist/compiler/semantics/SemanticAnalyzer.d.ts +28 -0
- package/dist/compiler/semantics/SemanticAnalyzer.js +682 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +9 -1
- package/package.json +12 -3
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import * as AST from '../parser/AST.js';
|
|
2
|
+
class Scope {
|
|
3
|
+
symbols = new Map();
|
|
4
|
+
parent;
|
|
5
|
+
constructor(parent) {
|
|
6
|
+
this.parent = parent;
|
|
7
|
+
}
|
|
8
|
+
define(name, kind, type, line) {
|
|
9
|
+
// In strict mode we might check for redeclaration here
|
|
10
|
+
this.symbols.set(name, { name, kind, type, definedAtLine: line });
|
|
11
|
+
}
|
|
12
|
+
lookup(name) {
|
|
13
|
+
let current = this;
|
|
14
|
+
while (current) {
|
|
15
|
+
if (current.symbols.has(name)) {
|
|
16
|
+
return current.symbols.get(name);
|
|
17
|
+
}
|
|
18
|
+
current = current.parent;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class SemanticAnalyzer {
|
|
24
|
+
globalScope = new Scope();
|
|
25
|
+
currentScope = this.globalScope;
|
|
26
|
+
typeRegistry = new Map(); // Stores interface fields
|
|
27
|
+
typeAliasRegistry = new Map(); // Stores type alias strings
|
|
28
|
+
memberTypes = new Map(); // Tracks Table.Field -> type mappings
|
|
29
|
+
functionRegistry = new Map(); // Stores function signatures
|
|
30
|
+
analyze(program) {
|
|
31
|
+
// Reset scope for fresh analysis (though typically instance is fresh)
|
|
32
|
+
this.currentScope = this.globalScope;
|
|
33
|
+
this.typeRegistry.clear();
|
|
34
|
+
this.typeAliasRegistry.clear();
|
|
35
|
+
this.memberTypes.clear();
|
|
36
|
+
this.functionRegistry.clear();
|
|
37
|
+
// First pass: Register types (to allow forward references)
|
|
38
|
+
for (const stmt of program.body) {
|
|
39
|
+
if (stmt.kind === AST.NodeType.TypeDecl) {
|
|
40
|
+
this.visitTypeDecl(stmt);
|
|
41
|
+
}
|
|
42
|
+
else if (stmt.kind === AST.NodeType.TypeAliasDecl) {
|
|
43
|
+
this.visitTypeAliasDecl(stmt);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Second pass: Analyze code
|
|
47
|
+
for (const stmt of program.body) {
|
|
48
|
+
if (stmt.kind !== AST.NodeType.TypeDecl && stmt.kind !== AST.NodeType.TypeAliasDecl) {
|
|
49
|
+
this.visitStatement(stmt);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
enterScope() {
|
|
54
|
+
this.currentScope = new Scope(this.currentScope);
|
|
55
|
+
}
|
|
56
|
+
exitScope() {
|
|
57
|
+
if (this.currentScope.parent) {
|
|
58
|
+
this.currentScope = this.currentScope.parent;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
visitStatement(stmt) {
|
|
62
|
+
switch (stmt.kind) {
|
|
63
|
+
case AST.NodeType.VariableDecl:
|
|
64
|
+
this.visitVariableDecl(stmt);
|
|
65
|
+
break;
|
|
66
|
+
case AST.NodeType.TypeDecl:
|
|
67
|
+
this.visitTypeDecl(stmt);
|
|
68
|
+
break;
|
|
69
|
+
case AST.NodeType.AssignmentStmt:
|
|
70
|
+
this.visitAssignmentStmt(stmt);
|
|
71
|
+
break;
|
|
72
|
+
case AST.NodeType.CompoundAssignment:
|
|
73
|
+
this.visitCompoundAssignment(stmt);
|
|
74
|
+
break;
|
|
75
|
+
case AST.NodeType.FunctionDecl:
|
|
76
|
+
this.visitFunctionDecl(stmt);
|
|
77
|
+
break;
|
|
78
|
+
case AST.NodeType.Block:
|
|
79
|
+
this.visitBlock(stmt);
|
|
80
|
+
break;
|
|
81
|
+
case AST.NodeType.IfStmt:
|
|
82
|
+
{
|
|
83
|
+
const s = stmt;
|
|
84
|
+
this.visitExpression(s.condition);
|
|
85
|
+
this.visitBlock(s.thenBody);
|
|
86
|
+
s.elseIfClauses?.forEach(c => {
|
|
87
|
+
this.visitExpression(c.condition);
|
|
88
|
+
this.visitBlock(c.body);
|
|
89
|
+
});
|
|
90
|
+
if (s.elseBody)
|
|
91
|
+
this.visitBlock(s.elseBody);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
case AST.NodeType.WhileStmt:
|
|
95
|
+
{
|
|
96
|
+
const s = stmt;
|
|
97
|
+
this.visitExpression(s.condition);
|
|
98
|
+
this.visitBlock(s.body);
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case AST.NodeType.ForStmt:
|
|
102
|
+
{
|
|
103
|
+
const s = stmt;
|
|
104
|
+
this.enterScope(); // For loop creates scope for iterators
|
|
105
|
+
s.iterators.forEach(i => this.currentScope.define(i.name, 'let', 'any', s.line || 0)); // Iterators can be anything in generic for
|
|
106
|
+
this.visitExpression(s.iterable);
|
|
107
|
+
// We can treat the body as part of this scope or nested.
|
|
108
|
+
// AST.Block usually suggests its own scope, but let's reuse this one or just visit statements.
|
|
109
|
+
// Since AST.Block logic below creates a NEW scope, we have: Scope(Iterators) -> Scope(Body). This is correct.
|
|
110
|
+
this.visitBlock(s.body);
|
|
111
|
+
this.exitScope();
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
case AST.NodeType.RangeForStmt:
|
|
115
|
+
{
|
|
116
|
+
const s = stmt;
|
|
117
|
+
this.enterScope();
|
|
118
|
+
this.currentScope.define(s.counter.name, 'let', 'number', s.line || 0); // Range for is always number
|
|
119
|
+
this.visitExpression(s.start);
|
|
120
|
+
this.visitExpression(s.end);
|
|
121
|
+
if (s.step)
|
|
122
|
+
this.visitExpression(s.step);
|
|
123
|
+
this.visitBlock(s.body);
|
|
124
|
+
this.exitScope();
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
// ... Handle other control flows that have blocks ...
|
|
128
|
+
case AST.NodeType.CommandStmt:
|
|
129
|
+
{
|
|
130
|
+
const s = stmt;
|
|
131
|
+
this.enterScope();
|
|
132
|
+
s.params.forEach(p => this.currentScope.define(p.name.name, 'param', 'string', s.line || 0)); // Command params are strings (usually)
|
|
133
|
+
this.visitBlock(s.body);
|
|
134
|
+
this.exitScope();
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
case AST.NodeType.EventHandler:
|
|
138
|
+
{
|
|
139
|
+
const s = stmt;
|
|
140
|
+
this.enterScope();
|
|
141
|
+
s.params.forEach(p => this.currentScope.define(p.name.name, 'param', 'any', s.line || 0));
|
|
142
|
+
this.visitBlock(s.body);
|
|
143
|
+
this.exitScope();
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case AST.NodeType.TryCatchStmt:
|
|
147
|
+
{
|
|
148
|
+
const s = stmt;
|
|
149
|
+
this.visitBlock(s.tryBody);
|
|
150
|
+
this.enterScope();
|
|
151
|
+
this.currentScope.define(s.catchParam.name, 'let', 'any', s.line || 0);
|
|
152
|
+
this.visitBlock(s.catchBody);
|
|
153
|
+
this.exitScope();
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
// Handle expression statements (standalone function calls like ProcessNumber("hello"))
|
|
157
|
+
case AST.NodeType.CallExpr:
|
|
158
|
+
this.visitExpression(stmt);
|
|
159
|
+
break;
|
|
160
|
+
// Basic fallback recursive visiting
|
|
161
|
+
default:
|
|
162
|
+
// Identify other nodes that contain blocks/expressions to visit
|
|
163
|
+
this.visitChildren(stmt);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
visitChildren(node) {
|
|
167
|
+
if (!node || typeof node !== 'object')
|
|
168
|
+
return;
|
|
169
|
+
for (const key in node) {
|
|
170
|
+
if (key === 'kind')
|
|
171
|
+
continue;
|
|
172
|
+
const val = node[key];
|
|
173
|
+
if (Array.isArray(val)) {
|
|
174
|
+
val.forEach(v => {
|
|
175
|
+
if (v && typeof v === 'object' && 'kind' in v) {
|
|
176
|
+
if (v.kind.endsWith('Stmt') || v.kind === 'VariableDecl' || v.kind === 'Block') {
|
|
177
|
+
this.visitStatement(v);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.visitExpression(v);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else if (val && typeof val === 'object' && 'kind' in val) {
|
|
186
|
+
if (val.kind.endsWith('Stmt') || val.kind === 'VariableDecl' || val.kind === 'Block') {
|
|
187
|
+
this.visitStatement(val);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
this.visitExpression(val);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
visitVariableDecl(decl) {
|
|
196
|
+
// Visit values first
|
|
197
|
+
if (decl.values) {
|
|
198
|
+
decl.values.forEach(v => this.visitExpression(v));
|
|
199
|
+
}
|
|
200
|
+
const kind = decl.scope === 'const' ? 'const' : 'let';
|
|
201
|
+
decl.names.forEach((n, index) => {
|
|
202
|
+
let type = 'any';
|
|
203
|
+
let customTypeName;
|
|
204
|
+
// 1. Try to get type from annotation
|
|
205
|
+
if (decl.typeAnnotations && decl.typeAnnotations[index]) {
|
|
206
|
+
const annotation = decl.typeAnnotations[index];
|
|
207
|
+
customTypeName = this.typeRegistry.has(annotation) ? annotation : undefined;
|
|
208
|
+
type = this.stringToType(annotation);
|
|
209
|
+
}
|
|
210
|
+
// 2. Try to infer from value if available
|
|
211
|
+
if (decl.values && decl.values[index]) {
|
|
212
|
+
const value = decl.values[index];
|
|
213
|
+
const inferred = this.inferType(value);
|
|
214
|
+
const annotation = decl.typeAnnotations && decl.typeAnnotations[index];
|
|
215
|
+
// If it's an array type (e.g., RobberyConfig[] or number[]) and value is a table, validate each element
|
|
216
|
+
if (annotation && annotation.endsWith('[]') && value.kind === AST.NodeType.TableLiteral) {
|
|
217
|
+
const elementTypeName = annotation.slice(0, -2); // Remove []
|
|
218
|
+
const expectedElementType = this.stringToType(elementTypeName);
|
|
219
|
+
const tableValue = value;
|
|
220
|
+
// Iterate through each element and validate
|
|
221
|
+
for (let i = 0; i < tableValue.fields.length; i++) {
|
|
222
|
+
const field = tableValue.fields[i];
|
|
223
|
+
// If element is a TableLiteral and element type is an interface, validate against interface
|
|
224
|
+
if (field.value.kind === AST.NodeType.TableLiteral && this.typeRegistry.has(elementTypeName)) {
|
|
225
|
+
this.validateTableAgainstType(field.value, elementTypeName, decl.line || 0);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// For primitive types (number[], string[], etc.), check each element type
|
|
229
|
+
const actualType = this.inferType(field.value);
|
|
230
|
+
if (expectedElementType !== 'any' && actualType !== 'any' && expectedElementType !== actualType) {
|
|
231
|
+
throw new Error(`Type mismatch in array at line ${decl.line}: Element ${i + 1} expected '${elementTypeName}', got '${actualType}'`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// If we have a custom type annotation and the value is a table (not array), validate it
|
|
237
|
+
else if (customTypeName && value.kind === AST.NodeType.TableLiteral) {
|
|
238
|
+
this.validateTableAgainstType(value, customTypeName, decl.line || 0);
|
|
239
|
+
}
|
|
240
|
+
// If we have an annotation, check compatibility
|
|
241
|
+
if (type !== 'any' && inferred !== 'any' && type !== inferred) {
|
|
242
|
+
throw new Error(`Type mismatch at line ${decl.line}: Expected '${type}', but got '${inferred}'`);
|
|
243
|
+
}
|
|
244
|
+
// If no annotation, keep type as 'any' (default)
|
|
245
|
+
// if (type === 'any' && (!decl.typeAnnotations || !decl.typeAnnotations[index])) {
|
|
246
|
+
// type = inferred;
|
|
247
|
+
// }
|
|
248
|
+
}
|
|
249
|
+
if (n.kind === AST.NodeType.Identifier) {
|
|
250
|
+
this.currentScope.define(n.name, kind, type, decl.line || 0);
|
|
251
|
+
}
|
|
252
|
+
else if (n.kind === AST.NodeType.ObjectDestructure) {
|
|
253
|
+
n.properties.forEach(p => {
|
|
254
|
+
this.currentScope.define(p.name, kind, 'any', decl.line || 0);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else if (n.kind === AST.NodeType.ArrayDestructure) {
|
|
258
|
+
n.elements.forEach(el => {
|
|
259
|
+
this.currentScope.define(el.name, kind, 'any', decl.line || 0);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
visitAssignmentStmt(stmt) {
|
|
265
|
+
// Check RHS
|
|
266
|
+
stmt.values.forEach(v => this.visitExpression(v));
|
|
267
|
+
// Check LHS
|
|
268
|
+
stmt.targets.forEach((t, index) => {
|
|
269
|
+
// If it's a simple identifier, check const and TYPE
|
|
270
|
+
if (t.kind === AST.NodeType.Identifier) {
|
|
271
|
+
const name = t.name;
|
|
272
|
+
const sym = this.currentScope.lookup(name);
|
|
273
|
+
if (sym) {
|
|
274
|
+
if (sym.kind === 'const') {
|
|
275
|
+
throw new Error(`Assignment to constant variable '${name}' at line ${stmt.line}`);
|
|
276
|
+
}
|
|
277
|
+
// Type Checking
|
|
278
|
+
if (stmt.values[index]) {
|
|
279
|
+
const valueType = this.inferType(stmt.values[index]);
|
|
280
|
+
if (sym.type !== 'any' && valueType !== 'any' && sym.type !== valueType) {
|
|
281
|
+
throw new Error(`Type mismatch at line ${stmt.line}: Variable '${name}' is type '${sym.type}', but assigned '${valueType}'`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Handle member expression type checking
|
|
287
|
+
if (t.kind === AST.NodeType.MemberExpr) {
|
|
288
|
+
const member = t;
|
|
289
|
+
// 1. Strict Property Existence Check
|
|
290
|
+
if (member.object.kind === AST.NodeType.Identifier && !member.computed) {
|
|
291
|
+
const objName = member.object.name;
|
|
292
|
+
const sym = this.currentScope.lookup(objName);
|
|
293
|
+
const propName = member.property.name;
|
|
294
|
+
if (sym && sym.type !== 'any' && sym.type !== 'table') {
|
|
295
|
+
// Check if it's an interface or alias
|
|
296
|
+
let fields;
|
|
297
|
+
// Remove array suffix for type lookup if needed (though variable shouldn't be array if accessing prop directly)
|
|
298
|
+
const baseType = sym.type.replace('[]', '');
|
|
299
|
+
if (this.typeRegistry.has(baseType)) {
|
|
300
|
+
fields = this.typeRegistry.get(baseType);
|
|
301
|
+
}
|
|
302
|
+
else if (this.typeAliasRegistry.has(baseType)) {
|
|
303
|
+
const aliasStr = this.typeAliasRegistry.get(baseType);
|
|
304
|
+
if (aliasStr.startsWith('{')) {
|
|
305
|
+
fields = this.parseObjectLiteralType(aliasStr);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (fields) {
|
|
309
|
+
const fieldExists = fields.some(f => f.name === propName);
|
|
310
|
+
if (!fieldExists) {
|
|
311
|
+
throw new Error(`Property '${propName}' does not exist on type '${sym.type}' at line ${stmt.line}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const memberKey = this.getMemberExprKey(t);
|
|
317
|
+
// If this assignment has a type annotation, register it
|
|
318
|
+
if (stmt.typeAnnotation && index === 0) {
|
|
319
|
+
const type = this.stringToType(stmt.typeAnnotation);
|
|
320
|
+
this.memberTypes.set(memberKey, type);
|
|
321
|
+
// Also check that the value matches the declared type
|
|
322
|
+
if (stmt.values[index]) {
|
|
323
|
+
const valueType = this.inferType(stmt.values[index]);
|
|
324
|
+
if (type !== 'any' && valueType !== 'any' && type !== valueType) {
|
|
325
|
+
throw new Error(`Type mismatch at line ${stmt.line}: '${memberKey}' is declared as '${stmt.typeAnnotation}', but assigned '${valueType}'`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Check if this member has a registered type
|
|
331
|
+
const registeredType = this.memberTypes.get(memberKey);
|
|
332
|
+
if (registeredType && stmt.values[index]) {
|
|
333
|
+
const valueType = this.inferType(stmt.values[index]);
|
|
334
|
+
if (registeredType !== 'any' && valueType !== 'any' && registeredType !== valueType) {
|
|
335
|
+
throw new Error(`Type mismatch at line ${stmt.line}: '${memberKey}' is type '${registeredType}', but assigned '${valueType}'`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
this.visitExpression(t);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// Helper to get a string key for a member expression (e.g., "Config.Locale")
|
|
344
|
+
getMemberExprKey(expr) {
|
|
345
|
+
let parts = [];
|
|
346
|
+
let current = expr;
|
|
347
|
+
while (current.kind === AST.NodeType.MemberExpr) {
|
|
348
|
+
const memberExpr = current;
|
|
349
|
+
if (memberExpr.property.kind === AST.NodeType.Identifier) {
|
|
350
|
+
parts.unshift(memberExpr.property.name);
|
|
351
|
+
}
|
|
352
|
+
current = memberExpr.object;
|
|
353
|
+
}
|
|
354
|
+
if (current.kind === AST.NodeType.Identifier) {
|
|
355
|
+
parts.unshift(current.name);
|
|
356
|
+
}
|
|
357
|
+
return parts.join('.');
|
|
358
|
+
}
|
|
359
|
+
visitCompoundAssignment(stmt) {
|
|
360
|
+
this.visitExpression(stmt.value);
|
|
361
|
+
if (stmt.target.kind === AST.NodeType.Identifier) {
|
|
362
|
+
const name = stmt.target.name;
|
|
363
|
+
const sym = this.currentScope.lookup(name);
|
|
364
|
+
if (sym && sym.kind === 'const') {
|
|
365
|
+
throw new Error(`Assignment to constant variable '${name}' at line ${stmt.line}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.visitExpression(stmt.target);
|
|
369
|
+
}
|
|
370
|
+
visitFunctionDecl(decl) {
|
|
371
|
+
// Name is defined in CURRENT scope
|
|
372
|
+
let funcName;
|
|
373
|
+
if (decl.name && decl.name.kind === AST.NodeType.Identifier) {
|
|
374
|
+
funcName = decl.name.name;
|
|
375
|
+
this.currentScope.define(funcName, 'const', 'function', decl.line || 0);
|
|
376
|
+
}
|
|
377
|
+
// Register function signature for call validation
|
|
378
|
+
const paramTypes = [];
|
|
379
|
+
decl.params.forEach(p => {
|
|
380
|
+
let type = 'any';
|
|
381
|
+
if (p.typeAnnotation) {
|
|
382
|
+
type = this.stringToType(p.typeAnnotation);
|
|
383
|
+
}
|
|
384
|
+
paramTypes.push({ name: p.name.name, type });
|
|
385
|
+
});
|
|
386
|
+
if (funcName) {
|
|
387
|
+
this.functionRegistry.set(funcName, { params: paramTypes });
|
|
388
|
+
}
|
|
389
|
+
this.enterScope();
|
|
390
|
+
decl.params.forEach(p => {
|
|
391
|
+
// Parse param type annotation
|
|
392
|
+
let type = 'any';
|
|
393
|
+
if (p.typeAnnotation) {
|
|
394
|
+
type = this.stringToType(p.typeAnnotation);
|
|
395
|
+
}
|
|
396
|
+
this.currentScope.define(p.name.name, 'param', type, decl.line || 0);
|
|
397
|
+
if (p.defaultValue) {
|
|
398
|
+
const defType = this.inferType(p.defaultValue);
|
|
399
|
+
if (type !== 'any' && defType !== 'any' && type !== defType) {
|
|
400
|
+
throw new Error(`Type mismatch in parameter default value at line ${decl.line}`);
|
|
401
|
+
}
|
|
402
|
+
this.visitExpression(p.defaultValue);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
this.visitBlock(decl.body);
|
|
406
|
+
this.exitScope();
|
|
407
|
+
}
|
|
408
|
+
visitBlock(block) {
|
|
409
|
+
this.enterScope();
|
|
410
|
+
block.statements.forEach(s => this.visitStatement(s));
|
|
411
|
+
this.exitScope();
|
|
412
|
+
}
|
|
413
|
+
visitExpression(expr) {
|
|
414
|
+
// Mostly just traversing to find nested scopes in functions/arrows
|
|
415
|
+
if (!expr)
|
|
416
|
+
return;
|
|
417
|
+
// Check for undefined variable usage
|
|
418
|
+
if (expr.kind === AST.NodeType.Identifier) {
|
|
419
|
+
const id = expr;
|
|
420
|
+
const sym = this.currentScope.lookup(id.name);
|
|
421
|
+
// Skip globals/builtins - only check if it looks like a user variable
|
|
422
|
+
// FiveM/Lua has many globals, so we only warn for simple lowercase identifiers
|
|
423
|
+
// that are not common Lua globals
|
|
424
|
+
const luaGlobals = ['print', 'type', 'tostring', 'tonumber', 'pairs', 'ipairs',
|
|
425
|
+
'next', 'error', 'assert', 'pcall', 'xpcall', 'require',
|
|
426
|
+
'table', 'string', 'math', 'os', 'io', 'coroutine', 'debug',
|
|
427
|
+
'setmetatable', 'getmetatable', 'rawget', 'rawset', 'rawequal',
|
|
428
|
+
'collectgarbage', 'dofile', 'load', 'loadfile', 'select',
|
|
429
|
+
'unpack', '_G', '_VERSION', 'true', 'false', 'nil',
|
|
430
|
+
// FiveM common globals
|
|
431
|
+
'Citizen', 'CreateThread', 'Wait', 'GetGameTimer', 'vector3',
|
|
432
|
+
'vector2', 'vector4', 'GetCurrentResourceName', 'exports',
|
|
433
|
+
'PlayerPedId', 'GetPlayerPed', 'GetEntityCoords', 'TriggerEvent',
|
|
434
|
+
'TriggerServerEvent', 'TriggerClientEvent', 'RegisterNetEvent',
|
|
435
|
+
'AddEventHandler', 'RegisterCommand', 'IsControlJustPressed',
|
|
436
|
+
'AddEventHandler', 'RegisterCommand', 'LocalPlayer', 'PlayerId',
|
|
437
|
+
'GetPlayerServerId', 'source', 'SetEntityHealth', 'ESX', 'QBCore',
|
|
438
|
+
// Additional FiveM/Lua globals
|
|
439
|
+
'Config', 'Framework', 'MySQL', 'lib', 'cache', 'json',
|
|
440
|
+
'NetworkGetNetworkIdFromEntity', 'NetworkGetEntityFromNetworkId',
|
|
441
|
+
'GetPlayerPed', 'GetVehiclePedIsIn', 'IsPedInAnyVehicle',
|
|
442
|
+
'SetPedCanRagdoll', 'SetTimeout', 'SetInterval', 'ClearTimeout',
|
|
443
|
+
'GetHashKey', 'IsModelValid', 'RequestModel', 'HasModelLoaded'];
|
|
444
|
+
if (!sym && !luaGlobals.includes(id.name)) {
|
|
445
|
+
// Check if it's a type name (not a variable)
|
|
446
|
+
if (!this.typeRegistry.has(id.name) && !this.typeAliasRegistry.has(id.name)) {
|
|
447
|
+
throw new Error(`Undefined variable '${id.name}' at line ${id.line ?? 0}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (expr.kind === AST.NodeType.ArrowFunc) {
|
|
453
|
+
const arrow = expr;
|
|
454
|
+
this.enterScope();
|
|
455
|
+
arrow.params.forEach(p => {
|
|
456
|
+
let type = 'any';
|
|
457
|
+
if (p.typeAnnotation) {
|
|
458
|
+
type = this.stringToType(p.typeAnnotation);
|
|
459
|
+
}
|
|
460
|
+
this.currentScope.define(p.name.name, 'param', type, expr.line || 0);
|
|
461
|
+
if (p.defaultValue)
|
|
462
|
+
this.visitExpression(p.defaultValue);
|
|
463
|
+
});
|
|
464
|
+
if ('kind' in arrow.body && arrow.body.kind === AST.NodeType.Block) {
|
|
465
|
+
this.visitBlock(arrow.body);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
this.visitExpression(arrow.body);
|
|
469
|
+
}
|
|
470
|
+
this.exitScope();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (expr.kind === AST.NodeType.FunctionDecl) {
|
|
474
|
+
this.visitFunctionDecl(expr);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Handle TableLiteral - recurse into field values only
|
|
478
|
+
if (expr.kind === AST.NodeType.TableLiteral) {
|
|
479
|
+
const table = expr;
|
|
480
|
+
for (const field of table.fields) {
|
|
481
|
+
// Don't check keys - they are field names, not variable references
|
|
482
|
+
// Only check computed keys like [expression] or string keys
|
|
483
|
+
if (field.key && field.key.kind !== AST.NodeType.Identifier) {
|
|
484
|
+
this.visitExpression(field.key);
|
|
485
|
+
}
|
|
486
|
+
this.visitExpression(field.value);
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// Handle BinaryExpr
|
|
491
|
+
if (expr.kind === AST.NodeType.BinaryExpr) {
|
|
492
|
+
const bin = expr;
|
|
493
|
+
this.visitExpression(bin.left);
|
|
494
|
+
this.visitExpression(bin.right);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Handle CallExpr - recurse into callee and args, validate argument types
|
|
498
|
+
if (expr.kind === AST.NodeType.CallExpr) {
|
|
499
|
+
const call = expr;
|
|
500
|
+
this.visitExpression(call.callee);
|
|
501
|
+
// Validate argument types if callee is a known function
|
|
502
|
+
if (call.callee.kind === AST.NodeType.Identifier) {
|
|
503
|
+
const funcName = call.callee.name;
|
|
504
|
+
const funcInfo = this.functionRegistry.get(funcName);
|
|
505
|
+
if (funcInfo) {
|
|
506
|
+
// Check each argument against expected param type
|
|
507
|
+
for (let i = 0; i < call.args.length && i < funcInfo.params.length; i++) {
|
|
508
|
+
const expectedType = funcInfo.params[i].type;
|
|
509
|
+
const argType = this.inferType(call.args[i]);
|
|
510
|
+
if (expectedType !== 'any' && argType !== 'any' && expectedType !== argType) {
|
|
511
|
+
throw new Error(`Type mismatch in function call '${funcName}' at line ${call.line || 0}: Argument ${i + 1} expects '${expectedType}', got '${argType}'`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const arg of call.args) {
|
|
517
|
+
this.visitExpression(arg);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// Handle MemberExpr - only check the object, not property (obj.property)
|
|
522
|
+
if (expr.kind === AST.NodeType.MemberExpr) {
|
|
523
|
+
const member = expr;
|
|
524
|
+
this.visitExpression(member.object);
|
|
525
|
+
// Don't check member.property - it's a field access, not a variable
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
inferType(expr) {
|
|
530
|
+
if (!expr)
|
|
531
|
+
return 'any';
|
|
532
|
+
switch (expr.kind) {
|
|
533
|
+
case AST.NodeType.NumberLiteral: return 'number';
|
|
534
|
+
case AST.NodeType.StringLiteral: return 'string';
|
|
535
|
+
case AST.NodeType.InterpolatedString: return 'string';
|
|
536
|
+
case AST.NodeType.BooleanLiteral: return 'boolean';
|
|
537
|
+
case AST.NodeType.NilLiteral: return 'nil';
|
|
538
|
+
case AST.NodeType.VectorLiteral: return 'vector';
|
|
539
|
+
case AST.NodeType.TableLiteral: return 'table';
|
|
540
|
+
case AST.NodeType.FunctionDecl: return 'function';
|
|
541
|
+
case AST.NodeType.ArrowFunc: return 'function';
|
|
542
|
+
case AST.NodeType.Identifier:
|
|
543
|
+
const sym = this.currentScope.lookup(expr.name);
|
|
544
|
+
return sym ? sym.type : 'any';
|
|
545
|
+
// TODO: Improve binary expr inference (e.g. number + number = number)
|
|
546
|
+
case AST.NodeType.BinaryExpr: return 'any';
|
|
547
|
+
default:
|
|
548
|
+
return 'any';
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
stringToType(str) {
|
|
552
|
+
// Array types are tables
|
|
553
|
+
if (str.endsWith('[]'))
|
|
554
|
+
return 'table';
|
|
555
|
+
// Remove array notation for basic type check: string[] -> string
|
|
556
|
+
const baseStr = str.replace(/\[\]/g, '');
|
|
557
|
+
switch (baseStr) {
|
|
558
|
+
case 'string': return 'string';
|
|
559
|
+
case 'number': return 'number';
|
|
560
|
+
case 'boolean': return 'boolean';
|
|
561
|
+
case 'vector':
|
|
562
|
+
case 'vector2':
|
|
563
|
+
case 'vector3':
|
|
564
|
+
case 'vector4': return 'vector';
|
|
565
|
+
case 'table': return 'table';
|
|
566
|
+
case 'function': return 'function';
|
|
567
|
+
case 'any': return 'any';
|
|
568
|
+
default:
|
|
569
|
+
// Check if it's a registered interface
|
|
570
|
+
if (this.typeRegistry.has(baseStr)) {
|
|
571
|
+
return 'table';
|
|
572
|
+
}
|
|
573
|
+
// Check if it's a type alias
|
|
574
|
+
if (this.typeAliasRegistry.has(baseStr)) {
|
|
575
|
+
// If alias resolves to a primitive, return that. If object literal, return 'table'
|
|
576
|
+
const aliased = this.typeAliasRegistry.get(baseStr);
|
|
577
|
+
if (aliased.startsWith('{'))
|
|
578
|
+
return 'table';
|
|
579
|
+
return this.stringToType(aliased);
|
|
580
|
+
}
|
|
581
|
+
return 'any';
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
visitTypeAliasDecl(decl) {
|
|
585
|
+
this.typeAliasRegistry.set(decl.name.name, decl.type);
|
|
586
|
+
// If it's an object literal type, validate field types
|
|
587
|
+
if (decl.type.startsWith('{')) {
|
|
588
|
+
const fields = this.parseObjectLiteralType(decl.type);
|
|
589
|
+
for (const field of fields) {
|
|
590
|
+
this.validateTypeExists(field.type, decl.name.line ?? 0, `type '${decl.name.name}'`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
// It's a simple alias, validate the aliased type exists
|
|
595
|
+
this.validateTypeExists(decl.type, decl.name.line ?? 0, `type '${decl.name.name}'`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
visitTypeDecl(decl) {
|
|
599
|
+
const typeName = decl.name.name;
|
|
600
|
+
// Register the type
|
|
601
|
+
this.typeRegistry.set(typeName, decl.fields);
|
|
602
|
+
// Validate that all field types exist
|
|
603
|
+
for (const field of decl.fields) {
|
|
604
|
+
this.validateTypeExists(field.type, decl.name.line ?? 0, `interface '${typeName}'`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
validateTypeExists(typeStr, line, context) {
|
|
608
|
+
// Remove array brackets for base type check
|
|
609
|
+
const baseType = typeStr.replace(/\[\]/g, '').trim();
|
|
610
|
+
// Skip object literal types (they are inline definitions)
|
|
611
|
+
if (baseType.startsWith('{'))
|
|
612
|
+
return;
|
|
613
|
+
// Check primitive types (including FiveM vector types)
|
|
614
|
+
const primitives = ['string', 'number', 'boolean', 'nil', 'any', 'table', 'function', 'vector', 'void', 'vector2', 'vector3', 'vector4'];
|
|
615
|
+
if (primitives.includes(baseType))
|
|
616
|
+
return;
|
|
617
|
+
// Check type registry (interfaces)
|
|
618
|
+
if (this.typeRegistry.has(baseType))
|
|
619
|
+
return;
|
|
620
|
+
// Check type alias registry
|
|
621
|
+
if (this.typeAliasRegistry.has(baseType))
|
|
622
|
+
return;
|
|
623
|
+
// Type not found!
|
|
624
|
+
throw new Error(`Unknown type '${baseType}' in ${context} at line ${line}`);
|
|
625
|
+
}
|
|
626
|
+
validateTableAgainstType(table, typeName, line) {
|
|
627
|
+
let fields;
|
|
628
|
+
// Remove array brackets if present (e.g. validating a single object against MyType[])
|
|
629
|
+
// This shouldn't happen often in this function call context but good for safety
|
|
630
|
+
typeName = typeName.replace(/\[\]/g, '');
|
|
631
|
+
// 1. Check Interface Registry
|
|
632
|
+
if (this.typeRegistry.has(typeName)) {
|
|
633
|
+
fields = this.typeRegistry.get(typeName);
|
|
634
|
+
}
|
|
635
|
+
// 2. Check Type Aliases (specifically object literal aliases)
|
|
636
|
+
else if (this.typeAliasRegistry.has(typeName)) {
|
|
637
|
+
const aliasStr = this.typeAliasRegistry.get(typeName);
|
|
638
|
+
if (aliasStr.startsWith('{')) {
|
|
639
|
+
fields = this.parseObjectLiteralType(aliasStr);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (!fields)
|
|
643
|
+
return; // Unknown type, skip validation
|
|
644
|
+
const providedFields = new Map();
|
|
645
|
+
for (const field of table.fields) {
|
|
646
|
+
if (field.key && field.key.kind === AST.NodeType.Identifier) {
|
|
647
|
+
providedFields.set(field.key.name, field.value);
|
|
648
|
+
}
|
|
649
|
+
else if (field.key && field.key.kind === AST.NodeType.StringLiteral) {
|
|
650
|
+
providedFields.set(field.key.value, field.value);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Check each expected field
|
|
654
|
+
for (const expected of fields) {
|
|
655
|
+
const provided = providedFields.get(expected.name);
|
|
656
|
+
if (!provided) {
|
|
657
|
+
throw new Error(`Type '${typeName}' requires field '${expected.name}' at line ${line}`);
|
|
658
|
+
}
|
|
659
|
+
const providedType = this.inferType(provided);
|
|
660
|
+
const expectedType = this.stringToType(expected.type);
|
|
661
|
+
if (expectedType !== 'any' && providedType !== 'any' && expectedType !== providedType) {
|
|
662
|
+
throw new Error(`Type mismatch: field '${expected.name}' should be '${expected.type}', got '${providedType}' at line ${line}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
parseObjectLiteralType(typeStr) {
|
|
667
|
+
const content = typeStr.trim().slice(1, -1);
|
|
668
|
+
if (!content)
|
|
669
|
+
return [];
|
|
670
|
+
const fields = [];
|
|
671
|
+
const parts = content.split(/,|;/);
|
|
672
|
+
for (const part of parts) {
|
|
673
|
+
if (!part.trim())
|
|
674
|
+
continue;
|
|
675
|
+
const [key, val] = part.split(':').map(s => s.trim());
|
|
676
|
+
if (key && val) {
|
|
677
|
+
fields.push({ name: key.replace('?', ''), type: val });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return fields;
|
|
681
|
+
}
|
|
682
|
+
}
|