• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / py-slang / 29210067604

12 Jul 2026 09:40PM UTC coverage: 77.336% (-0.5%) from 77.798%
29210067604

Pull #217

github

web-flow
Merge ab30003dd into b4bba1067
Pull Request #217: Add Module Loader

2708 of 3789 branches covered (71.47%)

Branch coverage included in aggregate %.

66 of 129 new or added lines in 8 files covered. (51.16%)

17 existing lines in 4 files now uncovered.

6287 of 7842 relevant lines covered (80.17%)

12543.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.69
/src/conductor/plugins/PyCseMachinePlugin.ts
1
import type {
2
  CseSnapshot,
3
  CseSerializedEnvFrame as SerializedEnvFrame,
4
  CseSerializedInstruction as SerializedInstruction,
5
  CseSerializedValue as SerializedValue,
6
} from "@sourceacademy/common-cse-machine";
7
import { Closure } from "../../engines/cse/closure";
8
import { Context } from "../../engines/cse/context";
9
import { Control } from "../../engines/cse/control";
10
import { Environment, UNASSIGNED } from "../../engines/cse/environment";
16✔
11
import { generateCSEMachineStateStream } from "../../engines/cse/interpreter";
16✔
12
import { Stash, Value } from "../../engines/cse/stash";
13
import { InstrType, operatorTranslator, typeTranslator } from "../../engines/cse/types";
16✔
14
import { toPythonFloat } from "../../stdlib/utils";
16✔
15
import { TokenType } from "../../tokenizer";
16

17
type ControlStackItem = {
18
  instrType?: string;
19
  env?: Environment;
20
  kind?: string;
21
  startToken?: { indexInSource?: number; line?: number; lexeme?: string; synthetic?: boolean };
22
  endToken?: { indexInSource?: number; line?: number; lexeme?: string; synthetic?: boolean } | null;
23
  body?: Array<{ kind: string }> | { kind: string; body: Array<{ kind: string }> };
24
  syntheticLabel?: string;
25
  numOfArgs?: number;
26
  numOfElements?: number;
27
  symbol?: string | TokenType;
28
  value?: unknown;
29
};
30

31
// ── Value serialisation ───────────────────────────────────────────────────────
32

33
// Stable integer ID for each list VALUE object across all snapshots in a run.
34
// Using a WeakMap keyed on the actual Value object preserves identity: the same
35
// Python list referenced by multiple bindings gets the same id everywhere, which
36
// lets the frontend deduplicate it to one DataArray canvas box.
37
const _listIdMap = new WeakMap<object, number>();
16✔
38
let _listSeq = 0;
16✔
39
function getListId(v: object): number {
40
  if (!_listIdMap.has(v)) _listIdMap.set(v, ++_listSeq);
3✔
41
  return _listIdMap.get(v)!;
3✔
42
}
43

44
function formatValue(v: Value): string {
45
  if (v === undefined || v === null) return "None";
405✔
46
  switch (v.type) {
403!
47
    case "bigint":
48
      return v.value.toString();
308✔
49
    case "number":
50
      return toPythonFloat(v.value);
3✔
51
    case "bool":
52
      return v.value ? "True" : "False";
3✔
53
    case "string":
54
      return `"${v.value}"`;
2✔
55
    case "none":
56
      return "None";
18✔
57
    case "complex":
58
      return v.value.toString();
1✔
59
    case "closure": {
60
      const cl: Closure = v.closure;
28✔
61
      return cl.node.kind === "FunctionDef" ? cl.node.name.lexeme : "lambda";
28✔
62
    }
63
    case "function":
64
      return v.name || "function";
×
65
    case "multi_lambda":
66
      return "lambda";
×
67
    case "error":
68
      return v.message;
1✔
69
    case "list": {
70
      const items = v.value.slice(0, 4).map(i => formatValue(i));
15✔
71
      const suffix = v.value.length > 4 ? ", ..." : "";
9✔
72
      return `[${items.join(", ")}${suffix}]`;
9✔
73
    }
74
    case "builtin":
75
      // Just the name, matching how "closure" above shows funcName rather than
76
      // Python's full repr() — this is a compact stash chip, not repr() output.
77
      // The "this is a builtin" fact is carried separately via `label`
78
      // (see serializeValue / typeTranslator), same pattern as closures.
79
      return v.name;
30✔
80
    case "opaque":
NEW
81
      return `<opaque value>`;
×
82
    default:
83
      v satisfies never;
×
84
      return "?";
×
85
  }
86
}
87

