Add fidlviz project
fidlviz is a tool that visualizes the FIDL wire format. For now it is a
standalone client-side web app, but it will likely be integrated into
fidlbolt in the future.
Change-Id: I4d3a6abcbb86ad01c7f2523682b289afb5eebdcb
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidl-misc/+/410797
Reviewed-by: Pascal Perez <pascallouis@google.com>
diff --git a/fidlviz/README.md b/fidlviz/README.md
new file mode 100644
index 0000000..c0582aa
--- /dev/null
+++ b/fidlviz/README.md
@@ -0,0 +1,11 @@
+# fidlviz
+
+fidlviz is a tool for visualizing the FIDL wire format.
+
+It is entirely client-side, implemented in `index.html`, `style.css`, and `script.js`.
+
+Click the Help button in the app to learn how it works.
+
+## Known issues
+
+fidlviz has only been tested in Chrome. It requires `BigInt` support, so it does not work in Safari and other browsers that lack support.
diff --git a/fidlviz/favicon.ico b/fidlviz/favicon.ico
new file mode 100644
index 0000000..00b6c9f
--- /dev/null
+++ b/fidlviz/favicon.ico
Binary files differ
diff --git a/fidlviz/index.html b/fidlviz/index.html
new file mode 100644
index 0000000..7d3784c
--- /dev/null
+++ b/fidlviz/index.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>fidlviz</title>
+ <link rel="shortcut icon" href="favicon.ico">
+ <link rel="stylesheet" href="style.css">
+ </head>
+ <body>
+ <div class="controls-container">
+ <button id="HelpButton" type="button" class="controls-button">Help</button>
+ <button id="DebugButton" type="button" class="controls-button">Debug</button>
+ <button id="ResetButton" type="button" class="controls-button">Reset</button>
+ <button id="LayoutButton" type="button" class="controls-button">Layout</button>
+ </div>
+ <div id="HelpModal" class="modal-container" hidden>
+ <div id="HelpModalContent" class="modal-box">
+ <h2>Help</h2>
+ <p>Welcome to <b>fidlviz</b>, a web app for visualizing the FIDL wire format.</p>
+ <p>Here are some tips to help you get started:</p>
+ <ul>
+ <li>The <b>input</b> editor is on the left. Enter your FIDL data structures there. If you forget the syntax, click the <b>Reset</b> button to get back the example.</li>
+ <li>The <b>output</b> view is on the right. Initially, this shows bytes. By dragging the slider in the top-right, or using <kbd>Ctrl</kbd><kbd>[</kbd> and <kbd>Ctrl</kbd><kbd>]</kbd>, you can instead view intermediate stages where the input gets <b>lowered</b> to progressively simpler elements.</li>
+ <li>To <b>highlight</b> something, just click on it. Highlighting input will cause the corresponding output to be highlighted too, and vice versa. Click inside curly braces to highlight all contained fields in different colors.</li>
+ <li>Hover over underlined regions of the output to reveal explanatory <b>tooltips</b>. When viewing bytes, there are no underlines because everything has a tooltip.</li>
+ <li>Click the <b>Layout</b> button to switch between the left/right editor layout and the top/bottom one (useful for long lines and narrow windows).</li>
+ <li>The app <b>autosaves</b> everything to your browser’s local storage. You can always start fresh by clicking the <b>Reset</b> button.</li>
+ <li>To <b>close</b> this help window, press <kbd>Esc</kbd> or <kbd>Enter</kbd>.</li>
+ </ul>
+ <p>Enjoy! If you find a bug, please report it to mkember@google.com.</p>
+ <hr>
+ <p><i>If you like <b>fidlviz</b>, you might also like <a href="https://fidlbolt-6jq5tlqt6q-uc.a.run.app/"><b>fidlbolt</b></a></i>.</p>
+ </div>
+ </div>
+ <div class="wrapper">
+ <div class="heading">
+ <h1><span class="heading-fidl">fidl</span><span class="heading-viz">viz</span></h1>
+ </div>
+ <div id="Container" class="container" hidden>
+ <div id="SplitContainer" class="split-container split-container--vertical">
+ <div id="InputPart" class="split-item split-item--first">
+ <div id="InputEditor" class="editor"></div>
+ </div>
+ <div id="Splitter" class="split-splitter split-splitter--vertical"></div>
+ <div id="OutputPart" class="split-item split-item--second">
+ <div class="part-header">
+ <h2 id="OutputTitle" class="part-title"> </h2>
+ <input id="OutputSlider" class="slider"
+ type="range" min="1" max="8"
+ value="8" list="Tickmarks">
+ </div>
+ <div id="OutputEditor" class="editor"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="OutputTooltip" class="tooltip" hidden></div>
+ <datalist id="Tickmarks">
+ <option value="1"></option>
+ <option value="2"></option>
+ <option value="3"></option>
+ <option value="4"></option>
+ <option value="5"></option>
+ <option value="6"></option>
+ <option value="7"></option>
+ <option value="8"></option>
+ </datalist>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.8/ace.js"></script>
+ <script src="script.js"></script>
+ </body>
+</html>
diff --git a/fidlviz/script.js b/fidlviz/script.js
new file mode 100644
index 0000000..ccf93a1
--- /dev/null
+++ b/fidlviz/script.js
@@ -0,0 +1,2557 @@
+(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
diff --git a/fidlviz/style.css b/fidlviz/style.css
new file mode 100644
index 0000000..d3ffdbd
--- /dev/null
+++ b/fidlviz/style.css
@@ -0,0 +1,423 @@
+html, body {
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ /* Copied from fidlbolt */
+ --fg: #333;
+ --bg: #f0f0f0;
+ --border: #666;
+ --element: #ddd;
+ --element-hover: #f5f5f5;
+ --intense: #fff;
+ --intense-hover: #e3e3e3;
+ --border: #666;
+ --link: #06d;
+
+ /* Not in fidlbolt */
+ --cover: rgba(255, 255, 255, 0.75);
+ --underline: rgba(0, 0, 0, 0.25);
+ --underline-active: rgba(0, 0, 0, 0.75);
+ --shadow: rgba(0, 0, 0, 0.3);
+ --tooltip-gradient: rgba(0, 0, 0, 0.1);
+ --input-error: #ffaaaa;
+ --highlight-1: #ffeea2;
+ --highlight-2: #cfff90;
+ --highlight-3: #a6ffec;
+ --highlight-4: #a4dbff;
+ --highlight-5: #adbcff;
+ --highlight-6: #dbb8fc;
+ --highlight-7: #ffb9f6;
+ --highlight-8: #ffb9b9;
+ --highlight-9: #ffd8a6;
+
+ font: 16px Helvetica, sans-serif;
+ color: var(--fg);
+ background: var(--bg);
+
+ /* Prevent tooltips near edges from making the document have scrollbars,
+ causing jankiness when mousing onto and off that element. */
+ overflow: hidden;
+}
+
+@media (prefers-color-scheme: dark) {
+ body {
+ /* Copied from fidlbolt */
+ --fg: #eee;
+ --bg: #333;
+ --element: #111;
+ --element-hover: #444;
+ --intense: #111;
+ --intense-hover: #444;
+ --border: #aaa;
+ --link: #6af;
+
+ /* Not in fidlbolt */
+ --cover: rgba(30, 30, 30, 0.75);
+ --underline: rgba(255, 255, 255, 0.8);
+ --underline-active: rgba(255, 255, 255, 1);
+ --shadow: rgba(255, 255, 255, 0.5);
+ --tooltip-gradient: rgba(255, 255, 255, 0.2);
+ --input-error: #c75252;
+ --highlight-1: #ad8e00;
+ --highlight-2: #518f00;
+ --highlight-3: #009474;
+ --highlight-4: #286b97;
+ --highlight-5: #4356a8;
+ --highlight-6: #7239a8;
+ --highlight-7: #971c87;
+ --highlight-8: #992121;
+ --highlight-9: #bd6b00;
+ }
+}
+
+.part-header {
+ display: flex;
+ justify-content: space-between;
+ background: var(--element);
+ border-bottom: 1px solid var(--border);
+ padding: 5px 10px 4px;
+ user-select: none;
+}
+
+.part-header:hover {
+ /* Prevent weird flicker when dragging splitter by disabling selection,
+ and enabling only on hover. Not perfect (selection reappears when you
+ hove again) but better than before. */
+ user-select: auto;
+}
+
+.part-title {
+ font-size: 18px;
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.button--active {
+ font-weight: bold;
+}
+
+.slider {
+ flex: 0 0 165px;
+}
+
+.input-error {
+ background: var(--input-error);
+ position: absolute;
+}
+
+.highlight-1 {
+ background: var(--highlight-1);
+ position: absolute;
+}
+
+.highlight-2 {
+ background: var(--highlight-2);
+ position: absolute;
+}
+
+.highlight-3 {
+ background: var(--highlight-3);
+ position: absolute;
+}
+
+.highlight-4 {
+ background: var(--highlight-4);
+ position: absolute;
+}
+
+.highlight-5 {
+ background: var(--highlight-5);
+ position: absolute;
+}
+
+.highlight-6 {
+ background: var(--highlight-6);
+ position: absolute;
+}
+
+.highlight-7 {
+ background: var(--highlight-7);
+ position: absolute;
+}
+
+.highlight-8 {
+ background: var(--highlight-8);
+ position: absolute;
+}
+
+.highlight-9 {
+ background: var(--highlight-9);
+ position: absolute;
+}
+
+.has-tooltip, .has-tooltip-active {
+ border-bottom: 3px solid var(--underline);
+ border-radius: 0;
+ box-sizing: border-box;
+ position: absolute;
+}
+
+.has-tooltip-active {
+ border-bottom-color: var(--underline-active);
+}
+
+/*
+https://github.com/devuxd/SeeCodeRun/wiki/Ace-code-editor#i-want-to-create-a-popup-that-comes-up-whenever-the-user-hovers-over-a-piece-of-code-whats-the-best-way-to-do-that
+*/
+.tooltip {
+ background-color: var(--bg);
+ background-image: -webkit-linear-gradient(top, transparent, var(--tooltip-gradient));
+ background-image: linear-gradient(to bottom, transparent, var(--tooltip-gradient));
+ border: 1px solid var(--border);
+ border-radius: 1px;
+ box-shadow: 0 1px 2px var(--shadow);
+ color: var(--fg);
+ max-width: 100%;
+ padding: 3px 4px;
+ position: absolute;
+ z-index: 999999;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ cursor: default;
+ white-space: pre;
+ word-wrap: break-word;
+ line-height: normal;
+ font-style: normal;
+ font-weight: normal;
+ letter-spacing: normal;
+ pointer-events: none;
+}
+
+/* ========================================================================== */
+/* ===== START copied from fidlbolt ========================================= */
+/* ========================================================================== */
+h1 {
+ font-size: 40px;
+ font-weight: bold;
+ margin: 20px 0;
+}
+h2 {
+ font-size: 25px;
+ font-weight: bold;
+ margin: 30px 0;
+}
+a {
+ text-decoration: none;
+ color: var(--link);
+}
+a:hover {
+ text-decoration: underline;
+}
+p {
+ margin: 0 0 1em 0;
+ line-height: 1.5;
+}
+ul, ol {
+ margin: 0;
+ padding: 0 0 0 25px;
+ list-style-position: outside;
+}
+li {
+ margin: 0 0 1em 0;
+ padding: 0;
+ line-height: 1.5;
+}
+.close-list li {
+ margin: 0;
+}
+kbd {
+ display: inline-block;
+ margin: 0 1px;
+ padding: 2px 3px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--intense);
+ font: 11px "Menlo", "Consolas", monospace;
+ white-space: nowrap;
+}
+.kbd-group {
+ white-space: nowrap;
+}
+hr {
+ border: none;
+ border-bottom: 1px solid var(--border);
+ margin: 30px auto;
+ padding: 0;
+}
+button {
+ font: inherit;
+ color: inherit;
+ outline: none;
+ border: none;
+ margin: 0;
+}
+button:focus {
+ text-decoration: underline;
+}
+.heading-fidl {
+ margin-left: 10px;
+}
+/* CHANGED (from heading-bolt) */
+.heading-viz {
+ color: var(--intense);
+ -webkit-text-stroke-width: 1.5px;
+ -webkit-text-stroke-color: var(--fg);
+ margin-right: 5px;
+}
+.wrapper {
+ margin: 0 20px;
+ display: flex;
+ flex-flow: column;
+ min-height: 100vh;
+}
+.heading {
+ text-align: center;
+}
+.container {
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ overflow: hidden;
+ flex-grow: 1;
+ margin-bottom: 20px;
+ position: relative;
+}
+.split-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+}
+.split-container--horizontal {
+ flex-direction: column;
+}
+.split-container--vertical {
+ flex-direction: row;
+}
+.split-item {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ height: 100%;
+}
+.split-item--first {
+ flex-shrink: 0;
+}
+.split-item--second {
+ flex-grow: 1;
+}
+.split-splitter {
+ flex-shrink: 0;
+ background: var(--border);
+}
+.split-splitter--vertical {
+ cursor: ew-resize;
+ width: 8px;
+}
+.split-splitter--horizontal {
+ cursor: ns-resize;
+ height: 8px;
+}
+.editor {
+ font: 14px "Menlo", "Consolas", monospace;
+ /* ADDED */ line-height: 18px;
+ flex-grow: 1;
+}
+.controls-container {
+ position: absolute;
+ top: 23px;
+ right: 20px;
+}
+.controls-button {
+ background: var(--intense);
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ cursor: pointer;
+ display: inline-block;
+ margin-left: 10px;
+ user-select: none;
+ -webkit-user-select: none;
+}
+.controls-button:hover {
+ background: var(--intense-hover);
+}
+.modal-container {
+ position: absolute;
+ /* CHANGED */
+ /* background: rgba(255, 255, 255, 0.5); */
+ background: var(--cover);
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 999;
+}
+.modal-box {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ max-width: min(100% - 100px, 525px);
+ padding: 0 20px 20px;
+ background: var(--bg);
+ border-left: 1px solid var(--border);
+ overflow-y: auto;
+}
+.modal-box .form-control {
+ margin-bottom: 12px;
+}
+.form-select {
+ margin-left: 8px;
+ width: 120px;
+}
+.form-checkbox {
+ margin-right: 8px;
+}
+.form-text, .form-number {
+ margin-left: 8px;
+ width: 40px;
+}
+.form-number--invalid {
+ color: #f33;
+}
+.form-button-group {
+ margin-top: 30px;
+}
+.form-button {
+ background: var(--intense);
+ padding: 8px;
+ margin: 0 5px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ cursor: pointer;
+ display: inline-block;
+ font: inherit;
+ color: inherit;
+ font-size: 14px;
+}
+.form-button:hover {
+ background: var(--intense-hover);
+}
+/* CHANGED from 750px (because more buttons) */
+@media (max-width: 850px) {
+ .heading {
+ text-align: left;
+ }
+ .status {
+ left: 0;
+ right: 0;
+ text-align: center;
+ }
+}
+/* ========================================================================== */
+/* ===== END copied from fidlbolt =======================--================== */
+/* ========================================================================== */