Files
scratch-vm/src/compiler/compiler.js
T

574 lines
15 KiB
JavaScript

const log = require('../util/log');
const Thread = require('../engine/thread');
const Runtime = require('../engine/runtime');
const Blocks = require('../engine/blocks');
const RenderedTarget = require('../sprites/rendered-target');
const CompilerHints = require('./hints');
const execute = require('./execute');
const statements = {};
const inputs = {};
const defaultExtensions = [
require('./blocks/compiler_natives'),
require('./blocks/compiler_scratch3_motion'),
require('./blocks/compiler_scratch3_looks'),
require('./blocks/compiler_scratch3_sounds'),
require('./blocks/compiler_scratch3_event'),
require('./blocks/compiler_scratch3_control'),
require('./blocks/compiler_scratch3_sensing'),
require('./blocks/compiler_scratch3_operators'),
require('./blocks/compiler_scratch3_data'),
require('./blocks/compiler_scratch3_procedures'),
// TODO: do not load extensions immediately
require('./extensions/compiler_pen'),
];
const TYPE_UNKNOWN = 0;
const TYPE_NUMBER = 1;
const TYPE_BOOLEAN = 2;
const TYPE_STRING = 3;
const FLAG_NANABLE = 1;
defaultExtensions.forEach((ext) => {
const extensionInputs = ext.getInputs();
for (const op in extensionInputs) {
if (extensionInputs.hasOwnProperty(op)) {
inputs[op] = extensionInputs[op];
}
}
const extensionStatements = ext.getStatements();
for (const op in extensionStatements) {
if (extensionStatements.hasOwnProperty(op)) {
statements[op] = extensionStatements[op];
}
}
});
/**
* Prevents the use of toString() on an object by throwing an error.
* Useful to make sure that a method is always called, never accidentally stringified.
* @param {Object} obj
*/
const disableToString = (obj) => {
obj.toString = () => {
throw new Error(`toString unexpectedly called on ${obj.name || 'object'}, did you forget to call it?`);
};
};
class BlockUtil {
/**
* @param {Compiler} compiler
*/
constructor(compiler, block) {
this.compiler = compiler;
this.block = block;
}
// Expose some constants that are likely to be of use to blocks.
get TYPE_UNKNOWN() { return TYPE_UNKNOWN; }
get TYPE_NUMBER() { return TYPE_NUMBER; }
get TYPE_BOOLEAN() { return TYPE_BOOLEAN; }
get TYPE_STRING() { return TYPE_STRING; }
get FLAG_NANABLE() { return FLAG_NANABLE; }
/**
* The target being compiled.
* Note: This target might not represent the `target` found at runtime, as scripts can be shared between clones.
*/
get target() {
return this.compiler.target;
}
/**
* Whether the target being compiled is a stage.
* @type {boolean}
*/
get isStage() {
return !!this.target.isStage;
}
/**
* Get the compiler hints.
*/
get hints() {
return this.compiler.hints;
}
/**
* Compile an input of this block.
* @param {string} name The name of the input. (CONDITION, VALUE, etc.)
* @returns {CompiledInput}
*/
input(name) {
if (!this.hasInput(name)) {
return new CompiledInput('""', TYPE_STRING);
}
return this.compiler.compileInput(this.block, name);
}
/**
* Return whether this block has an input of a given name.
* @param {string} name The name of the input. (CONDITION, VALUE, etc.)
*/
hasInput(name) {
return this.block.inputs.hasOwnProperty(name) && this.block.inputs[name].block !== null;
}
/**
* Get the field data object.
* @param {string} name The name of the field. (VARIABLE, TEXT, etc.)
*/
field(name) {
return this.block.fields[name];
}
/**
* Get the raw text value of a field.
* This value is *not* safe to include directly in scripts.
* @param {string} name The name of the field. (VARIABLE, TEXT, etc.)
* @returns {string}
*/
fieldValueUnsafe(name) {
return this.field(name).value;
}
/**
* Make text safe to include inside a JavaScript string.
* safe() does not put quotes around the string, you must do that yourself.
* @param {string} string The text to make safe
* @returns {string}
*/
safe(string) {
return string
.replace(/\\/g, '\\\\')
.replace(/'/g, '\\\'')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\{/g, '\\x7b')
.replace(/\}/g, '\\x7d');
}
}
class InputUtil extends BlockUtil {
unknown(source) {
return new CompiledInput(source, TYPE_UNKNOWN);
}
number(source) {
return new CompiledInput(source, TYPE_NUMBER);
}
boolean(source) {
return new CompiledInput(source, TYPE_BOOLEAN);
}
string(source) {
return new CompiledInput(source, TYPE_STRING);
}
fieldString(name) {
return new CompiledInput(`"${this.safe(this.fieldValueUnsafe(name))}"`, TYPE_STRING);
}
noop() {
return new CompiledInput('/* no-op */ undefined', TYPE_UNKNOWN);
}
}
class StatementUtil extends BlockUtil {
constructor(compiler, block) {
super(compiler, block);
this.source = '';
}
/**
* Yield script if not running in warp mode.
* Does not change thread state.
*/
yieldNotWarp() {
if (!this.hints.isWarp) {
this.writeLn('if (thread.warp === 0) yield;');
}
}
/**
* Pause script execution until threads complete.
* @param {string} threads Threads to wait for, should be a call to startHats()
*/
waitUntilThreadsComplete(threads) {
const v = this.var();
this.writeLn(`var ${v} = ${threads};`);
this.writeLn(`while (waitThreads(${v})) yield;`);
}
/**
* Write JS to this statement, followed by a newline.
* @param {string} s The source to write.
*/
writeLn(s) {
this.source += s + '\n';
}
/**
* Write JS to this statement.
* @param {string} s The source to write.
*/
write(s) {
this.source += s;
}
/**
* Explicitly do nothing.
*/
noop() {
this.writeLn('/* no-op */');
}
/**
* Stop this thread.
*/
retire() {
this.writeLn('retire(); yield;');
}
/**
* Get a local variable.
*/
var() {
return this.compiler.nextVariable();
}
/**
* Compile a substack.
* @param {string} inputName The name of the substack.
* @returns {string}
*/
substack(inputName) {
const inputValue = this.block.inputs[inputName];
if (!inputValue) {
// empty substack
return '';
}
const substack = inputValue.block;
return this.compiler.compileStack(substack);
}
}
class CompiledInput {
/**
* @param {string} source The input's source code.
* @param {number} type The input's type at runtime.
*/
constructor(source, type) {
/**
* The input's source code.
* @readonly
* @private
*/
this.source = source;
/**
* The input's type.
* @readonly
*/
this.type = type;
/**
* Internal flags.
* @private
*/
this.flags = 0;
}
/**
* Enable a flag.
* @param {number} flag The value of the flag to enable.
*/
setFlag(flag) {
this.flags |= flag;
return this;
}
toString() {
return this.source;
}
asNumber() {
if (this.type === TYPE_NUMBER) {
if (this.flags & FLAG_NANABLE) {
return 'toNotNaN(' + this.source + ')';
}
return this.source;
}
return 'toNumber(' + this.source + ')';
}
asBoolean() {
if (this.type === TYPE_BOOLEAN) return this.source;
return 'toBoolean(' + this.source + ')';
}
asString() {
if (this.type === TYPE_STRING) return this.source;
return 'toString(' + this.source + ')';
}
}
disableToString(CompiledInput.prototype.asNumber);
disableToString(CompiledInput.prototype.asString);
disableToString(CompiledInput.prototype.asBoolean);
/**
* @typedef {Function} CompiledScript
*/
/**
* @typedef {Object} CompilationResult
* @property {Function} startingFunction
* @property {Object.<string, CompiledScript>} procedures
*/
class Compiler {
/**
* @param {Thread} thread
*/
constructor(thread) {
/** @type {RenderedTarget} */
this.target = thread.target;
if (!this.target) {
throw new Error('Missing target');
}
/** @type {Runtime} */
this.runtime = this.target.runtime;
/** @type {Blocks} */
this.blocks = this.target.blocks;
/** @type {string} */
this.topBlock = thread.topBlock;
/**
* Compiled procedures.
* @type {Object.<string, CompiledScript>}
*/
this.procedures = {};
/**
* Number of local variables created.
*/
this.variableCount = 0;
/**
* Compiler optimization/behavior hints.
* This is set by compileScript.
* @type {CompilerHints}
*/
this.hints = undefined;
/**
* Procedures that are queued to be compiled.
* Map of procedure code to the ID of the definition block.
* @type {Map.<string, string>}
* @private
*/
this.uncompiledProcedures = new Map();
/**
* Procedures that are being compiled.
* Same structure as uncompiledProcedures.
* @type {Map.<string, string>}
* @private
*/
this.compilingProcedures = new Map();
}
/**
* Ask the compiler to queue the compilation of a procedure.
* This will do nothing if the procedure is already compiled or queued.
* @param {string} procedureCode The procedure's code
*/
dependProcedure(procedureCode) {
if (this.procedures.hasOwnProperty(procedureCode)) {
// already compiled
return;
}
if (this.compilingProcedures.has(procedureCode)) {
// being compiled
return;
}
if (this.uncompiledProcedures.has(procedureCode)) {
// queued to be compiled
return;
}
const definition = this.target.blocks.getProcedureDefinition(procedureCode);
this.uncompiledProcedures.set(procedureCode, definition);
}
/**
* @returns {CompiledInput}
*/
compileInput(parentBlock, inputName) {
const input = parentBlock.inputs[inputName];
const inputId = input.block;
const block = this.blocks.getBlock(inputId);
let compiler = inputs[block.opcode];
if (!compiler) {
log.error('unknown opcode', block);
throw new Error('unknown opcode: ' + block.opcode);
}
const util = new InputUtil(this, block);
const result = compiler(util);
return result;
}
/**
* @param {string} startingId The ID of the first block in the stack.
* @returns {string}
*/
compileStack(startingId) {
let blockId = startingId;
let source = '';
while (blockId !== null) {
const block = this.blocks.getBlock(blockId);
if (!block) {
throw new Error('no block');
}
let compiler = statements[block.opcode];
if (!compiler) {
log.error('unknown opcode', block);
throw new Error('unknown opcode: ' + block.opcode);
}
const util = new StatementUtil(this, block);
compiler(util);
source += util.source;
blockId = block.next;
}
return source;
}
/**
* Compile a script.
* @param {string} topBlock The ID of the top block of the script. This should not be the ID of the hat block.
* @param {CompilerHints} hints
*/
compileScript(topBlock, hints) {
// blocks will read hints from here
this.hints = hints;
let script = '';
script += 'function* g(';
// Procedures accept arguments
if (hints.isProcedure) {
script += 'C';
}
script += ') {\n';
// Increase warp level
if (hints.isWarp) {
script += 'thread.warp++;\n';
} else if (hints.isProcedure) {
script += 'if (thread.warp) thread.warp++;\n';
}
script += this.compileStack(topBlock);
if (hints.isProcedure) {
script += 'endCall();\n';
} else {
script += 'retire();\n';
}
script += '}';
const fn = execute.createScriptFactory(script);
log.info(`[${this.target.getName()}] compiled script`, script);
return fn;
}
/**
* Get the name of the next local variable.
*/
nextVariable() {
this.variableCount++;
return 'a' + this.variableCount;
}
/**
* @returns {CompilationResult}
*/
compile() {
const target = this.target;
if (!target) throw new Error('no target');
const topBlock = this.target.blocks.getBlock(this.topBlock);
// TODO: figure out how to run blocks from the flyout, they have their ID set to their opcode
if (!topBlock) throw new Error('top block is missing');
// Compile the initial script to be started.
// We skip hat blocks, as they do not have code that can be run.
let startingBlock;
if (this.runtime.getIsHat(topBlock.opcode)) {
startingBlock = topBlock.next;
} else {
startingBlock = this.topBlock;
}
const startingFunction = this.compileScript(startingBlock, new CompilerHints());
// Compile any required procedures.
// As procedures can depend on other procedures, this process may take several iterations.
while (this.uncompiledProcedures.size > 0) {
this.compilingProcedures = this.uncompiledProcedures;
this.uncompiledProcedures = new Map();
for (const [procedureCode, definitionId] of this.compilingProcedures.entries()) {
const definitionBlock = target.blocks.getBlock(definitionId);
const innerDefinition = target.blocks.getBlock(definitionBlock.inputs.custom_block.block);
const bodyStart = definitionBlock.next;
// Extract the function's warp mode.
// See Sequencer.stepToProcedure
let isWarp = false;
if (innerDefinition && innerDefinition.mutation) {
const warp = innerDefinition.mutation.warp;
if (typeof warp === 'boolean') {
isWarp = warp;
} else if (typeof warp === 'string') {
isWarp = JSON.parse(warp);
}
}
const hints = new CompilerHints();
hints.isProcedure = true;
hints.isWarp = isWarp;
const compiledProcedure = this.compileScript(bodyStart, hints);
this.procedures[procedureCode] = compiledProcedure;
}
}
return {
startingFunction: startingFunction,
procedures: this.procedures,
};
}
}
Compiler.BlockUtil = BlockUtil;
Compiler.InputUtil = InputUtil;
Compiler.StatementUtil = StatementUtil;
Compiler.CompiledInput = CompiledInput;
module.exports = Compiler;