8bitworkshop/src/common/script/env.ts

236 lines
8.3 KiB
TypeScript

import { WorkerError } from "../workertypes";
import ErrorStackParser = require("error-stack-parser");
import yufka from 'yufka';
import * as bitmap from "./lib/bitmap";
import * as io from "./lib/io";
import * as output from "./lib/output";
import * as color from "./lib/color";
import * as scriptui from "./lib/scriptui";
export interface Cell {
id: string;
object?: any;
}
export interface RunResult {
cells: Cell[];
state: {};
}
const IMPORTS = {
'bitmap': bitmap,
'io': io,
'output': output,
'color': color,
'ui': scriptui,
}
const LINE_NUMBER_OFFSET = 3; // TODO: shouldnt need?
const GLOBAL_BADLIST = [
'eval'
]
const GLOBAL_GOODLIST = [
'eval', // 'eval' can't be defined or assigned to in strict mode code
'Math', 'JSON',
'parseFloat', 'parseInt', 'isFinite', 'isNaN',
'String', 'Symbol', 'Number', 'Object', 'Boolean', 'NaN', 'Infinity', 'Date', 'BigInt',
'Set', 'Map', 'RegExp', 'Array', 'ArrayBuffer', 'DataView',
'Float32Array', 'Float64Array',
'Int8Array', 'Int16Array', 'Int32Array',
'Uint8Array', 'Uint16Array', 'Uint32Array', 'Uint8ClampedArray',
]
class RuntimeError extends Error {
constructor(public loc: acorn.SourceLocation, msg: string) {
super(msg);
}
}
export class Environment {
preamble: string;
postamble: string;
obj: {};
seq: number;
declvars : {[name : string] : acorn.Node};
constructor(
public readonly globalenv: any,
public readonly path: string
) {
var badlst = Object.getOwnPropertyNames(this.globalenv).filter(name => GLOBAL_GOODLIST.indexOf(name) < 0);
this.preamble = `'use strict';var ${badlst.join(',')};`;
for (var impname in IMPORTS) {
this.preamble += `var ${impname}=$$.${impname};`
}
this.preamble += '{\n';
this.postamble = '\n}';
}
error(varname: string, msg: string) {
console.log(varname, this.declvars[varname]);
throw new RuntimeError(this.declvars && this.declvars[varname].loc, msg);
}
preprocess(code: string): string {
this.declvars = {};
this.seq = 0;
let options = {
// https://www.npmjs.com/package/magic-string#sgeneratemap-options-
sourceMap: {
file: this.path,
source: this.path,
hires: false,
includeContent: false
},
// https://github.com/acornjs/acorn/blob/master/acorn/README.md
acorn: {
ecmaVersion: 6 as any,
locations: true,
allowAwaitOutsideFunction: true,
allowReturnOutsideFunction: true,
allowReserved: true,
}
};
const result = yufka(code, options, (node, { update, source, parent }) => {
function isTopLevel() {
return parent().type === 'ExpressionStatement' && parent(2) && parent(2).type === 'Program';
}
let left = node['left'];
switch (node.type) {
// error on forbidden keywords
case 'Identifier':
if (GLOBAL_BADLIST.indexOf(source()) >= 0) {
update(`__FORBIDDEN__KEYWORD__${source()}__`) // TODO? how to preserve line number?
}
break;
// x = expr --> var x = expr (first use)
case 'AssignmentExpression':
if (isTopLevel()) {
if (left && left.type === 'Identifier') {
if (!this.declvars[left.name]) {
update(`var ${left.name}=io.data.get(this.${source()}, ${JSON.stringify(left.name)})`)
this.declvars[left.name] = left;
} else {
update(`${left.name}=this.${source()}`)
}
}
}
break;
// literal comments
case 'Literal':
if (isTopLevel() && typeof node['value'] === 'string') {
update(`this.$$doc__${this.seq++} = { literaltext: ${source()} };`);
}
break;
}
});
return result.toString();
}
async run(code: string): Promise<void> {
// TODO: split into cells based on "--" linebreaks?
code = this.preprocess(code);
this.obj = {};
const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
const fn = new AsyncFunction('$$', this.preamble + code + this.postamble).bind(this.obj, IMPORTS);
await fn.call(this);
this.checkResult(this.obj, new Set(), []);
}
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
// TODO: return initial location of thingie
checkResult(o, checked: Set<object>, fullkey: string[]) {
if (o == null) return;
if (checked.has(o)) return;
if (typeof o === 'object') {
if (o.length > 100) return; // big array, don't bother
if (o.BYTES_PER_ELEMENT > 0) return; // typed array, don't bother
checked.add(o); // so we don't recurse if cycle
function prkey() { return fullkey.join('.') }
for (var [key, value] of Object.entries(o)) {
if (value == null && fullkey.length == 0) {
this.error(key, `"${key}" has no value.`)
}
fullkey.push(key);
if (typeof value === 'function') {
this.error(fullkey[0], `"${prkey()}" is a function. Did you forget to pass parameters?`); // TODO? did you mean (needs to see entire expr)
}
if (typeof value === 'symbol') {
this.error(fullkey[0], `"${prkey()}" is a Symbol, and can't be used.`) // TODO?
}
if (value instanceof Promise) {
this.error(fullkey[0], `"${prkey()}" is unresolved. Use "await" before expression.`) // TODO?
}
this.checkResult(value, checked, fullkey);
fullkey.pop();
}
}
}
render(): Cell[] {
var cells = [];
for (var [key, value] of Object.entries(this.obj)) {
if (typeof value === 'function') {
// TODO: find other values, functions embedded in objects?
} else {
var cell: Cell = { id: key, object: value };
cells.push(cell);
}
}
return cells;
}
extractErrors(e: Error): WorkerError[] {
let loc = e['loc'];
if (loc && loc.start && loc.end) {
return [{
path: this.path,
msg: e.message,
line: loc.start.line,
start: loc.start.column,
end: loc.end.line,
}]
}
if (loc && loc.line != null) {
return [{
path: this.path,
msg: e.message,
line: loc.line,
start: loc.column,
}]
}
// TODO: Cannot parse given Error object
let frames = ErrorStackParser.parse(e);
let frame = frames.findIndex(f => f.functionName === 'anonymous');
let errors = [];
while (frame >= 0) {
console.log(frames[frame]);
if (frames[frame].fileName.endsWith('Function')) {
errors.push( {
path: this.path,
msg: e.message,
line: frames[frame].lineNumber - LINE_NUMBER_OFFSET,
start: frames[frame].columnNumber,
} );
}
--frame;
}
if (errors.length == 0) {
errors.push( {
path: this.path,
msg: e.message,
line: 0
} );
}
return errors;
}
getLoadableState() {
let updated = null;
for (let [key, value] of Object.entries(this.declvars)) {
if (typeof value['$$getstate'] === 'function') {
let loadable = <any>value as io.Loadable;
if (updated == null) updated = {};
updated[key] = loadable.$$getstate();
}
}
return updated;
}
}