blob: ccf93a13b37287bf116a1c50bf163906d70fd831 [file] [log] [blame]
(function () {
// Indent used for formatting output.
const INDENT = " ";
// Table of ASCII character names used for tooltips on strings.
const ASCII_NAMES = {
0: "NUL (null)",
1: "SOH (start of heading)",
2: "STX (start of text)",
3: "ETX (end of text)",
4: "EOT (end of transmission)",
5: "ENQ (enquiry)",
6: "ACK (acknowledge)",
7: "BEL (bell)",
8: "BS (backspace)",
9: "TAB (horizontal tab)",
10: "LF (NL line feed, new line)",
11: "VT (vertical tab)",
12: "FF (NP form feed, new page)",
13: "CR (carriage return)",
14: "SO (shift out)",
15: "SI (shift in)",
16: "DLE (data link escape)",
17: "DC1 (device control 1)",
18: "DC2 (device control 2)",
19: "DC3 (device control 3)",
20: "DC4 (device control 4)",
21: "NAK (negative acknowledge)",
22: "SYN (synchronous idle)",
23: "ETB (end of trans. block)",
24: "CAN (cancel)",
25: "EM (end of medium)",
26: "SUB (substitute)",
27: "ESC (escape)",
28: "FS (file separator)",
29: "GS (group separator)",
30: "RS (record separator)",
31: "US (unit separator)",
32: "SPACE",
127: "DEL",
};
// =============================================================================
// ===== TOKENIZING ============================================================
// =============================================================================
// Tokenizes user input.
function tokenize(input) {
const tokens = [];
let currentToken = "";
let mode = "normal";
// True in "string" mode if the current character is escaped by a backslash.
let escape = false;
// Zero-based row and column of the current character. Col counts in bytes,
// so a multibyte character will advance col by > 1.
let row = 0, col = 0;
// {row, col} of the start of the current token.
let start = undefined;
// Pushes a character onto the current token.
function push(c) {
currentToken += c;
}
// Undoes the last push.
function unpush() {
currentToken = currentToken.substring(0, currentToken.length - 1);
}
// Emits the current token.
function emit() {
if (currentToken !== "") {
tokens.push({
text: currentToken,
// The row and col are 0-based. End row is inclusive, col exclusive.
start: {row: start.row, col: start.col},
end: {row, col},
});
}
start = undefined;
currentToken = "";
}
// Updates start to the current row and column, if not already set.
function setStart(c) {
if (c.trim() !== "" && start === undefined) {
start = {row, col};
}
}
// Increments row and col based on c.
function incPos(c) {
col += c.length;
if (c === "\n") {
row++;
col = 0;
}
}
for (const c of input) {
setStart(c);
if (mode === "normal") {
switch (c.trim()) {
case "#":
mode = "comment";
break;
case "\"":
emit();
setStart(c);
push(c);
mode = "string";
break;
case ":": case "{": case "}": case ",":
emit();
setStart(c);
push(c);
incPos(c);
emit();
continue;
case "":
emit();
break;
default:
push(c);
break;
}
} else if (mode === "string") {
if (c === "\n") {
if (escape) {
unpush();
} else {
push("\\n");
}
} else {
push(c);
}
switch (c) {
case "\\":
escape = !escape;
break;
case "\"":
if (!escape) {
incPos(c);
emit();
mode = "normal";
continue;
}
// fallthrough
default:
escape = false;
break;
}
} else if (mode === "comment") {
if (c === "\n") {
mode = "normal";
}
}
incPos(c);
}
emit();
if (mode === "string") {
throw {
token: tokens[tokens.length-1],
message: "unterminated string",
};
}
return tokens;
}
// =============================================================================
// ===== PARSING ===============================================================
// =============================================================================
// Parses a list of tokens.
function parse(tokens) {
let tokenIdx = 0;
// Store previous int and float tokens so that the next one can be inferred to
// have the same signedness and bit width.
let prevInt = null;
let prevFloat = null;
// Consumes a single token. If the argument is provided, checks that the token
// contains the given text and fails otherwise.
function consume(text) {
if (tokenIdx === tokens.length) {
throw {token: tokens[tokenIdx-1], message: "unexpected end of input"};
}
const t = tokens[tokenIdx++];
if (text !== undefined && t.text !== text) {
throw {token: t, message: `expected ${JSON.stringify(text)}`};
}
return t;
}
// Returns true if the first token contains the given text.
function peek(text) {
if (tokenIdx === tokens.length) {
throw {token: tokens[tokenIdx-1], message: "unexpected end of input"};
}
return tokens[tokenIdx].text === text;
}
// Returns true if v1 and v2 have compatible types.
function sameType(v1, v2) {
switch (v1.type) {
case "null":
return v1.type === v2.type || sameType(v2, v1);
case "bool":
return v1.type === v2.type;
case "string":
case "handle":
return v1.type === v2.type || (v2.type === "null" && v2.kind === v1.type);
case "int":
case "float":
return v1.type === v2.type && v1.width === v2.width;
case "struct":
return v1.type === v2.type
&& v1.fields.every((f, i) => sameType(f, v2.fields[i]));
case "array":
return v1.type === v2.type && v1.fields.length === v2.fields.length
// Note: Only check first element even for array/vector since
// validate() checks that all elements are the same.
&& sameType(v1.fields[0], v2.fields[0]);
case "bits":
case "enum":
return v1.type === v2.type && sameType(v1.fields[0], v2.fields[0]);
case "box":
return v1.type === v2.type || (v2.type === "null" && v2.kind === "struct");
case "vector":
return (v1.type === v2.type
&& (v1.fields.length === 0 || v2.fields.length === 0
|| sameType(v1.fields[0], v2.fields[0])))
|| (v2.type === "null" && v2.kind === v1.type);
case "table":
const map = {};
for (const entry of v1.fields) {
map[entry.ordinal] = entry.fields[0];
}
for (const entry of v2.fields) {
const field1 = map[entry.ordinal];
const field2 = entry.fields[0];
if (field1 !== undefined && !sameType(field1, field2)) {
return false;
}
}
case "union":
return v1.type === v2.type &&
(v1.fields.length === 0 || v2.fields.length === 0
|| v1.fields[0].ordinal !== v2.fields[0].ordinal
|| sameType(v1.fields[0], v2.fields[0]));
default:
throw Error(`unexpected type: ${v1.type}`);
}
}
// Validates a parsed FIDL value.
function validate(value) {
// Check number of fields.
switch (value.type) {
// Note: We COULD make bits{} mean 0, but we don't know the width. We
// could infer from the last int, but that means sometimes bits{} is
// allowed and sometimes not, which is confusing.
case "bits":
// Zero-length arrays are disallowed by fidlc.
case "array":
if (value.fields.length === 0) {
throw {
range: value.range,
message: `${value.type} must have at least 1 field; got `
+ `${value.fields.length}`,
};
}
break;
case "box":
case "enum":
case "handle":
case "union":
if (value.fields.length !== 1) {
throw {
range: value.range,
message: `${value.type} must have 1 field; got `
+ `${value.fields.length}`,
};
}
break;
}
// Check underlying types.
switch (value.type) {
case "enum":
if (value.fields[0].type !== "int") {
const field = value.fields[0];
throw {
range: field.range,
message: `${value.type} value must be an int, not ${field.type}`,
};
}
break;
case "bits":
if (value.fields[0].type !== "int" || value.fields[0].signed !== "u") {
const field = value.fields[0];
throw {
range: field.range,
message: `${value.type} value must be a uint, not ${field.type}`,
};
}
break;
case "handle":
if (value.fields[0].type !== "int" || value.fields[0].signed !== "u"
|| value.fields[0].width !== 32) {
const field = value.fields[0];
throw {
range: field.range,
message: `${value.type} value must be an uint32, not ${field.type}`,
};
}
if (value.fields[0].value == 0) {
const field = value.fields[0];
throw {
range: field.range,
message: `${value.type} value must be nonzero; use "null handle" for null`,
};
}
break;
case "box":
if (value.fields[0].type !== "struct") {
throw {
range: value.fields[0].range,
message: `${value.fields[0].type} values cannot be boxed`,
};
}
break;
}
// Check type homogeneity.
switch (value.type) {
case "vector":
case "array":
case "bits":
if (value.fields.length > 0) {
for (let i = 1; i < value.fields.length; i++) {
if (value.fields[0].type !== value.fields[i].type) {
throw {
range: value.fields[i].range,
message: `${value.type} element 0 (${value.fields[0].type}) `
+ `and element ${i} (${value.fields[i].type}) have different `
+ `types`,
};
}
if (!sameType(value.fields[0], value.fields[i])) {
throw {
range: value.fields[i].range,
message: `${value.type} element 0 (${value.fields[0].type}) `
+ `and element ${i} (${value.fields[i].type}) have `
+ `structurally different types`,
};
}
}
}
break;
}
// Check children.
if (value.fields !== undefined) {
for (const field of value.fields) {
validate(field);
}
}
}
// Consumes and parses a FIDL value.
function parseValue() {
const t = consume();
const range = {first: t, last: t};
if (/^"/.test(t.text)) {
let value;
try {
value = JSON.parse(t.text);
} catch (ex) {
throw {token: t, message: "invalid string literal"};
}
return {type: "string", value, range};
}
if (/^-?(?:\d|\.)/.test(t.text)) {
return parseNumber(t);
}
switch (t.text) {
case "true":
return {type: "bool", value: true, range};
case "false":
return {type: "bool", value: false, range};
case "handle":
case "bits":
case "enum":
case "struct":
case "array":
case "vector":
case "box":
return parseAgg(t, false);
case "table":
case "union":
return parseAgg(t, true);
case "null": {
const t2 = consume();
switch (t2.text) {
case "handle": case "string": case "struct": case "union": case "vector":
return {type: "null", kind: t2.text, range: {first: t, last: t2}};
default:
throw {token: t2, message: "expected one of: handle, string, struct, union, vector"};
}
}
}
throw {token: t, message: "invalid syntax"};
}
// Parses an already-consumed number token t.
function parseNumber(t) {
const range = {first: t, last: t};
if (/\./.test(t.text) || (!/x/.test(t.text) && /[eEf]/.test(t.text))) {
const match = t.text.match(/(.*)f(32|64)$/);
let result;
if (match) {
result = {
type: "float",
width: parseInt(match[2]),
orig: match[1],
value: parseFloat(match[1]),
range,
};
} else if (prevFloat) {
result = {...prevFloat, orig: t.text, value: parseFloat(t.text), range};
} else {
throw {token: t, message: "float missing suffix (f32 or f64)"};
}
// Check both because parseFloat can ignore trailing garbage.
if (isNaN(result.orig) || isNaN(result.value)) {
throw {token: t, message: "invalid float"};
}
return prevFloat = result;
}
const match = t.text.match(/(.*)(u|i)(8|16|32|64)$/);
let result;
if (match) {
result = {
type: "int",
signed: match[2],
width: parseInt(match[3]),
orig: match[1],
range,
};
} else if (prevInt) {
result = {...prevInt, orig: t.text, range};
} else {
throw {token: t, message: "int missing suffix (u8, i8, u16, i16, etc.)"};
}
try {
result.value = BigInt(result.orig);
} catch (ex) {
throw {token: t, message: `invalid ${result.signed}int${result.width}`};
}
if (result.signed === "u") {
if (result.value < 0) {
throw {token: t, message: `uint${result.width} cannot be negative`}
}
if (result.value >= 2n ** BigInt(result.width)) {
throw {token: t, message: `uint${result.width} out of range`}
}
} else {
const limit = 2n ** BigInt(result.width - 1);
if (result.value < -limit || result.value >= limit) {
throw {token: t, message: `int${result.width} out of range`}
}
}
return prevInt = result;
}
// Parses an aggregate structure whose keyword token (struct, table, etc.) has
// already been consumed. If ordinals is true, parses ordinals before each
// field and stores "$entry" nodes in the fields.
function parseAgg(keyword, ordinals) {
consume("{");
const fields = [];
while (!peek("}")) {
if (ordinals) {
const t = consume();
const ordinal = parseInt(t.text, 10);
// Check isNaN on both because parseInt can ignore trailing garbage.
if (isNaN(t.text) || isNaN(ordinal)) {
throw {token: t, message: "expected an integer ordinal"};
}
if (ordinal <= 0) {
throw {token: t, message: "ordinals must be positive"};
}
consume(":");
const value = parseValue();
fields.push({
// $entry is a special parse tree node that stores the ordinal. We use
// dollar signs for special nodes that are not printed literally. In
// the case of $entry, format() prints it with the colon syntax.
type: "$entry",
ordinal,
fields: [value],
range: {first: t, last: value.range.last},
});
} else {
fields.push(parseValue());
}
if (!peek("}")) {
consume(",");
}
}
return {
type: keyword.text,
fields,
range: {first: keyword, last: consume("}")},
};
}
const value = parseValue();
if (tokenIdx !== tokens.length) {
throw {
range: {first: tokens[tokenIdx], last: tokens[tokens.length-1]},
message: "extraneous input",
};
}
validate(value);
return value;
}
// =============================================================================
// ===== FORMATTING ============================================================
// =============================================================================
// Formats a parse tree back to the input syntax. Also mutates the nodes, adding
// outputRange properties of the form {start: {row, col}, end: {row, col}}.
function format(tree) {
let result = "";
// Zero-based row and column of the current position in result. Col counts in
// bytes, so a multibyte character will advance col by > 1.
let row = 0, col = 0;
// Keep track of secondary objects in order to number them in comments.
let secondaryObjectNumber = 1;
function push(text) {
result += text;
col += text.length;
}
function newline() {
result += "\n";
row++;
col = 0;
}
function commentHeader(msg, first /* = false */) {
if (!first) {
newline();
newline();
}
push(`# ${msg}`);
newline();
}
// Returns true if node should be displayed in a one liner between braces.
function isOneLiner(node) {
switch (node.type) {
case "bool":
case "$pointer":
return true;
case "string":
return node.value.length < 50;
case "int":
case "float":
return node.orig.length < 50;
case "$entry":
return isOneLiner(node.fields[0]);
case "struct":
return isPointerAndOut(node);
default:
return false;
}
}
// Returns true if node is a {$pointer, $out} struct lowered from a box.
function isPointerAndOut(node) {
return node.type === "struct" && node.fields.length === 2
&& node.fields[0].type === "$pointer"
&& node.fields[1].type === "$out";
}
// Returns true if the tree has any out-of-line blocks.
function hasOutOfLine(node) {
return node.type === "$out" || (node.fields && node.fields.some(hasOutOfLine));
}
// Helper function that keeps track of the current indentation level.
function helper(node, indent, topLevelObject) {
const start = {row, col};
// The thunks in this function work the same as in formatBytes(). They might
// be easier to understand in that function because it is shorter.
const thunks = [];
switch (node.type) {
case "null":
push(`null ${node.kind}`);
break;
case "$pointer":
push(node.value);
break;
case "bool":
case "string":
push(JSON.stringify(node.value));
break;
case "int":
push(node.orig + node.signed + node.width);
break;
case "float":
push(node.orig + "f" + node.width);
break;
case "$entry":
push(node.ordinal + ": ");
helper(node.fields[0], indent);
break;
case "handle":
case "bits":
case "struct":
case "array":
case "vector":
case "enum":
case "envelope":
case "box":
case "table":
case "union": {
if (node.fields.length === 0) {
push(node.type + " {}");
} else if (node.fields.length === 1 && isOneLiner(node.fields[0])) {
push(node.type + " { ");
thunks.push(helper(node.fields[0], 0, /* topLevelObject = */ false));
push(" }");
} else if (isPointerAndOut(node)) {
// Handle this specially because we don't want to wrap the "present"
// in a struct. We just want to emit "present" and add the out-of-line
// thunk to be emitting later.
const pointer = node.fields[0];
const out = node.fields[1];
helper(pointer, indent, topLevelObject);
thunks.push(helper(out, indent, topLevelObject));
} else {
push(node.type + " {");
newline();
for (const field of node.fields) {
push(INDENT.repeat(indent + 1));
thunks.push(helper(field, indent + 1, /* topLevelObject = */ false));
push(",");
newline();
}
push(INDENT.repeat(indent) + "}");
}
break;
}
case "$out":
// Don't emit anything yet. Return a thunk that will emit the
// out-of-line object after the inline portion is done.
return function() {
commentHeader(`Secondary object ${secondaryObjectNumber}`);
secondaryObjectNumber++;
helper(node.fields[0], 0, /* topLevelObject = */ true);
const ranges = node.fields[0].outputRanges;
node.outputRanges = ranges;
return ranges;
};
default:
throw Error(`unexpected type: ${node.type}`);
}
// outputRanges is a list to allow for multiple noncontiguous regions.
// This is used for bytes (inline and out-of-line). For format(), there
// is always only 1 output range per node.
node.outputRanges = [{start, end: {row, col}}];
const flushOutOfLine = () => {
let combined = [];
for (thunk of thunks) {
const ranges = thunk();
if (ranges !== undefined) {
node.outputRanges.push(...ranges);
combined.push(...ranges);
}
}
return combined;
};
if (topLevelObject) {
flushOutOfLine();
return function() {}
}
return flushOutOfLine;
}
if (hasOutOfLine(tree)) {
commentHeader("Primary object", /* first = */ true);
}
helper(tree, 0, /* topLevelObject = */ true);
return result;
}
// Format the bytes tree as hex bytes.
function formatBytes(tree) {
let result = "";
let row = 0, col = 0, byteIndex = 0;
const rowLengths = [];
function emitByte(byte) {
result += byte.toString(16).padStart(2, "0");
col += 2;
// NOTE: Keep this (8) in sync with output editor gutter renderer, which
// replaces line numbers with offsets 0, 8, 16, etc.
if (byteIndex % 8 == 7) {
result += "\n";
rowLengths.push(col);
col = 0;
row++;
} else {
result += " ";
col++;
}
byteIndex++;
}
// Get the position just before pos, to avoid including whitespace in ranges.
function justBefore({row, col}) {
if (col > 0) {
return {row, col: col - 1};
}
return {row: row - 1, col: rowLengths[row - 1]};
}
// Get the position just after pos, to avoid including whitespace in ranges.
function justAfter({row, col}) {
if (col < rowLengths[row]) {
return {row, col: col + 1};
}
return {row: row + 1, col: 0};
}
// Executes f on all padding nodes below node in DFS order.
function foreachInlinePadding(node, f) {
switch (node.type) {
case "bytes":
if (node.padding) {
f(node);
}
break;
case "inline":
for (const field of node.fields) {
foreachInlinePadding(field, f);
}
break;
}
}
function helper(node, topLevelObject) {
const start = {row, col};
switch (node.type) {
case "bytes":
node.bytes.forEach(emitByte);
node.outputRanges = [{start, end: justBefore({row, col})}];
return function() {}
case "inline":
const thunks = node.fields.map(f => helper(f, false));
node.outputRanges = [{start, end: justBefore({row, col})}];
// Remove padding regions from the node's ouput ranges, so that we never
// highlight padding.
foreachInlinePadding(node, paddingNode => {
const lastRange = node.outputRanges[node.outputRanges.length-1];
const {start: padStart, end: padEnd} = paddingNode.outputRanges[0];
const oldEnd = lastRange.end;
lastRange.end = justBefore(padStart);
node.outputRanges.push({start: justAfter(padEnd), end: oldEnd});
});
const flushOutOfLine = () => {
let combined = [];
for (thunk of thunks) {
const ranges = thunk();
if (ranges !== undefined) {
node.outputRanges.push(...ranges);
combined.push(...ranges);
}
}
return combined;
};
if (topLevelObject) {
flushOutOfLine();
return function() {}
}
return flushOutOfLine;
case "secondary":
// For secondary objects, don't encode anything yet. Instead, return a
// thunk to be executed after the inline portion of the last object has
// been fully encoded.
return function() {
helper(node.fields[0], /* topLevelObject = */ true);
const ranges = node.fields[0].outputRanges;
node.outputRanges = ranges;
return ranges;
};
default:
throw Error(`unexpected type in formatBytes: ${node.type}`);
}
}
helper(tree, /* topLevelObject = */ true);
return result;
}
// =============================================================================
// ===== LOWERING ==============================================================
// =============================================================================
// Global flag to disable adding "lowered" fields. This is an ugly hack.
let disableAddLowered = false;
// Adds lowered to node's list of lowered nodes, creating the list if necessary.
// Also adds links in the reverse direction.
function addLowered(node, lowered) {
if (disableAddLowered) {
return;
}
if (node.lowered === undefined) {
node.lowered = [];
}
node.lowered.push(lowered);
// If there is already a loweredFrom, overwrite it. Usually this is when a
// lowering stage copies a whole node (including its loweredFrom) field, and
// then calls addLowered. Ocassionally there genuinely are multiple things it
// was loweredFrom, like bits/enums -- the whole structure and the value
// inside all lower the same output. In these cases letting the last
// addLowered call win works -- it will be the largest structure, the whole
// bits/enum, which is what we want for reverse highlighting (otherwise if the
// bits has multiple fields, it will only highlight one of them).
lowered.loweredFrom = node;
}
// Creates a note/tooltip, incorporating an old one if present.
function addNote(oldNode, additional) {
return oldNode.note === undefined
? additional : `${oldNode.note}\n\n${additional}`;
}
// Creates a note/tooltip, but does not replace an existing one.
function provideNote(oldNode, newNote) {
return oldNode.note === undefined
? newNote : oldNode.note;
}
// Helper functions for English messages.
function plural(n) { return n === 1 ? "" : "s"; }
function article(n) { return n == 8 ? "an" : "a"; }
// Copies a tree node, omitting metadata that should not be copied.
function copyNode(node) {
return {
...node,
lowered: undefined,
loweredFrom: undefined,
};
}
// Helper combinator for lowering.
function doLowering(tree, self, helper) {
function augmentedHelper(node) {
const result = helper(node);
if (result !== undefined) {
// Reapply self. A proper lowering should not get into infinite recursion.
return self(result);
}
if ("fields" in node) {
return {...copyNode(node), fields: node.fields.map(self)};
}
// Very important that this copies the node rather than returning the same
// object. Otherwise highlighting gets into infinite recursion.
return {...copyNode(node)};
}
const lowered = augmentedHelper(tree);
addLowered(tree, lowered);
return lowered;
}
// Lowering stage 1: tables and unions.
function lower1(tree) {
return doLowering(tree, lower1, node => {
switch (node.type) {
case "table": {
if (node.fields.length === 0) {
return {
type: "vector",
fields: [],
note: addNote(node,
`Empty table lowers to an empty vector`),
};
}
const maxOrdinal =
node.fields.map(f => f.ordinal).reduce((a, b) => Math.max(a, b));
const ordinalToEntry = {};
for (const field of node.fields) {
ordinalToEntry[field.ordinal] = field;
}
const fields = [];
for (let i = 1; i <= maxOrdinal; i++) {
const entry = ordinalToEntry[i];
if (entry === undefined) {
const empty = {
type: "envelope",
fields: [],
note: `Empty envelope for omitted table field #${i}`,
};
fields.push(empty);
} else {
const present = {
type: "envelope",
fields: [entry.fields[0]],
note: `Envelope for table field #${i}`,
};
fields.push(present);
addLowered(entry, present);
}
}
return {
type: "vector",
fields,
note: addNote(node, `Table lowers to a vector of envelopes`),
};
}
case "null": {
if (node.kind !== "union") {
break;
}
return {
type: "struct",
fields: [
{
type: "int", signed: "u", width: 64,
orig: "0", value: 0,
note: "Zero ordinal for null union",
},
{
type: "envelope",
fields: [],
note: "Empty envelope for null union",
}
],
note: addNote(node,
`Null union lowers to ordinal 0 and an empty envelope`),
};
}
case "union": {
const entry = node.fields[0];
const ordinal = entry.ordinal;
const envelope = {
type: "envelope",
fields: [entry.fields[0]],
note: `Envelope wrapping union variant #${ordinal}`,
};
addLowered(entry, envelope);
return {
type: "struct",
fields: [
{
type: "int", signed: "u", width: 64,
orig: ordinal.toString(), value: ordinal,
note: `Union ordinal specifying variant #${ordinal}`,
},
envelope,
],
note: addNote(node,
`Union lowers to an ordinal (${ordinal}) and envelope`),
};
}
}
});
}
// Lowering stage 2: bits and enums. Assumes earlier lowerings are done.
function lower2(tree) {
return doLowering(tree, lower2, node => {
switch (node.type) {
case "bits": {
const type = (node.fields[0].signed === "u" ? "uint" : "int")
+ node.fields[0].width.toString();
const value = node.fields.map(f => f.value).reduce((a, b) => a | b);
const orig = value == 0 ? "0" : "0x" + value.toString(16);
let valueStr = orig;
if (node.fields.length > 1) {
valueStr = node.fields.map(f => "0x" + f.value.toString(16)).join(" | ") + ` = ${orig}`;
}
const result = {
...copyNode(node.fields[0]),
orig,
value,
note: addNote(node, `Bits ${valueStr} : ${type}`),
};
for (const field of node.fields) {
addLowered(field, result);
}
return result;
}
case "enum": {
const type = (node.fields[0].signed === "u" ? "uint" : "int")
+ node.fields[0].width.toString();
const result = {
...copyNode(node.fields[0]),
note: addNote(node,
`Enum ${node.fields[0].value} : ${type}`),
};
addLowered(node.fields[0], result);
return result;
}
}
});
}
// Lowering stage 3: strings. Assumes earlier lowerings are done.
function lower3(tree) {
return doLowering(tree, lower3, node => {
switch (node.type) {
case "null": {
if (node.kind !== "string") {
break;
}
return {
type: "null",
kind: "vector",
note: addNote(node, `Null string lowers to a null vector`),
};
}
case "string": {
const fields = [];
const u8 = (byte, extra) => ({
type: "int", signed: "u", width: 8,
orig: "0x" + byte.toString(16), value: byte,
...extra,
});
// Iterate over Unicode characters.
for (const char of node.value) {
// Trick to convert to UTF-8: https://stackoverflow.com/a/18729536
const utf8 = unescape(encodeURIComponent(char));
if (utf8.length === 1) {
const byte = utf8.charCodeAt(0);
const note = `Character: ${ASCII_NAMES[byte] || String.fromCharCode(byte)}`;
fields.push(u8(byte, {note}));
} else {
for (let i = 0; i < utf8.length; i++) {
fields.push(u8(utf8.charCodeAt(i), {
note: `Character: ${char} (byte ${i+1} of ${utf8.length})`,
}));
}
}
}
let displayStr;
if (node.value.length > 100) {
displayStr = JSON.stringify(node.value.substring(0, 100) + "…")
+ " (truncated)";
} else {
displayStr = JSON.stringify(node.value);
}
return {
type: "vector",
fields,
note: addNote(node, `String ${displayStr}`),
}
}
}
});
}
// Lowering stage 4: vectors. Assumes earlier lowerings are done.
function lower4(tree) {
return doLowering(tree, lower4, node => {
switch (node.type) {
case "null": {
if (node.kind !== "vector") {
break;
}
return {
type: "struct",
fields: [
{
type: "int", signed: "u", width: 64,
orig: "0", value: 0,
note: `Vector length: 0 elements (null vector)`,
},
{
type: "$pointer",
value: "absent",
note: `ABSENT pointer for null vector`,
},
],
note: addNote(node,
`Null vector lowers to length 0 and ABSENT pointer`),
};
}
case "vector": {
const len = node.fields.length;
const lengthNode = {
type: "int", signed: "u", width: 64,
orig: len.toString(), value: len,
note: `Vector length: ${len} elements`,
};
if (len === 0) {
return {
type: "struct",
fields: [
lengthNode,
{
type: "$pointer",
value: "present",
note: `Pointer to empty vector's out-of-line array; PRESENT `
+ `even though there is no array, to distinguish from null vectors`,
},
],
note: addNote(node,
`Vector lowers to a length (${len}) and an out-of-line array`),
};
}
return {
type: "struct",
fields: [
lengthNode,
{
type: "box",
fields: [{
type: "array",
fields: node.fields,
note: `Vector's out-of-line array`,
}],
note: `Pointer to vector's out-of-line array`,
},
],
note: addNote(node,
`Vector lowers to a length (${len}) and an out-of-line array`),
};
}
}
});
}
// Lowering stage 5: handles and envelopes. Assumes earlier lowerings are done.
function lower5(tree) {
function countPresentHandles(node) {
if (node.type === "handle") {
return 1;
}
if (node.fields !== undefined) {
let sum = 0;
for (const f of node.fields) {
sum += countPresentHandles(f);
}
return sum;
}
return 0;
}
return doLowering(tree, lower5, node => {
switch (node.type) {
case "null": {
if (node.kind !== "handle") {
break;
}
return {
type: "int", signed: "u", width: 32, orig: "0", value: 0,
note: addNote(node, `Null handle`),
};
}
case "handle": {
const result = {
...copyNode(node.fields[0]),
type: "int", signed: "u", width: 32, orig: "0xffffffff", value: 0xffffffff,
note: addNote(node, `Pointer to handle ${node.fields[0].value} (stored out-of-band in the handle vector)`),
};
addLowered(node.fields[0], result);
return result;
}
case "envelope": {
if (node.fields.length === 0) {
return {
type: "struct",
fields: [
{type: "int", signed: "u", width: 32, orig: "0", value: 0,
note: "Empty envelope: 0 bytes"},
{type: "int", signed: "u", width: 32, orig: "0", value: 0,
note: "Empty envelope: 0 handles"},
{type: "$pointer", value: "absent",
note: "ABSENT pointer for empty envelope"},
],
note: node.note,
};
}
disableAddLowered = true;
const bytes = countTotalBytes(lowerBytes(lower6(lower5(node.fields[0]))));
disableAddLowered = false;
const handles = countPresentHandles(node.fields[0]);
return {
type: "struct",
fields: [
{type: "int", signed: "u", width: 32, orig: bytes.toString(), value: bytes,
note: `Envelope: ${bytes} byte${plural(bytes)}`},
{type: "int", signed: "u", width: 32, orig: handles.toString(), value: handles,
note: `Envelope: ${handles} handle${plural(handles)}`},
{type: "box", fields: node.fields,
note: "Pointer to the envelope's out-of-line data"},
],
note: node.note,
};
}
}
});
}
// Lowering stage 6: boxes. Assumes earlier lowerings are done.
function lower6(tree) {
return doLowering(tree, lower6, node => {
switch (node.type) {
case "null": {
if (node.kind !== "struct") {
break;
}
return {
type: "$pointer",
value: "absent",
note: addNote(node, `ABSENT pointer for null struct`),
};
}
case "array": {
return {
...copyNode(node),
type: "struct",
note: addNote(node, `Array lowers to a struct where all fields have `
+ `the same type`),
};
}
case "box": {
return {
type: "struct",
fields: [
{
type: "$pointer",
value: "present",
note: provideNote(node, `Pointer to out-of-line struct`),
},
{
type: "$out",
fields: node.fields,
}
],
};
}
}
});
}
// Final lowering stage, to bytes. Assumes all earlier lowerings are done.
function lowerBytes(tree) {
// object can be "primary", "secondary", or false.
function lower(tree, object) {
const lowered = helper(tree, object);
addLowered(tree, lowered);
return lowered;
}
function helper(node, object) {
if (object && node.type !== "struct") {
// Instead of handling the 8-byte padding in every branch, we just handle
// it in the struct case, and wrap other things in structs.
node = {type: "struct", fields: [node]};
}
switch (node.type) {
case "int": {
const signed = node.signed === "i";
const type = signed ? `int${node.width}` : `uint${node.width}`;
const arrayType = signed
? {8: Int8Array, 16: Int16Array, 32: Int32Array, 64: BigInt64Array}[node.width]
: {8: Uint8Array, 16: Uint16Array, 32: Uint32Array, 64: BigUint64Array}[node.width];
const value = node.width === 64 ? BigInt(node.value) : Number(node.value);
const size = node.width / 8;
return {
type: "bytes",
bytes: new Uint8Array(arrayType.of(value).buffer),
size,
align: size,
note: provideNote(node, `${node.value} : ${type}`),
};
}
case "float": {
const type = `float${node.width}`;
const single = node.width === 32;
const arrayType = single ? Float32Array : Float64Array;
const size = node.width / 8;
return {
type: "bytes",
bytes: new Uint8Array(arrayType.of(node.value).buffer),
size,
align: size,
note: provideNote(node, `${node.value} : ${type}`),
};
}
case "bool": {
const value = node.value ? 1 : 0;
return {
type: "bytes",
bytes: Uint8Array.of(value),
size: 1,
align: 1,
note: provideNote(node, `${node.value} : bool`),
};
}
case "$pointer": {
let bytes;
switch (node.value) {
case "absent":
bytes = Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0);
break;
case "present":
bytes = Uint8Array.of(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff);
break;
default:
throw new Error(`invalid $pointer value: ${node.value}`);
}
return {
type: "bytes",
bytes,
size: 8,
align: 8,
note: node.note,
};
}
case "$out": {
return {
type: "secondary",
fields: [lower(node.fields[0], "secondary")],
};
}
case "struct": {
const fields = [];
let size = 0, align = 0;
// Helper to add padding.
function padToAlign(desiredAlign, message) {
align = Math.max(align, desiredAlign);
if (size % desiredAlign === 0) {
return;
}
const pad = desiredAlign - size % desiredAlign;
fields.push({
type: "bytes",
bytes: new Uint8Array(pad),
size: pad,
align: 1,
padding: true,
note: message(pad),
});
size += pad;
}
if (node.fields.length === 0) {
size = 1;
align = 1;
fields.push({
type: "bytes",
bytes: Uint8Array.of(0),
size,
align,
note: `An empty struct encodes to the byte 0x00.`,
});
}
for (const originalField of node.fields) {
const f = lower(originalField, false);
if (f.type === "secondary") {
fields.push(f);
} else {
padToAlign(f.align, pad => `${pad} byte${plural(pad)} padding `
+ `to align next field to ${article(f.align)} ${f.align}-byte boundary`);
// Note: We used to flatten nested inline fields here, but not
// anymore. Instead, functions like formatBytes() and
// countTotalBytes() were rewritten to allow for nested inlines.
fields.push(f);
size += f.size;
}
}
padToAlign(align, pad => `${pad} byte${plural(pad)} tail padding for `
+ `struct's ${pad}-byte alignment`);
if (object) {
padToAlign(8, pad => `${pad} byte${plural(pad)} tail padding for `
+ `${object} object ${align}-byte alignment`);
}
return {
type: "inline",
size,
align,
fields,
}
}
}
throw Error(`Unexpected type in lowerBytes: ${node.type}`);
}
return lower(tree, "primary");
}
// Calculates the total number of bytes that tree (returned by lowerBytes) will
// serialize to. This is distinct from tree.size, which is only the inline size
// (i.e. the size of the primary object).
function countTotalBytes(tree) {
let size = 0;
function visit(node, topLevelObject) {
switch (node.type) {
case "bytes":
// Do nothing: size is already rolled up into parent "inline" node.
break;
case "inline":
if (topLevelObject) {
size += node.size;
}
for (const field of node.fields) {
visit(field, /* topLevelObject = */ false);
}
break;
case "secondary":
visit(node.fields[0], /* topLevelObject = */ true);
break;
default:
throw Error(`Unexpected type in countTotalBytes: ${node.type}`);
}
}
visit(tree, /* topLevelObject = */ true);
return size;
}
// Applies lowering functions from...to inclusive.
// 7 means lowerBytes.
function lowerFromTo(tree, from, to) {
const lowerings = [
lower1, lower2, lower3, lower4, lower5, lower6, lowerBytes
];
for (let i = from - 1; i < to; i++) {
tree = lowerings[i](tree);
}
return tree;
}
// =============================================================================
// ===== ACE CUSTOMIZATION =====================================================
// =============================================================================
// Custom highlighting for Ace editor.
ace.define("ace/mode/fidlviz",
["require", "exports", "ace/lib/oop", "ace/mode/text", "ace/mode/fidlviz_highlight_rules",
"ace/mode/behaviour/cstyle", "ace/mode/folding/cstyle", "ace/mode/matching_brace_outdent"],
(require, exports) => {
const oop = require("ace/lib/oop");
const TextMode = require("ace/mode/text").Mode;
const FidlvizHighlightRules = require("ace/mode/fidlviz_highlight_rules").FidlvizHighlightRules;
const MatchingBraceOutdent = require("ace/mode/matching_brace_outdent").MatchingBraceOutdent;
const CstyleBehaviour = require("ace/mode/behaviour/cstyle").CstyleBehaviour;
const CstyleFoldMode = require("ace/mode/folding/cstyle").FoldMode;
const FidlvizMode = function FidlvizMode() {
this.$id = "ace/mode/fidlviz";
this.HighlightRules = FidlvizHighlightRules;
this.$outdent = new MatchingBraceOutdent();
this.$behaviour = new CstyleBehaviour();
this.foldingRules = new CstyleFoldMode();
this.lineCommentStart = "#";
// Adapted from:
// https://fuchsia.googlesource.com/fidlbolt/+/refs/heads/master/frontend/src/ace-modes.js#102
this.getNextLineIndent = (state, line, tab) => {
let indent = line.match(/^\s*/)[0];
const tokens = this.getTokenizer().getLineTokens(line, state).tokens;
if (tokens.length && tokens[tokens.length - 1].type === "comment") {
return indent;
}
if (state === "start") {
const match = line.match(/^.*\{\s*$/);
if (match) {
indent += tab;
}
}
return indent;
}
this.checkOutdent = (state, line, input) => {
return this.$outdent.checkOutdent(line, input);
}
this.autoOutdent = (state, doc, row) => {
this.$outdent.autoOutdent(doc, row);
}
};
oop.inherits(FidlvizMode, TextMode);
exports.Mode = FidlvizMode;
});
ace.define("ace/mode/fidlviz_highlight_rules",
["require", "exports", "ace/lib/oop", "ace/mode/text_highlight_rules"],
(require, exports) => {
const oop = require("ace/lib/oop");
const TextHighlightRules = require("ace/mode/text_highlight_rules").TextHighlightRules;
const FidlvizHighlightRules = function FidlvizHighlightRules() {
this.$rules = {
start: [
{
token: "comment",
regex: "#.*$",
},
],
};
};
oop.inherits(FidlvizHighlightRules, TextHighlightRules);
exports.FidlvizHighlightRules = FidlvizHighlightRules;
});
// Custom theme for Ace editor.
ace.define("ace/theme/fidlviz_light", ["require", "exports", "ace/lib/dom"], (require, exports) => {
exports.isDark = false;
exports.cssClass = "ace-fl";
exports.cssText = `
.ace-fl {
background: #fff;
color: #111;
}
.ace-fl .ace_gutter {
background: #eee;
color: #666;
}
.ace-fl .ace_comment {
color: #777;
}
.ace-fl .ace_selection {
background: #d0d0d0;
}
.ace-fl .ace_gutter-active-line {
background: #e0e0e0;
}
.ace-fl .ace_active-line {
background: rgba(0, 0, 0, 0.07);
}
`;
exports.$id = "ace/theme/fidlviz_light";
const dom = require("ace/lib/dom");
dom.importCssString(exports.cssText, exports.cssClass);
});
ace.define("ace/theme/fidlviz_dark", ["require", "exports", "ace/lib/dom"], (require, exports) => {
exports.isDark = false;
exports.cssClass = "ace-fd";
exports.cssText = `
.ace-fd {
background: #303030;
color: #eee;
}
.ace-fd .ace_gutter {
background: #404040;
color: #aaa;
}
.ace-fd .ace_comment {
color: #b0b0b0;
}
.ace-fd .ace_selection {
background: #777;
}
.ace-fd .ace_gutter-active-line {
background: #505050;
}
.ace-fd .ace_active-line {
background: rgba(255, 255, 255, 0.07);
}
`;
exports.$id = "ace/theme/fidlviz_dark";
const dom = require("ace/lib/dom");
dom.importCssString(exports.cssText, exports.cssClass);
});
// =============================================================================
// ===== DEFAULT INPUT =========================================================
// =============================================================================
// Input shown when the user has nothing saved (or after clicking Reset).
const DEFAULT_INPUT = `\
struct {
# Integers
0u8, -1i8, 2u16, 3u32, 4u64,
# Other bases
0b1010u8, 0xffu8, 0o755u16,
# Floats
1.2f32, 1e-8f64,
# Booleans
true, false,
# Strings
"Hello 🌎\\u0021",
null string, "",
# Handles
null handle, handle { 0xBEEFu32 },
# Arrays and vectors
array { 12u8, 223, 0xff }, # all u8
vector { 1.0f64, 2., .3 }, # all f64
null vector, vector {},
# Bits and enums
bits { 1, 2, 4 }, enum { 42 },
# Structs
struct {}, struct { 1u64 },
null struct, box { struct { 42 } },
# Tables
table {}, table { 1: true, 3: false },
# Unions
null union, union { 7: "hi" },
}
`;
// =============================================================================
// ===== MAIN UI CODE ==========================================================
// =============================================================================
// Main function called after the window loads.
function main() {
// ---------------------------------------------------------------------------
// ----- ACE CONFIG ----------------------------------------------------------
// ---------------------------------------------------------------------------
const inputConfig = {
mode: "ace/mode/fidlviz",
theme: getTheme(window.matchMedia
&& window.matchMedia("(prefers-color-scheme: dark)").matches),
fontSize: 14,
readOnly: false,
tabSize: 2,
useSoftTabs: true,
indentedSoftWrap: false,
scrollPastEnd: 1,
showGutter: true,
showLineNumbers: true,
showPrintMargin: false,
};
const outputConfig = {
...inputConfig,
readOnly: true,
highlightActiveLine: false,
};
// ---------------------------------------------------------------------------
// ----- DOM ELEMENTS --------------------------------------------------------
// ---------------------------------------------------------------------------
const $inputEditor = ace.edit("InputEditor", inputConfig);
const $outputEditor = ace.edit("OutputEditor", outputConfig);
const $outputSlider = document.getElementById("OutputSlider");
const $outputTitle = document.getElementById("OutputTitle");
const $outputTooltip = document.getElementById("OutputTooltip");
const $resetButton = document.getElementById("ResetButton");
const $debugButton = document.getElementById("DebugButton");
const $layoutButton = document.getElementById("LayoutButton");
const $helpButton = document.getElementById("HelpButton");
const $helpModal = document.getElementById("HelpModal");
const $helpModalContent = document.getElementById("HelpModalContent");
const $container = document.getElementById("Container");
const $inputPart = document.getElementById("InputPart");
const $splitContainer = document.getElementById("SplitContainer");
const $splitter = document.getElementById("Splitter");
for (editor of [$inputEditor, $outputEditor]) {
// We use Ctrl-[ and Ctrl-] to move the slider.
editor.commands.removeCommand('blockindent');
editor.commands.removeCommand('blockoutdent');
}
// ---------------------------------------------------------------------------
// ----- DARK MODE -----------------------------------------------------------
// ---------------------------------------------------------------------------
// Gets the Ace theme depending on whether dark mode is enabled.
function getTheme(darkMode) {
return darkMode ? "ace/theme/fidlviz_dark" : "ace/theme/fidlviz_light";
}
if (window.matchMedia) {
window.matchMedia("(prefers-color-scheme: dark)").addListener(e => {
const theme = getTheme(e.matches);
$inputEditor.setTheme(theme);
$outputEditor.setTheme(theme);
});
}
// ---------------------------------------------------------------------------
// ----- UI HELPERS ----------------------------------------------------------
// ---------------------------------------------------------------------------
// Vertical (left & right) or Horizontal (top & bottom).
let editorSplitOrientation;
function setEditorSplitOrientation(orientation) {
function toggle(o) {
return o === "vertical" ? "horizontal" : "vertical";
}
if (orientation === "toggle") {
orientation = toggle(editorSplitOrientation);
}
editorSplitOrientation = orientation;
const other = toggle(orientation);
$splitter.classList.remove(`split-splitter--${other}`);
$splitter.classList.add(`split-splitter--${orientation}`);
$splitContainer.classList.remove(`split-container--${other}`);
$splitContainer.classList.add(`split-container--${orientation}`);
resizeEditors(editorSplitPosition);
window.localStorage.editorSplitOrientation = orientation;
}
// Keep in sync with the output slider in index.html.
const LAST_DEBUG_OUTPUT_MODE = "3";
const LAST_OUTPUT_MODE = "8";
// In Debug mode, the output shows tokens or parse trees.
let debugEnabled;
let outputModeWhenDebugIs;
function setDebugEnabled(enabled, restoring) {
debugEnabled = !!enabled;
if (debugEnabled) {
$debugButton.classList.add("button--active");
$outputSlider.max = LAST_DEBUG_OUTPUT_MODE;
} else {
$debugButton.classList.remove("button--active");
$outputSlider.max = LAST_OUTPUT_MODE;
}
// Skip this at very beginning when called from restore().
if (restoring === undefined) {
setOuputMode(outputModeWhenDebugIs[debugEnabled]);
}
window.localStorage.debugEnabled = debugEnabled;
}
// Show byte offsets instead of line numbers when viewing bytes.
let currentOutputMode = undefined;
$outputEditor.session.gutterRenderer = {
getWidth: function(_session, lastLineNumber, config) {
// Note: lastLineNumber is a value returned by getText(), so it has
// already been multiplied by 8 if necessary.
return lastLineNumber.toString().length * config.characterWidth;
},
getText: function(_session, row) {
if (currentOutputMode === LAST_OUTPUT_MODE && !state.error) {
return row * 8;
}
return (row + 1).toString();
}
};
// Updates output slider and title, but not the editors.
function setOuputMode(mode) {
$outputSlider.value = mode;
$outputTitle.innerHTML = getTitle(mode);
outputModeWhenDebugIs[debugEnabled] = mode;
window.localStorage.outputModeWhenDebugIs = JSON.stringify(outputModeWhenDebugIs);
// Do a full update whenever the line number format needs to change.
if ((currentOutputMode === LAST_OUTPUT_MODE) !== (mode === LAST_OUTPUT_MODE)) {
$outputEditor.renderer.updateFull();
}
currentOutputMode = mode;
}
// Number from 0 to 1 (0.5 means split halfway).
let editorSplitPosition;
// Applies styling to resize the editors to a split defined by the position
// from 0 to 1 (0.5 means each editor takes up half the space).
function resizeEditors(position) {
// 4px is half the splitter thickness, 8px.
$inputPart.style.flexBasis = `calc(${position * 100}% - 4px)`;
if (editorSplitOrientation === "vertical") {
$inputPart.style.maxWidth = `calc(100% - 8px)`;
$inputPart.style.maxHeight = "none";
} else {
$inputPart.style.maxHeight = `calc(100% - 8px)`;
$inputPart.style.maxWidth = "none";
}
editorSplitPosition = position;
window.localStorage.editorPosition = JSON.stringify(position);
// Let Ace editors know their parent has resized.
$inputEditor.resize();
$outputEditor.resize();
}
// Converts a mode (slider value) to a title.
function getTitle(mode) {
if (debugEnabled) {
switch (mode) {
case "1":
return "Tokens";
case "2":
return "Annotated Tokens";
case "3":
return "Parse tree";
}
}
switch (mode) {
case "1":
return "Format";
case "2":
return "[1] Lower tables & unions";
case "3":
return "[2] Lower bits & enums";
case "4":
return "[3] Lower strings";
case "5":
return "[4] Lower vectors";
case "6":
return "[5] Lower handles & envelopes";
case "7":
return "[6] Lower arrays & boxes";
case "8":
return "Bytes";
}
}
// ---------------------------------------------------------------------------
// ----- UTILITIES --------------------------------------------------------------
// ---------------------------------------------------------------------------
// Converts an object to a pretty-printed JSON string.
function stringify(obj) {
return JSON.stringify(
obj,
(_key, value) => typeof value === "bigint" ? value.toString() : value,
INDENT
);
};
// Wraps text to the given maximum width, preserving existing newlines.
// https://fuchsia.googlesource.com/fidlbolt/+/refs/heads/master/frontend/src/util.ts#16
function wordWrap(text, width) {
function wrap(line) {
const words = line.split(' ');
let lineLength = 0;
const withNewlines = words.map(w => {
lineLength += w.length + 1;
if (lineLength > width) {
lineLength = w.length;
return '\n' + w;
}
return w;
});
return withNewlines.join(' ');
}
const lines = text.split('\n');
return lines.map(wrap).join('\n');
}
// Returns true if r1 and r2 represent the same output editor range.
function outputRangesEqual(r1, r2) {
return r1.start.row === r2.start.row
&& r1.start.col === r2.start.col
&& r1.end.row === r2.end.row
&& r1.end.col === r2.end.col;
}
// ---------------------------------------------------------------------------
// ----- MAIN UI UPDATES -----------------------------------------------------
// ---------------------------------------------------------------------------
// State retained between updates.
let state = {
// True if there is an error (which we are displaying in the output).
error: false,
// Keep track of markers in order to remove them later.
errorMarker: undefined,
inputMarkers: [],
outputMarkers: [],
underlineMarkers: {"has-tooltip": [], "has-tooltip-active": []},
// Parse tree and final output tree.
tree: undefined,
outputTree: undefined,
// Highlighting and tooltip state.
highlightState: {lastBestNode: undefined, lastOnFirst: undefined},
tooltipState: {lastBestNode: undefined, lastDocPos: undefined},
};
// Flag to avoid trigerring cursor events when writing output.
let updateOutputInProgress = false;
// Updates the output given input text.
function update() {
const outputMode = $outputSlider.value;
const previouslyHadError = state.error;
function getOutput(input) {
$inputEditor.session.clearAnnotations();
state.error = false;
if (state.errorMarker !== undefined) {
$inputEditor.session.removeMarker(state.errorMarker);
state.errorMarker = undefined;
}
clearHighlights();
clearTooltip();
clearUnderlines();
state.tree = undefined;
state.outputTree = undefined;
try {
const tokens = tokenize(input);
const tree = parse(tokens);
state.tree = tree;
if (debugEnabled) {
switch (outputMode) {
case "1":
return stringify(tokens.map(t => t.text));
case "2":
return stringify(tokens);
case "3":
return stringify(tree);
}
}
const outputTree = lowerFromTo(state.tree, 1, outputMode - 1);
state.outputTree = outputTree;
return outputMode === LAST_OUTPUT_MODE ? formatBytes(outputTree) : format(outputTree);
} catch (ex) {
state.error = true;
let start, end, message;
if (ex.message !== undefined) {
if (ex.token !== undefined) {
start = ex.token.start;
end = ex.token.end;
message = `${JSON.stringify(ex.token.text)}: ${ex.message}`;
} else if (ex.range !== undefined) {
start = ex.range.first.start;
end = ex.range.last.end;
message = ex.message;
} else {
message = ex.message;
}
} else {
message = ex;
}
if (start && end) {
state.errorMarker = $inputEditor.session.addMarker(
new ace.Range(start.row, start.col, end.row, end.col),
"input-error",
"text",
);
$inputEditor.session.setAnnotations([{
row: start.row,
column: start.col,
type: "error",
text: wordWrap(message, 66),
}]);
return `ERROR at ${start.row+1}:${start.col+1}: ${message}`;
}
return `ERROR: ${message}`;
}
}
const input = $inputEditor.getValue();
const output = getOutput(input);
updateOutputInProgress = true;
$outputEditor.session.setValue(output);
updateOutputInProgress = false;
// We need to rehighlight now that the output is changed. Even though the
// user might have had output-cursor-driven highlighting, we can't maintain
// that because the output could be completely different now. So highlight
// from the input cursor position.
highlight("input");
// The underlines are useless for bytes -- every byte would get underlined.
// Instead, we only underline for the active (hovered) node.
if (outputMode !== LAST_OUTPUT_MODE) {
underlineTooltipRegions(
state.outputTree, /* skipWhitespace = */ true, "has-tooltip");
}
// If we go between error/non-error in the byte output mode, update the
// renderer since the line numbers have changed (non-error bytes mode uses
// line numbers 0, 8, 16, etc.).
if (outputMode === LAST_OUTPUT_MODE && (state.error !== previouslyHadError)) {
$outputEditor.renderer.updateFull();
}
// Enable output editor wrapping only when showing errors, to ensure they
// are always fully visible.
$outputEditor.session.setUseWrapMode(state.error);
}
// Clears all input and output highlights.
function clearHighlights() {
for (const marker of state.inputMarkers) {
$inputEditor.session.removeMarker(marker);
}
for (const marker of state.outputMarkers) {
$outputEditor.session.removeMarker(marker);
}
state.inputMarkers = [];
state.outputMarkers = [];
}
// Highlights input and output editors. Also underlines output regions that
// have tooltips.
function highlight(cursorFrom /* : "input" | "output" */) {
if (state.tree === undefined) {
return;
}
function le(p1, p2) {
return p1.row < p2.row || (p1.row === p2.row && p1.col <= p2.col);
}
let bestNode;
let inputPos;
if (cursorFrom === "input") {
const cursor = $inputEditor.getCursorPosition();
const pos = {row: cursor.row, col: cursor.column};
inputPos = pos;
let bestStart = {row: -Infinity, col: -Infinity};
function visit(node) {
const start = node.range.first.start;
const end = node.range.last.end;
if (le(start, pos) && le(pos, end) && le(bestStart, start)) {
bestNode = node;
bestStart = start;
}
if (node.fields !== undefined) {
for (const field of node.fields) {
visit(field);
}
}
}
visit(state.tree);
} else if (cursorFrom === "output") {
if (state.outputTree === undefined) {
return;
}
const cursor = $outputEditor.getCursorPosition();
const pos = {row: cursor.row, col: cursor.column};
let node = findClosestOutputNode(pos, /* hasInputSource = */true);
if (!node) {
return;
}
// Walk back to find the input node.
while (node.loweredFrom !== undefined) {
node = node.loweredFrom;
}
if (node.range === undefined) {
throw Error("loweredFrom chain did not lead to input node with range");
}
bestNode = node;
} else {
throw Error(`unexpected cursorFrom: ${cursorFrom}`);
}
if (bestNode === undefined) {
state.highlightState.lastBestNode = undefined;
state.highlightState.lastOnFirst = undefined;
clearHighlights();
return;
}
const onFirst = inputPos === undefined ||
le(bestNode.range.first.start, inputPos) && le(inputPos, bestNode.range.first.end);
if (bestNode === state.highlightState.lastBestNode && onFirst === state.highlightState.lastOnFirst) {
return;
}
state.highlightState.lastBestNode = bestNode;
state.highlightState.lastOnFirst = onFirst;
clearHighlights();
const nodes =
onFirst || bestNode.fields === undefined || bestNode.fields.length === 0
? [bestNode] : bestNode.fields;
// List of lists of output ranges. Element i corresponds to nodes[i].
const outputRangesForNode = [];
// Keep track of whether all nodes map to the same output range. If so,
// we'll give them the same color to avoid confusion. This is the case when
// highlighting all bits members after they've been lowered to 1 integer.
let outputsCoincide = true;
// Populate outputRangesForNode and calculate outputsCoincide.
for (const node of nodes) {
// outputRanges: [{start, end}] positions.
// Position: {row, col} zero-based.
let outputRanges = [];
function explore(node) {
if (node.lowered) {
for (const lowered of node.lowered) {
explore(lowered);
}
} else if (node.outputRanges !== undefined) {
outputRanges.push(...node.outputRanges);
}
}
explore(node);
outputRangesForNode.push(outputRanges);
if (outputsCoincide
&& (outputRanges.length !== 1 || !outputRangesEqual(outputRanges[0], outputRangesForNode[0][0]))) {
outputsCoincide = false;
}
}
// Add highlight markers.
for (let i = 0; i < nodes.length; i++) {
// Range: {first, last} tokens.
// Token: {start, end} positions.
// Position: {row, col} zero-based.
const range = nodes[i].range;
// NOTE: Keep 9 in sync with the number of highlight classes in style.css.
const highlightClass = outputsCoincide ? "highlight-1" : `highlight-${i%9+1}`;
state.inputMarkers.push($inputEditor.session.addMarker(
new ace.Range(
range.first.start.row,
range.first.start.col,
range.last.end.row,
range.last.end.col,
),
highlightClass,
"text",
false, // behind the text, not in front
));
for (const {start, end} of outputRangesForNode[i]) {
state.outputMarkers.push($outputEditor.session.addMarker(
new ace.Range(
start.row,
start.col,
end.row,
end.col,
),
highlightClass,
"text",
false, // behind the text, not in front
));
}
}
}
// Clears underline markers in the output.
function clearUnderlines(cssClass) {
if (cssClass === undefined) {
clearUnderlines("has-tooltip");
clearUnderlines("has-tooltip-active");
return;
}
for (const marker of state.underlineMarkers[cssClass]) {
$outputEditor.session.removeMarker(marker);
}
state.underlineMarkers[cssClass] = [];
}
// Underlines regions of the output that have tooltips.
function underlineTooltipRegions(startingOutputNode, skipWhitespace, cssClass) {
clearUnderlines(cssClass);
if (state.outputTree === undefined) {
return;
}
// Comparison operators for {row, col} ranges (col exclusive).
function le(p1, p2) {
return p1.row < p2.row || (p1.row === p2.row && p1.col <= p2.col);
}
function lt(p1, p2) {
return !(p1.row === p2.row && p1.col === p2.col) && le(p1, p2);
}
// Subtract a list of [{start, end}] from range: {start, end}.
function subtract(range, others, p) {
let result = [{...range}];
for (const other of others) {
const newResult = [];
for (const r of result) {
if (lt(other.start, r.end) && lt(r.start, other.end)) {
if (le(other.start, r.start) && lt(other.end, r.end)) {
newResult.push({start: other.end, end: r.end});
} else if (lt(r.start, other.start) && lt(other.end, r.end)) {
newResult.push({start: r.start, end: other.start});
newResult.push({start: other.end, end: r.end});
} else if (lt(r.start, other.start) && le(r.end, other.end)) {
newResult.push({start: r.start, end: other.start});
} else {
}
} else {
newResult.push(r);
}
}
result = newResult;
}
return result;
}
// Returns a list of ranges that have notes/tooltips (if wantNote is true)
// or that do *not* (if wantNote is false).
function getTooltipRanges(node, wantNote) {
const children = node.fields === undefined ? [] : node.fields;
const haveNote = node.note !== undefined;
if (wantNote === haveNote) {
const opposite = getTooltipRanges(node, !wantNote);
return node.outputRanges.flatMap(r => subtract(r, opposite));
}
return children.flatMap(n => getTooltipRanges(n, wantNote));
}
// Get whitespace (or other tokens we don't want to underline).
const whitespaceRanges = [];
if (skipWhitespace) {
let row = 0, col = 0;
let whitespaceStart = undefined;
let inWhitespace = false;
for (const ch of $outputEditor.getValue()) {
if (!inWhitespace && (ch.trim() === "" || ch === "," || ch === "{" || ch === "}")) {
inWhitespace = true;
whitespaceStart = {row, col};
} else if (inWhitespace && !(ch.trim() === "" || ch === "," || ch === "{" || ch === "}")) {
whitespaceRanges.push({start: whitespaceStart, end: {row, col}});
inWhitespace = false;
whitespaceStart = undefined;
}
col++;
if (ch === "\n") {
row++;
col = 0;
}
}
if (whitespaceStart !== undefined) {
whitespaceRanges.push({start: whitespaceStart, end: {row, col}});
}
}
for (const fullRange of getTooltipRanges(startingOutputNode, true)) {
for (const range of
skipWhitespace ? subtract(fullRange, whitespaceRanges) : [fullRange]) {
state.underlineMarkers[cssClass].push($outputEditor.session.addMarker(
new ace.Range(
range.start.row,
range.start.col,
range.end.row,
range.end.col,
),
cssClass,
"text",
true, // in front of the text, not behind
));
}
}
}
// Clears the tooltip in the output editor.
function clearTooltip() {
$outputTooltip.hidden = true;
$outputTooltip.innerHTML = "";
state.tooltipState.lastBestNode = undefined;
// Remove the active underline since it's no longer active.
clearUnderlines("has-tooltip-active");
}
// Finds the output node closest to pos.
function findClosestOutputNode(pos, hasInputSource) {
function le(p1, p2) {
return p1.row < p2.row || (p1.row === p2.row && p1.col <= p2.col);
}
// Returns true if node can be traced back to an input node, with a range
// specifying the input's location in the input editor.
function searchForInput(node) {
while (node !== undefined) {
if (node.range !== undefined) {
return true;
}
node = node.loweredFrom;
}
return false;
}
let bestNode;
let bestStart = {row: -Infinity, col: -Infinity};
function visit(node) {
for (const {start, end} of node.outputRanges) {
if (le(start, pos) && le(pos, end) && le(bestStart, start)
&& (!hasInputSource || searchForInput(node))) {
bestNode = node;
bestStart = start;
}
}
if (node.fields !== undefined) {
for (const field of node.fields) {
visit(field);
}
}
}
visit(state.outputTree);
return bestNode;
}
// Shows tooltips based on the cursor position in the output editor.
function updateTooltip(docPos, pixelPos) {
if (state.outputTree === undefined) {
return;
}
if (docPos === state.tooltipState.lastDocPos) {
$outputTooltip.style.left = `${pixelPos.pageX}px`;
$outputTooltip.style.top = `${pixelPos.pageY}px`;
return;
}
state.tooltipState.lastDocPos = docPos;
const pos = {row: docPos.row, col: docPos.column};
const bestNode = findClosestOutputNode(pos, /* hasInputSource = */ false);
if (bestNode === undefined || bestNode.note === undefined) {
clearTooltip();
return;
}
if (bestNode === state.tooltipState.lastBestNode) {
$outputTooltip.style.left = `${pixelPos.pageX}px`;
$outputTooltip.style.top = `${pixelPos.pageY}px`;
return;
}
state.tooltipState.lastBestNode = bestNode;
$outputTooltip.style.left = `${pixelPos.pageX}px`;
$outputTooltip.style.top = `${pixelPos.pageY}px`;
$outputTooltip.innerHTML = wordWrap(bestNode.note, 66);
$outputTooltip.hidden = false;
// In bytes mode, we don't underline the things that can be hovered over
// (in update()) because *everything* can be hovered. Instead, we show an
// underline for the current tooltip to show the extent of it. In other
// modes, we also show a more distinct underline for the active one.
underlineTooltipRegions(
bestNode,
/* skipWhitespace = */ $outputSlider.value !== LAST_OUTPUT_MODE,
"has-tooltip-active");
}
// ---------------------------------------------------------------------------
// ----- RESTORE -------------------------------------------------------------
// ---------------------------------------------------------------------------
// Used to avoid firing events while restoring (either in the beginning or
// when the Reset button is clicked).
let restoreInProgress;
// Restores the site from localStorage.
function restore() {
restoreInProgress = true;
if (window.localStorage.editorPosition) {
editorSplitPosition = JSON.parse(window.localStorage.editorPosition);
} else {
editorSplitPosition = 0.5;
}
// This will also call resizeEditors.
setEditorSplitOrientation(window.localStorage.editorSplitOrientation || "vertical");
if (window.localStorage.debugEnabled) {
setDebugEnabled(JSON.parse(window.localStorage.debugEnabled), "restoring");
} else {
setDebugEnabled(false, "restoring");
}
if (window.localStorage.outputModeWhenDebugIs) {
outputModeWhenDebugIs = JSON.parse(window.localStorage.outputModeWhenDebugIs);
} else {
outputModeWhenDebugIs = {false: $outputSlider.max, true: $outputSlider.min};
}
setOuputMode(outputModeWhenDebugIs[debugEnabled]);
// Hidden at first to avoid a flash from default 50% editor split to the
// restored value. This also applies for the output slider (Debug or not
// Debug can cause flash). So we just hide the whole container till ready.
$container.hidden = false;
let input = window.localStorage.input;
if (!input || input.trim() === "") {
input = DEFAULT_INPUT;
}
$inputEditor.session.setValue(input);
// Move the cursor to the open brace so that we get the rainbow highlights.
// Note: still do this even if we restored from localStorage, but it was
// unchanged from the default.
if (input === DEFAULT_INPUT) {
$inputEditor.moveCursorTo(0, 8);
}
update();
restoreInProgress = false;
}
// ---------------------------------------------------------------------------
// ----- EVENTS --------------------------------------------------------------
// ---------------------------------------------------------------------------
// Update and save whenever the input changes.
$inputEditor.on("change", () => {
if (restoreInProgress) {
return;
}
update();
window.localStorage.input = $inputEditor.getValue();
});
// Re-highlight whenever the cursor moves. But not when selecting text.
$inputEditor.session.getSelection().on("changeCursor", () => {
if (restoreInProgress) {
return;
}
if ($inputEditor.session.getSelection().isEmpty()) {
highlight("input");
}
});
$outputEditor.session.getSelection().on("changeCursor", () => {
if (restoreInProgress || updateOutputInProgress) {
return;
}
if ($outputEditor.session.getSelection().isEmpty()) {
highlight("output");
}
});
// Update the output view whenever the slider changes.
$outputSlider.oninput = () => {
if (restoreInProgress) {
return;
}
setOuputMode($outputSlider.value);
update();
};
// Also allow moving the slider using Ctrl-[ and Ctrl-].
document.addEventListener("keydown", (e) => {
let delta;
if (e.ctrlKey && e.key === "[") {
delta = -1;
} else if (e.ctrlKey && e.key === "]") {
delta = 1;
} else {
return;
}
const newValue = (parseInt($outputSlider.value) + delta).toString();
if (newValue < $outputSlider.min || newValue > $outputSlider.max) {
return;
}
$outputSlider.value = newValue;
setOuputMode(newValue);
update();
});
// Update output tooltip when the cursor moves.
$outputEditor.on("mousemove", e => {
const docPos = e.getDocumentPosition();
if (!docPos || !$outputEditor.session.getSelection().isEmpty()) {
clearTooltip();
return;
}
const screenPos = $outputEditor.session.documentToScreenPosition(docPos);
if (
// Avoid tooltip persisting when moving into the blank space to the right.
$outputEditor.session.getScreenLastRowColumn(screenPos.row) === screenPos.column
// Avoid weird flicker when moving past top edge of window.
|| (docPos.row === 0 && docPos.column === 0 && screenPos.row === 0 && screenPos.column === 0)) {
clearTooltip();
return;
}
const pixelPos = $outputEditor.renderer.textToScreenCoordinates(docPos);
pixelPos.pageX += 10;
// Removing this because large tooltips at the bottom cause a scrollbar
// on the entire document, jerking the page when the tooltip appears.
// It can still happen with large tooltips, but it's alleviated by the fact
// that I enabled scrollPastEnd on the editors.
//
// EDIT: uncommenting it again because I have made body overflow: hidden
// so that problem is gone. On bytes it's nice to have the tooltip below
// to not obstruct the rest of the line.
pixelPos.pageY += $outputEditor.renderer.lineHeight + 2;
updateTooltip(docPos, pixelPos);
});
document.getElementById("OutputEditor").addEventListener("mouseleave", () => {
clearTooltip();
});
// Allow the editor splitter to be dragged.
let draggingSplitter = false;
$splitter.addEventListener("mousedown", () => draggingSplitter = true);
document.addEventListener("mouseup", () => draggingSplitter = false);
document.addEventListener("mousemove", (e) => {
if (!draggingSplitter) {
return;
}
const rect = $container.getBoundingClientRect();
// NOTE: Keep the number below in sync with style.css.
const position = editorSplitOrientation === "vertical"
? (e.pageX - rect.x) / rect.width
: (e.pageY - rect.y) / rect.height;
const clamped = Math.max(0, Math.min(1, position));
resizeEditors(clamped);
});
// Toggle Debug mode when the Debug button is clicked.
$debugButton.onclick = () => {
setDebugEnabled(!debugEnabled);
update();
};
$layoutButton.onclick = () => setEditorSplitOrientation("toggle");
function exitModalKeyListener(e) {
if (e.key === "Enter" || e.key === "Escape") {
// Without preventDefault, pressing Enter would hide the window and then
// immediately show it again because it presses the Help button again (if
// it's still in focus).
e.preventDefault();
hideHelpModal();
}
}
function showHelpModal() {
$helpModal.hidden = false;
document.addEventListener("keydown", exitModalKeyListener);
}
function hideHelpModal() {
$helpModal.hidden = true;
document.removeEventListener("keydown", exitModalKeyListener);
}
// Open the help window on clicking the Help button. It also functions as a
// toggle so that if you press space while it's still in focus, it will close
// the help window.
$helpButton.onclick = () => {
if ($helpModal.hidden) {
showHelpModal();
} else {
hideHelpModal();
}
};
// Click outside the modal to hid eit. But use stopPropagation to prevent
// clicks on the modal content itself from closing it.
$helpModal.onclick = hideHelpModal;
$helpModalContent.onclick = (e) => e.stopPropagation();
// Reset all data to the defaults on clicking the Reset buton.
$resetButton.onclick = () => {
window.localStorage.clear();
restore();
};
restore();
};
window.onload = () => {
// Load ace/mode/json first. Running main() eventually results in setting an
// editor mode to ace/mode/fidlviz. This imports some resources that are not
// exposed as separate files by the CDN, like ace/mode/behaviour/cstyle. By
// first loading ace/mode/json, these common resources get populated in Ace's
// internal map, so it won't attempt and fail to load them from the CDN.
ace.config.loadModule("ace/mode/json", () => {
main();
});
};
})(); // end IIFE