botql 1.0.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/Parser.js ADDED
@@ -0,0 +1,762 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * BotQL Parser
5
+ *
6
+ * Converte código-fonte .sql (sintaxe BotQL) numa AST de statements
7
+ * que o botql.js (interpreter) consegue percorrer e executar.
8
+ *
9
+ * Uso:
10
+ * const { Parser } = require('./Parser.js');
11
+ * const ast = new Parser(sourceCode).parseProgram();
12
+ */
13
+
14
+ // ===== Tokenizer =====
15
+
16
+ const TokenType = {
17
+ KEYWORD: 'KEYWORD',
18
+ STRING: 'STRING',
19
+ NUMBER: 'NUMBER',
20
+ IDENT: 'IDENT',
21
+ SYMBOL: 'SYMBOL',
22
+ EOF: 'EOF'
23
+ };
24
+
25
+ // Palavras reservadas (case-insensitive na fonte, normalizadas para maiúsculas)
26
+ const KEYWORDS = new Set([
27
+ 'CREATE', 'BOT', 'PLATFORM', 'CONNECT', 'TABLE', 'PREVENT', 'DEFAULT',
28
+ 'ON', 'START', 'MESSAGE', 'FROM',
29
+ 'WHEN', 'CONTAINS', 'OR', 'OTHERWISE', 'KEYWORDS', 'THINK', 'WAITING',
30
+ 'REPLY', 'TO', 'FORWARD', 'PARSE', 'SEND',
31
+ 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'WHERE',
32
+ 'RUN', 'IMPORT', 'AS', 'RESPONSE'
33
+ ]);
34
+
35
+ class Token {
36
+ constructor(type, value, line) {
37
+ this.type = type;
38
+ this.value = value;
39
+ this.line = line;
40
+ }
41
+ }
42
+
43
+ class Tokenizer {
44
+ constructor(source) {
45
+ this.source = source;
46
+ this.pos = 0;
47
+ this.line = 1;
48
+ this.tokens = [];
49
+ }
50
+
51
+ error(msg) {
52
+ throw new Error(`syntax error: ${msg}`);
53
+ }
54
+
55
+ peekChar(offset = 0) {
56
+ return this.source[this.pos + offset];
57
+ }
58
+
59
+ tokenize() {
60
+ while (this.pos < this.source.length) {
61
+ const c = this.peekChar();
62
+
63
+ // Nova linha
64
+ if (c === '\n') {
65
+ this.line++;
66
+ this.pos++;
67
+ continue;
68
+ }
69
+
70
+ // Espaços
71
+ if (/\s/.test(c)) {
72
+ this.pos++;
73
+ continue;
74
+ }
75
+
76
+ // Comentário de linha: -- ...
77
+ if (c === '-' && this.peekChar(1) === '-') {
78
+ while (this.pos < this.source.length && this.peekChar() !== '\n') this.pos++;
79
+ continue;
80
+ }
81
+
82
+ // String literal
83
+ if (c === '"' || c === "'") {
84
+ this.tokens.push(this.readString(c));
85
+ continue;
86
+ }
87
+
88
+ // Número
89
+ if (/[0-9]/.test(c)) {
90
+ this.tokens.push(this.readNumber());
91
+ continue;
92
+ }
93
+
94
+ // Símbolos de um caractere
95
+ if ('{}(),.=+*;'.includes(c)) {
96
+ this.tokens.push(new Token(TokenType.SYMBOL, c, this.line));
97
+ this.pos++;
98
+ continue;
99
+ }
100
+
101
+ // Identificador / palavra-chave
102
+ if (/[A-Za-z_]/.test(c)) {
103
+ const word = this.readWord();
104
+ this.tokens.push(word);
105
+ continue;
106
+ }
107
+
108
+ this.error(`unexpected character: "${c}"`);
109
+ }
110
+
111
+ this.tokens.push(new Token(TokenType.EOF, null, this.line));
112
+ return this.tokens;
113
+ }
114
+
115
+ readString(quote) {
116
+ const startLine = this.line;
117
+ this.pos++; // consome a aspa de abertura
118
+ let value = '';
119
+ while (this.pos < this.source.length && this.peekChar() !== quote) {
120
+ if (this.peekChar() === '\\') {
121
+ this.pos++;
122
+ value += this.peekChar();
123
+ this.pos++;
124
+ continue;
125
+ }
126
+ if (this.peekChar() === '\n') this.line++;
127
+ value += this.peekChar();
128
+ this.pos++;
129
+ }
130
+ if (this.peekChar() !== quote) {
131
+ this.error('unterminated string');
132
+ }
133
+ this.pos++; // consome a aspa de fecho
134
+ return new Token(TokenType.STRING, value, startLine);
135
+ }
136
+
137
+ readNumber() {
138
+ const startLine = this.line;
139
+ let value = '';
140
+ while (this.pos < this.source.length && /[0-9.]/.test(this.peekChar())) {
141
+ value += this.peekChar();
142
+ this.pos++;
143
+ }
144
+ return new Token(TokenType.NUMBER, Number(value), startLine);
145
+ }
146
+
147
+ readWord() {
148
+ const startLine = this.line;
149
+ let value = '';
150
+ while (this.pos < this.source.length && /[A-Za-z0-9_]/.test(this.peekChar())) {
151
+ value += this.peekChar();
152
+ this.pos++;
153
+ }
154
+ const upper = value.toUpperCase();
155
+ if (KEYWORDS.has(upper) && value === upper) {
156
+ return new Token(TokenType.KEYWORD, upper, startLine);
157
+ }
158
+ return new Token(TokenType.IDENT, value, startLine);
159
+ }
160
+ }
161
+
162
+ // ===== Parser (recursive descent) =====
163
+
164
+ class Parser {
165
+ constructor(source) {
166
+ this.tokens = new Tokenizer(source).tokenize();
167
+ this.pos = 0;
168
+ }
169
+
170
+ error(msg) {
171
+ const tok = this.current();
172
+ throw new Error(`syntax error: ${msg}, near "${tok ? tok.value : 'EOF'}"`);
173
+ }
174
+
175
+ current() {
176
+ return this.tokens[this.pos];
177
+ }
178
+
179
+ at(type, value) {
180
+ const tok = this.current();
181
+ if (tok.type !== type) return false;
182
+ if (value !== undefined && tok.value !== value) return false;
183
+ return true;
184
+ }
185
+
186
+ atKeyword(...values) {
187
+ return this.at(TokenType.KEYWORD) && values.includes(this.current().value);
188
+ }
189
+
190
+ // Casa por valor, independente do token ser KEYWORD ou IDENT.
191
+ // Necessário para palavras como "SIGNAL", que tanto introduz um
192
+ // evento (ON SIGNAL FROM) como é usada como identificador (SIGNAL.PAIR).
193
+ atWord(...values) {
194
+ const tok = this.current();
195
+ return (tok.type === TokenType.KEYWORD || tok.type === TokenType.IDENT) && values.includes(tok.value);
196
+ }
197
+
198
+ advance() {
199
+ const tok = this.current();
200
+ if (tok.type !== TokenType.EOF) this.pos++;
201
+ return tok;
202
+ }
203
+
204
+ expect(type, value) {
205
+ if (!this.at(type, value)) {
206
+ this.error(`expected ${value || type}`);
207
+ }
208
+ return this.advance();
209
+ }
210
+
211
+ expectKeyword(value) {
212
+ return this.expect(TokenType.KEYWORD, value);
213
+ }
214
+
215
+ isAtEnd() {
216
+ return this.at(TokenType.EOF);
217
+ }
218
+
219
+ // ---- Programa ----
220
+
221
+ parseProgram() {
222
+ const statements = [];
223
+ while (!this.isAtEnd()) {
224
+ statements.push(this.parseStatement());
225
+ }
226
+ return { type: 'Program', body: statements };
227
+ }
228
+
229
+ // ---- Bloco { ... } ou statement único ----
230
+ // Regra da linguagem: bloco com mais de uma ação usa chaves,
231
+ // uma única ação não precisa.
232
+
233
+ parseBody() {
234
+ if (this.at(TokenType.SYMBOL, '{')) {
235
+ this.advance(); // {
236
+ const statements = [];
237
+ while (!this.at(TokenType.SYMBOL, '}')) {
238
+ if (this.isAtEnd()) this.error('unclosed block "{"');
239
+ statements.push(this.parseStatement());
240
+ }
241
+ this.advance(); // }
242
+ return statements;
243
+ }
244
+ // Ação única, sem chaves
245
+ return [this.parseStatement()];
246
+ }
247
+
248
+ // ---- Dispatch de statement ----
249
+
250
+ parseStatement() {
251
+ if (this.atKeyword('CREATE')) return this.parseCreate();
252
+ if (this.atKeyword('PLATFORM')) return this.parsePlatform();
253
+ if (this.atKeyword('CONNECT')) return this.parseConnect();
254
+ if (this.atKeyword('ON')) return this.parseOn();
255
+ if (this.atKeyword('WHEN')) return this.parseWhen();
256
+ if (this.atKeyword('OTHERWISE')) return this.parseOtherwise();
257
+ if (this.atKeyword('REPLY')) return this.parseReply();
258
+ if (this.atKeyword('THINK')) return this.parseThink();
259
+ if (this.atKeyword('FORWARD')) return this.parseForward();
260
+ if (this.atKeyword('PARSE')) return this.parseParseSignal();
261
+ if (this.atKeyword('SEND')) return this.parseSend();
262
+ if (this.atKeyword('INSERT')) return this.parseInsert();
263
+ if (this.atKeyword('UPDATE')) return this.parseUpdate();
264
+ if (this.atKeyword('RUN')) return this.parseRunBot();
265
+ if (this.atKeyword('IMPORT')) return this.parseImport();
266
+
267
+ this.error('unexpected statement');
268
+ }
269
+
270
+ // ---- CREATE BOT / CREATE TABLE ----
271
+
272
+ parseCreate() {
273
+ this.expectKeyword('CREATE');
274
+ if (this.atKeyword('BOT')) {
275
+ this.advance();
276
+ const name = this.expect(TokenType.STRING).value;
277
+ return { type: 'CreateBot', name };
278
+ }
279
+ if (this.atKeyword('TABLE')) {
280
+ this.advance();
281
+ const name = this.expect(TokenType.IDENT).value;
282
+ const columns = this.parseColumnList();
283
+ const preventDefault = this.tryParsePreventDefault();
284
+ const defaultMessage = this.tryParseDefaultMessage();
285
+ return { type: 'CreateTable', name, columns, preventDefault, defaultMessage };
286
+ }
287
+ this.error('expected BOT or TABLE after CREATE');
288
+ }
289
+
290
+ parseColumnList() {
291
+ this.expect(TokenType.SYMBOL, '(');
292
+ const columns = [];
293
+ while (!this.at(TokenType.SYMBOL, ')')) {
294
+ const colName = this.expect(TokenType.IDENT).value;
295
+ const constraints = [];
296
+ let colType = null;
297
+ // Tipo e constraints são identificadores livres (INT, TEXT, PRIMARY, KEY, ...)
298
+ // até à vírgula ou ao fecho do parêntese.
299
+ while (this.at(TokenType.IDENT) && !this.at(TokenType.SYMBOL, ',') && !this.at(TokenType.SYMBOL, ')')) {
300
+ const word = this.advance().value;
301
+ if (!colType) colType = word;
302
+ else constraints.push(word);
303
+ }
304
+ columns.push({ name: colName, columnType: colType, constraints });
305
+ if (this.at(TokenType.SYMBOL, ',')) this.advance();
306
+ }
307
+ this.expect(TokenType.SYMBOL, ')');
308
+ return columns;
309
+ }
310
+
311
+ tryParsePreventDefault() {
312
+ if (this.atKeyword('PREVENT')) {
313
+ this.advance();
314
+ this.expectKeyword('DEFAULT');
315
+ return true;
316
+ }
317
+ return false;
318
+ }
319
+
320
+ // ---- DEFAULT MESSAGE "texto" ----
321
+ // ---- DEFAULT MESSAGE (ficheiro.txt, N) ----
322
+ //
323
+ // Modificador do CREATE TABLE, ao lado de PREVENT DEFAULT: saudação
324
+ // automática enviada só na primeira mensagem de cada client/sender
325
+ // (ver botql.js, receiveMessage). Aceita texto direto ou a mesma forma
326
+ // de ficheiro indexado do REPLY — por isso reaproveita isReplyFileForm
327
+ // para o lookahead. "DEFAULT" aqui não é o mesmo "DEFAULT" de "PREVENT
328
+ // DEFAULT" — só é reconhecido como início de DEFAULT MESSAGE quando NÃO
329
+ // vem logo a seguir a PREVENT (tryParsePreventDefault já consumiu esse
330
+ // caso antes de chegarmos aqui).
331
+ tryParseDefaultMessage() {
332
+ if (!this.atKeyword('DEFAULT')) return null;
333
+ const next = this.tokens[this.pos + 1];
334
+ if (!next || next.type !== TokenType.KEYWORD || next.value !== 'MESSAGE') return null;
335
+
336
+ this.advance(); // DEFAULT
337
+ this.advance(); // MESSAGE
338
+
339
+ if (this.isReplyFileForm()) {
340
+ this.expect(TokenType.SYMBOL, '(');
341
+ const file = this.parseFileName();
342
+ this.expect(TokenType.SYMBOL, ',');
343
+ const index = this.parseExpression();
344
+ this.expect(TokenType.SYMBOL, ')');
345
+ return { file, index };
346
+ }
347
+
348
+ const value = this.parseExpression();
349
+ return { value };
350
+ }
351
+
352
+ // ---- PLATFORM ----
353
+
354
+ parsePlatform() {
355
+ this.expectKeyword('PLATFORM');
356
+ const value = this.advance().value; // ex: WHATSAPP (IDENT)
357
+ return { type: 'Platform', value };
358
+ }
359
+
360
+ // ---- CONNECT SERVIÇO TIPO "credencial" ----
361
+ // ---- CONNECT RESPONSE alias ----
362
+ //
363
+ // A segunda forma liga o bot a uma IA usando um valor já importado (o
364
+ // alias de um IMPORT {..., N} AS alias) — só estabelece a ligação,
365
+ // não dispara nada sozinho (quem dispara é Response()/Response(alias),
366
+ // dentro de uma ação). RESPONSE é keyword fixa aqui, por isso da para
367
+ // distinguir logo no primeiro token, sem lookahead: a forma normal
368
+ // nunca começa por essa palavra.
369
+
370
+ parseConnect() {
371
+ this.expectKeyword('CONNECT');
372
+
373
+ if (this.atKeyword('RESPONSE')) {
374
+ this.advance();
375
+ const alias = this.expect(TokenType.IDENT).value;
376
+ return { type: 'ConnectResponse', alias };
377
+ }
378
+
379
+ const service = this.advance().value;
380
+ const kind = this.advance().value;
381
+ const credential = this.expect(TokenType.STRING).value;
382
+ return { type: 'Connect', service, kind, credential };
383
+ }
384
+
385
+ // ---- ON START | ON MESSAGE | ON SIGNAL FROM "..." ----
386
+
387
+ parseOn() {
388
+ this.expectKeyword('ON');
389
+ if (this.atKeyword('START')) {
390
+ this.advance();
391
+ const body = this.parseBody();
392
+ return { type: 'On', event: 'START', body };
393
+ }
394
+ if (this.atKeyword('MESSAGE')) {
395
+ this.advance();
396
+ const body = this.parseBody();
397
+ return { type: 'On', event: 'MESSAGE', body };
398
+ }
399
+ if (this.atWord('SIGNAL')) {
400
+ this.advance();
401
+ this.expectKeyword('FROM');
402
+ const source = this.expect(TokenType.STRING).value;
403
+ const body = this.parseBody();
404
+ return { type: 'On', event: 'SIGNAL', source, body };
405
+ }
406
+ this.error('unexpected event after ON');
407
+ }
408
+
409
+ // ---- WHEN CONTAINS "..." [OR CONTAINS "..."]* ----
410
+
411
+ parseWhen() {
412
+ this.expectKeyword('WHEN');
413
+ const conditions = [this.parseCondition()];
414
+ while (this.atKeyword('OR')) {
415
+ this.advance();
416
+ conditions.push(this.parseCondition());
417
+ }
418
+ const body = this.parseBody();
419
+ return { type: 'When', conditions, body };
420
+ }
421
+
422
+ parseCondition() {
423
+ this.expectKeyword('CONTAINS');
424
+
425
+ // CONTAINS ("oi", "ola", "boa tarde", ...) — lista inline, evita
426
+ // repetir "OR CONTAINS" para cada palavra-chave.
427
+ if (this.at(TokenType.SYMBOL, '(')) {
428
+ const values = this.parseStringList();
429
+ return { op: 'CONTAINS_ANY', values };
430
+ }
431
+
432
+ // CONTAINS KEYWORDS(ficheiro.txt) — lista carregada de um ficheiro
433
+ // à parte (uma palavra-chave por linha), para listas grandes
434
+ // (100+ palavras) sem poluir o .sql. Sem aspas de propósito: não é
435
+ // uma string de busca, é um nome de ficheiro (fica visualmente
436
+ // diferente de CONTAINS "texto").
437
+ if (this.atKeyword('KEYWORDS')) {
438
+ this.advance();
439
+ this.expect(TokenType.SYMBOL, '(');
440
+ const path = this.parseFileName();
441
+ this.expect(TokenType.SYMBOL, ')');
442
+ return { op: 'CONTAINS_KEYWORDS_FILE', path };
443
+ }
444
+
445
+ // CONTAINS "texto" — forma original, continua a funcionar.
446
+ const value = this.expect(TokenType.STRING).value;
447
+ return { op: 'CONTAINS', value };
448
+ }
449
+
450
+ // Lista de strings entre parênteses: ("a", "b", "c")
451
+ parseStringList() {
452
+ this.expect(TokenType.SYMBOL, '(');
453
+ const values = [];
454
+ while (!this.at(TokenType.SYMBOL, ')')) {
455
+ values.push(this.expect(TokenType.STRING).value);
456
+ if (this.at(TokenType.SYMBOL, ',')) this.advance();
457
+ }
458
+ this.expect(TokenType.SYMBOL, ')');
459
+ return values;
460
+ }
461
+
462
+ // Nome de ficheiro sem aspas, ex: saudacoes.txt ou lista_bot.sql.
463
+ // O tokenizer separa "saudacoes" (IDENT) e ".txt" em IDENT + SYMBOL('.')
464
+ // + IDENT, por isso remontamos aqui em vez de pedir um STRING.
465
+ parseFileName() {
466
+ let name = this.expect(TokenType.IDENT).value;
467
+ while (this.at(TokenType.SYMBOL, '.')) {
468
+ this.advance();
469
+ name += '.' + this.expect(TokenType.IDENT).value;
470
+ }
471
+ return name;
472
+ }
473
+
474
+ // ---- OTHERWISE ----
475
+
476
+ parseOtherwise() {
477
+ this.expectKeyword('OTHERWISE');
478
+ const body = this.parseBody();
479
+ return { type: 'Otherwise', body };
480
+ }
481
+
482
+ // ---- REPLY [TO alvo] expressão ----
483
+ // ---- REPLY [TO alvo] (ficheiro.txt, N) ----
484
+ //
485
+ // A segunda forma referencia uma entrada numerada de um ficheiro de
486
+ // respostas (uma linha "N- texto" por entrada). Distingue-se da
487
+ // expressão comum porque começa por "(" seguido de um nome de
488
+ // ficheiro (IDENT + "." + IDENT), e não por uma STRING/NUMBER/IDENT
489
+ // isolado — daí o lookahead antes de decidir qual ramo seguir.
490
+
491
+ // ---- THINK(ficheiro.txt) ----
492
+ // ---- THINK(ficheiro.txt) OR REPLY (fallback.txt, N) ----
493
+ // ---- THINK(ficheiro.txt) OR REPLY "texto fixo" ----
494
+ //
495
+ // Diferente de REPLY: THINK não responde com um texto já mapeado, faz
496
+ // retrieval local sobre um ficheiro de conhecimento (ver RAG.js) e só
497
+ // responde se achar um bloco com confiança suficiente. Antes de
498
+ // procurar, sinaliza onThinking (para a plataforma poder mostrar
499
+ // "Pensando..." enquanto isso, já que a busca — em ficheiros grandes —
500
+ // não é instantânea como um REPLY comum).
501
+ //
502
+ // O "OR REPLY ..." é o fallback: só corre se THINK não encontrar nada
503
+ // com confiança suficiente. Fica preso ao mesmo statement (não é um
504
+ // segundo statement solto no corpo do WHEN) porque só faz sentido
505
+ // junto — um REPLY fallback sem THINK antes seria só um REPLY normal.
506
+
507
+ parseThink() {
508
+ this.expectKeyword('THINK');
509
+ this.expect(TokenType.SYMBOL, '(');
510
+ const file = this.parseFileName();
511
+ this.expect(TokenType.SYMBOL, ')');
512
+
513
+ const waiting = this.tryParseWaiting();
514
+
515
+ let fallback = null;
516
+ if (this.atKeyword('OR')) {
517
+ this.advance();
518
+ fallback = this.parseReply();
519
+ }
520
+
521
+ return { type: 'Think', file, waiting, fallback };
522
+ }
523
+
524
+ // ---- WAITING() | WAITING(ficheiro.txt) | WAITING(ficheiro.txt, N) ----
525
+ //
526
+ // Modificador do THINK, sempre logo a seguir a ele, antes do OR (ver
527
+ // parseThink acima). Os dois argumentos são opcionais e independentes:
528
+ // sem nenhum, o interpretador usa texto e tempo mínimo por omissão
529
+ // ("Pensando...", 3 segundos) — ver botql.js, resolveWaitingConfig.
530
+ tryParseWaiting() {
531
+ if (!this.atKeyword('WAITING')) return null;
532
+ this.advance();
533
+ this.expect(TokenType.SYMBOL, '(');
534
+
535
+ let file = null;
536
+ let seconds = null;
537
+ if (!this.at(TokenType.SYMBOL, ')')) {
538
+ file = this.parseFileName();
539
+ if (this.at(TokenType.SYMBOL, ',')) {
540
+ this.advance();
541
+ seconds = this.parseExpression();
542
+ }
543
+ }
544
+
545
+ this.expect(TokenType.SYMBOL, ')');
546
+ return { file, seconds };
547
+ }
548
+
549
+ parseReply() {
550
+ this.expectKeyword('REPLY');
551
+ let target = null;
552
+ if (this.atKeyword('TO')) {
553
+ this.advance();
554
+ target = this.advance().value; // ex: ADMIN
555
+ }
556
+
557
+ if (this.isReplyFileForm()) {
558
+ this.expect(TokenType.SYMBOL, '(');
559
+ const file = this.parseFileName();
560
+ this.expect(TokenType.SYMBOL, ',');
561
+ const index = this.parseExpression();
562
+ this.expect(TokenType.SYMBOL, ')');
563
+ return { type: 'Reply', target, file, index };
564
+ }
565
+
566
+ const value = this.parseExpression();
567
+ return { type: 'Reply', target, value };
568
+ }
569
+
570
+ // Lookahead: "(" IDENT "." IDENT "," ... — só a forma de ficheiro
571
+ // tem "." logo a seguir ao primeiro identificador dentro dos
572
+ // parênteses. Uma chamada de função normal, ex: (NOW()), nunca
573
+ // bate aqui.
574
+ isReplyFileForm() {
575
+ if (!this.at(TokenType.SYMBOL, '(')) return false;
576
+ const next = this.tokens[this.pos + 1];
577
+ const afterNext = this.tokens[this.pos + 2];
578
+ return !!next && next.type === TokenType.IDENT &&
579
+ !!afterNext && afterNext.type === TokenType.SYMBOL && afterNext.value === '.';
580
+ }
581
+
582
+ // ---- FORWARD TO "contacto" ----
583
+
584
+ parseForward() {
585
+ this.expectKeyword('FORWARD');
586
+ this.expectKeyword('TO');
587
+ const target = this.parseExpression();
588
+ return { type: 'ForwardTo', target };
589
+ }
590
+
591
+ // ---- PARSE SIGNAL ----
592
+
593
+ parseParseSignal() {
594
+ this.expectKeyword('PARSE');
595
+ if (!this.atWord('SIGNAL')) this.error('expected SIGNAL after PARSE');
596
+ this.advance();
597
+ return { type: 'ParseSignal' };
598
+ }
599
+
600
+ // ---- SEND TO destino ----
601
+
602
+ parseSend() {
603
+ this.expectKeyword('SEND');
604
+ this.expectKeyword('TO');
605
+ const target = this.advance().value;
606
+ return { type: 'SendTo', target };
607
+ }
608
+
609
+ // ---- INSERT INTO tabela [(cols)] [VALUES (vals)] ----
610
+
611
+ parseInsert() {
612
+ this.expectKeyword('INSERT');
613
+ this.expectKeyword('INTO');
614
+ const table = this.expect(TokenType.IDENT).value;
615
+
616
+ let columns = null;
617
+ if (this.at(TokenType.SYMBOL, '(')) {
618
+ columns = this.parseExpressionList();
619
+ }
620
+
621
+ let values = null;
622
+ if (this.atKeyword('VALUES')) {
623
+ this.advance();
624
+ values = this.parseExpressionList();
625
+ }
626
+
627
+ return { type: 'Insert', table, columns, values };
628
+ }
629
+
630
+ parseExpressionList() {
631
+ this.expect(TokenType.SYMBOL, '(');
632
+ const items = [];
633
+ while (!this.at(TokenType.SYMBOL, ')')) {
634
+ items.push(this.parseExpression());
635
+ if (this.at(TokenType.SYMBOL, ',')) this.advance();
636
+ }
637
+ this.expect(TokenType.SYMBOL, ')');
638
+ return items;
639
+ }
640
+
641
+ // ---- UPDATE tabela SET col = expr WHERE expr ----
642
+
643
+ parseUpdate() {
644
+ this.expectKeyword('UPDATE');
645
+ const table = this.expect(TokenType.IDENT).value;
646
+ this.expectKeyword('SET');
647
+ const column = this.expect(TokenType.IDENT).value;
648
+ this.expect(TokenType.SYMBOL, '=');
649
+ const value = this.parseExpression();
650
+
651
+ let where = null;
652
+ if (this.atKeyword('WHERE')) {
653
+ this.advance();
654
+ where = this.parseComparison();
655
+ }
656
+
657
+ return { type: 'Update', table, set: { column, value }, where };
658
+ }
659
+
660
+ // ---- IMPORT {ficheiro.sql} ----
661
+ // ---- IMPORT {ficheiro.txt, N} ----
662
+ // ---- IMPORT {ficheiro.txt, N} AS alias ----
663
+ //
664
+ // A segunda forma importa só a entrada N de um ficheiro de valores
665
+ // (o mesmo formato "N- texto" usado por REPLY (ficheiro, N)), em vez
666
+ // do ficheiro inteiro. O índice é opcional, tal como em REPLY.
667
+ //
668
+ // AS alias só faz sentido junto com o índice (dá nome ao valor lido,
669
+ // para CONNECT RESPONSE/Response(...) e qualquer outra expressão
670
+ // referenciarem por esse nome em vez do nome generico "env"). Sem AS,
671
+ // mantém-se o comportamento original: o valor cai em env.NOME_FICHEIRO.
672
+
673
+ parseImport() {
674
+ this.expectKeyword('IMPORT');
675
+ this.expect(TokenType.SYMBOL, '{');
676
+ const path = this.parseFileName();
677
+ let index = null;
678
+ if (this.at(TokenType.SYMBOL, ',')) {
679
+ this.advance();
680
+ index = this.parseExpression();
681
+ }
682
+ this.expect(TokenType.SYMBOL, '}');
683
+
684
+ let alias = null;
685
+ if (this.atKeyword('AS')) {
686
+ this.advance();
687
+ alias = this.expect(TokenType.IDENT).value;
688
+ }
689
+
690
+ return { type: 'Import', path, index, alias };
691
+ }
692
+
693
+ // ---- RUN BOT ----
694
+
695
+ parseRunBot() {
696
+ this.expectKeyword('RUN');
697
+ this.expectKeyword('BOT');
698
+ return { type: 'RunBot' };
699
+ }
700
+
701
+ // ---- Expressões: literais, identificadores, chamadas, acesso a
702
+ // propriedade (obj.prop) e concatenação com "+" ----
703
+
704
+ parseExpression() {
705
+ let node = this.parsePrimary();
706
+ while (this.at(TokenType.SYMBOL, '+')) {
707
+ this.advance();
708
+ const right = this.parsePrimary();
709
+ node = { type: 'Binary', op: '+', left: node, right };
710
+ }
711
+ return node;
712
+ }
713
+
714
+ // Igualdade, usada em WHERE (ex: id = LAST_INSERT_ID())
715
+ parseComparison() {
716
+ const left = this.parseExpression();
717
+ if (this.at(TokenType.SYMBOL, '=')) {
718
+ this.advance();
719
+ const right = this.parseExpression();
720
+ return { type: 'Binary', op: '=', left, right };
721
+ }
722
+ return left;
723
+ }
724
+
725
+ parsePrimary() {
726
+ const tok = this.current();
727
+
728
+ if (tok.type === TokenType.STRING) {
729
+ this.advance();
730
+ return { type: 'Literal', value: tok.value };
731
+ }
732
+
733
+ if (tok.type === TokenType.NUMBER) {
734
+ this.advance();
735
+ return { type: 'Literal', value: tok.value };
736
+ }
737
+
738
+ if (tok.type === TokenType.IDENT) {
739
+ this.advance();
740
+ let node = { type: 'Identifier', name: tok.value };
741
+
742
+ // Chamada de função: NOME(...)
743
+ if (this.at(TokenType.SYMBOL, '(')) {
744
+ const args = this.parseExpressionList();
745
+ node = { type: 'Call', callee: node.name, args };
746
+ }
747
+
748
+ // Acesso a propriedade: NOME.CAMPO (ex: SIGNAL.PAIR)
749
+ while (this.at(TokenType.SYMBOL, '.')) {
750
+ this.advance();
751
+ const prop = this.expect(TokenType.IDENT).value;
752
+ node = { type: 'Member', object: node, property: prop };
753
+ }
754
+
755
+ return node;
756
+ }
757
+
758
+ this.error('invalid expression');
759
+ }
760
+ }
761
+
762
+ module.exports = { Parser, Tokenizer, TokenType, KEYWORDS };