diff --git a/package.json b/package.json index 2f719068..6a964650 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@codemirror/lang-python": "^0.19.2", "@codemirror/language": "^0.19.5", "@codemirror/panel": "^0.19.0", + "@codemirror/rangeset": "^0.19.9", "@codemirror/rectangular-selection": "^0.19.1", "@codemirror/search": "^0.19.3", "@codemirror/state": "^0.19.6", diff --git a/scripts/ValidateTranslations.js b/scripts/ValidateTranslations.js index 0e6b3534..7f6c6164 100644 --- a/scripts/ValidateTranslations.js +++ b/scripts/ValidateTranslations.js @@ -35,7 +35,8 @@ const checks = [ "Papyros.locales.*", "Papyros.states.*", "Papyros.input_modes.switch_to_*", - "Papyros.input_placeholder.*" + "Papyros.input_placeholder.*", + "Papyros.debugging_command.*" ] } ]; diff --git a/src/Backend.ts b/src/Backend.ts index 37356a86..973e3d32 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -1,4 +1,4 @@ -import { PapyrosEvent } from "./PapyrosEvent"; +import { BackendEvent } from "./BackendEvent"; import { Channel, readMessage, uuidv4 } from "sync-message"; import { parseData } from "./util/Util"; import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; @@ -18,7 +18,7 @@ export interface WorkerAutocompleteContext { } export abstract class Backend { - protected onEvent: (e: PapyrosEvent) => any; + protected onEvent: (e: BackendEvent) => any; protected runId: number; /** @@ -34,19 +34,19 @@ export abstract class Backend { /** * Initialize the backend by doing all setup-related work - * @param {function(PapyrosEvent):void} onEvent Callback for when events occur + * @param {function(BackendEvent):void} onEvent Callback for when events occur * @param {Channel} channel for communication with the main thread * @return {Promise} Promise of launching */ launch( - onEvent: (e: PapyrosEvent) => void, + onEvent: (e: BackendEvent) => void, channel: Channel ): Promise { // Input messages are handled in a special way // In order to link input requests to their responses // An ID is required to make the connection // The message must be read in the worker to not stall the main thread - const onInput = (e: PapyrosEvent): string => { + const onInput = (e: BackendEvent): string => { const inputData = parseData(e.data, e.contentType); const messageId = uuidv4(); inputData.messageId = messageId; @@ -55,7 +55,7 @@ export abstract class Backend { onEvent(e); return readMessage(channel, messageId); }; - this.onEvent = (e: PapyrosEvent) => { + this.onEvent = (e: BackendEvent) => { e.runId = this.runId; if (e.type === "input") { return onInput(e); @@ -71,7 +71,7 @@ export abstract class Backend { * Results or Errors must be passed by using the onEvent-callback * @param code The code to run */ - protected abstract _runCodeInternal(code: string): Promise; + protected abstract runCodeInternal(code: string): Promise; /** * Executes the given code @@ -79,11 +79,33 @@ export abstract class Backend { * @param {string} runId The uuid for this execution * @return {Promise} Promise of execution */ - async runCode(code: string, runId: number): Promise { + async runCode(code: string, runId: number): Promise { this.runId = runId; - return await this._runCodeInternal(code); + return await this.runCodeInternal(code); } + /** + * Run a piece of code in debug mode, allowing the user to figure out + * why things do or do not work + * @param {string} code The code to debug + * @param {number} runId The internal identifier for this code run + * @param {Set} breakpoints The line numbers where the user put a breakpoint + * @return {Promise} Promise of debugging + */ + debugCode(code: string, runId: number, breakpoints: Set): Promise { + this.runId = runId; + return this.debugCodeInternal(code, breakpoints); + } + + /** + * Internal helper method that actually debugs the code + * Communication is done by using the onEvent-callback + * @param {string} code The code to debug + * @param {Set} breakpoints The line numbers where the user put a breakpoint + * @return {Promise} Promise of debugging + */ + protected abstract debugCodeInternal(code: string, breakpoints: Set): Promise; + /** * Converts the context to a cloneable object containing useful properties * to generate autocompletion suggestions with diff --git a/src/BackendEvent.ts b/src/BackendEvent.ts new file mode 100644 index 00000000..2bff99ce --- /dev/null +++ b/src/BackendEvent.ts @@ -0,0 +1,41 @@ +/** + * Enum representing all possible types for supported events + */ +export enum BackendEventType { + Start = "start", + Input = "input", + Output = "output", + Error = "error", + Debug = "debug", + End = "end" +} +/** + * All possible types for ease of iteration + */ +export const BACKEND_EVENT_TYPES = [ + BackendEventType.Input, BackendEventType.Output, + BackendEventType.Error, BackendEventType.Debug, + BackendEventType.Start, BackendEventType.End +]; +/** + * Interface for events used for communication between threads + */ +export interface BackendEvent { + /** + * The type of action generating this event + */ + type: BackendEventType; + /** + * The identifier for the run this message is associated with + * This allows discarding outdated events that were delayed + */ + runId: number; + /** + * The actual data stored in this event + */ + data: string; + /** + * The format used for the data to help with parsing + */ + contentType: string; +} diff --git a/src/BackendManager.ts b/src/BackendManager.ts index 17b408b2..f5a559fe 100644 --- a/src/BackendManager.ts +++ b/src/BackendManager.ts @@ -1,45 +1,94 @@ +/* eslint-disable valid-jsdoc */ // Some parts are incorrectly marked as functions import { releaseProxy, Remote, wrap } from "comlink"; import { Backend } from "./Backend"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import PythonWorker from "./workers/python/PythonWorker.worker"; import JavaScriptWorker from "./workers/javascript/JavaScriptWorker.worker"; -// Store Worker per Backend as Comlink proxy has no explicit reference to the Worker -// We need the Worker itself to be able to terminate it (@see stopBackend) -const BACKEND_MAP: Map, Worker> = new Map(); - -const CREATE_WORKER_MAP: Map Worker> = new Map([ - [ProgrammingLanguage.Python, () => new PythonWorker()], - [ProgrammingLanguage.JavaScript, () => new JavaScriptWorker()] -]); +import { BackendEvent, BackendEventType } from "./BackendEvent"; +import { LogType, papyrosLog } from "./util/Logging"; /** - * Start a backend for the given language, while storing the worker - * @param {ProgrammingLanguage} language The programming language supported by the backend - * @return {Remote} A Comlink proxy for the Backend + * Callback type definition for subscribers + * @param {BackendEvent} e The published event */ -export function startBackend(language: ProgrammingLanguage): Remote { - if (CREATE_WORKER_MAP.has(language)) { - const worker = CREATE_WORKER_MAP.get(language)!(); - const backend = wrap(worker); - // store worker itself in the map - BACKEND_MAP.set(backend, worker); - return backend; - } else { - throw new Error(`${language} is not yet supported.`); - } -} +type BackendEventListener = (e: BackendEvent) => void; /** - * Stop a backend by terminating the worker and releasing memory - * @param {Remote} backend The proxy for the backend to stop + * Abstract class to implement the singleton pattern + * Static methods group functionality */ -export function stopBackend(backend: Remote): void { - if (BACKEND_MAP.has(backend)) { - const toStop = BACKEND_MAP.get(backend)!; - toStop.terminate(); - backend[releaseProxy](); - BACKEND_MAP.delete(backend); - } else { - throw new Error(`Unknown backend supplied for backend ${JSON.stringify(backend)}`); +export abstract class BackendManager { + /** + * Store Worker per Backend as Comlink proxy has no explicit reference to the Worker + * We need the Worker itself to be able to terminate it (@see stopBackend) + */ + static backendMap: Map, Worker> = new Map(); + static createWorkerMap: Map Worker> = new Map([ + [ProgrammingLanguage.Python, () => new PythonWorker()], + [ProgrammingLanguage.JavaScript, () => new JavaScriptWorker()] + ]); + /** + * Map an event type to interested subscribers + * Uses an Array to maintain order of subscription + */ + static subscriberMap: Map> = new Map(); + /** + * Start a backend for the given language, while storing the worker + * @param {ProgrammingLanguage} language The programming language supported by the backend + * @return {Remote} A Comlink proxy for the Backend + */ + static startBackend(language: ProgrammingLanguage): Remote { + if (this.createWorkerMap.has(language)) { + const worker = this.createWorkerMap.get(language)!(); + const backend = wrap(worker); + // store worker itself in the map + this.backendMap.set(backend, worker); + return backend; + } else { + throw new Error(`${language} is not yet supported.`); + } + } + + /** + * Stop a backend by terminating the worker and releasing memory + * @param {Remote} backend The proxy for the backend to stop + */ + static stopBackend(backend: Remote): void { + if (this.backendMap.has(backend)) { + const toStop = this.backendMap.get(backend)!; + toStop.terminate(); + backend[releaseProxy](); + this.backendMap.delete(backend); + } else { + throw new Error(`Unknown backend supplied for backend ${JSON.stringify(backend)}`); + } + } + + /** + * Register a callback for when an event of a certain type is published + * @param {BackendEventType} type The type of event to subscribe to + * @param {BackendEventListener} subscriber Callback for when an event + * of the given type is published + */ + static subscribe(type: BackendEventType, subscriber: BackendEventListener): void { + if (!this.subscriberMap.has(type)) { + this.subscriberMap.set(type, []); + } + const subscribers = this.subscriberMap.get(type)!; + if (!subscribers.includes(subscriber)) { + subscribers.push(subscriber); + } + } + + /** + * Publish an event, notifying all listeners for its type + * @param {BackendEventType} e The event to publish + */ + static publish(e: BackendEvent): void { + papyrosLog(LogType.Debug, "Publishing event: ", e); + if (this.subscriberMap.has(e.type)) { + this.subscriberMap.get(e.type)!.forEach(cb => cb(e)); + } } } + diff --git a/src/CodeEditor.ts b/src/CodeEditor.ts index 7bb3a89a..99132415 100644 --- a/src/CodeEditor.ts +++ b/src/CodeEditor.ts @@ -26,8 +26,8 @@ import { rectangularSelection } from "@codemirror/rectangular-selection"; import { defaultHighlightStyle } from "@codemirror/highlight"; import { lintKeymap } from "@codemirror/lint"; import { showPanel } from "@codemirror/panel"; -import { RenderOptions, renderWithOptions } from "./util/Util"; - +import { RenderOptions, renderWithOptions, t } from "./util/Util"; +import { breakpoints } from "./extensions/Breakpoints"; /** * Component that provides useful features to users writing code */ @@ -56,23 +56,27 @@ export class CodeEditor { * Compartment to configure the autocompletion at runtime */ autocompletionCompartment: Compartment = new Compartment(); + /** + * Indices of lines in the editor that have breakpoints + */ + readonly breakpointLines: Set; /** * Construct a new CodeEditor - * @param {ProgrammingLanguage} language The used programming language - * @param {string} editorPlaceHolder The placeholder for the editor * @param {string} initialCode The initial code to display * @param {number} indentLength The length in spaces for the indent unit */ - constructor(language: ProgrammingLanguage, - editorPlaceHolder: string, initialCode = "", indentLength = 4) { + constructor(initialCode = "", indentLength = 4) { + this.breakpointLines = new Set(); this.editorView = new EditorView( { state: EditorState.create({ doc: initialCode, extensions: [ - this.languageCompartment.of(CodeEditor.getLanguageSupport(language)), + breakpoints((lineNr: number, active: boolean) => + this.toggleBreakpoint(lineNr, active)), + this.languageCompartment.of([]), this.autocompletionCompartment.of( autocompletion() ), @@ -80,14 +84,39 @@ export class CodeEditor { indentUnit.of(CodeEditor.getIndentUnit(indentLength)) ), keymap.of([indentWithTab]), - this.placeholderCompartment.of(placeholder(editorPlaceHolder)), + this.placeholderCompartment.of([]), this.panelCompartment.of(showPanel.of(null)), - ...CodeEditor.getExtensions() + ...CodeEditor.getDefaultExtensions() ] }) }); } + /** + * Update the breakpoint status of the given line + * @param {number} lineNr The index of the line + * @param {boolean} active Whether the line has a breakpoint + */ + toggleBreakpoint(lineNr: number, active: boolean): void { + if (active) { + this.breakpointLines.add(lineNr); + } else { + this.breakpointLines.delete(lineNr); + } + } + + /** + * Highlight the given line + * @param {number} lineNr The 1-based number of the line to highlight + */ + highlight(lineNr: number): void { + this.editorView.dispatch( + { + selection: { anchor: this.editorView.state.doc.line(lineNr).from } + } + ); + } + /** * Render the editor with the given options and panel * @param {RenderOptions} options Options for rendering @@ -110,15 +139,15 @@ export class CodeEditor { * @param {CompletionSource} completionSource Function to generate autocomplete results * @param {string} editorPlaceHolder Placeholder when empty */ - setLanguage(language: ProgrammingLanguage, completionSource: CompletionSource, - editorPlaceHolder: string): void { + setLanguage(language: ProgrammingLanguage, completionSource: CompletionSource): void { this.editorView.dispatch({ effects: [ this.languageCompartment.reconfigure(CodeEditor.getLanguageSupport(language)), this.autocompletionCompartment.reconfigure( autocompletion({ override: [completionSource] }) ), - this.placeholderCompartment.reconfigure(placeholder(editorPlaceHolder)) + this.placeholderCompartment.reconfigure(placeholder(t("Papyros.code_placeholder", + { programmingLanguage: language }))) ] }); } @@ -221,7 +250,7 @@ export class CodeEditor { * - [indenting with tab](#commands.indentWithTab) * @return {Array { + static getDefaultExtensions(): Array { return [ lineNumbers(), highlightActiveLineGutter(), diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts new file mode 100644 index 00000000..d283e674 --- /dev/null +++ b/src/CodeRunner.ts @@ -0,0 +1,322 @@ +import { proxy, Remote } from "comlink"; +import { Backend } from "./Backend"; +import { BackendEvent, BackendEventType } from "./BackendEvent"; +import { BackendManager } from "./BackendManager"; +import { CodeEditor } from "./CodeEditor"; +import { + APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, + STATE_SPINNER_ID, STOP_BTN_ID, DEBUG_BTN_ID +} from "./Constants"; +import { InputManager, InputMode } from "./InputManager"; +import { ProgrammingLanguage } from "./ProgrammingLanguage"; +import { svgCircle } from "./util/HTMLShapes"; +import { LogType, papyrosLog } from "./util/Logging"; +import { + addListener, ButtonOptions, renderButton, + RenderOptions, renderWithOptions, getElement, + t, + parseData +} from "./util/Util"; + +interface DynamicButton { + id: string; + buttonHTML: string; + onClick: () => void; +} + +interface DebugAction { + action: string; + data: string; +} + +/** + * Enum representing the possible states while processing code + */ +export enum RunState { + Loading = "loading", + Running = "running", + AwaitingInput = "awaiting_input", + Stopping = "stopping", + Ready = "ready" +} +/** + * Helper component to manage and visualize the current RunState + */ +export class CodeRunner { + /** + * The currently used programming language + */ + private programmingLanguage: ProgrammingLanguage; + /** + * The editor in which the code is written + */ + readonly editor: CodeEditor; + /** + * Component to request and handle input from the user + */ + readonly inputManager: InputManager; + /** + * The backend that executes the code asynchronously + */ + private backend: Remote; + /** + * The identifier for the current run + */ + private runId: number; + /** + * Current state of the program + */ + private state: RunState; + /** + * Buttons managed by this component + */ + private buttons: Array; + + /** + * Construct a new RunStateManager with the given listeners + * @param {ProgrammingLanguage} programmingLanguage The language to use + */ + constructor(programmingLanguage: ProgrammingLanguage) { + this.programmingLanguage = programmingLanguage; + this.editor = new CodeEditor(); + this.inputManager = new InputManager(() => this.setState(RunState.Running)); + this.backend = {} as Remote; + this.runId = 0; + this.buttons = []; + this.addButton({ + id: RUN_BTN_ID, + buttonText: t("Papyros.run"), + extraClasses: "text-white bg-blue-500" + }, () => this.runCode(false)); + this.addButton({ + id: STOP_BTN_ID, + buttonText: t("Papyros.stop"), + extraClasses: "text-white bg-red-500" + }, () => this.stop()); + this.addButton({ + id: DEBUG_BTN_ID, + buttonText: t("Papyros.debug"), + extraClasses: "text-white bg-green-500" + }, () => this.runCode(true)); + BackendManager.subscribe(BackendEventType.Input, + () => this.setState(RunState.AwaitingInput)); + BackendManager.subscribe(BackendEventType.Debug, + e => this.onDebug(e)); + this.state = RunState.Ready; + } + + /** + * Start the backend to enable running code + */ + async start(): Promise { + this.setState(RunState.Loading); + this.backend = BackendManager.startBackend(this.programmingLanguage); + this.editor.setLanguage(this.programmingLanguage, + async context => { + const completionContext = Backend.convertCompletionContext(context); + return await this.backend.autocomplete(completionContext); + }); + // Allow passing messages between worker and main thread + await this.backend.launch(proxy(e => this.publishEvent(e)), this.inputManager.channel); + this.editor.focus(); + this.setState(RunState.Ready); + } + + /** + * Interrupt the currently running code + * @return {Promise} Promise of stopping + */ + async stop(): Promise { + this.runId += 1; // ignore messages coming from last run + this.setState(RunState.Stopping); + this.publishEvent({ + type: BackendEventType.End, + runId: this.runId, + data: "User cancelled run", contentType: "text/plain" + }); + // Since we use workers, the old one must be entirely replaced to interrupt it + BackendManager.stopBackend(this.backend); + return this.start(); + } + + /** + * Helper method to publish events, if they are still relevant + * @param {BackendEvent} e The event to publish + */ + publishEvent(e: BackendEvent): void { + if (e.runId === this.runId) { + BackendManager.publish(e); + } + } + /** + * Set the used programming language to the given one to allow editing and running code + * @param {ProgrammingLanguage} programmingLanguage The language to use + */ + async setProgrammingLanguage(programmingLanguage: ProgrammingLanguage): Promise { + if (this.programmingLanguage !== programmingLanguage) { // Expensive, so ensure it is needed + BackendManager.stopBackend(this.backend); + this.programmingLanguage = programmingLanguage; + await this.start(); + } + } + + getProgrammingLanguage(): ProgrammingLanguage { + return this.programmingLanguage; + } + + /** + * Get the button to run the code + */ + get runButton(): HTMLButtonElement { + return getElement(RUN_BTN_ID); + } + + /** + * Get the button to debug the code + */ + get debugButton(): HTMLButtonElement { + return getElement(DEBUG_BTN_ID); + } + + /** + * Get the button to interrupt the code + */ + get stopButton(): HTMLButtonElement { + return getElement(STOP_BTN_ID); + } + + /** + * Show or hide the spinning circle, representing a running animation + * @param {boolean} show Whether to show the spinner + */ + showSpinner(show: boolean): void { + getElement(STATE_SPINNER_ID).style.display = show ? "" : "none"; + } + + /** + * Show the current state of the program to the user + * @param {RunState} state The current state of the run + * @param {string} message Optional message to indicate the state + */ + setState(state: RunState, message?: string): void { + this.state = state; + this.stopButton.disabled = [RunState.Ready, RunState.Loading].includes(state); + if (state === RunState.Ready) { + this.showSpinner(false); + this.runButton.disabled = false; + this.debugButton.disabled = false; + } else { + this.showSpinner(true); + this.runButton.disabled = true; + this.debugButton.disabled = true; + } + getElement(APPLICATION_STATE_TEXT_ID).innerText = + message || t(`Papyros.states.${state}`); + } + + getState(): RunState { + return this.state; + } + + /** + * Add a button to display to the user + * @param {ButtonOptions} options Options for rendering the button + * @param {function} onClick Listener for click events on the button + */ + addButton(options: ButtonOptions, onClick: () => void): void { + this.buttons.push({ + id: options.id, + buttonHTML: renderButton(options), + onClick: onClick + }); + } + + /** + * Render the RunStateManager with the given options + * @param {RenderOptions} statusPanelOptions Options for rendering the panel + * @param {RenderOptions} inputOptions Options for rendering the InputManager + * @param {RenderOptions} codeEditorOptions Options for rendering the editor + * @return {HTMLElement} The rendered RunStateManager + */ + render(statusPanelOptions: RenderOptions, + inputOptions: RenderOptions, + codeEditorOptions: RenderOptions): HTMLElement { + const rendered = renderWithOptions(statusPanelOptions, ` +
+
+ ${this.buttons.map(b => b.buttonHTML).join("\n")} +
+
+
+ ${svgCircle(STATE_SPINNER_ID, "red")} +
+
`); + // Buttons are freshly added to the DOM, so attach listeners now + this.buttons.forEach(b => addListener(b.id, b.onClick, "click")); + this.inputManager.render(inputOptions); + this.editor.render(codeEditorOptions, rendered); + return rendered; + } + + /** + * Run the code that is currently present in the editor + * @param {boolean} debug Whether the run happens in debug mode + * @return {Promise} Promise of running the code + */ + async runCode(debug: boolean): Promise { + // Setup pre-run + this.runId += 1; + this.setState(RunState.Running); + this.publishEvent({ + type: BackendEventType.Start, + runId: this.runId, + data: "User started run", contentType: "text/plain" + }); + papyrosLog(LogType.Debug, "Running code in Papyros, sending to backend"); + const start = new Date().getTime(); + let endMessage = "Program finishd normally"; + try { + if (debug) { + this.inputManager.inputMode = InputMode.Debugging; + await this.backend.debugCode( + this.editor.getCode(), this.runId, this.editor.breakpointLines); + } else { + await this.backend.runCode(this.editor.getCode(), this.runId); + } + } catch (error: any) { + papyrosLog(LogType.Error, error); + this.publishEvent({ + type: BackendEventType.Error, + data: JSON.stringify(error), + runId: this.runId, + contentType: "text/json" + }); + endMessage = "Program terminated due to error: " + error.constructor.name; + } finally { + const end = new Date().getTime(); + this.setState(RunState.Ready, t("Papyros.finished", { time: (end - start) / 1000 })); + this.publishEvent({ + type: BackendEventType.End, + runId: this.runId, + data: endMessage, contentType: "text/plain" + }); + } + } + + onDebug(e: BackendEvent): void { + const data: DebugAction = parseData(e.data, e.contentType); + if (data.action === "highlight") { + this.editor.highlight(parseInt(data.data)); + } else if (data.action === "print") { + if (!data.data.endsWith("\n")) { + data.data += "\n"; + } + this.publishEvent({ + type: BackendEventType.Output, + runId: this.runId, + data: data.data, + contentType: "text/plain" + }); + } + } +} diff --git a/src/Constants.ts b/src/Constants.ts index e02e4d99..21fa3f13 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -8,7 +8,7 @@ import { ProgrammingLanguage } from "./ProgrammingLanguage"; function addPapyrosPrefix(s: string): string { return `__papyros-${s}`; } -/* Default HT%M ids for various elements */ +/* Default HTML ids for various elements */ export const MAIN_APP_ID = addPapyrosPrefix("papyros"); export const OUTPUT_TA_ID = addPapyrosPrefix("code-output-area"); export const INPUT_AREA_WRAPPER_ID = addPapyrosPrefix("code-input-area-wrapper"); @@ -18,10 +18,14 @@ export const EDITOR_WRAPPER_ID = addPapyrosPrefix("code-area"); export const PANEL_WRAPPER_ID = addPapyrosPrefix("code-status-panel"); export const STATE_SPINNER_ID = addPapyrosPrefix("state-spinner"); export const APPLICATION_STATE_TEXT_ID = addPapyrosPrefix("application-state-text"); + export const RUN_BTN_ID = addPapyrosPrefix("run-code-btn"); export const STOP_BTN_ID = addPapyrosPrefix("stop-btn"); +export const DEBUG_BTN_ID = addPapyrosPrefix("debug-btn"); export const SEND_INPUT_BTN_ID = addPapyrosPrefix("send-input-btn"); export const SWITCH_INPUT_MODE_A_ID = addPapyrosPrefix("switch-input-mode"); +export const DEBUGGING_INTERACTIVE_WRAPPER_ID = addPapyrosPrefix("debugging-interactive-wrapper"); + export const EXAMPLE_SELECT_ID = addPapyrosPrefix("example-select"); export const LOCALE_SELECT_ID = addPapyrosPrefix("locale-select"); export const PROGRAMMING_LANGUAGE_SELECT_ID = addPapyrosPrefix("programming-language-select"); diff --git a/src/InputManager.ts b/src/InputManager.ts index 043bf8cd..c7df3776 100644 --- a/src/InputManager.ts +++ b/src/InputManager.ts @@ -1,9 +1,9 @@ import { t } from "i18n-js"; import { SWITCH_INPUT_MODE_A_ID, - INPUT_TA_ID, SEND_INPUT_BTN_ID, USER_INPUT_WRAPPER_ID + USER_INPUT_WRAPPER_ID } from "./Constants"; -import { PapyrosEvent } from "./PapyrosEvent"; +import { BackendEvent, BackendEventType } from "./BackendEvent"; import { papyrosLog, LogType } from "./util/Logging"; import { addListener, parseData, @@ -13,11 +13,13 @@ import { Channel, makeChannel, writeMessage } from "sync-message"; import { InteractiveInputHandler } from "./input/InteractiveInputHandler"; import { UserInputHandler } from "./input/UserInputHandler"; import { BatchInputHandler } from "./input/BatchInputHandler"; -import { RunListener } from "./RunListener"; +import { PdbInputHandler } from "./input/PdbInputHandler"; +import { BackendManager } from "./BackendManager"; export enum InputMode { Interactive = "interactive", - Batch = "batch" + Batch = "batch", + Debugging = "debugging" } export const INPUT_MODES = [InputMode.Batch, InputMode.Interactive]; @@ -27,7 +29,8 @@ export interface InputData { messageId: string; } -export class InputManager implements RunListener { +export class InputManager { + private previousInputMode: InputMode; private _inputMode: InputMode; private inputHandlers: Map; private renderOptions: RenderOptions; @@ -38,24 +41,32 @@ export class InputManager implements RunListener { channel: Channel; messageId = ""; - constructor(onSend: () => void, inputMode: InputMode) { - this._inputMode = inputMode; + constructor(onSend: () => void) { + this.inputHandlers = this.buildInputHandlerMap(); + this._inputMode = InputMode.Interactive; + this.inputHandler.addInputListener(this); + this.previousInputMode = this._inputMode; this.channel = makeChannel()!; // by default we try to use Atomics this.onSend = onSend; this._waiting = false; this.prompt = ""; - this.inputHandlers = this.buildInputHandlerMap(); this.renderOptions = {} as RenderOptions; + BackendManager.subscribe(BackendEventType.Start, () => this.onRunStart()); + BackendManager.subscribe(BackendEventType.End, () => this.onRunEnd()); + BackendManager.subscribe(BackendEventType.Input, e => this.onInputRequest(e)); } private buildInputHandlerMap(): Map { const interactiveInputHandler: UserInputHandler = - new InteractiveInputHandler(() => this.sendLine(), INPUT_TA_ID, SEND_INPUT_BTN_ID); + new InteractiveInputHandler(); const batchInputHandler: UserInputHandler = - new BatchInputHandler(() => this.sendLine(), INPUT_TA_ID); + new BatchInputHandler(); + const debuggingInputHandler: UserInputHandler = + new PdbInputHandler(); return new Map([ [InputMode.Interactive, interactiveInputHandler], - [InputMode.Batch, batchInputHandler] + [InputMode.Batch, batchInputHandler], + [InputMode.Debugging, debuggingInputHandler] ]); } @@ -68,6 +79,7 @@ export class InputManager implements RunListener { this._inputMode = inputMode; this.render(this.renderOptions); this.inputHandler.onToggle(true); + this.inputHandler.addInputListener(this); } get inputHandler(): UserInputHandler { @@ -76,17 +88,24 @@ export class InputManager implements RunListener { render(options: RenderOptions): void { this.renderOptions = options; - const otherMode = this.inputMode === InputMode.Interactive ? - InputMode.Batch : InputMode.Interactive; + let switchMode = ""; + if (this.inputMode !== InputMode.Debugging) { + const otherMode = this.inputMode === InputMode.Interactive ? + InputMode.Batch : InputMode.Interactive; + switchMode = ` + ${t(`Papyros.input_modes.switch_to_${otherMode}`)} + `; + } + renderWithOptions(options, `
- - ${t(`Papyros.input_modes.switch_to_${otherMode}`)} -`); - addListener(SWITCH_INPUT_MODE_A_ID, im => this.inputMode = im, - "click", "data-value"); +${switchMode}`); + if (this.inputMode !== InputMode.Debugging) { + addListener(SWITCH_INPUT_MODE_A_ID, im => this.inputMode = im, + "click", "data-value"); + } this.inputHandler.render({ parentElementId: USER_INPUT_WRAPPER_ID }); this.inputHandler.waitWithPrompt(this._waiting, this.prompt); } @@ -96,7 +115,7 @@ class="flex flex-row-reverse hover:cursor-pointer text-blue-500"> this.inputHandler.waitWithPrompt(waiting, this.prompt); } - async sendLine(): Promise { + async onUserInput(): Promise { if (this.inputHandler.hasNext()) { const line = this.inputHandler.next(); papyrosLog(LogType.Debug, "Sending input to user: " + line); @@ -111,19 +130,20 @@ class="flex flex-row-reverse hover:cursor-pointer text-blue-500"> /** * Asynchronously handle an input request by prompting the user for input - * @param {PapyrosEvent} e Event containing the input data + * @param {BackendEvent} e Event containing the input data * @return {Promise} Promise of handling the request */ - async onInput(e: PapyrosEvent): Promise { + async onInputRequest(e: BackendEvent): Promise { papyrosLog(LogType.Debug, "Handling input request in Papyros"); const data = parseData(e.data, e.contentType) as InputData; this.messageId = data.messageId; this.prompt = data.prompt; - return this.sendLine(); + return this.onUserInput(); } onRunStart(): void { this.waiting = false; + this.previousInputMode = this.inputMode; this.inputHandler.onRunStart(); } @@ -131,5 +151,8 @@ class="flex flex-row-reverse hover:cursor-pointer text-blue-500"> this.prompt = ""; this.inputHandler.onRunEnd(); this.waiting = false; + if (this.previousInputMode !== this.inputMode) { + this.inputMode = this.previousInputMode; + } } } diff --git a/src/Library.ts b/src/Library.ts index aae23b6d..79468d62 100644 --- a/src/Library.ts +++ b/src/Library.ts @@ -2,17 +2,17 @@ import { CodeEditor } from "./CodeEditor"; import { InputManager, InputMode } from "./InputManager"; import { OutputManager } from "./OutputManager"; import { Papyros } from "./Papyros"; -import { PapyrosEvent } from "./PapyrosEvent"; -import { RunStateManager, RunState } from "./RunStateManager"; +import { BackendEvent } from "./BackendEvent"; +import { CodeRunner, RunState } from "./CodeRunner"; import { InputWorker } from "./workers/input/InputWorker"; export * from "./ProgrammingLanguage"; -export type { PapyrosEvent }; +export type { BackendEvent as PapyrosEvent }; export { Papyros, CodeEditor, RunState, - RunStateManager, + CodeRunner as RunStateManager, InputManager, InputMode, OutputManager, diff --git a/src/OutputManager.ts b/src/OutputManager.ts index 39982b7a..3ea3ff2b 100644 --- a/src/OutputManager.ts +++ b/src/OutputManager.ts @@ -1,6 +1,6 @@ import escapeHTML from "escape-html"; -import { PapyrosEvent } from "./PapyrosEvent"; -import { RunListener } from "./RunListener"; +import { BackendEvent, BackendEventType } from "./BackendEvent"; +import { BackendManager } from "./BackendManager"; import { inCircle } from "./util/HTMLShapes"; import { getElement, parseData, @@ -40,9 +40,16 @@ export interface FriendlyError { /** * Component for displaying code output or errors to the user */ -export class OutputManager implements RunListener { +export class OutputManager { // Store options to allow re-rendering - options: RenderOptions = { parentElementId: "" }; + options: RenderOptions; + + constructor() { + BackendManager.subscribe(BackendEventType.Start, () => this.reset()); + BackendManager.subscribe(BackendEventType.Output, e => this.showOutput(e)); + BackendManager.subscribe(BackendEventType.Error, e => this.showError(e)); + this.options = { parentElementId: "" }; + } /** * Retrieve the parent element containing all output parts @@ -78,9 +85,9 @@ export class OutputManager implements RunListener { /** * Display output to the user, based on its content type - * @param {PapyrosEvent} output Event containing the output data + * @param {BackendEvent} output Event containing the output data */ - showOutput(output: PapyrosEvent): void { + showOutput(output: BackendEvent): void { const data = parseData(output.data, output.contentType); if (output.contentType === "img/png;base64") { this.renderNextElement(``); @@ -91,9 +98,9 @@ export class OutputManager implements RunListener { /** * Display an error to the user - * @param {PapyrosEvent} error Event containing the error data + * @param {BackendEvent} error Event containing the error data */ - showError(error: PapyrosEvent): void { + showError(error: BackendEvent): void { let errorHTML = ""; const errorData = parseData(error.data, error.contentType); if (typeof (errorData) === "string") { @@ -144,12 +151,4 @@ export class OutputManager implements RunListener { reset(): void { this.render(this.options); } - - onRunStart(): void { - this.reset(); - } - - onRunEnd(): void { - // currently empty - } } diff --git a/src/Papyros.ts b/src/Papyros.ts index 341f91a3..854226cf 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -1,16 +1,11 @@ /* eslint-disable max-len */ import "./Papyros.css"; -import { proxy, Remote } from "comlink"; import I18n from "i18n-js"; -import { Backend } from "./Backend"; -import { startBackend, stopBackend } from "./BackendManager"; -import { CodeEditor } from "./CodeEditor"; import { EDITOR_WRAPPER_ID, PROGRAMMING_LANGUAGE_SELECT_ID, OUTPUT_TA_ID, LOCALE_SELECT_ID, INPUT_AREA_WRAPPER_ID, EXAMPLE_SELECT_ID, PANEL_WRAPPER_ID } from "./Constants"; -import { InputManager, InputMode } from "./InputManager"; -import { PapyrosEvent } from "./PapyrosEvent"; +import { InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { LogType, papyrosLog } from "./util/Logging"; import { @@ -19,39 +14,16 @@ import { RenderOptions, renderWithOptions, addListener, ButtonOptions, getElement } from "./util/Util"; -import { RunState, RunStateManager } from "./RunStateManager"; +import { RunState, CodeRunner } from "./CodeRunner"; import { getCodeForExample, getExampleNames } from "./examples/Examples"; import { OutputManager } from "./OutputManager"; import { makeChannel } from "sync-message"; -import { RunListener } from "./RunListener"; const LANGUAGE_MAP = new Map([ ["python", ProgrammingLanguage.Python], ["javascript", ProgrammingLanguage.JavaScript] ]); -/** - * Groups values related to running code - */ -interface PapyrosCodeState { - /** - * The currently used programming language - */ - programmingLanguage: ProgrammingLanguage; - /** - * The editor in which the code is written - */ - editor: CodeEditor; - /** - * The backend that executes the code asynchronously - */ - backend: Remote; - /** - * The identifier for the current run - */ - runId: number; -} - /** * Configuration options for this instance of Papyros */ @@ -109,25 +81,13 @@ export class Papyros { */ config: PapyrosConfig; /** - * Component to manage and visualize the state of the program - */ - stateManager: RunStateManager; - /** - * Component to request and handle input from the user + * Component to run code entered by the user */ - inputManager: InputManager; + codeRunner: CodeRunner; /** * Component to handle output generated by the user's code */ outputManager: OutputManager; - /** - * Groups all internal properties related to running code - */ - codeState: PapyrosCodeState; - /** - * Listeners to changes in the running state - */ - runListeners: Array; /** * Whether this instance has been launched */ @@ -139,53 +99,21 @@ export class Papyros { */ constructor(config: PapyrosConfig) { this.launched = false; - this.runListeners = []; this.config = config; // Load translations as other components depend on them loadTranslations(); I18n.locale = config.locale; const { programmingLanguage } = this.config; - this.codeState = { - programmingLanguage: programmingLanguage, - editor: new CodeEditor( - programmingLanguage, - t("Papyros.code_placeholder", { programmingLanguage })), - backend: {} as Remote, - runId: 0 - }; this.outputManager = new OutputManager(); - this.stateManager = new RunStateManager(() => this.runCode(), () => this.stop()); - this.inputManager = new InputManager(() => this.stateManager.setState(RunState.Running), config.inputMode); - this.addRunListener(this.inputManager); - this.addRunListener(this.outputManager); + this.codeRunner = new CodeRunner(programmingLanguage); } /** - * Register a listener to be notified when code runs start or end - * @param {RunListener} listener The new listener + * @return {RunState} The current state of the user's code */ - addRunListener(listener: RunListener): void { - this.runListeners.push(listener); - } - - /** - * Inform the listeners about the current run - * @param {boolean} start Whether the run started or ended - */ - private notifyListeners(start: boolean): void { - if (start) { - this.runListeners.forEach(l => l.onRunStart()); - } else { - this.runListeners.forEach(l => l.onRunEnd()); - } - } - - /** - * Getter for the current state of the program - */ - get state(): RunState { - return this.stateManager.state; + getState(): RunState { + return this.codeRunner.getState(); } /** @@ -195,9 +123,9 @@ export class Papyros { async launch(): Promise { if (!this.launched) { const start = new Date().getTime(); - await this.startBackend(); - papyrosLog(LogType.Important, `Finished loading backend after ${new Date().getTime() - start} ms`); - this.codeState.editor.focus(); + await this.codeRunner.start(); + papyrosLog(LogType.Important, + `Finished loading backend after ${new Date().getTime() - start} ms`); } return this; } @@ -207,42 +135,21 @@ export class Papyros { * @param {ProgrammingLanguage} programmingLanguage The language to use */ async setProgrammingLanguage(programmingLanguage: ProgrammingLanguage): Promise { - if (this.codeState.programmingLanguage !== programmingLanguage) { // Expensive, so ensure it is needed - stopBackend(this.codeState.backend); - this.codeState.programmingLanguage = programmingLanguage; - await this.startBackend(); - } + await this.codeRunner.setProgrammingLanguage(programmingLanguage); } /** * @param {string} code The code to use in the editor */ setCode(code: string): void { - this.codeState.editor.setCode(code); + this.codeRunner.editor.setCode(code); } /** * @return {string} The currently written code */ getCode(): string { - return this.codeState.editor.getCode(); - } - - /** - * Start up the backend for the current programming language - */ - private async startBackend(): Promise { - const programmingLanguage = this.codeState.programmingLanguage; - this.stateManager.setState(RunState.Loading); - const backend = startBackend(programmingLanguage); - // Allow passing messages between worker and main thread - await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); - this.codeState.backend = backend; - this.codeState.editor.setLanguage(programmingLanguage, - async context => await this.codeState.backend.autocomplete(Backend.convertCompletionContext(context)), - t("Papyros.code_placeholder", { programmingLanguage }) - ); - this.stateManager.setState(RunState.Ready); + return this.codeRunner.editor.getCode(); } /** @@ -267,16 +174,21 @@ export class Papyros { if (typeof SharedArrayBuffer === "undefined") { papyrosLog(LogType.Important, "SharedArrayBuffers are not available. "); if (!serviceWorkerRoot || !serviceWorkerName || !("serviceWorker" in navigator)) { - papyrosLog(LogType.Important, "Unable to register service worker. Please specify all required parameters and ensure service workers are supported."); + papyrosLog(LogType.Important, + "Unable to register service worker. Please specify all required parameters and ensure service workers are supported."); return false; } // Ensure there is a slash at the end to allow the script to be resolved - const rootWithSlash = serviceWorkerRoot.endsWith("/") ? serviceWorkerRoot : serviceWorkerRoot + "/"; + const rootWithSlash = serviceWorkerRoot.endsWith("/") ? + serviceWorkerRoot : serviceWorkerRoot + "/"; const serviceWorkerUrl = rootWithSlash + serviceWorkerName; papyrosLog(LogType.Important, `Registering service worker: ${serviceWorkerUrl}`); await window.navigator.serviceWorker.register(serviceWorkerUrl); - this.inputManager.channel = makeChannel({ serviceWorker: { scope: rootWithSlash } })!; - if (allowReload) { // Store that we are reloading, to prevent the next load from doing all this again + this.codeRunner.inputManager.channel = + makeChannel({ serviceWorker: { scope: rootWithSlash } })!; + if (allowReload) { + // Store that we are reloading + // to prevent the next load from doing all this again window.localStorage.setItem(RELOAD_STORAGE_KEY, RELOAD_STORAGE_KEY); // service worker adds new headers that may allow SharedArrayBuffers to be used window.location.reload(); @@ -288,88 +200,6 @@ export class Papyros { } } - /** - * Process PapyrosEvents with type="error" - * @param {PapyrosEvent} e The error-event - */ - private onError(e: PapyrosEvent): void { - papyrosLog(LogType.Debug, "Got error in Papyros: ", e); - this.outputManager.showError(e); - } - /** - * Process PapyrosEvents with type="input" - * @param {PapyrosEvent} e The input-event - */ - private async onInput(e: PapyrosEvent): Promise { - papyrosLog(LogType.Debug, "Received onInput event in Papyros: ", e); - this.stateManager.setState(RunState.AwaitingInput); - await this.inputManager.onInput(e); - } - - /** - * Generic handler function to pass PapyrosEvents to the relevant method - * @param {PapyrosEvent} e The event ro process - */ - onMessage(e: PapyrosEvent): void { - papyrosLog(LogType.Debug, "received event in onMessage", e); - if (e.runId === this.codeState.runId) { // Only process relevant messages - if (e.type === "output") { - this.outputManager.showOutput(e); - } else if (e.type === "input") { - this.onInput(e); - } else if (e.type === "error") { - this.onError(e); - } - } else { - papyrosLog(LogType.Debug, "Received event with outdated runId: ", e); - } - } - - /** - * Run the code that is currently present in the editor - * @return {Promise} Promise of running the code - */ - async runCode(): Promise { - if (this.state !== RunState.Ready) { - papyrosLog(LogType.Error, `Run code called from invalid state: ${this.state}`); - return; - } - // Setup pre-run - this.codeState.runId += 1; - this.stateManager.setState(RunState.Running); - this.notifyListeners(true); - - papyrosLog(LogType.Debug, "Running code in Papyros, sending to backend"); - const start = new Date().getTime(); - try { - await this.codeState.backend.runCode(this.getCode(), this.codeState.runId); - } catch (error: any) { - this.onError(error); - } finally { - const end = new Date().getTime(); - this.stateManager.setState(RunState.Ready, t("Papyros.finished", { time: (end - start) / 1000 })); - this.notifyListeners(false); - } - } - - /** - * Interrupt the currently running code - * @return {Promise} Promise of stopping - */ - async stop(): Promise { - if (![RunState.Running, RunState.AwaitingInput].includes(this.state)) { - papyrosLog(LogType.Error, `Stop called from invalid state: ${this.state}`); - return; - } - papyrosLog(LogType.Debug, "Stopping backend!"); - this.codeState.runId += 1; // ignore messages coming from last run - this.stateManager.setState(RunState.Stopping); - this.notifyListeners(false); - // Since we use workers, the old one must be entirely replaced to interrupt it - stopBackend(this.codeState.backend); - return this.startBackend(); - } - /** * Render Papyros with the given options * @param {PapyrosRenderOptions} renderOptions Options to use @@ -447,22 +277,20 @@ export class Papyros { removeSelection(EXAMPLE_SELECT_ID); // Modify search query params without reloading page history.pushState(null, "", `?locale=${I18n.locale}&language=${pl}`); - } + }, "change", "value" ); addListener(LOCALE_SELECT_ID, locale => { - document.location.href = `?locale=${locale}&language=${this.codeState.programmingLanguage}`; - }); + document.location.href = `?locale=${locale}&language=${this.codeRunner.getProgrammingLanguage()}`; + }, "change", "value"); addListener(EXAMPLE_SELECT_ID, name => { - const code = getCodeForExample(this.codeState.programmingLanguage, name); + const code = getCodeForExample(this.codeRunner.getProgrammingLanguage(), name); this.setCode(code); - }, "input"); + }, "change", "value"); // Ensure there is no initial selection removeSelection(EXAMPLE_SELECT_ID); } - - this.inputManager.render(renderOptions.inputOptions); - const runStatePanel = this.stateManager.render(renderOptions.statusPanelOptions); - this.codeState.editor.render(renderOptions.codeEditorOptions, runStatePanel); + this.codeRunner.render(renderOptions.statusPanelOptions, + renderOptions.inputOptions, renderOptions.codeEditorOptions); this.outputManager.render(renderOptions.outputOptions); } @@ -472,7 +300,7 @@ export class Papyros { * @param {function} onClick Listener for click events on the button */ addButton(options: ButtonOptions, onClick: () => void): void { - this.stateManager.addButton(options, onClick); + this.codeRunner.addButton(options, onClick); } /** diff --git a/src/PapyrosEvent.ts b/src/PapyrosEvent.ts deleted file mode 100644 index 7b97eae9..00000000 --- a/src/PapyrosEvent.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Interface for events used for communication between threads - */ -export interface PapyrosEvent { - /** - * The type of action generating this event - */ - type: "input" | "output" | "success" | "error"; - /** - * The identifier for the run this message is associated with - * This allows discarding outdated events that were delayed - */ - runId: number; - /** - * The actual data stored in this event - */ - data: string; - /** - * The format used for the data to help with parsing - */ - contentType: string; -} diff --git a/src/RunListener.ts b/src/RunListener.ts deleted file mode 100644 index 86ed0e21..00000000 --- a/src/RunListener.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Interface for components that maintain state based on runs of code - */ -export interface RunListener { - /** - * Inform this listener that a new run started - */ - onRunStart(): void; - /** - * Inform this listener that the run ended - */ - onRunEnd(): void; -} diff --git a/src/RunStateManager.ts b/src/RunStateManager.ts deleted file mode 100644 index 61e3039b..00000000 --- a/src/RunStateManager.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, STATE_SPINNER_ID, STOP_BTN_ID } from "./Constants"; -import { svgCircle } from "./util/HTMLShapes"; -import { - addListener, ButtonOptions, renderButton, - RenderOptions, renderWithOptions, getElement, - t -} from "./util/Util"; - -interface DynamicButton { - id: string; - buttonHTML: string; - onClick: () => void; -} - -/** - * Enum representing the possible states while processing code - */ -export enum RunState { - Loading = "loading", - Running = "running", - AwaitingInput = "awaiting_input", - Stopping = "stopping", - Ready = "ready" -} - -/** - * Helper component to manage and visualize the current RunState - */ -export class RunStateManager { - /** - * Current state of the program - */ - state: RunState; - /** - * Buttons managed by this component - */ - buttons: Array; - - /** - * Construct a new RunStateManager with the given listeners - * @param {function} onRunClicked Callback for when the run button is clicked - * @param {function} onStopClicked Callback for when the stop button is clicked - */ - constructor(onRunClicked: () => void, onStopClicked: () => void) { - this.buttons = []; - this.addButton({ - id: RUN_BTN_ID, - buttonText: t("Papyros.run"), - extraClasses: "text-white bg-blue-500" - }, onRunClicked); - this.addButton({ - id: STOP_BTN_ID, - buttonText: t("Papyros.stop"), - extraClasses: "text-white bg-red-500" - }, onStopClicked); - this.state = RunState.Ready; - } - - /** - * Get the button to run the code - */ - get runButton(): HTMLButtonElement { - return getElement(RUN_BTN_ID); - } - - /** - * Get the button to interrupt the code - */ - get stopButton(): HTMLButtonElement { - return getElement(STOP_BTN_ID); - } - - /** - * Show or hide the spinning circle, representing a running animation - * @param {boolean} show Whether to show the spinner - */ - showSpinner(show: boolean): void { - getElement(STATE_SPINNER_ID).style.display = show ? "" : "none"; - } - - - /** - * Show the current state of the program to the user - * @param {RunState} state The current state of the run - * @param {string} message Optional message to indicate the state - */ - setState(state: RunState, message?: string): void { - this.state = state; - this.stopButton.disabled = [RunState.Ready, RunState.Loading].includes(state); - if (state === RunState.Ready) { - this.showSpinner(false); - this.runButton.disabled = false; - } else { - this.showSpinner(true); - this.runButton.disabled = true; - } - getElement(APPLICATION_STATE_TEXT_ID).innerText = - message || t(`Papyros.states.${state}`); - } - - /** - * Add a button to display to the user - * @param {ButtonOptions} options Options for rendering the button - * @param {function} onClick Listener for click events on the button - */ - addButton(options: ButtonOptions, onClick: () => void): void { - this.buttons.push({ - id: options.id, - buttonHTML: renderButton(options), - onClick: onClick - }); - } - - /** - * Render the RunStateManager with the given options - * @param {RenderOptions} options Options for rendering - * @return {HTMLElement} The rendered RunStateManager - */ - render(options: RenderOptions): HTMLElement { - const rendered = renderWithOptions(options, ` -
-
- ${this.buttons.map(b => b.buttonHTML).join("\n")} -
-
-
- ${svgCircle(STATE_SPINNER_ID, "red")} -
-
`); - // Buttons are freshly added to the DOM, so attach listeners now - this.buttons.forEach(b => addListener(b.id, b.onClick, "click")); - return rendered; - } -} diff --git a/src/Translations.js b/src/Translations.js index 0349b93e..0df1fa27 100644 --- a/src/Translations.js +++ b/src/Translations.js @@ -10,13 +10,15 @@ const ENGLISH_TRANSLATION = { "input_placeholder": { "interactive": "Provide input and press enter to send", "batch": "Provide all input required by your code here.\n" + - "You can enter multiple lines by pressing enter." + "You can enter multiple lines by pressing enter.", + "debugging": "Provide a command for the debugger here." }, "input_disabled": "You can only provide input when your code requires it in interactive mode", "output": "Output", "output_placeholder": "The output of your code will appear here", "run": "Run", "stop": "Stop", + "debug": "Debug", "finished": "Code executed in %{time} s", "states": { "running": "Running", @@ -39,7 +41,12 @@ const ENGLISH_TRANSLATION = { "switch_to_batch": "Switch to batch input" }, "enter": "Enter", - "examples": "Examples" + "examples": "Examples", + "debugging_command": { + "step_over": "Step over", + "step_into": "Step into", + "continue": "Continue" + } }; const DUTCH_TRANSLATION = { @@ -50,13 +57,15 @@ const DUTCH_TRANSLATION = { "input_placeholder": { "interactive": "Geef invoer in en druk op enter", "batch": "Geef hier alle invoer die je code nodig heeft vooraf in.\n" + - "Je kan verschillende lijnen ingeven door op enter te drukken." + "Je kan verschillende lijnen ingeven door op enter te drukken.", + "debugging": "Geef hier een commando in voor de debugger." }, "input_disabled": "Je kan enkel invoer invullen als je code erom vraagt in interactieve modus", "output": "Uitvoer", "output_placeholder": "Hier komt de uitvoer van je code", "run": "Run", "stop": "Stop", + "debug": "Debug", "states": { "running": "Aan het uitvoeren", "stopping": "Aan het stoppen", @@ -79,7 +88,12 @@ const DUTCH_TRANSLATION = { "switch_to_batch": "Geef invoer vooraf in" }, "enter": "Enter", - "examples": "Voorbeelden" + "examples": "Voorbeelden", + "debugging_command": { + "step_over": "Stap over", + "step_into": "Stap in", + "continue": "Ga verder" + } }; const TRANSLATIONS = { diff --git a/src/examples/PythonExamples.ts b/src/examples/PythonExamples.ts index 7cda4061..60575ae4 100644 --- a/src/examples/PythonExamples.ts +++ b/src/examples/PythonExamples.ts @@ -128,5 +128,12 @@ x = np.linspace(0, 10, 1000) plt.plot(x, np.sin(x)); plt.show() -` +`, + "Debugging": `def fibonacci(n): + if n <= 1: + return n + else: + return fibonacci(n-2) + fibonacci(n-1) + +print(fibonacci(3))` }; diff --git a/src/extensions/Breakpoints.ts b/src/extensions/Breakpoints.ts new file mode 100644 index 00000000..2c306f10 --- /dev/null +++ b/src/extensions/Breakpoints.ts @@ -0,0 +1,75 @@ +import { RangeSet } from "@codemirror/rangeset"; +import { Extension, StateEffect, StateField } from "@codemirror/state"; +import { gutter, GutterMarker } from "@codemirror/gutter"; +import { + EditorView, +} + from "@codemirror/view"; +export function breakpoints(onToggle: (pos: number, value: boolean) => void): Extension { + const breakpointEffect = StateEffect.define<{ pos: number, on: boolean }>({ + map: (val, mapping) => ({ pos: mapping.mapPos(val.pos), on: val.on }) + }); + + const breakpointState = StateField.define>({ + create() { + return RangeSet.empty; + }, + update(set, transaction) { + let returnSet = set.map(transaction.changes); + for (const e of transaction.effects) { + if (e.is(breakpointEffect)) { + if (e.value.on) { + returnSet = returnSet.update( + { add: [breakpointMarker.range(e.value.pos)] } + ); + } else { + returnSet = returnSet.update({ filter: from => from != e.value.pos }); + } + } + } + return returnSet; + } + }); + + function toggleBreakpoint(view: EditorView, pos: number): void { + const breakpoints = view.state.field(breakpointState); + let hasBreakpoint = false; + breakpoints.between(pos, pos, () => { + hasBreakpoint = true; + }); + // Line numbers start at 1 + const lineNr = view.state.doc.lineAt(pos).number; + onToggle(lineNr, !hasBreakpoint); + view.dispatch({ + effects: breakpointEffect.of({ pos, on: !hasBreakpoint }) + }); + } + + const breakpointMarker = new class extends GutterMarker { + toDOM(): Text { + return document.createTextNode("🔴"); + } + }; + + return [ + breakpointState, + gutter({ + class: "cm-breakpoint-gutter", + markers: v => v.state.field(breakpointState), + initialSpacer: () => breakpointMarker, + domEventHandlers: { + mousedown(view, line) { + toggleBreakpoint(view, line.from); + return true; + } + } + }), + EditorView.baseTheme({ + ".cm-breakpoint-gutter .cm-gutterElement": { + color: "red", + paddingLeft: "5px", + cursor: "default" + } + }) + ]; +} diff --git a/src/input/BatchInputHandler.ts b/src/input/BatchInputHandler.ts index ba6c9958..194dd91a 100644 --- a/src/input/BatchInputHandler.ts +++ b/src/input/BatchInputHandler.ts @@ -1,3 +1,4 @@ +import { INPUT_TA_ID } from "../Constants"; import { InputMode } from "../InputManager"; import { RenderOptions, renderWithOptions } from "../util/Util"; import { UserInputHandler } from "./UserInputHandler"; @@ -16,15 +17,14 @@ export class BatchInputHandler extends UserInputHandler { /** * Construct a new BatchInputHandler * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field */ - constructor(onInput: () => void, inputAreaId: string) { - super(onInput, inputAreaId); + constructor() { + super(); this.lineNr = 0; this.previousInput = ""; } - onToggle(active: boolean): void { + override onToggle(active: boolean): void { if (active) { this.inputArea.value = this.previousInput; } else { @@ -32,14 +32,14 @@ export class BatchInputHandler extends UserInputHandler { } } - getInputMode(): InputMode { + override getInputMode(): InputMode { return InputMode.Batch; } /** * Retrieve the lines of input that the user has given so far * @return {Array} The entered lines */ - private get lines(): Array { + protected get lines(): Array { const l = this.inputArea.value.split("\n"); if (!l[l.length - 1]) { // last line is empty l.splice(l.length - 1); // do not consider it valid input @@ -47,27 +47,27 @@ export class BatchInputHandler extends UserInputHandler { return l; } - hasNext(): boolean { + override hasNext(): boolean { return this.lineNr < this.lines.length; } - next(): string { + override next(): string { const nextLine = this.lines[this.lineNr]; this.lineNr += 1; return nextLine; } - onRunStart(): void { + override onRunStart(): void { this.lineNr = 0; } - onRunEnd(): void { + override onRunEnd(): void { // Intentionally empty } render(options: RenderOptions): HTMLElement { const rendered = renderWithOptions(options, ` -`); @@ -77,7 +77,7 @@ focus:outline-none focus:ring-1 focus:ring-blue-500" rows="5"> if (this.lines.length < this.lineNr) { this.lineNr = this.lines.length - 1; } - this.onInput(); + this.onUserInput(); } }); return rendered; diff --git a/src/input/DebuggingInputHandler.ts b/src/input/DebuggingInputHandler.ts new file mode 100644 index 00000000..573ef193 --- /dev/null +++ b/src/input/DebuggingInputHandler.ts @@ -0,0 +1,129 @@ +import { DEBUGGING_INTERACTIVE_WRAPPER_ID } from "../Constants"; +import { InputMode } from "../InputManager"; +import { + addListener, + renderButton, RenderOptions, renderWithOptions, t +} from "../util/Util"; +import { InteractiveInputHandler } from "./InteractiveInputHandler"; + +/** + * Enum representing commands the user can give during an interactive debugging session + */ +export enum DebuggingCommand { + /** + * The step over command runs code until the next code line in the source file is reached. + * With next line, we mean the actual source code line following the current line + * This will not follow function calls or return to callers. + */ + StepOver = "step_over", + /** + * The step into command runs until another line in the source file is executed. + * This command will e.g. go into function calls or return back to the caller + */ + StepInto = "step_into", + /** + * This command runs until the next breakpoint in the source file is reached. + */ + Continue = "continue" +} +const DEBUGGING_COMMANDS = [ + DebuggingCommand.StepOver, DebuggingCommand.StepInto, DebuggingCommand.Continue +]; + +/** + * Base class for interactive debugging input handlers + * This allows communication between an interactive debugger that uses + * textual commands and the user. + */ +export abstract class DebuggingInputHandler extends InteractiveInputHandler { + /** + * Whether we are handling a debugging command or an actual input call + */ + protected debugging: boolean; + /** + * Maps a command to its textual representation for the debugger + * The command does not need to end with \n, this will be added if missing + */ + private commandMap: Map; + /** + * The command the user picked + */ + private command: DebuggingCommand | undefined; + + /** + * Construct a new DebuggingInputHandler + */ + constructor() { + super(); + this.commandMap = this.buildCommandMap(); + this.command = undefined; + this.debugging = false; + } + + override getInputMode(): InputMode { + return InputMode.Debugging; + } + + /** + * Build the map to convert DebuggingCommands to strings + */ + protected abstract buildCommandMap(): Map; + + override hasNext(): boolean { + return (this.debugging && this.command !== undefined) || + super.hasNext(); + } + + override next(): string { + let nextValue = ""; + if (this.command) { // Process debugging command + nextValue = this.commandMap.get(this.command) || ""; + if (!nextValue.endsWith("\n")) { + nextValue += "\n"; + } + this.reset(); + } else { // Process regular input prompt + nextValue = super.next(); + } + return nextValue; + } + + protected override reset(): void { + this.command = undefined; + this.debugging = false; + super.reset(); + } + + /** + * Callback for when a specific command button is clicked + * @param {DebuggingCommand} command The command attached to the button + */ + protected onCommandButtonClicked(command: DebuggingCommand): void { + if (this.debugging) { + this.command = command; + this.onUserInput(); + } + } + + render(options: RenderOptions): HTMLElement { + const buttons = DEBUGGING_COMMANDS.map(command => { + return renderButton({ + id: command, + buttonText: t(`Papyros.debugging_command.${command}`), + extraClasses: `btn-debugging-${command.replaceAll("_", "-")}` + }); + }).join("\n"); + const rendered = renderWithOptions(options, ` +
+ ${buttons} +
+
+
`); + super.render({ parentElementId: DEBUGGING_INTERACTIVE_WRAPPER_ID }); + // Add listeners after buttons are rendered + DEBUGGING_COMMANDS.forEach(command => { + addListener(command, () => this.onCommandButtonClicked(command), "click"); + }); + return rendered; + } +} diff --git a/src/input/InteractiveInputHandler.ts b/src/input/InteractiveInputHandler.ts index 988e7353..a4726ca8 100644 --- a/src/input/InteractiveInputHandler.ts +++ b/src/input/InteractiveInputHandler.ts @@ -1,46 +1,34 @@ +import { INPUT_TA_ID, SEND_INPUT_BTN_ID } from "../Constants"; import { InputMode } from "../InputManager"; -import { getElement, RenderOptions, renderWithOptions, t } from "../util/Util"; +import { addListener, getElement, RenderOptions, renderWithOptions, t } from "../util/Util"; import { UserInputHandler } from "./UserInputHandler"; +/** + * Input handler that takes input from the user in an interactive fashion + */ export class InteractiveInputHandler extends UserInputHandler { - /** - * HTML identifier for the used HTML button - */ - private sendButtonId: string; - /** - * Construct a new InteractiveInputHandler - * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field - * @param {string} sendButtonId HTML identifier for the used HTML button - */ - constructor(onInput: () => void, inputAreaId: string, - sendButtonId: string) { - super(onInput, inputAreaId); - this.sendButtonId = sendButtonId; - } - /** * Retrieve the button that users can click to send their input */ get sendButton(): HTMLButtonElement { - return getElement(this.sendButtonId); + return getElement(SEND_INPUT_BTN_ID); } - getInputMode(): InputMode { + override getInputMode(): InputMode { return InputMode.Interactive; } - hasNext(): boolean { + override hasNext(): boolean { return this.waiting; // Allow sending empty lines when the user does this explicitly } - next(): string { + override next(): string { const value = this.inputArea.value; - this.inputArea.value = ""; + this.reset(); return value; } - waitWithPrompt(waiting: boolean, prompt?: string): void { + override waitWithPrompt(waiting: boolean, prompt?: string): void { super.waitWithPrompt(waiting, prompt); this.sendButton.disabled = !waiting; this.inputArea.disabled = !waiting; @@ -51,36 +39,35 @@ export class InteractiveInputHandler extends UserInputHandler { } } - onToggle(): void { + override onToggle(): void { this.reset(); } - onRunStart(): void { + override onRunStart(): void { this.reset(); } - onRunEnd(): void { + override onRunEnd(): void { // Intentionally empty } render(options: RenderOptions): HTMLElement { const rendered = renderWithOptions(options, `
- -
`); - getElement(this.sendButtonId) - .addEventListener("click", () => this.onInput()); + addListener(SEND_INPUT_BTN_ID, () => this.onUserInput(), "click"); this.inputArea.addEventListener("keydown", (ev: KeyboardEvent) => { - if (this.waiting && ev.key.toLowerCase() === "enter") { - this.onInput(); + if (this.waiting && ev.key && ev.key.toLowerCase() === "enter") { + this.onUserInput(); } }); return rendered; diff --git a/src/input/PdbInputHandler.ts b/src/input/PdbInputHandler.ts new file mode 100644 index 00000000..ed390015 --- /dev/null +++ b/src/input/PdbInputHandler.ts @@ -0,0 +1,21 @@ +import { DebuggingCommand, DebuggingInputHandler } from "./DebuggingInputHandler"; + +/** + * Implementation of a DebuggingInputHandler for Python with Pdb + */ +export class PdbInputHandler extends DebuggingInputHandler { + protected override buildCommandMap(): Map { + return new Map([ + [DebuggingCommand.StepOver, "n"], + [DebuggingCommand.StepInto, "s"], + [DebuggingCommand.Continue, "c"] + ]); + } + + override waitWithPrompt(waiting: boolean, prompt?: string): void { + if (waiting) { + this.debugging = /^\(Pdb\)/.test(prompt || ""); + } + super.waitWithPrompt(waiting, prompt); + } +} diff --git a/src/input/UserInputHandler.ts b/src/input/UserInputHandler.ts index 594b36d4..1dcb555d 100644 --- a/src/input/UserInputHandler.ts +++ b/src/input/UserInputHandler.ts @@ -1,33 +1,36 @@ +import { INPUT_TA_ID } from "../Constants"; import { InputMode } from "../InputManager"; -import { RunListener } from "../RunListener"; import { getElement, RenderOptions, t } from "../util/Util"; +export interface InputListener { + onUserInput(): void; +} + /** * Base class for components that handle input from the user */ -export abstract class UserInputHandler implements RunListener { +export abstract class UserInputHandler { /** * Whether we are waiting for the user to input data */ protected waiting: boolean; - /** - * Callback for when the user has entered a value - */ - protected onInput: () => void; - /** - * HTML identifier for the used HTML input field - */ - protected inputAreaId: string; + + protected inputListeners: Set; /** * Construct a new UserInputHandler - * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field */ - constructor(onInput: () => void, inputAreaId: string) { + constructor() { this.waiting = false; - this.onInput = onInput; - this.inputAreaId = inputAreaId; + this.inputListeners = new Set(); + } + + public addInputListener(listener: InputListener): void { + this.inputListeners.add(listener); + } + + protected onUserInput(): void { + this.inputListeners.forEach(l => l.onUserInput()); } /** * Whether this handler has input ready @@ -66,7 +69,7 @@ export abstract class UserInputHandler implements RunListener { * Retrieve the HTMLInputElement for this InputHandler */ get inputArea(): HTMLInputElement { - return getElement(this.inputAreaId); + return getElement(INPUT_TA_ID); } /** @@ -89,7 +92,7 @@ export abstract class UserInputHandler implements RunListener { } /** - * Helper method to reset internal state when needed + * Helper method to reset internal state */ protected reset(): void { this.inputArea.value = ""; diff --git a/src/util/Util.ts b/src/util/Util.ts index f44b965d..7e7a47ba 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -115,11 +115,14 @@ type ElementIdentifier = string | HTMLElement; * @param {string} attribute The attribute affected by the event */ export function addListener( - elementId: ElementIdentifier, onEvent: (e: T) => void, eventType = "change", attribute = "value" + elementId: ElementIdentifier, onEvent: (e: T) => void, eventType: string, attribute?: string ): void { const element = getElement(elementId); element.addEventListener(eventType, () => { - onEvent((element as any)[attribute] || element.getAttribute(attribute) as T); + const value = attribute ? + ((element as any)[attribute] || element.getAttribute(attribute) as T) : + ""; + onEvent(value); }); } @@ -197,26 +200,29 @@ export function renderWithOptions( * @return {any} The parsed data */ export function parseData(data: unknown, contentType: string): any { - const [baseType, specificType] = contentType.split("/"); - switch (baseType) { - case "text": { - switch (specificType) { - case "plain": { - return data; - } - case "json": { - return JSON.parse(data as string); + const typeParts = contentType ? contentType.split("/") : []; + if (typeParts.length === 2) { + const [baseType, specificType] = typeParts; + switch (baseType) { + case "text": { + switch (specificType) { + case "plain": { + return data; + } + case "json": { + return JSON.parse(data as string); + } } + break; } - break; - } - case "img": { - switch (specificType) { - case "png;base64": { - return data; + case "img": { + switch (specificType) { + case "png;base64": { + return data; + } } + break; } - break; } } papyrosLog(LogType.Important, `Unhandled content type: ${contentType}`); diff --git a/src/workers/javascript/JavaScriptWorker.worker.ts b/src/workers/javascript/JavaScriptWorker.worker.ts index 0aa6c50a..b13fb552 100644 --- a/src/workers/javascript/JavaScriptWorker.worker.ts +++ b/src/workers/javascript/JavaScriptWorker.worker.ts @@ -2,6 +2,7 @@ import { expose } from "comlink"; import { Backend, WorkerAutocompleteContext } from "../../Backend"; import { CompletionResult } from "@codemirror/autocomplete"; import { javascriptLanguage } from "@codemirror/lang-javascript"; +import { BackendEventType } from "../../BackendEvent"; /** * Implementation of a JavaScript backend for Papyros @@ -41,7 +42,7 @@ class JavaScriptWorker extends Backend { */ private prompt(text = ""): string { return this.onEvent({ - type: "input", + type: BackendEventType.Input, data: JavaScriptWorker.stringify({ prompt: text }), contentType: "text/json", runId: this.runId @@ -54,7 +55,7 @@ class JavaScriptWorker extends Backend { */ private consoleLog(...args: any[]): void { this.onEvent({ - type: "output", + type: BackendEventType.Output, data: JavaScriptWorker.stringify(...args) + "\n", runId: this.runId, contentType: "text/plain" @@ -67,7 +68,7 @@ class JavaScriptWorker extends Backend { */ private consoleError(...args: any[]): void { this.onEvent({ - type: "error", + type: BackendEventType.Error, data: JavaScriptWorker.stringify(...args) + "\n", contentType: "text/plain", runId: this.runId @@ -121,7 +122,7 @@ class JavaScriptWorker extends Backend { return ret; } - override _runCodeInternal(code: string): Promise { + override runCodeInternal(code: string): Promise { // Builtins to store before execution and restore afterwards // Workers do not have access to prompt const oldContent = { @@ -144,7 +145,7 @@ class JavaScriptWorker extends Backend { } catch (error: any) { // try to create a friendly traceback Error.captureStackTrace(error); return Promise.resolve(this.onEvent({ - type: "error", + type: BackendEventType.Error, runId: this.runId, contentType: "text/json", data: JSON.stringify({ @@ -159,6 +160,10 @@ class JavaScriptWorker extends Backend { )(oldContent); } } + + protected debugCodeInternal(code: string): Promise { + return this.runCodeInternal(code); + } } // Default export to be recognized as a TS module diff --git a/src/workers/python/PythonWorker.worker.ts b/src/workers/python/PythonWorker.worker.ts index 7e9afbfc..a6ce2621 100644 --- a/src/workers/python/PythonWorker.worker.ts +++ b/src/workers/python/PythonWorker.worker.ts @@ -1,11 +1,13 @@ import { expose } from "comlink"; import { Backend, WorkerAutocompleteContext } from "../../Backend"; -import { PapyrosEvent } from "../../PapyrosEvent"; +import { BackendEvent } from "../../BackendEvent"; import { LogType, papyrosLog } from "../../util/Logging"; import { Pyodide, PYODIDE_INDEX_URL, PYODIDE_JS_URL } from "./Pyodide"; import { Channel } from "sync-message"; import { CompletionResult } from "@codemirror/autocomplete"; import { parseData } from "../../util/Util"; +import { DebuggingInputHandler } from "../../input/DebuggingInputHandler"; +import { PdbInputHandler } from "../../input/PdbInputHandler"; /* eslint-disable-next-line */ const initPythonString = require("!!raw-loader!./init.py").default; @@ -37,7 +39,7 @@ class PythonWorker extends Backend { } override async launch( - onEvent: (e: PapyrosEvent) => void, + onEvent: (e: BackendEvent) => void, channel: Channel ): Promise { await super.launch(onEvent, channel); @@ -46,18 +48,18 @@ class PythonWorker extends Backend { fullStdLib: false }); // Load our own modules to connect Papyros and Pyodide - await this._runCodeInternal(initPythonString); + await this.runCodeInternal(initPythonString); // Python calls our function with a PyProxy dict or a Js Map, // These must be converted to a PapyrosEvent (JS Object) to allow message passing const eventCallback = (data: any): void => { return this.onEvent(this.convert(data)); }; // Initialize our loaded Papyros module with the callback - this.pyodide.globals.get("init_papyros")(eventCallback); + await this.pyodide.globals.get("init_papyros")(eventCallback); this.initialized = true; } - override async _runCodeInternal(code: string): Promise { + override async runCodeInternal(code: string): Promise { if (this.initialized) { try { // Sometimes a SyntaxError can cause imports to fail @@ -74,6 +76,10 @@ class PythonWorker extends Backend { } } + override async debugCodeInternal(code: string, breakpoints: Set): Promise { + return await this.pyodide.globals.get("debug_code")(code, breakpoints); + } + override async autocomplete(context: WorkerAutocompleteContext): Promise { // Do not await as not strictly required to compute autocompletions diff --git a/src/workers/python/init.py b/src/workers/python/init.py index df5a8e54..14b44df3 100644 --- a/src/workers/python/init.py +++ b/src/workers/python/init.py @@ -1,6 +1,9 @@ +import builtins +import pdb +import os import sys import json -import os +import re from collections.abc import Awaitable import micropip @@ -9,19 +12,112 @@ await micropip.install("python_runner") import python_runner +# Install modules asynchronously to use when the user needs them ft = micropip.install("friendly_traceback") jedi_install = micropip.install("jedi") -# Otherwise `import matplotlib` fails while assuming a browser backend -os.environ["MPLBACKEND"] = "AGG" -# Code is executed in a worker with less resources than ful environment -sys.setrecursionlimit(500) +SYS_RECURSION_LIMIT = 500 # Global Papyros instance papyros = None +# Helper output stream to capture Pdb output +class PdbOutputStream(python_runner.output.SysStream): + def __init__(self, output_buffer): + super().__init__("debug", output_buffer) class Papyros(python_runner.PatchedStdinRunner): + DEFAULT_PDB_MESSAGES = ["--Return--", "--Call--", "--KeyboardInterrupt--"] + def __init__( + self, + *, + callback=None, + limit=SYS_RECURSION_LIMIT + ): + super().__init__() + self.limit = limit + self.debugger = None + self.last_highlight = None + self.override_globals() + self.set_event_callback(callback) + + def process_debugging_message(self, message): + message = message.rstrip() + if not message: + return None + # Pdb file position starts with >, followed by a file name without brackets + # Then line number between brackets, followed by a module name without brackets + # And ends with another set of brackets. We capture the line number + line_nr_match = re.search("> [^\(\)]*\((\d+)[\(\)]*\)", message) + if line_nr_match: + line_nr = int(line_nr_match.group(1)) + if self.filename in message: + if self.last_highlight != line_nr: + self.last_highlight = line_nr + return dict(action="highlight", data=line_nr) + else: # Highlighting same line twice means Pdb reached the end of the file + self.last_highlight = None + self.debugger.set_quit() + else: + return dict(action="unknown", data="No action specified yet for different file: " + message) + elif message not in self.DEFAULT_PDB_MESSAGES: + return dict(action="print", data=message) + else: # Currently just ignore default messages + return None + + def set_event_callback(self, event_callback): + if event_callback is None: + raise ValueError("Event callback must not be None") + + def runner_callback(event_type, data): + def cb(typ, dat, **kwargs): + return event_callback(to_js(dict(type=typ, data=dat, **kwargs))) + + # Translate python_runner events to papyros events + if event_type == "output": + for part in data["parts"]: + typ = part["type"] + if typ in ["stderr", "traceback", "syntax_error"]: + cb("error", part["text"], contentType="text/json") + elif typ == "stdout": + cb("output", part["text"], contentType="text/plain") + elif typ == "img": + cb("output", part["text"], contentType=part["contentType"]) + elif typ in ["input", "input_prompt"]: + continue + elif typ == "debug": + debug = self.process_debugging_message(part["text"]) + if debug: + cb("debug", json.dumps(debug), contentType="text/json") + else: + raise ValueError(f"Unknown output part type {typ}") + elif event_type == "input": + return cb("input", json.dumps(dict(prompt=data["prompt"])), contentType="text/json") + else: + raise ValueError(f"Unknown event type {event_type}") + + self.set_callback(runner_callback) + + def set_file(self, filename, code): + self.filename = os.path.normcase(os.path.abspath(filename)) + with open(self.filename, "w") as f: + f.write(code) + + def override_globals(self): + # Code is executed in a worker with less resources than ful environment + sys.setrecursionlimit(self.limit) + # Otherwise `import matplotlib` fails while assuming a browser backend + os.environ["MPLBACKEND"] = "AGG" + sys.stdin.readline = self.readline + builtins.input = self.input + try: + import matplotlib + except ModuleNotFoundError: + pass + else: + # Only override matplotlib when required by the code + self.override_matplotlib() + def override_matplotlib(self): # workaround from https://github.com/pyodide/pyodide/issues/1518 import base64 @@ -39,10 +135,19 @@ def show(): matplotlib.pyplot.show = show + def pre_run(self, source_code, mode="exec", top_level_await=False): + self.override_globals() + return super().pre_run(source_code, mode=mode, top_level_await=top_level_await) + + def execute(self, code_obj, source_code, mode=None): # noqa + return eval(code_obj, self.console.locals) # noqa + async def run_async(self, source_code, mode="exec", top_level_await=True): """ - Mostly a copy of the parent `run_async` with `await ft` in case of an exception, - because `serialize_traceback` isn't async. + Mostly a copy of the parent `run_async` with some key differences + We use `await ft` in case of an exception, because `serialize_traceback` isn't async. + We call pre_run within to handle its exceptions within this try-block too + As it will rethrow the SyntaxError (@see serialize_syntax_error) """ with self._execute_context(source_code): try: @@ -58,6 +163,25 @@ async def run_async(self, source_code, mode="exec", top_level_await=True): # Let `_execute_context` and `serialize_traceback` # handle the exception raise + + async def debug_code(self, source_code, breakpoints): + code_obj = self.pre_run(source_code, "exec", True) + if not code_obj: + return + with self._execute_context(source_code): + self.debugger = pdb.Pdb(stdout=PdbOutputStream(self.output_buffer)) + self.debugger.use_rawinput = True + for line_nr in breakpoints: + self.debugger.set_break(filename=self.filename, lineno=line_nr) + self.line = "c\n" # Ensure first interrupt is continued until breakpoint + self.debugger.set_trace() + self.output_buffer.flush_length = 1 + result = self.execute(code_obj, source_code, "exec") + while isinstance(result, Awaitable): + result = await result + self.debugger.set_quit() + self.debugger.clear_all_breaks() + return result def serialize_syntax_error(self, exc, source_code): raise # Rethrow to ensure FriendlyTraceback library is imported correctly @@ -105,50 +229,18 @@ def serialize_traceback(self, exc, source_code): ) -def init_papyros(event_callback): +async def init_papyros(event_callback, limit=SYS_RECURSION_LIMIT): global papyros - - def runner_callback(event_type, data): - def cb(typ, dat, **kwargs): - return event_callback(to_js(dict(type=typ, data=dat, **kwargs))) - - # Translate python_runner events to papyros events - if event_type == "output": - for part in data["parts"]: - typ = part["type"] - if typ in ["stderr", "traceback", "syntax_error"]: - cb("error", part["text"], contentType="text/json") - elif typ == "stdout": - cb("output", part["text"], contentType="text/plain") - elif typ == "img": - cb("output", part["text"], contentType=part["contentType"]) - elif typ in ["input", "input_prompt"]: - continue - else: - raise ValueError(f"Unknown output part type {typ}") - elif event_type == "input": - return cb("input", json.dumps(dict(prompt=data["prompt"])), contentType="text/json") - else: - raise ValueError(f"Unknown event type {event_type}") - - papyros = Papyros(callback=runner_callback) - + papyros = Papyros(callback=event_callback, limit=limit) async def process_code(code, filename="my_code.py"): - with open(filename, "w") as f: - f.write(code) - papyros.filename = filename - - try: - import matplotlib - except ModuleNotFoundError: - pass - else: - # Only override matplotlib when required by the code - papyros.override_matplotlib() - + papyros.set_file(filename, code) await papyros.run_async(code) +async def debug_code(code, breakpoints, filename="my_code.py"): + papyros.set_file(filename, code) + await papyros.debug_code(code, breakpoints.to_py()) + def convert_completion(completion, index): converted = dict(type=completion.type, label=completion.name_with_symbols) # if completion.get_signatures(): diff --git a/tsconfig.json b/tsconfig.json index 91dacf7f..ef22bc89 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable", @@ -14,7 +14,7 @@ "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "module": "esnext", + "module": "es6", "moduleResolution": "node", "resolveJsonModule": true, "noEmit": false, diff --git a/yarn.lock b/yarn.lock index 2ca00d5c..4b8a4e57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -544,6 +544,13 @@ dependencies: "@codemirror/state" "^0.19.0" +"@codemirror/rangeset@^0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@codemirror/rangeset/-/rangeset-0.19.9.tgz#e80895de93c39dc7899f5be31d368c9d88aa4efc" + integrity sha512-V8YUuOvK+ew87Xem+71nKcqu1SXd5QROMRLMS/ljT5/3MCxtgrRie1Cvild0G/Z2f1fpWxzX78V0U4jjXBorBQ== + dependencies: + "@codemirror/state" "^0.19.0" + "@codemirror/rectangular-selection@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@codemirror/rectangular-selection/-/rectangular-selection-0.19.1.tgz#5a88ece4fb68ce5682539497db8a64fc015aae63"