diff --git a/css/codemirror.css b/css/codemirror.css index 5a4f494c..2d4b4be6 100644 --- a/css/codemirror.css +++ b/css/codemirror.css @@ -371,7 +371,7 @@ span.CodeMirror-selectedtext { background: none; } .cm-s-mbo span.cm-property, .cm-s-mbo span.cm-attribute { color: #9ddfe9; } .cm-s-mbo span.cm-keyword { color: #ffb928; } -.cm-s-mbo span.cm-string { color: #ffcf6c; } +.cm-s-mbo span.cm-string { color: #b4fdb7; } .cm-s-mbo span.cm-string.cm-property { color: #ffffec; } .cm-s-mbo span.cm-variable { color: #ffffec; } diff --git a/css/ui.css b/css/ui.css index 381e9094..131f06e9 100644 --- a/css/ui.css +++ b/css/ui.css @@ -353,7 +353,7 @@ div.replaydiv { background-repeat: no-repeat; background-position: 50%; pointer-events:auto; - z-index:1; + z-index:2; } .gutter.gutter-horizontal { background-image: url('grips/vertical.png'); @@ -631,6 +631,15 @@ div.asset_toolbar { .transcript-input-char { max-width:5em; } +.transcript-print-head { + background: url('../images/print-head.png') no-repeat scroll 0 0 transparent; + position: absolute; + bottom: 0; + height: 3em; + width: 100%; + z-index: 1; + pointer-events: none; +} .tree-header { border: 2px solid #555; border-radius:8px; diff --git a/electron.html b/electron.html index 23591966..a96e76f1 100644 --- a/electron.html +++ b/electron.html @@ -361,6 +361,7 @@ body { + diff --git a/index.html b/index.html index 8c74cb93..e6d8ef67 100644 --- a/index.html +++ b/index.html @@ -556,6 +556,7 @@ if (window.location.host.endsWith('8bitworkshop.com')) { + diff --git a/presets/basic/hello.bas b/presets/basic/hello.bas new file mode 100644 index 00000000..a1327ddd --- /dev/null +++ b/presets/basic/hello.bas @@ -0,0 +1,10 @@ +10 PRINT "HELLO! LET'S PROGRAM IN BASIC." +15 PRINT +20 INPUT "WOULD YOU MIND TYPING IN YOUR NAME";A$ +25 PRINT +30 PRINT "THANKS, ";A$;"! THIS WILL BE FUN!" +35 PRINT +40 INPUT "NOW TELL ME YOUR FAVORITE NUMBER";N +45 PRINT +50 PRINT "THAT'S A GOOD ONE! I LIKE";N^2;"MYSELF." +60 PRINT "NICE MEETING YOU, ";A$;"." diff --git a/src/codemirror/basic.js b/src/codemirror/basic.js new file mode 100644 index 00000000..eaf34014 --- /dev/null +++ b/src/codemirror/basic.js @@ -0,0 +1,286 @@ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: http://codemirror.net/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +CodeMirror.defineMode("basic", function(conf, parserConf) { + var ERRORCLASS = 'error'; + + function wordRegexp(words) { + return new RegExp("^((" + words.join(")|(") + "))\\b", "i"); + } + + var singleOperators = new RegExp("^[\\+\\-\\*/%&\\\\|\\^~<>!]"); + var singleDelimiters = new RegExp('^[\\(\\)\\[\\]\\{\\}@,:`=;\\.]'); + var doubleOperators = new RegExp("^((==)|(<>)|(<=)|(>=)|(<>)|(<<)|(>>)|(//)|(\\*\\*))"); + var doubleDelimiters = new RegExp("^((\\+=)|(\\-=)|(\\*=)|(%=)|(/=)|(&=)|(\\|=)|(\\^=))"); + var tripleDelimiters = new RegExp("^((//=)|(>>=)|(<<=)|(\\*\\*=))"); + var identifiers = new RegExp("^[_A-Za-z][_A-Za-z0-9]*"); + + var openingKeywords = ['if','for']; + var middleKeywords = ['to']; + var endKeywords = ['next','end']; + + var operatorKeywords = ['and', 'or', 'not', 'xor', 'in']; + var wordOperators = wordRegexp(operatorKeywords); + var commonKeywords = [ + 'let','print','go','goto','gosub','next','dim','input','data', + 'read','restore','return','stop','on','def','option','then','step', + ]; + var commontypes = ['xxxxbyte','xxxxword']; + + var keywords = wordRegexp(commonKeywords); + var types = wordRegexp(commontypes); + var stringPrefixes = '"'; + + var opening = wordRegexp(openingKeywords); + var middle = wordRegexp(middleKeywords); + var closing = wordRegexp(endKeywords); + var doubleClosing = wordRegexp(['end']); + var doOpening = wordRegexp(['do']); + + var indentInfo = null; + + CodeMirror.registerHelper("hintWords", "vb", openingKeywords.concat(middleKeywords).concat(endKeywords) + .concat(operatorKeywords).concat(commonKeywords).concat(commontypes)); + + function indent(_stream, state) { + state.currentIndent++; + } + + function dedent(_stream, state) { + state.currentIndent--; + } + // tokenizers + function tokenBase(stream, state) { + if (stream.eatSpace()) { + return null; + } + + var ch = stream.peek(); + + // Handle Comments + if (ch === "'") { + stream.skipToEnd(); + return 'comment'; + } + + + // Handle Number Literals + if (stream.match(/^(\$)?[0-9\.a-f]/i, false)) { + var floatLiteral = false; + // Floats + if (stream.match(/^\d*\.\d+F?/i)) { floatLiteral = true; } + else if (stream.match(/^\d+\.\d*F?/)) { floatLiteral = true; } + else if (stream.match(/^\.\d+F?/)) { floatLiteral = true; } + + if (floatLiteral) { + // Float literals may be "imaginary" + stream.eat(/J/i); + return 'number'; + } + // Integers + var intLiteral = false; + // Hex + if (stream.match(/^\$[0-9a-f]+/i)) { intLiteral = true; } + // Octal + else if (stream.match(/^&O[0-7]+/i)) { intLiteral = true; } + // Decimal + else if (stream.match(/^[1-9]\d*F?/)) { + // Decimal literals may be "imaginary" + stream.eat(/J/i); + // TODO - Can you have imaginary longs? + intLiteral = true; + } + // Zero by itself with no other piece of number. + else if (stream.match(/^0(?![\dx])/i)) { intLiteral = true; } + if (intLiteral) { + // Integer literals may be "long" + stream.eat(/L/i); + return 'number'; + } + } + + // Handle RAM + if (stream.match(/\bREM/i)) { + stream.skipToEnd(); + return 'comment'; + } + + // Handle Strings + if (stream.match(stringPrefixes)) { + state.tokenize = tokenStringFactory(stream.current()); + return state.tokenize(stream, state); + } + + // Handle operators and Delimiters + if (stream.match(tripleDelimiters) || stream.match(doubleDelimiters)) { + return null; + } + if (stream.match(doubleOperators) + || stream.match(singleOperators) + || stream.match(wordOperators)) { + return 'operator'; + } + if (stream.match(singleDelimiters)) { + return null; + } + if (stream.match(doOpening)) { + /* + indent(stream,state); + state.doInCurrentLine = true; + */ + return 'keyword'; + } + if (stream.match(opening)) { + /* + if (! state.doInCurrentLine) + indent(stream,state); + else + state.doInCurrentLine = false; + */ + return 'keyword'; + } + if (stream.match(middle)) { + return 'keyword'; + } + + if (stream.match(doubleClosing)) { + dedent(stream,state); + dedent(stream,state); + return 'keyword'; + } + if (stream.match(closing)) { + dedent(stream,state); + return 'keyword'; + } + + if (stream.match(types)) { + return 'keyword'; + } + + if (stream.match(keywords)) { + return 'keyword'; + } + + if (stream.match(identifiers)) { + return 'variable'; + } + + // Handle non-detected items + stream.next(); + return ERRORCLASS; + } + + function tokenStringFactory(delimiter) { + var singleline = delimiter.length == 1; + var OUTCLASS = 'string'; + + return function(stream, state) { + while (!stream.eol()) { + stream.eatWhile(/[^'"]/); + if (stream.match(delimiter)) { + state.tokenize = tokenBase; + return OUTCLASS; + } else { + stream.eat(/['"]/); + } + } + if (singleline) { + if (parserConf.singleLineStringErrors) { + return ERRORCLASS; + } else { + state.tokenize = tokenBase; + } + } + return OUTCLASS; + }; + } + + + function tokenLexer(stream, state) { + var style = state.tokenize(stream, state); + var current = stream.current(); + + // Handle '.' connected identifiers + if (current === '.') { + style = state.tokenize(stream, state); + if (style === 'variable') { + return 'variable'; + } else { + return ERRORCLASS; + } + } + + + var delimiter_index = '[({'.indexOf(current); + if (delimiter_index !== -1) { + indent(stream, state ); + } + if (indentInfo === 'dedent') { + if (dedent(stream, state)) { + return ERRORCLASS; + } + } + delimiter_index = '])}'.indexOf(current); + if (delimiter_index !== -1) { + if (dedent(stream, state)) { + return ERRORCLASS; + } + } + + return style; + } + + var external = { + electricChars:"dDpPtTfFeE ", + startState: function() { + return { + tokenize: tokenBase, + lastToken: null, + currentIndent: 0, + nextLineIndent: 0, + doInCurrentLine: false + + + }; + }, + + token: function(stream, state) { + if (stream.sol()) { + state.currentIndent += state.nextLineIndent; + state.nextLineIndent = 0; + state.doInCurrentLine = 0; + } + var style = tokenLexer(stream, state); + + state.lastToken = {style:style, content: stream.current()}; + + + + return style; + }, + + indent: function(state, textAfter) { + var trueText = textAfter.replace(/^\s+|\s+$/g, '') ; + if (trueText.match(closing) || trueText.match(doubleClosing) || trueText.match(middle)) return conf.indentUnit*(state.currentIndent-1); + if(state.currentIndent < 0) return 0; + return state.currentIndent * conf.indentUnit; + }, + + lineComment: "'" + }; + return external; +}); + +CodeMirror.defineMIME("text/x-vb", "vb"); + +}); diff --git a/src/common/baseplatform.ts b/src/common/baseplatform.ts index 98728219..7eefd429 100644 --- a/src/common/baseplatform.ts +++ b/src/common/baseplatform.ts @@ -215,7 +215,6 @@ export abstract class BaseDebugPlatform extends BasePlatform { frameCount : number = 0; abstract getCPUState() : CpuState; - abstract readAddress(addr:number) : number; setBreakpoint(id : string, cond : DebugCondition) { if (cond) { @@ -416,6 +415,7 @@ export abstract class Base6502Platform extends BaseDebugPlatform { getSP() { return this.getCPUState().SP }; getPC() { return this.getCPUState().PC }; isStable() { return !this.getCPUState()['T']; } + abstract readAddress(addr:number) : number; newCPU(membus : MemoryBus) { var cpu = new jt.M6502(); diff --git a/src/common/basic/compiler.ts b/src/common/basic/compiler.ts new file mode 100644 index 00000000..543c07f4 --- /dev/null +++ b/src/common/basic/compiler.ts @@ -0,0 +1,679 @@ +import { WorkerError, CodeListingMap, SourceLocation } from "../workertypes"; + +export interface SourceLocated { + $loc?: SourceLocation; +} + +class CompileError extends Error { + constructor(msg: string) { + super(msg); + Object.setPrototypeOf(this, CompileError.prototype); + } +} + +// Lexer regular expression -- each (capture group) handles a different token type + +const re_toks = /([0-9.]+[E][+-]?\d+)|(\d*[.]\d*[E0-9]*)|(\d+)|(['].*)|(\bAND\b)|(\bOR\b)|(\w+[$]?)|(".*?")|([<>]?[=<>])|([-+*/^,;:()])|(\S+)/gi; + +export enum TokenType { + EOL = 0, + Float1, + Float2, + Int, + Remark, + And, + Or, + Ident, + String, + Relational, + Operator, + CatchAll, + _LAST, +} + +export type ExprTypes = BinOp | UnOp | IndOp | Literal; + +export type Expr = ExprTypes & SourceLocated; + +export type Opcode = 'add' | 'sub' | 'mul' | 'div' | 'pow' | 'eq' | 'ne' | 'lt' | 'gt' | 'le' | 'ge' | 'land' | 'lor'; + +export type Value = string | number; + +export interface Literal { + value: Value; +} + +export interface BinOp { + op: Opcode; + left: Expr; + right: Expr; +} + +export interface UnOp { + op: 'neg'; + expr: Expr; +} + +export interface IndOp extends SourceLocated { + name: string; + args: Expr[]; +} + +export interface PRINT_Statement { + command: "PRINT"; + args: Expr[]; +} + +export interface LET_Statement { + command: "LET"; + lexpr: IndOp; + right: Expr; +} + +export interface GOTO_Statement { + command: "GOTO"; + label: Expr; +} + +export interface GOSUB_Statement { + command: "GOSUB"; + label: Expr; +} + +export interface RETURN_Statement { + command: "RETURN"; +} + +export interface IF_Statement { + command: "IF"; + cond: Expr; +} + +export interface FOR_Statement { + command: "FOR"; + lexpr: IndOp; + initial: Expr; + target: Expr; + step?: Expr; +} + +export interface NEXT_Statement { + command: "NEXT"; + lexpr?: IndOp; +} + +export interface DIM_Statement { + command: "DIM"; + args: IndOp[]; +} + +export interface INPUT_Statement { + command: "INPUT"; + prompt: Expr; + args: IndOp[]; +} + +export interface READ_Statement { + command: "INPUT"; + args: IndOp[]; +} + +export interface DEF_Statement { + command: "DEF"; + lexpr: IndOp; + def: Expr; +} + +export interface ONGOTO_Statement { + command: "ONGOTO"; + expr: Expr; + labels: Expr[]; +} + +export interface DATA_Statement { + command: "DATA"; + datums: Expr[]; +} + +export interface OPTION_Statement { + command: "OPTION"; + optname: string; + optargs: string[]; +} + +export type StatementTypes = PRINT_Statement | LET_Statement | GOTO_Statement | GOSUB_Statement + | IF_Statement | FOR_Statement | DATA_Statement; + +export type Statement = StatementTypes & SourceLocated; + +export interface BASICLine { + label: string; + stmts: Statement[]; +} + +export interface BASICProgram { + lines: BASICLine[]; +} + +class Token { + str: string; + type: TokenType; + $loc: SourceLocation; +} + +const OPERATORS = { + 'AND': {f:'land',p:5}, + 'OR': {f:'lor',p:5}, + '=': {f:'eq',p:10}, + '<>': {f:'ne',p:10}, + '<': {f:'lt',p:10}, + '>': {f:'gt',p:10}, + '<=': {f:'le',p:10}, + '>=': {f:'ge',p:10}, + '+': {f:'add',p:100}, + '-': {f:'sub',p:100}, + '*': {f:'mul',p:200}, + '/': {f:'div',p:200}, + '^': {f:'pow',p:300} +}; + +function getOpcodeForOperator(op: string): Opcode { + return OPERATORS[op].f as Opcode; +} + +function getPrecedence(tok: Token): number { + switch (tok.type) { + case TokenType.Operator: + case TokenType.Relational: + case TokenType.Ident: + let op = OPERATORS[tok.str] + if (op) return op.p; + } + return -1; +} + +// is token an end of statement marker? (":" or end of line) +function isEOS(tok: Token) { + return (tok.type == TokenType.EOL) || (tok.type == TokenType.Operator && tok.str == ':'); +} + +function stripQuotes(s: string) { + // TODO: assert + return s.substr(1, s.length-2); +} + +// TODO +export interface BASICOptions { + uppercaseOnly : boolean; // convert everything to uppercase? + strictVarNames : boolean; // only allow A0-9 for numerics, single letter for arrays/strings + sharedArrayNamespace : boolean; // arrays and variables have same namespace? (conflict) + defaultArrayBase : number; // arrays start at this number (0 or 1) + defaultArraySize : number; // arrays are allocated w/ this size (starting @ 0) + maxDimensions : number; // max number of dimensions for arrays + stringConcat : boolean; // can concat strings with "+" operator? + typeConvert : boolean; // type convert strings <-> numbers? + maxArguments : number; // maximum # of arguments for user-defined functions + sparseArrays : boolean; // true == don't require DIM for arrays + tickComments : boolean; // support 'comments? + validKeywords : string[]; // valid keywords (or null for accept all) + validFunctions : string[]; // valid functions (or null for accept all) + validOperators : string[]; // valid operators (or null for accept all) + printZoneLength : number; // print zone length + printPrecision : number; // print precision # of digits + checkOverflow : boolean; // check for overflow of numerics? + defaultValues : boolean; // initialize unset variables to default value? (0 or "") +} + +///// BASIC PARSER + +export class BASICParser { + tokens: Token[]; + errors: WorkerError[]; + labels: { [label: string]: BASICLine }; + targets: { [targetlabel: string]: SourceLocation }; + eol: Token; + lineno : number; + curlabel: string; + listings: CodeListingMap; + lasttoken: Token; + opts : BASICOptions = ALTAIR_BASIC40; + + constructor() { + this.labels = {}; + this.targets = {}; + this.errors = []; + this.lineno = 0; + this.curlabel = null; + this.listings = {}; + } + compileError(msg: string, loc?: SourceLocation) { + if (!loc) loc = this.peekToken().$loc; + // TODO: pass SourceLocation to errors + this.errors.push({line:loc.line, msg:msg}); + throw new CompileError(`${msg} (line ${loc.line})`); // TODO: label too? + } + dialectError(what: string, loc?: SourceLocation) { + this.compileError(`The selected BASIC dialect doesn't support ${what}`, loc); // TODO + } + consumeToken(): Token { + var tok = this.lasttoken = (this.tokens.shift() || this.eol); + return tok; + } + expectToken(str: string) : Token { + var tok = this.consumeToken(); + var tokstr = tok.str.toUpperCase(); + if (str != tokstr) { + this.compileError(`I expected "${str}" here, but I saw "${tokstr}".`); + } + return tok; + } + peekToken(): Token { + var tok = this.tokens[0]; + return tok ? tok : this.eol; + } + pushbackToken(tok: Token) { + this.tokens.unshift(tok); + } + parseOptLabel(line: BASICLine) { + let tok = this.consumeToken(); + switch (tok.type) { + case TokenType.Int: + if (this.labels[tok.str] != null) this.compileError(`I saw a duplicated label "${tok.str}".`); + this.labels[tok.str] = line; + line.label = tok.str; + this.curlabel = tok.str; + break; + default: + // TODO + this.pushbackToken(tok); + break; + } + } + parseFile(file: string, path: string) : BASICProgram { + var pgmlines = file.split("\n").map((line) => this.parseLine(line)); + this.checkLabels(); + var program = { lines: pgmlines }; + this.listings[path] = this.generateListing(file, program); + return program; + } + parseLine(line: string) : BASICLine { + try { + this.tokenize(line); + return this.parse(); + } catch (e) { + if (!(e instanceof CompileError)) throw e; // ignore compile errors since errors[] list captures them + return {label:null, stmts:[]}; + } + } + tokenize(line: string) : void { + this.lineno++; + this.tokens = []; + var m; + while (m = re_toks.exec(line)) { + for (var i = 1; i < TokenType._LAST; i++) { + let s = m[i]; + if (s != null) { + this.tokens.push({ + str: s, + type: i, + $loc: { line: this.lineno, start: m.index, end: m.index+s.length } + }); + break; + } + } + } + this.eol = { type: TokenType.EOL, str: "", $loc: { line: this.lineno, start: line.length } }; + } + parse() : BASICLine { + var line = {label: null, stmts: []}; + // not empty line? + if (this.tokens.length) { + this.parseOptLabel(line); + line.stmts = this.parseCompoundStatement(); + this.curlabel = null; + } + return line; + } + parseCompoundStatement(): Statement[] { + var list = this.parseList(this.parseStatement, ':'); + if (!isEOS(this.peekToken())) this.compileError(`Expected end of line or ':'`, this.peekToken().$loc); + return list; + } + parseStatement(): Statement | null { + var cmdtok = this.consumeToken(); + var stmt; + switch (cmdtok.type) { + case TokenType.Remark: + if (!this.opts.tickComments) this.dialectError(`tick remarks`); + return null; + case TokenType.Ident: + var cmd = cmdtok.str.toUpperCase(); + // remark? ignore to eol + if (cmd == 'REM') { + while (this.consumeToken().type != TokenType.EOL) { } + return null; + } + // look for "GO TO" + if (cmd == 'GO' && this.peekToken().str == 'TO') { + this.consumeToken(); + cmd = 'GOTO'; + } + var fn = this['stmt__' + cmd]; + if (fn) { + if (this.opts.validKeywords && this.opts.validKeywords.indexOf(cmd) < 0) + this.dialectError(`the ${cmd} keyword`); + stmt = fn.bind(this)() as Statement; + break; + } else if (this.peekToken().str == '=' || this.peekToken().str == '(') { + // 'A = expr' or 'A(X) = expr' + this.pushbackToken(cmdtok); + stmt = this.stmt__LET(); + break; + } + case TokenType.EOL: + this.compileError(`I expected a command here`); + return null; + default: + this.compileError(`Unknown command "${cmdtok.str}"`); + return null; + } + if (stmt) stmt.$loc = { line: cmdtok.$loc.line, start: cmdtok.$loc.start, end: this.peekToken().$loc.start }; + return stmt; + } + parseVarOrIndexedOrFunc(): IndOp { + return this.parseVarOrIndexed(); + } + parseVarOrIndexed(): IndOp { + var tok = this.consumeToken(); + switch (tok.type) { + case TokenType.Ident: + let args = null; + if (this.peekToken().str == '(') { + this.expectToken('('); + args = this.parseExprList(); + this.expectToken(')'); + } + return { name: tok.str, args: args, $loc: tok.$loc }; + default: + this.compileError("Expected variable or array index"); + break; + } + } + parseList(parseFunc:()=>T, delim:string): T[] { + var sep; + var list = []; + do { + var el = parseFunc.bind(this)() + if (el != null) list.push(el); + sep = this.consumeToken(); + } while (sep.str == delim); + this.pushbackToken(sep); + return list; + } + parseVarOrIndexedList(): IndOp[] { + return this.parseList(this.parseVarOrIndexed, ','); + } + parseExprList(): Expr[] { + return this.parseList(this.parseExpr, ','); + } + parseLabelList(): Expr[] { + return this.parseList(this.parseLabel, ','); + } + parseLabel() : Expr { + var tok = this.consumeToken(); + switch (tok.type) { + case TokenType.Int: + var label = parseInt(tok.str).toString(); + this.targets[label] = tok.$loc; + return {value:label}; + default: + this.compileError(`I expected a line number here`); + return; + } + } + parsePrimary(): Expr { + let tok = this.consumeToken(); + switch (tok.type) { + case TokenType.Int: + case TokenType.Float1: + case TokenType.Float2: + return { value: parseFloat(tok.str), $loc: tok.$loc }; + case TokenType.String: + return { value: stripQuotes(tok.str), $loc: tok.$loc }; + case TokenType.Ident: + this.pushbackToken(tok); + return this.parseVarOrIndexedOrFunc(); + case TokenType.Operator: + if (tok.str == '(') { + let expr = this.parseExpr(); + this.expectToken(')'); + return expr; + } else if (tok.str == '-') { + let expr = this.parsePrimary(); // TODO: -2^2=-4 and -2-2=-4 + return { op: 'neg', expr: expr }; + } else if (tok.str == '+') { + return this.parsePrimary(); // TODO? + } + default: + this.compileError(`Unexpected "${tok.str}"`); + } + } + parseExpr1(left: Expr, minPred: number): Expr { + let look = this.peekToken(); + while (getPrecedence(look) >= minPred) { + let op = this.consumeToken(); + let right: Expr = this.parsePrimary(); + look = this.peekToken(); + while (getPrecedence(look) > getPrecedence(op)) { + right = this.parseExpr1(right, getPrecedence(look)); + look = this.peekToken(); + } + left = { op: getOpcodeForOperator(op.str), left: left, right: right }; + } + return left; + } + parseExpr(): Expr { + return this.parseExpr1(this.parsePrimary(), 0); + } + + //// STATEMENTS + + stmt__LET(): LET_Statement { + var lexpr = this.parseVarOrIndexed(); + this.expectToken("="); + var right = this.parseExpr(); + return { command: "LET", lexpr: lexpr, right: right }; + } + stmt__PRINT(): PRINT_Statement { + var sep, lastsep; + var list = []; + do { + sep = this.peekToken(); + if (isEOS(sep)) { + break; + } else if (sep.str == ';') { + this.consumeToken(); + lastsep = sep; + } else if (sep.str == ',') { + this.consumeToken(); + list.push({value:'\t'}); + lastsep = sep; + } else { + list.push(this.parseExpr()); + lastsep = null; + } + } while (true); + if (!(lastsep && (lastsep.str == ';' || sep.str != ','))) { + list.push({value:'\n'}); + } + return { command: "PRINT", args: list }; + } + stmt__GOTO(): GOTO_Statement { + return { command: "GOTO", label: this.parseLabel() }; + } + stmt__GOSUB(): GOSUB_Statement { + return { command: "GOSUB", label: this.parseLabel() }; + } + stmt__IF(): IF_Statement { + var cond = this.parseExpr(); + var iftrue: Statement[]; + this.expectToken('THEN'); + var lineno = this.peekToken(); + // assume GOTO if number given after THEN + if (lineno.type == TokenType.Int) { + this.pushbackToken({type:TokenType.Ident, str:'GOTO', $loc:lineno.$loc}); + } + // add fake ":" + this.pushbackToken({type:TokenType.Operator, str:':', $loc:lineno.$loc}); + return { command: "IF", cond: cond }; + } + stmt__FOR() : FOR_Statement { + var lexpr = this.parseVarOrIndexed(); // TODO: parseNumVar() + this.expectToken('='); + var init = this.parseExpr(); + this.expectToken('TO'); + var targ = this.parseExpr(); + if (this.peekToken().str == 'STEP') { + this.consumeToken(); + var step = this.parseExpr(); + } + return { command:'FOR', lexpr:lexpr, initial:init, target:targ, step:step }; + } + stmt__NEXT() : NEXT_Statement { + var lexpr = null; + if (!isEOS(this.peekToken())) { + lexpr = this.parseExpr(); + } + return { command:'NEXT', lexpr:lexpr }; + } + stmt__DIM() : DIM_Statement { + return { command:'DIM', args:this.parseVarOrIndexedList() }; + } + stmt__INPUT() : INPUT_Statement { + var prompt = this.consumeToken(); + var promptstr; + if (prompt.type == TokenType.String) { + this.expectToken(';'); + promptstr = stripQuotes(prompt.str); + } else { + this.pushbackToken(prompt); + promptstr = ""; + } + return { command:'INPUT', prompt:{ value: promptstr, $loc: prompt.$loc }, args:this.parseVarOrIndexedList() }; + } + stmt__DATA() : DATA_Statement { + return { command:'DATA', datums:this.parseExprList() }; + } + stmt__READ() { + return { command:'READ', args:this.parseVarOrIndexedList() }; + } + stmt__RESTORE() { + return { command:'RESTORE' }; + } + stmt__RETURN() { + return { command:'RETURN' }; + } + stmt__STOP() { + return { command:'STOP' }; + } + stmt__END() { + return { command:'END' }; + } + stmt__ON() : ONGOTO_Statement { + var expr = this.parseExpr(); + this.expectToken('GOTO'); + var labels = this.parseLabelList(); + return { command:'ONGOTO', expr:expr, labels:labels }; + } + stmt__DEF() : DEF_Statement { + var lexpr = this.parseVarOrIndexed(); + if (!lexpr.name.toUpperCase().startsWith('FN')) this.compileError(`Functions defined with DEF must begin with the letters "FN".`) + this.expectToken("="); + var func = this.parseExpr(); + return { command:'DEF', lexpr:lexpr, def:func }; + } + stmt__OPTION() : OPTION_Statement { + var tokname = this.consumeToken(); + if (tokname.type != TokenType.Ident) this.compileError(`I expected a name after the OPTION statement.`) + var list : string[] = []; + var tok; + do { + tok = this.consumeToken(); + if (isEOS(tok)) break; + list.push(tok.str.toUpperCase()); + } while (true); + return { command:'OPTION', optname:tokname.str.toUpperCase(), optargs:list }; + } + + // for workermain + generateListing(file: string, program: BASICProgram) { + var srclines = []; + var pc = 0; + program.lines.forEach((line, idx) => { + srclines.push({offset: pc, line: idx+1}); + pc += line.stmts.length; + }); + return { lines: srclines }; + } + getListings() : CodeListingMap { + return this.listings; + } + + // LINT STUFF + checkLabels() { + for (let targ in this.targets) { + if (this.labels[targ] == null) { + this.compileError(`I couldn't find line number ${targ}`, this.targets[targ]); + } + } + } +} + +///// BASIC DIALECTS + +// TODO + +export const ECMA55_MINIMAL : BASICOptions = { + uppercaseOnly : true, + strictVarNames : true, + sharedArrayNamespace : true, + defaultArrayBase : 0, + defaultArraySize : 11, + defaultValues : false, + stringConcat : false, + typeConvert : false, + maxDimensions : 2, + maxArguments : Infinity, + sparseArrays : false, + tickComments : false, + validKeywords : ['BASE','DATA','DEF','DIM','END', + 'FOR','GO','GOSUB','GOTO','IF','INPUT','LET','NEXT','ON','OPTION','PRINT', + 'RANDOMIZE','READ','REM','RESTORE','RETURN','STEP','STOP','SUB','THEN','TO' + ], + validFunctions : ['ABS','ATN','COS','EXP','INT','LOG','RND','SGN','SIN','SQR','TAN'], + validOperators : ['=', '<>', '<', '>', '<=', '>=', '+', '-', '*', '/', '^'], + printZoneLength : 15, + printPrecision : 6, + checkOverflow : true, +} + +export const ALTAIR_BASIC40 : BASICOptions = { + uppercaseOnly : true, + strictVarNames : true, + sharedArrayNamespace : true, + defaultArrayBase : 0, + defaultArraySize : 11, + defaultValues : false, + stringConcat : false, + typeConvert : false, + maxDimensions : 2, + maxArguments : Infinity, + sparseArrays : false, + tickComments : false, + validKeywords : null, // all + validFunctions : null, // all + validOperators : null, // all ['\\','MOD','NOT','AND','OR','XOR','EQV','IMP'], + printZoneLength : 15, + printPrecision : 6, + checkOverflow : true, +} diff --git a/src/common/basic/main.ts b/src/common/basic/main.ts new file mode 100644 index 00000000..4b6cd087 --- /dev/null +++ b/src/common/basic/main.ts @@ -0,0 +1,24 @@ + +import { BASICParser } from "./compiler"; + +var parser = new BASICParser(); +var readline = require('readline'); +var rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}); +rl.on('line', function (line) { + parser.tokenize(line); + console.log(parser.tokens); + try { + var ast = parser.parse(); + console.log(JSON.stringify(ast, null, 4)); + } catch (e) { + console.log(e); + } + if (parser.errors.length) { + console.log(parser.errors); + parser.errors = []; + } +}) diff --git a/src/common/basic/run.ts b/src/common/basic/run.ts new file mode 100644 index 00000000..6515c6a2 --- /dev/null +++ b/src/common/basic/run.ts @@ -0,0 +1,54 @@ + +import { BASICParser, ECMA55_MINIMAL } from "./compiler"; +import { BASICRuntime } from "./runtime"; + +var readline = require('readline'); +var rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + //terminal: false +}); + +var parser = new BASICParser(); +parser.opts = ECMA55_MINIMAL; +var fs = require('fs'); +var filename = process.argv[2]; +var data = fs.readFileSync(filename, 'utf-8'); +try { + var pgm = parser.parseFile(data, filename); +} catch (e) { + console.log("@@@ " + e.message); + throw e; +} + +var runtime = new BASICRuntime(); +runtime.trace = process.argv[3] == '-v'; +runtime.load(pgm); +runtime.reset(); +runtime.print = (s:string) => { + fs.writeSync(1, s+""); +} +runtime.input = async (prompt:string) => { + return new Promise( (resolve, reject) => { + fs.writeSync(1, "\n"); + rl.question(prompt, (answer) => { + var vals = answer.toUpperCase().split(','); + resolve(vals); + }); + }); +} +runtime.resume = function() { + process.nextTick(() => { + try { + if (runtime.step()) { + if (runtime.running) runtime.resume(); + } else if (runtime.exited) { + rl.close(); + } + } catch (e) { + console.log("### " + e.message); + throw e; + } + }); +} +runtime.resume(); diff --git a/src/common/basic/runtime.ts b/src/common/basic/runtime.ts new file mode 100644 index 00000000..a6337629 --- /dev/null +++ b/src/common/basic/runtime.ts @@ -0,0 +1,646 @@ + +import * as basic from "./compiler"; +import { EmuHalt } from "../emu"; + +function isLiteral(arg: basic.Expr): arg is basic.Literal { + return (arg as any).value != null; +} +function isLookup(arg: basic.Expr): arg is basic.IndOp { + return (arg as any).name != null; +} +function isBinOp(arg: basic.Expr): arg is basic.BinOp { + return (arg as any).op != null && (arg as any).left != null && (arg as any).right != null; +} +function isUnOp(arg: basic.Expr): arg is basic.UnOp { + return (arg as any).op != null && (arg as any).expr != null; +} + +class ExprOptions { + check: boolean; + isconst?: boolean; + locals?: string[]; +} + +interface CompiledStatement { + $run?: () => void; +} + +export class BASICRuntime { + + program : basic.BASICProgram; + allstmts : basic.Statement[]; + line2pc : number[]; + pc2line : Map; + label2lineidx : {[label : string] : number}; + label2pc : {[label : string] : number}; + datums : basic.Literal[]; + + curpc : number; + dataptr : number; + vars : {}; + arrays : {}; + forLoops : {}; + returnStack : number[]; + column : number; + abase : number; // array base + + running : boolean = false; + exited : boolean = false; + trace : boolean = false; + + load(program: basic.BASICProgram) { + this.program = program; + this.label2lineidx = {}; + this.label2pc = {}; + this.allstmts = []; + this.line2pc = []; + this.pc2line = new Map(); + this.datums = []; + // TODO: lines start @ 1? + program.lines.forEach((line, idx) => { + // make lookup tables + if (line.label != null) this.label2lineidx[line.label] = idx; + if (line.label != null) this.label2pc[line.label] = this.allstmts.length; + this.line2pc.push(this.allstmts.length); + this.pc2line.set(this.allstmts.length, idx); + // combine all statements into single list + line.stmts.forEach((stmt) => this.allstmts.push(stmt)); + // parse DATA literals + line.stmts.filter((stmt) => stmt.command == 'DATA').forEach((datastmt) => { + (datastmt as basic.DATA_Statement).datums.forEach(d => { + var functext = this.expr2js(d, {check:true, isconst:true}); + // TODO: catch exceptions + // TODO: any value doing this ahead of time? + var value = new Function(`return ${functext};`).bind(this)(); + this.datums.push({value:value}); + }); + }); + // TODO: compile statements? + //line.stmts.forEach((stmt) => this.compileStatement(stmt)); + }); + } + + reset() { + this.curpc = 0; + this.dataptr = 0; + this.vars = {}; + this.arrays = {}; + this.forLoops = {}; + this.returnStack = []; + this.column = 0; + this.abase = 1; + this.running = true; + this.exited = false; + } + + runtimeError(msg : string) { + this.curpc--; // we did curpc++ before executing statement + // TODO: pass source location to error + throw new EmuHalt(`${msg} (line ${this.getLabelForPC(this.curpc)})`); + } + + // TODO: sometimes on next line + getLineForPC(pc:number) { + var line; + do { + line = this.pc2line.get(pc); + if (line != null) break; + } while (--pc >= 0); + return line; + } + + getLabelForPC(pc:number) { + var lineno = this.getLineForPC(pc); + var pgmline = this.program.lines[lineno]; + return pgmline ? pgmline.label : '?'; + } + + getStatement() { + return this.allstmts[this.curpc]; + } + + step() : boolean { + if (!this.running) return false; + var stmt = this.getStatement(); + // end of program? + if (!stmt) { + this.running = false; + this.exited = true; + return false; + } + if (this.trace) console.log(this.curpc, stmt, this.vars, Object.keys(this.arrays)); + // skip to next statment + this.curpc++; + // compile statement to JS? + this.compileStatement(stmt); + this.executeStatement(stmt); + return this.running; + } + + compileStatement(stmt: basic.Statement & CompiledStatement) { + if (stmt.$run == null) { + var stmtfn = this['do__' + stmt.command]; + if (stmtfn == null) this.runtimeError(`I don't know how to "${stmt.command}".`); + var functext = stmtfn.bind(this)(stmt); + if (this.trace) console.log(functext); + stmt.$run = new Function(functext).bind(this); + } + } + executeStatement(stmt: basic.Statement & CompiledStatement) { + // run compiled statement + stmt.$run(); + } + + skipToEOL() { + do { + this.curpc++; + } while (this.curpc < this.allstmts.length && !this.pc2line.get(this.curpc)); + } + + skipToEOF() { + this.curpc = this.allstmts.length; + } + + gotoLabel(label) { + var pc = this.label2pc[label]; + if (pc >= 0) { + this.curpc = pc; + } else { + this.runtimeError(`I tried to go to the label "${label}" but couldn't find it.`); + } + } + + gosubLabel(label) { + this.returnStack.push(this.curpc + 1); + this.gotoLabel(label); + } + + returnFromGosub() { + if (this.returnStack.length == 0) + this.runtimeError("I tried to RETURN, but there wasn't a corresponding GOSUB."); // RETURN BEFORE GOSUB + var pc = this.returnStack.pop(); + this.curpc = pc; + } + + valueToString(obj) : string { + var str; + if (typeof obj === 'number') { + var numstr = obj.toString().toUpperCase(); + var prec = 11; + while (numstr.length > 11) { + numstr = obj.toPrecision(prec--); + } + if (numstr.startsWith('0.')) + numstr = numstr.substr(1); + else if (numstr.startsWith('-0.')) + numstr = '-'+numstr.substr(2); + if (numstr.startsWith('-')) { + str = `${numstr} `; + } else { + str = ` ${numstr} `; + } + } else if (obj == '\n') { + this.column = 0; + str = obj; + } else if (obj == '\t') { + var curgroup = Math.floor(this.column / 15); + var nextcol = (curgroup + 1) * 15; + str = this.TAB(nextcol); + } else { + str = `${obj}`; + } + return str; + } + + printExpr(obj) { + var str = this.valueToString(obj); + this.column += str.length; + this.print(str); + } + + // override this + print(str: string) { + console.log(str); + } + + // override this + async input(prompt: string, nargs: number) : Promise { + return []; + } + + // override this + resume() { } + + expr2js(expr: basic.Expr, opts: ExprOptions) : string { + if (isLiteral(expr)) { + return JSON.stringify(expr.value); + } else if (isLookup(expr)) { + if (opts.locals && opts.locals.indexOf(expr.name) >= 0) { + return expr.name; // local arg in DEF + } else { + if (opts.isconst) this.runtimeError(`I expected a constant value here`); + var s = ''; + if (expr.args && this[expr.name]) { // is it a function? + s += `this.${expr.name}(`; + s += expr.args.map((arg) => this.expr2js(arg, opts)).join(', '); + s += ')'; + } else if (expr.args) { // is it a subscript? + s += `this.getArray(${JSON.stringify(expr.name)}, ${expr.args.length})`; + s += expr.args.map((arg) => '[this.ROUND('+this.expr2js(arg, opts)+')]').join(''); + } else { + // just a variable + s = `this.vars.${expr.name}`; + } + if (opts.check) + return `this.checkValue(${s}, ${JSON.stringify(expr.name)})`; // TODO: better error + else + return s; + } + } else if (isBinOp(expr)) { + var left = this.expr2js(expr.left, opts); + var right = this.expr2js(expr.right, opts); + return `this.${expr.op}(${left}, ${right})`; + } else if (isUnOp(expr) && expr.op == 'neg') { + var e = this.expr2js(expr.expr, opts); + return `-(${e})`; // TODO: other ops? + } + } + + startForLoop(name, init, targ, step) { + // TODO: check for loop params + var pc = this.curpc; + if (!step) step = 1; + this.vars[name] = init; + if (this.trace) console.log(`FOR ${name} = ${this.vars[name]} TO ${targ} STEP ${step}`); + this.forLoops[name] = { + next: () => { + var done = step >= 0 ? this.vars[name] >= targ : this.vars[name] <= targ; + if (done) { + delete this.forLoops[name]; + } else { + this.vars[name] += step; + this.curpc = pc; + } + if (this.trace) console.log(`NEXT ${name}: ${this.vars[name]} TO ${targ} STEP ${step} DONE=${done}`); + } + }; + } + + nextForLoop(name) { + // TODO: check for for loop + var fl = this.forLoops[name]; + if (!fl) this.runtimeError(`I couldn't find a matching FOR for this NEXT.`) + this.forLoops[name].next(); + } + + // converts a variable to string/number based on var name + convert(name: string, right: number|string) : number|string { + // TODO: error check? + if (name.endsWith("$")) + return right+""; + else if (typeof right === 'string') + return parseFloat(right); + else if (typeof right === 'number') + return right; + else + return this.checkValue(right, name); + } + + // dimension array + dimArray(name: string, ...dims:number[]) { + var isstring = name.endsWith('$'); + // TODO: option for undefined float array elements? + var arrcons = isstring ? Array : Float64Array; + var ab = this.abase; + if (dims.length == 1) { + this.arrays[name] = new arrcons(dims[0]+ab); + } else if (dims.length == 2) { + this.arrays[name] = new Array(dims[0]+ab); + for (var i=ab; i labels.length) + this.runtimeError(`I needed a number between 1 and ${labels.length}, but I got ${value}.`); + this.gotoLabel(labels[value-1]); + } + + nextDatum() : string|number { + if (this.dataptr >= this.datums.length) + this.runtimeError("I tried to READ, but ran out of data."); + return this.datums[this.dataptr++].value; + } + + //// STATEMENTS + + do__PRINT(stmt : basic.PRINT_Statement) { + var s = ''; + for (var arg of stmt.args) { + var expr = this.expr2js(arg, {check:true}); + s += `this.printExpr(${expr});`; + } + return s; + } + + do__INPUT(stmt : basic.INPUT_Statement) { + var prompt = this.expr2js(stmt.prompt, {check:true}); + var setvals = ''; + stmt.args.forEach((arg, index) => { + var lexpr = this.expr2js(arg, {check:false}); + setvals += `${lexpr} = this.convert(${JSON.stringify(arg.name)}, vals[${index}]);` + }); + return `this.running=false; this.input(${prompt}, ${stmt.args.length}).then((vals) => {${setvals}; this.running=true; this.resume();})`; + } + + do__LET(stmt : basic.LET_Statement) { + var lexpr = this.expr2js(stmt.lexpr, {check:false}); + var right = this.expr2js(stmt.right, {check:true}); + return `${lexpr} = this.convert(${JSON.stringify(stmt.lexpr.name)}, ${right});`; + } + + do__FOR(stmt : basic.FOR_Statement) { + var name = JSON.stringify(stmt.lexpr.name); // TODO: args? + var init = this.expr2js(stmt.initial, {check:true}); + var targ = this.expr2js(stmt.target, {check:true}); + var step = stmt.step ? this.expr2js(stmt.step, {check:true}) : 'null'; + return `this.startForLoop(${name}, ${init}, ${targ}, ${step})`; + } + + do__NEXT(stmt : basic.NEXT_Statement) { + var name = JSON.stringify(stmt.lexpr.name); // TODO: args? lexpr == null? + return `this.nextForLoop(${name})`; + } + + do__IF(stmt : basic.IF_Statement) { + var cond = this.expr2js(stmt.cond, {check:true}); + return `if (!(${cond})) { this.skipToEOL(); }` + } + + do__DEF(stmt : basic.DEF_Statement) { + var lexpr = `this.${stmt.lexpr.name}`; + var args = []; + for (var arg of stmt.lexpr.args) { + if (isLookup(arg)) { + args.push(arg.name); + } else { + this.runtimeError("I found a DEF statement with arguments other than variable names."); + } + } + var functext = this.expr2js(stmt.def, {check:true, locals:args}); + // TODO: use stmt.args to add function params + return `${lexpr} = function(${args.join(',')}) { return ${functext}; }`; + } + + _DIM(dim : basic.IndOp) { + var argsstr = ''; + for (var arg of dim.args) { + // TODO: check for float + argsstr += ', ' + this.expr2js(arg, {check:true}); + } + return `this.dimArray(${JSON.stringify(dim.name)}${argsstr});`; + } + + do__DIM(stmt : basic.DIM_Statement) { + var s = ''; + stmt.args.forEach((dim) => s += this._DIM(dim)); + return s; + } + + do__GOTO(stmt : basic.GOTO_Statement) { + var label = this.expr2js(stmt.label, {check:true}); + return `this.gotoLabel(${label})`; + } + + do__GOSUB(stmt : basic.GOSUB_Statement) { + var label = this.expr2js(stmt.label, {check:true}); + return `this.gosubLabel(${label})`; + } + + do__RETURN(stmt : basic.RETURN_Statement) { + return `this.returnFromGosub()`; + } + + do__ONGOTO(stmt : basic.ONGOTO_Statement) { + var expr = this.expr2js(stmt.expr, {check:true}); + var labels = stmt.labels.map((arg) => this.expr2js(arg, {check:true})).join(', '); + return `this.onGotoLabel(${expr}, ${labels})`; + } + + do__DATA() { + // data is preprocessed + } + + do__READ(stmt : basic.READ_Statement) { + var s = ''; + stmt.args.forEach((arg) => { + s += `${this.expr2js(arg, {check:false})} = this.convert(${JSON.stringify(arg.name)}, this.nextDatum());`; + }); + return s; + } + + do__RESTORE() { + this.dataptr = 0; // TODO: line number? + } + + do__END() { + return `this.skipToEOF()`; + } + + do__STOP() { + return `this.skipToEOF()`; + } + + do__OPTION(stmt: basic.OPTION_Statement) { + switch (stmt.optname) { + case 'BASE': + let base = parseInt(stmt.optargs[0]); + if (base == 0 || base == 1) this.abase = base; + else this.runtimeError("OPTION BASE can only be 0 or 1."); + break; + default: + this.runtimeError(`OPTION ${stmt.optname} is not supported by this compiler.`); + break; + } + } + + // TODO: "SUBSCRIPT ERROR" + // TODO: gosubs nested too deeply + // TODO: memory quota + + // FUNCTIONS + + checkValue(obj:number|string, exprname:string) { + if (typeof obj !== 'number' && typeof obj !== 'string') { + if (exprname != null && obj == null) { + this.runtimeError(`I didn't find a value for ${exprname}`); + } else if (exprname != null) { + this.runtimeError(`I got an invalid value for ${exprname}: ${obj}`); + } else { + this.runtimeError(`I got an invalid value: ${obj}`); + } + } + return obj; + } + + checkNum(n:number) : number { + if (n === Infinity) this.runtimeError(`I computed a number too big to store.`); + if (isNaN(n)) this.runtimeError(`I computed an invalid number.`); + return n; + } + + checkString(s:string) : string { + if (typeof s !== 'string') this.runtimeError(`I expected a string here.`); + return s; + } + + add(a, b) : number|string { + // TODO: if string-concat + if (typeof a === 'number' && typeof b === 'number') + return this.checkNum(a + b); + else + return a + b; + } + sub(a:number, b:number) : number { + return this.checkNum(a - b); + } + mul(a:number, b:number) : number { + return this.checkNum(a * b); + } + div(a:number, b:number) : number { + if (b == 0) this.runtimeError(`I can't divide by zero.`); + return this.checkNum(a / b); + } + pow(a:number, b:number) : number { + if (a == 0 && b < 0) this.runtimeError(`I can't raise zero to a negative power.`); + return this.checkNum(Math.pow(a, b)); + } + land(a:number, b:number) : number { + return a && b; + } + lor(a:number, b:number) : number { + return a || b; + } + eq(a:number, b:number) : boolean { + return a == b; + } + ne(a:number, b:number) : boolean { + return a != b; + } + lt(a:number, b:number) : boolean { + return a < b; + } + gt(a:number, b:number) : boolean { + return a > b; + } + le(a:number, b:number) : boolean { + return a <= b; + } + ge(a:number, b:number) : boolean { + return a >= b; + } + + // FUNCTIONS (uppercase) + + ABS(arg : number) : number { + return this.checkNum(Math.abs(arg)); + } + ASC(arg : string) : number { + return arg.charCodeAt(0); + } + ATN(arg : number) : number { + return this.checkNum(Math.atan(arg)); + } + CHR$(arg : number) : string { + return String.fromCharCode(this.checkNum(arg)); + } + COS(arg : number) : number { + return this.checkNum(Math.cos(arg)); + } + EXP(arg : number) : number { + return this.checkNum(Math.exp(arg)); + } + FIX(arg : number) : number { + return this.checkNum(arg - Math.floor(arg)); + } + HEX$(arg : number) : string { + return arg.toString(16); + } + INSTR(a, b, c) : number { + if (c != null) { + return this.checkString(c).indexOf(this.checkString(b), a) + 1; + } else { + return this.checkString(b).indexOf(this.checkString(a)) + 1; + } + } + INT(arg : number) : number { + return this.checkNum(Math.floor(arg)); + } + LEFT$(arg : string, count : number) : string { + return arg.substr(0, count); + } + LEN(arg : string) : number { + return arg.length; + } + LOG(arg : number) : number { + if (arg == 0) this.runtimeError(`I can't take the logarithm of zero (${arg}).`) + if (arg < 0) this.runtimeError(`I can't take the logarithm of a negative number (${arg}).`) + return this.checkNum(Math.log(arg)); + } + MID$(arg : string, start : number, count : number) : string { + if (start < 1) this.runtimeError(`The second parameter to MID$ must be between 1 and the length of the string in the first parameter.`) + return arg.substr(start-1, count); + } + RIGHT$(arg : string, count : number) : string { + return arg.substr(arg.length - count, count); + } + RND(arg : number) : number { + return Math.random(); // argument ignored + } + ROUND(arg : number) : number { + return this.checkNum(Math.round(arg)); // TODO? + } + SGN(arg : number) : number { + return (arg < 0) ? -1 : (arg > 0) ? 1 : 0; + } + SIN(arg : number) : number { + return this.checkNum(Math.sin(arg)); + } + SPACE$(arg : number) : string { + return ' '.repeat(this.checkNum(arg)); + } + SQR(arg : number) : number { + if (arg < 0) this.runtimeError(`I can't take the square root of a negative number (${arg}).`) + return this.checkNum(Math.sqrt(arg)); + } + STR$(arg) : string { + return this.valueToString(arg); + } + TAB(arg : number) : string { + if (arg < 0) this.runtimeError(`I got a negative value for the TAB() function.`); + var spaces = this.ROUND(arg) - this.column; + return (spaces > 0) ? ' '.repeat(spaces) : ''; + } + TAN(arg : number) : number { + return this.checkNum(Math.tan(arg)); + } + VAL(arg) : number { + return parseFloat(arg+""); + } +} diff --git a/src/common/workertypes.ts b/src/common/workertypes.ts index 5848e0c4..88d201e2 100644 --- a/src/common/workertypes.ts +++ b/src/common/workertypes.ts @@ -1,6 +1,12 @@ export type FileData = string | Uint8Array; +export interface SourceLocation { + line: number; + start?: number; + end?: number; +} + export interface SourceLine { offset:number; line:number; diff --git a/src/ide/ui.ts b/src/ide/ui.ts index 8d38a955..c1118b2a 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -59,6 +59,7 @@ var lastViewClicked = null; var lastBreakExpr = "c.PC == 0x6000"; // TODO: codemirror multiplex support? +// TODO: move to views.ts? var TOOL_TO_SOURCE_STYLE = { 'dasm': '6502', 'acme': '6502', @@ -80,6 +81,7 @@ var TOOL_TO_SOURCE_STYLE = { 'smlrc': 'text/x-csrc', 'inform6': 'inform6', 'fastbasic': 'fastbasic', + 'basic': 'basic', } function gaEvent(category:string, action:string, label?:string, value?:string) { diff --git a/src/ide/views.ts b/src/ide/views.ts index 1a7bc191..924e3f6d 100644 --- a/src/ide/views.ts +++ b/src/ide/views.ts @@ -70,9 +70,10 @@ const MODEDEFS = { gas: { isAsm: true }, inform6: { theme: 'cobalt' }, markdown: { lineWrap: true }, + fastbasic: { noGutters: true }, + basic: { noLineNumbers: true, noGutters: true }, // TODO: not used? } - export class SourceEditor implements ProjectView { constructor(path:string, mode:string) { this.path = path; @@ -106,17 +107,20 @@ export class SourceEditor implements ProjectView { newEditor(parent:HTMLElement, isAsmOverride?:boolean) { var modedef = MODEDEFS[this.mode] || MODEDEFS.default; var isAsm = isAsmOverride || modedef.isAsm; - var lineWrap = modedef.lineWrap; + var lineWrap = !!modedef.lineWrap; var theme = modedef.theme || MODEDEFS.default.theme; + var lineNums = !modedef.noLineNumbers; + var gutters = ["CodeMirror-linenumbers", "gutter-offset", "gutter-info"]; + if (isAsm) gutters = ["CodeMirror-linenumbers", "gutter-offset", "gutter-bytes", "gutter-clock", "gutter-info"]; + if (modedef.noGutters) gutters = ["gutter-info"]; this.editor = CodeMirror(parent, { theme: theme, - lineNumbers: true, + lineNumbers: lineNums, matchBrackets: true, tabSize: 8, indentAuto: true, lineWrapping: lineWrap, - gutters: isAsm ? ["CodeMirror-linenumbers", "gutter-offset", "gutter-bytes", "gutter-clock", "gutter-info"] - : ["CodeMirror-linenumbers", "gutter-offset", "gutter-info"], + gutters: gutters }); } @@ -324,7 +328,7 @@ export class SourceEditor implements ProjectView { this.editor.setGutterMarker(line, "gutter-info", div); } - this.clearCurrentLine(); + this.clearCurrentLine(moveCursor); if (line>0) { addCurrentMarker(line-1); if (moveCursor) @@ -333,18 +337,18 @@ export class SourceEditor implements ProjectView { } } - clearCurrentLine() { + clearCurrentLine(moveCursor:boolean) { if (this.currentDebugLine) { this.editor.clearGutter("gutter-info"); - this.editor.setSelection(this.editor.getCursor()); + if (moveCursor) this.editor.setSelection(this.editor.getCursor()); this.currentDebugLine = 0; } } getActiveLine() { var state = lastDebugState; - if (state && state.c && this.sourcefile) { - var EPC = state.c.EPC || state.c.PC; + if (this.sourcefile && state) { + var EPC = (state && state.c && (state.c.EPC || state.c.PC)); // || (platform.getPC && platform.getPC()); var res = this.sourcefile.findLineForOffset(EPC, 15); return res && res.line; } else @@ -352,7 +356,7 @@ export class SourceEditor implements ProjectView { } refreshDebugState(moveCursor:boolean) { - this.clearCurrentLine(); + this.clearCurrentLine(moveCursor); var line = this.getActiveLine(); if (line >= 0) { this.setCurrentLine(line, moveCursor); diff --git a/src/platform/basic.ts b/src/platform/basic.ts new file mode 100644 index 00000000..8b024154 --- /dev/null +++ b/src/platform/basic.ts @@ -0,0 +1,414 @@ + +import { Platform, BreakpointCallback } from "../common/baseplatform"; +import { PLATFORMS, AnimationTimer } from "../common/emu"; +import { loadScript } from "../ide/ui"; +import { BASICRuntime } from "../common/basic/runtime"; +import { BASICProgram } from "../common/basic/compiler"; + +const BASIC_PRESETS = [ + { id: 'hello.bas', name: 'Hello World' } +]; + +class TeleType { + page: HTMLElement; + fixed: boolean; + scrolldiv: HTMLElement; + + curline: HTMLElement; + curstyle: number; + reverse: boolean; + col: number; + row: number; + lines: HTMLElement[]; + ncharsout : number; + + constructor(page: HTMLElement, fixed: boolean) { + this.page = page; + this.fixed = fixed; + this.clear(); + } + clear() { + this.curline = null; + this.curstyle = 0; + this.reverse = false; + this.col = 0; + this.row = -1; + this.lines = []; + this.ncharsout = 0; + $(this.page).empty(); + } + ensureline() { + if (this.curline == null) { + this.curline = this.lines[++this.row]; + if (this.curline == null) { + this.curline = $('
')[0]; + this.page.appendChild(this.curline); + this.lines[this.row] = this.curline; + this.scrollToBottom(); + } + } + } + flushline() { + this.curline = null; + } + // TODO: support fixed-width window (use CSS grid?) + addtext(line: string, style: number) { + this.ensureline(); + if (line.length) { + // in fixed mode, only do characters + if (this.fixed && line.length > 1) { + for (var i = 0; i < line.length; i++) + this.addtext(line[i], style); + return; + } + var span = $("").text(line); + for (var i = 0; i < 8; i++) { + if (style & (1 << i)) + span.addClass("transcript-style-" + (1 << i)); + } + if (this.reverse) span.addClass("transcript-reverse"); + //span.data('vmip', this.vm.pc); + // in fixed mode, we can overwrite individual characters + if (this.fixed && line.length == 1 && this.col < this.curline.childNodes.length) { + this.curline.replaceChild(span[0], this.curline.childNodes[this.col]); + } else { + span.appendTo(this.curline); + } + this.col += line.length; + this.ncharsout += line.length; + //this.movePrintHead(); + } + } + newline() { + this.flushline(); + this.col = 0; + } + // TODO: bug in interpreter where it tracks cursor position but maybe doesn't do newlines? + print(val: string) { + // split by newlines + var lines = val.split("\n"); + for (var i = 0; i < lines.length; i++) { + if (i > 0) this.newline(); + this.addtext(lines[i], this.curstyle); + } + } + move_cursor(col: number, row: number) { + if (!this.fixed) return; // fixed windows only + // ensure enough row elements + while (this.lines.length <= row) { + this.flushline(); + this.ensureline(); + } + // select row element + this.curline = this.lines[row]; + this.row = row; + // get children in row (individual text cells) + var children = $(this.curline).children(); + // add whitespace to line? + if (children.length > col) { + this.col = col; + } else { + while (this.col < col) + this.addtext(' ', this.curstyle); + } + } + setrows(size: number) { + if (!this.fixed) return; // fixed windows only + // truncate rows? + var allrows = $(this.page).children(); + if (allrows.length > size) { + this.flushline(); + allrows.slice(size).remove(); + this.lines = this.lines.slice(0, size); + //this.move_cursor(0,0); + } + } + formfeed() { + this.newline(); + } + scrollToBottom() { + this.curline.scrollIntoView(); + } + movePrintHead() { + var x = $(this.page).position().left + this.col * ($(this.page).width() / 80); + $("#printhead").offset({left: x}); + } +} + +class TeleTypeWithKeyboard extends TeleType { + input : HTMLInputElement; + runtime : BASICRuntime; + platform : BASICPlatform; + keepinput : boolean = true; + + focused : boolean = true; + scrolling : number = 0; + waitingfor : string; + resolveInput; + + constructor(page: HTMLElement, fixed: boolean, input: HTMLInputElement, platform: BASICPlatform) { + super(page, fixed); + this.input = input; + this.platform = platform; + this.runtime = platform.runtime; + this.runtime.input = async (prompt:string) => { + return new Promise( (resolve, reject) => { + this.addtext(prompt, 0); + this.addtext('? ', 0); + this.waitingfor = 'line'; + this.focusinput(); + this.resolveInput = resolve; + }); + } + this.input.onkeypress = (e) => { + this.sendkey(e); + }; + this.input.onfocus = (e) => { + this.focused = true; + console.log('inputline gained focus'); + }; + $("#workspace").on('click', (e) => { + this.focused = false; + console.log('inputline lost focus'); + }); + this.page.onclick = (e) => { + this.input.focus(); + }; + this.hideinput(); + } + focusinput() { + this.ensureline(); + // don't steal focus while editing + if (this.keepinput) + $(this.input).css('visibility', 'visible'); + else + $(this.input).appendTo(this.curline).show()[0]; + this.scrollToBottom(); + if (this.focused) { + $(this.input).focus(); + } + // change size + if (this.waitingfor == 'char') + $(this.input).addClass('transcript-input-char') + else + $(this.input).removeClass('transcript-input-char') + } + hideinput() { + if (this.keepinput) + $(this.input).css('visibility','hidden'); + else + $(this.input).appendTo($(this.page).parent()).hide(); + } + clearinput() { + this.input.value = ''; + this.waitingfor = null; + } + sendkey(e: KeyboardEvent) { + if (this.waitingfor == 'line') { + if (e.key == "Enter") { + this.sendinput(this.input.value.toString()); + } + } else if (this.waitingfor == 'char') { + this.sendchar(e.keyCode); + e.preventDefault(); + } + } + sendinput(s: string) { + if (this.resolveInput) { + s = s.toUpperCase(); + this.addtext(s, 4); + this.flushline(); + this.resolveInput(s.split(',')); + this.resolveInput = null; + } + this.clearinput(); + this.hideinput(); // keep from losing input handlers + } + sendchar(code: number) { + } + ensureline() { + if (!this.keepinput) $(this.input).hide(); + super.ensureline(); + } + scrollToBottom() { + // TODO: fails when lots of lines are scrolled + if (this.scrolldiv) { + this.scrolling++; + $(this.scrolldiv).stop().animate({scrollTop: $(this.page).height()}, 200, 'swing', () => { + this.scrolling = 0; + this.ncharsout = 0; + }); + } else { + this.input.scrollIntoView(); + } + } + isBusy() { + // stop execution when scrolling and printing non-newlines + return this.scrolling > 0 && this.ncharsout > 0; + } +} + +class BASICPlatform implements Platform { + mainElement: HTMLElement; + program: BASICProgram; + runtime: BASICRuntime; + timer: AnimationTimer; + tty: TeleTypeWithKeyboard; + ips: number = 500; + clock: number = 0; + + constructor(mainElement: HTMLElement) { + //super(); + this.mainElement = mainElement; + mainElement.style.overflowY = 'auto'; + mainElement.style.backgroundColor = 'white'; + } + + async start() { + await loadScript('./gen/common/basic/runtime.js'); + // create runtime + this.runtime = new BASICRuntime(); + this.runtime.reset(); + // create divs + var parent = this.mainElement; + // TODO: input line should be always flush left + var gameport = $('
').appendTo(parent); + var windowport = $('
').appendTo(gameport); + var inputline = $('').appendTo(parent); + //var printhead = $('
').appendTo(parent); + this.tty = new TeleTypeWithKeyboard(windowport[0], true, inputline[0] as HTMLInputElement, this); + this.tty.scrolldiv = parent; + this.timer = new AnimationTimer(60, this.advance1_60.bind(this)); + this.resize = () => { + // set font size proportional to window width + var charwidth = $(gameport).width() * 1.6 / 80; + $(windowport).css('font-size', charwidth + 'px'); + this.tty.scrollToBottom(); + } + this.resize(); + this.runtime.print = this.tty.print.bind(this.tty); + this.runtime.resume = this.resume.bind(this); + } + + advance1_60() { + if (this.tty.isBusy()) return; + this.clock += this.ips/60; + while (!this.runtime.exited && this.clock-- > 0) { + this.advance(); + } + } + + advance(novideo?: boolean) : number { + if (this.runtime.running) { + try { + var more = this.runtime.step(); + if (!more) { + this.pause(); + if (this.runtime.exited) { + this.tty.print("\n\n"); + this.tty.addtext("*** END OF PROGRAM ***", 1); + } + } + } catch (e) { + this.break(); + throw e; + } + return 1; + } else { + return 0; + } + } + + resize: () => void; + + loadROM(title, data) { + this.reset(); + this.program = data; + this.runtime.load(data); + } + + getROMExtension() { + return ".json"; + } + + reset(): void { + var didExit = this.runtime.exited; + this.tty.clear(); + this.runtime.reset(); + // restart program if it's finished, otherwise reset and hold + if (didExit) { + this.resume(); + } + } + + pause(): void { + this.timer.stop(); + } + + resume(): void { + this.clock = 0; + this.timer.start(); + } + + isRunning() { return this.timer.isRunning(); } + getDefaultExtension() { return ".bas"; } + getToolForFilename() { return "basic"; } + getPresets() { return BASIC_PRESETS; } + + getPC() { + return this.runtime.curpc; + } + getSP() { + return 0x1000 - this.runtime.returnStack.length; + } + isStable() { + return true; + } + + getCPUState() { + return { PC: this.getPC(), SP: this.getSP() } + } + saveState() { + return { + c: this.getCPUState(), + rt: $.extend(true, {}, this.runtime) // TODO: don't take all + } + } + loadState(state) { + $.extend(true, this.runtime, state); + } + getDebugTree() { + return this.runtime; + } + inspect(sym: string) { + var o = this.runtime.vars[sym]; + if (o != null) { + return o.toString(); + } + } + + // TODO: debugging (get running state, etc) + + onBreakpointHit : BreakpointCallback; + + setupDebug(callback : BreakpointCallback) : void { + this.onBreakpointHit = callback; + } + clearDebug() { + this.onBreakpointHit = null; + } + step() { + this.pause(); + this.advance(); + this.break(); + } + break() { + if (this.onBreakpointHit) { + this.onBreakpointHit(this.saveState()); + } + } +} + +// + +PLATFORMS['basic'] = BASICPlatform; diff --git a/src/worker/workermain.ts b/src/worker/workermain.ts index bc9cda4f..b168ea4c 100644 --- a/src/worker/workermain.ts +++ b/src/worker/workermain.ts @@ -311,9 +311,9 @@ var workerseq : number = 0; function compareData(a:FileData, b:FileData) : boolean { if (a.length != b.length) return false; - if (typeof a === 'string' && typeof b === 'string') - return a==b; - else { + if (typeof a === 'string' && typeof b === 'string') { + return a == b; + } else { for (var i=0; i { return (key=='$loc'?undefined:value) }); + putWorkFile(jsonpath, json); + if (anyTargetChanged(step, [jsonpath])) return { + output: ast, + listings: parser.getListings(), + }; + } +} + //////////////////////////// var TOOLS = { @@ -2668,6 +2696,7 @@ var TOOLS = { 'inform6': compileInform6, 'merlin32': assembleMerlin32, 'fastbasic': compileFastBasic, + 'basic': compileBASIC, } var TOOL_PRELOADFS = {