Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [
"@sourceacademy/web-stepper"
]
}
3 changes: 3 additions & 0 deletions lib/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ async function generateManifest() {
async function spawnPromise(...args: Parameters<typeof spawn>) {
return new Promise<void>((resolve, reject) => {
const child = spawn(...args);
child.on("error", err => {
reject(err);
});
child.on("close", code => {
if (code === 0) {
resolve();
Expand Down
14 changes: 14 additions & 0 deletions src/common/stepper/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
preset: "ts-jest/presets/js-with-ts-esm",
testEnvironment: "node",
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
},
],
},
testPathIgnorePatterns: [".*?dist/"],
coverageReporters: ["lcov"],
};
3 changes: 3 additions & 0 deletions src/common/stepper/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "installable"
}
37 changes: 37 additions & 0 deletions src/common/stepper/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@sourceacademy/common-stepper",
"version": "0.0.1",
"packageManager": "yarn@4.6.0",
"description": "Shared, language-agnostic protocol for the Source Academy stepper plugin pair",
"scripts": {
"build": "rollup -c",
"prepack": "yarn build",
"test": "jest",
"test-coverage": "jest --coverage"
},
"license": "ISC",
"files": [
"dist"
],
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/jest": "^30.0.0",
"jest": "^30.4.2",
"rollup": "^4.60.2",
"ts-jest": "^29.4.11",
"tslib": "^2.8.1",
"typescript": "^6.0.3"
}
}
21 changes: 21 additions & 0 deletions src/common/stepper/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import nodeResolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";

/**
* @type {import('rollup').RollupOptions}
*/
export default {
input: "src/index.ts",
output: [
{
file: "dist/index.cjs",
format: "cjs",
},
{
file: "dist/index.mjs",
format: "esm",
},
],
plugins: [nodeResolve(), typescript(), terser()],
};
11 changes: 11 additions & 0 deletions src/common/stepper/src/__tests__/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, test } from "vitest";

import { RUNNER_ID, STEPPER_CHANNEL_ID, WEB_ID } from "..";

test("runner and web ids are distinct", () => {
expect(RUNNER_ID).not.toBe(WEB_ID);
});

