Implement basic execution system and UI
This is a rather large commit that includes all of the following: - React UI with code editor, runtime renderer and input-output panes - Language providers for a sample language and Brainfuck - Implementation of code execution in a web worker - All-at-once unabortable execution of program fully functional
This commit is contained in:
parent
8746c803d7
commit
01ba292b9f
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
41
.gitignore
vendored
41
.gitignore
vendored
@ -1,4 +1,37 @@
|
|||||||
node_modules/
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
lerna-debug.log
|
|
||||||
npm-debug.log
|
# dependencies
|
||||||
packages/*/lib
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||||
|
|
||||||
|
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
21
engines/brainfuck/README.md
Normal file
21
engines/brainfuck/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Brainfuck
|
||||||
|
|
||||||
|
## Allowed symbols
|
||||||
|
|
||||||
|
- `>`: Move the pointer to the right
|
||||||
|
- `<`: Move the pointer to the left
|
||||||
|
- `+`: Increment the memory cell at the pointer
|
||||||
|
- `-`: Decrement the memory cell at the pointer
|
||||||
|
- `.`: Output the character signified by the cell at the pointer
|
||||||
|
- `,`: Input a character and store it in the cell at the pointer
|
||||||
|
- `[`: Jump past the matching `]` if the cell at the pointer is 0
|
||||||
|
- `]`: Jump back to the matching `[` if the cell at the pointer is nonzero
|
||||||
|
|
||||||
|
## Memory specifications
|
||||||
|
|
||||||
|
> These parameters will be configurable when engine configuration is added to the project
|
||||||
|
|
||||||
|
- For Turing-completeness, the number of cells is kept unbounded.
|
||||||
|
- Cell size is 8 bits, and allows values in the range `[-128, 127]`.
|
||||||
|
- Value `10` is designated for newlines.
|
||||||
|
- The value `0` is returned on reaching `EOF`.
|
81
engines/brainfuck/constants.ts
Normal file
81
engines/brainfuck/constants.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { MonacoTokensProvider } from "../types";
|
||||||
|
|
||||||
|
export type BFRS = {
|
||||||
|
tape: { [k: number]: number };
|
||||||
|
pointer: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum BF_OP {
|
||||||
|
LEFT = "<",
|
||||||
|
RIGHT = ">",
|
||||||
|
INCR = "+",
|
||||||
|
DECR = "-",
|
||||||
|
OUT = ".",
|
||||||
|
IN = ",",
|
||||||
|
LOOPIN = "[",
|
||||||
|
LOOPOUT = "]",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single instruction of the program */
|
||||||
|
export type BFInstruction = {
|
||||||
|
/** Type of instruction */
|
||||||
|
type: BF_OP;
|
||||||
|
/** Used for location of opposite end of loops */
|
||||||
|
param?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A single element of the program's AST */
|
||||||
|
export type BFAstStep = {
|
||||||
|
instr: BFInstruction;
|
||||||
|
location: { line: number; char: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sample program printing "Hello World!" */
|
||||||
|
export const sampleProgram = [
|
||||||
|
"+++++ +++ Set Cell #0 to 8",
|
||||||
|
"[",
|
||||||
|
" >++++ Add 4 to Cell #1; this will always set Cell #1 to 4",
|
||||||
|
" [ as the cell will be cleared by the loop",
|
||||||
|
" >++ Add 4*2 to Cell #2",
|
||||||
|
" >+++ Add 4*3 to Cell #3",
|
||||||
|
" >+++ Add 4*3 to Cell #4",
|
||||||
|
" >+ Add 4 to Cell #5",
|
||||||
|
" <<<<- Decrement the loop counter in Cell #1",
|
||||||
|
" ] Loop till Cell #1 is zero",
|
||||||
|
" >+ Add 1 to Cell #2",
|
||||||
|
" >+ Add 1 to Cell #3",
|
||||||
|
" >- Subtract 1 from Cell #4",
|
||||||
|
" >>+ Add 1 to Cell #6",
|
||||||
|
" [<] Move back to the first zero cell you find; this will",
|
||||||
|
" be Cell #1 which was cleared by the previous loop",
|
||||||
|
" <- Decrement the loop Counter in Cell #0",
|
||||||
|
"] Loop till Cell #0 is zero",
|
||||||
|
"",
|
||||||
|
"The result of this is:",
|
||||||
|
"Cell No : 0 1 2 3 4 5 6",
|
||||||
|
"Contents: 0 0 72 104 88 32 8",
|
||||||
|
"Pointer : ^",
|
||||||
|
"",
|
||||||
|
">>. Cell #2 has value 72 which is 'H'",
|
||||||
|
">---. Subtract 3 from Cell #3 to get 101 which is 'e'",
|
||||||
|
"+++++ ++..+++. Likewise for 'llo' from Cell #3",
|
||||||
|
">>. Cell #5 is 32 for the space",
|
||||||
|
"<-. Subtract 1 from Cell #4 for 87 to give a 'W'",
|
||||||
|
"<. Cell #3 was set to 'o' from the end of 'Hello'",
|
||||||
|
"+++.----- -.----- ---. Cell #3 for 'rl' and 'd'",
|
||||||
|
">>+. Add 1 to Cell #5 gives us an exclamation point",
|
||||||
|
">++. And finally a newline from Cell #6",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
/** Tokens provider */
|
||||||
|
export const editorTokensProvider: MonacoTokensProvider = {
|
||||||
|
tokenizer: {
|
||||||
|
root: [
|
||||||
|
[/[-\+]/, "operator"],
|
||||||
|
[/[<>]/, "annotation"],
|
||||||
|
[/[\[\]]/, "keyword"],
|
||||||
|
[/[\,\.]/, "type.identifier"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultToken: "comment",
|
||||||
|
};
|
181
engines/brainfuck/engine.ts
Normal file
181
engines/brainfuck/engine.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { DocumentRange, LanguageEngine, StepExecutionResult } from "../types";
|
||||||
|
import { BFAstStep, BFInstruction, BFRS, BF_OP } from "./constants";
|
||||||
|
|
||||||
|
// Default values for internal states
|
||||||
|
// Factories are used to create new objects on reset
|
||||||
|
const DEFAULT_AST = (): BFAstStep[] => [];
|
||||||
|
const DEFAULT_PTR = 0;
|
||||||
|
const DEFAULT_PC = -1;
|
||||||
|
const DEFAULT_TAPE = (): { [k: number]: number } => ({});
|
||||||
|
const DEFAULT_INPUT: string = "";
|
||||||
|
|
||||||
|
// Instruction characters
|
||||||
|
const OP_CHARS = Object.values(BF_OP);
|
||||||
|
|
||||||
|
class BrainfuckLanguageEngine implements LanguageEngine<BFRS> {
|
||||||
|
private _ast: BFAstStep[] = DEFAULT_AST();
|
||||||
|
private _ptr: number = DEFAULT_PTR;
|
||||||
|
private _tape: { [k: number]: number } = DEFAULT_TAPE();
|
||||||
|
private _input: string = DEFAULT_INPUT;
|
||||||
|
private _pc: number = DEFAULT_PC;
|
||||||
|
|
||||||
|
resetState() {
|
||||||
|
this._ast = DEFAULT_AST();
|
||||||
|
this._ptr = DEFAULT_PTR;
|
||||||
|
this._tape = DEFAULT_TAPE();
|
||||||
|
this._input = DEFAULT_INPUT;
|
||||||
|
this._pc = DEFAULT_PC;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(code: string, input: string) {
|
||||||
|
this._input = input;
|
||||||
|
this._ast = this.parseCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
executeStep(): StepExecutionResult<BFRS> {
|
||||||
|
// Execute and update program counter
|
||||||
|
let output: string | undefined = undefined;
|
||||||
|
if (this._pc !== -1) {
|
||||||
|
const astStep = this._ast[this._pc];
|
||||||
|
const opResult = this.processOp(astStep.instr);
|
||||||
|
this._pc = opResult?.newPc == null ? this._pc + 1 : opResult.newPc;
|
||||||
|
output = opResult?.output;
|
||||||
|
} else this._pc += 1;
|
||||||
|
|
||||||
|
// Prepare location of next step
|
||||||
|
let nextStepLocation: DocumentRange | null = null;
|
||||||
|
if (this._pc < this._ast.length) {
|
||||||
|
const { line, char } = this._ast[this._pc].location;
|
||||||
|
const charRange = { start: char, end: char + 1 };
|
||||||
|
nextStepLocation = { line, charRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare and return execution result
|
||||||
|
const rendererState = { tape: this._tape, pointer: this._ptr };
|
||||||
|
return { rendererState, nextStepLocation, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCode(code: string) {
|
||||||
|
const ast: BFAstStep[] = [];
|
||||||
|
|
||||||
|
// Stack to maintain loop counts. Element of stack denotes
|
||||||
|
// program counter for loop-opening instruction.
|
||||||
|
const loopStack: number[] = [];
|
||||||
|
|
||||||
|
// For each line...
|
||||||
|
code.split("\n").forEach((line, lIdx) => {
|
||||||
|
// For each character of this line...
|
||||||
|
line.split("").forEach((char, cIdx) => {
|
||||||
|
// Ignore if the character is not an operation
|
||||||
|
if (!OP_CHARS.includes(char as BF_OP)) return;
|
||||||
|
|
||||||
|
// Update loop-tracking stack if it's a loop-char
|
||||||
|
let jumpTarget = undefined;
|
||||||
|
if (char === BF_OP.LOOPIN) {
|
||||||
|
// Push loop start into stack
|
||||||
|
// Opposite end location will be added at loop close
|
||||||
|
loopStack.push(ast.length);
|
||||||
|
} else if (char === BF_OP.LOOPOUT) {
|
||||||
|
// Get location of loop-opener
|
||||||
|
jumpTarget = loopStack.pop()!;
|
||||||
|
// Add closing end location to loop-opener
|
||||||
|
ast[jumpTarget].instr.param = ast.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add instruction to AST
|
||||||
|
ast.push({
|
||||||
|
instr: { type: char as BF_OP, param: jumpTarget },
|
||||||
|
location: { line: lIdx + 1, char: cIdx + 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the given instruction and return the updated program counter and
|
||||||
|
* any output to send to stdout.
|
||||||
|
*
|
||||||
|
* If program counter is not returned, counter should be incremented by 1.
|
||||||
|
*
|
||||||
|
* @param instr Instruction to apply
|
||||||
|
* @returns Optional fields for new program counter and step output
|
||||||
|
*/
|
||||||
|
private processOp(
|
||||||
|
instr: BFInstruction
|
||||||
|
): { newPc?: number; output?: string } | void {
|
||||||
|
// Pointer-shift operations
|
||||||
|
if (instr.type === BF_OP.LEFT) this.decrementPtr();
|
||||||
|
else if (instr.type === BF_OP.RIGHT) this.incrementPtr();
|
||||||
|
// Current cell modifiers
|
||||||
|
else if (instr.type === BF_OP.INCR) this.incrementCell(this._ptr);
|
||||||
|
else if (instr.type === BF_OP.DECR) this.decrementCell(this._ptr);
|
||||||
|
// Input and output
|
||||||
|
else if (instr.type === BF_OP.OUT) return { output: this.outputChar() };
|
||||||
|
else if (instr.type === BF_OP.IN) this.inputChar();
|
||||||
|
// Looping
|
||||||
|
else if (instr.type === BF_OP.LOOPIN) {
|
||||||
|
// Conditionally jump past loop-closer
|
||||||
|
if (this.getCell(this._ptr) !== 0) return;
|
||||||
|
return { newPc: instr.param! + 1 };
|
||||||
|
} else if (instr.type === BF_OP.LOOPOUT) {
|
||||||
|
// Conditionally jump to loop-opener
|
||||||
|
if (this.getCell(this._ptr) === 0) return;
|
||||||
|
return { newPc: instr.param };
|
||||||
|
} else throw new Error("Unexpected instruction type");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output character from current cell */
|
||||||
|
private outputChar(): string {
|
||||||
|
const code = this._tape[this._ptr];
|
||||||
|
return String.fromCharCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input character into current cell */
|
||||||
|
private inputChar(): void {
|
||||||
|
if (this._input.length === 0) {
|
||||||
|
// EOF is treated as a zero
|
||||||
|
this._tape[this._ptr] = 0;
|
||||||
|
} else {
|
||||||
|
// Pop first char of input and set to cell
|
||||||
|
this._tape[this._ptr] = this._input.charCodeAt(0);
|
||||||
|
this._input = this._input.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get value of tape cell. Initializes cell if first use */
|
||||||
|
private getCell(cellId: number): number {
|
||||||
|
if (!this._tape[cellId]) this._tape[cellId] = 0;
|
||||||
|
return this._tape[cellId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Increment tape cell at specified location */
|
||||||
|
private incrementCell(cellId: number): void {
|
||||||
|
if (!this._tape[cellId]) this._tape[cellId] = 0;
|
||||||
|
this._tape[cellId] += 1;
|
||||||
|
if (this._tape[cellId] === 128) this._tape[cellId] = -128;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrement tape cell at specified location */
|
||||||
|
private decrementCell(cellId: number): void {
|
||||||
|
if (!this._tape[cellId]) this._tape[cellId] = 0;
|
||||||
|
this._tape[cellId] -= 1;
|
||||||
|
if (this._tape[cellId] === -129) this._tape[cellId] = 127;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the tape pointer one cell to the right */
|
||||||
|
private incrementPtr(): void {
|
||||||
|
this._ptr += 1;
|
||||||
|
this.getCell(this._ptr); // Init cell if required
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the tape pointer one cell to the left */
|
||||||
|
private decrementPtr(): void {
|
||||||
|
if (this._ptr <= 0) throw new Error("Ptr out of bounds");
|
||||||
|
this._ptr -= 1;
|
||||||
|
this.getCell(this._ptr); // Init cell if required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrainfuckLanguageEngine;
|
11
engines/brainfuck/index.ts
Normal file
11
engines/brainfuck/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Renderer } from "./renderer";
|
||||||
|
import { LanguageProvider } from "../types";
|
||||||
|
import { BFRS, sampleProgram, editorTokensProvider } from "./constants";
|
||||||
|
|
||||||
|
const provider: LanguageProvider<BFRS> = {
|
||||||
|
Renderer,
|
||||||
|
sampleProgram,
|
||||||
|
editorTokensProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provider;
|
60
engines/brainfuck/renderer.tsx
Normal file
60
engines/brainfuck/renderer.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { RendererProps } from "../types";
|
||||||
|
import { BFRS } from "./constants";
|
||||||
|
|
||||||
|
const styles: { [k: string]: CSSProperties } = {
|
||||||
|
container: {
|
||||||
|
padding: 10,
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignContent: "flex-start",
|
||||||
|
overflowY: "auto",
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
// Sizing
|
||||||
|
width: "12%",
|
||||||
|
height: "50px",
|
||||||
|
margin: "5px 0.25%",
|
||||||
|
padding: 12,
|
||||||
|
// Center-align values
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
// Border and colors
|
||||||
|
border: "1px solid gray",
|
||||||
|
borderRadius: 5,
|
||||||
|
background: "#394B59",
|
||||||
|
color: "#E1E8ED",
|
||||||
|
},
|
||||||
|
activeCell: {
|
||||||
|
background: "#CED9E0",
|
||||||
|
color: "#182026",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Component for displaying a single tape cell */
|
||||||
|
const Cell = ({ value, active }: { value: number; active: boolean }) => {
|
||||||
|
const cellStyle = { ...styles.cell, ...(active ? styles.activeCell : {}) };
|
||||||
|
return <div style={cellStyle}>{value}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renderer for Brainfuck */
|
||||||
|
export const Renderer = ({ state }: RendererProps<BFRS>) => {
|
||||||
|
/** Serialize tape from object format into linear array */
|
||||||
|
const serializeTapeObj = (tape: BFRS["tape"]) => {
|
||||||
|
const cellIdxs = Object.keys(tape).map((s) => parseInt(s, 10));
|
||||||
|
const maxCellIdx = Math.max(15, ...cellIdxs);
|
||||||
|
const linearTape = Array(maxCellIdx + 1).fill(0);
|
||||||
|
cellIdxs.forEach((i) => (linearTape[i] = tape[i] || 0));
|
||||||
|
return linearTape;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{serializeTapeObj(state?.tape || {}).map((num, i) => (
|
||||||
|
<Cell value={num} key={i} active={(state?.pointer || 0) === i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
60
engines/execution-controller.ts
Normal file
60
engines/execution-controller.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { LanguageEngine, StepExecutionResult } from "./types";
|
||||||
|
|
||||||
|
type ExecuteAllArgs<RS> = {
|
||||||
|
/** Interval between two execution steps, in milliseconds */
|
||||||
|
interval?: number;
|
||||||
|
/**
|
||||||
|
* Pass to run in streaming-response mode.
|
||||||
|
* Callback is called with exeuction result on every execution step.
|
||||||
|
*/
|
||||||
|
onResult?: (result: StepExecutionResult<RS>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ExecutionController<RS> {
|
||||||
|
private _engine: LanguageEngine<RS>;
|
||||||
|
private _result: StepExecutionResult<RS> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new ExecutionController.
|
||||||
|
* @param engine Language engine to use for execution
|
||||||
|
*/
|
||||||
|
constructor(engine: LanguageEngine<RS>) {
|
||||||
|
this._engine = engine;
|
||||||
|
this._engine.resetState();
|
||||||
|
this._result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset execution state in controller and engine.
|
||||||
|
* Clears out state from the current execution cycle.
|
||||||
|
*/
|
||||||
|
resetState() {
|
||||||
|
this._engine.resetState();
|
||||||
|
this._result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load code and user input into the engine to prepare for execution.
|
||||||
|
* @param code Code content, lines separated by `\n`
|
||||||
|
* @param input User input, lines separated by '\n'
|
||||||
|
*/
|
||||||
|
prepare(code: string, input: string) {
|
||||||
|
this._engine.prepare(code, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeAll({ interval, onResult }: ExecuteAllArgs<RS>) {
|
||||||
|
while (true) {
|
||||||
|
this._result = this._engine.executeStep();
|
||||||
|
onResult && onResult(this._result);
|
||||||
|
if (!this._result.nextStepLocation) break;
|
||||||
|
if (interval) await this.sleep(interval);
|
||||||
|
}
|
||||||
|
return this._result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sleep(millis: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, millis));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExecutionController;
|
10
engines/sample-lang/constants.ts
Normal file
10
engines/sample-lang/constants.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/** Type for state passed to renderer */
|
||||||
|
export type RS = { value: number };
|
||||||
|
|
||||||
|
/** Sample program */
|
||||||
|
export const sampleProgram = [
|
||||||
|
"ADD 10",
|
||||||
|
"SUBTRACT 4",
|
||||||
|
"MULTIPLY 3",
|
||||||
|
"DIVIDE 2",
|
||||||
|
].join("\n");
|
123
engines/sample-lang/engine.ts
Normal file
123
engines/sample-lang/engine.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { LanguageEngine, StepExecutionResult } from "../types";
|
||||||
|
import { RS } from "./constants";
|
||||||
|
|
||||||
|
// Default values for internal engine parameters
|
||||||
|
const DEFAULT_AST: ASTStep[] = [];
|
||||||
|
const DEFAULT_VALUE = 0;
|
||||||
|
const DEFAULT_PC = -1;
|
||||||
|
const DEFAULT_INPUT: number[] = [];
|
||||||
|
const DEFAULT_INPUT_PC = 0;
|
||||||
|
|
||||||
|
/** Valid op keywords */
|
||||||
|
enum OP_KEYWORD {
|
||||||
|
ADD = "ADD",
|
||||||
|
SUBTRACT = "SUBTRACT",
|
||||||
|
MULTIPLY = "MULTIPLY",
|
||||||
|
DIVIDE = "DIVIDE",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyword used as value for using user input */
|
||||||
|
const inputKeyword = "input";
|
||||||
|
|
||||||
|
type ASTStep = {
|
||||||
|
/** Line number the step is located on */
|
||||||
|
index: number;
|
||||||
|
|
||||||
|
/** Keyword and value of the step */
|
||||||
|
step: { keyword: OP_KEYWORD; value: number | typeof inputKeyword };
|
||||||
|
};
|
||||||
|
|
||||||
|
class SampleLanguageEngine implements LanguageEngine<RS> {
|
||||||
|
private _ast: ASTStep[] = DEFAULT_AST;
|
||||||
|
private _value: number = DEFAULT_VALUE;
|
||||||
|
private _pc: number = DEFAULT_PC;
|
||||||
|
private _input: number[] = DEFAULT_INPUT;
|
||||||
|
private _inputPc: number = DEFAULT_INPUT_PC;
|
||||||
|
|
||||||
|
prepare(code: string, input: string): void {
|
||||||
|
// Parse and load code
|
||||||
|
const lines = code.split("\n").map((l) => l.trim());
|
||||||
|
this._ast = lines.map((line, index) => {
|
||||||
|
const astStep = this.parseLine(line);
|
||||||
|
return { index: index + 1, step: astStep };
|
||||||
|
});
|
||||||
|
// Parse and load input
|
||||||
|
const inputWords = input.split(/\s+/); // Split on whitespace
|
||||||
|
this._input = inputWords.map((w) => parseInt(w, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
executeStep(): StepExecutionResult<RS> {
|
||||||
|
if (this._pc === -1) {
|
||||||
|
// Initial dummy step
|
||||||
|
this._pc += 1;
|
||||||
|
return {
|
||||||
|
rendererState: { value: this._value },
|
||||||
|
nextStepLocation: { line: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute step
|
||||||
|
if (this._pc !== -1) {
|
||||||
|
const step = this._ast[this._pc];
|
||||||
|
this.processOp(step.step);
|
||||||
|
}
|
||||||
|
const rendererState = { value: this._value };
|
||||||
|
|
||||||
|
// Increment pc and return
|
||||||
|
this._pc += 1;
|
||||||
|
if (this._pc >= this._ast.length) {
|
||||||
|
// Program execution is complete
|
||||||
|
return {
|
||||||
|
rendererState,
|
||||||
|
nextStepLocation: null,
|
||||||
|
output: this._value.toString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Add location of next line to be executed
|
||||||
|
const lineNum = this._ast[this._pc].index;
|
||||||
|
return { rendererState, nextStepLocation: { line: lineNum } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetState(): void {
|
||||||
|
this._ast = DEFAULT_AST;
|
||||||
|
this._pc = DEFAULT_PC;
|
||||||
|
this._value = DEFAULT_VALUE;
|
||||||
|
this._input = DEFAULT_INPUT;
|
||||||
|
this._inputPc = DEFAULT_INPUT_PC;
|
||||||
|
}
|
||||||
|
|
||||||
|
private processOp(step: ASTStep["step"]) {
|
||||||
|
// Handle user input
|
||||||
|
let value = 0;
|
||||||
|
if (step.value === "input") value = this._input[this._inputPc++];
|
||||||
|
else value = step.value;
|
||||||
|
|
||||||
|
// Modify runtime value according to instruction
|
||||||
|
if (step.keyword === OP_KEYWORD.ADD) this._value += value;
|
||||||
|
else if (step.keyword === OP_KEYWORD.SUBTRACT) this._value -= value;
|
||||||
|
else if (step.keyword === OP_KEYWORD.MULTIPLY) this._value *= value;
|
||||||
|
else if (step.keyword === OP_KEYWORD.DIVIDE) this._value /= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLine = (line: string): ASTStep["step"] => {
|
||||||
|
// Check that line has two words
|
||||||
|
const words = line.split(" ");
|
||||||
|
if (words.length !== 2) throw new Error("Invalid line");
|
||||||
|
|
||||||
|
// Check that keyword is valid
|
||||||
|
const [keyword, value] = words;
|
||||||
|
if (!(keyword in OP_KEYWORD)) throw new Error("Invalid keyword");
|
||||||
|
|
||||||
|
// Check that value is valid
|
||||||
|
const valueAsNum = parseInt(value, 10);
|
||||||
|
const isInvalidValue = value !== inputKeyword && Number.isNaN(valueAsNum);
|
||||||
|
if (isInvalidValue) throw new Error("Invalid value");
|
||||||
|
|
||||||
|
// Return as an AST step
|
||||||
|
const validValue = value === inputKeyword ? inputKeyword : valueAsNum;
|
||||||
|
return { keyword: keyword as OP_KEYWORD, value: validValue };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SampleLanguageEngine;
|
10
engines/sample-lang/index.ts
Normal file
10
engines/sample-lang/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Renderer } from "./renderer";
|
||||||
|
import { LanguageProvider } from "../types";
|
||||||
|
import { RS, sampleProgram } from "./constants";
|
||||||
|
|
||||||
|
const provider: LanguageProvider<RS> = {
|
||||||
|
Renderer,
|
||||||
|
sampleProgram,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provider;
|
24
engines/sample-lang/renderer.tsx
Normal file
24
engines/sample-lang/renderer.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { RendererProps } from "../types";
|
||||||
|
import { RS } from "./constants";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: "4em",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Renderer = ({ state }: RendererProps<RS>) => {
|
||||||
|
const value = state == null ? 0 : state.value;
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<h1 style={styles.text}>{value}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
71
engines/types.ts
Normal file
71
engines/types.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import monaco from "monaco-editor";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type alias for defining range of characters to highlight in a single line.
|
||||||
|
* - Missing `start` means highlight starting from start of the line.
|
||||||
|
* - Missing `end` means highlight ending at the end of the line.
|
||||||
|
*/
|
||||||
|
export type CharRange = { start?: number; end?: number };
|
||||||
|
|
||||||
|
/** Type denoting a range of text in document spanning within a line */
|
||||||
|
export type DocumentRange = {
|
||||||
|
line: number;
|
||||||
|
charRange?: CharRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Source code token provider for the language, specific to Monaco */
|
||||||
|
export type MonacoTokensProvider = monaco.languages.IMonarchLanguage;
|
||||||
|
|
||||||
|
/** Type alias for props passed to renderer */
|
||||||
|
export type RendererProps<RS> = { state: RS | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type alias for the result of engine executing a single step.
|
||||||
|
*/
|
||||||
|
export type StepExecutionResult<RS> = {
|
||||||
|
/** New props to be passed to the renderer */
|
||||||
|
rendererState: RS;
|
||||||
|
|
||||||
|
/** String to write to program output */
|
||||||
|
output?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to highlight next line to be executed in the editor.
|
||||||
|
* Passing `null` indicates reaching the end of program.
|
||||||
|
*/
|
||||||
|
nextStepLocation: DocumentRange | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language engine is responsible for providing
|
||||||
|
* execution and debugging API to the platform.
|
||||||
|
*/
|
||||||
|
export interface LanguageEngine<RS> {
|
||||||
|
/** Load code and user input into the engine and prepare for execution */
|
||||||
|
prepare: (code: string, input: string) => void;
|
||||||
|
|
||||||
|
/** Perform a single step of code execution */
|
||||||
|
executeStep: () => StepExecutionResult<RS>;
|
||||||
|
|
||||||
|
/** Reset all state to prepare for new cycle */
|
||||||
|
resetState: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language provider provides all language-specific
|
||||||
|
* functionality to the platform.
|
||||||
|
*/
|
||||||
|
export interface LanguageProvider<RS> {
|
||||||
|
/** Monaco-specific tokenizer for syntax highlighting */
|
||||||
|
editorTokensProvider?: MonacoTokensProvider;
|
||||||
|
|
||||||
|
/** Monaco-specific autocomplete provider */
|
||||||
|
autocompleteProvider?: any;
|
||||||
|
|
||||||
|
/** Sample code sample for the language */
|
||||||
|
sampleProgram: string;
|
||||||
|
|
||||||
|
/** React component for visualising runtime state */
|
||||||
|
Renderer: React.FC<RendererProps<RS>>;
|
||||||
|
}
|
23
engines/worker-constants.ts
Normal file
23
engines/worker-constants.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { StepExecutionResult } from "./types";
|
||||||
|
|
||||||
|
export type WorkerRequestData =
|
||||||
|
| {
|
||||||
|
type: "Init";
|
||||||
|
params?: null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "Reset";
|
||||||
|
params?: null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "Prepare";
|
||||||
|
params: { code: string; input: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "Execute";
|
||||||
|
params: { interval?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerResponseData<RS> =
|
||||||
|
| { type: "state"; data: "empty" | "ready" }
|
||||||
|
| { type: "result"; data: StepExecutionResult<RS> };
|
71
engines/worker.ts
Normal file
71
engines/worker.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import BrainfuckLanguageEngine from "./brainfuck/engine";
|
||||||
|
import ExecutionController from "./execution-controller";
|
||||||
|
import SampleLanguageEngine from "./sample-lang/engine";
|
||||||
|
import { StepExecutionResult } from "./types";
|
||||||
|
import { WorkerRequestData, WorkerResponseData } from "./worker-constants";
|
||||||
|
|
||||||
|
let _controller: ExecutionController<any> | null = null;
|
||||||
|
|
||||||
|
/** Create a worker response for state update */
|
||||||
|
const stateMessage = <RS>(
|
||||||
|
state: "empty" | "ready"
|
||||||
|
): WorkerResponseData<RS> => ({
|
||||||
|
type: "state",
|
||||||
|
data: state,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Create a worker response for execution result */
|
||||||
|
const resultMessage = <RS>(
|
||||||
|
result: StepExecutionResult<RS>
|
||||||
|
): WorkerResponseData<RS> => ({
|
||||||
|
type: "result",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the execution controller.
|
||||||
|
*/
|
||||||
|
const initController = () => {
|
||||||
|
// const engine = new SampleLanguageEngine();
|
||||||
|
const engine = new BrainfuckLanguageEngine();
|
||||||
|
_controller = new ExecutionController(engine);
|
||||||
|
postMessage(stateMessage("empty"));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the state of the controller and engine, to
|
||||||
|
* prepare for execution of a new program.
|
||||||
|
*/
|
||||||
|
const resetController = () => {
|
||||||
|
_controller!.resetState();
|
||||||
|
postMessage(stateMessage("empty"));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load program code into the engine.
|
||||||
|
* @param code Code content of the program
|
||||||
|
*/
|
||||||
|
const prepare = ({ code, input }: { code: string; input: string }) => {
|
||||||
|
_controller!.prepare(code, input);
|
||||||
|
postMessage(stateMessage("ready"));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the entire program loaded on engine,
|
||||||
|
* and return result of execution.
|
||||||
|
*/
|
||||||
|
const execute = (interval?: number) => {
|
||||||
|
console.info(`Executing at interval ${interval}`);
|
||||||
|
_controller!.executeAll({
|
||||||
|
interval,
|
||||||
|
onResult: (res) => postMessage(resultMessage(res)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListener("message", (ev: MessageEvent<WorkerRequestData>) => {
|
||||||
|
if (ev.data.type === "Init") return initController();
|
||||||
|
if (ev.data.type === "Reset") return resetController();
|
||||||
|
if (ev.data.type === "Prepare") return prepare(ev.data.params);
|
||||||
|
if (ev.data.type === "Execute") return execute(ev.data.params.interval);
|
||||||
|
throw new Error("Invalid worker message type");
|
||||||
|
});
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"packages": [
|
|
||||||
"packages/*"
|
|
||||||
],
|
|
||||||
"version": "0.0.0"
|
|
||||||
}
|
|
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
4
next.config.js
Normal file
4
next.config.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
module.exports = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
25
package.json
25
package.json
@ -1,9 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "esolang-park",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@blueprintjs/core": "^3.51.3",
|
||||||
|
"@monaco-editor/react": "^4.3.1",
|
||||||
|
"monaco-editor": "^0.30.1",
|
||||||
|
"next": "12.0.7",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
|
"react-mosaic-component": "^5.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^14.14.41",
|
"@types/node": "16.11.11",
|
||||||
"lerna": "^4.0.0",
|
"@types/react": "17.0.37",
|
||||||
"typescript": "^4.2.4"
|
"eslint": "8.4.0",
|
||||||
|
"eslint-config-next": "12.0.7",
|
||||||
|
"typescript": "4.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12
pages/_app.tsx
Normal file
12
pages/_app.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import "../styles/globals.css";
|
||||||
|
import "../styles/editor.css";
|
||||||
|
import "@blueprintjs/core/lib/css/blueprint.css";
|
||||||
|
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||||
|
import "react-mosaic-component/react-mosaic-component.css";
|
||||||
|
import type { AppProps } from "next/app";
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
|
return <Component {...pageProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp;
|
17
pages/index.tsx
Normal file
17
pages/index.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { Mainframe } from "../ui/Mainframe";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
const Index: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Esolang Park</title>
|
||||||
|
</Head>
|
||||||
|
<Mainframe />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
4
public/vercel.svg
Normal file
4
public/vercel.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
12
styles/editor.css
Normal file
12
styles/editor.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.code-highlight {
|
||||||
|
background-color: #ffff0077;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakpoint-glyph {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4%;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 10px;
|
||||||
|
background-color: #ff5555;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
23
styles/globals.css
Normal file
23
styles/globals.css
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
@import "@blueprintjs/core/lib/css/blueprint.css";
|
||||||
|
@import "@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||||
|
@import "react-mosaic-component/react-mosaic-component.css";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__next {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
58
ui/MainLayout.tsx
Normal file
58
ui/MainLayout.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Mosaic, MosaicNode, MosaicWindow } from "react-mosaic-component";
|
||||||
|
|
||||||
|
// IDs of windows in the mosaic layout
|
||||||
|
type WINDOW_ID = "editor" | "renderer" | "input" | "output";
|
||||||
|
|
||||||
|
const WindowTitles = {
|
||||||
|
editor: "Code Editor",
|
||||||
|
renderer: "Visualization",
|
||||||
|
input: "User Input",
|
||||||
|
output: "Execution Output",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
renderEditor: () => React.ReactNode;
|
||||||
|
renderRenderer: () => React.ReactNode;
|
||||||
|
renderInput: () => React.ReactNode;
|
||||||
|
renderOutput: () => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainLayout = (props: Props) => {
|
||||||
|
const MOSAIC_MAP = {
|
||||||
|
editor: props.renderEditor,
|
||||||
|
renderer: props.renderRenderer,
|
||||||
|
input: props.renderInput,
|
||||||
|
output: props.renderOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_LAYOUT: MosaicNode<WINDOW_ID> = {
|
||||||
|
direction: "row",
|
||||||
|
first: "editor",
|
||||||
|
second: {
|
||||||
|
direction: "column",
|
||||||
|
first: "renderer",
|
||||||
|
second: {
|
||||||
|
direction: "row",
|
||||||
|
first: "input",
|
||||||
|
second: "output",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Mosaic<keyof typeof MOSAIC_MAP>
|
||||||
|
className="mosaic-blueprint-theme bp3-dark"
|
||||||
|
initialValue={INITIAL_LAYOUT}
|
||||||
|
renderTile={(windowId, path) => (
|
||||||
|
<MosaicWindow<number>
|
||||||
|
path={path}
|
||||||
|
title={WindowTitles[windowId]}
|
||||||
|
toolbarControls={<span />}
|
||||||
|
>
|
||||||
|
{MOSAIC_MAP[windowId]()}
|
||||||
|
</MosaicWindow>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
78
ui/Mainframe.tsx
Normal file
78
ui/Mainframe.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CodeEditor, CodeEditorRef } from "../ui/code-editor";
|
||||||
|
import { InputEditor, InputEditorRef } from "../ui/input-editor";
|
||||||
|
import { MainLayout } from "../ui/MainLayout";
|
||||||
|
import { useExecController } from "../ui/use-exec-controller";
|
||||||
|
import { DocumentRange, LanguageProvider } from "../engines/types";
|
||||||
|
import SampleLangProvider from "../engines/sample-lang";
|
||||||
|
import BrainfuckProvider from "../engines/brainfuck";
|
||||||
|
import { OutputViewer } from "../ui/output-viewer";
|
||||||
|
|
||||||
|
export const Mainframe = () => {
|
||||||
|
const codeEditorRef = React.useRef<CodeEditorRef>(null);
|
||||||
|
const inputEditorRef = React.useRef<InputEditorRef>(null);
|
||||||
|
// const providerRef = React.useRef<LanguageProvider<any>>(SampleLangProvider);
|
||||||
|
const providerRef = React.useRef<LanguageProvider<any>>(BrainfuckProvider);
|
||||||
|
const execController = useExecController();
|
||||||
|
|
||||||
|
// UI states used in execution time
|
||||||
|
const [rendererState, setRendererState] = React.useState<any>(null);
|
||||||
|
const [output, setOutput] = React.useState<string | null>(null);
|
||||||
|
const [codeHighlights, setCodeHighlights] = React.useState<
|
||||||
|
DocumentRange | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const testDrive = React.useCallback(async () => {
|
||||||
|
console.info("=== RUNNING TEST DRIVE ===");
|
||||||
|
|
||||||
|
// Check that controller is ready to execute
|
||||||
|
const readyStates = ["empty", "ready", "done"];
|
||||||
|
if (!readyStates.includes(execController.state)) {
|
||||||
|
console.error(`Controller not ready: state is ${execController.state}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare for execution
|
||||||
|
setOutput("");
|
||||||
|
await execController.resetState();
|
||||||
|
await execController.prepare(
|
||||||
|
codeEditorRef.current!.getValue(),
|
||||||
|
inputEditorRef.current!.getValue()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Begin execution
|
||||||
|
await execController.executeAll((result) => {
|
||||||
|
setRendererState(result.rendererState);
|
||||||
|
setCodeHighlights(result.nextStepLocation || undefined);
|
||||||
|
setOutput((o) => (o || "") + (result.output || ""));
|
||||||
|
}, 20);
|
||||||
|
}, [execController.state]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = (ev: KeyboardEvent) => {
|
||||||
|
if (!(ev.ctrlKey && ev.code === "KeyY")) return;
|
||||||
|
testDrive();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [testDrive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout
|
||||||
|
renderEditor={() => (
|
||||||
|
<CodeEditor
|
||||||
|
ref={codeEditorRef}
|
||||||
|
languageId="brainfuck"
|
||||||
|
highlights={codeHighlights}
|
||||||
|
defaultValue={providerRef.current.sampleProgram}
|
||||||
|
tokensProvider={providerRef.current.editorTokensProvider}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderRenderer={() => (
|
||||||
|
<providerRef.current.Renderer state={rendererState} />
|
||||||
|
)}
|
||||||
|
renderInput={() => <InputEditor ref={inputEditorRef} />}
|
||||||
|
renderOutput={() => <OutputViewer value={output} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
85
ui/code-editor/index.tsx
Normal file
85
ui/code-editor/index.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||||
|
import monaco from "monaco-editor";
|
||||||
|
import { DocumentRange, MonacoTokensProvider } from "../../engines/types";
|
||||||
|
import { useEditorConfig } from "./use-editor-config";
|
||||||
|
|
||||||
|
// Type aliases for the Monaco editor
|
||||||
|
type EditorInstance = monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
/** Create Monaco decoration range object from highlights */
|
||||||
|
const createRange = (
|
||||||
|
monacoInstance: typeof monaco,
|
||||||
|
highlights: DocumentRange
|
||||||
|
) => {
|
||||||
|
const lineNum = highlights.line;
|
||||||
|
const startChar = highlights.charRange?.start || 0;
|
||||||
|
const endChar = highlights.charRange?.end || 1000;
|
||||||
|
const range = new monacoInstance.Range(lineNum, startChar, lineNum, endChar);
|
||||||
|
const isWholeLine = !highlights.charRange;
|
||||||
|
return { range, options: { isWholeLine, inlineClassName: "code-highlight" } };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface for interacting with the editor
|
||||||
|
export interface CodeEditorRef {
|
||||||
|
/**
|
||||||
|
* Get the current text content of the editor.
|
||||||
|
*/
|
||||||
|
getValue: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** ID of the active language */
|
||||||
|
languageId: string;
|
||||||
|
/** Default code to display in editor */
|
||||||
|
defaultValue: string;
|
||||||
|
/** Code range to highlight in the editor */
|
||||||
|
highlights?: DocumentRange;
|
||||||
|
/** Tokens provider for the language */
|
||||||
|
tokensProvider?: MonacoTokensProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around the Monaco editor that reveals
|
||||||
|
* only the required functionality to the parent container.
|
||||||
|
*/
|
||||||
|
const CodeEditorComponent = (props: Props, ref: React.Ref<CodeEditorRef>) => {
|
||||||
|
const editorRef = React.useRef<EditorInstance | null>(null);
|
||||||
|
const monacoInstance = useMonaco();
|
||||||
|
const { highlights } = props;
|
||||||
|
useEditorConfig({
|
||||||
|
languageId: props.languageId,
|
||||||
|
tokensProvider: props.tokensProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change editor highlights when prop changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!editorRef.current || !highlights) return;
|
||||||
|
const range = createRange(monacoInstance!, highlights);
|
||||||
|
const decors = editorRef.current!.deltaDecorations([], [range]);
|
||||||
|
return () => {
|
||||||
|
editorRef.current!.deltaDecorations(decors, []);
|
||||||
|
};
|
||||||
|
}, [highlights]);
|
||||||
|
|
||||||
|
// Provide handle to parent for accessing editor contents
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
getValue: () => editorRef.current!.getValue(),
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
theme="vs-dark"
|
||||||
|
defaultLanguage="brainfuck"
|
||||||
|
defaultValue={props.defaultValue}
|
||||||
|
onMount={(editor) => (editorRef.current = editor)}
|
||||||
|
options={{ minimap: { enabled: false } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeEditor = React.forwardRef(CodeEditorComponent);
|
28
ui/code-editor/use-editor-config.ts
Normal file
28
ui/code-editor/use-editor-config.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useMonaco } from "@monaco-editor/react";
|
||||||
|
import { MonacoTokensProvider } from "../../engines/types";
|
||||||
|
|
||||||
|
type ConfigParams = {
|
||||||
|
languageId: string;
|
||||||
|
tokensProvider?: MonacoTokensProvider;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add custom language and relevant providers to Monaco */
|
||||||
|
export const useEditorConfig = (params: ConfigParams) => {
|
||||||
|
const monaco = useMonaco();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!monaco) return;
|
||||||
|
|
||||||
|
// Register language
|
||||||
|
monaco.languages.register({ id: params.languageId });
|
||||||
|
|
||||||
|
// If provided, register token provider for language
|
||||||
|
if (params.tokensProvider) {
|
||||||
|
monaco.languages.setMonarchTokensProvider(
|
||||||
|
params.languageId,
|
||||||
|
params.tokensProvider
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [monaco]);
|
||||||
|
};
|
38
ui/input-editor.tsx
Normal file
38
ui/input-editor.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TextArea } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
// Interface for interacting with the editor
|
||||||
|
export interface InputEditorRef {
|
||||||
|
/**
|
||||||
|
* Get the current text content of the editor.
|
||||||
|
*/
|
||||||
|
getValue: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A very simple text editor for user input
|
||||||
|
*/
|
||||||
|
const InputEditorComponent = (_: {}, ref: React.Ref<InputEditorRef>) => {
|
||||||
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
getValue: () => textareaRef.current!.value,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
fill
|
||||||
|
large
|
||||||
|
growVertically
|
||||||
|
inputRef={textareaRef}
|
||||||
|
placeholder="Enter program input here..."
|
||||||
|
style={{ height: "100%", resize: "none", boxShadow: "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InputEditor = React.forwardRef(InputEditorComponent);
|
33
ui/output-viewer.tsx
Normal file
33
ui/output-viewer.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { TextArea } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For aesthetic reasons, we use readonly textarea for displaying output.
|
||||||
|
* Textarea displays placeholder if value passed is empty string, which is undesired.
|
||||||
|
* This function is a fake-whitespace workaround.
|
||||||
|
*
|
||||||
|
* @param value Value received from parent. Placeholder shown on `null`.
|
||||||
|
* @returns Value to pass as prop to Blueprint TextArea
|
||||||
|
*/
|
||||||
|
const toTextareaValue = (value: string | null): string | undefined => {
|
||||||
|
if (value == null) return undefined; // Placeholder shown
|
||||||
|
if (value === "") return "\u0020"; // Fake whitespace to hide placeholder
|
||||||
|
return value; // Non-empty output value
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OutputViewer = ({ value }: Props) => {
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
fill
|
||||||
|
large
|
||||||
|
readOnly
|
||||||
|
growVertically
|
||||||
|
value={toTextareaValue(value)}
|
||||||
|
placeholder="Run code to see output..."
|
||||||
|
style={{ height: "100%", resize: "none", boxShadow: "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
135
ui/use-exec-controller.ts
Normal file
135
ui/use-exec-controller.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { StepExecutionResult } from "../engines/types";
|
||||||
|
import {
|
||||||
|
WorkerRequestData,
|
||||||
|
WorkerResponseData,
|
||||||
|
} from "../engines/worker-constants";
|
||||||
|
|
||||||
|
/** Possible states for the worker to be in */
|
||||||
|
type WorkerState =
|
||||||
|
| "loading" // Worker is not initialized yet
|
||||||
|
| "empty" // Worker loaded, no code loaded yet
|
||||||
|
| "ready" // Code loaded, ready to execute
|
||||||
|
| "processing" // Executing code
|
||||||
|
| "done"; // Program ended, reset now
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Hook that manages initialization, communication and
|
||||||
|
* cleanup for the worker thread used for code execution.
|
||||||
|
*
|
||||||
|
* Also abstracts away the details of message-passing and exposes
|
||||||
|
* an imperative API to the parent component.
|
||||||
|
*/
|
||||||
|
export const useExecController = <RS>() => {
|
||||||
|
const workerRef = React.useRef<Worker | null>(null);
|
||||||
|
const [workerState, setWorkerState] = React.useState<WorkerState>("loading");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semi-typesafe wrapper to abstract request-response cycle into
|
||||||
|
* a simple imperative asynchronous call. Returns Promise that resolves
|
||||||
|
* with response data.
|
||||||
|
*
|
||||||
|
* Note that if the worker misbehaves due to any reason, the returned response data
|
||||||
|
* (or `onData` argument) may not correspond to the request. Check this in the caller.
|
||||||
|
*
|
||||||
|
* @param request Data to send in request
|
||||||
|
* @param onData Optional argument - if passed, function enters response-streaming mode.
|
||||||
|
* Callback called with response data. Return `true` to keep the connection alive, `false` to end.
|
||||||
|
* On end, promise resolves with last (already used) response data.
|
||||||
|
*/
|
||||||
|
const requestWorker = (
|
||||||
|
request: WorkerRequestData,
|
||||||
|
onData?: (data: WorkerResponseData<RS>) => boolean
|
||||||
|
): Promise<WorkerResponseData<RS>> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (ev: MessageEvent<WorkerResponseData<RS>>) => {
|
||||||
|
if (!onData) {
|
||||||
|
// Normal mode
|
||||||
|
workerRef.current!.removeEventListener("message", handler);
|
||||||
|
resolve(ev.data);
|
||||||
|
} else {
|
||||||
|
// Persistent connection mode
|
||||||
|
const keepAlive = onData(ev.data);
|
||||||
|
if (keepAlive) return;
|
||||||
|
// keepAlive is false: terminate connection
|
||||||
|
workerRef.current!.removeEventListener("message", handler);
|
||||||
|
resolve(ev.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
workerRef.current!.addEventListener("message", handler);
|
||||||
|
workerRef.current!.postMessage(request);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialization and cleanup of web worker
|
||||||
|
React.useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (workerRef.current) throw new Error("Tried to reinitialize worker");
|
||||||
|
workerRef.current = new Worker(
|
||||||
|
new URL("../engines/worker.ts", import.meta.url)
|
||||||
|
);
|
||||||
|
const resp = await requestWorker({ type: "Init" });
|
||||||
|
if (resp.type === "state" && resp.data === "empty")
|
||||||
|
setWorkerState("empty");
|
||||||
|
else throw new Error(`Unexpected response on init: ${resp}`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Terminate worker and clean up
|
||||||
|
workerRef.current!.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load code and user input into the execution controller.
|
||||||
|
* @param code Code content
|
||||||
|
* @param input User input
|
||||||
|
*/
|
||||||
|
const prepare = React.useCallback(async (code: string, input: string) => {
|
||||||
|
const res = await requestWorker({
|
||||||
|
type: "Prepare",
|
||||||
|
params: { code, input },
|
||||||
|
});
|
||||||
|
if (res.type === "state" && res.data === "ready") setWorkerState("ready");
|
||||||
|
else throw new Error(`Unexpected response on loadCode: ${res.toString()}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the state of the controller and engine.
|
||||||
|
*/
|
||||||
|
const resetState = React.useCallback(async () => {
|
||||||
|
const res = await requestWorker({ type: "Reset" });
|
||||||
|
if (res.type === "state" && res.data === "empty") setWorkerState("empty");
|
||||||
|
else
|
||||||
|
throw new Error(`Unexpected response on resetState: ${res.toString()}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the code loaded into the engine
|
||||||
|
* @param onResult Callback used when an execution result is received
|
||||||
|
*/
|
||||||
|
const executeAll = React.useCallback(
|
||||||
|
async (
|
||||||
|
onResult: (result: StepExecutionResult<RS>) => void,
|
||||||
|
interval?: number
|
||||||
|
) => {
|
||||||
|
setWorkerState("processing");
|
||||||
|
// Set up a streaming-response cycle with the worker
|
||||||
|
await requestWorker({ type: "Execute", params: { interval } }, (res) => {
|
||||||
|
if (res.type !== "result") return false; // TODO: Throw error here
|
||||||
|
onResult(res.data);
|
||||||
|
if (res.data.nextStepLocation) return true;
|
||||||
|
// Clean up and terminate response stream
|
||||||
|
setWorkerState("done");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() => ({ state: workerState, resetState, prepare, executeAll }),
|
||||||
|
[workerState, resetState, prepare, executeAll]
|
||||||
|
);
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user