@newt-dev/compiler 0.1.0

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.
@@ -0,0 +1,459 @@
1
+ import { makeCatalogError, NewtError } from "./errors.js";
2
+ export function parse(tokens) {
3
+ return new Parser(tokens).parseProgram();
4
+ }
5
+ class Parser {
6
+ tokens;
7
+ current = 0;
8
+ constructor(tokens) {
9
+ this.tokens = tokens;
10
+ }
11
+ parseProgram() {
12
+ const body = [];
13
+ this.skipNewlines();
14
+ while (!this.isAtEnd()) {
15
+ if (this.checkKeyword("bot")) {
16
+ body.push(this.parseBotDecl());
17
+ }
18
+ else if (this.checkKeyword("on")) {
19
+ body.push(this.parseHandler());
20
+ }
21
+ else if (this.checkKeyword("every") || this.checkKeyword("at")) {
22
+ body.push(this.parseTimer());
23
+ }
24
+ else {
25
+ const token = this.peek();
26
+ throw makeCatalogError("NEWT_E004", token.line, token.column, this.sourceLineHint(token));
27
+ }
28
+ this.skipNewlines();
29
+ }
30
+ return { type: "Program", loc: { line: 1, column: 1 }, body };
31
+ }
32
+ parseBotDecl() {
33
+ const start = this.consumeKeyword("bot");
34
+ const kindToken = this.consumeType("KEYWORD", "Bot declarations need a setting like name, prefix, or token.");
35
+ if (!["name", "prefix", "token"].includes(kindToken.value)) {
36
+ throw this.error(kindToken, "Bot declarations support name, prefix, and token.");
37
+ }
38
+ let fromEnv = false;
39
+ if (kindToken.value === "token" && this.matchKeyword("from")) {
40
+ this.consumeKeyword("env");
41
+ fromEnv = true;
42
+ }
43
+ const value = this.parseStringLiteral();
44
+ this.consumeLineEnd();
45
+ return {
46
+ type: "BotDecl",
47
+ loc: this.loc(start),
48
+ kind: kindToken.value,
49
+ value,
50
+ fromEnv
51
+ };
52
+ }
53
+ parseHandler() {
54
+ const start = this.consumeKeyword("on");
55
+ const event = this.consumeType("KEYWORD", "Handlers need an event name after on.");
56
+ let handler;
57
+ if (event.value === "ready") {
58
+ handler = { type: "ReadyHandler", loc: this.loc(start), body: [] };
59
+ }
60
+ else if (event.value === "command") {
61
+ const command = this.parseStringLiteral();
62
+ handler = { type: "CommandHandler", loc: this.loc(start), command: command.value, body: [] };
63
+ }
64
+ else if (event.value === "message") {
65
+ this.consumeKeyword("contains");
66
+ const needle = this.parseStringLiteral();
67
+ handler = { type: "MessageContainsHandler", loc: this.loc(start), needle, body: [] };
68
+ }
69
+ else if (event.value === "join") {
70
+ handler = { type: "JoinHandler", loc: this.loc(start), body: [] };
71
+ }
72
+ else if (event.value === "leave") {
73
+ handler = { type: "LeaveHandler", loc: this.loc(start), body: [] };
74
+ }
75
+ else if (event.value === "reaction") {
76
+ this.consumeKeyword("add");
77
+ const emoji = this.parseStringLiteral();
78
+ handler = { type: "ReactionAddHandler", loc: this.loc(start), emoji, body: [] };
79
+ }
80
+ else {
81
+ throw makeCatalogError("NEWT_E003", event.line, event.column, this.sourceLineHint(event));
82
+ }
83
+ this.consumeBlockStart();
84
+ handler.body = this.parseBlock();
85
+ return handler;
86
+ }
87
+ parseTimer() {
88
+ if (this.checkKeyword("every")) {
89
+ const start = this.advance();
90
+ const amount = this.parseNumberLiteral();
91
+ const unit = this.consumeType("KEYWORD", "Timers need a unit like seconds, minutes, or hours.");
92
+ this.consumeBlockStart();
93
+ return {
94
+ type: "EveryTimerDecl",
95
+ loc: this.loc(start),
96
+ amount,
97
+ unit: unit.value,
98
+ body: this.parseBlock()
99
+ };
100
+ }
101
+ const start = this.consumeKeyword("at");
102
+ const time = this.parseStringLiteral();
103
+ this.consumeKeyword("daily");
104
+ this.consumeBlockStart();
105
+ return { type: "DailyTimerDecl", loc: this.loc(start), time, body: this.parseBlock() };
106
+ }
107
+ parseBlock() {
108
+ this.skipNewlines();
109
+ this.consumeType("INDENT", "Indented lines should come after a block header.");
110
+ const body = [];
111
+ this.skipNewlines();
112
+ while (!this.isAtEnd() && !this.check("DEDENT")) {
113
+ body.push(this.parseStatement());
114
+ this.skipNewlines();
115
+ }
116
+ this.consumeType("DEDENT", "Blocks need to return to the previous indentation level.");
117
+ return body;
118
+ }
119
+ parseStatement() {
120
+ if (this.checkKeyword("reply")) {
121
+ const start = this.advance();
122
+ const message = this.parseExpressionUntilLineEnd();
123
+ this.consumeLineEnd();
124
+ return { type: "ReplyStatement", loc: this.loc(start), message };
125
+ }
126
+ if (this.checkKeyword("say")) {
127
+ return this.parseSayStatement();
128
+ }
129
+ if (this.checkKeyword("let")) {
130
+ const start = this.advance();
131
+ const name = this.consumeType("IDENTIFIER", "let needs a variable name.").value;
132
+ this.consumeType("EQUALS", "let needs an equals sign before the value.");
133
+ const value = this.parseExpressionUntilLineEnd();
134
+ this.consumeLineEnd();
135
+ return { type: "LetDecl", loc: this.loc(start), name, value };
136
+ }
137
+ if (this.checkKeyword("store")) {
138
+ const start = this.advance();
139
+ const namespace = this.parseAtom();
140
+ const key = this.consumeWord("store needs a key name after the namespace.").value;
141
+ this.consumeType("EQUALS", "store needs an equals sign before the value.");
142
+ const value = this.parseExpressionUntilLineEnd();
143
+ this.consumeLineEnd();
144
+ return { type: "StoreStatement", loc: this.loc(start), namespace, key, value };
145
+ }
146
+ if (this.checkKeyword("if")) {
147
+ return this.parseIfStatement();
148
+ }
149
+ if (this.checkKeyword("for")) {
150
+ return this.parseForEachStatement();
151
+ }
152
+ if (this.checkKeyword("require")) {
153
+ const start = this.advance();
154
+ this.consumeKeyword("role");
155
+ const role = this.parseStringLiteral();
156
+ this.consumeLineEnd();
157
+ return { type: "RequireRoleStatement", loc: this.loc(start), role };
158
+ }
159
+ if (this.checkKeyword("give") || this.checkKeyword("remove")) {
160
+ return this.parseRoleMutation();
161
+ }
162
+ if (this.checkKeyword("mute")) {
163
+ const start = this.advance();
164
+ const subject = this.parseAtom();
165
+ let duration;
166
+ if (this.matchKeyword("for")) {
167
+ duration = this.parseDuration();
168
+ }
169
+ this.consumeLineEnd();
170
+ return { type: "MuteStatement", loc: this.loc(start), subject, duration };
171
+ }
172
+ if (this.checkKeyword("kick") || this.checkKeyword("ban")) {
173
+ const start = this.advance();
174
+ const subject = this.parseAtom();
175
+ this.consumeLineEnd();
176
+ return { type: start.value === "kick" ? "KickStatement" : "BanStatement", loc: this.loc(start), subject };
177
+ }
178
+ if (this.checkKeyword("try")) {
179
+ return this.parseTryCatch();
180
+ }
181
+ if (this.checkKeyword("wait")) {
182
+ const start = this.advance();
183
+ this.consumeKeyword("for");
184
+ const duration = this.parseDuration();
185
+ this.consumeLineEnd();
186
+ return { type: "WaitStatement", loc: this.loc(start), duration };
187
+ }
188
+ const start = this.peek();
189
+ const expression = this.parseExpressionUntilLineEnd();
190
+ this.consumeLineEnd();
191
+ return { type: "ExpressionStatement", loc: this.loc(start), expression };
192
+ }
193
+ parseSayStatement() {
194
+ const start = this.consumeKeyword("say");
195
+ if (this.matchKeyword("embed")) {
196
+ this.consumeBlockStart();
197
+ const embed = this.parseEmbedBlock(start);
198
+ return { type: "SayEmbedStatement", loc: this.loc(start), embed };
199
+ }
200
+ const message = this.parseExpressionUntil(["NEWLINE", "EOF"], ["in"]);
201
+ let channel;
202
+ if (this.matchKeyword("in")) {
203
+ this.consumeKeyword("channel");
204
+ channel = this.parseStringLiteral();
205
+ }
206
+ this.consumeLineEnd();
207
+ return { type: "SayStatement", loc: this.loc(start), message, channel };
208
+ }
209
+ parseEmbedBlock(start) {
210
+ this.skipNewlines();
211
+ this.consumeType("INDENT", "Embed details need to be indented.");
212
+ const embed = { type: "EmbedBlock", loc: this.loc(start), fields: [] };
213
+ this.skipNewlines();
214
+ while (!this.isAtEnd() && !this.check("DEDENT")) {
215
+ if (this.matchKeyword("title")) {
216
+ embed.title = this.parseStringLiteral();
217
+ }
218
+ else if (this.matchKeyword("description")) {
219
+ embed.description = this.parseStringLiteral();
220
+ }
221
+ else if (this.matchKeyword("color")) {
222
+ const color = this.consumeType("HASH_COLOR", "Embed colors look like #5865F2.");
223
+ embed.color = { type: "ColorLiteral", loc: this.loc(color), value: color.value };
224
+ }
225
+ else if (this.matchKeyword("field")) {
226
+ const name = this.parseStringLiteral();
227
+ const value = this.parseStringLiteral();
228
+ embed.fields.push({ type: "EmbedField", loc: name.loc, name, value });
229
+ }
230
+ else {
231
+ throw this.error(this.peek(), "Embeds support title, description, color, and field lines.");
232
+ }
233
+ this.consumeLineEnd();
234
+ this.skipNewlines();
235
+ }
236
+ this.consumeType("DEDENT", "Embed blocks need to return to the previous indentation level.");
237
+ return embed;
238
+ }
239
+ parseIfStatement() {
240
+ const start = this.consumeKeyword("if");
241
+ const condition = this.parseExpressionUntil(["COLON"]);
242
+ this.consumeBlockStart();
243
+ const consequent = this.parseBlock();
244
+ let alternate = [];
245
+ if (this.matchKeyword("else")) {
246
+ this.consumeBlockStart();
247
+ alternate = this.parseBlock();
248
+ }
249
+ return { type: "IfStatement", loc: this.loc(start), condition, consequent, alternate };
250
+ }
251
+ parseForEachStatement() {
252
+ const start = this.consumeKeyword("for");
253
+ this.consumeKeyword("each");
254
+ const itemName = this.consumeWord("for each needs an item name.").value;
255
+ this.consumeKeyword("in");
256
+ const iterable = this.parseExpressionUntil(["COLON"]);
257
+ this.consumeBlockStart();
258
+ return { type: "ForEachStatement", loc: this.loc(start), itemName, iterable, body: this.parseBlock() };
259
+ }
260
+ parseRoleMutation() {
261
+ const start = this.advance();
262
+ const subject = this.parseAtom();
263
+ this.consumeKeyword("role");
264
+ const role = this.parseStringLiteral();
265
+ this.consumeLineEnd();
266
+ return start.value === "give"
267
+ ? { type: "GiveRoleStatement", loc: this.loc(start), subject, role }
268
+ : { type: "RemoveRoleStatement", loc: this.loc(start), subject, role };
269
+ }
270
+ parseTryCatch() {
271
+ const start = this.consumeKeyword("try");
272
+ this.consumeBlockStart();
273
+ const body = this.parseBlock();
274
+ this.consumeKeyword("on");
275
+ this.consumeKeyword("error");
276
+ this.consumeBlockStart();
277
+ const errorHandler = this.parseBlock();
278
+ return { type: "TryCatchStatement", loc: this.loc(start), body, errorHandler };
279
+ }
280
+ parseDuration() {
281
+ const amount = this.parseNumberLiteral();
282
+ const unit = this.consumeType("KEYWORD", "Durations need a unit like seconds, minutes, or hours.");
283
+ return { type: "DurationLiteral", loc: amount.loc, amount, unit: unit.value };
284
+ }
285
+ parseExpressionUntilLineEnd() {
286
+ return this.parseExpressionUntil(["NEWLINE", "EOF"]);
287
+ }
288
+ parseExpressionUntil(endTypes, endKeywords = []) {
289
+ const parts = [];
290
+ const operators = [];
291
+ while (!this.isAtEnd() && !endTypes.includes(this.peek().type) && !this.isEndKeyword(endKeywords)) {
292
+ if (this.check("OPERATOR") || this.checkKeyword("or") || this.checkKeyword("and") || this.checkKeyword("has")) {
293
+ const operator = this.advance();
294
+ operators.push(operator);
295
+ if (operator.value === "has" && this.checkKeyword("role")) {
296
+ this.advance();
297
+ }
298
+ }
299
+ else {
300
+ parts.push(this.parseAtom());
301
+ }
302
+ }
303
+ if (parts.length === 0) {
304
+ throw this.error(this.peek(), "This line needs a value here.");
305
+ }
306
+ let expression = parts[0];
307
+ for (let index = 1; index < parts.length; index += 1) {
308
+ const operator = operators[index - 1]?.value ?? "+";
309
+ expression = {
310
+ type: "BinaryExpr",
311
+ loc: expression.loc,
312
+ operator,
313
+ left: expression,
314
+ right: parts[index]
315
+ };
316
+ }
317
+ return expression;
318
+ }
319
+ parseAtom() {
320
+ if (this.check("STRING")) {
321
+ return this.parseStringLiteral();
322
+ }
323
+ if (this.check("NUMBER")) {
324
+ return this.parseNumberLiteral();
325
+ }
326
+ if (this.matchKeyword("load")) {
327
+ const start = this.previous();
328
+ const namespace = this.parseAtom();
329
+ const key = this.consumeWord("load needs a key name after the namespace.").value;
330
+ let fallback;
331
+ if (this.matchKeyword("or")) {
332
+ fallback = this.parseAtom();
333
+ }
334
+ return { type: "LoadExpr", loc: this.loc(start), namespace, key, fallback };
335
+ }
336
+ if (this.matchKeyword("fetch")) {
337
+ const start = this.previous();
338
+ return { type: "FetchExpr", loc: this.loc(start), url: this.parseAtom() };
339
+ }
340
+ if (this.match("LPAREN")) {
341
+ const expression = this.parseExpressionUntil(["RPAREN"]);
342
+ this.consumeType("RPAREN", "Close this expression with ).");
343
+ return expression;
344
+ }
345
+ const token = this.consumeWord("Expected a value.");
346
+ const path = [token.value];
347
+ while (this.match("DOT")) {
348
+ path.push(this.consumeWord("Expected a name after the dot.").value);
349
+ }
350
+ if (path[0] === "args" && this.match("LBRACKET")) {
351
+ const index = this.parseNumberLiteral();
352
+ this.consumeType("RBRACKET", "Close args[index] with ].");
353
+ return { type: "ArgsIndexExpr", loc: this.loc(token), index: index.value };
354
+ }
355
+ return path.length === 1
356
+ ? { type: "IdentifierExpr", loc: this.loc(token), name: path[0] }
357
+ : { type: "MemberExpr", loc: this.loc(token), path };
358
+ }
359
+ parseStringLiteral() {
360
+ const token = this.consumeType("STRING", "Text values need double quotes.");
361
+ return { type: "StringLiteral", loc: this.loc(token), value: token.value, interpolated: Boolean(token.interpolated) };
362
+ }
363
+ parseNumberLiteral() {
364
+ const token = this.consumeType("NUMBER", "Expected a number here.");
365
+ return { type: "NumberLiteral", loc: this.loc(token), value: Number(token.value) };
366
+ }
367
+ consumeBlockStart() {
368
+ if (!this.match("COLON")) {
369
+ const token = this.peek();
370
+ throw makeCatalogError("NEWT_E002", token.line, token.column, this.sourceLineHint(token));
371
+ }
372
+ this.consumeLineEnd();
373
+ }
374
+ consumeLineEnd() {
375
+ if (this.match("NEWLINE") || this.check("EOF")) {
376
+ return;
377
+ }
378
+ throw this.error(this.peek(), "I expected this line to end here.");
379
+ }
380
+ skipNewlines() {
381
+ while (this.match("NEWLINE")) {
382
+ // Keep moving.
383
+ }
384
+ }
385
+ isEndKeyword(keywords) {
386
+ return this.check("KEYWORD") && keywords.includes(this.peek().value);
387
+ }
388
+ consumeKeyword(value) {
389
+ if (this.checkKeyword(value)) {
390
+ return this.advance();
391
+ }
392
+ throw this.error(this.peek(), `Expected "${value}" here.`);
393
+ }
394
+ matchKeyword(value) {
395
+ if (!this.checkKeyword(value)) {
396
+ return false;
397
+ }
398
+ this.advance();
399
+ return true;
400
+ }
401
+ checkKeyword(value) {
402
+ return this.check("KEYWORD") && this.peek().value === value;
403
+ }
404
+ consumeWord(message) {
405
+ if (this.check("IDENTIFIER") || this.check("KEYWORD")) {
406
+ return this.advance();
407
+ }
408
+ throw this.error(this.peek(), message);
409
+ }
410
+ consumeType(type, message) {
411
+ if (this.check(type)) {
412
+ return this.advance();
413
+ }
414
+ throw this.error(this.peek(), message);
415
+ }
416
+ match(type) {
417
+ if (!this.check(type)) {
418
+ return false;
419
+ }
420
+ this.advance();
421
+ return true;
422
+ }
423
+ check(type) {
424
+ if (this.isAtEnd()) {
425
+ return type === "EOF";
426
+ }
427
+ return this.peek().type === type;
428
+ }
429
+ advance() {
430
+ if (!this.isAtEnd()) {
431
+ this.current += 1;
432
+ }
433
+ return this.previous();
434
+ }
435
+ isAtEnd() {
436
+ return this.peek().type === "EOF";
437
+ }
438
+ peek() {
439
+ return this.tokens[this.current] ?? this.tokens[this.tokens.length - 1];
440
+ }
441
+ previous() {
442
+ return this.tokens[this.current - 1] ?? this.tokens[0];
443
+ }
444
+ loc(token) {
445
+ return { line: token.line, column: token.column };
446
+ }
447
+ sourceLineHint(_token) {
448
+ return undefined;
449
+ }
450
+ error(token, message) {
451
+ return new NewtError({
452
+ code: "NEWT_E001",
453
+ message,
454
+ suggestion: "Check the Newt syntax for this line and try again.",
455
+ line: token.line,
456
+ column: token.column
457
+ });
458
+ }
459
+ }
@@ -0,0 +1,3 @@
1
+ import type { Program } from "./ast.js";
2
+ import { NewtError } from "./errors.js";
3
+ export declare function validate(program: Program, source?: string): NewtError[];
@@ -0,0 +1,176 @@
1
+ import { makeCatalogError } from "./errors.js";
2
+ const builtIns = new Set([
3
+ "user.name",
4
+ "user.id",
5
+ "user.mention",
6
+ "message.content",
7
+ "channel.name",
8
+ "server.name",
9
+ "server.members",
10
+ "args",
11
+ "target"
12
+ ]);
13
+ export function validate(program, source = "") {
14
+ const validator = new Validator(source);
15
+ return validator.validate(program);
16
+ }
17
+ class Validator {
18
+ source;
19
+ errors = [];
20
+ hasBotName = false;
21
+ hasBotToken = false;
22
+ constructor(source) {
23
+ this.source = source;
24
+ }
25
+ validate(program) {
26
+ for (const node of program.body) {
27
+ this.visitTopLevel(node);
28
+ }
29
+ if (!this.hasBotName) {
30
+ this.errors.push(makeCatalogError("NEWT_E008", 1, 1, this.line(1)));
31
+ }
32
+ if (!this.hasBotToken) {
33
+ this.errors.push(makeCatalogError("NEWT_E009", 1, 1, this.line(1)));
34
+ }
35
+ return this.errors;
36
+ }
37
+ visitTopLevel(node) {
38
+ if (node.type === "BotDecl") {
39
+ this.visitBotDecl(node);
40
+ return;
41
+ }
42
+ if (node.type === "EveryTimerDecl" || node.type === "DailyTimerDecl") {
43
+ this.visitTimer(node);
44
+ return;
45
+ }
46
+ this.visitStatements(node.body, new Set());
47
+ }
48
+ visitBotDecl(node) {
49
+ if (node.kind === "name") {
50
+ this.hasBotName = true;
51
+ }
52
+ if (node.kind === "token") {
53
+ this.hasBotToken = true;
54
+ if (!node.fromEnv) {
55
+ this.errors.push(makeCatalogError("NEWT_E005", node.loc.line, node.loc.column, this.line(node.loc.line)));
56
+ }
57
+ }
58
+ }
59
+ visitTimer(node) {
60
+ if (node.type === "EveryTimerDecl" && node.amount.value <= 0) {
61
+ this.errors.push(makeCatalogError("NEWT_E014", node.loc.line, node.loc.column, this.line(node.loc.line)));
62
+ }
63
+ this.visitStatements(node.body, new Set());
64
+ }
65
+ visitStatements(statements, scope, inTry = false) {
66
+ for (const statement of statements) {
67
+ switch (statement.type) {
68
+ case "LetDecl":
69
+ this.visitExpression(statement.value, scope, inTry);
70
+ scope.add(statement.name);
71
+ break;
72
+ case "ReplyStatement":
73
+ case "SayStatement":
74
+ case "ExpressionStatement":
75
+ this.visitExpression("message" in statement ? statement.message : statement.expression, scope, inTry);
76
+ break;
77
+ case "SayEmbedStatement":
78
+ if (statement.embed.title)
79
+ this.visitExpression(statement.embed.title, scope, inTry);
80
+ if (statement.embed.description)
81
+ this.visitExpression(statement.embed.description, scope, inTry);
82
+ for (const field of statement.embed.fields) {
83
+ this.visitExpression(field.name, scope, inTry);
84
+ this.visitExpression(field.value, scope, inTry);
85
+ }
86
+ break;
87
+ case "StoreStatement":
88
+ this.visitExpression(statement.namespace, scope, inTry);
89
+ this.visitExpression(statement.value, scope, inTry);
90
+ break;
91
+ case "IfStatement":
92
+ this.visitExpression(statement.condition, scope, inTry);
93
+ this.visitStatements(statement.consequent, new Set(scope), inTry);
94
+ this.visitStatements(statement.alternate, new Set(scope), inTry);
95
+ break;
96
+ case "ForEachStatement":
97
+ this.visitExpression(statement.iterable, scope, inTry);
98
+ this.visitStatements(statement.body, new Set([...scope, statement.itemName]), inTry);
99
+ break;
100
+ case "RequireRoleStatement":
101
+ case "GiveRoleStatement":
102
+ case "RemoveRoleStatement":
103
+ if ("role" in statement && statement.role.value.length === 0) {
104
+ this.errors.push(makeCatalogError("NEWT_E015", statement.loc.line, statement.loc.column, this.line(statement.loc.line)));
105
+ }
106
+ if ("subject" in statement)
107
+ this.visitExpression(statement.subject, scope, inTry);
108
+ break;
109
+ case "MuteStatement":
110
+ case "KickStatement":
111
+ case "BanStatement":
112
+ this.visitExpression(statement.subject, scope, inTry);
113
+ break;
114
+ case "TryCatchStatement":
115
+ this.visitStatements(statement.body, new Set(scope), true);
116
+ this.visitStatements(statement.errorHandler, new Set(scope), true);
117
+ break;
118
+ case "WaitStatement":
119
+ if (statement.duration.amount.value <= 0) {
120
+ this.errors.push(makeCatalogError("NEWT_E014", statement.loc.line, statement.loc.column, this.line(statement.loc.line)));
121
+ }
122
+ break;
123
+ default:
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ visitExpression(expression, scope, inTry) {
129
+ switch (expression.type) {
130
+ case "IdentifierExpr":
131
+ if (!scope.has(expression.name) && !["user", "message", "channel", "server", "args", "target", "member", "current"].includes(expression.name)) {
132
+ this.errors.push(makeCatalogError("NEWT_E007", expression.loc.line, expression.loc.column, this.line(expression.loc.line), {
133
+ length: expression.name.length
134
+ }));
135
+ }
136
+ break;
137
+ case "MemberExpr": {
138
+ const path = expression.path.join(".");
139
+ const isKnown = builtIns.has(path) || scope.has(expression.path[0] ?? "") || expression.path[0] === "member";
140
+ if (!isKnown) {
141
+ this.errors.push(makeCatalogError("NEWT_E013", expression.loc.line, expression.loc.column, this.line(expression.loc.line), {
142
+ length: path.length
143
+ }));
144
+ }
145
+ break;
146
+ }
147
+ case "LoadExpr":
148
+ this.visitExpression(expression.namespace, scope, inTry);
149
+ if (expression.fallback)
150
+ this.visitExpression(expression.fallback, scope, inTry);
151
+ break;
152
+ case "FetchExpr":
153
+ if (!inTry) {
154
+ this.errors.push(makeCatalogError("NEWT_E011", expression.loc.line, expression.loc.column, this.line(expression.loc.line)));
155
+ }
156
+ this.visitExpression(expression.url, scope, inTry);
157
+ break;
158
+ case "BinaryExpr":
159
+ this.visitExpression(expression.left, scope, inTry);
160
+ this.visitExpression(expression.right, scope, inTry);
161
+ break;
162
+ case "UnaryExpr":
163
+ this.visitExpression(expression.argument, scope, inTry);
164
+ break;
165
+ case "CallExpr":
166
+ for (const arg of expression.args)
167
+ this.visitExpression(arg, scope, inTry);
168
+ break;
169
+ default:
170
+ break;
171
+ }
172
+ }
173
+ line(line) {
174
+ return this.source.split(/\r?\n/)[line - 1] ?? "";
175
+ }
176
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { test } from "node:test";
5
+ import { compile, tokenize } from "../src/index.js";
6
+ const examplesDir = resolve("../../examples");
7
+ test("all example programs compile", () => {
8
+ const files = readdirSync(examplesDir).filter((file) => file.endsWith(".newt"));
9
+ assert.deepEqual(files.sort(), [
10
+ "hello-world.newt",
11
+ "moderation-bot.newt",
12
+ "points-bot.newt",
13
+ "welcome-bot.newt"
14
+ ]);
15
+ for (const file of files) {
16
+ const source = readFileSync(join(examplesDir, file), "utf8");
17
+ const result = compile(source, file);
18
+ assert.equal(result.success, true, `${file} should compile`);
19
+ if (result.success) {
20
+ assert.match(result.botJs, /new Client/);
21
+ assert.match(result.packageJson, /discord\.js/);
22
+ }
23
+ }
24
+ });
25
+ test("lexer emits indentation and interpolated string tokens", () => {
26
+ const tokens = tokenize(`on command "hello":\n reply "Hi {user.name}!"\n`);
27
+ assert.ok(tokens.some((token) => token.type === "INDENT"));
28
+ assert.ok(tokens.some((token) => token.type === "DEDENT"));
29
+ assert.ok(tokens.some((token) => token.type === "STRING" && token.interpolated));
30
+ });
31
+ test("compiler reports missing bot token", () => {
32
+ const result = compile(`bot name "NoToken"\n\non ready:\n say "Ready"\n`, "broken.newt");
33
+ assert.equal(result.success, false);
34
+ if (!result.success) {
35
+ assert.ok(result.errors.some((error) => error.code === "NEWT_E009"));
36
+ }
37
+ });