| (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.length == v2.fields.length |
| && 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; |
| } |
| } |
| return true; |
| 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 "pad4": |
| 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) { |
| if (field.type !== "$out") { |
| push(INDENT.repeat(indent + 1)); |
| } |
| thunks.push(helper(field, indent + 1, /* topLevelObject = */ false)); |
| if (field.type !== "$out") { |
| 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. Occasionally 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, config, 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, config); |
| } |
| if ("fields" in node) { |
| return {...copyNode(node), fields: node.fields.map(f => self(f, config))}; |
| } |
| // 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, config) { |
| return doLowering(tree, config, 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, config) { |
| return doLowering(tree, config, 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, config) { |
| return doLowering(tree, config, 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, config) { |
| return doLowering(tree, config, 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, config) { |
| 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, config, 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) { |
| if (config.v2Enabled) { |
| return { |
| type: "$pointer", |
| value: "absent", |
| note: node.note, |
| }; |
| } |
| 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 payload = lowerBytes(lower6(lower5(node.fields[0], config), config), config); |
| const bytes = countTotalBytes(payload); |
| disableAddLowered = false; |
| const handles = countPresentHandles(node.fields[0]); |
| if (config.v2Enabled) { |
| const lastField = payload.fields[payload.fields.length - 1] |
| if (bytes === 8 && lastField.padding && lastField.size >= 4) { |
| return { |
| type: "struct", |
| fields: [ |
| { |
| ...copyNode(node), |
| type: "pad4", |
| note: "Pad inlined envelope value to 4 bytes" |
| }, |
| {type: "int", signed: "u", width: 16, |
| orig: handles.toString(), value: handles, |
| note: `Envelope: ${handles} handle${plural(handles)}`}, |
| {type: "int", signed: "u", width: 16, orig: "1", value: 1, |
| note: "Envelope flags: inlined representation"}, |
| ], |
| note: node.note, |
| }; |
| } |
| 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: 16, orig: handles.toString(), value: handles, |
| note: `Envelope: ${handles} handle${plural(handles)}`}, |
| {type: "int", signed: "u", width: 16, orig: "0", value: 0, |
| note: "Envelope flags: out-of-line representation"}, |
| {type: "$out", fields: node.fields, |
| note: "Envelope: out-of-line data"}, |
| ], |
| note: node.note, |
| }; |
| } |
| 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: arrays and boxes. Assumes earlier lowerings are done. |
| function lower6(tree, config) { |
| return doLowering(tree, config, 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, config) { |
| // object can be "primary", "secondary", "pad4", 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 "pad4": { |
| return lower(node.fields[0], "pad4"); |
| } |
| 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 === "pad4") { |
| padToAlign(4, pad => `${pad} byte${plural(pad)} tail padding for ` |
| + `object inlined in envelope`); |
| } else 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, config) { |
| const lowerings = [ |
| lower1, lower2, lower3, lower4, lower5, lower6, lowerBytes |
| ]; |
| for (let i = from - 1; i < to; i++) { |
| tree = lowerings[i](tree, config); |
| } |
| 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 $outputV2Control = document.getElementById("OutputV2Control"); |
| const $outputV2Checkbox = document.getElementById("OutputV2Checkbox"); |
| 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; |
| $outputV2Control.hidden = true; |
| } else { |
| $debugButton.classList.remove("button--active"); |
| $outputSlider.max = LAST_OUTPUT_MODE; |
| $outputV2Control.hidden = false; |
| } |
| // 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; |
| |
| const config = { |
| v2Enabled: $outputV2Checkbox.checked, |
| }; |
| |
| 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, config); |
| 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]); |
| $outputV2Checkbox.checked = true; |
| // 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(); |
| }; |
| // Update the output view whenever the v2 checkox changes. |
| $outputV2Checkbox.oninput = () => { |
| if (restoreInProgress) { |
| return; |
| } |
| 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 |