88
function serializeValue(v: Value | typeof UNASSIGNED, envId = ""): SerializedValue {
10✔
89
  // A local that createEnvironment preallocated at CALL time but that hasn't been
90
  // assigned yet — mirrors js-slang's uninitialized-`const`/`let` placeholder rendering.
91
  if (v === UNASSIGNED) return { displayValue: "", label: "unassigned" };
372!
92
  if (v === undefined || v === null) return { displayValue: "None", label: "NoneType" };
372✔
93
  const base = { displayValue: formatValue(v), label: typeTranslator(v.type) };
371✔
94
  if (v.type === "closure") {
371✔
95
    const cl = v.closure;
28✔
96
    const funcName = cl.node.kind === "FunctionDef" ? cl.node.name.lexeme : "lambda";
28✔
97
    const params = cl.node.parameters.map((p: { lexeme: string }) => p.lexeme);
28✔
98
    return { ...base, metadata: { closureFrameId: cl.environment.id, params, funcName } };
28✔
99
  }
100
  if (v.type === "list") {
343✔
101
    return {
3✔
102
      displayValue: formatValue(v),
103
      label: "list",
104
      metadata: {
105
        id: getListId(v),
106
        envId,
107
        elements: v.value.map((el: Value) => serializeValue(el, envId)),
4✔
108
      },
109
    };
110
  }
111
  return base;
340✔
112
}
113

114
// ── Control serialisation ─────────────────────────────────────────────────────
115

116
// Friendly fallback labels for AST node kinds that have no source tokens.
117
const KIND_LABELS: Record<string, string> = {
16✔
118
  FileInput: "program",
119
  FunctionDef: "def",
120
  Lambda: "lambda",
121
  Assign: "assign",
122
  Return: "return",
123
  SimpleExpr: "expr",
124
  If: "if",
125
  While: "while",
126
  For: "for",
127
  Binary: "bin op",
128
  Unary: "unary op",
129
  Compare: "cmp",
130
  BoolOp: "bool op",
131
  Ternary: "ternary",
132
  Call: "call",
133
  Variable: "var",
134
  Literal: "literal",
135
  BigIntLiteral: "int",
136
  None: "None",
137
  List: "list",
138
  Subscript: "index",
139
  Starred: "starred",
140
  StatementSequence: "stmts",
141
  Grouping: "group",
142
  FromImport: "import",
143
  Pass: "pass",
144
  Break: "break",
145
  Continue: "continue",
146
};
147

148
// Map py-slang InstrType string values → js-slang InstrType string values so the
149
// frontend animation system can dispatch on the correct type.  Values that differ
150
// between the two enum declarations are listed; identical values fall through.
151
const PY_TO_JS_INSTR_TYPE: Partial<Record<InstrType, string>> = {
16✔
152
  [InstrType.APPLICATION]: "Application",
153
  [InstrType.ASSIGNMENT]: "Assignment",
154
  [InstrType.BINARY_OP]: "BinaryOperation",
155
  [InstrType.BOOL_OP]: "BinaryOperation", // no separate BoolOp in js-slang
156
  [InstrType.UNARY_OP]: "UnaryOperation",
157
  [InstrType.POP]: "Pop",
158
  [InstrType.BRANCH]: "Branch",
159
  [InstrType.WHILE]: "While", // py: "WhileInstr" → js: "While"
160
  [InstrType.FOR]: "For", // py: "ForInstr"   → js: "For"
161
  [InstrType.RESET]: "Reset",
162
  [InstrType.LIST]: "ArrayLiteral", // py: "ListLiteral" → js: "ArrayLiteral"
163
  [InstrType.LIST_ACCESS]: "ArrayAccess", // py: "ListAccess"  → js: "ArrayAccess"
164
  [InstrType.LIST_ASSIGNMENT]: "ArrayAssignment", // py: "ListAssignment" → js: "ArrayAssignment"
165
  [InstrType.CONTINUE_MARKER]: "ContinueMarker", // py: "continueMarker" → js: "ContinueMarker"
166
  [InstrType.BREAK]: "Break", // py: "BreakInstr"  → js: "Break"
167
  [InstrType.CONTINUE]: "Continue", // py: "ContinueInstr" → js: "Continue"
168
  [InstrType.MODULE_FUNCTION_CALL]: "ModuleFunctionCall",
169
};
170

