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 0000000..718d6fe Binary files /dev/null and b/public/favicon.ico differ 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 ( +