@newt-dev/compiler 0.1.0 → 0.2.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.
package/dist/src/ast.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface BotDecl extends BaseNode {
18
18
  value: StringLiteral;
19
19
  fromEnv: boolean;
20
20
  }
21
- export type Handler = ReadyHandler | CommandHandler | MessageContainsHandler | JoinHandler | LeaveHandler | ReactionAddHandler;
21
+ export type Handler = ReadyHandler | CommandHandler | SlashCommandHandler | MessageContainsHandler | JoinHandler | LeaveHandler | ReactionAddHandler | ButtonClickHandler | SelectMenuHandler;
22
22
  export interface ReadyHandler extends BaseNode {
23
23
  type: "ReadyHandler";
24
24
  body: Statement[];
@@ -46,6 +46,30 @@ export interface ReactionAddHandler extends BaseNode {
46
46
  emoji: StringLiteral;
47
47
  body: Statement[];
48
48
  }
49
+ export interface SlashCommandHandler extends BaseNode {
50
+ type: "SlashCommandHandler";
51
+ command: string;
52
+ description?: StringLiteral;
53
+ options?: SlashOption[];
54
+ body: Statement[];
55
+ }
56
+ export interface SlashOption extends BaseNode {
57
+ type: "SlashOption";
58
+ name: string;
59
+ description: StringLiteral;
60
+ required: BooleanLiteral;
61
+ optionType: "string" | "number" | "boolean" | "user" | "channel" | "role";
62
+ }
63
+ export interface ButtonClickHandler extends BaseNode {
64
+ type: "ButtonClickHandler";
65
+ buttonId: StringLiteral;
66
+ body: Statement[];
67
+ }
68
+ export interface SelectMenuHandler extends BaseNode {
69
+ type: "SelectMenuHandler";
70
+ menuId: StringLiteral;
71
+ body: Statement[];
72
+ }
49
73
  export type TimerDecl = EveryTimerDecl | DailyTimerDecl;
50
74
  export interface EveryTimerDecl extends BaseNode {
51
75
  type: "EveryTimerDecl";
@@ -59,7 +83,7 @@ export interface DailyTimerDecl extends BaseNode {
59
83
  body: Statement[];
60
84
  }
61
85
  export type TimeUnit = "second" | "seconds" | "minute" | "minutes" | "hour" | "hours" | "day" | "days";
62
- export type Statement = ReplyStatement | SayStatement | SayEmbedStatement | LetDecl | StoreStatement | IfStatement | ForEachStatement | RequireRoleStatement | GiveRoleStatement | RemoveRoleStatement | MuteStatement | KickStatement | BanStatement | PinStatement | DeleteKeyStatement | WaitStatement | TryCatchStatement | ExpressionStatement;
86
+ export type Statement = ReplyStatement | SayStatement | SayEmbedStatement | SayComponentsStatement | LetDecl | StoreStatement | IfStatement | ForEachStatement | RequireRoleStatement | GiveRoleStatement | RemoveRoleStatement | MuteStatement | KickStatement | BanStatement | PinStatement | DeleteKeyStatement | WaitStatement | TryCatchStatement | ExpressionStatement;
63
87
  export interface ReplyStatement extends BaseNode {
64
88
  type: "ReplyStatement";
65
89
  message: Expression;
@@ -73,6 +97,22 @@ export interface SayEmbedStatement extends BaseNode {
73
97
  type: "SayEmbedStatement";
74
98
  embed: EmbedBlock;
75
99
  }
100
+ export interface SayComponentsStatement extends BaseNode {
101
+ type: "SayComponentsStatement";
102
+ message: Expression;
103
+ components: Component[];
104
+ }
105
+ export interface Component extends BaseNode {
106
+ type: "ButtonComponent" | "SelectMenuComponent";
107
+ id: StringLiteral;
108
+ label?: StringLiteral;
109
+ options?: SelectOption[];
110
+ }
111
+ export interface SelectOption extends BaseNode {
112
+ type: "SelectOption";
113
+ label: StringLiteral;
114
+ value: StringLiteral;
115
+ }
76
116
  export interface EmbedBlock extends BaseNode {
77
117
  type: "EmbedBlock";
78
118
  title?: StringLiteral;
@@ -7,7 +7,7 @@ export function generate(program) {
7
7
  .filter((node) => node.type.endsWith("Handler") || node.type.endsWith("TimerDecl"))
8
8
  .map((node) => emitTopLevel(node, prefix))
9
9
  .join("\n\n");
10
- const botJs = `import { Client, EmbedBuilder, GatewayIntentBits, Partials } from "discord.js";
10
+ const botJs = `import { Client, EmbedBuilder, GatewayIntentBits, Partials, ButtonBuilder, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ButtonStyle } from "discord.js";
11
11
  import Database from "better-sqlite3";
12
12
  import axios from "axios";
13
13
 
@@ -113,6 +113,53 @@ ${emitStatements(node.body, " ", "member")}
113
113
  const channel = message.channel;
114
114
  const server = message.guild;
115
115
  ${emitStatements(node.body, " ", "message")}
116
+ });`;
117
+ case "SlashCommandHandler":
118
+ const optionsDef = node.options ? node.options.map(opt => `{
119
+ name: "${opt.name}",
120
+ description: ${emitExpression(opt.description)},
121
+ type: ${getOptionTypeValue(opt.optionType)},
122
+ required: ${opt.required.value}
123
+ }`).join(",\n ") : "";
124
+ const commandReg = `client.on("ready", async () => {
125
+ try {
126
+ await client.application.commands.create({
127
+ name: "${node.command}",
128
+ description: ${node.description ? emitExpression(node.description) : '"No description"'},
129
+ options: [${optionsDef}]
130
+ });
131
+ } catch (err) {
132
+ console.error("Failed to register slash command:", err);
133
+ }
134
+ });`;
135
+ const commandHandler = `client.on("interactionCreate", async (interaction) => {
136
+ if (!interaction.isChatInputCommand()) return;
137
+ if (interaction.commandName !== "${node.command}") return;
138
+ const user = interaction.user;
139
+ const channel = interaction.channel;
140
+ const server = interaction.guild;
141
+ const args = interaction.options;
142
+ ${emitStatements(node.body, " ", "interaction")}
143
+ });`;
144
+ return commandReg + "\n\n" + commandHandler;
145
+ case "ButtonClickHandler":
146
+ return `client.on("interactionCreate", async (interaction) => {
147
+ if (!interaction.isButton()) return;
148
+ if (interaction.customId !== ${emitExpression(node.buttonId)}) return;
149
+ const user = interaction.user;
150
+ const channel = interaction.channel;
151
+ const server = interaction.guild;
152
+ ${emitStatements(node.body, " ", "interaction")}
153
+ });`;
154
+ case "SelectMenuHandler":
155
+ return `client.on("interactionCreate", async (interaction) => {
156
+ if (!interaction.isStringSelectMenu()) return;
157
+ if (interaction.customId !== ${emitExpression(node.menuId)}) return;
158
+ const user = interaction.user;
159
+ const channel = interaction.channel;
160
+ const server = interaction.guild;
161
+ const values = interaction.values;
162
+ ${emitStatements(node.body, " ", "interaction")}
116
163
  });`;
117
164
  case "EveryTimerDecl":
118
165
  return `setInterval(async () => {\n${emitStatements(node.body, " ", "message")}\n}, ${durationMs(node.amount.value, node.unit)});`;
@@ -145,6 +192,12 @@ function emitStatement(statement, indent, triggerName) {
145
192
  return `${indent}await findChannel(server, "general")?.send({ embeds: [${emitEmbed(statement.embed)}] });`;
146
193
  }
147
194
  return `${indent}await (${triggerName}.channel ?? channel)?.send({ embeds: [${emitEmbed(statement.embed)}] });`;
195
+ case "SayComponentsStatement":
196
+ const components = emitComponents(statement.components);
197
+ if (triggerName === "member") {
198
+ return `${indent}await findChannel(server, "general")?.send({ content: ${emitExpression(statement.message)}, components: [${components}] });`;
199
+ }
200
+ return `${indent}await (${triggerName}.channel ?? channel)?.send({ content: ${emitExpression(statement.message)}, components: [${components}] });`;
148
201
  case "LetDecl":
149
202
  return `${indent}const ${statement.name} = ${emitExpression(statement.value)};`;
150
203
  case "StoreStatement":
@@ -264,3 +317,37 @@ function durationMs(amount, unit) {
264
317
  };
265
318
  return amount * (multipliers[unit] ?? 1000);
266
319
  }
320
+ function getOptionTypeValue(type) {
321
+ const types = {
322
+ string: 3,
323
+ number: 4,
324
+ boolean: 5,
325
+ user: 6,
326
+ channel: 7,
327
+ role: 8
328
+ };
329
+ return types[type] ?? 3;
330
+ }
331
+ function emitComponents(components) {
332
+ const rows = [];
333
+ let currentRow = [];
334
+ for (const component of components) {
335
+ if (component.type === "ButtonComponent") {
336
+ currentRow.push(`new ButtonBuilder()
337
+ .setCustomId(${emitExpression(component.id)})
338
+ .setLabel(${emitExpression(component.label)})
339
+ .setStyle(ButtonStyle.Primary)`);
340
+ }
341
+ else if (component.type === "SelectMenuComponent") {
342
+ currentRow.push(`new StringSelectMenuBuilder()
343
+ .setCustomId(${emitExpression(component.id)})
344
+ .setOptions(${component.options.map((opt) => `new StringSelectMenuOptionBuilder()
345
+ .setLabel(${emitExpression(opt.label)})
346
+ .setValue(${emitExpression(opt.value)})`).join(", ")})`);
347
+ }
348
+ }
349
+ if (currentRow.length > 0) {
350
+ rows.push(`new ActionRowBuilder().addComponents(${currentRow.join(", ")})`);
351
+ }
352
+ return rows.join(", ");
353
+ }
package/dist/src/lexer.js CHANGED
@@ -7,11 +7,23 @@ const keywords = new Set([
7
7
  "on",
8
8
  "ready",
9
9
  "command",
10
+ "slash",
11
+ "button",
12
+ "select",
13
+ "menu",
14
+ "click",
10
15
  "join",
11
16
  "leave",
12
17
  "message",
13
18
  "reaction",
14
19
  "add",
20
+ "description",
21
+ "with",
22
+ "options",
23
+ "components",
24
+ "button",
25
+ "label",
26
+ "select",
15
27
  "let",
16
28
  "if",
17
29
  "else",
@@ -77,6 +77,47 @@ class Parser {
77
77
  const emoji = this.parseStringLiteral();
78
78
  handler = { type: "ReactionAddHandler", loc: this.loc(start), emoji, body: [] };
79
79
  }
80
+ else if (event.value === "slash") {
81
+ const command = this.parseStringLiteral();
82
+ let description;
83
+ let options;
84
+ if (this.matchKeyword("description")) {
85
+ description = this.parseStringLiteral();
86
+ }
87
+ if (this.matchKeyword("with")) {
88
+ this.consumeKeyword("options");
89
+ options = [];
90
+ while (this.check("IDENTIFIER")) {
91
+ const name = this.consumeType("IDENTIFIER", "Expected option name").value;
92
+ this.consumeKeyword("as");
93
+ const optionType = this.consumeType("IDENTIFIER", "Expected option type").value;
94
+ this.consumeKeyword("description");
95
+ const optDescription = this.parseStringLiteral();
96
+ const required = this.matchKeyword("required") ?
97
+ { type: "BooleanLiteral", loc: this.loc(start), value: true } :
98
+ { type: "BooleanLiteral", loc: this.loc(start), value: false };
99
+ options.push({
100
+ type: "SlashOption",
101
+ loc: this.loc(start),
102
+ name,
103
+ description: optDescription,
104
+ required,
105
+ optionType: optionType
106
+ });
107
+ }
108
+ }
109
+ handler = { type: "SlashCommandHandler", loc: this.loc(start), command: command.value, description, options, body: [] };
110
+ }
111
+ else if (event.value === "button") {
112
+ this.consumeKeyword("click");
113
+ const buttonId = this.parseStringLiteral();
114
+ handler = { type: "ButtonClickHandler", loc: this.loc(start), buttonId, body: [] };
115
+ }
116
+ else if (event.value === "select") {
117
+ this.consumeKeyword("menu");
118
+ const menuId = this.parseStringLiteral();
119
+ handler = { type: "SelectMenuHandler", loc: this.loc(start), menuId, body: [] };
120
+ }
80
121
  else {
81
122
  throw makeCatalogError("NEWT_E003", event.line, event.column, this.sourceLineHint(event));
82
123
  }
@@ -197,6 +238,13 @@ class Parser {
197
238
  const embed = this.parseEmbedBlock(start);
198
239
  return { type: "SayEmbedStatement", loc: this.loc(start), embed };
199
240
  }
241
+ if (this.matchKeyword("with")) {
242
+ this.consumeKeyword("components");
243
+ const message = this.parseExpressionUntil(["NEWLINE", "EOF", "COLON"], ["in"]);
244
+ this.consumeBlockStart();
245
+ const components = this.parseComponents(start);
246
+ return { type: "SayComponentsStatement", loc: this.loc(start), message, components };
247
+ }
200
248
  const message = this.parseExpressionUntil(["NEWLINE", "EOF"], ["in"]);
201
249
  let channel;
202
250
  if (this.matchKeyword("in")) {
@@ -206,6 +254,55 @@ class Parser {
206
254
  this.consumeLineEnd();
207
255
  return { type: "SayStatement", loc: this.loc(start), message, channel };
208
256
  }
257
+ parseComponents(start) {
258
+ this.skipNewlines();
259
+ this.consumeType("INDENT", "Components need to be indented.");
260
+ const components = [];
261
+ this.skipNewlines();
262
+ while (!this.isAtEnd() && !this.check("DEDENT")) {
263
+ if (this.matchKeyword("button")) {
264
+ const id = this.parseStringLiteral();
265
+ this.consumeKeyword("label");
266
+ const label = this.parseStringLiteral();
267
+ components.push({
268
+ type: "ButtonComponent",
269
+ loc: this.loc(start),
270
+ id,
271
+ label
272
+ });
273
+ }
274
+ else if (this.matchKeyword("select")) {
275
+ this.consumeKeyword("menu");
276
+ const id = this.parseStringLiteral();
277
+ this.consumeKeyword("with");
278
+ this.consumeKeyword("options");
279
+ const options = [];
280
+ while (this.check("STRING")) {
281
+ const label = this.parseStringLiteral();
282
+ this.consumeKeyword("as");
283
+ const value = this.parseStringLiteral();
284
+ options.push({
285
+ type: "SelectOption",
286
+ loc: this.loc(start),
287
+ label,
288
+ value
289
+ });
290
+ }
291
+ components.push({
292
+ type: "SelectMenuComponent",
293
+ loc: this.loc(start),
294
+ id,
295
+ options
296
+ });
297
+ }
298
+ else {
299
+ this.advance();
300
+ }
301
+ this.skipNewlines();
302
+ }
303
+ this.consumeType("DEDENT", "Expected end of components block.");
304
+ return components;
305
+ }
209
306
  parseEmbedBlock(start) {
210
307
  this.skipNewlines();
211
308
  this.consumeType("INDENT", "Embed details need to be indented.");
@@ -43,6 +43,27 @@ class Validator {
43
43
  this.visitTimer(node);
44
44
  return;
45
45
  }
46
+ // Handle new handler types
47
+ if (node.type === "SlashCommandHandler") {
48
+ if (node.options) {
49
+ for (const option of node.options) {
50
+ this.visitExpression(option.description, new Set(), false);
51
+ }
52
+ }
53
+ if (node.description) {
54
+ this.visitExpression(node.description, new Set(), false);
55
+ }
56
+ // Add built-in variables for slash commands
57
+ const slashScope = new Set(["user", "channel", "server", "args", "interaction"]);
58
+ this.visitStatements(node.body, slashScope);
59
+ return;
60
+ }
61
+ if (node.type === "ButtonClickHandler" || node.type === "SelectMenuHandler") {
62
+ // Add built-in variables for interactions
63
+ const interactionScope = new Set(["user", "channel", "server", "interaction", "values"]);
64
+ this.visitStatements(node.body, interactionScope);
65
+ return;
66
+ }
46
67
  this.visitStatements(node.body, new Set());
47
68
  }
48
69
  visitBotDecl(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newt-dev/compiler",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Compiler for the Newt Discord bot DSL.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/ast.ts CHANGED
@@ -27,10 +27,13 @@ export interface BotDecl extends BaseNode {
27
27
  export type Handler =
28
28
  | ReadyHandler
29
29
  | CommandHandler
30
+ | SlashCommandHandler
30
31
  | MessageContainsHandler
31
32
  | JoinHandler
32
33
  | LeaveHandler
33
- | ReactionAddHandler;
34
+ | ReactionAddHandler
35
+ | ButtonClickHandler
36
+ | SelectMenuHandler;
34
37
 
35
38
  export interface ReadyHandler extends BaseNode {
36
39
  type: "ReadyHandler";
@@ -65,6 +68,34 @@ export interface ReactionAddHandler extends BaseNode {
65
68
  body: Statement[];
66
69
  }
67
70
 
71
+ export interface SlashCommandHandler extends BaseNode {
72
+ type: "SlashCommandHandler";
73
+ command: string;
74
+ description?: StringLiteral;
75
+ options?: SlashOption[];
76
+ body: Statement[];
77
+ }
78
+
79
+ export interface SlashOption extends BaseNode {
80
+ type: "SlashOption";
81
+ name: string;
82
+ description: StringLiteral;
83
+ required: BooleanLiteral;
84
+ optionType: "string" | "number" | "boolean" | "user" | "channel" | "role";
85
+ }
86
+
87
+ export interface ButtonClickHandler extends BaseNode {
88
+ type: "ButtonClickHandler";
89
+ buttonId: StringLiteral;
90
+ body: Statement[];
91
+ }
92
+
93
+ export interface SelectMenuHandler extends BaseNode {
94
+ type: "SelectMenuHandler";
95
+ menuId: StringLiteral;
96
+ body: Statement[];
97
+ }
98
+
68
99
  export type TimerDecl = EveryTimerDecl | DailyTimerDecl;
69
100
 
70
101
  export interface EveryTimerDecl extends BaseNode {
@@ -86,6 +117,7 @@ export type Statement =
86
117
  | ReplyStatement
87
118
  | SayStatement
88
119
  | SayEmbedStatement
120
+ | SayComponentsStatement
89
121
  | LetDecl
90
122
  | StoreStatement
91
123
  | IfStatement
@@ -118,6 +150,25 @@ export interface SayEmbedStatement extends BaseNode {
118
150
  embed: EmbedBlock;
119
151
  }
120
152
 
153
+ export interface SayComponentsStatement extends BaseNode {
154
+ type: "SayComponentsStatement";
155
+ message: Expression;
156
+ components: Component[];
157
+ }
158
+
159
+ export interface Component extends BaseNode {
160
+ type: "ButtonComponent" | "SelectMenuComponent";
161
+ id: StringLiteral;
162
+ label?: StringLiteral;
163
+ options?: SelectOption[];
164
+ }
165
+
166
+ export interface SelectOption extends BaseNode {
167
+ type: "SelectOption";
168
+ label: StringLiteral;
169
+ value: StringLiteral;
170
+ }
171
+
121
172
  export interface EmbedBlock extends BaseNode {
122
173
  type: "EmbedBlock";
123
174
  title?: StringLiteral;
package/src/codegen.ts CHANGED
@@ -22,7 +22,7 @@ export function generate(program: Program): GeneratedProject {
22
22
  .map((node) => emitTopLevel(node, prefix))
23
23
  .join("\n\n");
24
24
 
25
- const botJs = `import { Client, EmbedBuilder, GatewayIntentBits, Partials } from "discord.js";
25
+ const botJs = `import { Client, EmbedBuilder, GatewayIntentBits, Partials, ButtonBuilder, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ButtonStyle } from "discord.js";
26
26
  import Database from "better-sqlite3";
27
27
  import axios from "axios";
28
28
 
@@ -131,6 +131,58 @@ ${emitStatements(node.body, " ", "member")}
131
131
  const channel = message.channel;
132
132
  const server = message.guild;
133
133
  ${emitStatements(node.body, " ", "message")}
134
+ });`;
135
+ case "SlashCommandHandler":
136
+ const optionsDef = node.options ? node.options.map(opt =>
137
+ `{
138
+ name: "${opt.name}",
139
+ description: ${emitExpression(opt.description)},
140
+ type: ${getOptionTypeValue(opt.optionType)},
141
+ required: ${opt.required.value}
142
+ }`
143
+ ).join(",\n ") : "";
144
+
145
+ const commandReg = `client.on("ready", async () => {
146
+ try {
147
+ await client.application.commands.create({
148
+ name: "${node.command}",
149
+ description: ${node.description ? emitExpression(node.description) : '"No description"'},
150
+ options: [${optionsDef}]
151
+ });
152
+ } catch (err) {
153
+ console.error("Failed to register slash command:", err);
154
+ }
155
+ });`;
156
+
157
+ const commandHandler = `client.on("interactionCreate", async (interaction) => {
158
+ if (!interaction.isChatInputCommand()) return;
159
+ if (interaction.commandName !== "${node.command}") return;
160
+ const user = interaction.user;
161
+ const channel = interaction.channel;
162
+ const server = interaction.guild;
163
+ const args = interaction.options;
164
+ ${emitStatements(node.body, " ", "interaction")}
165
+ });`;
166
+
167
+ return commandReg + "\n\n" + commandHandler;
168
+ case "ButtonClickHandler":
169
+ return `client.on("interactionCreate", async (interaction) => {
170
+ if (!interaction.isButton()) return;
171
+ if (interaction.customId !== ${emitExpression(node.buttonId)}) return;
172
+ const user = interaction.user;
173
+ const channel = interaction.channel;
174
+ const server = interaction.guild;
175
+ ${emitStatements(node.body, " ", "interaction")}
176
+ });`;
177
+ case "SelectMenuHandler":
178
+ return `client.on("interactionCreate", async (interaction) => {
179
+ if (!interaction.isStringSelectMenu()) return;
180
+ if (interaction.customId !== ${emitExpression(node.menuId)}) return;
181
+ const user = interaction.user;
182
+ const channel = interaction.channel;
183
+ const server = interaction.guild;
184
+ const values = interaction.values;
185
+ ${emitStatements(node.body, " ", "interaction")}
134
186
  });`;
135
187
  case "EveryTimerDecl":
136
188
  return `setInterval(async () => {\n${emitStatements(node.body, " ", "message")}\n}, ${durationMs(node.amount.value, node.unit)});`;
@@ -165,6 +217,12 @@ function emitStatement(statement: Statement, indent: string, triggerName: string
165
217
  return `${indent}await findChannel(server, "general")?.send({ embeds: [${emitEmbed(statement.embed)}] });`;
166
218
  }
167
219
  return `${indent}await (${triggerName}.channel ?? channel)?.send({ embeds: [${emitEmbed(statement.embed)}] });`;
220
+ case "SayComponentsStatement":
221
+ const components = emitComponents(statement.components);
222
+ if (triggerName === "member") {
223
+ return `${indent}await findChannel(server, "general")?.send({ content: ${emitExpression(statement.message)}, components: [${components}] });`;
224
+ }
225
+ return `${indent}await (${triggerName}.channel ?? channel)?.send({ content: ${emitExpression(statement.message)}, components: [${components}] });`;
168
226
  case "LetDecl":
169
227
  return `${indent}const ${statement.name} = ${emitExpression(statement.value)};`;
170
228
  case "StoreStatement":
@@ -284,3 +342,43 @@ function durationMs(amount: number, unit: string): number {
284
342
  };
285
343
  return amount * (multipliers[unit] ?? 1000);
286
344
  }
345
+
346
+ function getOptionTypeValue(type: string): number {
347
+ const types: Record<string, number> = {
348
+ string: 3,
349
+ number: 4,
350
+ boolean: 5,
351
+ user: 6,
352
+ channel: 7,
353
+ role: 8
354
+ };
355
+ return types[type] ?? 3;
356
+ }
357
+
358
+ function emitComponents(components: any[]): string {
359
+ const rows: string[] = [];
360
+ let currentRow: string[] = [];
361
+
362
+ for (const component of components) {
363
+ if (component.type === "ButtonComponent") {
364
+ currentRow.push(`new ButtonBuilder()
365
+ .setCustomId(${emitExpression(component.id)})
366
+ .setLabel(${emitExpression(component.label)})
367
+ .setStyle(ButtonStyle.Primary)`);
368
+ } else if (component.type === "SelectMenuComponent") {
369
+ currentRow.push(`new StringSelectMenuBuilder()
370
+ .setCustomId(${emitExpression(component.id)})
371
+ .setOptions(${component.options.map((opt: any) =>
372
+ `new StringSelectMenuOptionBuilder()
373
+ .setLabel(${emitExpression(opt.label)})
374
+ .setValue(${emitExpression(opt.value)})`
375
+ ).join(", ")})`);
376
+ }
377
+ }
378
+
379
+ if (currentRow.length > 0) {
380
+ rows.push(`new ActionRowBuilder().addComponents(${currentRow.join(", ")})`);
381
+ }
382
+
383
+ return rows.join(", ");
384
+ }
package/src/lexer.ts CHANGED
@@ -37,11 +37,23 @@ const keywords = new Set([
37
37
  "on",
38
38
  "ready",
39
39
  "command",
40
+ "slash",
41
+ "button",
42
+ "select",
43
+ "menu",
44
+ "click",
40
45
  "join",
41
46
  "leave",
42
47
  "message",
43
48
  "reaction",
44
49
  "add",
50
+ "description",
51
+ "with",
52
+ "options",
53
+ "components",
54
+ "button",
55
+ "label",
56
+ "select",
45
57
  "let",
46
58
  "if",
47
59
  "else",
package/src/parser.ts CHANGED
@@ -8,12 +8,15 @@ import {
8
8
  type EveryTimerDecl,
9
9
  type Expression,
10
10
  type Handler,
11
+ type SlashOption,
11
12
  type MessageContainsHandler,
12
13
  type Program,
13
14
  type Statement,
14
15
  type StringLiteral,
15
16
  type TimeUnit,
16
- type TopLevelNode
17
+ type TopLevelNode,
18
+ type Component,
19
+ type SelectOption
17
20
  } from "./ast.js";
18
21
  import { makeCatalogError, NewtError } from "./errors.js";
19
22
  import type { Token, TokenType } from "./lexer.js";
@@ -95,6 +98,48 @@ class Parser {
95
98
  this.consumeKeyword("add");
96
99
  const emoji = this.parseStringLiteral();
97
100
  handler = { type: "ReactionAddHandler", loc: this.loc(start), emoji, body: [] };
101
+ } else if (event.value === "slash") {
102
+ const command = this.parseStringLiteral();
103
+ let description: StringLiteral | undefined;
104
+ let options: SlashOption[] | undefined;
105
+
106
+ if (this.matchKeyword("description")) {
107
+ description = this.parseStringLiteral();
108
+ }
109
+
110
+ if (this.matchKeyword("with")) {
111
+ this.consumeKeyword("options");
112
+ options = [];
113
+ while (this.check("IDENTIFIER")) {
114
+ const name = this.consumeType("IDENTIFIER", "Expected option name").value;
115
+ this.consumeKeyword("as");
116
+ const optionType = this.consumeType("IDENTIFIER", "Expected option type").value;
117
+ this.consumeKeyword("description");
118
+ const optDescription = this.parseStringLiteral();
119
+ const required = this.matchKeyword("required") ?
120
+ { type: "BooleanLiteral" as const, loc: this.loc(start), value: true } :
121
+ { type: "BooleanLiteral" as const, loc: this.loc(start), value: false };
122
+
123
+ options.push({
124
+ type: "SlashOption",
125
+ loc: this.loc(start),
126
+ name,
127
+ description: optDescription,
128
+ required,
129
+ optionType: optionType as any
130
+ });
131
+ }
132
+ }
133
+
134
+ handler = { type: "SlashCommandHandler", loc: this.loc(start), command: command.value, description, options, body: [] };
135
+ } else if (event.value === "button") {
136
+ this.consumeKeyword("click");
137
+ const buttonId = this.parseStringLiteral();
138
+ handler = { type: "ButtonClickHandler", loc: this.loc(start), buttonId, body: [] };
139
+ } else if (event.value === "select") {
140
+ this.consumeKeyword("menu");
141
+ const menuId = this.parseStringLiteral();
142
+ handler = { type: "SelectMenuHandler", loc: this.loc(start), menuId, body: [] };
98
143
  } else {
99
144
  throw makeCatalogError("NEWT_E003", event.line, event.column, this.sourceLineHint(event));
100
145
  }
@@ -236,6 +281,14 @@ class Parser {
236
281
  return { type: "SayEmbedStatement", loc: this.loc(start), embed };
237
282
  }
238
283
 
284
+ if (this.matchKeyword("with")) {
285
+ this.consumeKeyword("components");
286
+ const message = this.parseExpressionUntil(["NEWLINE", "EOF", "COLON"], ["in"]);
287
+ this.consumeBlockStart();
288
+ const components = this.parseComponents(start);
289
+ return { type: "SayComponentsStatement", loc: this.loc(start), message, components };
290
+ }
291
+
239
292
  const message = this.parseExpressionUntil(["NEWLINE", "EOF"], ["in"]);
240
293
  let channel: StringLiteral | undefined;
241
294
  if (this.matchKeyword("in")) {
@@ -246,6 +299,56 @@ class Parser {
246
299
  return { type: "SayStatement", loc: this.loc(start), message, channel };
247
300
  }
248
301
 
302
+ private parseComponents(start: Token): Component[] {
303
+ this.skipNewlines();
304
+ this.consumeType("INDENT", "Components need to be indented.");
305
+ const components: Component[] = [];
306
+ this.skipNewlines();
307
+
308
+ while (!this.isAtEnd() && !this.check("DEDENT")) {
309
+ if (this.matchKeyword("button")) {
310
+ const id = this.parseStringLiteral();
311
+ this.consumeKeyword("label");
312
+ const label = this.parseStringLiteral();
313
+ components.push({
314
+ type: "ButtonComponent",
315
+ loc: this.loc(start),
316
+ id,
317
+ label
318
+ });
319
+ } else if (this.matchKeyword("select")) {
320
+ this.consumeKeyword("menu");
321
+ const id = this.parseStringLiteral();
322
+ this.consumeKeyword("with");
323
+ this.consumeKeyword("options");
324
+ const options: SelectOption[] = [];
325
+ while (this.check("STRING")) {
326
+ const label = this.parseStringLiteral();
327
+ this.consumeKeyword("as");
328
+ const value = this.parseStringLiteral();
329
+ options.push({
330
+ type: "SelectOption",
331
+ loc: this.loc(start),
332
+ label,
333
+ value
334
+ });
335
+ }
336
+ components.push({
337
+ type: "SelectMenuComponent",
338
+ loc: this.loc(start),
339
+ id,
340
+ options
341
+ });
342
+ } else {
343
+ this.advance();
344
+ }
345
+ this.skipNewlines();
346
+ }
347
+
348
+ this.consumeType("DEDENT", "Expected end of components block.");
349
+ return components;
350
+ }
351
+
249
352
  private parseEmbedBlock(start: Token): EmbedBlock {
250
353
  this.skipNewlines();
251
354
  this.consumeType("INDENT", "Embed details need to be indented.");
package/src/validator.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  BotDecl,
3
3
  Expression,
4
+ Handler,
4
5
  Program,
5
6
  Statement,
6
7
  TimerDecl,
@@ -58,6 +59,29 @@ class Validator {
58
59
  return;
59
60
  }
60
61
 
62
+ // Handle new handler types
63
+ if (node.type === "SlashCommandHandler") {
64
+ if (node.options) {
65
+ for (const option of node.options) {
66
+ this.visitExpression(option.description, new Set(), false);
67
+ }
68
+ }
69
+ if (node.description) {
70
+ this.visitExpression(node.description, new Set(), false);
71
+ }
72
+ // Add built-in variables for slash commands
73
+ const slashScope = new Set(["user", "channel", "server", "args", "interaction"]);
74
+ this.visitStatements(node.body, slashScope);
75
+ return;
76
+ }
77
+
78
+ if (node.type === "ButtonClickHandler" || node.type === "SelectMenuHandler") {
79
+ // Add built-in variables for interactions
80
+ const interactionScope = new Set(["user", "channel", "server", "interaction", "values"]);
81
+ this.visitStatements(node.body, interactionScope);
82
+ return;
83
+ }
84
+
61
85
  this.visitStatements(node.body, new Set());
62
86
  }
63
87