171
// Map py-slang AST node kinds → js-slang ESTree node type names so the animation
172
// system dispatches the right animation (ControlExpansionAnimation, LookupAnimation, etc.).
173
const PY_TO_JS_NODE_TYPE: Record<string, string> = {
16✔
174
  FileInput: "StatementSequence",
175
  StatementSequence: "StatementSequence",
176
  FunctionDef: "FunctionDeclaration",
177
  Lambda: "ArrowFunctionExpression",
178
  Return: "ReturnStatement",
179
  Assign: "VariableDeclaration",
180
  If: "IfStatement",
181
  While: "WhileStatement",
182
  For: "ForStatement",
183
  Binary: "BinaryExpression",
184
  Compare: "BinaryExpression",
185
  BoolOp: "BinaryExpression",
186
  Unary: "UnaryExpression",
187
  Call: "CallExpression",
188
  Variable: "Identifier",
189
  Literal: "Literal",
190
  BigIntLiteral: "Literal",
191
  None: "Literal",
192
  List: "ArrayExpression",
193
  Subscript: "MemberExpression",
194
  Ternary: "ConditionalExpression",
195
  SimpleExpr: "ExpressionStatement",
196
  Grouping: "ExpressionStatement",
197
};
198

199
function instrDisplayText(item: ControlStackItem): string {
200
  switch (item.instrType as InstrType) {
488!
201
    case InstrType.RESET:
202
      return "return";
13✔
203
    case InstrType.END_OF_FUNCTION_BODY:
204
      return "return None";
8✔
205
    case InstrType.POP:
206
      return "pop";
84✔
207
    case InstrType.CONTINUE_MARKER:
208
      return "mark";
88✔
209
    case InstrType.CONTINUE:
210
      return "continue";
1✔
211
    case InstrType.BREAK:
212
      return "break";
1✔
213
    case InstrType.BRANCH:
214
      return "branch";
1✔
215
    case InstrType.WHILE:
216
      return "while";
1✔
217
    case InstrType.FOR:
218
      return "for";
163✔
219
    case InstrType.LIST:
220
      return `arr lit ${item.numOfElements}`;
2✔
221
    case InstrType.LIST_ACCESS:
222
      return "arr acc";
1✔
223
    case InstrType.LIST_ASSIGNMENT:
224
      return "arr asgn";
1✔
225
    case InstrType.ASSIGNMENT:
226
      return `asgn ${item.symbol}`;
46✔
227
    case InstrType.APPLICATION:
228
      return `call ${item.numOfArgs}`;
42✔
229
    case InstrType.UNARY_OP:
230
    case InstrType.BINARY_OP:
231
    case InstrType.BOOL_OP:
232
      return operatorTranslator(item.symbol!);
36✔
233
    case InstrType.MODULE_FUNCTION_CALL:
NEW
UNCOV
234
      return `mod call ${item.numOfArgs}`;
×
235
    default:
236
      return String(item.instrType);
×
237
  }
238
}
239

