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

295 lines
11 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 const PROP_CONSTRUCTOR_NAME = "$$consname";
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);
}
}
function setConstructorName(o: object) : void {
let name = Object.getPrototypeOf(o)?.constructor?.name;
if (name != null && name != 'Object') {
o[PROP_CONSTRUCTOR_NAME] = name;
}
}
export class Environment {
preamble: string;
postamble: string;
obj: {};
seq: number;
declvars : {[name : string] : acorn.Node};
builtins : {}
constructor(
public readonly globalenv: any,
public readonly path: string
) {
var badlst = Object.getOwnPropertyNames(this.globalenv).filter(name => GLOBAL_GOODLIST.indexOf(name) < 0);
this.builtins = {
print: (...args) => this.print(args),
...IMPORTS
}
this.preamble = `'use strict';var ${badlst.join(',')};`;
for (var impname in this.builtins) {
this.preamble += `var ${impname}=$$.${impname};`
}
this.preamble += '{\n';
this.postamble = '\n}';
}
error(varname: string, msg: string) {
let obj = this.declvars && this.declvars[varname];
console.log('ERROR', varname, obj, this);
throw new RuntimeError(obj && obj.loc, msg);
}
print(args: any[]) {
if (args && args.length > 0 && args[0] != null) {
this.obj[`$print__${this.seq++}`] = args.length == 1 ? args[0] : args;
}
}
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 }) => {
const isTopLevel = () => {
return parent() && parent().type === 'ExpressionStatement' && parent(2) && parent(2).type === 'Program';
}
const convertTopToPrint = () => {
if (isTopLevel()) {
let printkey = `$print__${this.seq++}`;
update(`this.${printkey} = io.data.load(${source()}, ${JSON.stringify(printkey)})`);
//update(`print(${source()});`)
}
}
const left = node['left'];
switch (node.type) {
// add preamble, postamble
case 'Program':
update(`${this.preamble}${source()}${this.postamble}`)
break;
// error on forbidden keywords
case 'Identifier':
if (GLOBAL_BADLIST.indexOf(source()) >= 0) {
update(`__FORBIDDEN__KEYWORD__${source()}__`) // TODO? how to preserve line number?
} else {
convertTopToPrint();
}
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.load(this.${source()}, ${JSON.stringify(left.name)})`)
this.declvars[left.name] = left;
} else {
update(`${left.name}=this.${source()}`)
}
}
}
break;
// convert lone expressions to print()
case 'UnaryExpression':
case 'BinaryExpression':
case 'CallExpression':
case 'MemberExpression':
convertTopToPrint();
break;
// literal comments
case 'Literal':
if (typeof node['value'] === 'string' && isTopLevel()) {
update(`this.$doc__${this.seq++} = { literaltext: ${source()} };`);
} else {
convertTopToPrint();
}
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('$$', code).bind(this.obj, this.builtins);
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') {
setConstructorName(o);
delete o.$$callback; // clear callbacks (TODO? put somewhere else?)
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('.') }
// go through all object properties recursively
for (var [key, value] of Object.entries(o)) {
if (value == null && fullkey.length == 0 && !key.startsWith("$")) {
this.error(key, `"${key}" has no value.`)
}
fullkey.push(key);
if (typeof value === 'function') {
if (fullkey.length == 1)
this.error(fullkey[0], `"${prkey()}" is a function. Did you forget to pass parameters?`); // TODO? did you mean (needs to see entire expr)
else
this.error(fullkey[0], `This expression may be incomplete, or it contains a function object: ${prkey()}`); // 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 = [];
// if ErrorStackParser fails, resort to regex
if (frame < 0 && e.stack != null) {
let m = /.anonymous.:(\d+):(\d+)/g.exec(e.stack);
if (m != null) {
errors.push( {
path: this.path,
msg: e.message,
line: parseInt(m[1]) - LINE_NUMBER_OFFSET,
});
}
}
// otherwise iterate thru all the frames
while (frame >= 0) {
console.log(frames[frame]);
if (frames[frame].fileName.endsWith('Function')) {
// TODO: use source map
errors.push( {
path: this.path,
msg: e.message,
line: frames[frame].lineNumber - LINE_NUMBER_OFFSET,
//start: frames[frame].columnNumber,
} );
}
--frame;
}
// if no stack frames parsed, last resort error msg
if (errors.length == 0) {
errors.push( {
path: this.path,
msg: e.message,
line: 0
} );
}
return errors;
}
commitLoadableState() {
// TODO: visit children?
for (let [key, value] of Object.entries(this.obj)) {
let loadable = <any>value as io.Loadable;
io.data.save(loadable, key);
}
return io.$$getData();
}
}