@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 +42 -2
- package/dist/src/codegen.js +88 -1
- package/dist/src/lexer.js +12 -0
- package/dist/src/parser.js +97 -0
- package/dist/src/validator.js +21 -0
- package/package.json +1 -1
- package/src/ast.ts +52 -1
- package/src/codegen.ts +99 -1
- package/src/lexer.ts +12 -0
- package/src/parser.ts +104 -1
- package/src/validator.ts +24 -0
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;
|
package/dist/src/codegen.js
CHANGED
|
@@ -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",
|
package/dist/src/parser.js
CHANGED
|
@@ -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.");
|
package/dist/src/validator.js
CHANGED
|
@@ -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
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
|
|