240
function serializeControlItem(item: ControlStackItem, code: string): SerializedInstruction {
241
  // Instructions have 'instrType'.
242
  if (item.instrType !== undefined) {
1,072✔
243
    if (item.instrType === InstrType.ENVIRONMENT && item.env?.id !== undefined) {
485✔
244
      return { displayText: "ENVIRONMENT", metadata: { envId: item.env.id } };
14✔
245
    }
246
    const jsInstrType = PY_TO_JS_INSTR_TYPE[item.instrType as InstrType];
471✔
247
    const meta: Record<string, unknown> = {};
471✔
248
    if (jsInstrType !== undefined) {
471✔
249
      meta.instrType = jsInstrType;
464✔
250
    }
251
    // Extra fields consumed by specific animations.
252
    if (item.instrType === InstrType.APPLICATION && item.numOfArgs !== undefined) {
471✔
253
      meta.numOfArgs = item.numOfArgs;
41✔
254
    }
255
    if (item.instrType === InstrType.MODULE_FUNCTION_CALL && item.numOfArgs !== undefined) {
471!
NEW
UNCOV
256
      meta.numOfArgs = item.numOfArgs;
×
257
    }
258
    if (item.instrType === InstrType.ASSIGNMENT && item.symbol !== undefined) {
471✔
259
      meta.symbol = item.symbol;
45✔
260
    }
261
    if (
471✔
262
      (item.instrType === InstrType.BINARY_OP ||
1,383✔
263
        item.instrType === InstrType.BOOL_OP ||
264
        item.instrType === InstrType.UNARY_OP) &&
265
      item.symbol !== undefined
266
    ) {
267
      meta.symbol = operatorTranslator(item.symbol);
33✔
268
    }
269
    if (item.instrType === InstrType.LIST && item.numOfElements !== undefined) {
471✔
270
      meta.arity = item.numOfElements;
1✔
271
    }
272
    return Object.keys(meta).length > 0
471✔
273
      ? { displayText: instrDisplayText(item), metadata: meta }
274
      : { displayText: instrDisplayText(item) };
275
  }
276

277
  // AST nodes have 'kind' and startToken/endToken with source positions.
278
  if (item.kind !== undefined) {
587✔
279
    const start: number = item.startToken?.indexInSource ?? -1;
586✔
280
    const endTok = item.endToken;
586✔
281
    const end: number =
282
      endTok != null && endTok.indexInSource !== undefined
586✔
283
        ? endTok.indexInSource + (endTok.lexeme?.length ?? 0)
579!
284
        : -1;
285
    // Synthetic nodes generated at runtime (e.g. loop range BigIntLiterals) carry tokens
286
    // explicitly marked `synthetic` — they don't correspond to real source text, even
287
    // when their indexInSource coincides with a genuine position (e.g. 0, the very first
288
    // token of the file). Do not infer syntheticness from position alone.
289
    const isRealSourceNode = !item.startToken?.synthetic && !endTok?.synthetic;
586✔
290
    let displayText: string;
291
    let loc: { startLine: number; endLine: number } | undefined;
292
    if (start >= 0 && end > start && end <= code.length && isRealSourceNode) {
586✔
293
      displayText = code.slice(start, end).trim();
151✔
294
      // Token.line is 1-based (matches ESTree convention expected by ControlStack).
295
      const startLine: number | undefined = item.startToken?.line;
151✔
296
      const endLine: number | undefined = endTok?.line;
151✔
297
      if (startLine !== undefined) {
151✔
298
        loc = { startLine, endLine: endLine ?? startLine };
151!
299
      }
300
    } else if (item.kind === "BigIntLiteral" && item.value !== undefined) {
435✔
301
      // Synthetic BigIntLiteral (implicit range start/step) — show its value.
302
      displayText = String(item.value);
323✔
303
    } else {
304
      displayText = KIND_LABELS[item.kind] ?? `<${item.kind}>`;
112✔
305
    }
306
    if (item.syntheticLabel) {
586✔
307
      displayText = item.syntheticLabel;
97✔
308
    }
309

310
    const jsNodeType = PY_TO_JS_NODE_TYPE[item.kind];
586✔
311
    const nodeMeta: Record<string, unknown> = {};
586✔
312
    if (loc) {
586✔
313
      nodeMeta.startLine = loc.startLine;
151✔
314
      nodeMeta.endLine = loc.endLine;
151✔
315
    }
316
    if (jsNodeType !== undefined) {
586✔
317
      nodeMeta.nodeType = jsNodeType;
585✔
318
    }
319
    // For block-like nodes pass body length and child types so the adapter can build
320
    // stub body arrays (used by ControlExpansionAnimation / StatementSequence handling).
321
    if (
586✔
322
      (jsNodeType === "StatementSequence" ||
1,758✔
323
        jsNodeType === "FunctionDeclaration" ||
324
        jsNodeType === "ArrowFunctionExpression") &&
325
      item.body !== undefined
326
    ) {
327
      const body =
328
        item.body !== undefined &&
5✔
329
        item.body !== null &&
330
        "body" in item.body &&
331
        jsNodeType === "StatementSequence"
332
          ? item.body.body
333
          : item.body;
334
      const bodyArray = Array.isArray(body) ? body : [body];
5✔
335
      nodeMeta.bodyLength = bodyArray.length;
5✔
336
      nodeMeta.bodyNodeTypes = bodyArray.map(n => PY_TO_JS_NODE_TYPE[n.kind] ?? "Identifier");
8!
337
    }
338

339
    return Object.keys(nodeMeta).length > 0 ? { displayText, metadata: nodeMeta } : { displayText };
586✔
340
  }
341

342
  return { displayText: "<unknown>" };
1✔
343
}
344

