diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..874bf8b81 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*] +end_of_line = lf \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 79e628819..7c2e486a3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,6 +9,7 @@ "plugin:@typescript-eslint/recommended-requiring-type-checking", "prettier" ], + "ignorePatterns": ["src/**/__tests__/**", "src/**/__mocks__/**"], "parser": "@typescript-eslint/parser", "parserOptions": { "project": "tsconfig.json", @@ -45,5 +46,12 @@ "@typescript-eslint/unbound-method": "off", "prefer-rest-params": "off", "simple-import-sort/imports": "warn" - } + }, + "overrides": [{ + "files": ["src/**/__tests__/**", "src/**/__mocks__/**"], + "plugins": ["simple-import-sort"], + "rules": { + "simple-import-sort/imports": "warn" + } + }] } \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 3ea972a68..86f590c00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ /src/scm-slang/ -/src/py-slang/ \ No newline at end of file +/src/py-slang/ +/src/**/__tests__/**/__snapshots__ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index c63ef6c84..5e5702b65 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "printWidth": 100, "parser": "typescript", "trailingComma": "none", - "arrowParens": "avoid" + "arrowParens": "avoid", + "endOfLine": "lf" } diff --git a/src/ec-evaluator/interpreter.ts b/src/ec-evaluator/interpreter.ts index e10b0d7ac..e12999731 100644 --- a/src/ec-evaluator/interpreter.ts +++ b/src/ec-evaluator/interpreter.ts @@ -7,7 +7,7 @@ /* tslint:disable:max-classes-per-file */ import * as es from 'estree' -import { partition, uniqueId } from 'lodash' +import { uniqueId } from 'lodash' import { IOptions } from '..' import { UNKNOWN_LOCATION } from '../constants' @@ -15,10 +15,12 @@ import * as errors from '../errors/errors' import { RuntimeSourceError } from '../errors/runtimeSourceError' import Closure from '../interpreter/closure' import { UndefinedImportError } from '../modules/errors' -import { loadModuleBundle, loadModuleTabs } from '../modules/moduleLoader' -import { ModuleFunctions } from '../modules/moduleTypes' +import { initModuleContext, loadModuleBundle } from '../modules/moduleLoader' +import { ImportTransformOptions } from '../modules/moduleTypes' import { checkEditorBreakpoints } from '../stdlib/inspector' import { Context, ContiguousArrayElements, Result, Value } from '../types' +import assert from '../utils/assert' +import { filterImportDeclarations } from '../utils/ast/helpers' import * as ast from '../utils/astCreator' import { evaluateBinaryExpression, evaluateUnaryExpression } from '../utils/operators' import * as rttc from '../utils/rttc' @@ -172,45 +174,31 @@ export function resumeEvaluate(context: Context) { function evaluateImports( program: es.Program, context: Context, - loadTabs: boolean, - checkImports: boolean + { loadTabs, checkImports }: ImportTransformOptions ) { - const [importNodes] = partition(program.body, ({ type }) => type === 'ImportDeclaration') as [ - es.ImportDeclaration[], - es.Statement[] - ] - const moduleFunctions: Record = {} - try { - for (const node of importNodes) { - const moduleName = node.source.value - if (typeof moduleName !== 'string') { - throw new Error(`ImportDeclarations should have string sources, got ${moduleName}`) - } - - if (!(moduleName in moduleFunctions)) { - context.moduleContexts[moduleName] = { - state: null, - tabs: loadTabs ? loadModuleTabs(moduleName, node) : null - } - moduleFunctions[moduleName] = loadModuleBundle(moduleName, context, node) - } - - const functions = moduleFunctions[moduleName] - const environment = currentEnvironment(context) - for (const spec of node.specifiers) { - if (spec.type !== 'ImportSpecifier') { - throw new Error(`Only ImportSpecifiers are supported, got: ${spec.type}`) + const [importNodeMap] = filterImportDeclarations(program) + + const environment = currentEnvironment(context) + Object.entries(importNodeMap).forEach(([moduleName, nodes]) => { + initModuleContext(moduleName, context, loadTabs) + const functions = loadModuleBundle(moduleName, context, nodes[0]) + for (const node of nodes) { + for (const spec of node.specifiers) { + assert( + spec.type === 'ImportSpecifier', + `Only ImportSpecifiers are supported, got: ${spec.type}` + ) + + if (checkImports && !(spec.imported.name in functions)) { + throw new UndefinedImportError(spec.imported.name, moduleName, spec) + } + + declareIdentifier(context, spec.local.name, node, environment) + defineVariable(context, spec.local.name, functions[spec.imported.name], true, node) } - - if (checkImports && !(spec.imported.name in functions)) { - throw new UndefinedImportError(spec.imported.name, moduleName, node) - } - - declareIdentifier(context, spec.local.name, node, environment) - defineVariable(context, spec.local.name, functions[spec.imported.name], true, node) } - } + }) } catch (error) { // console.log(error) handleRuntimeError(context, error) @@ -335,7 +323,11 @@ const cmdEvaluators: { [type: string]: CmdEvaluator } = { if (hasDeclarations(command) || hasImportDeclarations(command)) { const environment = createBlockEnvironment(context, 'programEnvironment') pushEnvironment(context, environment) - evaluateImports(command as unknown as es.Program, context, true, true) + evaluateImports(command as unknown as es.Program, context, { + wrapSourceModules: true, + checkImports: true, + loadTabs: true + }) declareFunctionsAndVariables(context, command, environment) } diff --git a/src/index.ts b/src/index.ts index e0f1e8b7a..c26a13e73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { FuncDeclWithInferredTypeAnnotation, ModuleContext, NodeWithInferredType, + RecursivePartial, Result, SourceError, SVMProgram, @@ -31,6 +32,7 @@ import { ECEResultPromise, resumeEvaluate } from './ec-evaluator/interpreter' import { CannotFindModuleError } from './errors/localImportErrors' import { validateFilePath } from './localImports/filePaths' import preprocessFileImports from './localImports/preprocessor' +import type { ImportTransformOptions } from './modules/moduleTypes' import { getKeywords, getProgramNames, NameDeclaration } from './name-extractor' import { parse } from './parser/parser' import { decodeError, decodeValue } from './parser/scheme' @@ -56,6 +58,8 @@ export interface IOptions { isPrelude: boolean throwInfiniteLoops: boolean envSteps: number + + importOptions: ImportTransformOptions } // needed to work on browsers @@ -303,7 +307,7 @@ export function getTypeInformation( export async function runInContext( code: string, context: Context, - options: Partial = {} + options: RecursivePartial = {} ): Promise { const defaultFilePath = '/default.js' const files: Partial> = {} @@ -315,7 +319,7 @@ export async function runFilesInContext( files: Partial>, entrypointFilePath: string, context: Context, - options: Partial = {} + options: RecursivePartial = {} ): Promise { for (const filePath in files) { const filePathError = validateFilePath(filePath) @@ -340,7 +344,13 @@ export async function runFilesInContext( if (program === null) { return resolvedErrorPromise } - return fullJSRunner(program, context, options) + const fullImportOptions = { + loadTabs: true, + checkImports: false, + wrapSourceModules: false, + ...options.importOptions + } + return fullJSRunner(program, context, fullImportOptions) } if (context.chapter === Chapter.HTML) { diff --git a/src/infiniteLoops/__tests__/instrument.ts b/src/infiniteLoops/__tests__/instrument.ts index f4f69c924..56886b86a 100644 --- a/src/infiniteLoops/__tests__/instrument.ts +++ b/src/infiniteLoops/__tests__/instrument.ts @@ -37,7 +37,11 @@ function mockFunctionsAndState() { * Returns the value saved in the code using the builtin 'output'. * e.g. runWithMock('output(2)') --> 2 */ -function runWithMock(main: string, codeHistory?: string[], builtins: Map = new Map()) { +async function runWithMock( + main: string, + codeHistory?: string[], + builtins: Map = new Map() +) { let output = undefined builtins.set('output', (x: any) => (output = x)) builtins.set('undefined', undefined) @@ -53,21 +57,23 @@ function runWithMock(main: string, codeHistory?: string[], builtins: Map { const main = 'output(2);' - expect(runWithMock(main, [])).toBe(2) + return expect(runWithMock(main, [])).resolves.toBe(2) }) test('binary and unary expressions work', () => { - expect(runWithMock('output(1+1);', [])).toBe(2) - expect(runWithMock('output(!true);', [])).toBe(false) + return Promise.all([ + expect(runWithMock('output(1+1);', [])).resolves.toBe(2), + expect(runWithMock('output(!true);', [])).resolves.toBe(false) + ]) }) test('assignment works as expected', () => { @@ -75,13 +81,13 @@ test('assignment works as expected', () => { let a = []; a[0] = 3; output(x+a[0]);` - expect(runWithMock(main)).toBe(5) + return expect(runWithMock(main)).resolves.toBe(5) }) test('globals from old code accessible', () => { const main = 'output(z+1);' const prev = ['const z = w+1;', 'let w = 10;'] - expect(runWithMock(main, prev)).toBe(12) + return expect(runWithMock(main, prev)).resolves.toBe(12) }) test('functions run as expected', () => { @@ -89,7 +95,7 @@ test('functions run as expected', () => { return x===0?x:f(x-1,y)+y; } output(f(5,2));` - expect(runWithMock(main)).toBe(10) + return expect(runWithMock(main)).resolves.toBe(10) }) test('nested functions run as expected', () => { @@ -100,7 +106,7 @@ test('nested functions run as expected', () => { return x===0?x:f(x-1,y)+y; } output(f(5,2));` - expect(runWithMock(main)).toBe(2) + return expect(runWithMock(main)).resolves.toBe(2) }) test('higher order functions run as expected', () => { @@ -108,14 +114,14 @@ test('higher order functions run as expected', () => { return f(x+1); } output(run(x=>x+1, 1));` - expect(runWithMock(main)).toBe(3) + return expect(runWithMock(main)).resolves.toBe(3) }) test('loops run as expected', () => { const main = `let w = 0; for (let i = w; i < 10; i=i+1) {w = i;} output(w);` - expect(runWithMock(main)).toBe(9) + return expect(runWithMock(main)).resolves.toBe(9) }) test('nested loops run as expected', () => { @@ -126,13 +132,13 @@ test('nested loops run as expected', () => { } } output(w);` - expect(runWithMock(main)).toBe(100) + return expect(runWithMock(main)).resolves.toBe(100) }) test('multidimentional arrays work', () => { const main = `const x = [[1],[2]]; output(x[1] === undefined? undefined: x[1][0]);` - expect(runWithMock(main)).toBe(2) + return expect(runWithMock(main)).resolves.toBe(2) }) test('if statements work as expected', () => { @@ -141,7 +147,7 @@ test('if statements work as expected', () => { x = x + 1; } else {} output(x);` - expect(runWithMock(main)).toBe(2) + return expect(runWithMock(main)).resolves.toBe(2) }) test('combination of loops and functions run as expected', () => { @@ -158,5 +164,5 @@ test('combination of loops and functions run as expected', () => { w = minus(w,1); } output(z);` - expect(runWithMock(main)).toBe(100) + return expect(runWithMock(main)).resolves.toBe(100) }) diff --git a/src/infiniteLoops/__tests__/runtime.ts b/src/infiniteLoops/__tests__/runtime.ts index 4eb48dae8..d6f6dd1a9 100644 --- a/src/infiniteLoops/__tests__/runtime.ts +++ b/src/infiniteLoops/__tests__/runtime.ts @@ -3,15 +3,15 @@ import * as es from 'estree' import { runInContext } from '../..' import createContext from '../../createContext' import { mockContext } from '../../mocks/context' -import * as moduleLoader from '../../modules/moduleLoader' import { parse } from '../../parser/parser' import { Chapter, Variant } from '../../types' import { stripIndent } from '../../utils/formatters' import { getInfiniteLoopData, InfiniteLoopError, InfiniteLoopErrorType } from '../errors' import { testForInfiniteLoop } from '../runtime' -jest.spyOn(moduleLoader, 'memoizedGetModuleFile').mockImplementationOnce(() => { - return stripIndent` +jest.mock('../../modules/moduleLoaderAsync', () => ({ + memoizedGetModuleBundleAsync: jest.fn(() => + Promise.resolve(stripIndent` require => { 'use strict'; var exports = {}; @@ -36,8 +36,21 @@ jest.spyOn(moduleLoader, 'memoizedGetModuleFile').mockImplementationOnce(() => { }); return exports; } - ` -}) + `) + ), + memoizedGetModuleManifestAsync: jest.fn(() => + Promise.resolve({ + repeat: { tabs: [] } + }) + ), + memoizedGetModuleDocsAsync: jest.fn(() => + Promise.resolve({ + repeat: '', + twice: '', + thrice: '' + }) + ) +})) test('works in runInContext when throwInfiniteLoops is true', async () => { const code = `function fib(x) { @@ -77,84 +90,84 @@ const testForInfiniteLoopWithCode = (code: string, previousPrograms: es.Program[ return testForInfiniteLoop(program, previousPrograms) } -test('non-infinite recursion not detected', () => { +test('non-infinite recursion not detected', async () => { const code = `function fib(x) { return x<=1?x:fib(x-1) + fib(x-2); } fib(100000); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result).toBeUndefined() }) -test('non-infinite loop not detected', () => { +test('non-infinite loop not detected', async () => { const code = `for(let i = 0;i<2000;i=i+1){i+1;} let j = 0; while(j<2000) {j=j+1;} ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result).toBeUndefined() }) -test('no base case function detected', () => { +test('no base case function detected', async () => { const code = `function fib(x) { return fib(x-1) + fib(x-2); } fib(100000); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.NoBaseCase) expect(result?.streamMode).toBe(false) }) -test('no base case loop detected', () => { +test('no base case loop detected', async () => { const code = `for(let i = 0;true;i=i+1){i+1;} ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.NoBaseCase) expect(result?.streamMode).toBe(false) }) -test('no variables changing function detected', () => { +test('no variables changing function detected', async () => { const code = `let x = 1; function f() { return x===0?x:f(); } f(); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) expect(result?.streamMode).toBe(false) expect(result?.explain()).toContain('None of the variables are being updated.') }) -test('no state change function detected', () => { +test('no state change function detected', async () => { const code = `let x = 1; function f() { return x===0?x:f(); } f(); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) expect(result?.streamMode).toBe(false) expect(result?.explain()).toContain('None of the variables are being updated.') }) -test('infinite cycle detected', () => { +test('infinite cycle detected', async () => { const code = `function f(x) { return x[0] === 1? x : f(x); } f([2,3,4]); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) expect(result?.streamMode).toBe(false) expect(result?.explain()).toContain('cycle') expect(result?.explain()).toContain('[2,3,4]') }) -test('infinite data structures detected', () => { +test('infinite data structures detected', async () => { const code = `function f(x) { return is_null(x)? x : f(tail(x)); } @@ -162,32 +175,32 @@ test('infinite data structures detected', () => { set_tail(tail(tail(circ)), circ); f(circ); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) expect(result?.streamMode).toBe(false) expect(result?.explain()).toContain('cycle') expect(result?.explain()).toContain('(CIRCULAR)') }) -test('functions using SMT work', () => { +test('functions using SMT work', async () => { const code = `function f(x) { return x===0? x: f(x+1); } f(1); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.FromSmt) expect(result?.streamMode).toBe(false) }) -test('detect forcing infinite streams', () => { +test('detect forcing infinite streams', async () => { const code = `stream_to_list(integers_from(0));` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.NoBaseCase) expect(result?.streamMode).toBe(true) }) -test('detect mutual recursion', () => { +test('detect mutual recursion', async () => { const code = `function e(x){ return x===0?1:1-o(x-1); } @@ -195,23 +208,23 @@ test('detect mutual recursion', () => { return x===1?0:1-e(x-1); } e(9);` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.FromSmt) expect(result?.streamMode).toBe(false) }) -test('functions passed as arguments not checked', () => { +test('functions passed as arguments not checked', async () => { // if they are checked -> this will throw no base case const code = `const twice = f => x => f(f(x)); const thrice = f => x => f(f(f(x))); const add = x => x + 1; (thrice)(twice(twice))(twice(add))(0);` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result).toBeUndefined() }) -test('detect complicated cycle example', () => { +test('detect complicated cycle example', async () => { const code = `function permutations(s) { return is_null(s) ? list(null) @@ -230,12 +243,12 @@ test('detect complicated cycle example', () => { remove_duplicate(list(list(1,2,3), list(1,2,3))); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) expect(result?.streamMode).toBe(false) }) -test('detect complicated cycle example 2', () => { +test('detect complicated cycle example 2', async () => { const code = `function make_big_int_from_number(num){ let output = num; while(output !== 0){ @@ -246,12 +259,12 @@ test('detect complicated cycle example 2', () => { } make_big_int_from_number(1234); ` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) expect(result?.streamMode).toBe(false) }) -test('detect complicated fromSMT example 2', () => { +test('detect complicated fromSMT example 2', async () => { const code = `function fast_power(b,n){ if (n % 2 === 0){ return b* fast_power(b, n-2); @@ -261,47 +274,47 @@ test('detect complicated fromSMT example 2', () => { } fast_power(2,3);` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.FromSmt) expect(result?.streamMode).toBe(false) }) -test('detect complicated stream example', () => { +test('detect complicated stream example', async () => { const code = `function up(a, b) { return (a > b) ? up(1, 1 + b) : pair(a, () => stream_reverse(up(a + 1, b))); } eval_stream(up(1,1), 22);` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result).toBeDefined() expect(result?.streamMode).toBe(true) }) -test('math functions are disabled in smt solver', () => { +test('math functions are disabled in smt solver', async () => { const code = ` function f(x) { return x===0? x: f(math_floor(x+1)); } f(1);` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result).toBeUndefined() }) -test('cycle detection ignores non deterministic functions', () => { +test('cycle detection ignores non deterministic functions', async () => { const code = ` function f(x) { return x===0?0:f(math_floor(math_random()/2) + 1); } f(1);` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result).toBeUndefined() }) -test('handle imports properly', () => { +test('handle imports properly', async () => { const code = `import {thrice} from "repeat"; function f(x) { return is_number(x) ? f(x) : 42; } display(f(thrice(x=>x+1)(0)));` - const result = testForInfiniteLoopWithCode(code, []) + const result = await testForInfiniteLoopWithCode(code, []) expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle) }) diff --git a/src/infiniteLoops/instrument.ts b/src/infiniteLoops/instrument.ts index c8c98d0e8..6b3af9749 100644 --- a/src/infiniteLoops/instrument.ts +++ b/src/infiniteLoops/instrument.ts @@ -574,25 +574,34 @@ function trackLocations(program: es.Program) { }) } -function handleImports(programs: es.Program[]): [string, string[]] { - const [prefixes, imports] = programs.reduce( - ([prefix, moduleNames], program) => { - const [prefixToAdd, importsToAdd, otherNodes] = transformImportDeclarations( +async function handleImports(programs: es.Program[]): Promise<[string, string[]]> { + const transformed = await Promise.all( + programs.map(async program => { + const [prefixToAdd, importsToAdd, otherNodes] = await transformImportDeclarations( program, new Set(), - false + { + wrapSourceModules: false, + checkImports: false, + loadTabs: false + } ) program.body = (importsToAdd as es.Program['body']).concat(otherNodes) - prefix.push(prefixToAdd) - const importedNames = importsToAdd.flatMap(node => node.declarations.map( decl => ((decl.init as es.MemberExpression).object as es.Identifier).name ) ) - return [prefix, moduleNames.concat(importedNames)] - }, - [[] as string[], [] as string[]] + return [prefixToAdd, importedNames] as [string, string[]] + }) + ) + + const [prefixes, imports] = transformed.reduce( + ([prefixes, moduleNames], [prefix, importedNames]) => [ + [...prefixes, prefix], + [...moduleNames, ...importedNames] + ], + [[], []] as [string[], string[]] ) return [prefixes.join('\n'), [...new Set(imports)]] @@ -606,11 +615,11 @@ function handleImports(programs: es.Program[]): [string, string[]] { * @param builtins Names of builtin functions. * @returns code with instrumentations. */ -function instrument( +async function instrument( previous: es.Program[], program: es.Program, builtins: Iterable -): string { +): Promise { const { builtinsId, functionsId, stateId } = globalIds const predefined = {} predefined[builtinsId] = builtinsId @@ -618,7 +627,7 @@ function instrument( predefined[stateId] = stateId const innerProgram = { ...program } - const [prefix, moduleNames] = handleImports([program].concat(previous)) + const [prefix, moduleNames] = await handleImports([program].concat(previous)) for (const name of moduleNames) { predefined[name] = name } diff --git a/src/infiniteLoops/runtime.ts b/src/infiniteLoops/runtime.ts index a9b81b95f..83783e251 100644 --- a/src/infiniteLoops/runtime.ts +++ b/src/infiniteLoops/runtime.ts @@ -305,7 +305,10 @@ functions[FunctionNames.evalU] = sym.evaluateHybridUnary * @param previousProgramsStack Any code previously entered in the REPL & parsed into AST. * @returns SourceError if an infinite loop was detected, undefined otherwise. */ -export function testForInfiniteLoop(program: es.Program, previousProgramsStack: es.Program[]) { +export async function testForInfiniteLoop( + program: es.Program, + previousProgramsStack: es.Program[] +) { const context = createContext(Chapter.SOURCE_4, Variant.DEFAULT, undefined, undefined) const prelude = parse(context.prelude as string, context) as es.Program context.prelude = null @@ -313,7 +316,7 @@ export function testForInfiniteLoop(program: es.Program, previousProgramsStack: const newBuiltins = prepareBuiltins(context.nativeStorage.builtins) const { builtinsId, functionsId, stateId } = InfiniteLoopRuntimeObjectNames - const instrumentedCode = instrument(previous, program, newBuiltins.keys()) + const instrumentedCode = await instrument(previous, program, newBuiltins.keys()) const state = new st.State() const sandboxedRun = new Function( @@ -327,7 +330,7 @@ export function testForInfiniteLoop(program: es.Program, previousProgramsStack: ) try { - sandboxedRun(instrumentedCode, functions, state, newBuiltins, getRequireProvider(context)) + await sandboxedRun(instrumentedCode, functions, state, newBuiltins, getRequireProvider(context)) } catch (error) { if (error instanceof InfiniteLoopError) { if (state.lastLocation !== undefined) { diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index b459f08a1..f883d3a95 100644 --- a/src/interpreter/interpreter.ts +++ b/src/interpreter/interpreter.ts @@ -7,10 +7,11 @@ import { LazyBuiltIn } from '../createContext' import * as errors from '../errors/errors' import { RuntimeSourceError } from '../errors/runtimeSourceError' import { UndefinedImportError } from '../modules/errors' -import { loadModuleBundle, loadModuleTabs } from '../modules/moduleLoader' +import { initModuleContext, loadModuleBundle } from '../modules/moduleLoader' import { ModuleFunctions } from '../modules/moduleTypes' import { checkEditorBreakpoints } from '../stdlib/inspector' import { Context, ContiguousArrayElements, Environment, Frame, Value, Variant } from '../types' +import assert from '../utils/assert' import * as create from '../utils/astCreator' import { conditionalExpression, literal, primitive } from '../utils/astCreator' import { evaluateBinaryExpression, evaluateUnaryExpression } from '../utils/operators' @@ -734,27 +735,26 @@ export function* evaluateProgram( yield* visit(context, node) const moduleName = node.source.value - if (typeof moduleName !== 'string') { - throw new Error(`ImportDeclarations should have string sources, got ${moduleName}`) - } + assert( + typeof moduleName === 'string', + `ImportDeclarations should have string sources, got ${moduleName}` + ) if (!(moduleName in moduleFunctions)) { - context.moduleContexts[moduleName] = { - state: null, - tabs: loadTabs ? loadModuleTabs(moduleName, node) : null - } + initModuleContext(moduleName, context, loadTabs) moduleFunctions[moduleName] = loadModuleBundle(moduleName, context, node) } const functions = moduleFunctions[moduleName] for (const spec of node.specifiers) { - if (spec.type !== 'ImportSpecifier') { - throw new Error(`Only Import Specifiers are supported, got ${spec.type}`) - } + assert( + spec.type === 'ImportSpecifier', + `Only Import Specifiers are supported, got ${spec.type}` + ) if (checkImports && !(spec.imported.name in functions)) { - throw new UndefinedImportError(spec.imported.name, moduleName, node) + throw new UndefinedImportError(spec.imported.name, moduleName, spec) } declareIdentifier(context, spec.local.name, node) diff --git a/src/localImports/directedGraph.ts b/src/localImports/directedGraph.ts index c7ff436ae..a6997a51d 100644 --- a/src/localImports/directedGraph.ts +++ b/src/localImports/directedGraph.ts @@ -1,3 +1,5 @@ +import assert from '../utils/assert' + /** * The result of attempting to find a topological ordering * of nodes on a DirectedGraph. @@ -113,9 +115,10 @@ export class DirectedGraph { // all nodes have an in-degree of 0 after running Kahn's algorithm. // This in turn implies that Kahn's algorithm was able to find a // valid topological ordering & that the graph contains no cycles. - if (startingNodeInCycle === null) { - throw new Error('There are no cycles in this graph. This should never happen.') - } + assert( + startingNodeInCycle !== null, + 'There are no cycles in this graph. This should never happen.' + ) const cycle = [startingNodeInCycle] // Then, we keep picking arbitrary nodes with non-zero in-degrees until @@ -132,9 +135,10 @@ export class DirectedGraph { // An in-degree of 0 implies that the node is not part of a cycle, // which is a contradiction since the current node was picked because // it is part of a cycle. - if (neighbours.size === 0) { - throw new Error(`Node '${currentNode}' has no incoming edges. This should never happen.`) - } + assert( + neighbours.size > 0, + `Node '${currentNode}' has no incoming edges. This should never happen.` + ) let nextNodeInCycle: string | null = null for (const neighbour of neighbours) { @@ -146,11 +150,10 @@ export class DirectedGraph { // By the invariant stated above, if the current node is part of a cycle, // then one of its neighbours must also be part of the same cycle. This // is because a cycle contains at least 2 nodes. - if (nextNodeInCycle === null) { - throw new Error( - `None of the neighbours of node '${currentNode}' are part of the same cycle. This should never happen.` - ) - } + assert( + nextNodeInCycle !== null, + `None of the neighbours of node '${currentNode}' are part of the same cycle. This should never happen.` + ) // If the next node we pick is already part of the cycle, // we drop all elements before the first instance of the diff --git a/src/localImports/preprocessor.ts b/src/localImports/preprocessor.ts index 40a1d7e9e..ae4204a7e 100644 --- a/src/localImports/preprocessor.ts +++ b/src/localImports/preprocessor.ts @@ -1,10 +1,12 @@ import es from 'estree' -import * as posixPath from 'path/posix' +import { posix as posixPath } from 'path' import { CannotFindModuleError, CircularImportError } from '../errors/localImportErrors' import { parse } from '../parser/parser' import { AcornOptions } from '../parser/types' import { Context } from '../types' +import assert from '../utils/assert' +import { isImportDeclaration, isModuleDeclaration } from '../utils/ast/typeGuards' import { isIdentifier } from '../utils/rttc' import { createInvokedFunctionResultVariableDeclaration } from './constructors/contextSpecificConstructors' import { DirectedGraph } from './directedGraph' @@ -23,7 +25,6 @@ import { getInvokedFunctionResultVariableNameToImportSpecifiersMap, transformProgramToFunctionDeclaration } from './transformers/transformProgramToFunctionDeclaration' -import { isImportDeclaration, isModuleDeclaration } from './typeGuards' /** * Returns all absolute local module paths which should be imported. @@ -218,11 +219,10 @@ const preprocessFileImports = ( const functionDeclaration = transformProgramToFunctionDeclaration(program, filePath) const functionName = functionDeclaration.id?.name - if (functionName === undefined) { - throw new Error( - 'A transformed function declaration is missing its name. This should never happen.' - ) - } + assert( + functionName !== undefined, + 'A transformed function declaration is missing its name. This should never happen.' + ) functionDeclarations[functionName] = functionDeclaration } @@ -242,11 +242,10 @@ const preprocessFileImports = ( const functionDeclaration = functionDeclarations[functionName] const functionParams = functionDeclaration.params.filter(isIdentifier) - if (functionParams.length !== functionDeclaration.params.length) { - throw new Error( - 'Function declaration contains non-Identifier AST nodes as params. This should never happen.' - ) - } + assert( + functionParams.length === functionDeclaration.params.length, + 'Function declaration contains non-Identifier AST nodes as params. This should never happen.' + ) const invokedFunctionResultVariableDeclaration = createInvokedFunctionResultVariableDeclaration( functionName, diff --git a/src/localImports/transformers/hoistAndMergeImports.ts b/src/localImports/transformers/hoistAndMergeImports.ts index 96699587c..85864e189 100644 --- a/src/localImports/transformers/hoistAndMergeImports.ts +++ b/src/localImports/transformers/hoistAndMergeImports.ts @@ -1,9 +1,9 @@ import es from 'estree' import * as _ from 'lodash' +import { isImportDeclaration } from '../../utils/ast/typeGuards' import { createImportDeclaration, createLiteral } from '../constructors/baseConstructors' import { cloneAndStripImportSpecifier } from '../constructors/contextSpecificConstructors' -import { isImportDeclaration } from '../typeGuards' /** * Hoists import declarations to the top of the program & merges duplicate diff --git a/src/localImports/transformers/removeExports.ts b/src/localImports/transformers/removeExports.ts index 7e81e9367..eb2962d8b 100644 --- a/src/localImports/transformers/removeExports.ts +++ b/src/localImports/transformers/removeExports.ts @@ -1,7 +1,7 @@ import es from 'estree' +import { isDeclaration } from '../../utils/ast/typeGuards' import { ancestor } from '../../utils/walkers' -import { isDeclaration } from '../typeGuards' /** * Removes all export-related nodes from the AST. diff --git a/src/localImports/transformers/removeNonSourceModuleImports.ts b/src/localImports/transformers/removeNonSourceModuleImports.ts index 0aa3f99e9..61052de6c 100644 --- a/src/localImports/transformers/removeNonSourceModuleImports.ts +++ b/src/localImports/transformers/removeNonSourceModuleImports.ts @@ -1,5 +1,6 @@ import es from 'estree' +import assert from '../../utils/assert' import { ancestor } from '../../utils/walkers' import { isFilePath } from '../filePaths' @@ -94,9 +95,7 @@ export const removeNonSourceModuleImports = (program: es.Program): void => { // have any specifiers (thus being functionally useless). ancestor(program, { ImportDeclaration(node: es.ImportDeclaration, _state: es.Node[], ancestors: es.Node[]): void { - if (typeof node.source.value !== 'string') { - throw new Error('Module names must be strings.') - } + assert(typeof node.source.value === 'string', 'Module names must be strings.') // ImportDeclaration nodes without any specifiers are functionally useless and are thus removed. if (node.specifiers.length === 0) { removeImportDeclaration(node, ancestors) diff --git a/src/localImports/transformers/transformProgramToFunctionDeclaration.ts b/src/localImports/transformers/transformProgramToFunctionDeclaration.ts index 870d0aa98..4ff2f3d56 100644 --- a/src/localImports/transformers/transformProgramToFunctionDeclaration.ts +++ b/src/localImports/transformers/transformProgramToFunctionDeclaration.ts @@ -1,7 +1,14 @@ import es from 'estree' -import * as posixPath from 'path/posix' +import { posix as posixPath } from 'path' import { defaultExportLookupName } from '../../stdlib/localImport.prelude' +import assert from '../../utils/assert' +import { + isDeclaration, + isDirective, + isModuleDeclaration, + isStatement +} from '../../utils/ast/typeGuards' import { createFunctionDeclaration, createIdentifier, @@ -17,7 +24,6 @@ import { transformFilePathToValidFunctionName, transformFunctionNameToInvokedFunctionResultVariableName } from '../filePaths' -import { isDeclaration, isDirective, isModuleDeclaration, isStatement } from '../typeGuards' import { isSourceModule } from './removeNonSourceModuleImports' type ImportSpecifier = es.ImportSpecifier | es.ImportDefaultSpecifier | es.ImportNamespaceSpecifier @@ -34,11 +40,11 @@ export const getInvokedFunctionResultVariableNameToImportSpecifiersMap = ( return } const importSource = node.source.value - if (typeof importSource !== 'string') { - throw new Error( - 'Encountered an ImportDeclaration node with a non-string source. This should never occur.' - ) - } + assert( + typeof importSource === 'string', + 'Encountered an ImportDeclaration node with a non-string source. This should never occur.' + ) + // Only handle import declarations for non-Source modules. if (isSourceModule(importSource)) { return @@ -89,18 +95,18 @@ export const getInvokedFunctionResultVariableNameToImportSpecifiersMap = ( const getIdentifier = (node: es.Declaration): es.Identifier | null => { switch (node.type) { case 'FunctionDeclaration': - if (node.id === null) { - throw new Error( - 'Encountered a FunctionDeclaration node without an identifier. This should have been caught when parsing.' - ) - } + assert( + node.id !== null, + 'Encountered a FunctionDeclaration node without an identifier. This should have been caught when parsing.' + ) return node.id case 'VariableDeclaration': const id = node.declarations[0].id // In Source, variable names are Identifiers. - if (id.type !== 'Identifier') { - throw new Error(`Expected variable name to be an Identifier, but was ${id.type} instead.`) - } + assert( + id.type === 'Identifier', + `Expected variable name to be an Identifier, but was ${id.type} instead.` + ) return id case 'ClassDeclaration': throw new Error('Exporting of class is not supported.') @@ -163,11 +169,10 @@ const getDefaultExportExpression = ( if (node.type !== 'ExportDefaultDeclaration') { return } - if (defaultExport !== null) { - // This should never occur because multiple default exports should have - // been caught by the Acorn parser when parsing into an AST. - throw new Error('Encountered multiple default exports!') - } + // This should never occur because multiple default exports should have + // been caught by the Acorn parser when parsing into an AST. + assert(defaultExport === null, 'Encountered multiple default exports!') + if (isDeclaration(node.declaration)) { const identifier = getIdentifier(node.declaration) if (identifier === null) { diff --git a/src/modules/__mocks__/moduleLoader.ts b/src/modules/__mocks__/moduleLoader.ts new file mode 100644 index 000000000..49498ac16 --- /dev/null +++ b/src/modules/__mocks__/moduleLoader.ts @@ -0,0 +1,29 @@ +import { Context } from '../../types' + +export function loadModuleBundle() { + return { + foo: () => 'foo', + bar: () => 'bar' + } +} + +export function loadModuleTabs(_name: string) { + return [] +} +export const memoizedGetModuleManifest = () => ({ + one_module: { tabs: [] }, + other_module: { tabs: [] }, + another_module: { tabs: [] } +}) + +export async function initModuleContext(moduleName: string, context: Context, loadTabs: boolean) { + // Load the module's tabs + if (!(moduleName in context.moduleContexts)) { + context.moduleContexts[moduleName] = { + state: null, + tabs: loadTabs ? loadModuleTabs(moduleName) : null + } + } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) { + context.moduleContexts[moduleName].tabs = loadModuleTabs(moduleName) + } +} diff --git a/src/modules/__mocks__/moduleLoaderAsync.ts b/src/modules/__mocks__/moduleLoaderAsync.ts new file mode 100644 index 000000000..c4fec6642 --- /dev/null +++ b/src/modules/__mocks__/moduleLoaderAsync.ts @@ -0,0 +1,46 @@ +import { Context } from '../../types' + +export const memoizedGetModuleDocsAsync = jest.fn().mockResolvedValue({ + foo: 'foo', + bar: 'bar' +}) + +export const memoizedGetModuleBundleAsync = jest.fn().mockResolvedValue( + `require => ({ + foo: () => 'foo', + bar: () => 'bar', + })` +) + +export const memoizedGetModuleManifestAsync = jest.fn().mockResolvedValue({ + one_module: { tabs: [] }, + other_module: { tabs: [] }, + another_module: { tabs: [] } +}) + +export function loadModuleBundleAsync() { + return Promise.resolve({ + foo: () => 'foo', + bar: () => 'bar' + }) +} + +export function loadModuleTabsAsync(_name: string) { + return Promise.resolve([]) +} + +export async function initModuleContextAsync( + moduleName: string, + context: Context, + loadTabs: boolean +) { + // Load the module's tabs + if (!(moduleName in context.moduleContexts)) { + context.moduleContexts[moduleName] = { + state: null, + tabs: loadTabs ? await loadModuleTabsAsync(moduleName) : null + } + } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) { + context.moduleContexts[moduleName].tabs = await loadModuleTabsAsync(moduleName) + } +} diff --git a/src/modules/__tests__/moduleLoader.ts b/src/modules/__tests__/moduleLoader.ts index d191d8fac..84a553fdb 100644 --- a/src/modules/__tests__/moduleLoader.ts +++ b/src/modules/__tests__/moduleLoader.ts @@ -183,4 +183,17 @@ describe('Testing modules/moduleLoader.ts in a jsdom environment', () => { expect(mockedXMLHttpRequest3.send).toHaveBeenCalledTimes(1) expect(mockedXMLHttpRequest3.send).toHaveBeenCalledWith(null) }) + + test('Able to load tabs with export default declarations', () => { + mockXMLHttpRequest({ responseText: `{ "valid_module": { "tabs": ["Tab1", "Tab2"] } }` }) + mockXMLHttpRequest({ responseText: `export default require => ({ foo: () => 'foo' })` }) + mockXMLHttpRequest({ responseText: `require => ({ foo: () => 'foo' })` }) + + const [rawTab1, rawTab2] = moduleLoader.loadModuleTabs('valid_module') + const tab1 = rawTab1(jest.fn()) + expect(tab1.foo()).toEqual('foo') + + const tab2 = rawTab2(jest.fn()) + expect(tab2.foo()).toEqual('foo') + }) }) diff --git a/src/modules/__tests__/moduleLoaderAsync.ts b/src/modules/__tests__/moduleLoaderAsync.ts new file mode 100644 index 000000000..a4f64fd20 --- /dev/null +++ b/src/modules/__tests__/moduleLoaderAsync.ts @@ -0,0 +1,197 @@ +import type { MockedFunction } from 'jest-mock' + +import { mockContext } from '../../mocks/context' +import { Chapter, Variant } from '../../types' +import { ModuleConnectionError, ModuleInternalError } from '../errors' +import { MODULES_STATIC_URL } from '../moduleLoader' +import * as moduleLoader from '../moduleLoaderAsync' + +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + memoize: jest.fn(x => x) +})) + +global.fetch = jest.fn() +const mockedFetch = fetch as MockedFunction + +function mockResponse(response: string, status: number = 200) { + mockedFetch.mockResolvedValueOnce({ + text: () => Promise.resolve(response), + json: () => Promise.resolve(JSON.parse(response)), + status + } as any) +} + +async function expectSuccess( + correctUrl: string, + expectedResp: T, + func: () => Promise, + callCount: number = 1 +) { + const response = await func() + + expect(fetch).toHaveBeenCalledTimes(callCount) + + const [calledUrl, callOpts] = mockedFetch.mock.calls[0] + expect(calledUrl).toEqual(correctUrl) + expect(callOpts).toMatchObject({ method: 'GET' }) + if (typeof expectedResp === 'string') { + expect(response).toEqual(expectedResp) + } else { + expect(response).toMatchObject(expectedResp) + } +} + +async function expectFailure(sampleUrl: string, expectedErr: any, func: () => Promise) { + await expect(() => func()).rejects.toBeInstanceOf(expectedErr) + + const [calledUrl, callOpts] = mockedFetch.mock.calls[0] + expect(calledUrl).toEqual(sampleUrl) + expect(callOpts).toMatchObject({ method: 'GET' }) +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('Test httpGetAsync', () => { + test('Http GET function httpGetAsync() works correctly', async () => { + const sampleResponse = `{ "repeat": { "contents": ["Repeat"] } }` + const sampleUrl = 'https://www.example.com' + + mockResponse(sampleResponse) + await expectSuccess(sampleUrl, sampleResponse, () => + moduleLoader.httpGetAsync(sampleUrl, 'text') + ) + }) + + test('Http GET function httpGetAsync() throws ModuleConnectionError', async () => { + const sampleUrl = 'https://www.example.com' + mockResponse('', 404) + + await expectFailure(sampleUrl, ModuleConnectionError, () => + moduleLoader.httpGetAsync(sampleUrl, 'text') + ) + }) + + test('Http GET modules manifest correctly', async () => { + const sampleResponse = `{ "repeat": { "contents": ["Repeat"] } }` + const correctUrl = MODULES_STATIC_URL + `/modules.json` + mockResponse(sampleResponse) + + await expectSuccess(correctUrl, JSON.parse(sampleResponse), () => + moduleLoader.memoizedGetModuleManifestAsync() + ) + }) + + test('Http GET returns objects when "json" is specified', async () => { + const sampleResponse = `{ "repeat": { "contents": ["Repeat"] } }` + const correctUrl = MODULES_STATIC_URL + `/modules.json` + mockResponse(sampleResponse) + const result = await moduleLoader.httpGetAsync(correctUrl, 'json') + expect(result).toMatchObject(JSON.parse(sampleResponse)) + }) + + test('Handles TypeErrors thrown by fetch', async () => { + mockedFetch.mockImplementationOnce(() => { + throw new TypeError() + }) + await expectFailure('anyUrl', ModuleConnectionError, () => + moduleLoader.httpGetAsync('anyUrl', 'text') + ) + }) +}) + +describe('Test bundle loading', () => { + const sampleModuleName = 'valid_module' + const sampleModuleUrl = MODULES_STATIC_URL + `/bundles/${sampleModuleName}.js` + + test('Http GET module bundle correctly', async () => { + const sampleResponse = `require => ({ foo: () => 'foo' })` + mockResponse(sampleResponse) + + const bundleText = await moduleLoader.memoizedGetModuleBundleAsync(sampleModuleName) + + expect(fetch).toHaveBeenCalledTimes(1) + + const [calledUrl, callOpts] = mockedFetch.mock.calls[0] + expect(calledUrl).toEqual(sampleModuleUrl) + expect(callOpts).toMatchObject({ method: 'GET' }) + expect(bundleText).toEqual(sampleResponse) + }) + + test('Loading a correctly implemented module bundle', async () => { + const context = mockContext(Chapter.SOURCE_4, Variant.DEFAULT) + const sampleResponse = `require => ({ foo: () => 'foo' })` + mockResponse(sampleResponse) + + const loadedModule = await moduleLoader.loadModuleBundleAsync(sampleModuleName, context, false) + + expect(loadedModule.foo()).toEqual('foo') + expect(fetch).toHaveBeenCalledTimes(1) + + const [calledUrl, callOpts] = mockedFetch.mock.calls[0] + expect(calledUrl).toEqual(sampleModuleUrl) + expect(callOpts).toMatchObject({ method: 'GET' }) + }) + + test('Loading a wrongly implemented module bundle throws ModuleInternalError', async () => { + const context = mockContext(Chapter.SOURCE_4, Variant.DEFAULT) + const wrongModuleText = `export function es6_function(params) {};` + mockResponse(wrongModuleText) + await expect(() => + moduleLoader.loadModuleBundleAsync(sampleModuleName, context, true) + ).rejects.toBeInstanceOf(ModuleInternalError) + + expect(fetch).toHaveBeenCalledTimes(1) + }) +}) + +describe('Test tab loading', () => { + const sampleTabUrl = `${MODULES_STATIC_URL}/tabs/Tab1.js` + const sampleManifest = `{ "one_module": { "tabs": ["Tab1", "Tab2"] } }` + + test('Http GET module tab correctly', async () => { + const sampleResponse = `require => ({ foo: () => 'foo' })` + mockResponse(sampleResponse) + + const bundleText = await moduleLoader.memoizedGetModuleTabAsync('Tab1') + + expect(fetch).toHaveBeenCalledTimes(1) + + const [calledUrl, callOpts] = mockedFetch.mock.calls[0] + expect(calledUrl).toEqual(sampleTabUrl) + expect(callOpts).toMatchObject({ method: 'GET' }) + expect(bundleText).toEqual(sampleResponse) + }) + + test('Loading a wrongly implemented tab throws ModuleInternalError', async () => { + mockResponse(sampleManifest) + + const wrongTabText = `export function es6_function(params) {};` + mockResponse(wrongTabText) + mockResponse(wrongTabText) + + await expect(() => moduleLoader.loadModuleTabsAsync('one_module')).rejects.toBeInstanceOf( + ModuleInternalError + ) + expect(fetch).toHaveBeenCalledTimes(3) + + const [[call0Url], [call1Url], [call2Url]] = mockedFetch.mock.calls + expect(call0Url).toEqual(`${MODULES_STATIC_URL}/modules.json`) + expect(call1Url).toEqual(`${MODULES_STATIC_URL}/tabs/Tab1.js`) + expect(call2Url).toEqual(`${MODULES_STATIC_URL}/tabs/Tab2.js`) + }) + + test('Able to handle tabs with export default declarations', async () => { + mockResponse(sampleManifest) + mockResponse(`export default require => ({ foo: () => 'foo' })`) + mockResponse(`require => ({ foo: () => 'foo' })`) + const [rawTab1, rawTab2] = await moduleLoader.loadModuleTabsAsync('one_module') + const tab1 = rawTab1(jest.fn()) + expect(tab1.foo()).toEqual('foo') + + const tab2 = rawTab2(jest.fn()) + expect(tab2.foo()).toEqual('foo') + }) +}) diff --git a/src/modules/errors.ts b/src/modules/errors.ts index fa3ebaef0..4f69c1939 100644 --- a/src/modules/errors.ts +++ b/src/modules/errors.ts @@ -1,4 +1,4 @@ -import type { ImportDeclaration } from 'estree' +import type * as es from 'estree' import { RuntimeSourceError } from '../errors/runtimeSourceError' @@ -6,7 +6,7 @@ export class UndefinedImportError extends RuntimeSourceError { constructor( public readonly symbol: string, public readonly moduleName: string, - node?: ImportDeclaration + node?: es.ImportSpecifier ) { super(node) } @@ -19,3 +19,51 @@ export class UndefinedImportError extends RuntimeSourceError { return "Check your imports and make sure what you're trying to import exists!" } } + +export class ModuleConnectionError extends RuntimeSourceError { + private static message: string = `Unable to get modules.` + private static elaboration: string = `You should check your Internet connection, and ensure you have used the correct module path.` + constructor(node?: es.Node) { + super(node) + } + + public explain() { + return ModuleConnectionError.message + } + + public elaborate() { + return ModuleConnectionError.elaboration + } +} + +export class ModuleNotFoundError extends RuntimeSourceError { + constructor(public moduleName: string, node?: es.Node) { + super(node) + } + + public explain() { + return `Module "${this.moduleName}" not found.` + } + + public elaborate() { + return ` + You should check your import declarations, and ensure that all are valid modules. + ` + } +} + +export class ModuleInternalError extends RuntimeSourceError { + constructor(public moduleName: string, public error?: any, node?: es.Node) { + super(node) + } + + public explain() { + return `Error(s) occured when executing the module "${this.moduleName}".` + } + + public elaborate() { + return ` + You may need to contact with the author for this module to fix this error. + ` + } +} diff --git a/src/modules/moduleLoader.ts b/src/modules/moduleLoader.ts index 2801ce81a..c4445a32b 100644 --- a/src/modules/moduleLoader.ts +++ b/src/modules/moduleLoader.ts @@ -9,8 +9,9 @@ import { } from '../errors/moduleErrors' import { Context } from '../types' import { wrapSourceModule } from '../utils/operators' -import { ModuleBundle, ModuleDocumentation, ModuleFunctions, Modules } from './moduleTypes' +import { ModuleBundle, ModuleDocumentation, ModuleFunctions, ModuleManifest } from './moduleTypes' import { getRequireProvider } from './requireProvider' +import { evalRawTab } from './utils' // Supports both JSDom (Web Browser) environment and Node environment export const newHttpRequest = () => @@ -46,7 +47,7 @@ export function httpGet(url: string): string { * @return Modules */ export const memoizedGetModuleManifest = memoize(getModuleManifest) -function getModuleManifest(): Modules { +function getModuleManifest(): ModuleManifest { const rawManifest = httpGet(`${MODULES_STATIC_URL}/modules.json`) return JSON.parse(rawManifest) } @@ -107,7 +108,7 @@ export function loadModuleTabs(path: string, node?: es.Node) { return sideContentTabPaths.map(path => { const rawTabFile = memoizedGetModuleFile(path, 'tab') try { - return eval(rawTabFile) + return evalRawTab(rawTabFile) } catch (error) { // console.error('tab error:', error); throw new ModuleInternalError(path, error, node) @@ -129,3 +130,19 @@ export function loadModuleDocs(path: string, node?: es.Node) { return null } } + +export function initModuleContext( + moduleName: string, + context: Context, + loadTabs: boolean, + node?: es.Node +) { + if (!(moduleName in context.moduleContexts)) { + context.moduleContexts[moduleName] = { + state: null, + tabs: loadTabs ? loadModuleTabs(moduleName, node) : null + } + } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) { + context.moduleContexts[moduleName].tabs = loadModuleTabs(moduleName, node) + } +} diff --git a/src/modules/moduleLoaderAsync.ts b/src/modules/moduleLoaderAsync.ts new file mode 100644 index 000000000..e0a6be116 --- /dev/null +++ b/src/modules/moduleLoaderAsync.ts @@ -0,0 +1,130 @@ +import type { Node } from 'estree' +import { memoize } from 'lodash' + +import type { Context } from '..' +import { PromiseTimeoutError, timeoutPromise } from '../utils/misc' +import { wrapSourceModule } from '../utils/operators' +import { ModuleConnectionError, ModuleInternalError, ModuleNotFoundError } from './errors' +import { MODULES_STATIC_URL } from './moduleLoader' +import type { ModuleBundle, ModuleDocumentation, ModuleManifest } from './moduleTypes' +import { getRequireProvider } from './requireProvider' +import { evalRawTab } from './utils' + +export function httpGetAsync(path: string, type: 'json'): Promise +export function httpGetAsync(path: string, type: 'text'): Promise +export async function httpGetAsync(path: string, type: 'json' | 'text') { + try { + const resp = await timeoutPromise( + fetch(path, { + method: 'GET' + }), + 10000 + ) + + if (resp.status !== 200 && resp.status !== 304) { + throw new ModuleConnectionError() + } + + const promise = type === 'text' ? resp.text() : resp.json() + return timeoutPromise(promise, 10000) + } catch (error) { + if (error instanceof TypeError || error instanceof PromiseTimeoutError) { + throw new ModuleConnectionError() + } + if (!(error instanceof ModuleConnectionError)) throw new ModuleInternalError(path, error) + throw error + } +} + +/** + * Send a HTTP GET request to the modules endpoint to retrieve the manifest + * @return Modules + */ +export const memoizedGetModuleManifestAsync = memoize(getModuleManifestAsync) +function getModuleManifestAsync(): Promise { + return httpGetAsync(`${MODULES_STATIC_URL}/modules.json`, 'json') as Promise +} + +async function checkModuleExists(moduleName: string, node?: Node) { + const modules = await memoizedGetModuleManifestAsync() + // Check if the module exists + if (!(moduleName in modules)) throw new ModuleNotFoundError(moduleName, node) + + return modules[moduleName] +} + +export const memoizedGetModuleBundleAsync = memoize(getModuleBundleAsync) +async function getModuleBundleAsync(moduleName: string): Promise { + return httpGetAsync(`${MODULES_STATIC_URL}/bundles/${moduleName}.js`, 'text') +} + +export const memoizedGetModuleTabAsync = memoize(getModuleTabAsync) +function getModuleTabAsync(tabName: string): Promise { + return httpGetAsync(`${MODULES_STATIC_URL}/tabs/${tabName}.js`, 'text') +} + +export const memoizedGetModuleDocsAsync = memoize(getModuleDocsAsync) +async function getModuleDocsAsync(moduleName: string): Promise { + try { + const result = await httpGetAsync(`${MODULES_STATIC_URL}/jsons/${moduleName}.json`, 'json') + return result as ModuleDocumentation + } catch (error) { + console.warn(`Failed to load documentation for ${moduleName}:`, error) + return null + } +} + +export async function loadModuleTabsAsync(moduleName: string, node?: Node) { + const moduleInfo = await checkModuleExists(moduleName, node) + + // Load the tabs for the current module + return Promise.all( + moduleInfo.tabs.map(async path => { + const rawTabFile = await memoizedGetModuleTabAsync(path) + try { + return evalRawTab(rawTabFile) + } catch (error) { + // console.error('tab error:', error); + throw new ModuleInternalError(path, error, node) + } + }) + ) +} + +export async function loadModuleBundleAsync( + moduleName: string, + context: Context, + wrapModule: boolean, + node?: Node +) { + // await checkModuleExists(moduleName, node) + const moduleText = await memoizedGetModuleBundleAsync(moduleName) + try { + const moduleBundle: ModuleBundle = eval(moduleText) + + if (wrapModule) return wrapSourceModule(moduleName, moduleBundle, getRequireProvider(context)) + return moduleBundle(getRequireProvider(context)) + } catch (error) { + // console.error("bundle error: ", error) + throw new ModuleInternalError(moduleName, error, node) + } +} + +/** + * Initialize module contexts and add UI tabs needed for modules to program context + */ +export async function initModuleContextAsync( + moduleName: string, + context: Context, + loadTabs: boolean +) { + // Load the module's tabs + if (!(moduleName in context.moduleContexts)) { + context.moduleContexts[moduleName] = { + state: null, + tabs: loadTabs ? await loadModuleTabsAsync(moduleName) : null + } + } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) { + context.moduleContexts[moduleName].tabs = await loadModuleTabsAsync(moduleName) + } +} diff --git a/src/modules/moduleTypes.ts b/src/modules/moduleTypes.ts index 974f27de4..8182f0a17 100644 --- a/src/modules/moduleTypes.ts +++ b/src/modules/moduleTypes.ts @@ -1,6 +1,6 @@ import type { RequireProvider } from './requireProvider' -export type Modules = { +export type ModuleManifest = { [module: string]: { tabs: string[] } @@ -13,3 +13,9 @@ export type ModuleFunctions = { } export type ModuleDocumentation = Record + +export type ImportTransformOptions = { + wrapSourceModules: boolean + loadTabs: boolean + checkImports: boolean +} diff --git a/src/modules/utils.ts b/src/modules/utils.ts new file mode 100644 index 000000000..2d53696e9 --- /dev/null +++ b/src/modules/utils.ts @@ -0,0 +1,12 @@ +/** + * For tabs that are export default declarations, we need to remove + * the `export default` bit from the front before they can be loaded + * by js-slang + */ +export function evalRawTab(text: string) { + if (text.startsWith('export default')) { + text = text.substring(14) + } + + return eval(text) +} diff --git a/src/repl/transpiler.ts b/src/repl/transpiler.ts index db7d3906b..4b198628a 100644 --- a/src/repl/transpiler.ts +++ b/src/repl/transpiler.ts @@ -11,7 +11,7 @@ import { transpile } from '../transpiler/transpiler' import { Chapter, Variant } from '../types' import { validateAndAnnotate } from '../validator/validator' -function transpileCode( +async function transpileCode( chapter: Chapter = Chapter.SOURCE_1, variant: Variant = Variant.DEFAULT, code = '', @@ -35,7 +35,8 @@ function transpileCode( if (pretranspile) { return generate(program) } else { - return transpile(program as Program, context).transpiled + const { transpiled } = await transpile(program as Program, context) + return transpiled } } @@ -90,8 +91,9 @@ function main() { }) process.stdin.on('end', () => { const code = Buffer.concat(chunks).toString('utf-8') - const transpiled = transpileCode(chapter, variant, code, pretranspile) - process.stdout.write(transpiled) + transpileCode(chapter, variant, code, pretranspile).then(transpiled => + process.stdout.write(transpiled) + ) }) } diff --git a/src/runner/fullJSRunner.ts b/src/runner/fullJSRunner.ts index 97f8c3b79..1327b8c4f 100644 --- a/src/runner/fullJSRunner.ts +++ b/src/runner/fullJSRunner.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { generate } from 'astring' -import * as es from 'estree' +import type * as es from 'estree' import { RawSourceMap } from 'source-map' -import { IOptions, Result } from '..' +import type { Result } from '..' import { NATIVE_STORAGE_ID } from '../constants' import { RuntimeSourceError } from '../errors/runtimeSourceError' import { hoistAndMergeImports } from '../localImports/transformers/hoistAndMergeImports' +import { ImportTransformOptions } from '../modules/moduleTypes' import { getRequireProvider, RequireProvider } from '../modules/requireProvider' import { parse } from '../parser/parser' import { @@ -19,7 +20,7 @@ import type { Context, NativeStorage } from '../types' import * as create from '../utils/astCreator' import { getFunctionDeclarationNamesInProgram } from '../utils/uniqueIds' import { toSourceError } from './errors' -import { appendModulesToContext, resolvedErrorPromise } from './utils' +import { resolvedErrorPromise } from './utils' function fullJSEval( code: string, @@ -54,7 +55,7 @@ function containsPrevEval(context: Context): boolean { export async function fullJSRunner( program: es.Program, context: Context, - options: Partial = {} + importOptions: ImportTransformOptions ): Promise { // prelude & builtins // only process builtins and preludes if it is a fresh eval context @@ -68,7 +69,6 @@ export async function fullJSRunner( // modules hoistAndMergeImports(program) - appendModulesToContext(program, context) // evaluate and create a separate block for preludes and builtins const preEvalProgram: es.Program = create.program([ @@ -88,12 +88,12 @@ export async function fullJSRunner( let transpiled let sourceMapJson: RawSourceMap | undefined try { - ;({ transpiled, sourceMapJson } = transpile(program, context)) - return Promise.resolve({ + ;({ transpiled, sourceMapJson } = await transpile(program, context, importOptions)) + return { status: 'finished', context, value: await fullJSEval(transpiled, requireProvider, context.nativeStorage) - }) + } } catch (error) { context.errors.push( error instanceof RuntimeSourceError ? error : await toSourceError(error, sourceMapJson) diff --git a/src/runner/htmlRunner.ts b/src/runner/htmlRunner.ts index f52c688f8..ba17dd062 100644 --- a/src/runner/htmlRunner.ts +++ b/src/runner/htmlRunner.ts @@ -1,5 +1,5 @@ import { IOptions, Result } from '..' -import { Context } from '../types' +import { Context, RecursivePartial } from '../types' const HTML_ERROR_HANDLING_SCRIPT_TEMPLATE = `