From 94dce5bfa9cd9f5e12dfae16106a7c6f80ccb5cd Mon Sep 17 00:00:00 2001
From: Nilay Majorwar <nilaymajorwar@gmail.com>
Date: Sat, 22 Jan 2022 21:22:38 +0530
Subject: [PATCH] Add automatic syntax checker, fix editor bugs

---
 engines/brainfuck/runtime.ts         |  4 +++
 engines/chef/runtime/index.ts        |  4 +++
 engines/deadfish/runtime.ts          |  4 +++
 engines/execution-controller.ts      | 15 +++++++++
 engines/setup-worker.ts              | 16 ++++++++++
 engines/types.ts                     |  3 ++
 engines/worker-constants.ts          | 11 ++++++-
 ui/Mainframe.tsx                     |  1 +
 ui/code-editor/index.tsx             | 35 ++++++++++++++-------
 ui/code-editor/monaco-utils.ts       | 19 +++++++++++
 ui/code-editor/use-code-validator.ts | 47 ++++++++++++++++++++++++++++
 ui/use-exec-controller.ts            | 19 +++++++++--
 12 files changed, 163 insertions(+), 15 deletions(-)
 create mode 100644 ui/code-editor/use-code-validator.ts

diff --git a/engines/brainfuck/runtime.ts b/engines/brainfuck/runtime.ts
index c107bc8..21fba5f 100644
--- a/engines/brainfuck/runtime.ts
+++ b/engines/brainfuck/runtime.ts
@@ -28,6 +28,10 @@ export default class BrainfuckLanguageEngine implements LanguageEngine<BFRS> {
     this._pc = DEFAULT_PC;
   }
 
