codexivo 0.2.0 → 0.2.3

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,310 @@
1
+ import { evaluarReto } from './challenge';
2
+ import type { Challenge, ChallengeResult } from './challenge';
3
+ import type { ErrorInfo } from './errors';
4
+ import { Machine, serialize } from './runtime/machine';
5
+ import type { MachineEvent, MachineOptions, MachineStatus, SerializedValue, Snapshot } from './runtime/machine';
6
+ import { compile } from './runtime/compiler';
7
+ import { parse } from './syntax/parser';
8
+ import type * as ast from './syntax/ast';
9
+ import type { Token } from './syntax/token';
10
+ import { createWorld } from './world';
11
+ import type { WorldDefinition } from './world';
12
+
13
+ /**
14
+ * Protocolo del sandbox: los mensajes entre la página y el worker que ejecuta el código. La lógica vive aquí,
15
+ * sin depender de `postMessage`, para poder probarla; `worker.ts` la conecta con el worker y `sandbox.ts` es
16
+ * el cliente tipado para la página.
17
+ */
18
+
19
+ /** Qué eventos llegan en cada tanda. */
20
+ export type EventDetail = 'nada' | 'pasos' | 'decisiones' | 'todo';
21
+
22
+ export interface SandboxOptions {
23
+ /** Entrada inicial, además de la que se dé con `entrada`. */
24
+ input?: string[];
25
+ /** Si `leer()` sin entrada pausa la ejecución para pedirla. Por defecto `true`. */
26
+ interactiveInput?: boolean;
27
+ /** Semilla de `aleatorio`. Si no se da, se elige una al cargar y se conserva en los retrocesos. */
28
+ seed?: number;
29
+ maxOperations?: number;
30
+ maxCallDepth?: number;
31
+ maxOutput?: number;
32
+ breakpoints?: number[];
33
+ /** Mundo del modo mundo, en el formato de `WorldDefinition`. */
34
+ world?: WorldDefinition;
35
+ /**
36
+ * `pasos`: solo `paso` y `robot`; `decisiones`: además `decide`; `todo`: todos los eventos; `nada`: ninguno.
37
+ * Por defecto `decisiones`.
38
+ */
39
+ detail?: EventDetail;
40
+ /** Operaciones por tanda antes de ceder el control. Por defecto 50 000. */
41
+ batchSize?: number;
42
+ }
43
+
44
+ export type SandboxRequest =
45
+ | { type: 'cargar'; id: number; source: string; options?: SandboxOptions }
46
+ /** Ejecuta hasta terminar, sin detenerse en los puntos de interrupción. */
47
+ | { type: 'ejecutar'; id: number }
48
+ /** Ejecuta hasta el siguiente punto de interrupción, o hasta terminar. */
49
+ | { type: 'continuar'; id: number }
50
+ | { type: 'paso'; id: number }
51
+ | { type: 'pausar'; id: number }
52
+ /** Vuelve a ejecutar desde el inicio, con la misma entrada y semilla, hasta el paso `step`. */
53
+ | { type: 'ir_a_paso'; id: number; step: number }
54
+ | { type: 'entrada'; id: number; line: string }
55
+ | { type: 'cerrar_entrada'; id: number }
56
+ | { type: 'puntos_de_interrupción'; id: number; lines: number[] }
57
+ | { type: 'evaluar_reto'; id: number; source: string; challenge: Challenge };
58
+
59
+ export type StopReason = 'paso' | 'punto_de_interrupción' | 'pausa' | 'entrada' | 'fin' | 'error' | 'retroceso';
60
+
61
+ export interface Loaded {
62
+ type: 'cargado';
63
+ id: number;
64
+ tokens: Token[];
65
+ program: ast.Program;
66
+ /** Errores léxicos y sintácticos. Si hay alguno, no se puede ejecutar. */
67
+ errors: ErrorInfo[];
68
+ /** Semilla de `aleatorio` de esta carga. */
69
+ seed: number;
70
+ }
71
+
72
+ export interface Progress {
73
+ type: 'progreso';
74
+ id: number;
75
+ operations: number;
76
+ steps: number;
77
+ /** Salida nueva desde el mensaje anterior. */
78
+ output: string;
79
+ events: MachineEvent[];
80
+ }
81
+
82
+ export interface Stopped {
83
+ type: 'detenido';
84
+ id: number;
85
+ status: MachineStatus;
86
+ reason: StopReason;
87
+ operations: number;
88
+ steps: number;
89
+ /** Salida nueva desde el mensaje anterior; con `replaceOutput`, toda la salida. */
90
+ output: string;
91
+ /** En un retroceso, la salida reemplaza a la anterior en lugar de agregarse. */
92
+ replaceOutput?: boolean;
93
+ events: MachineEvent[];
94
+ snapshot: Snapshot;
95
+ result?: SerializedValue;
96
+ error?: ErrorInfo;
97
+ }
98
+
99
+ export type SandboxResponse =
100
+ | Loaded
101
+ | Progress
102
+ | Stopped
103
+ | { type: 'hecho'; id: number }
104
+ | { type: 'reto'; id: number; result: ChallengeResult }
105
+ /** El mensaje no se pudo atender (no hay programa cargado, la ejecución sigue en curso…). */
106
+ | { type: 'error'; id: number; message: string };
107
+
108
+ const DETAIL: Record<EventDetail, ReadonlySet<MachineEvent['type']> | 'todo'> = {
109
+ nada: new Set(),
110
+ pasos: new Set(['paso', 'robot']),
111
+ decisiones: new Set(['paso', 'robot', 'decide']),
112
+ todo: 'todo',
113
+ };
114
+
115
+ const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0));
116
+
117
+ export class SandboxHost {
118
+ private program?: ast.Program;
119
+ private options: SandboxOptions = {};
120
+ private seed = 0;
121
+ private machine?: Machine;
122
+ /** Líneas que se le dieron a `leer()` en esta carga, para repetirlas al retroceder. */
123
+ private inputLog: string[] = [];
124
+ private inputClosed = false;
125
+ private events: MachineEvent[] = [];
126
+ private muted = false;
127
+ private sentOutput = 0;
128
+ private running = false;
129
+ private pauseRequested = false;
130
+
131
+ constructor(private readonly post: (message: SandboxResponse) => void) {}
132
+
133
+ async handle(request: SandboxRequest): Promise<void> {
134
+ switch (request.type) {
135
+ case 'cargar':
136
+ if (this.running) return this.busy(request.id);
137
+ return this.load(request.id, request.source, request.options ?? {});
138
+ case 'evaluar_reto':
139
+ this.post({ type: 'reto', id: request.id, result: evaluarReto(request.source, request.challenge) });
140
+ return;
141
+ case 'pausar':
142
+ this.pauseRequested = this.running;
143
+ this.post({ type: 'hecho', id: request.id });
144
+ return;
145
+ }
146
+
147
+ const machine = this.machine;
148
+ if (!machine) {
149
+ this.post({ type: 'error', id: request.id, message: 'No hay un programa cargado sin errores.' });
150
+ return;
151
+ }
152
+ switch (request.type) {
153
+ case 'entrada':
154
+ this.inputLog.push(request.line);
155
+ machine.provideInput(request.line);
156
+ this.post({ type: 'hecho', id: request.id });
157
+ return;
158
+ case 'cerrar_entrada':
159
+ this.inputClosed = true;
160
+ machine.closeInput();
161
+ this.post({ type: 'hecho', id: request.id });
162
+ return;
163
+ case 'puntos_de_interrupción':
164
+ this.options = { ...this.options, breakpoints: request.lines };
165
+ machine.setBreakpoints(request.lines);
166
+ this.post({ type: 'hecho', id: request.id });
167
+ return;
168
+ }
169
+
170
+ if (this.running) return this.busy(request.id);
171
+ switch (request.type) {
172
+ case 'paso':
173
+ machine.step();
174
+ this.stopped(request.id, machine);
175
+ return;
176
+ case 'ejecutar':
177
+ case 'continuar':
178
+ return this.runInBatches(request.id, machine, request.type === 'ejecutar');
179
+ case 'ir_a_paso':
180
+ this.goToStep(request.id, request.step);
181
+ return;
182
+ }
183
+ }
184
+
185
+ private busy(id: number): void {
186
+ this.post({ type: 'error', id, message: 'La ejecución sigue en curso; pausa antes de mandar otra orden.' });
187
+ }
188
+
189
+ private load(id: number, source: string, options: SandboxOptions): void {
190
+ this.options = options;
191
+ this.seed = (options.seed ?? Math.floor(Math.random() * 2 ** 32)) >>> 0;
192
+ this.inputLog = [];
193
+ this.inputClosed = false;
194
+ this.events = [];
195
+ this.sentOutput = 0;
196
+ const { program, tokens, errors } = parse(source);
197
+ this.program = errors.length === 0 ? program : undefined;
198
+ this.machine = this.program ? this.createMachine() : undefined;
199
+ this.post({ type: 'cargado', id, tokens, program, errors: errors.map(error => error.toJSON()), seed: this.seed });
200
+ }
201
+
202
+ private createMachine(): Machine {
203
+ const { options } = this;
204
+ const levels = DETAIL[options.detail ?? 'decisiones'];
205
+ const machineOptions: MachineOptions = {
206
+ input: options.input ?? [],
207
+ interactiveInput: options.interactiveInput ?? true,
208
+ seed: this.seed,
209
+ breakpoints: options.breakpoints ?? [],
210
+ onEvent: event => {
211
+ if (this.muted) return;
212
+ if (levels === 'todo' || levels.has(event.type)) this.events.push(event);
213
+ },
214
+ ...(options.maxOperations !== undefined ? { maxOperations: options.maxOperations } : {}),
215
+ ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}),
216
+ ...(options.maxOutput !== undefined ? { maxOutput: options.maxOutput } : {}),
217
+ ...(options.world ? { world: createWorld(options.world) } : {}),
218
+ };
219
+ return new Machine(compile(this.program!), machineOptions);
220
+ }
221
+
222
+ private async runInBatches(id: number, machine: Machine, ignoreBreakpoints: boolean): Promise<void> {
223
+ this.running = true;
224
+ this.pauseRequested = false;
225
+ const breakpoints = this.options.breakpoints ?? [];
226
+ if (ignoreBreakpoints) machine.setBreakpoints([]);
227
+ try {
228
+ while (true) {
229
+ const status = machine.run(this.options.batchSize ?? 50_000);
230
+ if (status !== 'pausada' || machine.pauseReason !== 'presupuesto') break;
231
+ if (this.pauseRequested) break;
232
+ this.post({
233
+ type: 'progreso',
234
+ id,
235
+ operations: machine.operations,
236
+ steps: machine.steps,
237
+ output: this.newOutput(machine),
238
+ events: this.takeEvents(),
239
+ });
240
+ // Ceder el control deja que llegue un `pausar`.
241
+ await tick();
242
+ if (this.pauseRequested) break;
243
+ }
244
+ } finally {
245
+ if (ignoreBreakpoints) machine.setBreakpoints(breakpoints);
246
+ this.running = false;
247
+ }
248
+ this.stopped(id, machine, this.pauseRequested && machine.status === 'pausada' ? 'pausa' : undefined);
249
+ this.pauseRequested = false;
250
+ }
251
+
252
+ private goToStep(id: number, step: number): void {
253
+ // Como la ejecución es determinista, otra máquina con la misma entrada y semilla llega al mismo estado.
254
+ const machine = this.createMachine();
255
+ for (const line of this.inputLog) machine.provideInput(line);
256
+ if (this.inputClosed) machine.closeInput();
257
+ this.muted = true;
258
+ try {
259
+ while (machine.steps < step && !machine.done && machine.status !== 'esperando_entrada') machine.step();
260
+ } finally {
261
+ this.muted = false;
262
+ }
263
+ this.machine = machine;
264
+ this.events = [];
265
+ this.sentOutput = 0;
266
+ this.stopped(id, machine, 'retroceso', true);
267
+ }
268
+
269
+ private newOutput(machine: Machine): string {
270
+ const output = machine.output;
271
+ const fresh = output.slice(this.sentOutput);
272
+ this.sentOutput = output.length;
273
+ return fresh;
274
+ }
275
+
276
+ private takeEvents(): MachineEvent[] {
277
+ const events = this.events;
278
+ this.events = [];
279
+ return events;
280
+ }
281
+
282
+ private stopped(id: number, machine: Machine, reason?: StopReason, replaceOutput = false): void {
283
+ const status = machine.status;
284
+ const finalReason: StopReason =
285
+ reason ??
286
+ (status === 'terminada'
287
+ ? 'fin'
288
+ : status === 'error'
289
+ ? 'error'
290
+ : status === 'esperando_entrada'
291
+ ? 'entrada'
292
+ : machine.pauseReason === 'punto_de_interrupción'
293
+ ? 'punto_de_interrupción'
294
+ : 'paso');
295
+ this.post({
296
+ type: 'detenido',
297
+ id,
298
+ status,
299
+ reason: finalReason,
300
+ operations: machine.operations,
301
+ steps: machine.steps,
302
+ output: this.newOutput(machine),
303
+ ...(replaceOutput ? { replaceOutput: true } : {}),
304
+ events: this.takeEvents(),
305
+ snapshot: machine.snapshot(),
306
+ ...(status === 'terminada' ? { result: serialize(machine.result) } : {}),
307
+ ...(machine.error ? { error: machine.error.toJSON() } : {}),
308
+ });
309
+ }
310
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { Challenge, ChallengeResult } from './challenge';
2
+ import type { Loaded, Progress, SandboxOptions, SandboxRequest, SandboxResponse, Stopped } from './sandbox-host';
3
+
4
+ /** Lo que `createSandbox` necesita de un worker (un `Worker` del navegador o de Bun). */
5
+ export interface WorkerLike {
6
+ postMessage(message: SandboxRequest): void;
7
+ addEventListener(type: 'message', listener: (event: MessageEvent<SandboxResponse>) => void): void;
8
+ terminate?(): void;
9
+ }
10
+
11
+ export interface Sandbox {
12
+ /** Analiza el código; si no tiene errores, queda listo para ejecutarse. */
13
+ load(source: string, options?: SandboxOptions): Promise<Loaded>;
14
+ /** Ejecuta hasta terminar, sin detenerse en los puntos de interrupción. */
15
+ run(): Promise<Stopped>;
16
+ /** Ejecuta hasta el siguiente punto de interrupción, o hasta terminar. */
17
+ resume(): Promise<Stopped>;
18
+ step(): Promise<Stopped>;
19
+ /** Pide pausar; la promesa de `run()` o `resume()` se resuelve con el estado en el que quedó. */
20
+ pause(): Promise<void>;
21
+ /** Retrocede (o avanza) hasta el paso `n` volviendo a ejecutar desde el inicio. */
22
+ goToStep(step: number): Promise<Stopped>;
23
+ provideInput(line: string): Promise<void>;
24
+ closeInput(): Promise<void>;
25
+ setBreakpoints(lines: number[]): Promise<void>;
26
+ evaluarReto(source: string, challenge: Challenge): Promise<ChallengeResult>;
27
+ /** Progreso de cada tanda (operaciones, salida nueva, eventos). Regresa cómo dejar de escuchar. */
28
+ onProgress(listener: (progress: Progress) => void): () => void;
29
+ /** Cada vez que la ejecución se detiene. Regresa cómo dejar de escuchar. */
30
+ onStopped(listener: (stopped: Stopped) => void): () => void;
31
+ terminate(): void;
32
+ }
33
+
34
+ type Body<T extends SandboxRequest['type']> = Omit<Extract<SandboxRequest, { type: T }>, 'id'>;
35
+
36
+ /** Envuelve el worker de `codexivo/worker` con promesas y suscripciones. */
37
+ export const createSandbox = (worker: WorkerLike): Sandbox => {
38
+ let nextId = 1;
39
+ const pending = new Map<number, { resolve: (value: never) => void; reject: (error: Error) => void }>();
40
+ const progressListeners = new Set<(progress: Progress) => void>();
41
+ const stoppedListeners = new Set<(stopped: Stopped) => void>();
42
+
43
+ worker.addEventListener('message', ({ data }) => {
44
+ if (data.type === 'progreso') {
45
+ for (const listener of progressListeners) listener(data);
46
+ return;
47
+ }
48
+ if (data.type === 'detenido') for (const listener of stoppedListeners) listener(data);
49
+ const request = pending.get(data.id);
50
+ if (!request) return;
51
+ pending.delete(data.id);
52
+ if (data.type === 'error') request.reject(new Error(data.message));
53
+ else if (data.type === 'hecho') request.resolve(undefined as never);
54
+ else if (data.type === 'reto') request.resolve(data.result as never);
55
+ else request.resolve(data as never);
56
+ });
57
+
58
+ const send = <T extends SandboxRequest['type'], R>(type: T, body: Omit<Body<T>, 'type'> = {} as never) =>
59
+ new Promise<R>((resolve, reject) => {
60
+ const id = nextId++;
61
+ pending.set(id, { resolve: resolve as (value: never) => void, reject });
62
+ worker.postMessage({ type, id, ...body } as SandboxRequest);
63
+ });
64
+
65
+ const subscribe = <T>(listeners: Set<T>, listener: T) => {
66
+ listeners.add(listener);
67
+ return () => listeners.delete(listener);
68
+ };
69
+
70
+ return {
71
+ load: (source, options) => send<'cargar', Loaded>('cargar', { source, ...(options ? { options } : {}) }),
72
+ run: () => send<'ejecutar', Stopped>('ejecutar'),
73
+ resume: () => send<'continuar', Stopped>('continuar'),
74
+ step: () => send<'paso', Stopped>('paso'),
75
+ pause: () => send<'pausar', void>('pausar'),
76
+ goToStep: step => send<'ir_a_paso', Stopped>('ir_a_paso', { step }),
77
+ provideInput: line => send<'entrada', void>('entrada', { line }),
78
+ closeInput: () => send<'cerrar_entrada', void>('cerrar_entrada'),
79
+ setBreakpoints: lines => send<'puntos_de_interrupción', void>('puntos_de_interrupción', { lines }),
80
+ evaluarReto: (source, challenge) => send<'evaluar_reto', ChallengeResult>('evaluar_reto', { source, challenge }),
81
+ onProgress: listener => {
82
+ const unsubscribe = subscribe(progressListeners, listener);
83
+ return () => void unsubscribe();
84
+ },
85
+ onStopped: listener => {
86
+ const unsubscribe = subscribe(stoppedListeners, listener);
87
+ return () => void unsubscribe();
88
+ },
89
+ terminate: () => {
90
+ worker.terminate?.();
91
+ for (const request of pending.values()) request.reject(new Error('El sandbox se cerró.'));
92
+ pending.clear();
93
+ },
94
+ };
95
+ };
package/src/syntax/ast.ts CHANGED
@@ -9,10 +9,12 @@ interface BaseNode {
9
9
  span: Span;
10
10
  }