345
// ── Environment serialisation ─────────────────────────────────────────────────
346

347
function serializeEnvChain(
348
  environments: Environment[],
349
  stashValues: Value[],
350
  controlItems: ControlStackItem[],
351
  activeEnv: Environment,
352
): SerializedEnvFrame[] {
353
  const seen = new Set<string>();
273✔
354
  const queue: Environment[] = [];
273✔
355

356
  // BFS — visit env + its tail chain, then recursively follow any closure environments
357
  // found in its head bindings. This ensures closure environments that have already
358
  // returned (and are no longer on the active call stack) still appear in the snapshot.
359
  const visit = (env: Environment | null | undefined) => {
273✔
360
    if (!env || seen.has(env.id)) return;
1,147✔
361
    seen.add(env.id);
554✔
362
    queue.push(env);
554✔
363
    visit(env.tail);
554✔
364
    for (const val of Object.values(env.head)) {
554✔
365
      if (val && val !== UNASSIGNED && val.type === "closure") visit(val.closure?.environment);
452✔
366
    }
367
  };
368

369
  for (const env of environments) visit(env);
552✔
370

371
  // Also follow closures sitting on the stash whose environments may not be on the active stack
372
  for (const item of stashValues) {
273✔
373
    if (item && item.type === "closure") visit(item.closure.environment);
170✔
374
  }
375

376
  // ENV instructions on the control stack keep frames alive that are not on the
377
  // call stack right now (e.g. h's frame while g(x) is executing inside h).
378
  // Without this seed, those frames die and then "resurrect" when control returns —
379
  // violating the invariant that dead frames never come back.
380
  for (const item of controlItems) {
273✔
381
    if (item.instrType === "environment" && item.env) {
1,051✔
382
      visit(item.env);
13✔
383
    }
384
  }
385

386
  const callStackIds = new Set(environments.map(e => e.id));
552✔
387

388
  // Walk up the tail chain skipping any filtered frames to find the visible parent.
389
  const visibleParentId = (env: Environment): string | null => {
273✔
390
    let cur = env.tail;
552✔
391
    while (cur) {
552✔
392
      if (cur.name !== "prelude") return cur.id;
280✔
393
      cur = cur.tail;
2✔
394
    }
395
    return null;
274✔
396
  };
397

398
  return queue
273✔
399
    .filter(env => env.name !== "prelude")
554✔
400
    .map(env => ({
552✔
401
      id: env.id,
402
      name: env.name,
403
      parentId: visibleParentId(env),
404
      closureFrameId: env.closure?.environment?.id,
405
      bindings: Object.entries(env.head)
406
        .filter(([name]) => name !== "__program__")
452✔
407
        .map(([name, val]) => ({
188✔
408
          name,
409
          value: serializeValue(val, env.id),
410
        })),
411
      isActive: env.id === activeEnv.id,
412
      isOnCallStack: callStackIds.has(env.id),
413
      globalNames: env.closure?.globalVariables.size ? [...env.closure.globalVariables] : undefined,
552✔
414
    }));
415
}
416

