Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support debugging in the browser #109

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion scripts/ValidateTranslations.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ const checks = [
"Papyros.locales.*",
"Papyros.states.*",
"Papyros.input_modes.switch_to_*",
"Papyros.input_placeholder.*"
"Papyros.input_placeholder.*",
"Papyros.debugging_command.*"
]
}
];
Expand Down
40 changes: 31 additions & 9 deletions src/Backend.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -18,7 +18,7 @@ export interface WorkerAutocompleteContext {
}

export abstract class Backend {
protected onEvent: (e: PapyrosEvent) => any;
protected onEvent: (e: BackendEvent) => any;
protected runId: number;

/**
Expand All @@ -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<void>} Promise of launching
*/
launch(
onEvent: (e: PapyrosEvent) => void,
onEvent: (e: BackendEvent) => void,
channel: Channel
): Promise<void> {
// 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;
Expand All @@ -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);
Expand All @@ -71,19 +71,41 @@ 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<any>;
protected abstract runCodeInternal(code: string): Promise<void>;

/**
* Executes the given code
* @param {string} code The code to run
* @param {string} runId The uuid for this execution
* @return {Promise<void>} Promise of execution
*/
async runCode(code: string, runId: number): Promise<any> {
async runCode(code: string, runId: number): Promise<void> {
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<number>} breakpoints The line numbers where the user put a breakpoint
* @return {Promise<void>} Promise of debugging
*/
debugCode(code: string, runId: number, breakpoints: Set<number>): Promise<void> {
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<number>} breakpoints The line numbers where the user put a breakpoint
* @return {Promise<void>} Promise of debugging
*/
protected abstract debugCodeInternal(code: string, breakpoints: Set<number>): Promise<any>;

/**
* Converts the context to a cloneable object containing useful properties
* to generate autocompletion suggestions with
Expand Down
41 changes: 41 additions & 0 deletions src/BackendEvent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
113 changes: 81 additions & 32 deletions src/BackendManager.ts
Original file line number Diff line number Diff line change
@@ -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<Remote<Backend>, Worker> = new Map();

const CREATE_WORKER_MAP: Map<ProgrammingLanguage, () => 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<Backend>} A Comlink proxy for the Backend
* Callback type definition for subscribers
* @param {BackendEvent} e The published event
*/
export function startBackend(language: ProgrammingLanguage): Remote<Backend> {
if (CREATE_WORKER_MAP.has(language)) {
const worker = CREATE_WORKER_MAP.get(language)!();
const backend = wrap<Backend>(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>} backend The proxy for the backend to stop
* Abstract class to implement the singleton pattern
* Static methods group functionality
*/
export function stopBackend(backend: Remote<Backend>): 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<Remote<Backend>, Worker> = new Map();
static createWorkerMap: Map<ProgrammingLanguage, () => 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<BackendEventType, Array<BackendEventListener>> = 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<Backend>} A Comlink proxy for the Backend
*/
static startBackend(language: ProgrammingLanguage): Remote<Backend> {
if (this.createWorkerMap.has(language)) {
const worker = this.createWorkerMap.get(language)!();
const backend = wrap<Backend>(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>} backend The proxy for the backend to stop
*/
static stopBackend(backend: Remote<Backend>): 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));
}
}
}

55 changes: 42 additions & 13 deletions src/CodeEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -56,38 +56,67 @@ 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<number>;

/**
* 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()
),
this.indentCompartment.of(
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
Expand All @@ -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 })))
]
});
}
Expand Down Expand Up @@ -221,7 +250,7 @@ export class CodeEditor {
* - [indenting with tab](#commands.indentWithTab)
* @return {Array<Extension} Default extensions to use
*/
static getExtensions(): Array<Extension> {
static getDefaultExtensions(): Array<Extension> {
return [
lineNumbers(),
highlightActiveLineGutter(),
Expand Down
Loading