codexivo 0.2.0 → 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.
@@ -2,10 +2,33 @@ import { CodexivoError } from '../errors';
2
2
  import type { ErrorInfo } from '../errors';
3
3
  import type * as ast from '../syntax/ast';
4
4
  import type { Span } from '../syntax/token';
5
- import { natives as defaultNatives } from './builtins';
5
+ import type { RobotEvent, World, WorldState } from '../world';
6
+ import { constants as libraryConstants, internals, natives as defaultNatives } from './builtins';
7
+ import { COSTS } from './costs';
8
+ import { libraryProcedures } from './prelude';
6
9
  import type { Chunk, DecisionLabel, Instruction } from './compiler';
7
- import { describeType, equals, isCallable, isList, repr, Scope, typeOf, WAIT_FOR_INPUT } from './values';
8
- import type { ListValue, NativeContext, NativeValue, ProcedureValue, Value } from './values';
10
+ import {
11
+ describeType,
12
+ equals,
13
+ isCallable,
14
+ isDict,
15
+ isDomain,
16
+ isList,
17
+ repr,
18
+ Scope,
19
+ typeOf,
20
+ WAIT_FOR_INPUT,
21
+ } from './values';
22
+ import type {
23
+ DictKey,
24
+ DictValue,
25
+ DomainValue,
26
+ ListValue,
27
+ NativeContext,
28
+ NativeValue,
29
+ ProcedureValue,
30
+ Value,
31
+ } from './values';
9
32
  import { suggestName } from './suggest';
10
33
 
11
34
  export type MachineStatus = 'lista' | 'pausada' | 'esperando_entrada' | 'terminada' | 'error';
@@ -21,14 +44,21 @@ export interface MachineOptions {
21
44
  input?: string | string[];
22
45
  /** Si es `true`, `leer()` sin entrada pausa la máquina en lugar de regresar `nulo`. */
23
46
  interactiveInput?: boolean;
24
- /** Procedimientos nativos adicionales (por ejemplo, los del modo mundo). */
47
+ /** Procedimientos nativos adicionales. */
25
48
  natives?: Record<string, NativeValue>;
49
+ /** Mundo del robot (de `createWorld`): agrega sus procedimientos y su estado a `snapshot()`. */
50
+ world?: World;
26
51
  /** Líneas donde `run()` se detiene antes de ejecutar la sentencia. */
27
52
  breakpoints?: number[];
28
53
  /** Alcance global compartido entre ejecuciones (lo usa el REPL). */
29
54
  globals?: Scope;
30
55
  /** Permite volver a declarar una variable existente (útil en el REPL). */
31
56
  allowRedeclare?: boolean;
57
+ /**
58
+ * Semilla de `aleatorio`. Con la misma semilla (y la misma entrada) la ejecución se repite idéntica. Si no
59
+ * se da, la máquina elige una y la expone en `machine.seed`.
60
+ */
61
+ seed?: number;
32
62
  /** Recibe cada evento de la ejecución. Activarlo hace la ejecución más lenta. */
33
63
  onEvent?: (event: MachineEvent) => void;
34
64
  }
@@ -41,8 +71,17 @@ export type SerializedValue =
41
71
  | { type: 'lógico'; value: boolean; repr: string }
42
72
  | { type: 'nulo'; value: null; repr: string }
43
73
  | { type: 'lista'; ref: number; length: number; repr: string }
74
+ | { type: 'diccionario'; ref: number; size: number; repr: string }
75
+ | { type: 'dominio'; ref: number; name: string; repr: string }
44
76
  | { type: 'procedimiento'; name?: string; repr: string };
45
77
 
78
+ /** Contenido de una lista o de un diccionario en `Snapshot.heap`. */
79
+ export type HeapEntry =
80
+ | SerializedValue[]
81
+ | { entries: Array<{ key: SerializedValue; value: SerializedValue }> }
82
+ /** Un dominio: sus miembros en orden de declaración, y cuáles se pueden cambiar desde fuera. */
83
+ | { domain: string; members: Array<{ name: string; value: SerializedValue; writable: boolean }> };
84
+
46
85
  export const serialize = (value: Value): SerializedValue => {
47
86
  const text = repr(value);
48
87
  if (value === null) return { type: 'nulo', value: null, repr: text };
@@ -55,6 +94,8 @@ export const serialize = (value: Value): SerializedValue => {
55
94
  return { type: 'lógico', value, repr: text };
56
95
  }
57
96
  if (value.kind === 'lista') return { type: 'lista', ref: value.id, length: value.items.length, repr: text };
97
+ if (value.kind === 'diccionario') return { type: 'diccionario', ref: value.id, size: value.entries.size, repr: text };
98
+ if (value.kind === 'dominio') return { type: 'dominio', ref: value.id, name: value.name, repr: text };
58
99
  return { type: 'procedimiento', name: value.name, repr: text };
59
100
  };
60
101
 
