UNPKG

@readable-js/core

Version:

ReadableJS is a toolkit for teachers to craft small demos that can be explored interactively.

310 lines (309 loc) 13.6 kB
import * as esprima from 'esprima'; import * as escodegen from 'escodegen'; import { is } from '../../../enode-type-check'; import { SPY_FC_NAME } from '../run-code-with-spy'; import { createSpyString } from './create-spy-string/create-spy-string'; function insertSpy({ code, index, offset, spyString }) { return code.substring(0, index + offset) + spyString + code.substring(index + offset, code.length); } function insertSpyCodeBefore({ originalCode, newCode, eNode, offset, options }) { let insertedCodeLength = []; const m = new Map([ ["IfStatement", (e) => { const spyStringOpening = createSpyString(originalCode, e, options, undefined, true); insertedCodeLength.push(spyStringOpening.length); // add spy right after opening curvy bracket newCode = insertSpy({ code: newCode, index: e.consequent.range[0] + 1, offset, spyString: spyStringOpening, }); // `if () {} spy()`, how do you know if the test is true or false ? // you don't with a single if, unless you analyze what has been called previously // to avoid such bothersome extra work, just add an else. const spyStringClosing = createSpyString(originalCode, e, options, undefined, false); // we are going to need the new offset only if there was anything in the else from start. if (e.alternate) { insertedCodeLength.push(spyStringOpening.length + spyStringClosing.length); } // add spy right after closing curvy bracket newCode = insertSpy({ code: newCode, // without else, or with else index: !e.alternate ? e.consequent.range[1] : e.alternate.range[0] + 1, offset: offset + spyStringOpening.length, // without else, or with else spyString: !e.alternate ? ` else {\n${spyStringClosing}\n}` : spyStringClosing, }); }], ["VariableDeclaration", (e) => { // i'm totally unsure about escodegen.generate(d.id), should work for variables, but the other things, // i don't know what they are. const spyString = createSpyString(originalCode, e, options, e.declarations.map(d => escodegen.generate(d.id))); insertedCodeLength.push(spyString.length); newCode = insertSpy({ code: newCode, index: e.range[1], offset, spyString, }); }], // can be i++; or i+=1 or 1+1 or c() ["ExpressionStatement", (e) => { let evaluateVar; // nothing as default let index = e.range[1]; // by default after // s(); if (is.CallExpression(e.expression)) { evaluateVar = [escodegen.generate(e.expression.callee)]; index = e.range[0]; // insert before call } // i+=1; else if (is.AssignmentExpression(e.expression)) { evaluateVar = [escodegen.generate(e.expression.left)]; index = e.range[1]; } // i++; else if (is.UpdateExpression(e.expression) && is.Identifier(e.expression.argument)) { evaluateVar = [e.expression.argument.name]; index = e.range[1]; } else { console.warn(`No spy put because unsupported ExpressionStatement node: ${eNode.type} and ${e.type}, range: ${JSON.stringify(eNode.range)}`); } const spyString = createSpyString(originalCode, e, options, evaluateVar); insertedCodeLength.push(spyString.length); newCode = insertSpy({ code: newCode, index: index, offset, spyString, }); }], ["FunctionDeclaration", (e) => { // In js we have hoisting. (function can be declared after use) // would be nice to show it, but I have an issue with the offset. // It would need to be at the top of the stack, telling us every function declaration. insertedCodeLength.push(0); }], ["WhileStatement", (e) => { const spyString = createSpyString(originalCode, e, options, undefined, true); insertedCodeLength.push(spyString.length); newCode = insertSpy({ code: newCode, index: e.body.range[0] + 1, offset, spyString, }); // exit of while loop const spyStringExit = createSpyString(originalCode, e, options, undefined, false); newCode = insertSpy({ code: newCode, index: e.range[1], offset: offset + spyString.length, spyString: spyStringExit, }); }], ['ForStatement', (e) => { const spyString = createSpyString(originalCode, e, options, undefined, true); insertedCodeLength.push(spyString.length); // update length only because it is before children statements newCode = insertSpy({ code: newCode, index: e.body.range[0] + 1, offset, spyString, }); let updateLength = 0; // for loop, not always have the update, like i++ if (!!e.update) { const fakeNode = { type: 'ExpressionStatement', expression: e.update, loc: e.update.loc, range: e.update.range }; let evaluateVar; // i+=1 if (is.AssignmentExpression(e.update)) { evaluateVar = [escodegen.generate(e.update.left)]; } // i++; else if (is.UpdateExpression(e.update) && is.Identifier(e.update.argument)) { evaluateVar = [e.update.argument.name]; } const updateSpyString = createSpyString(originalCode, fakeNode, options, evaluateVar); updateLength = updateSpyString.length; newCode = insertSpy({ code: newCode, index: e.body.range[1] - 1, offset: offset + spyString.length, spyString: updateSpyString, }); } // exit of for loop const spyStringExit = createSpyString(originalCode, e, options, undefined, false); newCode = insertSpy({ code: newCode, index: e.range[1], offset: offset + spyString.length + updateLength, spyString: spyStringExit, }); }], ]); const fc = m.get(eNode.type); if (fc) { fc(eNode); } else { console.warn(`No spy put because unsupported node: ${eNode.type}, range: ${JSON.stringify(eNode.range)}`); } return { insertedCodeLength, newCode, }; } function isHasRange(a) { return !!a.range; } /** * Most of the time, you will get [[nodes]] array. * But then we have the special case of if and else, where they * are multiple bodies. * * It will return [[consequent], [alternate]]. * * Why ? Because we need a different offset for each. Indeed, when processing `if...else`, * we add a spy inside the if, and inside the else. thereby making two different offset, * for two different block statement. * * ``` * // processing if condition, to know what the if test evaluated to, we need two spies. * if () { * spy() // offset += 5 * } else { * spy() // offset += 10 * } * ``` */ function getChildStatements(eNode) { const m = new Map([ ["IfStatement", (next) => { let ret = []; /* * if (true) i++; */ if (is.ExpressionStatement(next.consequent)) { // skip for now, putting a spy with semi-colon in the middle will change the if ! // next.consequent.expression } /* * if (true) { * i++; * } * */ if (!next.alternate && is.BlockStatement(next.consequent)) { return [next.consequent.body]; } /* * else statement with {} * */ if (!!next.alternate && is.BlockStatement(next.alternate) && is.BlockStatement(next.consequent)) { return [next.consequent.body, next.alternate.body]; } return [[]]; }], ["ExpressionStatement", (next) => { return [[]]; }], ["VariableDeclaration", (next) => { return [[]]; }], ["FunctionDeclaration", (next) => { return [next.body.body]; }], ["WhileStatement", (next) => { if (!is.BlockStatement(next.body)) { // skip for now return [[]]; } else { return [next.body.body]; } }], ["ForStatement", (next) => { if (!is.BlockStatement(next.body)) { // skip for now return [[]]; } else { return [next.body.body]; } }], ]); const getChildFc = m.get(eNode.type); if (!getChildFc) { console.warn(`${eNode.type} in ${getChildStatements.name} not processed.`); } return getChildFc ? getChildFc(eNode) : []; } export function insertSpies(code, options) { const defaultOptions = { spyFcName: SPY_FC_NAME, spyParamHook: undefined, range: true, loc: true, classNames: { value: 'readable-value', variable: 'readable-variable', expression: 'readable-expression', } }; options = Object.assign(Object.assign({}, defaultOptions), options); // options are just for the rendered spy code, I need range here. // but loc is not needed for inserting spies const r = esprima.parseScript(code, { "range": true, "loc": true, // needed for messages. }); // copy as to not modify original let codeModified = code; // Process Program separately, it doesn't need any insertion of spy const stack = r.body.map(e => { // It should have loc and range because we called parseScript with correct params. if (!isHasRange(e)) { console.log(e); throw new Error(`Have you called esprima.parseScript without range param ?`); } return { offset: 0, last: e }; }); // Note recursive version would not require offset, but it look more messy. // recursive version would call insertSpyCodeBefore for parent only when all // children are done. Parent go first in DFS. // // DFS while (stack.length > 0) { // start by last, to not invalidate the ranges by esprima. const { offset, last } = stack.pop(); // cannot be undefined because stack.length > 0 // insert spy right before or after the code const { insertedCodeLength, newCode, } = insertSpyCodeBefore({ originalCode: code, newCode: codeModified, eNode: last, offset, options: options, // a bit false, because spyParamHook can be undefined }); // doesn't matter that we modified the code before, because we take that in account with offset. codeModified = newCode; // get more elements here and add them to stack. stack.push(...(getChildStatements(last).map((block, blockIndex) => block.map((e) => { // It should have loc and range because we called parseScript with correct params. if (!isHasRange(e)) { console.log(e); throw new Error(`Have you called esprima.parseScript without range param ?`); } return { // Add offset of parent stack: example, a if get a spy, that is put before the block statement of the if. // why is insertedCodeLength an array ? // --> example, in a if...else, insertedCodeLength[0] is offset of if, and insertedCodeLength[1] for else offset: offset + insertedCodeLength[blockIndex], last: e, }; }))).flat()); } return codeModified; }