Add automatic syntax checker, fix editor bugs

This commit is contained in:
Nilay Majorwar 2022-01-22 21:22:38 +05:30
parent dbccab5244
commit 94dce5bfa9
12 changed files with 163 additions and 15 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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);
}

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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";

View File

@ -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)
}

View File

@ -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 }}
/>

View File

@ -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.

View File

@ -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]);
};

View File

@ -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,