+  validateCode(code: string) {
+    this.parseCode(code);
+  }
+
   prepare(code: string, input: string) {
     this._input = input;
     this._ast = this.parseCode(code);
diff --git a/engines/chef/runtime/index.ts b/engines/chef/runtime/index.ts
index ab8f2dd..7b09db0 100644
--- a/engines/chef/runtime/index.ts
+++ b/engines/chef/runtime/index.ts
@@ -36,6 +36,10 @@ export default class ChefLanguageEngine implements LanguageEngine<T.ChefRS> {
     this._input = DEFAULT_INPUT();
   }
 
+  validateCode(code: string) {
+    parseProgram(code);
+  }
+
   prepare(code: string, input: string) {
     this._ast = parseProgram(code);
     this._input = new InputStream(input);
diff --git a/engines/deadfish/runtime.ts b/engines/deadfish/runtime.ts
index f53abd8..eadc44f 100644
--- a/engines/deadfish/runtime.ts
+++ b/engines/deadfish/runtime.ts
@@ -21,6 +21,10 @@ export default class DeadfishLanguageEngine implements LanguageEngine<DFRS> {
     this._pc = DEFAULT_PC;
   }
 
+  validateCode(code: string) {
+    this.parseCode(code);
+  }
+
   prepare(code: string, _input: string) {
     this._ast = this.parseCode(code);
   }
diff --git a/engines/execution-controller.ts b/engines/execution-controller.ts
index 4b21043..9936c30 100644
--- a/engines/execution-controller.ts
+++ b/engines/execution-controller.ts
@@ -1,6 +1,8 @@
 import { LanguageEngine, StepExecutionResult } from "./types";
 import {
+  isParseError,
   isRuntimeError,
+  serializeParseError,
   serializeRuntimeError,
   WorkerRuntimeError,
 } from "./worker-errors";
@@ -58,6 +60,19 @@ class ExecutionController<RS> {
     this._breakpoints = points;
   }
 
+  /**
+   * Validate the syntax of the given code
+   * @param code Code content, lines separated by '\n'
+   */
+  validateCode(code: string) {
+    try {
+      this._engine.validateCode(code);
+    } catch (error) {
+      if (isParseError(error)) return serializeParseError(error);
+      else throw error;
+    }
+  }
+
   /**
    * Pause the ongoing execution.
    * - If already paused, returns immediately
diff --git a/engines/setup-worker.ts b/engines/setup-worker.ts
index 5da4455..46c5432 100644
--- a/engines/setup-worker.ts
+++ b/engines/setup-worker.ts
@@ -13,6 +13,11 @@ const ackMessage = <RS, A extends C.WorkerAckType>(
   error,
 });
 
+/** Create a worker response for code validation result */
+const validationMessage = <RS, A extends C.WorkerAckType>(
+  error?: E.WorkerParseError
+): C.WorkerResponseData<RS, A> => ({ type: "validate", error });
+
 /** Create a worker response for execution result */
 const resultMessage = <RS, A extends C.WorkerAckType>(
   result: StepExecutionResult<RS>,
@@ -72,6 +77,15 @@ const updateBreakpoints = <RS>(
   postMessage(ackMessage("bp-update"));
 };
 
+/** Validate the user's program syntax */
+const validateCode = <RS>(
+  controller: ExecutionController<RS>,
+  code: string
+) => {
+  const error = controller.validateCode(code);
+  postMessage(validationMessage(error));
+};
+
 /**
  * Execute the entire program loaded on engine,
  * and return result of execution.
@@ -112,6 +126,8 @@ export const setupWorker = <RS>(engine: LanguageEngine<RS>) => {
       if (ev.data.type === "Reset") return resetController(controller);
       if (ev.data.type === "Prepare")
         return prepare(controller, ev.data.params);
+      if (ev.data.type === "ValidateCode")
+        return validateCode(controller, ev.data.params.code);
       if (ev.data.type === "Execute")
         return execute(controller, ev.data.params.interval);
       if (ev.data.type === "Pause") return await pauseExecution(controller);
diff --git a/engines/types.ts b/engines/types.ts
index 3b49d9e..0901c13 100644
--- a/engines/types.ts
+++ b/engines/types.ts
@@ -45,6 +45,9 @@ export type StepExecutionResult<RS> = {
  * execution and debugging API to the platform.
  */
 export interface LanguageEngine<RS> {
+  /** Validate the syntax of the given code. Throw ParseError if any */
+  validateCode: (code: string) => void;
+
   /** Load code and user input into the engine and prepare for execution */
   prepare: (code: string, input: string) => void;
 
diff --git a/engines/worker-constants.ts b/engines/worker-constants.ts
index 81759e6..238aef8 100644
--- a/engines/worker-constants.ts
+++ b/engines/worker-constants.ts
@@ -1,4 +1,4 @@
-import { DocumentRange, StepExecutionResult } from "./types";
+import { StepExecutionResult } from "./types";
 import * as E from "./worker-errors";
 
 /** Types of requests the worker handles */
@@ -19,6 +19,10 @@ export type WorkerRequestData =
       type: "UpdateBreakpoints";
       params: { points: number[] };
     }
+  | {
+      type: "ValidateCode";
+      params: { code: string };
+    }
   | {
       type: "Execute";
       params: { interval: number };
@@ -57,6 +61,11 @@ export type WorkerResponseData<RS, A extends WorkerAckType> =
       data: A;
       error?: WorkerAckError[A];
     }
+  /** Result of code validation, containing parsing error (if any) */
+  | {
+      type: "validate";
+      error?: E.WorkerParseError;
+    }
   /** Response containing step execution result, and runtime error (if any) */
   | {
       type: "result";
diff --git a/ui/Mainframe.tsx b/ui/Mainframe.tsx
index a540786..2a330a8 100644
--- a/ui/Mainframe.tsx
+++ b/ui/Mainframe.tsx
@@ -139,6 +139,7 @@ export const Mainframe = <RS extends {}>({ langName, provider }: Props<RS>) => {
           languageId="brainfuck"
           defaultValue={providerRef.current.sampleProgram}
           tokensProvider={providerRef.current.editorTokensProvider}
+          onValidateCode={execController.validateCode}
           onUpdateBreakpoints={(newPoints) =>
             execController.updateBreakpoints(newPoints)
           }
diff --git a/ui/code-editor/index.tsx b/ui/code-editor/index.tsx
index 452a333..98e2a73 100644
--- a/ui/code-editor/index.tsx
+++ b/ui/code-editor/index.tsx
@@ -11,6 +11,8 @@ import { useEditorBreakpoints } from "./use-editor-breakpoints";
 import darkTheme from "./themes/dark.json";
 import lightTheme from "./themes/light.json";
 import { useDarkMode } from "../providers/dark-mode-provider";
+import { WorkerParseError } from "../../engines/worker-errors";
+import { useCodeValidator } from "./use-code-validator";
 
 // Interface for interacting with the editor
 export interface CodeEditorRef {
@@ -27,6 +29,8 @@ type Props = {
   defaultValue: string;
   /** Tokens provider for the language */
   tokensProvider?: MonacoTokensProvider;
+  /** Callback to validate code syntax */
+  onValidateCode: (code: string) => Promise<WorkerParseError | undefined>;
   /** Callback to update debugging breakpoints */
   onUpdateBreakpoints: (newBreakpoints: number[]) => void;
 };
@@ -36,15 +40,17 @@ type Props = {
  * only the required functionality to the parent container.
  */
 const CodeEditorComponent = (props: Props, ref: React.Ref<CodeEditorRef>) => {
-  const editorRef = React.useRef<EditorInstance | null>(null);
-  const monacoRef = React.useRef<MonacoInstance | null>(null);
+  const [editor, setEditor] = React.useState<EditorInstance | null>(null);
+  const [monaco, setMonaco] = React.useState<MonacoInstance | null>(null);
+  // const editorRef = React.useRef<EditorInstance | null>(null);
+  // const monacoRef = React.useRef<MonacoInstance | null>(null);
   const highlightRange = React.useRef<string[]>([]);
   const { isDark } = useDarkMode();
 
   // Breakpoints
   useEditorBreakpoints({
-    editor: editorRef.current,
-    monaco: monacoRef.current,
+    editor,
+    monaco,
     onUpdateBreakpoints: props.onUpdateBreakpoints,
   });
 
@@ -54,16 +60,23 @@ const CodeEditorComponent = (props: Props, ref: React.Ref<CodeEditorRef>) => {
     tokensProvider: props.tokensProvider,
   });
 
+  // Code validation
+  useCodeValidator({
+    editor,
+    monaco,
+    onValidateCode: props.onValidateCode,
+  });
+
   /** Update code highlights */
   const updateHighlights = React.useCallback((hl: DocumentRange | null) => {
     // Remove previous highlights
     const prevRange = highlightRange.current;
-    editorRef.current!.deltaDecorations(prevRange, []);
+    editor!.deltaDecorations(prevRange, []);
 
     // Add new highlights
     if (!hl) return;
-    const newRange = createHighlightRange(monacoRef.current!, hl);
-    const rangeStr = editorRef.current!.deltaDecorations([], [newRange]);
+    const newRange = createHighlightRange(monaco!, hl);
+    const rangeStr = editor!.deltaDecorations([], [newRange]);
     highlightRange.current = rangeStr;
   }, []);
 
@@ -71,10 +84,10 @@ const CodeEditorComponent = (props: Props, ref: React.Ref<CodeEditorRef>) => {
   React.useImperativeHandle(
     ref,
     () => ({
-      getValue: () => editorRef.current!.getValue(),
+      getValue: () => editor!.getValue(),
       updateHighlights,
     }),
-    []
+    [editor]
   );
 
   return (
@@ -88,8 +101,8 @@ const CodeEditorComponent = (props: Props, ref: React.Ref<CodeEditorRef>) => {
       }}
       onMount={(editor, monaco) => {
         if (!editor || !monaco) throw new Error("Error in initializing editor");
-        editorRef.current = editor;
-        monacoRef.current = monaco;
+        setEditor(editor);
+        setMonaco(monaco);
       }}
       options={{ minimap: { enabled: false }, glyphMargin: true }}
     />
diff --git a/ui/code-editor/monaco-utils.ts b/ui/code-editor/monaco-utils.ts
index 12f02ab..f2ce699 100644
--- a/ui/code-editor/monaco-utils.ts
+++ b/ui/code-editor/monaco-utils.ts
@@ -1,5 +1,6 @@
 import monaco from "monaco-editor";
 import { DocumentRange } from "../../engines/types";
+import { WorkerParseError } from "../../engines/worker-errors";
 
 /** Type alias for an instance of Monaco editor */
 export type EditorInstance = monaco.editor.IStandaloneCodeEditor;
@@ -41,6 +42,24 @@ export const createBreakpointRange = (
   return { range, options: { glyphMarginClassName: className } };
 };
 
+/** Create Monaco syntax-error marker from message and document range */
+export const createValidationMarker = (
+  monacoInstance: MonacoInstance,
+  error: WorkerParseError,
+  range: DocumentRange
+): monaco.editor.IMarkerData => {
+  const location = get1IndexedLocation(range);
+  return {
+    startLineNumber: location.line,
+    endLineNumber: location.line,
+    startColumn: location.charRange?.start || 0,
+    endColumn: location.charRange?.end || 1000,
+    severity: monacoInstance.MarkerSeverity.Error,
+    message: error.message,
+    source: error.name,
+  };
+};
+
 /**
  * Convert a DocumentRange to use 1-indexed values. Used since language engines
  * use 0-indexed ranges but Monaco requires 1-indexed ranges.
diff --git a/ui/code-editor/use-code-validator.ts b/ui/code-editor/use-code-validator.ts
new file mode 100644
index 0000000..ae26704
--- /dev/null
+++ b/ui/code-editor/use-code-validator.ts
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { WorkerParseError } from "../../engines/worker-errors";
+import {
+  createValidationMarker,
+  EditorInstance,
+  MonacoInstance,
+} from "./monaco-utils";
+
+/** Constant denoting "owner" of syntax error markers */
+const MARKER_OWNER = "code-validation";
+
+/** Delay between user's last edit and sending validation request */
+const VALIDATE_DELAY = 500;
+
+type Args = {
+  editor: EditorInstance | null;
+  monaco: MonacoInstance | null;
+  onValidateCode: (code: string) => Promise<WorkerParseError | undefined>;
+};
+
+/**
+ * React hook that sets up code validation lifecycle on the Monaco editor.
+ * Code validation is done a fixed delay after user's last edit, and markers
+ * for indicating syntax error are added to the editor.
+ */
+export const useCodeValidator = ({ editor, monaco, onValidateCode }: Args) => {
+  const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
+
+  const runValidator = async () => {
+    if (!editor || !monaco) return;
+    const error = await onValidateCode(editor.getValue());
+    if (error)
+      monaco.editor.setModelMarkers(editor.getModel()!, MARKER_OWNER, [
+        createValidationMarker(monaco, error, error.range),
+      ]);
+  };
+
+  React.useEffect(() => {
+    if (!editor || !monaco) return;
+    const disposer = editor.getModel()!.onDidChangeContent(() => {
+      monaco.editor.setModelMarkers(editor.getModel()!, MARKER_OWNER, []);
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+      timeoutRef.current = setTimeout(runValidator, VALIDATE_DELAY);
+    });
+    return () => disposer.dispose();
+  }, [editor, monaco]);
+};
diff --git a/ui/use-exec-controller.ts b/ui/use-exec-controller.ts
index 187cc7f..e7539d3 100644
--- a/ui/use-exec-controller.ts
+++ b/ui/use-exec-controller.ts
@@ -77,9 +77,6 @@ export const useExecController = <RS>(langName: string) => {
     (async () => {
       if (workerRef.current) throw new Error("Tried to reinitialize worker");
       workerRef.current = new Worker(`../workers/${langName}.js`);
-      workerRef.current!.addEventListener("error", (ev) =>
-        console.log("Ev: ", ev)
-      );
       const res = await requestWorker({ type: "Init" });
       if (res.type === "ack" && res.data === "init") setWorkerState("empty");
       else throwUnexpectedRes("init", res);
@@ -137,6 +134,21 @@ export const useExecController = <RS>(langName: string) => {
     else throwUnexpectedRes("resetState", res);
   }, []);
 
+  /**
+   * Validate the syntax of the user's program code
+   * @param code Code content of the user's program
+   */
+  const validateCode = React.useCallback(
+    async (code: string): Promise<WorkerParseError | undefined> => {
+      const res = await requestWorker(
+        { type: "ValidateCode", params: { code } },
+        (res) => res.type !== "validate"
+      );
+      return res.error;
+    },
+    []
+  );
+
   /**
    * Pause program execution
    */
@@ -206,6 +218,7 @@ export const useExecController = <RS>(langName: string) => {
     resetState,
     prepare,
     pauseExecution,
+    validateCode,
     execute,
     executeStep,
     updateBreakpoints,