From 01ba292b9f1d9f93239063523a04afda500161d6 Mon Sep 17 00:00:00 2001 From: Nilay Majorwar Date: Tue, 14 Dec 2021 21:58:13 +0530 Subject: [PATCH] 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 --- .eslintrc.json | 3 + .gitignore | 41 +- README.md | 34 + engines/brainfuck/README.md | 21 + engines/brainfuck/constants.ts | 81 + engines/brainfuck/engine.ts | 181 + engines/brainfuck/index.ts | 11 + engines/brainfuck/renderer.tsx | 60 + engines/execution-controller.ts | 60 + engines/sample-lang/constants.ts | 10 + engines/sample-lang/engine.ts | 123 + engines/sample-lang/index.ts | 10 + engines/sample-lang/renderer.tsx | 24 + engines/types.ts | 71 + engines/worker-constants.ts | 23 + engines/worker.ts | 71 + lerna.json | 6 - next-env.d.ts | 5 + next.config.js | 4 + package.json | 25 +- pages/_app.tsx | 12 + pages/index.tsx | 17 + public/favicon.ico | Bin 0 -> 25931 bytes public/vercel.svg | 4 + styles/editor.css | 12 + styles/globals.css | 23 + tsconfig.json | 20 + ui/MainLayout.tsx | 58 + ui/Mainframe.tsx | 78 + ui/code-editor/index.tsx | 85 + ui/code-editor/use-editor-config.ts | 28 + ui/input-editor.tsx | 38 + ui/output-viewer.tsx | 33 + ui/use-exec-controller.ts | 135 + yarn.lock | 5654 ++++++++++----------------- 35 files changed, 3532 insertions(+), 3529 deletions(-) create mode 100644 .eslintrc.json create mode 100644 README.md create mode 100644 engines/brainfuck/README.md create mode 100644 engines/brainfuck/constants.ts create mode 100644 engines/brainfuck/engine.ts create mode 100644 engines/brainfuck/index.ts create mode 100644 engines/brainfuck/renderer.tsx create mode 100644 engines/execution-controller.ts create mode 100644 engines/sample-lang/constants.ts create mode 100644 engines/sample-lang/engine.ts create mode 100644 engines/sample-lang/index.ts create mode 100644 engines/sample-lang/renderer.tsx create mode 100644 engines/types.ts create mode 100644 engines/worker-constants.ts create mode 100644 engines/worker.ts delete mode 100644 lerna.json create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 pages/_app.tsx create mode 100644 pages/index.tsx create mode 100644 public/favicon.ico create mode 100644 public/vercel.svg create mode 100644 styles/editor.css create mode 100644 styles/globals.css create mode 100644 tsconfig.json create mode 100644 ui/MainLayout.tsx create mode 100644 ui/Mainframe.tsx create mode 100644 ui/code-editor/index.tsx create mode 100644 ui/code-editor/use-editor-config.ts create mode 100644 ui/input-editor.tsx create mode 100644 ui/output-viewer.tsx create mode 100644 ui/use-exec-controller.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore index 7fe1af9..88b6f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,37 @@ -node_modules/ -lerna-debug.log -npm-debug.log -packages/*/lib +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c87e042 --- /dev/null +++ b/README.md @@ -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. diff --git a/engines/brainfuck/README.md b/engines/brainfuck/README.md new file mode 100644 index 0000000..6dfd37a --- /dev/null +++ b/engines/brainfuck/README.md @@ -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`. diff --git a/engines/brainfuck/constants.ts b/engines/brainfuck/constants.ts new file mode 100644 index 0000000..3e62507 --- /dev/null +++ b/engines/brainfuck/constants.ts @@ -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", +}; diff --git a/engines/brainfuck/engine.ts b/engines/brainfuck/engine.ts new file mode 100644 index 0000000..8158f94 --- /dev/null +++ b/engines/brainfuck/engine.ts @@ -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 { + 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 { + // 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; diff --git a/engines/brainfuck/index.ts b/engines/brainfuck/index.ts new file mode 100644 index 0000000..afd067d --- /dev/null +++ b/engines/brainfuck/index.ts @@ -0,0 +1,11 @@ +import { Renderer } from "./renderer"; +import { LanguageProvider } from "../types"; +import { BFRS, sampleProgram, editorTokensProvider } from "./constants"; + +const provider: LanguageProvider = { + Renderer, + sampleProgram, + editorTokensProvider, +}; + +export default provider; diff --git a/engines/brainfuck/renderer.tsx b/engines/brainfuck/renderer.tsx new file mode 100644 index 0000000..e57a157 --- /dev/null +++ b/engines/brainfuck/renderer.tsx @@ -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
{value}
; +}; + +/** Renderer for Brainfuck */ +export const Renderer = ({ state }: RendererProps) => { + /** 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 ( +
+ {serializeTapeObj(state?.tape || {}).map((num, i) => ( + + ))} +
+ ); +}; diff --git a/engines/execution-controller.ts b/engines/execution-controller.ts new file mode 100644 index 0000000..ea1ecee --- /dev/null +++ b/engines/execution-controller.ts @@ -0,0 +1,60 @@ +import { LanguageEngine, StepExecutionResult } from "./types"; + +type ExecuteAllArgs = { + /** 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) => void; +}; + +class ExecutionController { + private _engine: LanguageEngine; + private _result: StepExecutionResult | null; + + /** + * Create a new ExecutionController. + * @param engine Language engine to use for execution + */ + constructor(engine: LanguageEngine) { + 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) { + 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((resolve) => setTimeout(resolve, millis)); + } +} + +export default ExecutionController; diff --git a/engines/sample-lang/constants.ts b/engines/sample-lang/constants.ts new file mode 100644 index 0000000..54f3c75 --- /dev/null +++ b/engines/sample-lang/constants.ts @@ -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"); diff --git a/engines/sample-lang/engine.ts b/engines/sample-lang/engine.ts new file mode 100644 index 0000000..15fd8e2 --- /dev/null +++ b/engines/sample-lang/engine.ts @@ -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 { + 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 { + 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; diff --git a/engines/sample-lang/index.ts b/engines/sample-lang/index.ts new file mode 100644 index 0000000..365174f --- /dev/null +++ b/engines/sample-lang/index.ts @@ -0,0 +1,10 @@ +import { Renderer } from "./renderer"; +import { LanguageProvider } from "../types"; +import { RS, sampleProgram } from "./constants"; + +const provider: LanguageProvider = { + Renderer, + sampleProgram, +}; + +export default provider; diff --git a/engines/sample-lang/renderer.tsx b/engines/sample-lang/renderer.tsx new file mode 100644 index 0000000..6ca6fe4 --- /dev/null +++ b/engines/sample-lang/renderer.tsx @@ -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) => { + const value = state == null ? 0 : state.value; + return ( +
+