@@ -68,8 +109,18 @@ interface EventBase {
68
109
  export type MachineEvent = EventBase &
69
110
  (
70
111
  | { type: 'paso'; nodeId: number; nodeKind: ast.Statement['kind']; span: Span }
71
- | { type: 'declara'; name: string; value: SerializedValue; nodeId: number; span: Span }
112
+ | { type: 'declara'; name: string; constant: boolean; value: SerializedValue; nodeId: number; span: Span }
72
113
  | { type: 'asigna'; name: string; before: SerializedValue; after: SerializedValue; nodeId: number; span: Span }
114
+ /** Cambio de un elemento de una lista (`lista[i] = …`); `ref` identifica la lista. */
115
+ | {
116
+ type: 'modifica';
117
+ ref: number;
118
+ key: SerializedValue;
119
+ before: SerializedValue;
120
+ after: SerializedValue;
121
+ nodeId: number;
122
+ span: Span;
123
+ }
73
124
  | { type: 'decide'; label: DecisionLabel; value: boolean; nodeId: number; ownerId: number; span: Span }
74
125
  | {
75
126
  type: 'opera';
@@ -81,6 +132,7 @@ export type MachineEvent = EventBase &
81
132
  }
82
133
  | { type: 'llama'; name: string; args: SerializedValue[]; nodeId: number; span: Span }
83
134
  | { type: 'regresa'; name: string; value: SerializedValue }
135
+ | (RobotEvent & { nodeId: number; span: Span })
84
136
  | { type: 'imprime'; text: string }
85
137
  | { type: 'lee'; text: string | null }
86
138
  | { type: 'error'; error: ErrorInfo }
@@ -90,6 +142,8 @@ export type MachineEvent = EventBase &
90
142
  export interface FrameSnapshot {
91
143
  name: string;
92
144
  callSpan?: Span;
145
+ /** `true` en un procedimiento de la biblioteca escrito en Codexivo; sus variables no se muestran. */
146
+ library?: boolean;
93
147
  /** Alcances del procedimiento, del más externo al más interno. */
94
148
  scopes: Array<Record<string, SerializedValue>>;
95
149
  }
@@ -97,14 +151,18 @@ export interface FrameSnapshot {
97
151
  export interface Snapshot {
98
152
  status: MachineStatus;
99
153
  operations: number;
154
+ /** Puntos de pausa por los que ya pasó la ejecución (ver `Machine.steps`). */
155
+ steps: number;
100
156
  /** Nodo donde está detenida la máquina. */
101
157
  nodeId?: number;
102
158
  span?: Span;
103
159
  /** Llamadas activas, del programa principal a la más reciente. */
104
160
  frames: FrameSnapshot[];
105
161
  /** Contenido de las listas que aparecen en `frames`, por `ref`. */
106
- heap: Record<number, SerializedValue[]>;
162
+ heap: Record<number, HeapEntry>;
107
163
  output: string;
164
+ /** Estado del mundo, si la máquina corre en modo mundo. */
165
+ world?: WorldState;
108
166
  }
109
167
 
110
168
  // ── máquina ──────────────────────────────────────────────────────────────────
@@ -127,7 +185,20 @@ const DEFAULTS = {
127
185
  maxOutput: 1_000_000,
128
186
  };
129
187
 
130
- const NO_COUNT = new Set<Instruction['op']>(['step', 'enterScope', 'exitScope', 'jump', 'halt']);
188
+ const VISIBLE_FROM_LIBRARY = new Set<MachineEvent['type']>(['llama', 'regresa', 'imprime', 'lee', 'error', 'fin']);
189
+
190
+ const NO_COUNT = new Set<Instruction['op']>([
191
+ 'step',
192
+ 'enterScope',
193
+ 'exitScope',
194
+ 'jump',
195
+ 'dup',
196
+ 'pop',
197
+ 'repeatStart',
198
+ 'forEachStart',
199
+ 'rangeStart',
200
+ 'halt',
201
+ ]);
131
202
 
132
203
  /**
133
204
  * Ejecuta un programa compilado. Tiene su propia pila de llamadas, así que la recursión no depende
@@ -138,7 +209,7 @@ export class Machine {
138
209
  private readonly stack: Value[] = [];
139
210
  private readonly natives: Record<string, NativeValue>;
140
211
  private readonly inputQueue: string[];
141
- private readonly breakpoints: Set<number>;
212
+ private breakpoints: Set<number>;
142
213
  private readonly limits: typeof DEFAULTS;
143
214
  private readonly onEvent?: (event: MachineEvent) => void;
144
215
  private readonly outputParts: string[] = [];
@@ -148,8 +219,19 @@ export class Machine {
148
219
  private inputClosed = false;
149
220
  private currentNode?: ast.Node;
150
221
 
222
+ /** Semilla de `aleatorio` de esta ejecución (la de las opciones, o una elegida al crear la máquina). */
223
+ readonly seed: number;
224
+ private randomState: number;
225
+
151
226
  status: MachineStatus = 'lista';
152
227
  operations = 0;
228
+ /**
229
+ * Puntos de pausa por los que ya pasó la ejecución: cada `step()` avanza exactamente uno. Como la máquina es
230
+ * determinista, «volver al paso n» es ejecutar otra vez desde el inicio hasta que `steps` llegue a n.
231
+ */
232
+ steps = 0;
233
+ /** Por qué se detuvo la última vez que quedó `pausada`. */
234
+ pauseReason?: 'paso' | 'presupuesto' | 'punto_de_interrupción';
153
235
  result: Value = null;
154
236
  error?: CodexivoError;
155
237
 
@@ -157,7 +239,7 @@ export class Machine {
157
239
  program: Chunk,
158
240
  private readonly options: MachineOptions = {},
159
241
  ) {
160
- this.natives = { ...defaultNatives, ...options.natives };
242
+ this.natives = { ...defaultNatives, ...options.world?.natives, ...options.natives };
161
243
  this.limits = {
162
244
  maxOperations: options.maxOperations ?? DEFAULTS.maxOperations,
163
245
  maxCallDepth: options.maxCallDepth ?? DEFAULTS.maxCallDepth,
@@ -166,6 +248,9 @@ export class Machine {
166
248
  this.inputQueue = typeof options.input === 'string' ? splitLines(options.input) : [...(options.input ?? [])];
167
249
  this.breakpoints = new Set(options.breakpoints ?? []);
168
250
  this.onEvent = options.onEvent;
251
+ // Elegir la semilla es lo único que no es determinista; desde ahí, todo se puede repetir.
252
+ this.seed = (options.seed ?? Math.floor(Math.random() * 2 ** 32)) >>> 0;
253
+ this.randomState = this.seed;
169
254
  const globals = options.globals ?? new Scope();
170
255
  this.frames.push({ chunk: program, pc: 0, scope: globals, root: globals, stackBase: 0, name: 'programa' });
171
256
  }
@@ -189,6 +274,11 @@ export class Machine {
189
274
  return this.execute('step', Infinity);
190
275
  }
191
276
 
277
+ /** Cambia las líneas donde `run()` se detiene. */
278
+ setBreakpoints(lines: number[]): void {
279
+ this.breakpoints = new Set(lines);
280
+ }
281
+
192
282
  /** Entrega una línea a un `leer()` que está esperando. */
193
283
  provideInput(line: string): void {
194
284
  this.inputQueue.push(...splitLines(line));
@@ -202,11 +292,28 @@ export class Machine {
202
292
  }
203
293
 
204
294
  snapshot(): Snapshot {
205
- const heap: Record<number, SerializedValue[]> = {};
295
+ const heap: Record<number, HeapEntry> = {};
206
296
  const collect = (value: Value) => {
207
- if (!isList(value) || heap[value.id]) return;
208
- heap[value.id] = value.items.map(serialize);
209
- value.items.forEach(collect);
297
+ if ((!isList(value) && !isDict(value) && !isDomain(value)) || heap[value.id]) return;
298
+ if (isDomain(value)) {
299
+ heap[value.id] = {
300
+ domain: value.name,
301
+ members: [...value.scope.variables].map(([name, member]) => ({
302
+ name,
303
+ value: serialize(member),
304
+ writable: value.writable.has(name),
305
+ })),
306
+ };
307
+ value.scope.variables.forEach(collect);
308
+ } else if (isList(value)) {
309
+ heap[value.id] = value.items.map(serialize);
310
+ value.items.forEach(collect);
311
+ } else {
312
+ heap[value.id] = {
313
+ entries: [...value.entries].map(([key, item]) => ({ key: serialize(key), value: serialize(item) })),
314
+ };
315
+ value.entries.forEach(collect);
316
+ }
210
317
  };
211
318
  const frames = this.frames.map(frame => {
212
319
  const scopes: Array<Record<string, SerializedValue>> = [];
@@ -221,16 +328,19 @@ export class Machine {
221
328
  if (scope === frame.root) break;
222
329
  scope = scope.parent;
223
330
  }
331
+ if (frame.chunk.hidden) return { name: frame.name, callSpan: frame.callNode?.span, library: true, scopes: [] };
224
332
  return { name: frame.name, callSpan: frame.callNode?.span, scopes };
225
333
  });
226
334
  return {
227
335
  status: this.status,
228
336
  operations: this.operations,
337
+ steps: this.steps,
229
338
  nodeId: this.currentNode?.id,
230
339
  span: this.currentNode?.span,
231
340
  frames,
232
341
  heap,
233
342
  output: this.output,
343
+ ...(this.options.world ? { world: this.options.world.state() } : {}),
234
344
  };
235
345
  }
236
346
 
@@ -246,7 +356,7 @@ export class Machine {
246
356
  const frame = this.frames[this.frames.length - 1]!;
247
357
  const instruction = frame.chunk.code[frame.pc]!;
248
358
 
249
- if (!NO_COUNT.has(instruction.op)) {
359
+ if (!NO_COUNT.has(instruction.op) && !frame.chunk.hidden) {
250
360
  if (this.operations >= this.limits.maxOperations) {
251
361
  throw this.limitError(
252
362
  'DEMASIADAS_OPERACIONES',
@@ -256,6 +366,7 @@ export class Machine {
256
366
  }
257
367
  if (this.operations >= stopAt) {
258
368
  this.status = 'pausada';
369
+ this.pauseReason = 'presupuesto';
259
370
  return this.status;
260
371
  }
261
372
  }
@@ -264,10 +375,12 @@ export class Machine {
264
375
  if (
265
376
  instruction.op === 'step' &&
266
377
  mode === 'run' &&
378
+ !frame.chunk.hidden &&
267
379
  executed > 0 &&
268
380
  this.breakpoints.has(instruction.node.span.start.line)
269
381
  ) {
270
382
  this.status = 'pausada';
383
+ this.pauseReason = 'punto_de_interrupción';
271
384
  return this.status;
272
385
  }
273
386
 
@@ -276,9 +389,14 @@ export class Machine {
276
389
  // `exec` puede cambiar el estado.
277
390
  const status = this.status as MachineStatus;
278
391
  if (status === 'esperando_entrada' || status === 'terminada') return status;
279
- if (mode === 'step' && pause) {
280
- this.status = 'pausada';
281
- return this.status;
392
+ // El código de la biblioteca nunca es un punto de pausa; sí lo es el de `f` que llama.
393
+ if (pause && !frame.chunk.hidden) {
394
+ this.steps += 1;
395
+ if (mode === 'step') {
396
+ this.status = 'pausada';
397
+ this.pauseReason = 'paso';
398
+ return this.status;
399
+ }
282
400
  }
283
401
  }
284
402
  } catch (error) {
@@ -293,10 +411,11 @@ export class Machine {
293
411
  /** Ejecuta una instrucción. Regresa `true` si es un punto de pausa para `step()`. */
294
412
  private exec(frame: Frame, instruction: Instruction): boolean {
295
413
  frame.pc += 1;
296
- if (!NO_COUNT.has(instruction.op)) this.operations += 1;
414
+ if (!NO_COUNT.has(instruction.op) && !frame.chunk.hidden) this.operations += 1;
297
415
 
298
416
  switch (instruction.op) {
299
417
  case 'step':
418
+ if (frame.chunk.hidden) return false;
300
419
  this.currentNode = instruction.node;
301
420
  this.emit({
302
421
  type: 'paso',
@@ -316,14 +435,28 @@ export class Machine {
316
435
  return false;
317
436
  }
318
437
 
438
+ case 'dict': {
439
+ const values = this.stack.splice(this.stack.length - instruction.count * 2, instruction.count * 2);
440
+ const entries = new Map<DictKey, Value>();
441
+ instruction.node.entries.forEach((entry, index) => {
442
+ entries.set(this.dictKey(values[index * 2]!, entry.key), values[index * 2 + 1]!);
443
+ });
444
+ this.stack.push(this.newDict(entries));
445
+ return false;
446
+ }
447
+
319
448
  case 'load': {
320
449
  const scope = frame.scope.lookup(instruction.name);
321
450
  if (scope) {
322
451
  this.stack.push(scope.variables.get(instruction.name)!);
323
452
  return false;
324
453
  }
325
- const native = this.natives[instruction.name];
326
- if (native && !instruction.forUpdate) {
454
+ const native =
455
+ (frame.chunk.hidden ? internals[instruction.name] : undefined) ??
456
+ this.natives[instruction.name] ??
457
+ libraryProcedures[instruction.name] ??
458
+ libraryConstants[instruction.name];
459
+ if (native !== undefined && !instruction.forUpdate) {
327
460
  this.stack.push(native);
328
461
  return false;
329
462
  }
@@ -337,14 +470,19 @@ export class Machine {
337
470
  throw this.runtimeError(
338
471
  'VARIABLE_REPETIDA',
339
472
  `La variable \`${instruction.name}\` ya existe en este bloque.`,
340
- instruction.node.name,
473
+ instruction.target,
341
474
  `Para cambiar su valor escribe \`${instruction.name} = …\`, sin \`variable\`.`,
342
475
  );
343
476
  }
344
477
  frame.scope.variables.set(instruction.name, value);
478
+ if (instruction.constant) frame.scope.constants.add(instruction.name);
479
+ else frame.scope.constants.delete(instruction.name);
480
+ if (instruction.loop) frame.scope.loopVariables.add(instruction.name);
481
+ else frame.scope.loopVariables.delete(instruction.name);
345
482
  this.emit({
346
483
  type: 'declara',
347
484
  name: instruction.name,
485
+ constant: instruction.constant,
348
486
  value: serialize(value),
349
487
  nodeId: instruction.node.id,
350
488
  span: instruction.node.span,
@@ -355,7 +493,26 @@ export class Machine {
355
493
  case 'store': {
356
494
  const value = this.stack.pop()!;
357
495
  const scope = frame.scope.lookup(instruction.name);
358
- if (!scope) throw this.undeclaredAssignment(instruction.name, instruction.node.target);
496
+ if (!scope) throw this.undeclaredAssignment(instruction.name, instruction.target);
497
+ if (scope.loopVariables.has(instruction.name)) {
498
+ throw this.runtimeError(
499
+ 'VARIABLE_DE_PARA',
500
+ `La variable de un \`para\` avanza sola; \`${instruction.name}\` no se puede cambiar dentro del bucle.`,
501
+ instruction.node,
502
+ `Si necesitas otro valor, guárdalo en una variable nueva: \`variable otro = ${instruction.name} + 1\`.`,
503
+ );
504
+ }
505
+ if (scope.constants.has(instruction.name)) {
506
+ if (isDomain(scope.variables.get(instruction.name)!)) {
507
+ throw this.runtimeError(
508
+ 'CONSTANTE_REASIGNADA',
509
+ `\`${instruction.name}\` es un dominio; su nombre no se puede usar para otra cosa.`,
510
+ instruction.node,
511
+ `Para cambiar un miembro escribe \`${instruction.name}.miembro = …\`; para otro valor, usa otro nombre.`,
512
+ );
513
+ }
514
+ throw this.constantReassignment(instruction.name, instruction.node);
515
+ }
359
516
  const before = scope.variables.get(instruction.name)!;
360
517
  scope.variables.set(instruction.name, value);
361
518
  if (this.onEvent) {
@@ -371,6 +528,22 @@ export class Machine {
371
528
  return false;
372
529
  }
373
530
 
531
+ case 'setIndex': {
532
+ const value = this.stack.pop()!;
533
+ const index = this.stack.pop()!;
534
+ const object = this.stack.pop()!;
535
+ this.setIndex(object, index, value, instruction.target, instruction.node);
536
+ return false;
537
+ }
538
+
539
+ case 'dup':
540
+ this.stack.push(...this.stack.slice(this.stack.length - instruction.count));
541
+ return false;
542
+
543
+ case 'pop':
544
+ this.stack.length -= instruction.count;
545
+ return false;
546
+
374
547
  case 'result': {
375
548
  const value = this.stack.pop()!;
376
549
  if (this.frames.length === 1) this.lastValue = value;
@@ -422,7 +595,10 @@ export class Machine {
422
595
  this.finish(value);
423
596
  return true;
424
597
  }
425
- this.emit({ type: 'regresa', name: frame.name, value: serialize(value) });
598
+ const caller = this.frames[this.frames.length - 2]!;
599
+ if (!(frame.chunk.hidden && caller.chunk.hidden)) {
600
+ this.emit({ type: 'regresa', name: frame.name, value: serialize(value) });
601
+ }
426
602
  this.frames.pop();
427
603
  this.stack.length = frame.stackBase - 1;
428
604
  this.stack.push(value);
@@ -437,7 +613,7 @@ export class Machine {
437
613
  const condition = this.stack.pop()!;
438
614
  if (typeof condition !== 'boolean')
439
615
  throw this.nonBooleanCondition(instruction.label, condition, instruction.node);
440
- this.currentNode = instruction.node;
616
+ if (!frame.chunk.hidden) this.currentNode = instruction.node;
441
617
  this.emit({
442
618
  type: 'decide',
443
619
  label: instruction.label,
@@ -450,6 +626,186 @@ export class Machine {
450
626
  return true;
451
627
  }
452
628
 
629
+ case 'repeatStart': {
630
+ const count = this.stack[this.stack.length - 1]!;
631
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) {
632
+ throw this.invalidRepeatCount(count, instruction.node);
633
+ }
634
+ return false;
635
+ }
636
+
637
+ case 'repeatNext': {
638
+ const remaining = this.stack[this.stack.length - 1] as number;
639
+ const again = remaining > 0;
640
+ if (again) this.stack[this.stack.length - 1] = remaining - 1;
641
+ else this.stack.pop();
642
+ if (!frame.chunk.hidden) this.currentNode = instruction.node;
643
+ this.emit({
644
+ type: 'decide',
645
+ label: 'repetir',
646
+ value: again,
647
+ nodeId: instruction.node.id,
648
+ ownerId: instruction.owner.id,
649
+ span: instruction.node.span,
650
+ });
651
+ if (!again) frame.pc = instruction.target;
652
+ return true;
653
+ }
654
+
655
+ case 'rangeStart': {
656
+ const step = this.stack.pop()!;
657
+ const to = this.stack.pop()!;
658
+ const from = this.stack.pop()!;
659
+ const { owner } = instruction;
660
+ const checkNumber = (value: Value, node: ast.Node, what: string): number => {
661
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
662
+ throw this.runtimeError(
663
+ 'PARA_LÍMITE_INVÁLIDO',
664
+ `${what} de \`para\` tiene que ser un número, pero es ${repr(value)}.`,
665
+ node,
666
+ typeof value === 'string' ? 'Convierte el texto con `número(…)`.' : undefined,
667
+ );
668
+ };
669
+ const start = checkNumber(from, owner.from, 'El inicio');
670
+ const end = checkNumber(to, owner.to, 'El final');
671
+ const increment = checkNumber(step, owner.step ?? owner, 'El paso');
672
+ if (increment === 0) {
673
+ throw this.runtimeError(
674
+ 'PARA_PASO_CERO',
675
+ 'El paso de un `para` no puede ser 0: nunca llegaría al final.',
676
+ owner.step ?? owner,
677
+ 'Usa un paso positivo para contar hacia arriba o uno negativo para contar hacia abajo.',
678
+ );
679
+ }
680
+ if ((end - start) * increment < 0) {
681
+ // Sin magia: no se invierte solo ni hace cero vueltas en silencio.
682
+ throw this.runtimeError(
683
+ 'PARA_SENTIDO_CONTRARIO',
684
+ increment > 0
685
+ ? `El \`para\` empieza en ${repr(start)}, que es mayor que ${repr(end)}, y cuenta hacia arriba.`
686
+ : `El \`para\` empieza en ${repr(start)}, que es menor que ${repr(end)}, y cuenta hacia abajo.`,
687
+ instruction.explicitStep ? owner.step! : owner.to,
688
+ instruction.explicitStep
689
+ ? `Para ir de ${repr(start)} a ${repr(end)} el paso tiene que ser ${increment > 0 ? 'negativo' : 'positivo'}.`
690
+ : 'Para contar hacia atrás escribe `con_paso -1`.',
691
+ );
692
+ }
693
+ // Se tolera un error de redondeo pequeño para que `desde 0 hasta 1 con_paso 0.1` incluya el 1.
694
+ const count = Math.floor((end - start) / increment + 1e-9) + 1;
695
+ this.stack.push(start, increment, count, 0);
696
+ return false;
697
+ }
698
+
699
+ case 'rangeNext': {
700
+ const turn = this.stack[this.stack.length - 1] as number;
701
+ const count = this.stack[this.stack.length - 2] as number;
702
+ const again = turn < count;
703
+ if (again) {
704
+ const start = this.stack[this.stack.length - 4] as number;
705
+ const increment = this.stack[this.stack.length - 3] as number;
706
+ this.stack[this.stack.length - 1] = turn + 1;
707
+ // Se calcula desde el inicio (no sumando) y con 15 cifras, así `0.1 * 3` se ve como 0.3.
708
+ this.stack.push(Number((start + turn * increment).toPrecision(15)));
709
+ } else {
710
+ this.stack.length -= 4;
711
+ }
712
+ if (!frame.chunk.hidden) this.currentNode = instruction.node;
713
+ this.emit({
714
+ type: 'decide',
715
+ label: 'para',
716
+ value: again,
717
+ nodeId: instruction.node.id,
718
+ ownerId: instruction.owner.id,
719
+ span: instruction.node.span,
720
+ });
721
+ if (!again) frame.pc = instruction.target;
722
+ return true;
723
+ }
724
+
725
+ case 'forEachStart': {
726
+ const iterable = this.stack[this.stack.length - 1]!;
727
+ const { owner } = instruction;
728
+ if (isDict(iterable)) {
729
+ // Las claves se copian al empezar; así se nota si el diccionario cambia durante el recorrido.
730
+ const keys: ListValue = { kind: 'lista', id: 0, items: [...iterable.entries.keys()] };
731
+ this.stack.push(keys, 0);
732
+ return false;
733
+ }
734
+ if (owner.value) {
735
+ throw this.runtimeError(
736
+ 'PARA_CADA_INVÁLIDO',
737
+ `\`para_cada ${owner.variable.name}, ${owner.value.name}\` recorre un diccionario, no ${describeType(typeOf(iterable))}.`,
738
+ instruction.node,
739
+ isList(iterable) || typeof iterable === 'string'
740
+ ? `Para una lista o un texto usa una sola variable: \`para_cada ${owner.value.name} en …\`.`
741
+ : undefined,
742
+ );
743
+ }
744
+ if (!isList(iterable) && typeof iterable !== 'string') {
745
+ throw this.runtimeError(
746
+ 'PARA_CADA_INVÁLIDO',
747
+ `\`para_cada\` recorre listas, textos y diccionarios, no ${describeType(typeOf(iterable))}.`,
748
+ instruction.node,
749
+ typeof iterable === 'number'
750
+ ? `Para contar, recorre un rango: \`rango(1, ${repr(iterable)})\`.`
751
+ : undefined,
752
+ );
753
+ }
754
+ this.stack.push(isList(iterable) ? iterable.items.length : iterable.length, 0);
755
+ return false;
756
+ }
757
+
758
+ case 'forEachNext': {
759
+ const position = this.stack[this.stack.length - 1] as number;
760
+ const state = this.stack[this.stack.length - 2] as number | ListValue;
761
+ const iterable = this.stack[this.stack.length - 3] as ListValue | DictValue | string;
762
+ const length = typeof state === 'number' ? state : state.items.length;
763
+ if (isList(iterable) && iterable.items.length !== length) {
764
+ throw this.runtimeError(
765
+ 'LISTA_MODIFICADA_AL_RECORRER',
766
+ `La lista cambió de tamaño mientras se recorría: tenía ${length} y ahora tiene ${iterable.items.length}.`,
767
+ instruction.node,
768
+ 'Recorre una copia o junta los cambios en otra lista y aplícalos al terminar el `para_cada`.',
769
+ );
770
+ }
771
+ if (
772
+ isDict(iterable) &&
773
+ (iterable.entries.size !== length ||
774
+ (position < length && !iterable.entries.has((state as ListValue).items[position] as DictKey)))
775
+ ) {
776
+ throw this.runtimeError(
777
+ 'DICCIONARIO_MODIFICADO_AL_RECORRER',
778
+ 'Se agregaron o quitaron claves del diccionario mientras se recorría.',
779
+ instruction.node,
780
+ 'Recorre `claves(d)`, que es una lista nueva, o junta los cambios y aplícalos al terminar.',
781
+ );
782
+ }
783
+ const again = position < length;
784
+ if (again) {
785
+ this.stack[this.stack.length - 1] = position + 1;
786
+ if (isDict(iterable)) {
787
+ const key = (state as ListValue).items[position] as DictKey;
788
+ if (instruction.owner.value) this.stack.push(iterable.entries.get(key)!);
789
+ this.stack.push(key);
790
+ } else {
791
+ this.stack.push(isList(iterable) ? iterable.items[position]! : iterable[position]!);
792
+ }
793
+ } else {
794
+ this.stack.length -= 3;
795
+ }
796
+ if (!frame.chunk.hidden) this.currentNode = instruction.node;
797
+ this.emit({
798
+ type: 'decide',
799
+ label: 'para_cada',
800
+ value: again,
801
+ nodeId: instruction.node.id,
802
+ ownerId: instruction.owner.id,
803
+ span: instruction.node.span,
804
+ });
805
+ if (!again) frame.pc = instruction.target;
806
+ return true;
807
+ }
808
+
453
809
  case 'shortCircuit': {
454
810
  const left = this.stack[this.stack.length - 1]!;
455
811
  if (typeof left !== 'boolean') throw this.nonBooleanLogical(instruction.node, left, instruction.node.left);
@@ -474,9 +830,41 @@ export class Machine {
474
830
  }
475
831
 
476
832
  case 'enterScope':
833
+ case 'domainStart':
477
834
  frame.scope = new Scope(frame.scope);
478
835
  return false;
479
836
 
837
+ case 'domainEnd': {
838
+ const domain: DomainValue = {
839
+ kind: 'dominio',
840
+ id: this.nextId++,
841
+ name: instruction.name,
842
+ scope: frame.scope,
843
+ writable: new Set(instruction.writable),
844
+ };
845
+ frame.scope = frame.scope.parent ?? frame.scope;
846
+ if (frame.scope.variables.has(instruction.name) && !this.options.allowRedeclare) {
847
+ throw this.runtimeError(
848
+ 'VARIABLE_REPETIDA',
849
+ `Ya existe algo llamado \`${instruction.name}\` en este bloque.`,
850
+ instruction.node.name,
851
+ 'Cada dominio necesita un nombre propio en su bloque.',
852
+ );
853
+ }
854
+ // El nombre de un dominio no se reasigna: siempre se refiere al mismo espacio de nombres.
855
+ frame.scope.variables.set(instruction.name, domain);
856
+ frame.scope.constants.add(instruction.name);
857
+ this.emit({
858
+ type: 'declara',
859
+ name: instruction.name,
860
+ constant: true,
861
+ value: serialize(domain),
862
+ nodeId: instruction.node.id,
863
+ span: instruction.node.span,
864
+ });
865
+ return false;
866
+ }
867
+
480
868
  case 'exitScope':
481
869
  frame.scope = frame.scope.parent ?? frame.scope;
482
870
  return false;
@@ -512,9 +900,22 @@ export class Machine {
512
900
  }
513
901
 
514
902
  const args = this.stack.slice(calleeIndex + 1);
515
- const expected = callee.kind === 'nativo' ? callee.arity : callee.chunk.params.length;
903
+ const expected =
904
+ callee.kind === 'nativo'
905
+ ? callee.arity
906
+ : callee.chunk.minParams !== undefined
907
+ ? ([callee.chunk.minParams, callee.chunk.params.length] as [number, number])
908
+ : callee.chunk.params.length;
516
909
  checkArity(expected, argc, name, (message, hint) =>
517
- this.runtimeError('CANTIDAD_DE_ARGUMENTOS', message, node, hint),
910
+ this.runtimeError(
911
+ 'CANTIDAD_DE_ARGUMENTOS',
912
+ message,
913
+ node,
914
+ hint ??
915
+ (frame.chunk.hidden
916
+ ? `\`${frame.name}\` llama al procedimiento que recibió con ${argc === 1 ? '1 valor' : `${argc} valores`}.`
917
+ : undefined),
918
+ ),
518
919
  );
519
920
 
520
921
  if (callee.kind === 'nativo') {
@@ -522,10 +923,11 @@ export class Machine {
522
923
  if (result === WAIT_FOR_INPUT) {
523
924
  // Repetimos la llamada cuando llegue la entrada.
524
925
  frame.pc -= 1;
525
- this.operations -= 1;
926
+ if (!frame.chunk.hidden) this.operations -= 1;
526
927
  this.status = 'esperando_entrada';
527
928
  return true;
528
929
  }
930
+ if (!frame.chunk.hidden) this.charge(callee.cost, args);
529
931
  this.stack.length = calleeIndex;
530
932
  this.stack.push(result);
531
933
  return false;
@@ -541,9 +943,13 @@ export class Machine {
541
943
  );
542
944
  }
543
945
 
946
+ // La biblioteca cobra según la tabla de costos; dentro de ella, nada más se cobra (§10).
947
+ if (callee.chunk.hidden && !frame.chunk.hidden) this.charge(COSTS[callee.chunk.name], args);
544
948
  const scope = new Scope(callee.closure);
545
- callee.chunk.params.forEach((param, index) => scope.variables.set(param, args[index]!));
546
- this.emit({ type: 'llama', name, args: args.map(serialize), nodeId: node.id, span: node.span });
949
+ callee.chunk.params.forEach((param, index) => scope.variables.set(param, args[index] ?? null));
950
+ if (!(frame.chunk.hidden && callee.chunk.hidden)) {
951
+ this.emit({ type: 'llama', name, args: args.map(serialize), nodeId: node.id, span: node.span });
952
+ }
547
953
  this.frames.push({
548
954
  chunk: callee.chunk,
549
955
  pc: 0,
@@ -556,6 +962,11 @@ export class Machine {
556
962
  return false;
557
963
  }
558
964
 
965
+ /** Cobra lo que cuesta un procedimiento de la biblioteca; la instrucción `call` ya contó 1. */
966
+ private charge(cost: ((args: Value[]) => number) | undefined, args: Value[]): void {
967
+ if (cost) this.operations += Math.max(0, cost(args) - 1);
968
+ }
969
+
559
970
  private nativeContext(node: ast.Call): NativeContext {
560
971
  return {
561
972
  write: text => {
@@ -582,7 +993,10 @@ export class Machine {
582
993
  return null;
583
994
  },
584
995
  newList: items => this.newList(items),
996
+ newDict: entries => this.newDict(entries),
997
+ random: () => this.random(),
585
998
  fail: (code, message, hint) => this.runtimeError(code, message, node, hint),
999
+ emit: event => this.emit({ ...event, nodeId: node.id, span: node.span }),
586
1000
  };
587
1001
  }
588
1002
 
@@ -590,6 +1004,29 @@ export class Machine {
590
1004
  return { kind: 'lista', id: this.nextId++, items };
591
1005
  }
592
1006
 
1007
+ /** mulberry32: pequeño, rápido y suficiente para ejercicios; da lo mismo en cualquier navegador. */
1008
+ private random(): number {
1009
+ this.randomState = (this.randomState + 0x6d2b79f5) >>> 0;
1010
+ let t = this.randomState;
1011
+ t = Math.imul(t ^ (t >>> 15), t | 1);
1012
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1013
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1014
+ }
1015
+
1016
+ private newDict(entries: Map<DictKey, Value>): DictValue {
1017
+ return { kind: 'diccionario', id: this.nextId++, entries };
1018
+ }
1019
+
1020
+ /** Revisa que `key` sirva como clave de un diccionario. */
1021
+ private dictKey(key: Value, node: { span: Span }): DictKey {
1022
+ if (typeof key === 'string' || typeof key === 'number') return key;
1023
+ throw this.runtimeError(
1024
+ 'CLAVE_INVÁLIDA',
1025
+ `Las claves de un diccionario son textos o números, no ${describeType(typeOf(key))}.`,
1026
+ node,
1027
+ );
1028
+ }
1029
+
593
1030
  // ── operadores ─────────────────────────────────────────────────────────────
594
1031
 
595
1032
  private unary(operator: ast.UnaryOperator, operand: Value, node: ast.Unary): Value {
@@ -614,7 +1051,12 @@ export class Machine {
614
1051
  return !operand;
615
1052
  }
616
1053
 
617
- private binary(operator: ast.BinaryOperator, left: Value, right: Value, node: ast.Binary | ast.Assignment): Value {
1054
+ private binary(
1055
+ operator: ast.BinaryOperator,
1056
+ left: Value,
1057
+ right: Value,
1058
+ node: ast.Binary | ast.Assignment | ast.Increment,
1059
+ ): Value {
618
1060
  switch (operator) {
619
1061
  case '==':
620
1062
  return equals(left, right);
@@ -635,12 +1077,27 @@ export class Machine {
635
1077
  throw this.operandError(operator, left, right, node);
636
1078
  case '-':
637
1079
  case '*':
638
- case '/': {
1080
+ case '/':
1081
+ case 'div':
1082
+ case 'mod':
1083
+ case '^': {
639
1084
  if (typeof left !== 'number' || typeof right !== 'number') throw this.operandError(operator, left, right, node);
640
1085
  if (operator === '-') return left - right;
641
1086
  if (operator === '*') return left * right;
1087
+ if (operator === '^') {
1088
+ const result = left ** right;
1089
+ if (Number.isNaN(result)) {
1090
+ throw this.runtimeError(
1091
+ 'POTENCIA_INVÁLIDA',
1092
+ `\`${left < 0 ? `(${repr(left)})` : repr(left)} ^ ${repr(right)}\` no tiene un resultado real.`,
1093
+ node,
1094
+ 'Un número negativo solo se puede elevar a un exponente entero.',
1095
+ );
1096
+ }
1097
+ return result;
1098
+ }
642
1099
  if (right === 0) {
643
- const divisor = node.kind === 'Binary' ? node.right : node.value;
1100
+ const divisor = node.kind === 'Binary' ? node.right : node.kind === 'Assignment' ? node.value : node;
644
1101
  throw this.runtimeError(
645
1102
  'DIVISIÓN_ENTRE_CERO',
646
1103
  'No se puede dividir entre cero.',
@@ -648,7 +1105,10 @@ export class Machine {
648
1105
  'Revisa que el divisor no valga 0 antes de dividir.',
649
1106
  );
650
1107
  }
651
- return left / right;
1108
+ if (operator === '/') return left / right;
1109
+ // `div` y `mod` dividen hacia abajo, así `a == (a div b) * b + (a mod b)` y `-7 mod 3 == 2`.
1110
+ const quotient = Math.floor(left / right);
1111
+ return operator === 'div' ? quotient : left - right * quotient;
652
1112
  }
653
1113
  case '<':
654
1114
  case '>':
@@ -676,19 +1136,159 @@ export class Machine {
676
1136
  }
677
1137
 
678
1138
  private index(object: Value, index: Value, node: ast.Index): Value {
679
- if (!isList(object) && typeof object !== 'string') {
1139
+ if (isDomain(object)) return object.scope.variables.get(this.member(object, index, node))!;
1140
+ if (isDict(object)) {
1141
+ const key = this.dictKey(index, node.index);
1142
+ if (!object.entries.has(key)) throw this.missingKey(key, node);
1143
+ return object.entries.get(key)!;
1144
+ }
1145
+ if (node.dot || (!isList(object) && typeof object !== 'string')) {
680
1146
  throw this.runtimeError(
681
1147
  'ÍNDICE_NO_SOPORTADO',
682
- `Solo se puede usar \`[ ]\` con listas y textos, no con ${describeType(typeOf(object))}.`,
1148
+ node.dot
1149
+ ? `Solo los diccionarios y los dominios tienen campos con \`.\`, no ${describeType(typeOf(object))}.`
1150
+ : `Solo se puede usar \`[ ]\` con listas, textos y diccionarios, no con ${describeType(typeOf(object))}.`,
683
1151
  node.object,
684
1152
  );
685
1153
  }
1154
+ const position = this.position(object, index, node);
1155
+ return isList(object) ? object.items[position]! : object[position]!;
1156
+ }
1157
+
1158
+ private setIndex(
1159
+ object: Value,
1160
+ index: Value,
1161
+ value: Value,
1162
+ target: ast.Index,
1163
+ node: ast.Assignment | ast.Increment,
1164
+ ): void {
1165
+ if (typeof object === 'string') {
1166
+ throw this.runtimeError(
1167
+ 'TEXTO_INMUTABLE',
1168
+ 'Un texto no se puede cambiar letra por letra.',
1169
+ target,
1170
+ 'Construye un texto nuevo, por ejemplo uniendo partes con `+`.',
1171
+ );
1172
+ }
1173
+ if (isDomain(object)) {
1174
+ const name = this.member(object, index, target);
1175
+ if (!object.writable.has(name)) {
1176
+ throw this.runtimeError(
1177
+ 'MIEMBRO_DE_SOLO_LECTURA',
1178
+ `\`${object.name}.${name}\` no se puede cambiar desde fuera del dominio.`,
1179
+ target,
1180
+ 'Desde fuera de un dominio solo se cambian sus miembros declarados con `variable`; las constantes, los procedimientos y los dominios que contiene quedan fijos.',
1181
+ );
1182
+ }
1183
+ const before = object.scope.variables.get(name)!;
1184
+ object.scope.variables.set(name, value);
1185
+ if (this.onEvent) {
1186
+ this.emit({
1187
+ type: 'asigna',
1188
+ name: `${object.name}.${name}`,
1189
+ before: serialize(before),
1190
+ after: serialize(value),
1191
+ nodeId: node.id,
1192
+ span: node.span,
1193
+ });
1194
+ }
1195
+ return;
1196
+ }
1197
+ if (target.dot && !isDict(object)) {
1198
+ throw this.runtimeError(
1199
+ 'ÍNDICE_NO_SOPORTADO',
1200
+ `Solo los diccionarios y los dominios tienen campos con \`.\`, no ${describeType(typeOf(object))}.`,
1201
+ target.object,
1202
+ );
1203
+ }
1204
+ if (isDict(object)) {
1205
+ const key = this.dictKey(index, target.index);
1206
+ const before = object.entries.has(key) ? object.entries.get(key)! : null;
1207
+ object.entries.set(key, value);
1208
+ this.emitModification(object, key, before, value, node);
1209
+ return;
1210
+ }
1211
+ if (!isList(object)) {
1212
+ throw this.runtimeError(
1213
+ 'ÍNDICE_NO_SOPORTADO',
1214
+ `Solo se puede cambiar un elemento de una lista o de un diccionario, no de ${describeType(typeOf(object))}.`,
1215
+ target.object,
1216
+ );
1217
+ }
1218
+ const position = this.position(object, index, target);
1219
+ const before = object.items[position]!;
1220
+ object.items[position] = value;
1221
+ this.emitModification(object, position, before, value, node);
1222
+ }
1223
+
1224
+ private emitModification(
1225
+ object: ListValue | DictValue,
1226
+ key: DictKey,
1227
+ before: Value,
1228
+ after: Value,
1229
+ node: ast.Node,
1230
+ ): void {
1231
+ if (!this.onEvent) return;
1232
+ this.emit({
1233
+ type: 'modifica',
1234
+ ref: object.id,
1235
+ key: serialize(key),
1236
+ before: serialize(before),
1237
+ after: serialize(after),
1238
+ nodeId: node.id,
1239
+ span: node.span,
1240
+ });
1241
+ }
1242
+
1243
+ /** El miembro de un dominio al que se refiere `dominio.miembro`; error si no existe o si se usó `[ ]`. */
1244
+ private member(domain: DomainValue, index: Value, node: ast.Index): string {
1245
+ if (!node.dot) {
1246
+ throw this.runtimeError(
1247
+ 'ÍNDICE_NO_SOPORTADO',
1248
+ `Los miembros del dominio \`${domain.name}\` se usan con \`.\`, no con \`[ ]\`.`,
1249
+ node,
1250
+ typeof index === 'string' && /^[\p{L}_][\p{L}\d_]*$/u.test(index)
1251
+ ? `Escribe \`${domain.name}.${index}\`.`
1252
+ : `Por ejemplo: \`${domain.name}.miembro\`.`,
1253
+ );
1254
+ }
1255
+ const name = index as string;
1256
+ if (!domain.scope.variables.has(name)) {
1257
+ const suggestion = suggestName(name, [...domain.scope.variables.keys()]);
1258
+ throw this.runtimeError(
1259
+ 'MIEMBRO_INEXISTENTE',
1260
+ `El dominio \`${domain.name}\` no tiene nada llamado \`${name}\`.`,
1261
+ node.index,
1262
+ suggestion
1263
+ ? `¿Quisiste decir \`${domain.name}.${suggestion}\`?`
1264
+ : 'Los miembros de un dominio se declaran dentro de sus llaves; desde fuera no se pueden agregar.',
1265
+ );
1266
+ }
1267
+ return name;
1268
+ }
1269
+
1270
+ private missingKey(key: DictKey, node: ast.Index): CodexivoError {
1271
+ const written = node.dot ? `\`${key}\`` : repr(key);
1272
+ return this.runtimeError(
1273
+ 'CLAVE_INEXISTENTE',
1274
+ `El diccionario no tiene la clave ${written}.`,
1275
+ node.index,
1276
+ `Usa \`tiene(d, ${repr(key)})\` para preguntar antes de leerla.`,
1277
+ );
1278
+ }
1279
+
1280
+ /** Posición real de `index` (los negativos cuentan desde el final); error si no es válida. */
1281
+ private position(object: ListValue | string, index: Value, node: ast.Index): number {
686
1282
  if (typeof index !== 'number' || !Number.isInteger(index)) {
687
1283
  throw this.runtimeError(
688
1284
  'ÍNDICE_INVÁLIDO',
689
1285
  `El índice tiene que ser un número entero, pero es ${repr(index)}.`,
690
1286
  node.index,
691
- typeof index === 'number' ? 'Redondea el índice con `redondear(…)`.' : undefined,
1287
+ typeof index === 'number'
1288
+ ? 'Redondea el índice con `redondear(…)`.'
1289
+ : typeof index === 'string'
1290
+ ? 'Las claves de texto son para diccionarios; una lista se recorre con posiciones 0, 1, 2…'
1291
+ : undefined,
692
1292
  );
693
1293
  }
694
1294
  const length = isList(object) ? object.items.length : object.length;
@@ -701,21 +1301,44 @@ export class Machine {
701
1301
  `El índice ${index} está fuera de ${what}, que tiene ${length} ${length === 1 ? unit : unit === 'carácter' ? 'caracteres' : 'elementos'}.`,
702
1302
  node.index,
703
1303
  length === 0
704
- ? `${what[0]!.toUpperCase()}${what.slice(1)} está vacía.`
1304
+ ? isList(object)
1305
+ ? 'La lista está vacía.'
1306
+ : 'El texto está vacío.'
705
1307
  : `Los índices válidos van de 0 a ${length - 1} (o de -1 a -${length} desde el final).`,
706
1308
  );
707
1309
  }
708
- return isList(object) ? object.items[position]! : object[position]!;
1310
+ return position;
709
1311
  }
710
1312
 
711
1313
  // ── errores ────────────────────────────────────────────────────────────────
712
1314
 
1315
+ /** Nombres de las llamadas activas. Dentro de la biblioteca solo se ve el procedimiento que llamó el programa. */
713
1316
  private callStack(): string[] {
714
- return this.frames.map(frame => frame.name);
1317
+ return this.frames
1318
+ .filter((frame, index) => !(frame.chunk.hidden && this.frames[index - 1]?.chunk.hidden))
1319
+ .map(frame => frame.name);
1320
+ }
1321
+
1322
+ /**
1323
+ * El código de la biblioteca no está en el programa, así que un error ahí se ubica en la llamada del programa
1324
+ * que entró a la biblioteca (`mapear(…)`).
1325
+ */
1326
+ private visibleSpan(span: Span | undefined): Span | undefined {
1327
+ let index = this.frames.length - 1;
1328
+ if (!this.frames[index]?.chunk.hidden) return span;
1329
+ while (index > 0 && this.frames[index - 1]!.chunk.hidden) index -= 1;
1330
+ return this.frames[index]!.callNode?.span ?? span;
715
1331
  }
716
1332
 
717
1333
  private runtimeError(code: string, message: string, node: { span: Span }, hint?: string): CodexivoError {
718
- return new CodexivoError({ kind: 'ejecución', code, message, hint, span: node.span, callStack: this.callStack() });
1334
+ return new CodexivoError({
1335
+ kind: 'ejecución',
1336
+ code,
1337
+ message,
1338
+ hint,
1339
+ span: this.visibleSpan(node.span),
1340
+ callStack: this.callStack(),
1341
+ });
719
1342
  }
720
1343
 
721
1344
  private limitError(code: string, message: string, hint: string, node?: { span: Span }): CodexivoError {
@@ -724,7 +1347,7 @@ export class Machine {
724
1347
  code,
725
1348
  message,
726
1349
  hint,
727
- span: node?.span ?? this.currentNode?.span,
1350
+ span: this.visibleSpan(node?.span ?? this.currentNode?.span),
728
1351
  callStack: this.callStack(),
729
1352
  });
730
1353
  }
@@ -738,7 +1361,11 @@ export class Machine {
738
1361
  }
739
1362
 
740
1363
  private unknownName(frame: Frame, name: string, node: ast.Identifier): CodexivoError {
741
- const visible: string[] = [...Object.keys(this.natives)];
1364
+ const visible: string[] = [
1365
+ ...Object.keys(this.natives),
1366
+ ...Object.keys(libraryProcedures),
1367
+ ...Object.keys(libraryConstants),
1368
+ ];
742
1369
  for (let scope: Scope | undefined = frame.scope; scope; scope = scope.parent)
743
1370
  visible.push(...scope.variables.keys());
744
1371
  const suggestion = suggestName(name, visible);
@@ -752,8 +1379,42 @@ export class Machine {
752
1379
  );
753
1380
  }
754
1381
 
1382
+ private constantReassignment(name: string, node: ast.Assignment | ast.Increment): CodexivoError {
1383
+ return this.runtimeError(
1384
+ 'CONSTANTE_REASIGNADA',
1385
+ `\`${name}\` es una constante; su valor no puede cambiar.`,
1386
+ node,
1387
+ `Si necesitas cambiarlo, declárala con \`variable ${name} = …\` en lugar de \`constante\`.`,
1388
+ );
1389
+ }
1390
+
1391
+ private invalidRepeatCount(count: Value, node: ast.Expression): CodexivoError {
1392
+ if (typeof count !== 'number') {
1393
+ return this.runtimeError(
1394
+ 'REPETICIONES_INVÁLIDAS',
1395
+ `\`repetir\` necesita un número de veces, pero recibió ${describeType(typeOf(count))} (${repr(count)}).`,
1396
+ node,
1397
+ typeof count === 'string' ? 'Convierte el texto con `número(…)`.' : undefined,
1398
+ );
1399
+ }
1400
+ return this.runtimeError(
1401
+ 'REPETICIONES_INVÁLIDAS',
1402
+ `\`repetir\` necesita un número entero de veces, 0 o más, pero recibió ${repr(count)}.`,
1403
+ node,
1404
+ count < 0 ? 'Revisa que la cantidad no sea negativa.' : 'Redondea la cantidad con `redondear(…)`.',
1405
+ );
1406
+ }
1407
+
755
1408
  private undeclaredAssignment(name: string, node: ast.Identifier): CodexivoError {
756
- if (this.natives[name]) {
1409
+ if (Object.hasOwn(libraryConstants, name)) {
1410
+ return this.runtimeError(
1411
+ 'NOMBRE_RESERVADO_POR_BIBLIOTECA',
1412
+ `\`${name}\` es una constante de Codexivo; no se le puede asignar un valor.`,
1413
+ node,
1414
+ `Si quieres tu propia variable con ese nombre, declárala: \`variable ${name} = …\`.`,
1415
+ );
1416
+ }
1417
+ if (this.natives[name] || libraryProcedures[name]) {
757
1418
  return this.runtimeError(
758
1419
  'NOMBRE_RESERVADO_POR_BIBLIOTECA',
759
1420
  `\`${name}\` es un procedimiento de Codexivo; no se le puede asignar un valor.`,
@@ -800,6 +1461,8 @@ export class Machine {
800
1461
 
801
1462
  private emit(event: DistributiveOmit<MachineEvent, 'operations' | 'depth'>): void {
802
1463
  if (!this.onEvent) return;
1464
+ // Dentro de la biblioteca solo se ven las llamadas (a `f` y de regreso), la salida, los errores y el fin.
1465
+ if (this.frames[this.frames.length - 1]?.chunk.hidden && !VISIBLE_FROM_LIBRARY.has(event.type)) return;
803
1466
  this.onEvent({ ...event, operations: this.operations, depth: this.frames.length } as MachineEvent);
804
1467
  }
805
1468
  }
@@ -811,20 +1474,31 @@ const splitLines = (text: string): string[] => text.replace(/\r\n/g, '\n').repla
811
1474
  const formatCount = (count: number): string => count.toLocaleString('es-MX').replace(/,/g, ' ');
812
1475
 
813
1476
  const describeCallee = (node: ast.Call): string => {
814
- if (node.callee.kind === 'Identifier') return node.callee.name;
1477
+ const { callee } = node;
1478
+ if (callee.kind === 'Identifier') return callee.name;
1479
+ if (
1480
+ callee.kind === 'Index' &&
1481
+ callee.dot &&
1482
+ callee.object.kind === 'Identifier' &&
1483
+ callee.index.kind === 'StringLiteral'
1484
+ ) {
1485
+ return `${callee.object.name}.${callee.index.value}`;
1486
+ }
815
1487
  return 'esto';
816
1488
  };
817
1489
 
818
1490
  const conditionHint = (value: Value): string | undefined => {
819
1491
  switch (typeOf(value)) {
820
1492
  case 'número':
821
- return `¿Quisiste comparar? Por ejemplo: \`x != 0\` o \`x > 0\`.`;
1493
+ return `¿Quisiste comparar? Por ejemplo: \`x es_distinto_de 0\` o \`x es_mayor_que 0\`.`;
822
1494
  case 'texto':
823
- return '¿Quisiste comparar? Por ejemplo: `texto != ""`.';
1495
+ return '¿Quisiste comparar? Por ejemplo: `texto es_distinto_de ""`.';
824
1496
  case 'lista':
825
- return '¿Quisiste decir `longitud(lista) > 0`?';
1497
+ return '¿Quisiste decir `longitud(lista) es_mayor_que 0`?';
1498
+ case 'diccionario':
1499
+ return '¿Quisiste decir `longitud(d) es_mayor_que 0` o `tiene(d, clave)`?';
826
1500
  case 'nulo':
827
- return '¿Quisiste decir `x != nulo`?';
1501
+ return '¿Quisiste decir `x es_distinto_de nulo`?';
828
1502
  default:
829
1503
  return undefined;
830
1504
  }