11
11
 
12
- export type BinaryOperator = '+' | '-' | '*' | '/' | '==' | '!=' | '<' | '>' | '<=' | '>=';
12
+ /** `%` y `mod` son el mismo operador; en el AST siempre aparece como `mod`. */
13
+ export type BinaryOperator = '+' | '-' | '*' | '/' | 'div' | 'mod' | '^' | '==' | '!=' | '<' | '>' | '<=' | '>=';
13
14
  export type LogicalOperator = 'y' | 'o';
14
15
  export type UnaryOperator = '-' | 'no';
15
- export type AssignmentOperator = '=' | '+=' | '-=' | '*=' | '/=';
16
+ export type AssignmentOperator = '=' | '+=' | '-=' | '*=' | '/=' | '%=';
17
+ export type IncrementOperator = '++' | '--';
16
18
 
17
19
  // ── expresiones ──────────────────────────────────────────────────────────────
18
20
 
@@ -76,6 +78,20 @@ export interface Index extends BaseNode {
76
78
  kind: 'Index';
77
79
  object: Expression;
78
80
  index: Expression;
81
+ /** `true` si se escribió `d.campo`; entonces `index` es el texto `"campo"`. */
82
+ dot?: boolean;
83
+ }
84
+
85
+ export interface DictEntry {
86
+ /** Rango de `clave: valor`. */
87
+ span: Span;
88
+ key: Expression;
89
+ value: Expression;
90
+ }
91
+
92
+ export interface DictLiteral extends BaseNode {
93
+ kind: 'DictLiteral';
94
+ entries: DictEntry[];
79
95
  }
