primrose
Version:
Syntax-highlighting text editor that renders to an HTML5 Canvas element
1,451 lines (1,320 loc) • 157 kB
JavaScript
const singleLineOutput = Object.freeze([
"CursorLeft",
"CursorRight",
"CursorSkipLeft",
"CursorSkipRight",
"CursorHome",
"CursorEnd",
"CursorFullHome",
"CursorFullEnd",
"SelectLeft",
"SelectRight",
"SelectSkipLeft",
"SelectSkipRight",
"SelectHome",
"SelectEnd",
"SelectFullHome",
"SelectFullEnd",
"SelectAll"
]);
const multiLineOutput = Object.freeze(singleLineOutput
.concat([
"CursorDown",
"CursorUp",
"CursorPageDown",
"CursorPageUp",
"SelectDown",
"SelectUp",
"SelectPageDown",
"SelectPageUp",
"ScrollDown",
"ScrollUp"
]));
const input = [
"Backspace",
"Delete",
"DeleteWordLeft",
"DeleteWordRight",
"DeleteLine",
"Undo",
"Redo",
];
const singleLineInput = Object.freeze(singleLineOutput
.concat(input));
const multiLineInput = Object.freeze(multiLineOutput
.concat(input)
.concat([
"AppendNewline",
"PrependNewline"
]));
const combiningMarks =
/(<%= allExceptCombiningMarks %>)(<%= combiningMarks %>+)/g,
surrogatePair = /(<%= highSurrogates %>)(<%= lowSurrogates %>)/g;
// unicode-aware string reverse
function reverse(str) {
str = str.replace(combiningMarks, function (match, capture1,
capture2) {
return reverse(capture2) + capture1;
})
.replace(surrogatePair, "$2$1");
let res = "";
for (let i = str.length - 1; i >= 0; --i) {
res += str[i];
}
return res;
}
class Cursor {
static min(a, b) {
if (a.i <= b.i) {
return a;
}
return b;
}
static max(a, b) {
if (a.i > b.i) {
return a;
}
return b;
}
constructor(i, x, y) {
this.i = i || 0;
this.x = x || 0;
this.y = y || 0;
Object.seal(this);
}
clone() {
return new Cursor(this.i, this.x, this.y);
}
toString() {
return `[i:${this.i} x:${this.x} y:${this.y}]`;
}
copy(cursor) {
this.i = cursor.i;
this.x = cursor.x;
this.y = cursor.y;
}
fullHome() {
this.i = 0;
this.x = 0;
this.y = 0;
}
fullEnd(rows) {
this.i = 0;
let lastLength = 0;
for (let y = 0; y < rows.length; ++y) {
const row = rows[y];
lastLength = row.stringLength;
this.i += lastLength;
}
this.y = rows.length - 1;
this.x = lastLength;
}
left(rows, skipAdjust = false) {
if (this.i > 0) {
--this.i;
--this.x;
if (this.x < 0) {
--this.y;
const row = rows[this.y];
this.x = row.stringLength - 1;
}
else if (!skipAdjust) {
rows[this.y].adjust(this, -1);
}
}
}
skipLeft(rows) {
if (this.x <= 1) {
this.left(rows);
}
else {
const x = this.x - 1,
row = rows[this.y],
word = reverse(row.substring(0, x)),
m = word.match(/\w+/),
dx = m
? (m.index + m[0].length + 1)
: this.x;
this.i -= dx;
this.x -= dx;
rows[this.y].adjust(this, -1);
}
}
right(rows, skipAdjust = false) {
const row = rows[this.y];
if (this.y < rows.length - 1
|| this.x < row.stringLength) {
++this.i;
++this.x;
if (this.y < rows.length - 1
&& this.x === row.stringLength) {
this.x = 0;
++this.y;
}
else if (!skipAdjust) {
rows[this.y].adjust(this, 1);
}
}
}
skipRight(rows) {
const row = rows[this.y];
if (this.x < row.stringLength - 1) {
const x = this.x + 1,
subrow = row.substring(x),
m = subrow.match(/\w+/),
dx = m
? (m.index + m[0].length + 1)
: (row.stringLength - this.x);
this.i += dx;
this.x += dx;
if (this.x > 0
&& this.x === row.stringLength
&& this.y < rows.length - 1) {
--this.x;
--this.i;
}
rows[this.y].adjust(this, 1);
}
else if (this.y < rows.length - 1) {
this.right(rows);
}
}
home() {
this.i -= this.x;
this.x = 0;
}
end(rows) {
const row = rows[this.y];
let dx = row.stringLength - this.x;
if (this.y < rows.length - 1) {
--dx;
}
this.i += dx;
this.x += dx;
}
up(rows, skipAdjust = false) {
if (this.y > 0) {
--this.y;
const row = rows[this.y],
dx = Math.min(0, row.stringLength - this.x - 1);
this.x += dx;
this.i -= row.stringLength - dx;
if (!skipAdjust) {
rows[this.y].adjust(this, 1);
}
}
}
down(rows, skipAdjust = false) {
if (this.y < rows.length - 1) {
const prevRow = rows[this.y];
++this.y;
this.i += prevRow.stringLength;
const row = rows[this.y];
if (this.x >= row.stringLength) {
let dx = this.x - row.stringLength;
if (this.y < rows.length - 1) {
++dx;
}
this.i -= dx;
this.x -= dx;
}
if (!skipAdjust) {
rows[this.y].adjust(this, 1);
}
}
}
incX(rows, dx) {
const dir = Math.sign(dx);
dx = Math.abs(dx);
if (dir === -1) {
for (let i = 0; i < dx; ++i) {
this.left(rows, true);
}
rows[this.y].adjust(this, -1);
}
else if (dir === 1) {
for (let i = 0; i < dx; ++i) {
this.right(rows, true);
}
rows[this.y].adjust(this, 1);
}
}
incY(rows, dy) {
const dir = Math.sign(dy);
dy = Math.abs(dy);
if (dir === -1) {
for (let i = 0; i < dy; ++i) {
this.up(rows, true);
}
}
else if (dir === 1) {
for (let i = 0; i < dy; ++i) {
this.down(rows, true);
}
}
rows[this.y].adjust(this, 1);
}
setXY(rows, x, y) {
x = Math.floor(x);
y = Math.floor(y);
this.y = Math.max(0, Math.min(rows.length - 1, y));
const row = rows[this.y];
this.x = Math.max(0, Math.min(row.stringLength, x));
this.i = this.x;
for (let i = 0; i < this.y; ++i) {
this.i += rows[i].stringLength;
}
if (this.x > 0
&& this.x === row.stringLength
&& this.y < rows.length - 1) {
--this.x;
--this.i;
}
rows[this.y].adjust(this, 1);
}
setI(rows, i) {
const delta = this.i - i,
dir = Math.sign(delta);
this.x = this.i = i;
this.y = 0;
let total = 0,
row = rows[this.y];
while (this.x > row.stringLength) {
this.x -= row.stringLength;
total += row.stringLength;
if (this.y >= rows.length - 1) {
this.i = total;
this.x = row.stringLength;
break;
}
++this.y;
row = rows[this.y];
}
if (this.y < rows.length - 1
&& this.x === row.stringLength) {
this.x = 0;
++this.y;
}
rows[this.y].adjust(this, dir);
}
}
/**
* Removes an item at the given index from an array.
* @param {any[]} arr
* @param {number} idx
* @returns {any} - the item that was removed.
*/
function arrayRemoveAt(arr, idx) {
if (!(arr instanceof Array)) {
throw new Error("Must provide an array as the first parameter.");
}
return arr.splice(idx, 1);
}
function isFunction(func) {
return typeof func === "function" || func instanceof Function;
}
/** A polyfill for EventTarget, which is not extendable in Safari */
const EventBase = (function () {
try {
new window.EventTarget();
return class EventBase extends EventTarget {
constructor() {
super();
}
};
} catch (exp) {
/** @type {WeakMap<EventBase, Map<string, Listener[]>> */
const selfs = new WeakMap();
return class EventBase {
constructor() {
selfs.set(this, new Map());
}
/**
* @param {string} type
* @param {Function} callback
* @param {any} options
*/
addEventListener(type, callback, options) {
if (isFunction(callback)) {
const self = selfs.get(this);
if (!self.has(type)) {
self.set(type, []);
}
const listeners = self.get(type);
if (!listeners.find(l => l.callback === callback)) {
listeners.push({
target: this,
callback,
options
});
}
}
}
/**
* @param {string} type
* @param {Function} callback
*/
removeEventListener(type, callback) {
if (isFunction(callback)) {
const self = selfs.get(this);
if (self.has(type)) {
const listeners = self.get(type),
idx = listeners.findIndex(l => l.callback === callback);
if (idx >= 0) {
arrayRemoveAt(listeners, idx);
}
}
}
}
/**
* @param {Event} evt
*/
dispatchEvent(evt) {
const self = selfs.get(this);
if (!self.has(evt.type)) {
return true;
}
else {
const listeners = self.get(evt.type);
for (const listener of listeners) {
if (listener.options && listener.options.once) {
this.removeEventListener(evt.type, listener.callback);
}
listener.callback.call(listener.target, evt);
}
return !evt.defaultPrevented;
}
}
};
}
})();
// Various flags used for feature detecting and configuring the system
const isOpera = !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0;
const isFirefox = typeof window.InstallTrigger !== "undefined";
const isiOS = /iP(hone|od|ad)/.test(navigator.userAgent || "");
const isMacOS = /Macintosh/.test(navigator.userAgent || "");
const isApple = isiOS || isMacOS;
const isSafari = Object.prototype.toString.call(window.HTMLElement)
.indexOf("Constructor") > 0;
function testUserAgent(a) {
return /(android|bb\d+|meego).+|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od|ad)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(
a) ||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
a.substring(0, 4));
}
const isMobile = testUserAgent(navigator.userAgent || navigator.vendor || window.opera);
// A selection of fonts for preferred monospace rendering.
const monospaceFamily = "'Droid Sans Mono', 'Consolas', 'Lucida Console', 'Courier New', 'Courier', monospace";
// A single syntax matching rule, for tokenizing code.
class Rule {
constructor(name, test) {
this.name = name;
this.test = test;
Object.freeze(this);
}
carveOutMatchedToken(tokens, j) {
const token = tokens[j];
if (token.type === "regular") {
const res = this.test.exec(token.value);
if (!!res) {
// Only use the last group that matches the regex, to allow for more
// complex regexes that can match in special contexts, but not make
// the context part of the token.
const midx = res[res.length - 1],
start = res.input.indexOf(midx),
end = start + midx.length;
if (start === 0) {
// the rule matches the start of the token
token.type = this.name;
if (end < token.length) {
// but not the end
const next = token.splitAt(end);
next.type = "regular";
tokens.splice(j + 1, 0, next);
}
}
else {
// the rule matches from the middle of the token
const mid = token.splitAt(start);
if (midx.length < mid.length) {
// but not the end
const right = mid.splitAt(midx.length);
tokens.splice(j + 1, 0, right);
}
mid.type = this.name;
tokens.splice(j + 1, 0, mid);
}
}
}
}
}
// A chunk of text that represents a single element of code,
// with fields linking it back to its source.
class Token {
constructor(value, type, stringIndex) {
this.value = value;
this.startStringIndex = stringIndex;
this.type = type;
Object.seal(this);
}
get length() {
return this.value.length;
}
get endStringIndex() {
return this.startStringIndex + this.length;
}
clone() {
return new Token(this.value, this.type, this.startStringIndex);
}
splitAt(i) {
var next = this.value.substring(i);
this.value = this.value.substring(0, i);
return new Token(next, this.type, this.startStringIndex + i);
}
toString() {
return `[${this.type}: ${this.value}]`;
}
}
// Color themes for text-oriented controls, for use when coupled with a parsing grammar.
// A dark background with a light foreground for text.
const Dark = Object.freeze({
name: "Dark",
cursorColor: "white",
unfocused: "rgba(0, 0, 255, 0.25)",
currentRowBackColor: "#202020",
selectedBackColor: "#404040",
lineNumbers: {
foreColor: "white"
},
regular: {
backColor: "rgba(0, 0, 0, 0.5)",
foreColor: "#c0c0c0"
},
strings: {
foreColor: "#aa9900",
fontStyle: "italic"
},
regexes: {
foreColor: "#aa0099",
fontStyle: "italic"
},
numbers: {
foreColor: "green"
},
comments: {
foreColor: "yellow",
fontStyle: "italic"
},
keywords: {
foreColor: "cyan"
},
functions: {
foreColor: "brown",
fontWeight: "bold"
},
members: {
foreColor: "green"
},
error: {
foreColor: "red",
fontStyle: "underline italic"
}
});
// A light background with dark foreground text.
const Light = Object.freeze({
name: "Light",
cursorColor: "black",
unfocused: "rgba(0, 0, 255, 0.25)",
currentRowBackColor: "#f0f0f0",
selectedBackColor: "#c0c0c0",
lineNumbers: {
foreColor: "black"
},
regular: {
backColor: "white",
foreColor: "black"
},
strings: {
foreColor: "#aa9900",
fontStyle: "italic"
},
regexes: {
foreColor: "#aa0099",
fontStyle: "italic"
},
numbers: {
foreColor: "green"
},
comments: {
foreColor: "grey",
fontStyle: "italic"
},
keywords: {
foreColor: "blue"
},
functions: {
foreColor: "brown",
fontWeight: "bold"
},
members: {
foreColor: "green"
},
error: {
foreColor: "red",
fontStyle: "underline italic"
}
});
const themes = Object.freeze(new Map([
["light", Light],
["dark", Dark]
]));
function assignAttributes(elem, ...rest) {
rest.filter(x => !(x instanceof Element)
&& !(x instanceof String)
&& typeof x !== "string")
.forEach(attr => {
for (let key in attr) {
const value = attr[key];
if (key === "style") {
for (let subKey in value) {
elem[key][subKey] = value[subKey];
}
}
else if (key === "textContent" || key === "innerText") {
elem.appendChild(document.createTextNode(value));
}
else if (key.startsWith("on") && typeof value === "function") {
elem.addEventListener(key.substring(2), value);
}
else if (!(typeof value === "boolean" || value instanceof Boolean)
|| key === "muted") {
elem[key] = value;
}
else if (value) {
elem.setAttribute(key, "");
}
else {
elem.removeAttribute(key);
}
}
});
}
function clear(elem) {
while (elem.lastChild) {
elem.lastChild.remove();
}
}
function tag(name, ...rest) {
const elem = document.createElement(name);
assignAttributes(elem, ...rest);
const textContent = rest.filter(x => x instanceof String || typeof x === "string")
.reduce((a, b) => (a + "\n" + b), "")
.trim();
if (textContent.length > 0) {
elem.appendChild(document.createTextNode(textContent));
}
rest.filter(x => x instanceof Element)
.forEach(elem.appendChild.bind(elem));
return elem;
}
function br() { return tag("br"); }
function canvas(...rest) { return tag("canvas", ...rest); }
function div(...rest) { return tag("div", ...rest); }
function span(...rest) { return tag("span", ...rest); }
function text(value) { return document.createTextNode(value); }
function isCanvas(elem) {
if (elem instanceof HTMLCanvasElement) {
return true;
}
if (window.OffscreenCanvas
&& elem instanceof OffscreenCanvas) {
return true;
}
return false;
}
function offscreenCanvas(options) {
const width = options && options.width || 512,
height = options && options.height || width;
if (options instanceof Object) {
Object.assign(options, {
width,
height
});
}
if (window.OffscreenCanvas) {
return new OffscreenCanvas(width, height);
}
return canvas(options);
}
function setCanvasSize(canv, w, h, superscale = 1) {
w = Math.floor(w * superscale);
h = Math.floor(h * superscale);
if (canv.width != w
|| canv.height != h) {
canv.width = w;
canv.height = h;
return true;
}
return false;
}
function setContextSize(ctx, w, h, superscale = 1) {
const oldImageSmoothingEnabled = ctx.imageSmoothingEnabled,
oldTextBaseline = ctx.textBaseline,
oldTextAlign = ctx.textAlign,
oldFont = ctx.font,
resized = setCanvasSize(
ctx.canvas,
w,
h,
superscale);
if (resized) {
ctx.imageSmoothingEnabled = oldImageSmoothingEnabled;
ctx.textBaseline = oldTextBaseline;
ctx.textAlign = oldTextAlign;
ctx.font = oldFont;
}
return resized;
}
function resizeContext(ctx, superscale = 1) {
return setContextSize(
ctx,
ctx.canvas.clientWidth,
ctx.canvas.clientHeight,
superscale);
}
/*
pliny.class({
parent: "Primrose.Text",
name: "Grammar",
parameters: [{
name: "grammarName",
type: "String",
description: "A user-friendly name for the grammar, to be able to include it in an options listing."
}, {
name: "rules",
type: "Array",
description: "A collection of rules to apply to tokenize text. The rules should be an array of two-element arrays. The first element should be a token name (see [`Primrose.Text.Rule`](#Primrose_Text_Rule) for a list of valid token names), followed by a regular expression that selects the token out of the source code."
}],
description: "A Grammar is a collection of rules for processing text into tokens. Tokens are special characters that tell us about the structure of the text, things like keywords, curly braces, numbers, etc. After the text is tokenized, the tokens get a rough processing pass that groups them into larger elements that can be rendered in color on the screen.\n\
\n\
As tokens are discovered, they are removed from the text being processed, so order is important. Grammar rules are applied in the order they are specified, and more than one rule can produce the same token type.\n\
\n\
See [`Primrose.Text.Rule`](#Primrose_Text_Rule) for a list of valid token names.",
examples: [{
name: "A plain-text \"grammar\".",
description: "Plain text does not actually have a grammar that needs to be processed. However, to get the text to work with the rendering system, a basic grammar is necessary to be able to break the text up into lines and prepare it for rendering.\n\
\n\
## Code:\n\
\n\
grammar(\"JavaScript\");\n\
var plainTextGrammar = new Primrose.Text.Grammar(\n\
// The name is for displaying in options views.\n\
\"Plain-text\", [\n\
// Text needs at least the newlines token, or else every line will attempt to render as a single line and the line count won't work.\n\
[\"newlines\", /(?:\\r\\n|\\r|\\n)/] \n\
] );"
}, {
name: "A grammar for BASIC",
description: "The BASIC programming language is now defunct, but a grammar for it to display in Primrose is quite easy to build.\n\
\n\
## Code:\n\
\n\
grammar(\"JavaScript\");\n\
var basicGrammar = new Primrose.Text.Grammar( \"BASIC\",\n\
// Grammar rules are applied in the order they are specified.\n\
[\n\
// Text needs at least the newlines token, or else every line will attempt to render as a single line and the line count won't work.\n\
[ \"newlines\", /(?:\\r\\n|\\r|\\n)/ ],\n\
// BASIC programs used to require the programmer type in her own line numbers. The start at the beginning of the line.\n\
[ \"lineNumbers\", /^\\d+\\s+/ ],\n\
// Comments were lines that started with the keyword \"REM\" (for REMARK) and ran to the end of the line. They did not have to be numbered, because they were not executable and were stripped out by the interpreter.\n\
[ \"startLineComments\", /^REM\\s/ ],\n\
// Both double-quoted and single-quoted strings were not always supported, but in this case, I'm just demonstrating how it would be done for both.\n\
[ \"strings\", /\"(?:\\\\\"|[^\"])*\"/ ],\n\
[ \"strings\", /'(?:\\\\'|[^'])*'/ ],\n\
// Numbers are an optional dash, followed by a optional digits, followed by optional period, followed by 1 or more required digits. This allows us to match both integers and decimal numbers, both positive and negative, with or without leading zeroes for decimal numbers between (-1, 1).\n\
[ \"numbers\", /-?(?:(?:\\b\\d*)?\\.)?\\b\\d+\\b/ ],\n\
// Keywords are really just a list of different words we want to match, surrounded by the \"word boundary\" selector \"\\b\".\n\
[ \"keywords\",\n\
/\\b(?:RESTORE|REPEAT|RETURN|LOAD|LABEL|DATA|READ|THEN|ELSE|FOR|DIM|LET|IF|TO|STEP|NEXT|WHILE|WEND|UNTIL|GOTO|GOSUB|ON|TAB|AT|END|STOP|PRINT|INPUT|RND|INT|CLS|CLK|LEN)\\b/\n\
],\n\
// Sometimes things we want to treat as keywords have different meanings in different locations. We can specify rules for tokens more than once.\n\
[ \"keywords\", /^DEF FN/ ],\n\
// These are all treated as mathematical operations.\n\
[ \"operators\",\n\
/(?:\\+|;|,|-|\\*\\*|\\*|\\/|>=|<=|=|<>|<|>|OR|AND|NOT|MOD|\\(|\\)|\\[|\\])/\n\
],\n\
// Once everything else has been matched, the left over blocks of words are treated as variable and function names.\n\
[ \"identifiers\", /\\w+\\$?/ ]\n\
] );"
}]
});
*/
function crudeParsing(tokens) {
var commentDelim = null,
stringDelim = null;
for (let i = 0; i < tokens.length; ++i) {
const t = tokens[i];
if (stringDelim) {
if (t.type === "stringDelim" && t.value === stringDelim && (i === 0 || tokens[i - 1].value[tokens[i - 1].value.length - 1] !== "\\")) {
stringDelim = null;
}
if (t.type !== "newlines") {
t.type = "strings";
}
}
else if (commentDelim) {
if (commentDelim === "startBlockComments" && t.type === "endBlockComments" ||
commentDelim === "startLineComments" && t.type === "newlines") {
commentDelim = null;
}
if (t.type !== "newlines") {
t.type = "comments";
}
}
else if (t.type === "stringDelim") {
stringDelim = t.value;
t.type = "strings";
}
else if (t.type === "startBlockComments" || t.type === "startLineComments") {
commentDelim = t.type;
t.type = "comments";
}
}
// recombine like-tokens
for (let i = tokens.length - 1; i > 0; --i) {
const p = tokens[i - 1],
t = tokens[i];
if (p.type === t.type
&& p.type !== "newlines") {
p.value += t.value;
tokens.splice(i, 1);
}
}
// remove empties
for (let i = tokens.length - 1; i >= 0; --i) {
if (tokens[i].length === 0) {
tokens.splice(i, 1);
}
}
}
class Grammar {
constructor(grammarName, rules) {
rules = rules || [];
/*
pliny.property({
parent: "Primrose.Text.Grammar",
name: " name",
type: "String",
description: "A user-friendly name for the grammar, to be able to include it in an options listing."
});
*/
this.name = grammarName;
/*
pliny.property({
parent: "Primrose.Text.Grammar",
name: "grammar",
type: "Array",
description: "A collection of rules to apply to tokenize text. The rules should be an array of two-element arrays. The first element should be a token name (see [`Primrose.Text.Rule`](#Primrose_Text_Rule) for a list of valid token names), followed by a regular expression that selects the token out of the source code."
});
*/
// clone the preprocessing grammar to start a new grammar
this.grammar = rules.map((rule) =>
new Rule(rule[0], rule[1]));
Object.freeze(this);
}
/*
pliny.method({
parent: "Primrose.Text.Grammar",
name: "tokenize",
parameters: [{
name: "text",
type: "String",
description: "The text to tokenize."
}],
returns: "An array of tokens, ammounting to drawing instructions to the renderer. However, they still need to be layed out to fit the bounds of the text area.",
description: "Breaks plain text up into a list of tokens that can later be rendered with color.",
examples: [{
name: 'Tokenize some JavaScript',
description: 'Primrose comes with a grammar for JavaScript built in.\n\
\n\
## Code:\n\
\n\
grammar(\"JavaScript\");\n\
var tokens = new Primrose.Text.Grammars.JavaScript\n\
.tokenize("var x = 3;\\n\\\n\
var y = 2;\\n\\\n\
console.log(x + y);");\n\
console.log(JSON.stringify(tokens));\n\
\n\
## Result:\n\
\n\
grammar(\"JavaScript\");\n\
[ \n\
{ "value": "var", "type": "keywords", "index": 0, "line": 0 },\n\
{ "value": " x = ", "type": "regular", "index": 3, "line": 0 },\n\
{ "value": "3", "type": "numbers", "index": 8, "line": 0 },\n\
{ "value": ";", "type": "regular", "index": 9, "line": 0 },\n\
{ "value": "\\n", "type": "newlines", "index": 10, "line": 0 },\n\
{ "value": " y = ", "type": "regular", "index": 11, "line": 1 },\n\
{ "value": "2", "type": "numbers", "index": 16, "line": 1 },\n\
{ "value": ";", "type": "regular", "index": 17, "line": 1 },\n\
{ "value": "\\n", "type": "newlines", "index": 18, "line": 1 },\n\
{ "value": "console", "type": "members", "index": 19, "line": 2 },\n\
{ "value": ".", "type": "regular", "index": 26, "line": 2 },\n\
{ "value": "log", "type": "functions", "index": 27, "line": 2 },\n\
{ "value": "(x + y);", "type": "regular", "index": 30, "line": 2 }\n\
]'
}]
});
*/
tokenize(text) {
// all text starts off as regular text, then gets cut up into tokens of
// more specific type
const tokens = [new Token(text, "regular", 0)];
for (let rule of this.grammar) {
for (var j = 0; j < tokens.length; ++j) {
rule.carveOutMatchedToken(tokens, j);
}
}
crudeParsing(tokens);
return tokens;
}
toHTML(parent, txt, theme, fontSize) {
if (theme === undefined) {
theme = Light;
}
var tokenRows = this.tokenize(txt),
temp = div();
for (var y = 0; y < tokenRows.length; ++y) {
// draw the tokens on this row
var t = tokenRows[y];
if (t.type === "newlines") {
temp.appendChild(br());
}
else {
var style = theme[t.type] || {},
elem = span({
fontWeight: style.fontWeight || theme.regular.fontWeight,
fontStyle: style.fontStyle || theme.regular.fontStyle || "",
color: style.foreColor || theme.regular.foreColor,
backgroundColor: style.backColor || theme.regular.backColor,
fontFamily: monospaceFamily
});
elem.appendChild(text(t.value));
temp.appendChild(elem);
}
}
parent.innerHTML = temp.innerHTML;
Object.assign(parent.style, {
backgroundColor: theme.regular.backColor,
fontSize: `${fontSize}px`,
lineHeight: `${fontSize}px`,
});
}
}
// A grammar and an interpreter for a BASIC-like language.
class BasicGrammar extends Grammar {
constructor() {
super("BASIC",
// Grammar rules are applied in the order they are specified.
[
["newlines", /(?:\r\n|\r|\n)/],
// BASIC programs used to require the programmer type in her own line numbers. The start at the beginning of the line.
["lineNumbers", /^\d+\s+/],
["whitespace", /(?:\s+)/],
// Comments were lines that started with the keyword "REM" (for REMARK) and ran to the end of the line. They did not have to be numbered, because they were not executable and were stripped out by the interpreter.
["startLineComments", /^REM\s/],
// Both double-quoted and single-quoted strings were not always supported, but in this case, I'm just demonstrating how it would be done for both.
["stringDelim", /("|')/],
// Numbers are an optional dash, followed by a optional digits, followed by optional period, followed by 1 or more required digits. This allows us to match both integers and decimal numbers, both positive and negative, with or without leading zeroes for decimal numbers between (-1, 1).
["numbers", /-?(?:(?:\b\d*)?\.)?\b\d+\b/],
// Keywords are really just a list of different words we want to match, surrounded by the "word boundary" selector "\b".
["keywords",
/\b(?:RESTORE|REPEAT|RETURN|LOAD|LABEL|DATA|READ|THEN|ELSE|FOR|DIM|LET|IF|TO|STEP|NEXT|WHILE|WEND|UNTIL|GOTO|GOSUB|ON|TAB|AT|END|STOP|PRINT|INPUT|RND|INT|CLS|CLK|LEN)\b/
],
// Sometimes things we want to treat as keywords have different meanings in different locations. We can specify rules for tokens more than once.
["keywords", /^DEF FN/],
// These are all treated as mathematical operations.
["operators",
/(?:\+|;|,|-|\*\*|\*|\/|>=|<=|=|<>|<|>|OR|AND|NOT|MOD|\(|\)|\[|\])/
],
// Once everything else has been matched, the left over blocks of words are treated as variable and function names.
["members", /\w+\$?/]
]);
}
tokenize(code) {
return super.tokenize(code.toUpperCase());
}
interpret(sourceCode, input, output, errorOut, next, clearScreen, loadFile, done) {
var tokens = this.tokenize(sourceCode),
EQUAL_SIGN = new Token("=", "operators"),
counter = 0,
isDone = false,
program = new Map(),
lineNumbers = [],
currentLine = [],
lines = [currentLine],
data = [],
returnStack = [],
forLoopCounters = new Map(),
dataCounter = 0;
Object.assign(window, {
INT: function (v) {
return v | 0;
},
RND: function () {
return Math.random();
},
CLK: function () {
return Date.now() / 3600000;
},
LEN: function (id) {
return id.length;
},
LINE: function () {
return lineNumbers[counter];
},
TAB: function (v) {
var str = "";
for (var i = 0; i < v; ++i) {
str += " ";
}
return str;
},
POW: function (a, b) {
return Math.pow(a, b);
}
});
function toNum(ln) {
return new Token(ln.toString(), "numbers");
}
function toStr(str) {
return new Token("\"" + str.replace("\n", "\\n")
.replace("\"", "\\\"") + "\"", "strings");
}
var tokenMap = {
"OR": "||",
"AND": "&&",
"NOT": "!",
"MOD": "%",
"<>": "!="
};
while (tokens.length > 0) {
var token = tokens.shift();
if (token.type === "newlines") {
currentLine = [];
lines.push(currentLine);
}
else if (token.type !== "regular" && token.type !== "comments") {
token.value = tokenMap[token.value] || token.value;
currentLine.push(token);
}
}
for (var i = 0; i < lines.length; ++i) {
var line = lines[i];
if (line.length > 0) {
var lastLine = lineNumbers[lineNumbers.length - 1];
var lineNumber = line.shift();
if (lineNumber.type !== "lineNumbers") {
line.unshift(lineNumber);
if (lastLine === undefined) {
lastLine = -1;
}
lineNumber = toNum(lastLine + 1);
}
lineNumber = parseFloat(lineNumber.value);
if (lastLine && lineNumber <= lastLine) {
throw new Error("expected line number greater than " + lastLine +
", but received " + lineNumber + ".");
}
else if (line.length > 0) {
lineNumbers.push(lineNumber);
program.set(lineNumber, line);
}
}
}
function process(line) {
if (line && line.length > 0) {
var op = line.shift();
if (op) {
if (commands.hasOwnProperty(op.value)) {
return commands[op.value](line);
}
else if (!isNaN(op.value)) {
return setProgramCounter([op]);
}
else if (window[op.value] ||
(line.length > 0 && line[0].type === "operators" &&
line[0].value === "=")) {
line.unshift(op);
return translate(line);
}
else {
error("Unknown command. >>> " + op.value);
}
}
}
return pauseBeforeComplete();
}
function error(msg) {
errorOut("At line " + lineNumbers[counter] + ": " + msg);
}
function getLine(i) {
var lineNumber = lineNumbers[i];
var line = program.get(lineNumber);
return line && line.slice();
}
function evaluate(line) {
var script = "";
for (var i = 0; i < line.length; ++i) {
var t = line[i];
var nest = 0;
if (t.type === "identifiers" &&
typeof window[t.value] !== "function" &&
i < line.length - 1 &&
line[i + 1].value === "(") {
for (var j = i + 1; j < line.length; ++j) {
var t2 = line[j];
if (t2.value === "(") {
if (nest === 0) {
t2.value = "[";
}
++nest;
}
else if (t2.value === ")") {
--nest;
if (nest === 0) {
t2.value = "]";
}
}
else if (t2.value === "," && nest === 1) {
t2.value = "][";
}
if (nest === 0) {
break;
}
}
}
script += t.value;
}
try {
return eval(script); // jshint ignore:line
}
catch (exp) {
console.error(exp);
console.debug(line.join(", "));
console.error(script);
error(exp.message + ": " + script);
}
}
function declareVariable(line) {
var decl = [],
decls = [decl],
nest = 0,
i;
for (i = 0; i < line.length; ++i) {
var t = line[i];
if (t.value === "(") {
++nest;
}
else if (t.value === ")") {
--nest;
}
if (nest === 0 && t.value === ",") {
decl = [];
decls.push(decl);
}
else {
decl.push(t);
}
}
for (i = 0; i < decls.length; ++i) {
decl = decls[i];
var id = decl.shift();
if (id.type !== "identifiers") {
error("Identifier expected: " + id.value);
}
else {
var val = null,
j;
id = id.value;
if (decl[0].value === "(" && decl[decl.length - 1].value === ")") {
var sizes = [];
for (j = 1; j < decl.length - 1; ++j) {
if (decl[j].type === "numbers") {
sizes.push(decl[j].value | 0);
}
}
if (sizes.length === 0) {
val = [];
}
else {
val = new Array(sizes[0]);
var queue = [val];
for (j = 1; j < sizes.length; ++j) {
var size = sizes[j];
for (var k = 0,
l = queue.length; k < l; ++k) {
var arr = queue.shift();
for (var m = 0; m < arr.length; ++m) {
arr[m] = new Array(size);
if (j < sizes.length - 1) {
queue.push(arr[m]);
}
}
}
}
}
}
window[id] = val;
return true;
}
}
}
function print(line) {
var endLine = "\n";
var nest = 0;
line = line.map(function (t, i) {
t = t.clone();
if (t.type === "operators") {
if (t.value === ",") {
if (nest === 0) {
t.value = "+ \", \" + ";
}
}
else if (t.value === ";") {
t.value = "+ \" \"";
if (i < line.length - 1) {
t.value += " + ";
}
else {
endLine = "";
}
}
else if (t.value === "(") {
++nest;
}
else if (t.value === ")") {
--nest;
}
}
return t;
});
var txt = evaluate(line);
if (txt === undefined) {
txt = "";
}
output(txt + endLine);
return true;
}
function setProgramCounter(line) {
var lineNumber = parseFloat(evaluate(line));
counter = -1;
while (counter < lineNumbers.length - 1 &&
lineNumbers[counter + 1] < lineNumber) {
++counter;
}
return true;
}
function checkConditional(line) {
var thenIndex = -1,
elseIndex = -1,
i;
for (i = 0; i < line.length; ++i) {
if (line[i].type === "keywords" && line[i].value === "THEN") {
thenIndex = i;
}
else if (line[i].type === "keywords" && line[i].value === "ELSE") {
elseIndex = i;
}
}
if (thenIndex === -1) {
error("Expected THEN clause.");
}
else {
var condition = line.slice(0, thenIndex);
for (i = 0; i < condition.length; ++i) {
var t = condition[i];
if (t.type === "operators" && t.value === "=") {
t.value = "==";
}
}
var thenClause,
elseClause;
if (elseIndex === -1) {
thenClause = line.slice(thenIndex + 1);
}
else {
thenClause = line.slice(thenIndex + 1, elseIndex);
elseClause = line.slice(elseIndex + 1);
}
if (evaluate(condition)) {
return process(thenClause);
}
else if (elseClause) {
return process(elseClause);
}
}
return true;
}
function pauseBeforeComplete() {
output("PROGRAM COMPLETE - PRESS RETURN TO FINISH.");
input(function () {
isDone = true;
if (done) {
done();
}
});
return false;
}
function labelLine(line) {
line.push(EQUAL_SIGN);
line.push(toNum(lineNumbers[counter]));
return translate(line);
}
function waitForInput(line) {
var toVar = line.pop();
if (line.length > 0) {
print(line);
}
input(function (str) {
str = str.toUpperCase();
var valueToken = null;
if (!isNaN(str)) {
valueToken = toNum(str);
}
else {
valueToken = toStr(str);
}
evaluate([toVar, EQUAL_SIGN, valueToken]);
if (next) {
next();
}
});
return false;
}
function onStatement(line) {
var idxExpr = [],
idx = null,
targets = [];
try {
while (line.length > 0 &&
(line[0].type !== "keywords" ||
line[0].value !== "GOTO")) {
idxExpr.push(line.shift());
}
if (line.length > 0) {
line.shift(); // burn the goto;
for (var i = 0; i < line.length; ++i) {
var t = line[i];
if (t.type !== "operators" ||
t.value !== ",") {
targets.push(t);
}
}
idx = evaluate(idxExpr) - 1;
if (0 <= idx && idx < targets.length) {
return setProgramCounter([targets[idx]]);
}
}