{value}

+
+ ); +}; diff --git a/engines/types.ts b/engines/types.ts new file mode 100644 index 0000000..57d00c4 --- /dev/null +++ b/engines/types.ts @@ -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 = { state: RS | null }; + +/** + * Type alias for the result of engine executing a single step. + */ +export type StepExecutionResult = { + /** 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 { + /** 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; + + /** Reset all state to prepare for new cycle */ + resetState: () => void; +} + +/** + * Language provider provides all language-specific + * functionality to the platform. + */ +export interface LanguageProvider { + /** 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>; +} diff --git a/engines/worker-constants.ts b/engines/worker-constants.ts new file mode 100644 index 0000000..2e2f27c --- /dev/null +++ b/engines/worker-constants.ts @@ -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 = + | { type: "state"; data: "empty" | "ready" } + | { type: "result"; data: StepExecutionResult }; diff --git a/engines/worker.ts b/engines/worker.ts new file mode 100644 index 0000000..215d503 --- /dev/null +++ b/engines/worker.ts @@ -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 | null = null; + +/** Create a worker response for state update */ +const stateMessage = ( + state: "empty" | "ready" +): WorkerResponseData => ({ + type: "state", + data: state, +}); + +/** Create a worker response for execution result */ +const resultMessage = ( + result: StepExecutionResult +): WorkerResponseData => ({ + 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) => { + 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"); +}); diff --git a/lerna.json b/lerna.json deleted file mode 100644 index d6707ca..0000000 --- a/lerna.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "packages": [ - "packages/*" - ], - "version": "0.0.0" -} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..3dd7ef1 --- /dev/null +++ b/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + reactStrictMode: true, +}; diff --git a/package.json b/package.json index 5406974..764b202 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,26 @@ { - "name": "root", + "name": "esolang-park", "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": { - "@types/node": "^14.14.41", - "lerna": "^4.0.0", - "typescript": "^4.2.4" + "@types/node": "16.11.11", + "@types/react": "17.0.37", + "eslint": "8.4.0", + "eslint-config-next": "12.0.7", + "typescript": "4.5.2" } } diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 0000000..aeff5dd --- /dev/null +++ b/pages/_app.tsx @@ -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 ; +} + +export default MyApp; diff --git a/pages/index.tsx b/pages/index.tsx new file mode 100644 index 0000000..4e43e31 --- /dev/null +++ b/pages/index.tsx @@ -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 ( + <> + + Esolang Park + + + + ); +}; + +export default Index; diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..fbf0e25 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/styles/editor.css b/styles/editor.css new file mode 100644 index 0000000..2971f37 --- /dev/null +++ b/styles/editor.css @@ -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; +} diff --git a/styles/globals.css b/styles/globals.css new file mode 100644 index 0000000..8e1015f --- /dev/null +++ b/styles/globals.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..99710e8 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/ui/MainLayout.tsx b/ui/MainLayout.tsx new file mode 100644 index 0000000..161ffe5 --- /dev/null +++ b/ui/MainLayout.tsx @@ -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 = { + direction: "row", + first: "editor", + second: { + direction: "column", + first: "renderer", + second: { + direction: "row", + first: "input", + second: "output", + }, + }, + }; + + return ( + + className="mosaic-blueprint-theme bp3-dark" + initialValue={INITIAL_LAYOUT} + renderTile={(windowId, path) => ( + + path={path} + title={WindowTitles[windowId]} + toolbarControls={} + > + {MOSAIC_MAP[windowId]()} + + )} + /> + ); +}; diff --git a/ui/Mainframe.tsx b/ui/Mainframe.tsx new file mode 100644 index 0000000..de331c2 --- /dev/null +++ b/ui/Mainframe.tsx @@ -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(null); + const inputEditorRef = React.useRef(null); + // const providerRef = React.useRef>(SampleLangProvider); + const providerRef = React.useRef>(BrainfuckProvider); + const execController = useExecController(); + + // UI states used in execution time + const [rendererState, setRendererState] = React.useState(null); + const [output, setOutput] = React.useState(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 ( + ( + + )} + renderRenderer={() => ( + + )} + renderInput={() => } + renderOutput={() => } + /> + ); +}; diff --git a/ui/code-editor/index.tsx b/ui/code-editor/index.tsx new file mode 100644 index 0000000..b04200c --- /dev/null +++ b/ui/code-editor/index.tsx @@ -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) => { + const editorRef = React.useRef(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 ( + (editorRef.current = editor)} + options={{ minimap: { enabled: false } }} + /> + ); +}; + +export const CodeEditor = React.forwardRef(CodeEditorComponent); diff --git a/ui/code-editor/use-editor-config.ts b/ui/code-editor/use-editor-config.ts new file mode 100644 index 0000000..ca5277b --- /dev/null +++ b/ui/code-editor/use-editor-config.ts @@ -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]); +}; diff --git a/ui/input-editor.tsx b/ui/input-editor.tsx new file mode 100644 index 0000000..63713d6 --- /dev/null +++ b/ui/input-editor.tsx @@ -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) => { + const textareaRef = React.useRef(null); + + React.useImperativeHandle( + ref, + () => ({ + getValue: () => textareaRef.current!.value, + }), + [] + ); + + return ( +