From 62da4a702fb4aa2577721fe5653da39ab8e976d9 Mon Sep 17 00:00:00 2001 From: larrywang0701 <113608053+larrywang0701@users.noreply.github.com> Date: Wed, 1 Nov 2023 06:11:15 +0800 Subject: [PATCH] Update REPL module (#261) * Update REPL module - Add drag-to-resize feature between editor area and output area - Adjustments as Professor Martin instructed: - Adjust the font and color of the display messages and execution result messages - Rename function `module_display` to `repl_display` - Fix documentation for the REPL module * Add files via upload * Add files via upload * Delete src/bundles/repl/evaluators.ts * Improve documentation * Improve documentation --- src/bundles/repl/config.ts | 10 + src/bundles/repl/evaluators.ts | 12 - src/bundles/repl/functions.ts | 180 +++++---- src/bundles/repl/index.ts | 120 +++--- src/bundles/repl/programmable_repl.ts | 518 +++++++++++++------------- src/tabs/Repl/index.tsx | 290 ++++++++------ 6 files changed, 603 insertions(+), 527 deletions(-) create mode 100644 src/bundles/repl/config.ts delete mode 100644 src/bundles/repl/evaluators.ts diff --git a/src/bundles/repl/config.ts b/src/bundles/repl/config.ts new file mode 100644 index 000000000..e873007b1 --- /dev/null +++ b/src/bundles/repl/config.ts @@ -0,0 +1,10 @@ +export const COLOR_REPL_DISPLAY_DEFAULT = 'cyan'; +export const COLOR_RUN_CODE_RESULT = 'white'; +export const COLOR_ERROR_MESSAGE = 'red'; +export const FONT_MESSAGE = { + fontFamily: 'Inconsolata, Consolas, monospace', + fontSize: '16px', + fontWeight: 'normal', +}; +export const DEFAULT_EDITOR_HEIGHT = 375; +export const MINIMUM_EDITOR_HEIGHT = 40; diff --git a/src/bundles/repl/evaluators.ts b/src/bundles/repl/evaluators.ts deleted file mode 100644 index cc5a92f30..000000000 --- a/src/bundles/repl/evaluators.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * When use this function as the entrance function in the parameter of "set_evaluator", the Programmable Repl will directly use the default js-slang interpreter to run your program in Programmable Repl editor. Do not directly call this function in your own code. - * @param {program} Do not directly set this parameter in your code. - * @param {safeKey} A parameter that is designed to prevent student from directly calling this function in Source language. - * - * @category Main - */ -export function default_js_slang(_program: string) : any { - throw new Error('Invaild Call: Function "default_js_slang" can not be directly called by user\'s code in editor. You should use it as the parameter of the function "set_evaluator"'); - // When the function is normally called by set_evaluator function, safeKey is set to "document.body", which has a type "Element". - // Students can not create objects and use HTML Elements in Source due to limitations and rules in Source, so they can't set the safeKey to a HTML Element, thus they can't use this function in Source. -} diff --git a/src/bundles/repl/functions.ts b/src/bundles/repl/functions.ts index d2edbdd7b..605d1c407 100644 --- a/src/bundles/repl/functions.ts +++ b/src/bundles/repl/functions.ts @@ -1,68 +1,112 @@ -/** - * Functions for Programmable REPL - * @module repl - * @author Wang Zihan - */ - -import context from 'js-slang/context'; -import { ProgrammableRepl } from './programmable_repl'; - -const INSTANCE = new ProgrammableRepl(); -context.moduleContexts.repl.state = INSTANCE; -/** - * Setup the programmable REPL with given metacircular evaulator entrance function - * @param {evalFunc} evalFunc - metacircular evaulator entrance function - * - * @category Main - */ -export function set_evaluator(evalFunc: Function) { - if (!(evalFunc instanceof Function)) { - const typeName = typeof (evalFunc); - throw new Error(`Wrong parameter type "${typeName}' in function "set_evaluator". It supposed to be a function and it's the entrance function of your metacircular evaulator.`); - } - INSTANCE.evalFunction = evalFunc; - return { - toReplString: () => '', - }; -} - - -/** - * Redirects the display message into Programmable Repl Tab - * If you give a pair as the parameter, it will use the given pair to generate rich text and use rich text display mode to display the string in Programmable Repl Tab with undefined return value (see module description for more information). - * If you give other things as the parameter, it will simply display the toString value of the parameter in Programmable Repl Tab and returns the displayed string itself. - * @param {content} the content you want to display - * - * @category Main - */ -export function module_display(content: any) : any { - if (INSTANCE.richDisplayInternal(content) === 'not_rich_text_pair') { - INSTANCE.pushOutputString(content.toString(), 'white', 'plaintext');// students may set the value of the parameter "str" to types other than a string (for example "module_display(1)" ). So here I need to first convert the parameter "str" into a string before preceding. - return content; - } - return undefined; -} - - -/** - * Set Programmable Repl editor background image with a customized image URL - * @param {img_url} the url to the new background image - * @param {background_color_alpha} the alpha (transparency) of the original background color that covers on your background image [0 ~ 1]. Recommended value is 0.5 . - * - * @category Main - */ -export function set_background_image(img_url: string, background_color_alpha: number) : void { - INSTANCE.customizedEditorProps.backgroundImageUrl = img_url; - INSTANCE.customizedEditorProps.backgroundColorAlpha = background_color_alpha; -} - - -/** - * Set Programmable Repl editor font size - * @param {font_size_px} font size (in pixel) - * - * @category Main - */ -export function set_font_size(font_size_px: number) { - INSTANCE.customizedEditorProps.fontSize = parseInt(font_size_px.toString());// The TypeScript type checker will throw an error as "parseInt" in TypeScript only accepts one string as parameter. -} +/** + * Functions for Programmable REPL + * @module repl + * @author Wang Zihan + */ + +import context from 'js-slang/context'; +import { ProgrammableRepl } from './programmable_repl'; +import { COLOR_REPL_DISPLAY_DEFAULT } from './config'; + +const INSTANCE = new ProgrammableRepl(); +context.moduleContexts.repl.state = INSTANCE; +/** + * Setup the programmable REPL with given metacircular evaulator entrance function + * @param {evalFunc} evalFunc - metacircular evaulator entrance function + * + * @category Main + */ +export function set_evaluator(evalFunc: Function) { + if (!(evalFunc instanceof Function)) { + const typeName = typeof (evalFunc); + throw new Error(`Wrong parameter type "${typeName}' in function "set_evaluator". It supposed to be a function and it's the entrance function of your metacircular evaulator.`); + } + INSTANCE.evalFunction = evalFunc; + return { + toReplString: () => '', + }; +} + + +/** + * Display message in Programmable Repl Tab + * If you give a pair as the parameter, it will use the given pair to generate rich text and use rich text display mode to display the string in Programmable Repl Tab with undefined return value (see module description for more information). + * If you give other things as the parameter, it will simply display the toString value of the parameter in Programmable Repl Tab and returns the displayed string itself. + * + * **Rich Text Display** + * - First you need to `import { repl_display } from "repl";` + * - Format: pair(pair("string",style),style)... + * - Examples: + * + * ```js + * // A large italic underlined "Hello World" + * repl_display(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic")); + * + * // A large italic underlined "Hello World" in blue + * repl_display(pair(pair(pair(pair(pair("Hello World", "underline"),"italic"), "bold"), "gigantic"), "clrt#0000ff")); + * + * // A large italic underlined "Hello World" with orange foreground and purple background + * repl_display(pair(pair(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic"), "clrb#A000A0"),"clrt#ff9700")); + * ``` + * + * - Coloring: + * - `clrt` stands for text color, `clrb` stands for background color. The color string are in hexadecimal begin with "#" and followed by 6 hexadecimal digits. + * - Example: `pair("123","clrt#ff0000")` will produce a red "123"; `pair("456","clrb#00ff00")` will produce a green "456". + * - Besides coloring, the following styles are also supported: + * - `bold`: Make the text bold. + * - `italic`: Make the text italic. + * - `small`: Make the text in small size. + * - `medium`: Make the text in medium size. + * - `large`: Make the text in large size. + * - `gigantic`: Make the text in very large size. + * - `underline`: Underline the text. + * - Note that if you apply the conflicting attributes together, only one conflicted style will take effect and other conflicting styles will be discarded, like "pair(pair(pair("123", small), medium), large) " (Set conflicting font size for the same text) + * - Also note that for safety matters, certain words and characters are not allowed to appear under rich text display mode. + * + * @param {content} the content you want to display + * @category Main + */ +export function repl_display(content: any) : any { + if (INSTANCE.richDisplayInternal(content) === 'not_rich_text_pair') { + INSTANCE.pushOutputString(content.toString(), COLOR_REPL_DISPLAY_DEFAULT, 'plaintext');// students may set the value of the parameter "str" to types other than a string (for example "repl_display(1)" ). So here I need to first convert the parameter "str" into a string before preceding. + return content; + } + return undefined; +} + + +/** + * Set Programmable Repl editor background image with a customized image URL + * @param {img_url} the url to the new background image + * @param {background_color_alpha} the alpha (transparency) of the original background color that covers on your background image [0 ~ 1]. Recommended value is 0.5 . + * + * @category Main + */ +export function set_background_image(img_url: string, background_color_alpha: number) : void { + INSTANCE.customizedEditorProps.backgroundImageUrl = img_url; + INSTANCE.customizedEditorProps.backgroundColorAlpha = background_color_alpha; +} + + +/** + * Set Programmable Repl editor font size + * @param {font_size_px} font size (in pixel) + * + * @category Main + */ +export function set_font_size(font_size_px: number) { + INSTANCE.customizedEditorProps.fontSize = parseInt(font_size_px.toString());// The TypeScript type checker will throw an error as "parseInt" in TypeScript only accepts one string as parameter. +} + +/** + * When use this function as the entrance function in the parameter of "set_evaluator", the Programmable Repl will directly use the default js-slang interpreter to run your program in Programmable Repl editor. Do not directly call this function in your own code. + * @param {program} Do not directly set this parameter in your code. + * @param {safeKey} A parameter that is designed to prevent student from directly calling this function in Source language. + * + * @category Main + */ +export function default_js_slang(_program: string) : any { + throw new Error('Invaild Call: Function "default_js_slang" can not be directly called by user\'s code in editor. You should use it as the parameter of the function "set_evaluator"'); + // When the function is normally called by set_evaluator function, safeKey is set to "document.body", which has a type "Element". + // Students can not create objects and use HTML Elements in Source due to limitations and rules in Source, so they can't set the safeKey to a HTML Element, thus they can't use this function in Source. +} diff --git a/src/bundles/repl/index.ts b/src/bundles/repl/index.ts index 3a7fc78c3..ec02024a2 100644 --- a/src/bundles/repl/index.ts +++ b/src/bundles/repl/index.ts @@ -1,74 +1,46 @@ -/** - * Bundle for Source Academy Programmable REPL module - * @module repl - * @author Wang Zihan - */ - -/* - -Example on usage: - <*> Use with metacircular evaluator: - - import { set_evaluator, module_display } from "repl"; - - const primitive_functions = list( - ...... - list("display", module_display ), // Here change this from "display" to "module_display" to let the display result goes to the repl tab. - ...... - } - - function parse_and_evaluate(code){ - (your metacircular evaluator entry function) - } - - set_evaluator(parse_and_evaluate); // This can invoke the repl with your metacircular evaluator's evaluation entry - - =*=*=*=*=*= I'm the deluxe split line :) =*=*=*=*=*= - - <*> Use with Source Academy's builtin js-slang - import { set_evaluator, default_js_slang, module_display } from "repl"; // Here you also need to import "module_display" along with "set_evaluator" and "default_js_slang". - - set_evaluator(default_js_slang); // This can invoke the repl with Source Academy's builtin js-slang evaluation entry - - (Note that you can't directly call "default_js_slang" in your own code. It should only be used as the parameter of "set_evaluator") - - =*=*=*=*=*= I'm the deluxe split line :) =*=*=*=*=*= - - <*> Customize Editor Appearance - import { set_background_image, set_font_size } from "repl"; - set_background_image("https://www.some_image_website.xyz/your_favorite_image.png"); // Set the background image of the editor in repl tab - set_font_size(20.5); // Set the font size of the editor in repl tab - - =*=*=*=*=*= I'm the deluxe split line :) =*=*=*=*=*= - - <*> Rich Text Display - first import { module_display } from "repl"; - Format: pair(pair("string",style),style)... - - Examples: - module_display(pair(pair(pair(pair("Hello World","underline"),"italic"),"bold"),"gigantic")); - module_display(pair(pair(pair(pair(pair(pair("Hello World","underline"),"italic"),"bold"),"gigantic"),"clrb#FF00B9"),"clrt#ff9700")); - module_display(pair(pair(pair(pair(pair("Hello World","underline"),"italic"),"bold"),"gigantic"),"clrt#0ff1ed")); - - Coloring: "clrt" stands for text color, "clrb" stands for background color. The color string are in hexadecimal begin with "#" and followed by 6 hexadecimal digits. - Example: pair("123","clrt#ff0000") will produce a red "123"; pair("456","clrb#00ff00") will produce a green "456". - Besides coloring, the following styles are also supported: - bold: Make the text bold. - italic: Make the text italic. - small: Make the text in small size. - medium: Make the text in medium size. - large: Make the text in large size. - gigantic: Make the text in very large size. - underline: Underline the text. - Note that if you apply the conflicting attributes together, only one conflicted style will take effect and other conflicting styles will be discarded, like " pair(pair(pair("123",small),medium),large) " (Set conflicting font size for the same text) - Also note that for safety matters, certain words and characters are not allowed to appear under rich text display mode. -*/ - -export { - set_evaluator, - module_display, - set_background_image, - set_font_size, -} from './functions'; - -export * from './evaluators'; +/** + * ## Example of usage: + * ### Use with metacircular evaluator: + * ```js + * import { set_evaluator, repl_display } from "repl"; + * + * const primitive_functions = list( + * // (omitted other primitive functions) + * list("display", repl_display), // Here change this from "display" to "repl_display" to let the display result goes to the repl tab. + * // (omitted other primitive functions) + * } + * + * function parse_and_evaluate(code){ + * // (your metacircular evaluator entry function) + * } + * + * set_evaluator(parse_and_evaluate); // This can invoke the repl with your metacircular evaluator's evaluation entry + * ``` + * + * ### Use with Source Academy's builtin js-slang + * ```js + * import { set_evaluator, default_js_slang, repl_display } from "repl"; // Here you also need to import "repl_display" along with "set_evaluator" and "default_js_slang". + * + * set_evaluator(default_js_slang); // This can invoke the repl with Source Academy's builtin js-slang evaluation entry + * ``` + * (Note that you can't directly call "default_js_slang" in your own code. It should only be used as the parameter of "set_evaluator") + * + * + * ### Customize Editor Appearance + * ```js + * import { set_background_image, set_font_size } from "repl"; + * set_background_image("https://www.some_image_website.xyz/your_favorite_image.png"); // Set the background image of the editor in repl tab + * set_font_size(20.5); // Set the font size of the editor in repl tab + * ``` + * + * @module repl + * @author Wang Zihan +*/ + +export { + set_evaluator, + repl_display, + set_background_image, + set_font_size, + default_js_slang, +} from './functions'; diff --git a/src/bundles/repl/programmable_repl.ts b/src/bundles/repl/programmable_repl.ts index 81df8a1de..6e0b67f55 100644 --- a/src/bundles/repl/programmable_repl.ts +++ b/src/bundles/repl/programmable_repl.ts @@ -1,257 +1,261 @@ -/** - * Source Academy Programmable REPL module - * @module repl - * @author Wang Zihan - */ - -import context from 'js-slang/context'; -import { default_js_slang } from './evaluators'; -import { runFilesInContext, type IOptions } from 'js-slang'; - -export class ProgrammableRepl { - public evalFunction: Function; - public userCodeInEditor: string; - public outputStrings: any[]; - private _editorInstance; - private _tabReactComponent: any; - - public customizedEditorProps = { - backgroundImageUrl: 'no-background-image', - backgroundColorAlpha: 1, - fontSize: 17, - }; - - constructor() { - this.evalFunction = (_placeholder) => this.easterEggFunction(); - this.userCodeInEditor = this.getSavedEditorContent(); - this.outputStrings = []; - this._editorInstance = null;// To be set when calling "SetEditorInstance" in the ProgrammableRepl Tab React Component render function. - developmentLog(this); - } - - InvokeREPL_Internal(evalFunc: Function) { - this.evalFunction = evalFunc; - } - - runCode() { - this.outputStrings = []; - let retVal: any; - try { - if (Object.is(this.evalFunction, default_js_slang)) { - retVal = this.runInJsSlang(this.userCodeInEditor); - } else { - retVal = this.evalFunction(this.userCodeInEditor); - } - } catch (exception: any) { - console.log(exception); - this.pushOutputString(`Line ${exception.location.start.line.toString()}: ${exception.error.message}`, 'red'); - this.reRenderTab(); - return; - } - if (retVal === undefined) { - this.pushOutputString('Program exit with undefined return value.', 'cyan'); - } else { - if (typeof (retVal) === 'string') { - retVal = `"${retVal}"`; - } - // Here must use plain text output mode because retVal contains strings from the users. - this.pushOutputString(`Program exit with return value ${retVal}`, 'cyan'); - } - this.reRenderTab(); - developmentLog('RunCode finished'); - } - - updateUserCode(code) { - this.userCodeInEditor = code; - } - - // Rich text output method allow output strings to have html tags and css styles. - pushOutputString(content : string, textColor : string, outputMethod : string = 'plaintext') { - let tmp = { - content, - color: textColor, - outputMethod, - }; - this.outputStrings.push(tmp); - } - - setEditorInstance(instance: any) { - if (instance === undefined) return; // It seems that when calling this function in gui->render->ref, the React internal calls this function for multiple times (at least two times) , and in at least one call the parameter 'instance' is set to 'undefined'. If I don't add this if statement, the program will throw a runtime error when rendering tab. - this._editorInstance = instance; - this._editorInstance.on('guttermousedown', (e) => { - const breakpointLine = e.getDocumentPosition().row; - developmentLog(breakpointLine); - }); - - this._editorInstance.setOptions({ fontSize: `${this.customizedEditorProps.fontSize.toString()}pt` }); - } - - richDisplayInternal(pair_rich_text) { - developmentLog(pair_rich_text); - const head = (pair) => pair[0]; - const tail = (pair) => pair[1]; - const is_pair = (obj) => obj instanceof Array && obj.length === 2; - if (!is_pair(pair_rich_text)) return 'not_rich_text_pair'; - function checkColorStringValidity(htmlColor:string) { - if (htmlColor.length !== 7) return false; - if (htmlColor[0] !== '#') return false; - for (let i = 1; i < 7; i++) { - const char = htmlColor[i]; - developmentLog(` ${char}`); - if (!((char >= '0' && char <= '9') || (char >= 'A' && char <= 'F') || (char >= 'a' && char <= 'f'))) { - return false; - } - } - return true; - } - function recursiveHelper(thisInstance, param): string { - if (typeof (param) === 'string') { - // There MUST be a safe check on users' strings, because users may insert something that can be interpreted as executable JavaScript code when outputing rich text. - const safeCheckResult = thisInstance.userStringSafeCheck(param); - if (safeCheckResult !== 'safe') { - throw new Error(`For safety matters, the character/word ${safeCheckResult} is not allowed in rich text output. Please remove it or use plain text output mode and try again.`); - } - developmentLog(head(param)); - return `">${param}`; - // return param; - } - if (!is_pair(param)) { - throw new Error(`Unexpected data type ${typeof (param)} when processing rich text. It should be a pair.`); - } else { - const pairStyleToCssStyle : { [pairStyle : string] : string } = { - bold: 'font-weight:bold;', - italic: 'font-style:italic;', - small: 'font-size: 14px;', - medium: 'font-size: 20px;', - large: 'font-size: 25px;', - gigantic: 'font-size: 50px;', - underline: 'text-decoration: underline;', - }; - if (typeof (tail(param)) !== 'string') { - throw new Error(`The tail in style pair should always be a string, but got ${typeof (tail(param))}.`); - } - let style = ''; - if (tail(param) - .substring(0, 3) === 'clr') { - let prefix = ''; - if (tail(param)[3] === 't') prefix = 'color:'; - else if (tail(param)[3] === 'b') prefix = 'background-color:'; - else throw new Error('Error when decoding rich text color data'); - const colorHex = tail(param) - .substring(4); - if (!checkColorStringValidity(colorHex)) { - throw new Error(`Invalid html color string ${colorHex}. It should start with # and followed by 6 characters representing a hex number.`); - } - style = `${prefix + colorHex};`; - } else { - style = pairStyleToCssStyle[tail(param)]; - if (style === undefined) { - throw new Error(`Found undefined style ${tail(param)} during processing rich text.`); - } - } - // return `${recursiveHelper(thisInstance, head(param))}`; - return style + recursiveHelper(thisInstance, head(param)); - } - } - this.pushOutputString(`', 'script', 'javascript', 'eval', 'document', 'window', 'console', 'location']; - for (let word of forbiddenWords) { - if (tmp.indexOf(word) !== -1) { - return word; - } - } - return 'safe'; - } - - /* - Directly invoking Source Academy's builtin js-slang runner. - Needs hard-coded support from js-slang part for the "sourceRunner" function and "backupContext" property in the content object for this to work. - */ - runInJsSlang(code: string): string { - developmentLog('js-slang context:'); - // console.log(context); - const options : Partial = { - originalMaxExecTime: 1000, - scheduler: 'preemptive', - stepLimit: 1000, - throwInfiniteLoops: true, - useSubst: false, - }; - context.prelude = 'const display=(x)=>module_display(x);'; - context.errors = []; // Here if I don't manually clear the "errors" array in context, the remaining errors from the last evaluation will stop the function "preprocessFileImports" in preprocessor.ts of js-slang thus stop the whole evaluation. - const sourceFile : Record = { - '/ReplModuleUserCode.js': code, - }; - - runFilesInContext(sourceFile, '/ReplModuleUserCode.js', context, options) - .then((evalResult) => { - if (evalResult.status === 'suspended' || evalResult.status === 'suspended-ec-eval') { - throw new Error('This should not happen'); - } - if (evalResult.status !== 'error') { - this.pushOutputString('js-slang program finished with value:', 'cyan'); - // Here must use plain text output mode because evalResult.value contains strings from the users. - this.pushOutputString(evalResult.value === undefined ? 'undefined' : evalResult.value.toString(), 'cyan'); - } else { - const errors = context.errors; - console.log(errors); - const errorCount = errors.length; - for (let i = 0; i < errorCount; i++) { - const error = errors[i]; - if (error.explain() - .indexOf('Name module_display not declared.') !== -1) { - this.pushOutputString('[Error] It seems that you haven\'t import the function "module_display" correctly when calling "set_evaluator" in Source Academy\'s main editor.', 'red'); - } else this.pushOutputString(`Line ${error.location.start.line}: ${error.type} Error: ${error.explain()} (${error.elaborate()})`, 'red'); - } - } - this.reRenderTab(); - }); - - return 'Async run in js-slang'; - } - - setTabReactComponentInstance(tab : any) { - this._tabReactComponent = tab; - } - - private reRenderTab() { - this._tabReactComponent.setState({});// Forces the tab React Component to re-render using setState - } - - saveEditorContent() { - localStorage.setItem('programmable_repl_saved_editor_code', this.userCodeInEditor.toString()); - this.pushOutputString('Saved', 'lightgreen'); - this.pushOutputString('The saved code is stored locally in your browser. You may lose the saved code if you clear browser data or use another device.', 'gray', 'richtext'); - this.reRenderTab(); - } - - private getSavedEditorContent() { - let savedContent = localStorage.getItem('programmable_repl_saved_editor_code'); - if (savedContent === null) return ''; - return savedContent; - } - - // Small Easter Egg that doesn't affect module functionality and normal user experience :) - // Please don't modify these text! Thanks! :) - private easterEggFunction() { - this.pushOutputString('[Author (Wang Zihan)] ❤I love Keqing and Ganyu.❤', 'pink', 'richtext'); - this.pushOutputString('Showing my love to my favorite girls through a SA module, is that the so-called "romance of a programmer"?', 'gray', 'richtext'); - this.pushOutputString('❤❤❤❤❤', 'pink'); - this.pushOutputString('
', 'white', 'richtext'); - this.pushOutputString('If you see this, please check whether you have called invoke_repl function with the correct parameter before using the Programmable Repl Tab.', 'yellow', 'richtext'); - return 'Easter Egg!'; - } -} - -// Comment all the codes inside this function before merging the code to github as production version. -// Because console.log() can expose the sandboxed VM location to students thus may cause security concerns. -function developmentLog(_content) { - // console.log(`[Programmable Repl Log] ${_content}`); -} +/** + * Source Academy Programmable REPL module + * @module repl + * @author Wang Zihan + */ + + +import context from 'js-slang/context'; +import { default_js_slang } from './functions'; +import { runFilesInContext, type IOptions } from 'js-slang'; +import { COLOR_RUN_CODE_RESULT, COLOR_ERROR_MESSAGE, DEFAULT_EDITOR_HEIGHT } from './config'; + +export class ProgrammableRepl { + public evalFunction: Function; + public userCodeInEditor: string; + public outputStrings: any[]; + private _editorInstance; + private _tabReactComponent: any; + // I store editorHeight value separately in here although it is already stored in the module's Tab React component state because I need to keep the editor height + // when the Tab component is re-mounted due to the user drags the area between the module's Tab and Source Academy's original REPL to resize the module's Tab height. + public editorHeight : number; + + public customizedEditorProps = { + backgroundImageUrl: 'no-background-image', + backgroundColorAlpha: 1, + fontSize: 17, + }; + + constructor() { + this.evalFunction = (_placeholder) => this.easterEggFunction(); + this.userCodeInEditor = this.getSavedEditorContent(); + this.outputStrings = []; + this._editorInstance = null;// To be set when calling "SetEditorInstance" in the ProgrammableRepl Tab React Component render function. + this.editorHeight = DEFAULT_EDITOR_HEIGHT; + developmentLog(this); + } + + InvokeREPL_Internal(evalFunc: Function) { + this.evalFunction = evalFunc; + } + + runCode() { + this.outputStrings = []; + let retVal: any; + try { + if (Object.is(this.evalFunction, default_js_slang)) { + retVal = this.runInJsSlang(this.userCodeInEditor); + } else { + retVal = this.evalFunction(this.userCodeInEditor); + } + } catch (exception: any) { + console.log(exception); + this.pushOutputString(`Line ${exception.location.start.line.toString()}: ${exception.error.message}`, COLOR_ERROR_MESSAGE); + this.reRenderTab(); + return; + } + if (retVal === undefined) { + this.pushOutputString('Program exited with undefined return value.', COLOR_RUN_CODE_RESULT); + } else { + if (typeof (retVal) === 'string') { + retVal = `"${retVal}"`; + } + // Here must use plain text output mode because retVal contains strings from the users. + this.pushOutputString(`Program exited with return value ${retVal}`, COLOR_RUN_CODE_RESULT); + } + this.reRenderTab(); + developmentLog('RunCode finished'); + } + + updateUserCode(code) { + this.userCodeInEditor = code; + } + + // Rich text output method allow output strings to have html tags and css styles. + pushOutputString(content : string, textColor : string, outputMethod : string = 'plaintext') { + let tmp = { + content, + color: textColor, + outputMethod, + }; + this.outputStrings.push(tmp); + } + + setEditorInstance(instance: any) { + if (instance === undefined) return; // It seems that when calling this function in gui->render->ref, the React internal calls this function for multiple times (at least two times) , and in at least one call the parameter 'instance' is set to 'undefined'. If I don't add this if statement, the program will throw a runtime error when rendering tab. + this._editorInstance = instance; + this._editorInstance.on('guttermousedown', (e) => { + const breakpointLine = e.getDocumentPosition().row; + developmentLog(breakpointLine); + }); + + this._editorInstance.setOptions({ fontSize: `${this.customizedEditorProps.fontSize.toString()}pt` }); + } + + richDisplayInternal(pair_rich_text) { + developmentLog(pair_rich_text); + const head = (pair) => pair[0]; + const tail = (pair) => pair[1]; + const is_pair = (obj) => obj instanceof Array && obj.length === 2; + if (!is_pair(pair_rich_text)) return 'not_rich_text_pair'; + function checkColorStringValidity(htmlColor:string) { + if (htmlColor.length !== 7) return false; + if (htmlColor[0] !== '#') return false; + for (let i = 1; i < 7; i++) { + const char = htmlColor[i]; + developmentLog(` ${char}`); + if (!((char >= '0' && char <= '9') || (char >= 'A' && char <= 'F') || (char >= 'a' && char <= 'f'))) { + return false; + } + } + return true; + } + function recursiveHelper(thisInstance, param): string { + if (typeof (param) === 'string') { + // There MUST be a safe check on users' strings, because users may insert something that can be interpreted as executable JavaScript code when outputing rich text. + const safeCheckResult = thisInstance.userStringSafeCheck(param); + if (safeCheckResult !== 'safe') { + throw new Error(`For safety matters, the character/word ${safeCheckResult} is not allowed in rich text output. Please remove it or use plain text output mode and try again.`); + } + developmentLog(head(param)); + return `">${param}
`; + } + if (!is_pair(param)) { + throw new Error(`Unexpected data type ${typeof (param)} when processing rich text. It should be a pair.`); + } else { + const pairStyleToCssStyle : { [pairStyle : string] : string } = { + bold: 'font-weight:bold;', + italic: 'font-style:italic;', + small: 'font-size: 14px;', + medium: 'font-size: 20px;', + large: 'font-size: 25px;', + gigantic: 'font-size: 50px;', + underline: 'text-decoration: underline;', + }; + if (typeof (tail(param)) !== 'string') { + throw new Error(`The tail in style pair should always be a string, but got ${typeof (tail(param))}.`); + } + let style = ''; + if (tail(param) + .substring(0, 3) === 'clr') { + let prefix = ''; + if (tail(param)[3] === 't') prefix = 'color:'; + else if (tail(param)[3] === 'b') prefix = 'background-color:'; + else throw new Error('Error when decoding rich text color data'); + const colorHex = tail(param) + .substring(4); + if (!checkColorStringValidity(colorHex)) { + throw new Error(`Invalid html color string ${colorHex}. It should start with # and followed by 6 characters representing a hex number.`); + } + style = `${prefix + colorHex};`; + } else { + style = pairStyleToCssStyle[tail(param)]; + if (style === undefined) { + throw new Error(`Found undefined style ${tail(param)} during processing rich text.`); + } + } + return style + recursiveHelper(thisInstance, head(param)); + } + } + this.pushOutputString(`', 'script', 'javascript', 'eval', 'document', 'window', 'console', 'location']; + for (let word of forbiddenWords) { + if (tmp.indexOf(word) !== -1) { + return word; + } + } + return 'safe'; + } + + /* + Directly invoking Source Academy's builtin js-slang runner. + Needs hard-coded support from js-slang part for the "sourceRunner" function and "backupContext" property in the content object for this to work. + */ + runInJsSlang(code: string): string { + developmentLog('js-slang context:'); + // console.log(context); + const options : Partial = { + originalMaxExecTime: 1000, + scheduler: 'preemptive', + stepLimit: 1000, + throwInfiniteLoops: true, + useSubst: false, + }; + context.prelude = 'const display=(x)=>repl_display(x);'; + context.errors = []; // Here if I don't manually clear the "errors" array in context, the remaining errors from the last evaluation will stop the function "preprocessFileImports" in preprocessor.ts of js-slang thus stop the whole evaluation. + const sourceFile : Record = { + '/ReplModuleUserCode.js': code, + }; + + runFilesInContext(sourceFile, '/ReplModuleUserCode.js', context, options) + .then((evalResult) => { + if (evalResult.status === 'suspended' || evalResult.status === 'suspended-ec-eval') { + throw new Error('This should not happen'); + } + if (evalResult.status !== 'error') { + this.pushOutputString('js-slang program finished with value:', COLOR_RUN_CODE_RESULT); + // Here must use plain text output mode because evalResult.value contains strings from the users. + this.pushOutputString(evalResult.value === undefined ? 'undefined' : evalResult.value.toString(), COLOR_RUN_CODE_RESULT); + } else { + const errors = context.errors; + console.log(errors); + const errorCount = errors.length; + for (let i = 0; i < errorCount; i++) { + const error = errors[i]; + if (error.explain() + .indexOf('Name repl_display not declared.') !== -1) { + this.pushOutputString('[Error] It seems that you haven\'t import the function "repl_display" correctly when calling "set_evaluator" in Source Academy\'s main editor.', COLOR_ERROR_MESSAGE); + } else this.pushOutputString(`Line ${error.location.start.line}: ${error.type} Error: ${error.explain()} (${error.elaborate()})`, COLOR_ERROR_MESSAGE); + } + } + this.reRenderTab(); + }); + + return 'Async run in js-slang'; + } + + setTabReactComponentInstance(tab : any) { + this._tabReactComponent = tab; + } + + private reRenderTab() { + this._tabReactComponent.setState({});// Forces the tab React Component to re-render using setState + } + + saveEditorContent() { + localStorage.setItem('programmable_repl_saved_editor_code', this.userCodeInEditor.toString()); + this.pushOutputString('Saved', 'lightgreen'); + this.pushOutputString('The saved code is stored locally in your browser. You may lose the saved code if you clear browser data or use another device.', 'gray', 'richtext'); + this.reRenderTab(); + } + + private getSavedEditorContent() { + let savedContent = localStorage.getItem('programmable_repl_saved_editor_code'); + if (savedContent === null) return ''; + return savedContent; + } + + // Small Easter Egg that doesn't affect module functionality and normal user experience :) + // Please don't modify these text! Thanks! :) + private easterEggFunction() { + this.pushOutputString('[Author (Wang Zihan)] ❤I love Keqing and Ganyu.❤', 'pink', 'richtext'); + this.pushOutputString('Showing my love to my favorite girls through a SA module, is that the so-called "romance of a programmer"?', 'gray', 'richtext'); + this.pushOutputString('❤❤❤❤❤', 'pink'); + this.pushOutputString('
', 'white', 'richtext'); + this.pushOutputString('If you see this, please check whether you have called invoke_repl function with the correct parameter before using the Programmable Repl Tab.', 'yellow', 'richtext'); + return 'Easter Egg!'; + } +} + +// Comment all the codes inside this function before merging the code to github as production version. +// Because console.log() can expose the sandboxed VM location to students thus may cause security concerns. +function developmentLog(_content) { + // console.log(`[Programmable Repl Log] ${_content}`); +} diff --git a/src/tabs/Repl/index.tsx b/src/tabs/Repl/index.tsx index 567887049..034a2ee08 100644 --- a/src/tabs/Repl/index.tsx +++ b/src/tabs/Repl/index.tsx @@ -1,116 +1,174 @@ -/** - * Tab for Source Academy Programmable REPL module - * @module repl - * @author Wang Zihan - */ - -import React from 'react'; -import type { DebuggerContext } from '../../typings/type_helpers'; -import { Button } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import type { ProgrammableRepl } from '../../bundles/repl/programmable_repl'; -import AceEditor from 'react-ace'; - -import 'ace-builds/src-noconflict/mode-javascript'; -import 'ace-builds/src-noconflict/theme-twilight'; -import 'ace-builds/src-noconflict/ext-language_tools'; - -type Props = { - programmableReplInstance: ProgrammableRepl; -}; - -class ProgrammableReplGUI extends React.Component { - public replInstance : ProgrammableRepl; - constructor(data: Props) { - super(data); - this.replInstance = data.programmableReplInstance; - this.replInstance.setTabReactComponentInstance(this); - } - public render() { - const outputDivs : JSX.Element[] = []; - const outputStringCount = this.replInstance.outputStrings.length; - for (let i = 0; i < outputStringCount; i++) { - const str = this.replInstance.outputStrings[i]; - if (str.outputMethod === 'richtext') { - if (str.color === '') { - outputDivs.push(
); - } else { - outputDivs.push(
); - } - } else if (str.color === '') { - outputDivs.push(
{ str.content }
); - } else { - outputDivs.push(
{ str.content }
); - } - } - return ( -
-
- ); - } -} - - -export default { - /** - * This function will be called to determine if the component will be - * rendered. - * @param {DebuggerContext} context - * @returns {boolean} - */ - toSpawn(_context: DebuggerContext) { - return true; - }, - - /** - * This function will be called to render the module tab in the side contents - * on Source Academy frontend. - * @param {DebuggerContext} context - */ - body(context: DebuggerContext) { - return ; - }, - - /** - * The Tab's icon tooltip in the side contents on Source Academy frontend. - */ - label: 'Programmable Repl Tab', - - /** - * BlueprintJS IconName element's name, used to render the icon which will be - * displayed in the side contents panel. - * @see https://blueprintjs.com/docs/#icons - */ - iconName: 'code', -}; +/** + * Tab for Source Academy Programmable REPL module + * @module repl + * @author Wang Zihan + */ + +import React from 'react'; +import type { DebuggerContext } from '../../typings/type_helpers'; +import { Button } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import type { ProgrammableRepl } from '../../bundles/repl/programmable_repl'; +import { FONT_MESSAGE, MINIMUM_EDITOR_HEIGHT } from '../../bundles/repl/config'; +// If I use import for AceEditor it will cause runtime error and crash Source Academy when spawning tab in the new module building system. +// import AceEditor from 'react-ace'; +const AceEditor = require('react-ace').default; + +import 'ace-builds/src-noconflict/mode-javascript'; +import 'ace-builds/src-noconflict/theme-twilight'; +import 'ace-builds/src-noconflict/ext-language_tools'; + +type Props = { + programmableReplInstance: ProgrammableRepl; +}; + +type State = { + editorHeight: number, + isDraggingDragBar: boolean, +}; + +const BOX_PADDING_VALUE = 4; + +class ProgrammableReplGUI extends React.Component { + public replInstance : ProgrammableRepl; + private editorAreaRect; + constructor(data: Props) { + super(data); + this.replInstance = data.programmableReplInstance; + this.replInstance.setTabReactComponentInstance(this); + this.state = { + editorHeight: this.replInstance.editorHeight, + isDraggingDragBar: false, + }; + } + private dragBarOnMouseDown = (e) => { + e.preventDefault(); + this.setState({ isDraggingDragBar: true }); + }; + private onMouseMove = (e) => { + if (this.state.isDraggingDragBar) { + const height = Math.max(e.clientY - this.editorAreaRect.top - BOX_PADDING_VALUE * 2, MINIMUM_EDITOR_HEIGHT); + this.replInstance.editorHeight = height; + this.setState({ editorHeight: height }); + } + }; + private onMouseUp = (_e) => { + this.setState({ isDraggingDragBar: false }); + }; + componentDidMount() { + document.addEventListener('mousemove', this.onMouseMove); + document.addEventListener('mouseup', this.onMouseUp); + } + componentWillUnmount() { + document.removeEventListener('mousemove', this.onMouseMove); + document.removeEventListener('mouseup', this.onMouseUp); + } + public render() { + const { editorHeight } = this.state; + const outputDivs : JSX.Element[] = []; + const outputStringCount = this.replInstance.outputStrings.length; + for (let i = 0; i < outputStringCount; i++) { + const str = this.replInstance.outputStrings[i]; + if (str.outputMethod === 'richtext') { + if (str.color === '') { + outputDivs.push(
); + } else { + outputDivs.push(
); + } + } else if (str.color === '') { + outputDivs.push(
{ str.content }
); + } else { + outputDivs.push(
{ str.content }
); + } + } + return ( +
+