80
96
 
81
97
  export interface FunctionLiteral extends BaseNode {
@@ -92,6 +108,7 @@ export type Expression =
92
108
  | BooleanLiteral
93
109
  | NullLiteral
94
110
  | ListLiteral
111
+ | DictLiteral
95
112
  | Identifier
96
113
  | Unary
97
114
  | Binary
@@ -104,17 +121,36 @@ export type Expression =
104
121
 
105
122
  export interface VariableDeclaration extends BaseNode {
106
123
  kind: 'VariableDeclaration';
124
+ /** `true` si se declaró con `constante`: no se puede reasignar. */
125
+ constant: boolean;
107
126
  name: Identifier;
108
127
  value: Expression;
109
128
  }
110
129
 
130
+ /** Lo que puede ir a la izquierda de `=`: una variable o un elemento (`lista[i]`). */
131
+ export type AssignmentTarget = Identifier | Index;
132
+
111
133
  export interface Assignment extends BaseNode {
112
134
  kind: 'Assignment';
113
- target: Identifier;
135
+ target: AssignmentTarget;
114
136
  operator: AssignmentOperator;
115
137
  value: Expression;
116
138
  }
117
139
 
140
+ /** `i++` / `i--`: son sentencias, no expresiones. */
141
+ export interface Increment extends BaseNode {
142
+ kind: 'Increment';
143
+ target: AssignmentTarget;
144
+ operator: IncrementOperator;
145
+ }
146
+
147
+ /** `procedimiento nombre(…) { … }`: se puede usar desde el inicio de su bloque. */
148
+ export interface FunctionDeclaration extends BaseNode {
149
+ kind: 'FunctionDeclaration';
150
+ name: Identifier;
151
+ function: FunctionLiteral;
152
+ }
153
+
118
154
  export interface ExpressionStatement extends BaseNode {
119
155
  kind: 'ExpressionStatement';
120
156
  expression: Expression;
@@ -159,14 +195,78 @@ export interface DoUntil extends BaseNode {
159
195
 
160
196
  export interface For extends BaseNode {
161
197
  kind: 'For';
162
- init?: VariableDeclaration | Assignment | ExpressionStatement;
198
+ init?: VariableDeclaration | Assignment | Increment | ExpressionStatement;
163
199
  condition?: Expression;
164
- update?: Assignment | ExpressionStatement;
200
+ update?: Assignment | Increment | ExpressionStatement;
201
+ body: Block;
202
+ }
203
+
204
+ /**
205
+ * `para i desde a hasta b con_paso p { … }`: cuenta de `a` a `b`, incluidos los dos. Los límites y el paso se
206
+ * evalúan una vez, al empezar; `i` es nueva en cada vuelta y no se puede cambiar dentro del cuerpo.
207
+ */
208
+ export interface ForRange extends BaseNode {
209
+ kind: 'ForRange';
210
+ variable: Identifier;
211
+ from: Expression;
212
+ to: Expression;
213
+ step?: Expression;
165
214
  body: Block;
166
215
  }
167
216
 
217
+ /** `repetir n veces { … }`: el contador es interno, no una variable visible. */
218
+ export interface Repeat extends BaseNode {
219
+ kind: 'Repeat';
220
+ count: Expression;
221
+ body: Block;
222
+ }
223
+
224
+ /** `para_cada x en lista { … }`: recorre una lista o un texto. */
225
+ export interface ForEach extends BaseNode {
226
+ kind: 'ForEach';
227
+ variable: Identifier;
228
+ /** Segunda variable de `para_cada clave, valor en diccionario`. */
229
+ value?: Identifier;
230
+ iterable: Expression;
231
+ body: Block;
232
+ }
233
+
234
+ /**
235
+ * `dominio nombre { … }`: un espacio de nombres. Agrupa declaraciones (variables, constantes, procedimientos y
236
+ * otros dominios) que se usan desde fuera con `nombre.miembro`. No es un módulo: no hay `importar`.
237
+ */
238
+ export interface Domain extends BaseNode {
239
+ kind: 'Domain';
240
+ name: Identifier;
241
+ body: Block;
242
+ }
243
+
244
+ export interface Break extends BaseNode {
245
+ kind: 'Break';
246
+ }
247
+
248
+ export interface Continue extends BaseNode {
249
+ kind: 'Continue';
250
+ }
251
+
168
252
  export type Statement =
169
- VariableDeclaration | Assignment | ExpressionStatement | Return | Block | If | While | DoUntil | For;
253
+ | VariableDeclaration
254
+ | FunctionDeclaration
255
+ | Assignment
256
+ | Increment
257
+ | ExpressionStatement
258
+ | Return
259
+ | Block
260
+ | If
261
+ | While
262
+ | DoUntil
263
+ | For
264
+ | ForRange
265
+ | ForEach
266
+ | Repeat
267
+ | Break
268
+ | Continue
269
+ | Domain;
170
270
 
171
271
  export interface Program extends BaseNode {
172
272
  kind: 'Program';
@@ -183,6 +283,8 @@ export const childrenOf = (node: Node): Node[] => {
183
283
  return node.statements;
184
284
  case 'ListLiteral':
185
285
  return node.elements;
286
+ case 'DictLiteral':
287
+ return node.entries.flatMap(entry => [entry.key, entry.value]);
186
288
  case 'Unary':
187
289
  return [node.operand];
188
290
  case 'Binary':
@@ -198,6 +300,12 @@ export const childrenOf = (node: Node): Node[] => {
198
300
  return [node.name, node.value];
199
301
  case 'Assignment':
200
302
  return [node.target, node.value];
303
+ case 'Increment':
304
+ return [node.target];
305
+ case 'FunctionDeclaration':
306
+ return [node.name, node.function];
307
+ case 'ForEach':
308
+ return [node.variable, ...(node.value ? [node.value] : []), node.iterable, node.body];
201
309
  case 'ExpressionStatement':
202
310
  return [node.expression];
203
311
  case 'Return':
@@ -211,6 +319,12 @@ export const childrenOf = (node: Node): Node[] => {
211
319
  return [node.condition, node.body];
212
320
  case 'DoUntil':
213
321
  return [node.body, node.condition];
322
+ case 'Domain':
323
+ return [node.name, node.body];
324
+ case 'ForRange':
325
+ return [node.variable, node.from, node.to, ...(node.step ? [node.step] : []), node.body];
326
+ case 'Repeat':
327
+ return [node.count, node.body];
214
328
  case 'For':
215
329
  return [
216
330
  ...(node.init ? [node.init] : []),
@@ -20,6 +20,9 @@ const TWO_CHAR_OPERATORS: Readonly<Record<string, TokenType>> = {
20
20
  '-=': TokenType.MINUS_ASSIGN,
21
21
  '*=': TokenType.STAR_ASSIGN,
22
22
  '/=': TokenType.SLASH_ASSIGN,
23
+ '%=': TokenType.PERCENT_ASSIGN,
24
+ '++': TokenType.INCREMENT,
25
+ '--': TokenType.DECREMENT,
23
26
  };
24
27
 
25
28
  const ONE_CHAR_OPERATORS: Readonly<Record<string, TokenType>> = {
@@ -28,10 +31,14 @@ const ONE_CHAR_OPERATORS: Readonly<Record<string, TokenType>> = {
28
31
  '-': TokenType.MINUS,
29
32
  '*': TokenType.STAR,
30
33
  '/': TokenType.SLASH,
34
+ '%': TokenType.PERCENT,
35
+ '^': TokenType.CARET,
31
36
  '!': TokenType.BANG,
32
37
  '<': TokenType.LT,
33
38
  '>': TokenType.GT,
34
39
  ',': TokenType.COMMA,
40
+ ':': TokenType.COLON,
41
+ '.': TokenType.DOT,
35
42
  ';': TokenType.SEMICOLON,
36
43
  '(': TokenType.LPAREN,
37
44
  ')': TokenType.RPAREN,
@@ -41,6 +48,15 @@ const ONE_CHAR_OPERATORS: Readonly<Record<string, TokenType>> = {
41
48
  ']': TokenType.RBRACKET,
42
49
  };
43
50
 
51
+ const VALUE_ENDS = new Set([
52
+ TokenType.IDENT,
53
+ TokenType.NUMBER,
54
+ TokenType.STRING,
55
+ TokenType.RPAREN,
56
+ TokenType.RBRACKET,
57
+ TokenType.RBRACE,
58
+ ]);
59
+
44
60
  const ESCAPES: Readonly<Record<string, string>> = {
45
61
  n: '\n',
46
62
  t: '\t',
@@ -129,13 +145,20 @@ export const tokenize = (source: string): LexResult => {
129
145
  }
130
146
 
131
147
  // números
132
- if (DIGIT.test(character) || (character === '.' && DIGIT.test(peek(1)))) {
133
- while (DIGIT.test(peek())) advance();
148
+ // Después de un valor, `.` es acceso a un campo (`d.1` es un error claro, no `d` seguido de `.1`).
149
+ const last = tokens[tokens.length - 1];
150
+ const afterValue = last !== undefined && VALUE_ENDS.has(last.type) && last.span.end.offset === offset;
151
+ if (DIGIT.test(character) || (character === '.' && DIGIT.test(peek(1)) && !afterValue)) {
152
+ // `_` separa grupos de dígitos (`1_000_000`); se revisa que esté bien puesto al final.
153
+ const digits = () => {
154
+ while (DIGIT.test(peek()) || peek() === '_') advance();
155
+ };
156
+ digits();
134
157
  if (peek() === '.' && DIGIT.test(peek(1))) {
135
158
  advance();
136
- while (DIGIT.test(peek())) advance();
159
+ digits();
137
160
  }
138
- if (LETTER.test(peek())) {
161
+ if (LETTER.test(peek()) && peek() !== '_') {
139
162
  while (LETTER.test(peek()) || DIGIT.test(peek())) advance();
140
163
  const token = push(TokenType.ILLEGAL, start);
141
164
  lexicalError(
@@ -146,7 +169,18 @@ export const tokenize = (source: string): LexResult => {
146
169
  );
147
170
  continue;
148
171
  }
149
- push(TokenType.NUMBER, start, Number(source.slice(start.offset, offset)));
172
+ const text = source.slice(start.offset, offset);
173
+ if (/(^|\D)_|_(\D|$)/.test(text)) {
174
+ const token = push(TokenType.ILLEGAL, start);
175
+ lexicalError(
176
+ 'SEPARADOR_INVÁLIDO',
177
+ `\`${text}\` no es un número válido.`,
178
+ token.span,
179
+ 'El `_` solo puede ir entre dos dígitos para separar grupos, como en `1_000_000`.',
180
+ );
181
+ continue;
182
+ }
183
+ push(TokenType.NUMBER, start, Number(text.replaceAll('_', '')));
150
184
  continue;
151
185
  }
152
186