diff --git a/src/base/SExpr.zig b/src/base/SExpr.zig index afcd333dab..6a84320beb 100644 --- a/src/base/SExpr.zig +++ b/src/base/SExpr.zig @@ -22,7 +22,6 @@ pub const Color = enum { node_name, string, number, - region, punctuation, }; @@ -52,10 +51,10 @@ const PlainTextSExprWriter = struct { // No-op for plain text } - pub fn beginSourceRange(self: *@This(), start_token: u32, end_token: u32) !void { + pub fn beginSourceRange(self: *@This(), start_byte: u32, end_byte: u32) !void { _ = self; - _ = start_token; - _ = end_token; + _ = start_byte; + _ = end_byte; // No-op for plain text } @@ -92,7 +91,6 @@ const HtmlSExprWriter = struct { .node_name => "token-keyword", // Node names are like keywords in S-expressions .string => "token-string", .number => "token-number", - .region => "token-comment", // Regions are metadata, similar to comments .punctuation => "token-punctuation", }; try self.writer.print("", .{css_class}); @@ -102,8 +100,8 @@ const HtmlSExprWriter = struct { self.current_color = color; } - pub fn beginSourceRange(self: *@This(), start_token: u32, end_token: u32) !void { - try self.writer.print("", .{ start_token, end_token }); + pub fn beginSourceRange(self: *@This(), start_byte: u32, end_byte: u32) !void { + try self.writer.print("", .{ start_byte, end_byte }); } pub fn endSourceRange(self: *@This()) !void { @@ -125,10 +123,10 @@ pub const AttributeValue = union(enum) { node_idx: u32, region: RegionInfo, raw_string: []const u8, // for unquoted strings - tokens_range: struct { + bytes_range: struct { region: RegionInfo, - start_token: u32, - end_token: u32, + start_byte: u32, + end_byte: u32, }, }; @@ -168,7 +166,7 @@ pub fn deinit(self: *SExpr, gpa: Allocator) void { gpa.free(r.line_text); } }, - .tokens_range => |tr| { + .bytes_range => |tr| { // Free the region line text if it's not empty if (tr.region.line_text.len > 0) { gpa.free(tr.region.line_text); @@ -242,9 +240,9 @@ pub fn appendBoolAttr(self: *SExpr, gpa: Allocator, key: []const u8, value: bool } /// Append a token range attribute with region information -pub fn appendTokenRange(self: *SExpr, gpa: Allocator, region: RegionInfo, start_token: u32, end_token: u32) void { +pub fn appendByteRange(self: *SExpr, gpa: Allocator, region: RegionInfo, start_byte: u32, end_byte: u32) void { const owned_value = gpa.dupe(u8, region.line_text) catch |err| exitOnOom(err); - self.addAttribute(gpa, "tokens", .{ .tokens_range = .{ + self.addAttribute(gpa, "tokens", .{ .bytes_range = .{ .region = RegionInfo{ .start_line_idx = region.start_line_idx, .start_col_idx = region.start_col_idx, @@ -252,8 +250,8 @@ pub fn appendTokenRange(self: *SExpr, gpa: Allocator, region: RegionInfo, start_ .end_col_idx = region.end_col_idx, .line_text = owned_value, }, - .start_token = start_token, - .end_token = end_token, + .start_byte = start_byte, + .end_byte = end_byte, } }); } @@ -325,7 +323,6 @@ fn toStringImpl(node: SExpr, writer_impl: anytype, indent: usize) !void { }, .region => |r| { try writer_impl.print(" ", .{}); - try writer_impl.setColor(.region); try writer_impl.print("@{d}.{d}-{d}.{d}", .{ // add one to display numbers instead of index r.start_line_idx + 1, @@ -333,12 +330,10 @@ fn toStringImpl(node: SExpr, writer_impl: anytype, indent: usize) !void { r.end_line_idx + 1, r.end_col_idx + 1, }); - try writer_impl.setColor(.default); }, - .tokens_range => |tr| { + .bytes_range => |tr| { try writer_impl.print(" ", .{}); - try writer_impl.beginSourceRange(tr.start_token, tr.end_token); - try writer_impl.setColor(.region); + try writer_impl.beginSourceRange(tr.start_byte, tr.end_byte); try writer_impl.print("@{d}.{d}-{d}.{d}", .{ // add one to display numbers instead of index tr.region.start_line_idx + 1, @@ -346,7 +341,6 @@ fn toStringImpl(node: SExpr, writer_impl: anytype, indent: usize) !void { tr.region.end_line_idx + 1, tr.region.end_col_idx + 1, }); - try writer_impl.setColor(.default); try writer_impl.endSourceRange(); }, } diff --git a/src/check/canonicalize/CIR.zig b/src/check/canonicalize/CIR.zig index b3d6591dfe..c1379ead37 100644 --- a/src/check/canonicalize/CIR.zig +++ b/src/check/canonicalize/CIR.zig @@ -930,11 +930,9 @@ pub const IntValue = struct { } }; -/// Helper function to convert the entire Canonical IR to a string in S-expression format -/// and write it to the given writer. -/// -/// If a single expression is provided we only print that expression -pub fn toSExprStr(ir: *CIR, writer: std.io.AnyWriter, maybe_expr_idx: ?Expr.Idx, source: []const u8) !void { +/// Helper function to generate the S-expression node for the entire Canonical IR. +/// If a single expression is provided, only that expression is returned. +pub fn toSExpr(ir: *CIR, maybe_expr_idx: ?Expr.Idx, source: []const u8) SExpr { // Set temporary source for region info calculation during SExpr generation ir.temp_source_for_sexpr = source; defer ir.temp_source_for_sexpr = null; @@ -942,13 +940,9 @@ pub fn toSExprStr(ir: *CIR, writer: std.io.AnyWriter, maybe_expr_idx: ?Expr.Idx, if (maybe_expr_idx) |expr_idx| { // Get the expression from the store - var expr_node = ir.store.getExpr(expr_idx).toSExpr(ir); - defer expr_node.deinit(gpa); - - expr_node.toStringPretty(writer); + return ir.store.getExpr(expr_idx).toSExpr(ir); } else { var root_node = SExpr.init(gpa, "can-ir"); - defer root_node.deinit(gpa); // Iterate over all the definitions in the file and convert each to an S-expression const defs_slice = ir.store.sliceDefs(ir.all_defs); @@ -968,10 +962,21 @@ pub fn toSExprStr(ir: *CIR, writer: std.io.AnyWriter, maybe_expr_idx: ?Expr.Idx, root_node.appendNode(gpa, &stmt_node); } - root_node.toStringPretty(writer); + return root_node; } } +/// Helper function to convert the entire Canonical IR to a string in S-expression format +/// and write it to the given writer. +/// +/// If a single expression is provided we only print that expression +pub fn toSExprStr(ir: *CIR, env: *ModuleEnv, writer: std.io.AnyWriter, maybe_expr_idx: ?Expr.Idx, source: []const u8) !void { + const gpa = ir.env.gpa; + var node = toSExpr(ir, env, maybe_expr_idx, source); + defer node.deinit(gpa); + node.toStringPretty(writer); +} + test "NodeStore - init and deinit" { var store = CIR.NodeStore.init(testing.allocator); defer store.deinit(); diff --git a/src/check/parse/AST.zig b/src/check/parse/AST.zig index f8afb2c0b8..d1c1e97040 100644 --- a/src/check/parse/AST.zig +++ b/src/check/parse/AST.zig @@ -80,11 +80,20 @@ pub fn calcRegionInfo(self: *AST, region: TokenizedRegion, line_starts: []const /// Append region information to an S-expression node for diagnostics pub fn appendRegionInfoToSexprNode(self: *AST, env: *base.ModuleEnv, node: *SExpr, region: TokenizedRegion) void { - node.appendTokenRange( + const start = self.tokens.resolve(region.start); + const end = self.tokens.resolve(region.end); + const info: base.RegionInfo = base.RegionInfo.position(self.source, env.line_starts.items, start.start.offset, end.end.offset) catch .{ + .start_line_idx = 0, + .start_col_idx = 0, + .end_line_idx = 0, + .end_col_idx = 0, + .line_text = "", + }; + node.appendByteRange( env.gpa, - self.calcRegionInfo(region, env.line_starts.items), - region.start, - region.end, + info, + start.start.offset, + end.end.offset, ); } @@ -756,13 +765,13 @@ pub const Statement = union(enum) { const header_node = ast.store.nodes.get(@enumFromInt(@intFromEnum(a.header))); if (header_node.tag == .malformed) { // Handle malformed type header by creating a placeholder - header.appendRegion(env.gpa, ast.calcRegionInfo(header_node.region, env.line_starts.items)); + ast.appendRegionInfoToSexprNode(env, &header, header_node.region); header.appendStringAttr(env.gpa, "name", ""); var args_node = SExpr.init(env.gpa, "args"); header.appendNode(env.gpa, &args_node); } else { const ty_header = ast.store.getTypeHeader(a.header); - header.appendRegion(env.gpa, ast.calcRegionInfo(ty_header.region, env.line_starts.items)); + ast.appendRegionInfoToSexprNode(env, &header, ty_header.region); header.appendStringAttr(env.gpa, "name", ast.resolve(ty_header.name)); var args_node = SExpr.init(env.gpa, "args"); @@ -830,7 +839,7 @@ pub const Statement = union(enum) { .@"return" => |a| { var node = SExpr.init(env.gpa, "s-return"); - node.appendRegion(env.gpa, ast.calcRegionInfo(a.region, env.line_starts.items)); + ast.appendRegionInfoToSexprNode(env, &node, a.region); var child = ast.store.getExpr(a.expr).toSExpr(env, ast); node.appendNode(env.gpa, &child); @@ -1074,7 +1083,7 @@ pub const Pattern = union(enum) { }, .as => |a| { var node = SExpr.init(env.gpa, "p-as"); - node.appendRegion(env.gpa, ast.calcRegionInfo(a.region, env.line_starts.items)); + ast.appendRegionInfoToSexprNode(env, &node, a.region); var pattern_node = ast.store.getPattern(a.pattern).toSExpr(env, ast); node.appendStringAttr(env.gpa, "name", ast.resolve(a.name)); @@ -1419,7 +1428,7 @@ pub const ExposedItem = union(enum) { .malformed => |m| { var node = SExpr.init(env.gpa, "exposed-malformed"); node.appendStringAttr(env.gpa, "reason", @tagName(m.reason)); - node.appendRegion(env.gpa, ast.calcRegionInfo(m.region, env.line_starts.items)); + ast.appendRegionInfoToSexprNode(env, &node, m.region); return node; }, } diff --git a/src/snapshot.css b/src/snapshot.css index 80544d1600..5ee2dc5425 100644 --- a/src/snapshot.css +++ b/src/snapshot.css @@ -100,26 +100,40 @@ body { background-color: #f0f0f0; } .highlighted { - background-color: #ffffcc !important; - outline: 2px solid #ffd700 !important; + background-color: #ffffcc; + outline: 2px solid #ffd700; +} + +/* Source range highlighting for PARSE tree */ +.source-range { + cursor: pointer; + transition: background-color 0.2s ease; +} +.source-range:hover { + background-color: #f0f0f0; + text-decoration: underline; +} + +/* Byte range highlighting */ +.highlight { + background-color: #ffffcc; + border-bottom: 2px solid #ffd700; } /* Flash animation for click highlighting */ -@keyframes flash { +@keyframes flash-underline { 0%, - 50%, 100% { background-color: #ffffcc; - outline: 2px solid #ffd700; + border-bottom-color: #ffd700; } - 25%, - 75% { + 50% { background-color: #ffeb3b; - outline: 2px solid #ff9800; + border-bottom-color: #ff9800; } } .flash-highlight { - animation: flash 0.3s ease-in-out 2; + animation: flash-underline 0.3s ease-in-out 2; } /* Syntax highlighting */ @@ -153,6 +167,10 @@ body { .token-default { color: #000000; } +.source-range { + color: #008000; + font-style: italic; +} /* Hidden data storage */ .hidden { diff --git a/src/snapshot.js b/src/snapshot.js index 4bad308e78..51535c12d1 100644 --- a/src/snapshot.js +++ b/src/snapshot.js @@ -1,224 +1,736 @@ // Token highlighting functionality document.addEventListener("DOMContentLoaded", function () { - // Use event delegation for token highlighting to avoid re-adding listeners - function setupTokenHighlighting() { - // Remove any existing event listeners - document.removeEventListener("mouseenter", tokenHighlightHandler, true); - document.removeEventListener("mouseleave", tokenUnhighlightHandler, true); - document.removeEventListener("click", tokenClickHandler, true); - - // Add event listeners using delegation - document.addEventListener("mouseenter", tokenHighlightHandler, true); - document.addEventListener("mouseleave", tokenUnhighlightHandler, true); - document.addEventListener("click", tokenClickHandler, true); - } - - function highlightTokens(min, max) { - for (let i = min; i <= max; i++) { - const token = document.querySelector(`[data-token-id="${i}"]`); - if (token) { - token.classList.add("highlighted"); + // Pane switching functionality + window.switchLeftPane = function () { + const selector = document.getElementById("left-selector"); + const selectedSection = selector.value; + switchToPane("left", selectedSection); + }; + + window.switchRightPane = function () { + const selector = document.getElementById("right-selector"); + const selectedSection = selector.value; + switchToPane("right", selectedSection); + }; + + // Setup event delegation for source-range elements + setupSourceRangeHandlers(); + + function switchToPane(pane, sectionName) { + const paneContent = document.getElementById(`${pane}-pane-content`); + + const paneSelector = document.getElementById(`${pane}-selector`); + if (paneSelector.value !== sectionName) { + paneSelector.value = sectionName; + } + + // Handle special sections with JavaScript-generated content + if (sectionName === "SOURCE") { + // Initialize source display with byte range highlighting + paneContent.innerHTML = + '
'; + if (typeof initializeSourceDisplay === "function") { + initializeSourceDisplay(); } + return; } - } - function unhighlightTokens(min, max) { - for (let i = min; i <= max; i++) { - const token = document.querySelector(`[data-token-id="${i}"]`); - if (token) { - token.classList.remove("highlighted"); + if (sectionName === "TOKENS") { + // Initialize tokens display with hover highlighting + paneContent.innerHTML = + '
'; + if (typeof initializeTokensDisplay === "function") { + initializeTokensDisplay(); } + return; } - } - function tokenHighlightHandler(event) { - const target = event.target; - const tokenId = target.dataset.tokenId; - if (tokenId !== undefined) { - // Highlight the current element - target.classList.add("highlighted"); - // Find and highlight corresponding tokens in all areas - const allTokens = document.querySelectorAll( - `[data-token-id="${tokenId}"]`, - ); - allTokens.forEach((token) => { - if (token !== target) { - token.classList.add("highlighted"); + // Find the section in the data sections for other sections + const sections = document.querySelectorAll("#data-sections .section"); + let targetSectionContent = null; + + for (const section of sections) { + if (section.dataset.section === sectionName) { + const content = section.querySelector(".section-content"); + if (content) { + targetSectionContent = content.cloneNode(true); + break; } - }); - return; + } } - const startToken = target.dataset.startToken; - const endToken = target.dataset.endToken; - if (startToken !== undefined && endToken !== undefined) { - const startId = parseInt(startToken, 10); - const endId = parseInt(endToken, 10); - if (startId > endId) { - console.warn("Invalid token range:", startId, endId); - return; + // Update pane content + if (targetSectionContent && paneContent) { + paneContent.innerHTML = ""; + paneContent.appendChild(targetSectionContent); + } + } + + // Initial setup - show SOURCE on left, TOKENS on right + switchToPane("left", "SOURCE"); + switchToPane("right", "TOKENS"); +}); +// UTF-8 byte handling functions from rust_highlight_demo.html +function getUtf8Bytes(str) { + return new TextEncoder().encode(str); +} + +function decodeUtf8AndBuildMapping(bytes) { + const decoder = new TextDecoder("utf-8"); + const text = decoder.decode(bytes); + const byteToUtf16 = new Array(bytes.length); + const encoder = new TextEncoder(); + + let byteIndex = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const utf8 = encoder.encode(char); + // Map all bytes of this character to the same UTF-16 position + for (let j = 0; j < utf8.length; j++) { + if (byteIndex < bytes.length) { + byteToUtf16[byteIndex] = i; + byteIndex++; } - highlightTokens(startId, endId); - return; } } + return { text, byteToUtf16 }; +} - function tokenUnhighlightHandler(event) { - const target = event.target; - const tokenId = target.dataset.tokenId; +function buildTextClassSegments(text, byteToUtf16, tokens, highlightRange) { + const segments = []; + let cursor = 0; + let tokenIdx = 0; + const totalBytes = byteToUtf16.length; - if (tokenId !== undefined) { - // Remove highlights from all tokens with this ID - const allTokens = document.querySelectorAll( - `[data-token-id="${tokenId}"]`, - ); - allTokens.forEach((token) => { - token.classList.remove("highlighted"); - }); + let hlStart = highlightRange ? highlightRange.start : null; + let hlEnd = highlightRange + ? highlightRange.start + highlightRange.length + : null; + + while (cursor < totalBytes) { + // Find next token that starts at or after cursor + while ( + tokenIdx < tokens.length && + tokens[tokenIdx].start + tokens[tokenIdx].length <= cursor + ) { + tokenIdx++; } - const startToken = target.dataset.startToken; - const endToken = target.dataset.endToken; - if (startToken !== undefined && endToken !== undefined) { - const startId = parseInt(startToken, 10); - const endId = parseInt(endToken, 10); - if (startId > endId) { - console.warn("Invalid token range:", startId, endId); - return; + let nextToken = tokenIdx < tokens.length ? tokens[tokenIdx] : null; + let tokenStart = nextToken ? Math.max(nextToken.start, cursor) : totalBytes; + let tokenEnd = nextToken ? nextToken.start + nextToken.length : totalBytes; + + let boundaries = [tokenStart, tokenEnd]; + if (hlStart !== null) boundaries.push(hlStart); + if (hlEnd !== null) boundaries.push(hlEnd); + boundaries.push(totalBytes); + + let nextBoundary = Math.min(...boundaries.filter((b) => b > cursor)); + if (nextBoundary === Infinity) nextBoundary = totalBytes; + + let inToken = nextToken && cursor >= nextToken.start && cursor < tokenEnd; + let tokenClass = inToken ? nextToken.class : null; + let inHighlight = hlStart !== null && cursor >= hlStart && cursor < hlEnd; + + let classes = []; + if (tokenClass) classes.push(tokenClass); + if (inHighlight) classes.push("highlight"); + let classStr = classes.length ? classes.join(" ") : null; + + let start16 = byteToUtf16[cursor] ?? text.length; + let end16 = + byteToUtf16[Math.min(nextBoundary, totalBytes - 1)] ?? text.length; + if (nextBoundary >= totalBytes) end16 = text.length; + + if (start16 < end16) { + const segment = { text: text.slice(start16, end16), class: classStr }; + // Store byte range instead of token index for consistent mapping + if (inToken) { + segment.byteStart = nextToken.start; + segment.byteEnd = nextToken.start + nextToken.length; } - // Unhighlight all tokens in the range - unhighlightTokens(startId, endId); - return; + segments.push(segment); + } + + cursor = nextBoundary; + + // If we reached the end of a token, potentially advance tokenIdx + if (nextToken && cursor >= tokenEnd) { + tokenIdx++; } } - function tokenClickHandler(event) { - const target = event.target; - const tokenId = target.dataset.tokenId; + return segments; +} - if (!tokenId) return; +function updateDomFromSegments(container, newSegments) { + container.innerHTML = ""; + for (const seg of newSegments) { + if (seg.class) { + const span = document.createElement("span"); + span.className = seg.class; + span.textContent = seg.text; + // Add byte range data attributes for consistent mapping + if (seg.byteStart !== undefined && seg.byteEnd !== undefined) { + span.dataset.byteStart = seg.byteStart; + span.dataset.byteEnd = seg.byteEnd; + span.style.cursor = "pointer"; + } + container.appendChild(span); + } else { + container.appendChild(document.createTextNode(seg.text)); + } + } + // Add event listeners for reverse highlighting + addSourceEventListeners(container); +} - console.log("Clicked token ID:", tokenId, "Element:", target); +// Token category mapping +function getTokenClass(tokenKind) { + const keywords = [ + "KwApp", + "KwAs", + "KwCrash", + "KwDbg", + "KwElse", + "KwExpect", + "KwExposes", + "KwExposing", + "KwFor", + "KwGenerates", + "KwHas", + "KwHosted", + "KwIf", + "KwImplements", + "KwImport", + "KwImports", + "KwIn", + "KwInterface", + "KwMatch", + "KwModule", + "KwPackage", + "KwPackages", + "KwPlatform", + "KwProvides", + "KwRequires", + "KwReturn", + "KwVar", + "KwWhere", + "KwWith", + ]; + const identifiers = [ + "UpperIdent", + "LowerIdent", + "DotLowerIdent", + "DotUpperIdent", + "NoSpaceDotLowerIdent", + "NoSpaceDotUpperIdent", + "NamedUnderscore", + "OpaqueName", + ]; + const strings = [ + "StringStart", + "StringEnd", + "StringPart", + "MultilineStringStart", + "MultilineStringEnd", + "SingleQuote", + ]; + const numbers = [ + "Float", + "Int", + "DotInt", + "NoSpaceDotInt", + "MalformedNumberBadSuffix", + "MalformedNumberUnicodeSuffix", + "MalformedNumberNoDigits", + "MalformedNumberNoExponentDigits", + ]; + const operators = [ + "OpPlus", + "OpStar", + "OpBinaryMinus", + "OpUnaryMinus", + "OpEquals", + "OpNotEquals", + "OpAnd", + "OpOr", + "OpGreaterThan", + "OpLessThan", + "OpGreaterThanOrEq", + "OpLessThanOrEq", + "OpAssign", + "OpColonEqual", + "OpArrow", + "OpBackslash", + "OpBar", + "OpBang", + "OpQuestion", + "OpColon", + "OpPercent", + "OpDoubleSlash", + "OpCaret", + "OpAmpersand", + "OpPizza", + "OpSlash", + "OpDoubleQuestion", + "OpBackArrow", + "OpFatArrow", + "NoSpaceOpQuestion", + ]; + const brackets = [ + "OpenRound", + "CloseRound", + "OpenSquare", + "CloseSquare", + "OpenCurly", + "CloseCurly", + ]; + const punctuation = ["Comma", "Dot", "DoubleDot", "TripleDot", "Underscore"]; - // Find corresponding tokens in other panes - const leftPaneContent = document.getElementById("left-pane-content"); - const rightPaneContent = document.getElementById("right-pane-content"); + if (keywords.includes(tokenKind)) return "token-keyword"; + if (identifiers.includes(tokenKind)) return "token-identifier"; + if (strings.includes(tokenKind)) return "token-string"; + if (numbers.includes(tokenKind)) return "token-number"; + if (operators.includes(tokenKind)) return "token-operator"; + if (brackets.includes(tokenKind)) return "token-bracket"; + if (punctuation.includes(tokenKind)) return "token-punctuation"; + return "token-default"; +} - // Determine which pane the clicked token is in - const isInLeftPane = leftPaneContent.contains(target); - const isInRightPane = rightPaneContent.contains(target); +// Central function to find token index by byte range - single source of truth +function findTokenIndexByByteRange(byteStart, byteEnd) { + if (!window.rocTokens) return -1; + return window.rocTokens.findIndex( + ([kind, start, end]) => start === byteStart && end === byteEnd, + ); +} - console.log( - "Is in left pane:", - isInLeftPane, - "Is in right pane:", - isInRightPane, - ); +// Central function to find token by index - single source of truth +function getTokenByIndex(index) { + if (!window.rocTokens || index < 0 || index >= window.rocTokens.length) + return null; + const [kind, start, end] = window.rocTokens[index]; + return { kind, start, end, index }; +} - if (isInLeftPane) { - // Clicked in left pane, find and scroll to token in right pane - const rightToken = rightPaneContent.querySelector( - `[data-token-id="${tokenId}"]`, - ); - console.log("Looking for right token:", rightToken); - if (rightToken) { - scrollToAndFlash(rightToken, rightPaneContent); +// Add event listeners for source elements to highlight corresponding tokens or PARSE elements +function addSourceEventListeners(container) { + const spans = container.querySelectorAll( + "span[data-byte-start][data-byte-end]", + ); + spans.forEach((span) => { + span.addEventListener("mouseenter", () => { + const byteStart = parseInt(span.dataset.byteStart); + const byteEnd = parseInt(span.dataset.byteEnd); + + // Check what's currently displayed in the right pane + const rightSelector = document.getElementById("right-selector"); + const currentSection = rightSelector ? rightSelector.value : "TOKENS"; + + if (currentSection === "TOKENS") { + const tokenIndex = findTokenIndexByByteRange(byteStart, byteEnd); + if (tokenIndex >= 0) { + highlightTokenInList(tokenIndex); + } + } else if (currentSection === "PARSE") { + highlightParseElementAtByte(byteStart); + } + }); + span.addEventListener("mouseleave", () => { + const rightSelector = document.getElementById("right-selector"); + const currentSection = rightSelector ? rightSelector.value : "TOKENS"; + + if (currentSection === "TOKENS") { + clearTokenHighlights(); + } else if (currentSection === "PARSE") { + clearParseHighlights(); } - } else if (isInRightPane) { - // Clicked in right pane, find and scroll to token in left pane - // Look specifically for source tokens (spans with syntax highlighting classes) - const leftToken = leftPaneContent.querySelector( - `span[data-token-id="${tokenId}"]`, - ); - console.log("Looking for left token:", leftToken); - if (leftToken) { - scrollToAndFlash(leftToken, leftPaneContent); + }); + span.addEventListener("click", (event) => { + const byteStart = parseInt(span.dataset.byteStart); + const byteEnd = parseInt(span.dataset.byteEnd); + + // Get the click position within the span to determine the exact byte + const clickByte = getClickBytePosition(event, span, byteStart, byteEnd); + + const rightSelector = document.getElementById("right-selector"); + const currentSection = rightSelector ? rightSelector.value : "TOKENS"; + + if (currentSection === "TOKENS") { + const tokenIndex = findTokenIndexByByteRange(byteStart, byteEnd); + if (tokenIndex >= 0) { + scrollToTokenInList(tokenIndex); + } + } else if (currentSection === "PARSE") { + scrollToParseElementAtByte(clickByte); } + }); + }); +} + +function highlightTokenInList(tokenIndex) { + const tokensContainer = document.getElementById("tokens-display"); + if (tokensContainer) { + const tokenDivs = tokensContainer.querySelectorAll(".token-item"); + if (tokenDivs[tokenIndex]) { + tokenDivs[tokenIndex].style.backgroundColor = "#ffffcc"; + tokenDivs[tokenIndex].style.outline = "2px solid #ffd700"; } } +} - function scrollToAndFlash(element, container) { - // Scroll the container to bring the element into view - const containerRect = container.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); +function clearTokenHighlights() { + const tokensContainer = document.getElementById("tokens-display"); + if (tokensContainer) { + const tokenDivs = tokensContainer.querySelectorAll(".token-item"); + tokenDivs.forEach((div) => { + div.style.backgroundColor = ""; + div.style.outline = ""; + }); + } +} - // Calculate if element is outside the visible area - const isAbove = elementRect.top < containerRect.top; - const isBelow = elementRect.bottom > containerRect.bottom; +function scrollToTokenInList(tokenIndex) { + const tokensContainer = document.getElementById("tokens-display"); + if (tokensContainer) { + const tokenDivs = tokensContainer.querySelectorAll(".token-item"); + if (tokenDivs[tokenIndex]) { + const targetToken = tokenDivs[tokenIndex]; + const paneContent = tokensContainer.closest(".pane-content"); + if (paneContent) { + scrollToAndFlash(targetToken, paneContent); + } + } + } +} - if (isAbove || isBelow) { - // Calculate the scroll position to center the element - const containerScrollTop = container.scrollTop; - const elementOffsetTop = element.offsetTop; - const containerHeight = container.clientHeight; - const elementHeight = element.offsetHeight; +function scrollToSourceToken(tokenIndex) { + const token = getTokenByIndex(tokenIndex); + if (!token) return; - const scrollTo = - elementOffsetTop - containerHeight / 2 + elementHeight / 2; + // Use byte range highlighting instead of flashing + scrollToSourceRange(token.start, token.end); +} - // Smooth scroll to the target position - container.scrollTo({ - top: scrollTo, - behavior: "smooth", - }); - } +function scrollToAndFlash(element, container) { + // Calculate if element is outside the visible area + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); - // Add flash animation - element.classList.remove("flash-highlight"); - // Force reflow to restart animation - element.offsetHeight; - element.classList.add("flash-highlight"); + const isAbove = elementRect.top < containerRect.top; + const isBelow = elementRect.bottom > containerRect.bottom; + + if (isAbove || isBelow) { + // Calculate the scroll position to center the element + const containerScrollTop = container.scrollTop; + const elementOffsetTop = element.offsetTop; + const containerHeight = container.clientHeight; + const elementHeight = element.offsetHeight; - // Remove the flash class after animation completes - setTimeout(() => { - element.classList.remove("flash-highlight"); - }, 300); + const scrollTo = elementOffsetTop - containerHeight / 2 + elementHeight / 2; + + // Smooth scroll to the target position + container.scrollTo({ + top: scrollTo, + behavior: "smooth", + }); } - // Pane switching functionality - window.switchLeftPane = function () { - const selector = document.getElementById("left-selector"); - const selectedSection = selector.value; - switchToPane("left", selectedSection); - }; + // Add flash animation + element.classList.remove("flash-highlight"); + // Force reflow to restart animation + element.offsetHeight; + element.classList.add("flash-highlight"); - window.switchRightPane = function () { - const selector = document.getElementById("right-selector"); - const selectedSection = selector.value; - switchToPane("right", selectedSection); - }; + // Remove the flash class after animation completes + setTimeout(() => { + element.classList.remove("flash-highlight"); + }, 600); +} - function switchToPane(pane, sectionName) { - const paneContent = document.getElementById(`${pane}-pane-content`); +// Initialize display when sections are selected +function initializeSourceDisplay() { + if (!window.rocSourceCode) return; + const sourceElem = document.getElementById("source-display"); + if (sourceElem) { + const bytes = getUtf8Bytes(window.rocSourceCode); + const { text, byteToUtf16 } = decodeUtf8AndBuildMapping(bytes); + window.rocSourceMapping = { bytes, text, byteToUtf16 }; - const paneSelector = document.getElementById(`${pane}-selector`); - if (paneSelector.value !== sectionName) { - paneSelector.value = sectionName; + // Convert tokens to format expected by buildTextClassSegments + if (window.rocTokens) { + window.rocTokensFormatted = window.rocTokens + .map(([kind, start, end]) => ({ + start, + length: end - start, + class: getTokenClass(kind), + })) + .sort((a, b) => a.start - b.start); } - // Find the section in the data sections - const sections = document.querySelectorAll("#data-sections .section"); - let targetSectionContent = null; + renderSourceHighlight(null); + } +} - for (const section of sections) { - if (section.dataset.section === sectionName) { - const content = section.querySelector(".section-content"); - if (content) { - targetSectionContent = content.cloneNode(true); - break; +function initializeTokensDisplay() { + if (!window.rocTokens) return; + const tokensElem = document.getElementById("tokens-display"); + if (tokensElem) { + tokensElem.innerHTML = ""; + window.rocTokens.forEach(([kind, start, end], index) => { + const div = document.createElement("div"); + div.className = "token-item " + getTokenClass(kind); + div.textContent = `${kind}`; + // Store both index and byte ranges for consistent mapping + div.dataset.tokenIndex = index; + div.dataset.byteStart = start; + div.dataset.byteEnd = end; + div.style.cursor = "pointer"; + div.addEventListener("mouseenter", () => { + renderSourceHighlight({ start, length: end - start }); + }); + div.addEventListener("mouseleave", () => { + renderSourceHighlight(null); + }); + div.addEventListener("click", () => { + scrollToSourceToken(index); + }); + tokensElem.appendChild(div); + }); + } +} + +function renderSourceHighlight(range) { + if (!window.rocSourceMapping) return; + const sourceElem = document.getElementById("source-display"); + if (!sourceElem) return; + + const { text, byteToUtf16 } = window.rocSourceMapping; + const tokens = window.rocTokensFormatted || []; + const segments = buildTextClassSegments(text, byteToUtf16, tokens, range); + updateDomFromSegments(sourceElem, segments); +} + +// Setup event delegation for source-range elements in PARSE tree and other sections +function setupSourceRangeHandlers() { + document.addEventListener( + "mouseenter", + function (event) { + if (event.target.classList.contains("source-range")) { + const startByte = parseInt(event.target.dataset.startByte); + const endByte = parseInt(event.target.dataset.endByte); + if (!isNaN(startByte) && !isNaN(endByte)) { + highlightSourceRange(startByte, endByte); } } + }, + true, + ); + + document.addEventListener( + "mouseleave", + function (event) { + if (event.target.classList.contains("source-range")) { + clearSourceRangeHighlight(); + } + }, + true, + ); + + document.addEventListener( + "click", + function (event) { + if (event.target.classList.contains("source-range")) { + const startByte = parseInt(event.target.dataset.startByte); + const endByte = parseInt(event.target.dataset.endByte); + if (!isNaN(startByte) && !isNaN(endByte)) { + scrollToSourceRange(startByte, endByte); + } + } + }, + true, + ); +} + +// Highlight source range in the SOURCE pane +function highlightSourceRange(startByte, endByte) { + const range = { start: startByte, length: endByte - startByte }; + renderSourceHighlight(range); +} + +// Clear source range highlighting +function clearSourceRangeHighlight() { + renderSourceHighlight(null); +} + +// Scroll to and highlight a source range using yellow highlighting +function scrollToSourceRange(startByte, endByte) { + const sourceContainer = document.getElementById("source-display"); + if (!sourceContainer) return; + + // Always use byte range highlighting with synthetic elements (yellow background) + highlightSourceRange(startByte, endByte); + + // Find the best element to scroll to by looking for overlapping spans + const allSpans = sourceContainer.querySelectorAll("span[data-byte-start][data-byte-end]"); + let targetSpan = null; + + for (const span of allSpans) { + const spanStart = parseInt(span.dataset.byteStart); + const spanEnd = parseInt(span.dataset.byteEnd); + + // Check if this span overlaps with our target range + if (spanStart <= startByte && startByte < spanEnd) { + targetSpan = span; + break; } + } - // Update pane content - if (targetSectionContent && paneContent) { - paneContent.innerHTML = ""; - paneContent.appendChild(targetSectionContent); + // If no overlapping token span found, look for the highlighted elements themselves + if (!targetSpan) { + const highlightedSpans = sourceContainer.querySelectorAll("span.highlight"); + if (highlightedSpans.length > 0) { + targetSpan = highlightedSpans[0]; // Use the first highlighted element } } - // Initial setup - show SOURCE on left, TOKENS on right - switchToPane("left", "SOURCE"); - switchToPane("right", "TOKENS"); - setupTokenHighlighting(); -}); + if (targetSpan) { + const paneContent = sourceContainer.closest(".pane-content"); + if (paneContent) { + // Scroll to the target span + const containerRect = paneContent.getBoundingClientRect(); + const elementRect = targetSpan.getBoundingClientRect(); + + const isAbove = elementRect.top < containerRect.top; + const isBelow = elementRect.bottom > containerRect.bottom; + + if (isAbove || isBelow) { + const containerScrollTop = paneContent.scrollTop; + const elementOffsetTop = targetSpan.offsetTop; + const containerHeight = paneContent.clientHeight; + const elementHeight = targetSpan.offsetHeight; + + const scrollTo = elementOffsetTop - containerHeight / 2 + elementHeight / 2; + + paneContent.scrollTo({ + top: scrollTo, + behavior: "smooth", + }); + } + } + } + + // Add flashing animation to the highlighted synthetic elements + flashHighlightedElements(); +} + +// Flash the currently highlighted synthetic elements +function flashHighlightedElements() { + const sourceContainer = document.getElementById("source-display"); + if (!sourceContainer) return; + + const highlightedSpans = sourceContainer.querySelectorAll("span.highlight"); + highlightedSpans.forEach(span => { + span.classList.add("flash-highlight"); + }); + + // Remove the flash class after animation completes + setTimeout(() => { + highlightedSpans.forEach(span => { + span.classList.remove("flash-highlight"); + }); + }, 600); +} + +// Get the exact byte position within a span based on click coordinates +function getClickBytePosition(event, span, byteStart, byteEnd) { + const spanRect = span.getBoundingClientRect(); + const clickX = event.clientX - spanRect.left; + const spanWidth = spanRect.width; + + if (spanWidth === 0) return byteStart; + + // Calculate the relative position within the span (0 to 1) + const relativePosition = Math.max(0, Math.min(1, clickX / spanWidth)); + + // Map to byte position + const byteRange = byteEnd - byteStart; + const clickByte = Math.floor(byteStart + (relativePosition * byteRange)); + + return Math.max(byteStart, Math.min(byteEnd - 1, clickByte)); +} + +// Find the narrowest PARSE element containing a specific byte position +function highlightParseElementAtByte(bytePosition) { + const parseContainer = document.getElementById("right-pane-content"); + if (!parseContainer) return; + + const targetElement = findNarrowestParseElement(parseContainer, bytePosition); + if (targetElement) { + // Clear existing highlights + clearParseHighlights(); + + // Highlight the target element + targetElement.classList.add("highlighted"); + } +} + +// Scroll to and flash the narrowest PARSE element containing a specific byte position +function scrollToParseElementAtByte(bytePosition) { + const parseContainer = document.getElementById("right-pane-content"); + if (!parseContainer) return; + + const targetElement = findNarrowestParseElement(parseContainer, bytePosition); + if (targetElement) { + // Clear existing highlights + clearParseHighlights(); + + // Highlight the target element + targetElement.classList.add("highlighted"); + + // Scroll to the element and flash it + const paneContent = parseContainer.closest(".pane-content"); + if (paneContent) { + scrollToAndFlash(targetElement, paneContent); + } + } +} + +// Find the narrowest (most specific) element containing the byte position +function findNarrowestParseElement(container, bytePosition) { + const sourceRangeElements = container.querySelectorAll(".source-range[data-start-byte][data-end-byte]"); + + let narrowestElement = null; + let narrowestRange = Infinity; + + for (const element of sourceRangeElements) { + const startByte = parseInt(element.dataset.startByte); + const endByte = parseInt(element.dataset.endByte); + + // Check if the byte position is within this element's range + if (bytePosition >= startByte && bytePosition < endByte) { + const range = endByte - startByte; + + // Choose this element if it's narrower than the current best + // In case of ties, the first one encountered wins (DOM order) + if (range < narrowestRange) { + narrowestElement = element; + narrowestRange = range; + } + } + } + + return narrowestElement; +} + +// Clear all highlights in the PARSE tree +function clearParseHighlights() { + const parseContainer = document.getElementById("right-pane-content"); + if (parseContainer) { + const highlightedElements = parseContainer.querySelectorAll(".highlighted"); + highlightedElements.forEach(element => { + element.classList.remove("highlighted"); + }); + } +} diff --git a/src/snapshot.zig b/src/snapshot.zig index 3472a1d914..abda0801e0 100644 --- a/src/snapshot.zig +++ b/src/snapshot.zig @@ -317,7 +317,7 @@ fn processRocFileAsSnapshot(allocator: Allocator, output_path: []const u8, roc_c // Generate all sections try generateMetaSection(&output, &content); - try generateSourceSection(&output, &content, &ast); + try generateSourceSection(&output, &content); try generateProblemsSection(&output, &ast, &can_ir, &solver, &content, output_path, &module_env); try generateTokensSection(&output, &ast, &content, &module_env); try generateParseSection(&output, &content, &ast, &module_env); @@ -728,109 +728,39 @@ fn generateMetaSection(output: *DualOutput, content: *const Content) !void { } /// Generate SOURCE section for both markdown and HTML -fn generateSourceSection(output: *DualOutput, content: *const Content, parse_ast: *AST) !void { +fn generateSourceSection(output: *DualOutput, content: *const Content) !void { try output.begin_section("SOURCE"); try output.begin_code_block("roc"); try output.md_writer.writeAll(content.source); try output.md_writer.writeAll("\n"); - // HTML SOURCE section with syntax highlighting + // HTML SOURCE section - encode source as JavaScript string try output.html_writer.writeAll( - \\
+ \\
+ \\
+ \\ \\ ); + try output.end_code_block(); try output.end_section(); } @@ -976,8 +906,12 @@ fn generateTokensSection(output: *DualOutput, parse_ast: *AST, content: *const C try output.begin_section("TOKENS"); try output.begin_code_block("zig"); + // HTML TOKENS section - encode tokens as JavaScript array try output.html_writer.writeAll( - \\
+ \\
+ \\
+ \\ \\ ); try output.end_code_block(); @@ -1152,25 +1091,20 @@ fn generateFormattedSection(output: *DualOutput, content: *const Content, parse_ /// Generate CANONICALIZE section for both markdown and HTML fn generateCanonicalizeSection(output: *DualOutput, content: *const Content, can_ir: *CIR, maybe_expr_idx: ?CIR.Expr.Idx) !void { - var canonicalized = std.ArrayList(u8).init(output.gpa); - defer canonicalized.deinit(); - - try can_ir.toSExprStr(canonicalized.writer().any(), maybe_expr_idx, content.source); + var node = can_ir.toSExpr(maybe_expr_idx, content.source); + defer node.deinit(can_ir.env.gpa); try output.begin_section("CANONICALIZE"); - try output.begin_code_block("clojure"); - try output.md_writer.writeAll(canonicalized.items); - try output.md_writer.writeAll("\n"); - // HTML CANONICALIZE section try output.html_writer.writeAll( \\
     );
+    try output.begin_code_block("clojure");
 
-    // Escape HTML in canonicalized content
-    for (canonicalized.items) |char| {
-        try escapeHtmlChar(output.html_writer, char);
-    }
+    node.toStringPretty(output.md_writer.any());
+    node.toHtml(output.html_writer.any());
+
+    try output.md_writer.writeAll("\n");
 
     try output.html_writer.writeAll(
         \\
@@ -1309,9 +1243,8 @@ fn generateHtmlClosing(output: *DualOutput) !void { \\
\\ \\ @@ -1462,7 +1395,7 @@ fn processSnapshotFileUnified(gpa: Allocator, snapshot_path: []const u8, maybe_f // Generate all sections simultaneously try generateMetaSection(&output, &content); - try generateSourceSection(&output, &content, &parse_ast); + try generateSourceSection(&output, &content); try generateProblemsSection(&output, &parse_ast, &can_ir, &solver, &content, snapshot_path, &module_env); try generateTokensSection(&output, &parse_ast, &content, &module_env);