From 51f974501eea5df65e65d7efa690369f9fbedf7b Mon Sep 17 00:00:00 2001 From: Benchmarko Date: Sun, 8 Dec 2024 15:17:47 +0100 Subject: [PATCH] JS syntax check for browser --- package.json | 2 +- src/Core.ts | 19 ++++++-- src/Interfaces.ts | 4 +- src/Ui.ts | 115 ++++++++++++++++++++++++++++++++++++++++++++-- src/main.ts | 1 + 5 files changed, 132 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 6eb9a4e..d779e87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "locobasic", - "version": "0.1.9", + "version": "0.1.11", "description": "# LocoBasic - Loco BASIC", "type": "commonjs", "scripts": { diff --git a/src/Core.ts b/src/Core.ts index 9432b59..2af1a35 100644 --- a/src/Core.ts +++ b/src/Core.ts @@ -56,6 +56,8 @@ export class Core implements ICore { private vm = vm; + private onCheckSyntax = async (_s: string) => ""; // eslint-disable-line @typescript-eslint/no-unused-vars + public getConfigObject() { return this.startConfig; } @@ -80,6 +82,10 @@ export class Core implements ICore { vm.setOnCls(fn); } + setOnCheckSyntax(fn: (s: string) => Promise) { + this.onCheckSyntax = fn; + } + private arithmeticParser: Parser | undefined; public compileScript(script: string) { @@ -100,6 +106,12 @@ export class Core implements ICore { } let output: string; + output = await this.onCheckSyntax(compiledScript); + if (output) { + vm.cls(); + return "ERROR: " + output; + } + try { const fnScript = new Function("_o", compiledScript); const result = fnScript(this.vm) || ""; @@ -111,17 +123,18 @@ export class Core implements ICore { } } catch (error) { + vm.cls(); output = "ERROR: "; if (error instanceof Error) { - output += error.message; + output += String(error); const anyErr = error as any; const lineNumber = anyErr.lineNumber; // only on FireFox const columnNumber = anyErr.columnNumber; // only on FireFox if (lineNumber || columnNumber) { - const errLine = lineNumber - 2; // for some reason line 0 is 2 - output += ` (line ${errLine}, column ${columnNumber})`; + const errLine = lineNumber - 2; // lineNumber -2 because of anonymous function added by new Function() constructor + output += ` (Line ${errLine}, column ${columnNumber})`; } } else { output += "unknown"; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index c5f3583..fb2ddf9 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -14,10 +14,12 @@ export interface ICore { compileScript(script: string): string, executeScript(compiledScript: string): Promise, setOnCls(fn: () => void): void + setOnCheckSyntax(fn: (s: string) => Promise): void } export interface IUi { parseUri(urlQuery: string, config: ConfigType): string[], onWindowLoad(event: Event): void, - setOutputText(value: string): void + setOutputText(value: string): void, + checkSyntax(str: string): Promise } diff --git a/src/Ui.ts b/src/Ui.ts index a203b4a..f0b4acb 100644 --- a/src/Ui.ts +++ b/src/Ui.ts @@ -2,10 +2,55 @@ import { ICore, IUi, ConfigType } from "./Interfaces"; +// Worker: +type PlainErrorEventType = { + lineno: number, + colno: number, + message: string +}; + +type ProcessingQueueType = { + resolve: (value: PlainErrorEventType) => void, + jsText: string +}; + +// based on: https://stackoverflow.com/questions/35252731/find-details-of-syntaxerror-thrown-by-javascript-new-function-constructor +// https://stackoverflow.com/a/55555357 +const workerFn = () => { + const doEvalAndReply = (jsText: string) => { + self.addEventListener( + 'error', + (errorEvent) => { + // Don't pollute the browser console: + errorEvent.preventDefault(); + // The properties we want are actually getters on the prototype; + // they won't be retrieved when just stringifying so, extract them manually, and put them into a new object: + const { lineno, colno, message } = errorEvent; + const plainErrorEventObj: PlainErrorEventType = { lineno, colno, message }; + self.postMessage(JSON.stringify(plainErrorEventObj)); + }, + { once: true } + ); + /* const fn = */ new Function("_o", jsText); + const plainErrorEventObj: PlainErrorEventType = { + lineno: -1, + colno: -1, + message: 'No Error: Parsing successful!' + }; + self.postMessage(JSON.stringify(plainErrorEventObj)); + }; + self.addEventListener('message', (e) => { + doEvalAndReply(e.data); + }); +}; + + export class Ui implements IUi { private readonly core: ICore; private basicCm: any; private compiledCm: any; + //private static worker?: Worker; + private static getErrorEvent?: (s: string) => Promise; constructor(core: ICore) { this.core = core; @@ -52,7 +97,7 @@ export class Ui implements IUi { this.setOutputText(this.getOutputText() + output + (output.endsWith("\n") ? "" : "\n")); } - private oncompiledTextChange(_event: Event) { // eslint-disable-line @typescript-eslint/no-unused-vars + private onCompiledTextChange(_event: Event) { // eslint-disable-line @typescript-eslint/no-unused-vars const autoExecuteInput = document.getElementById("autoExecuteInput") as HTMLInputElement; if (autoExecuteInput.checked) { const executeButton = window.document.getElementById("executeButton") as HTMLButtonElement; @@ -107,7 +152,6 @@ export class Ui implements IUi { } } - private setExampleSelectOptions(examples: Record) { const exampleSelect = document.getElementById("exampleSelect") as HTMLSelectElement; @@ -124,6 +168,69 @@ export class Ui implements IUi { } } + private static getErrorEventFn() { + if (Ui.getErrorEvent) { + return Ui.getErrorEvent; + } + + const blob = new Blob( + [`(${workerFn})();`], + { type: "text/javascript" } + ); + + const worker = new Worker(window.URL.createObjectURL(blob)); + // Use a queue to ensure processNext only calls the worker once the worker is idle + const processingQueue: ProcessingQueueType[] = []; + let processing = false; + + const processNext = () => { + processing = true; + const { resolve, jsText } = processingQueue.shift() as ProcessingQueueType; + worker.addEventListener( + 'message', + ({ data }) => { + resolve(JSON.parse(data)); + if (processingQueue.length) { + processNext(); + } else { + processing = false; + } + }, + { once: true } + ); + worker.postMessage(jsText); + }; + + const getErrorEvent = (jsText: string) => new Promise((resolve) => { + processingQueue.push({ resolve, jsText }); + if (!processing) { + processNext(); + } + }); + + Ui.getErrorEvent = getErrorEvent; + return getErrorEvent; + } + + private static describeError(stringToEval: string, lineno: number, colno: number) { + const lines = stringToEval.split('\n'); + const line = lines[lineno - 1]; + return `${line}\n${' '.repeat(colno - 1) + '^'}`; + } + + public async checkSyntax(str: string) { + const getErrorEvent = Ui.getErrorEventFn(); + + let output = ""; + const { lineno, colno, message } = await getErrorEvent(str); + if (message === 'No Error: Parsing successful!') { + return ""; + } + output += `Syntax error thrown at: Line ${lineno - 2}, col: ${colno}\n`; // lineNo -2 because of anonymous function added by new Function() constructor + output += Ui.describeError(str, lineno - 2, colno); + return output; + } + private fnDecodeUri(s: string) { let decoded = ""; @@ -161,7 +268,7 @@ export class Ui implements IUi { basicText.addEventListener('change', (event) => this.onbasicTextChange(event)); const compiledText = window.document.getElementById("compiledText") as HTMLTextAreaElement; - compiledText.addEventListener('change', (event) => this.oncompiledTextChange(event)); + compiledText.addEventListener('change', (event) => this.onCompiledTextChange(event)); const compileButton = window.document.getElementById("compileButton") as HTMLButtonElement; compileButton.addEventListener('click', (event) => this.onCompileButtonClick(event), false); @@ -184,7 +291,7 @@ export class Ui implements IUi { lineNumbers: true, mode: 'javascript' }); - this.compiledCm.on('changes', this.debounce((event: Event) => this.oncompiledTextChange(event), "debounceExecute")); + this.compiledCm.on('changes', this.debounce((event: Event) => this.onCompiledTextChange(event), "debounceExecute")); } Ui.asyncDelay(() => { diff --git a/src/main.ts b/src/main.ts index 865e8ed..c2207b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -148,6 +148,7 @@ if (typeof window !== "undefined") { fnParseArgs(args, config); core.setOnCls(() => ui.setOutputText("")); + core.setOnCheckSyntax((s: string) => Promise.resolve(ui.checkSyntax(s))); ui.onWindowLoad(new Event("onload")); }