test("has a stable channel id", () => {
expect(STEPPER_CHANNEL_ID).toBe("__stepper");
});
192 changes: 192 additions & 0 deletions src/common/stepper/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Shared, language-agnostic protocol for the Source Academy Stepper plugin pair.
*
* The Stepper is split into:
* - a {@link https://github.com/source-academy/conductor | Conductor} **runner** plugin
* (`@sourceacademy/runner-stepper`) that turns an AST into evaluation steps, and
* - a **web/host** plugin (`@sourceacademy/web-stepper`) that displays those steps.
*
* They communicate over a single {@link STEPPER_CHANNEL_ID | channel} using the
* {@link StepperMessage} protocol. Everything that crosses the channel must be plain,
* structured-clone-able JSON — class instances with methods cannot survive a `MessageChannel`.
*/

/** The channel the stepper runner and host plugins communicate over. */
export const STEPPER_CHANNEL_ID = "__stepper";

/** The id of the runner (worker-side) stepper plugin. */
export const RUNNER_ID = "__runner_stepper";

/** The id of the web/host (browser-side) stepper plugin. */
export const WEB_ID = "__web_stepper";

/**
* The id used to look the stepper up in the plugin directory (i.e. the argument to
* `IRunnerPlugin.hostLoadPlugin`). The host resolves this to the web plugin's bundle URL.
*/
export const STEPPER_DIRECTORY_ID = "stepper";

/**
* A single AST node, serialized to plain JSON.
*
* Language-specific stepper ASTs are class instances dispatched on a `type` string. After
* serialization the methods are gone, but `type` and the child fields remain so the host can
* still render the node. Every node additionally carries a stable {@link SerializedStepperNode.nodeId}
* assigned during serialization, so markers can reference nodes by id rather than by object
* identity (which does not survive serialization).
*/
export interface SerializedStepperNode {
/** The node kind, e.g. `"BinaryExpression"`. Mirrors the source AST's `type`. */
type: string;
/** A stable id, unique within a single step's tree. Used to match {@link SerializedMarker}s. */
nodeId: string;
/** Child nodes, arrays of nodes, and primitive properties of the original node. */
[key: string]: unknown;
}

/**
* Highlights a redex (reducible expression) within a step and explains the reduction.
*
* In the original (in-memory) stepper a marker pointed at a node by reference. Because that
* identity is lost across the channel, a serialized marker instead references the target node by
* its {@link SerializedStepperNode.nodeId} via {@link SerializedMarker.redexId}.
*/
export interface SerializedMarker {
/** The `nodeId` of the highlighted node, or `null`/absent when there is nothing to highlight. */
redexId?: string | null;
/**
* The `type` of the highlighted node (e.g. `"DebuggerStatement"`). Serialized alongside
* {@link redexId} because the host can no longer dereference the node to read its type (object
* identity is lost across the channel). Used e.g. for breakpoint navigation.
*/
redexNodeType?: string;
/** Whether the highlight applies before or after the reduction. */
redexType?: "beforeMarker" | "afterMarker";
/** A human-readable explanation of the reduction, shown alongside the step. */
explanation?: string;
}

/** One step of an evaluation: a fully-serialized AST plus the markers describing the reduction. */
export interface SerializedStepperStep {
/** The program AST at this step. */
ast: SerializedStepperNode;
/** Markers highlighting/explaining the redex(es) involved in reaching the next step. */
markers?: SerializedMarker[];
}

/* -------------------------------------------------------------------------- */
/* Syntax profiles */
/* -------------------------------------------------------------------------- */

/**
* The serialized stepper AST is structural (estree-shaped) and language-agnostic — node `type`s and
* field names are shared across languages, but the *surface syntax* (keywords, punctuation, layout)
* is not. A {@link SyntaxProfile} is the data a language's runner ships so the host can render that
* language's syntax **without any per-language code in the host**: the host is a generic interpreter
* of these profiles. A new language becomes renderable by providing a profile and registering its
* runner — the host is never edited. When no profile is supplied, the host falls back to its default
* (Source/JavaScript) renderer.
*
* Everything here is plain JSON so it can cross the runner→host channel.
*/

/** A CSS class hint for a rendered token, mapped by the host to its stepper colour classes. */
export type StepperTokenClass = "operator" | "identifier" | "literal" | "conditional";

/**
* One piece of a node's render template. A template is an ordered list of parts; the host emits each
* part in order, recursing into child nodes (which are themselves rendered via the profile), so the
* generic interpreter never needs to know any language's grammar.
*
* - `string` — a literal token, rendered as-is.
* - `{ token, cls? }` — a literal token with an optional style class (e.g. a keyword/operator).
* - `{ prop, cls? }` — the node's own (possibly dotted, e.g. `"id.name"`) property, as text.
* - `{ child, isRight? }` — recurse into `node[child]` (a single child node); a `null` child renders
* nothing. `isRight` marks the right operand of a binary/logical node so the host parenthesises
* with the correct associativity. Only `child` parts establish a parenthesisation context.
* - `{ list, sep, prefix?, cls? }` — render each node in the `node[list]` array, joined by `sep`;
* `prefix` is emitted before the list only when it is non-empty (e.g. a leading space).
* - `{ block }` — render the `node[block]` array as an indented suite (one statement per line).
* - `{ lines }` — render the `node[lines]` array one-per-line without extra indentation (the root).
* - `{ when, parts }` — render `parts` only when `node[when]` is present (e.g. an optional `else`).
*/
export type SyntaxTemplatePart =
| string
| { token: string; cls?: StepperTokenClass }
| { prop: string; cls?: StepperTokenClass }
| { child: string; isRight?: boolean }
| { list: string; sep: string; prefix?: string; cls?: StepperTokenClass }
| { block: string }
| { lines: string }
| { when: string; parts: SyntaxTemplatePart[] };

/**
* Declares a node type as a "function value" in the substitution model and where to read its name.
*
* A function value that carries a name is rendered collapsed as that name — a bold "mu-term" — with
* a hover popover showing its full definition (the node's own template); an anonymous one is rendered
* inline from its template. This mirrors Source's stepper, where a named (possibly recursive) function
* shows as just its name and you hover to reveal the body, so a substituted function never expands its
* whole body inline at every use. The host implements this generically from these rules, so any
* language gets the behaviour by listing its function-value node types — no per-language host code.
*/
export interface FunctionValueRule {
/** The node `type` this applies to, e.g. `"ArrowFunctionExpression"` or `"FunctionDeclaration"`. */
type: string;
/**
* Dotted path to the property holding the mu-term name (e.g. `"name"`, or `"id.name"` for a node
* whose name is on a child `id`). When the path resolves to an empty value the function is treated
* as anonymous and rendered inline.
*/
nameProp: string;
}

/**
* A language's complete rendering rules: a per-node-type template table plus the precedence maps the
* host uses to insert parentheses generically. Authored once per language and shipped by its runner.
*/
export interface SyntaxProfile {
/** node `type` → render template. A type with no template renders as a `<type>` placeholder. */
templates: Record<string, SyntaxTemplatePart[]>;
/** Operator string → precedence, used for parenthesising binary/logical operands. */
operatorPrecedence?: Record<string, number>;
/** Node `type` → precedence, used for parenthesising sub-expressions. */
expressionPrecedence?: Record<string, number>;
/**
* Node types that are function values in the substitution model. A named one renders as a
* collapsed mu-term + hover popover instead of expanding its body inline. See {@link FunctionValueRule}.
*/
functionValues?: FunctionValueRule[];
}

/* -------------------------------------------------------------------------- */
/* Channel protocol */
/* -------------------------------------------------------------------------- */

/** Runner → host: the computed evaluation steps for the most recent run. */
export interface StepperStepsMessage {
type: "steps";
steps: SerializedStepperStep[];
/**
* The language's rendering rules. Optional and run-level (the same for every step). When absent,
* the host renders with its default (Source/JavaScript) syntax. See {@link SyntaxProfile}.
*/
profile?: SyntaxProfile;
}

/** Runner → host: stepping failed (e.g. a parse error). */
export interface StepperErrorMessage {
type: "error";
error: string;
}

/**
* Host → runner: asks the runner to (re)send the steps it last computed. Used to repopulate the
* display when the stepper tab is (re)opened without re-running the program.
*/
export interface StepperRequestMessage {
type: "request";
}

/** Every message that may cross the {@link STEPPER_CHANNEL_ID} channel. */
export type StepperMessage = StepperStepsMessage | StepperErrorMessage | StepperRequestMessage;
11 changes: 11 additions & 0 deletions src/common/stepper/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"exclude": ["./dist"],
"include": ["./src"],
"compilerOptions": {
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["jest"]
}
}
14 changes: 14 additions & 0 deletions src/runner/stepper/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
preset: "ts-jest/presets/js-with-ts-esm",
testEnvironment: "node",
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
},
],
},
testPathIgnorePatterns: [".*?dist/"],
coverageReporters: ["lcov"],
};
3 changes: 3 additions & 0 deletions src/runner/stepper/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "installable"
}
42 changes: 42 additions & 0 deletions src/runner/stepper/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@sourceacademy/runner-stepper",
"version": "0.0.1",
"packageManager": "yarn@4.6.0",
"description": "Language-agnostic runner plugin for the Source Academy stepper",
"scripts": {
"build": "rollup -c",
"prepack": "yarn build",
"test": "jest",
"test-coverage": "jest --coverage"
},
"license": "ISC",
"files": [
"dist"
],
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"peerDependencies": {
"@sourceacademy/conductor": ">=0.3.0"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@sourceacademy/common-stepper": "workspace:*",
"@sourceacademy/conductor": ">=0.3.0",
"@types/jest": "^30.0.0",
"jest": "^30.4.2",
"rollup": "^4.60.2",
"ts-jest": "^29.4.11",
"tslib": "^2.8.1",
"typescript": "^6.0.3"
}
}
21 changes: 21 additions & 0 deletions src/runner/stepper/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import nodeResolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";

/**
* @type {import('rollup').RollupOptions}
*/
export default {
input: "src/index.ts",
output: [
{
file: "dist/index.cjs",
format: "cjs",
},
{
file: "dist/index.mjs",
format: "esm",
},
],
plugins: [nodeResolve(), typescript(), terser()],
};
Loading