417
// ── Snapshot collection ───────────────────────────────────────────────────────
418

419
export async function collectSnapshots(
16✔
420
  context: Context,
421
  control: Control,
422
  stash: Stash,
423
  envSteps: number,
424
  stepLimit: number,
425
  variant: number,
426
  code: string,
427
  maxSnapshots: number = 1000,
16✔
428
): Promise<{ snapshots: CseSnapshot[]; breakpointSteps: number[] }> {
429
  const snapshots: CseSnapshot[] = [];
18✔
430
  // Runtime-fabricated nodes (e.g. implicit range() bounds, the for-loop increment —
431
  // see utils.ts's evaluateForIterator/generateForIncrement) carry tokens pinned to
432
  // line 0, since they don't correspond to any line the user actually wrote. Real
433
  // tokens are always 1-based (see parser/token-bridge.ts), so line 0 is an
434
  // unambiguous "not a real line" signal. Keep showing the last real line instead of
435
  // flickering to 0 while these synthetic nodes are being evaluated.
436
  let lastKnownLine: number | undefined;
437

438
  const stream = generateCSEMachineStateStream(
18✔
439
    code,
440
    context,
441
    control,
442
    stash,
443
    envSteps,
444
    stepLimit,
445
    1024,
446
    variant,
447
    false,
448
  );
449

450
  for await (const { stash: s, control: c, steps } of stream) {
18✔
451
    // maxSnapshots === 0 → run the program to completion (for stdout/errors) but
452
    // collect nothing. Used for chapters where the CSE machine is disabled.
453
    if (maxSnapshots === 0) continue;
268✔
454
    if (snapshots.length >= maxSnapshots) break;
264✔
455

456
    const activeEnv = context.runtime.environments[0];
263✔
457
    const rawControlStack = c.getStack() as unknown as ControlStackItem[];
263✔
458
    const controlItems = rawControlStack
263✔
459
      .slice()
460
      .reverse()
461
      .map(item => serializeControlItem(item, code));
1,051✔
462
    const stashItems = s
263✔
463
      .getStack()
464
      .slice()
465
      .reverse()
466
      .map(sv => serializeValue(sv, activeEnv.id));
170✔
467
    const environments = serializeEnvChain(
263✔
468
      context.runtime.environments,
469
      s.getStack(),
470
      rawControlStack,
471
      activeEnv,
472
    );
473

474
    // The node most recently evaluated at this step. Mirrors the non-conductor CSE
475
    // machine's updateInspector, which reads context.runtime.nodes[0] for the blue
476
    // "current line" highlight. py-slang nodes carry a 1-based startToken.line.
477
    const currentNode = context.runtime.nodes[0] as { startToken?: { line?: number } } | undefined;
263✔
478
    const rawLine = currentNode?.startToken?.line;
263✔
479
    if (rawLine) {
263✔
480
      lastKnownLine = rawLine;
263✔
481
    }
482

483
    snapshots.push({
263✔
484
      stepIndex: steps - 1,
485
      control: controlItems,
486
      stash: stashItems,
487
      environments,
488
      currentLine: lastKnownLine,
489
    });
490
  }
491

492
  // context.runtime.breakpointSteps is recorded by the interpreter regardless of the
493
  // maxSnapshots cutoff above; filter out any indices past what was actually collected so the
494
  // host never gets pointed at a step with no corresponding snapshot.
495
  const breakpointSteps = context.runtime.breakpointSteps.filter(step => step < snapshots.length);
18✔
496

497
  return { snapshots, breakpointSteps };
18✔
498
}
499

500
// The runner-side plugin that transports these snapshots now lives in
501
// @sourceacademy/runner-cse-machine (CseMachinePlugin). This module only owns the
502
// Python-specific serialization of control/stash/environment into CseSnapshots.
503

504
// Exported for unit testing only — not part of the public API.
505
export { formatValue, instrDisplayText, serializeControlItem, serializeEnvChain, serializeValue };
16✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc