]> Cypherpunks repositories - gostls13.git/commitdiff
cmd/compile: enhance astdump flag to also generate HTML
authorDavid Chase <drchase@google.com>
Fri, 30 Jan 2026 06:46:05 +0000 (08:46 +0200)
committerDavid Chase <drchase@google.com>
Wed, 4 Feb 2026 04:24:06 +0000 (20:24 -0800)
AI-generated code, 3 merged commits, plus a LOT of hand
cleanups and tweaks, including removing cargo-culted dead
code from the SSA example, reorganizing CSS and JS out of a
single giant comment, using defer appropriately to ensure
balanced open/close tags, running output through tidy to
check compliance.  Prompts are included for reference.

This is intended to produce an HTML file in the style of "ssa.html",
but for AST.  The result of various phases appears in columns,
which can be scrolled side to side, and also dragged sideways
to make them wider (because AST tends wide).

This supports three kinds of highlighting, which I tweaked
along the way to make them (in my opinion) more useful.

1) Node outlining.  This outlines a node and all of its subtrees.
When the cursor is a "cell" (outlined cross) node highlighting
is available.  Note that "NAME" nodes are repeated within the
tree, so click on one of these will outline every occurrence.
This is actually done with pointer identity.

2) Name highlighting, available with a "crosshair" cursor.
This highlights a name, e.g. "autotmp_1".

3) Position highlighting, available with a "crosshair" cursor.
This highlights either a file (all occurrences of that file's positions),
a line within a file (all occurrences of that file:line combination),
or a column (all occurrences of that particular file:line:column).
Inlined positions are treated as a sequence of positions, not a single
position.

Prompts:
```
The file cmd/compile/internal/ir/dump.go contains a function AstDump
that calls FDump to generate a textual representation of the AST from
several phases within the compiler.

The file cmd/compile/internal/ir/fmt.go contains the definition of
FDump.

The SSA phases of the compiler use code in
cmd/compile/internal/ssa/html.go to render its textual representation
into an html display that allows hiding phases and highlighting blocks,
identifiers and line numbers.

Please write a similar HTML-generating phase for AST that produces an
output that will allow hiding phases and perhaps hiding subtrees, and
highlighting identifiers and line numbers.  The idioms and hacks used
in the SSA html display have worked well, if you want to copy them.
```

```
Not bad, but the output contains a strike-through beginning on a line
that contains  "TYPE  type *testing.B tc(1)".  The strike through
begins after "TYPE " and continues for the rest of the output.  Can you
find that bug and fix it?
```

```
The AST output is often wider than the statically sized columns in the
HTML output.  Either the columns need to have a width that can be
resized (dragged wider, for example) or the AST needs to be draggable,
side-to-side, within the columns.  Resizable columns seems like the
preferable choice, if it is possible.
```

```
The highlighting for file name and line number is not quite right -- all
the lines in the same file are grouped together, where what I want, is
that each different file:line:column gets its own number.  There's also
the issue of inlining, in some cases the location is described as more
than one file:line:column, where the first is the call site and the
second is the inlined function.  I think it makes sense to treat each
single file:line:column as its own item for highlighting, instead of
trying to treat the sequence of file:line:column as a single distinct
location.  One thing that might be interesting, but I am not sure how
hard it would be, is to distinguish between clicks to the file part,
the line part, and the column part -- click on file means highlight
all that matches file, click on line means all that machines file:line
(not just the line numner, since there may be different files, with
inlining) and click on the column means to highlight the specific
file:line:column triple.  That is, if it is possible.
```

```
Lovely.  Can you implement highlighting for names, strings
like "NAME-testing.b" so that all uses of a variable or a temporary can
easily be seen?
```

Change-Id: I1ed97cd92cdae16d556e3334e543af37973799e5
Reviewed-on: https://go-review.googlesource.com/c/go/+/740563
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: t hepudds <thepudds1460@gmail.com>
Reviewed-by: Keith Randall <khr@google.com>
src/cmd/compile/internal/gc/main.go
src/cmd/compile/internal/ir/dump.go
src/cmd/compile/internal/ir/html.go [new file with mode: 0644]
src/cmd/compile/internal/ir/html_test.go [new file with mode: 0644]

index 66b7ca35b4172ca6b49d5ca02dbee89b8b38221a..9c090f2a362f06467d40d9b1a12f3bf1f63bcf62 100644 (file)
@@ -47,6 +47,7 @@ import (
 // already been some compiler errors). It may also be invoked from the explicit panic in
 // hcrash(), in which case, we pass the panic on through.
 func handlePanic() {
+       ir.CloseHTMLWriters()
        if err := recover(); err != nil {
                if err == "-h" {
                        // Force real panic now with -h option (hcrash) - the error
@@ -243,6 +244,12 @@ func Main(archInit func(*ssagen.ArchInfo)) {
                }
        }
 
+       for _, fn := range typecheck.Target.Funcs {
+               if ir.MatchAstDump(fn, "start") {
+                       ir.AstDump(fn, "start, "+ir.FuncName(fn))
+               }
+       }
+
        // Apply bloop markings.
        bloop.Walk(typecheck.Target)
 
@@ -250,6 +257,12 @@ func Main(archInit func(*ssagen.ArchInfo)) {
        base.Timer.Start("fe", "devirtualize-and-inline")
        interleaved.DevirtualizeAndInlinePackage(typecheck.Target, profile)
 
+       for _, fn := range typecheck.Target.Funcs {
+               if ir.MatchAstDump(fn, "devirtualize-and-inline") {
+                       ir.AstDump(fn, "devirtualize-and-inline, "+ir.FuncName(fn))
+               }
+       }
+
        noder.MakeWrappers(typecheck.Target) // must happen after inlining
 
        // Get variable capture right in for loops.
index e31b9e468a37257509c3114c470e9c3764a1adbb..13599d7c50a15721b58a746a7d81bf83d3ff28f0 100644 (file)
@@ -143,6 +143,12 @@ func AstDump(fn *Func, why string) {
                        FDump(w, why, fn)
                },
        )
+       // strip text following comma, for phase names.
+       comma := strings.Index(why, ",")
+       if comma > 0 {
+               why = why[:comma]
+       }
+       DumpNodeHTML(fn, why, fn)
        if err != nil {
                fmt.Fprintf(os.Stderr, "Dump returned error %v\n", err)
        }
@@ -189,6 +195,37 @@ func withLockAndFile(fn *Func, dump func(io.Writer)) (err error) {
        return
 }
 
+var htmlWriters = make(map[*Func]*HTMLWriter)
+var orderedFuncs = []*Func{}
+
+// DumpNodeHTML dumps the node n to the HTML writer for fn.
+// It uses the same phase name as the text dump.
+func DumpNodeHTML(fn *Func, why string, n Node) {
+       mu.Lock()
+       defer mu.Unlock()
+       w, ok := htmlWriters[fn]
+       if !ok {
+               name := escapedFileName(fn, ".html")
+               w = NewHTMLWriter(name, fn, "")
+               htmlWriters[fn] = w
+               orderedFuncs = append(orderedFuncs, fn)
+       }
+       w.WritePhase(why, why)
+}
+
+// CloseHTMLWriter closes the HTML writer for fn, if one exists.
+func CloseHTMLWriters() {
+       mu.Lock()
+       defer mu.Unlock()
+       for _, fn := range orderedFuncs {
+               if w, ok := htmlWriters[fn]; ok {
+                       w.Close()
+                       delete(htmlWriters, fn)
+               }
+       }
+       orderedFuncs = nil
+}
+
 type dumper struct {
        output  io.Writer
        fieldrx *regexp.Regexp  // field name filter
diff --git a/src/cmd/compile/internal/ir/html.go b/src/cmd/compile/internal/ir/html.go
new file mode 100644 (file)
index 0000000..c17d465
--- /dev/null
@@ -0,0 +1,926 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package ir
+
+import (
+       "bufio"
+       "cmd/compile/internal/base"
+       "cmd/compile/internal/types"
+       "cmd/internal/src"
+       "crypto/sha256"
+       "encoding/hex"
+       "fmt"
+       "html"
+       "io"
+       "os"
+       "path/filepath"
+       "reflect"
+       "strings"
+)
+
+// An HTMLWriter dumps IR to multicolumn HTML, similar to what the
+// ssa backend does for GOSSAFUNC.  This is not the format used for
+// the ast column in GOSSAFUNC output.
+type HTMLWriter struct {
+       w             *BufferedWriterCloser
+       Func          *Func
+       canonIdMap    map[Node]int
+       prevCanonId   int
+       path          string
+       prevHash      []byte
+       pendingPhases []string
+       pendingTitles []string
+}
+
+// BufferedWriterCloser is here to help avoid pre-buffering the whole
+// rendered HTML in memory, which can cause problems for large inputs.
+type BufferedWriterCloser struct {
+       file io.Closer
+       w    *bufio.Writer
+}
+
+func (b *BufferedWriterCloser) Write(p []byte) (n int, err error) {
+       return b.w.Write(p)
+}
+
+func (b *BufferedWriterCloser) Close() error {
+       b.w.Flush()
+       b.w = nil
+       return b.file.Close()
+}
+
+func NewBufferedWriterCloser(f io.WriteCloser) *BufferedWriterCloser {
+       return &BufferedWriterCloser{file: f, w: bufio.NewWriter(f)}
+}
+
+func NewHTMLWriter(path string, f *Func, cfgMask string) *HTMLWriter {
+       path = strings.ReplaceAll(path, "/", string(filepath.Separator))
+       out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+       if err != nil {
+               base.Fatalf("%v", err)
+       }
+       reportPath := path
+       if !filepath.IsAbs(reportPath) {
+               pwd, err := os.Getwd()
+               if err != nil {
+                       base.Fatalf("%v", err)
+               }
+               reportPath = filepath.Join(pwd, path)
+       }
+       h := HTMLWriter{
+               w:          NewBufferedWriterCloser(out),
+               Func:       f,
+               path:       reportPath,
+               canonIdMap: make(map[Node]int),
+       }
+       h.start()
+       return &h
+}
+
+// canonId assigns indices to nodes based on pointer identity.
+// this helps ensure that output html files don't gratuitously
+// differ from run to run.
+func (h *HTMLWriter) canonId(n Node) int {
+       if id := h.canonIdMap[n]; id > 0 {
+               return id
+       }
+       h.prevCanonId++
+       h.canonIdMap[n] = h.prevCanonId
+       return h.prevCanonId
+}
+
+// Fatalf reports an error and exits.
+func (w *HTMLWriter) Fatalf(msg string, args ...any) {
+       base.FatalfAt(src.NoXPos, msg, args...)
+}
+
+func (w *HTMLWriter) start() {
+       if w == nil {
+               return
+       }
+       escName := html.EscapeString(PkgFuncName(w.Func))
+       w.Print("<!DOCTYPE html>")
+       w.Print("<html>")
+       w.Printf(`<head>
+<meta name="generator" content="AST display for %s">
+<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
+%s
+%s
+<title>AST display for %s</title>
+</head>`, escName, CSS, JS, escName)
+       w.Print("<body>")
+       w.Print("<h1>")
+       w.Print(html.EscapeString(w.Func.Sym().Name))
+       w.Print("</h1>")
+       w.Print(`
+<a href="#" onclick="toggle_visibility('help');return false;" id="helplink">help</a>
+<div id="help">
+
+<p>
+Click anywhere on a node (with "cell" cursor) to outline a node and all of its subtrees.
+</p>
+<p>
+Click on a name (with "crosshair" cursor) to highlight every occurrence of a name.
+(Note that all the name nodes are the same node, so those also all outline together).
+</p>
+<p>
+Click on a file, line, or column (with "crosshair" cursor) to highlight positions
+in that file, at that file:line, or at that file:line:column, respectively.<br>Inlined
+locations are not treated as a single location, but as a sequence of locations that
+can be independently highlighted.
+</p>
+
+</div>
+<label for="dark-mode-button" style="margin-left: 15px; cursor: pointer;">darkmode</label>
+<input type="checkbox" onclick="toggleDarkMode();" id="dark-mode-button" style="cursor: pointer" />
+`)
+       w.Print("<table>")
+       w.Print("<tr>")
+}
+
+func (w *HTMLWriter) Close() {
+       if w == nil {
+               return
+       }
+       w.Print("</tr>")
+       w.Print("</table>")
+       w.Print("</body>")
+       w.Print("</html>\n")
+       w.w.Close()
+       fmt.Fprintf(os.Stderr, "Writing html ast output for %s to %s\n", PkgFuncName(w.Func), w.path)
+}
+
+// WritePhase writes f in a column headed by title.
+// phase is used for collapsing columns and should be unique across the table.
+func (w *HTMLWriter) WritePhase(phase, title string) {
+       if w == nil {
+               return // avoid generating HTML just to discard it
+       }
+       w.pendingPhases = append(w.pendingPhases, phase)
+       w.pendingTitles = append(w.pendingTitles, title)
+       w.flushPhases()
+}
+
+// flushPhases collects any pending phases and titles, writes them to the html, and resets the pending slices.
+func (w *HTMLWriter) flushPhases() {
+       phaseLen := len(w.pendingPhases)
+       if phaseLen == 0 {
+               return
+       }
+       phases := strings.Join(w.pendingPhases, "  +  ")
+       w.WriteMultiTitleColumn(
+               phases,
+               w.pendingTitles,
+               "allow-x-scroll",
+               w.FuncHTML(w.pendingPhases[phaseLen-1]),
+       )
+       w.pendingPhases = w.pendingPhases[:0]
+       w.pendingTitles = w.pendingTitles[:0]
+}
+
+func (w *HTMLWriter) WriteMultiTitleColumn(phase string, titles []string, class string, writeContent func()) {
+       if w == nil {
+               return
+       }
+       id := strings.ReplaceAll(phase, " ", "-")
+       // collapsed column
+       w.Printf("<td id=\"%v-col\" class=\"collapsed\"><div>%v</div></td>", id, phase)
+
+       if class == "" {
+               w.Printf("<td id=\"%v-exp\">", id)
+       } else {
+               w.Printf("<td id=\"%v-exp\" class=\"%v\">", id, class)
+       }
+       for _, title := range titles {
+               w.Print("<h2>" + title + "</h2>")
+       }
+       writeContent()
+       w.Print("<div class=\"resizer\"></div>")
+       w.Print("</td>\n")
+}
+
+func (w *HTMLWriter) Printf(msg string, v ...any) {
+       if _, err := fmt.Fprintf(w.w, msg, v...); err != nil {
+               w.Fatalf("%v", err)
+       }
+}
+
+func (w *HTMLWriter) Print(s string) {
+       if _, err := fmt.Fprint(w.w, s); err != nil {
+               w.Fatalf("%v", err)
+       }
+}
+
+func (w *HTMLWriter) indent(n int) {
+       indent(w.w, n)
+}
+
+func (w *HTMLWriter) FuncHTML(phase string) func() {
+       return func() {
+               w.Print("<pre>") // use pre for formatting to preserve indentation
+               w.dumpNodesHTML(w.Func.Body, 1)
+               w.Print("</pre>")
+       }
+}
+
+func (h *HTMLWriter) dumpNodesHTML(list Nodes, depth int) {
+       if len(list) == 0 {
+               h.Print(" <nil>")
+               return
+       }
+
+       for _, n := range list {
+               h.dumpNodeHTML(n, depth)
+       }
+}
+
+func (h *HTMLWriter) dumpNodeHTML(n Node, depth int) {
+       indent(h.w, depth)
+       if depth > 40 {
+               h.Print("...")
+               return
+       }
+
+       if n == nil {
+               h.Print("NilIrNode")
+               return
+       }
+
+       // For HTML, we want to wrap the node and its details in a span that can be highlighted
+       // across all occurrences of the span in all columns, so it has to be linked to the node ID,
+       // which is its address. Canonicalize the address to a counter so that repeated compiler
+       // runs yield the same html.
+       //
+       // JS Equivalence logic:
+       //   var c = elem.classList.item(0);
+       //   var x = document.getElementsByClassName(c);
+       //
+       // Tag each class with its canonicalized index.
+
+       h.Printf("<span class=\"n%d ir-node\">", h.canonId(n))
+       defer h.Printf("</span>")
+
+       if len(n.Init()) != 0 {
+               h.Printf("%+v-init", n.Op())
+               h.dumpNodesHTML(n.Init(), depth+1)
+               h.indent(depth)
+       }
+
+       switch n.Op() {
+       default:
+               h.Printf("%+v", n.Op())
+               h.dumpNodeHeaderHTML(n)
+
+       case OLITERAL:
+               h.Printf("%+v-%v", n.Op(), html.EscapeString(fmt.Sprintf("%v", n.Val())))
+               h.dumpNodeHeaderHTML(n)
+               return
+
+       case ONAME, ONONAME:
+               if n.Sym() != nil {
+                       // Name highlighting:
+                       // Create a hash for the symbol name to use as a class
+                       // We use the same irValueClicked logic which uses the first class as the identifier
+                       name := fmt.Sprintf("%v", n.Sym())
+                       hash := sha256.Sum256([]byte(name))
+                       symID := "sym-" + hex.EncodeToString(hash[:6])
+                       h.Printf("%+v-<span class=\"%s variable-name\">%+v</span>", n.Op(), symID, html.EscapeString(name))
+               } else {
+                       h.Printf("%+v", n.Op())
+               }
+               h.dumpNodeHeaderHTML(n)
+               return
+
+       case OLINKSYMOFFSET:
+               n := n.(*LinksymOffsetExpr)
+               h.Printf("%+v-%v", n.Op(), html.EscapeString(fmt.Sprintf("%v", n.Linksym)))
+               if n.Offset_ != 0 {
+                       h.Printf("%+v", n.Offset_)
+               }
+               h.dumpNodeHeaderHTML(n)
+
+       case OASOP:
+               n := n.(*AssignOpStmt)
+               h.Printf("%+v-%+v", n.Op(), n.AsOp)
+               h.dumpNodeHeaderHTML(n)
+
+       case OTYPE:
+               h.Printf("%+v %+v", n.Op(), html.EscapeString(fmt.Sprintf("%v", n.Sym())))
+               h.dumpNodeHeaderHTML(n)
+               return
+
+       case OCLOSURE:
+               h.Printf("%+v", n.Op())
+               h.dumpNodeHeaderHTML(n)
+
+       case ODCLFUNC:
+               n := n.(*Func)
+               h.Printf("%+v", n.Op())
+               h.dumpNodeHeaderHTML(n)
+               fn := n
+               if len(fn.Dcl) > 0 {
+                       h.indent(depth)
+                       h.Printf("%+v-Dcl", n.Op())
+                       for _, dcl := range n.Dcl {
+                               h.dumpNodeHTML(dcl, depth+1)
+                       }
+               }
+               if len(fn.ClosureVars) > 0 {
+                       h.indent(depth)
+                       h.Printf("%+v-ClosureVars", n.Op())
+                       for _, cv := range fn.ClosureVars {
+                               h.dumpNodeHTML(cv, depth+1)
+                       }
+               }
+               if len(fn.Body) > 0 {
+                       h.indent(depth)
+                       h.Printf("%+v-body", n.Op())
+                       h.dumpNodesHTML(fn.Body, depth+1)
+               }
+               return
+       }
+
+       v := reflect.ValueOf(n).Elem()
+       t := reflect.TypeOf(n).Elem()
+       nf := t.NumField()
+       for i := 0; i < nf; i++ {
+               tf := t.Field(i)
+               vf := v.Field(i)
+               if tf.PkgPath != "" {
+                       continue
+               }
+               switch tf.Type.Kind() {
+               case reflect.Interface, reflect.Ptr, reflect.Slice:
+                       if vf.IsNil() {
+                               continue
+                       }
+               }
+               name := strings.TrimSuffix(tf.Name, "_")
+               switch name {
+               case "X", "Y", "Index", "Chan", "Value", "Call":
+                       name = ""
+               }
+               switch val := vf.Interface().(type) {
+               case Node:
+                       if name != "" {
+                               h.indent(depth)
+                               h.Printf("%+v-%s", n.Op(), name)
+                       }
+                       h.dumpNodeHTML(val, depth+1)
+               case Nodes:
+                       if len(val) == 0 {
+                               continue
+                       }
+                       if name != "" {
+                               h.indent(depth)
+                               h.Printf("%+v-%s", n.Op(), name)
+                       }
+                       h.dumpNodesHTML(val, depth+1)
+               default:
+                       if vf.Kind() == reflect.Slice && vf.Type().Elem().Implements(nodeType) {
+                               if vf.Len() == 0 {
+                                       continue
+                               }
+                               if name != "" {
+                                       h.indent(depth)
+                                       h.Printf("%+v-%s", n.Op(), name)
+                               }
+                               for i, n := 0, vf.Len(); i < n; i++ {
+                                       h.dumpNodeHTML(vf.Index(i).Interface().(Node), depth+1)
+                               }
+                       }
+               }
+       }
+}
+
+func (h *HTMLWriter) dumpNodeHeaderHTML(n Node) {
+       // print pointer to be able to see identical nodes
+       if base.Debug.DumpPtrs != 0 {
+               h.Printf(" p(%p)", n)
+       }
+
+       if base.Debug.DumpPtrs != 0 && n.Name() != nil && n.Name().Defn != nil {
+               h.Printf(" defn(%p)", n.Name().Defn)
+       }
+
+       if base.Debug.DumpPtrs != 0 && n.Name() != nil && n.Name().Curfn != nil {
+               h.Printf(" curfn(%p)", n.Name().Curfn)
+       }
+       if base.Debug.DumpPtrs != 0 && n.Name() != nil && n.Name().Outer != nil {
+               h.Printf(" outer(%p)", n.Name().Outer)
+       }
+
+       if EscFmt != nil {
+               if esc := EscFmt(n); esc != "" {
+                       h.Printf(" %s", html.EscapeString(esc))
+               }
+       }
+
+       if n.Sym() != nil && n.Op() != ONAME && n.Op() != ONONAME && n.Op() != OTYPE {
+               h.Printf(" %+v", html.EscapeString(fmt.Sprintf("%v", n.Sym())))
+       }
+
+       v := reflect.ValueOf(n).Elem()
+       t := v.Type()
+       nf := t.NumField()
+       for i := 0; i < nf; i++ {
+               tf := t.Field(i)
+               if tf.PkgPath != "" {
+                       continue
+               }
+               k := tf.Type.Kind()
+               if reflect.Bool <= k && k <= reflect.Complex128 {
+                       name := strings.TrimSuffix(tf.Name, "_")
+                       vf := v.Field(i)
+                       vfi := vf.Interface()
+                       if name == "Offset" && vfi == types.BADWIDTH || name != "Offset" && vf.IsZero() {
+                               continue
+                       }
+                       if vfi == true {
+                               h.Printf(" %s", name)
+                       } else {
+                               h.Printf(" %s:%+v", name, html.EscapeString(fmt.Sprintf("%v", vf.Interface())))
+                       }
+               }
+       }
+
+       v = reflect.ValueOf(n)
+       t = v.Type()
+       nm := t.NumMethod()
+       for i := 0; i < nm; i++ {
+               tm := t.Method(i)
+               if tm.PkgPath != "" {
+                       continue
+               }
+               m := v.Method(i)
+               mt := m.Type()
+               if mt.NumIn() == 0 && mt.NumOut() == 1 && mt.Out(0).Kind() == reflect.Bool {
+                       func() {
+                               defer func() { recover() }()
+                               if m.Call(nil)[0].Bool() {
+                                       name := strings.TrimSuffix(tm.Name, "_")
+                                       h.Printf(" %s", name)
+                               }
+                       }()
+               }
+       }
+
+       if n.Op() == OCLOSURE {
+               n := n.(*ClosureExpr)
+               if fn := n.Func; fn != nil && fn.Nname.Sym() != nil {
+                       h.Printf(" fnName(%+v)", html.EscapeString(fmt.Sprintf("%v", fn.Nname.Sym())))
+               }
+       }
+
+       if n.Type() != nil {
+               if n.Op() == OTYPE {
+                       h.Printf(" type")
+               }
+               h.Printf(" %+v", html.EscapeString(fmt.Sprintf("%v", n.Type())))
+       }
+       if n.Typecheck() != 0 {
+               h.Printf(" tc(%d)", n.Typecheck())
+       }
+
+       if n.Pos().IsKnown() {
+               h.Print(" <span class=\"line-number\">")
+               switch n.Pos().IsStmt() {
+               case src.PosNotStmt:
+                       h.Print("_")
+               case src.PosIsStmt:
+                       h.Print("+")
+               }
+               sep := ""
+               base.Ctxt.AllPos(n.Pos(), func(pos src.Pos) {
+                       h.Print(sep)
+                       sep = " "
+                       // Hierarchical highlighting:
+                       // Click file -> highlight all ranges in this file
+                       // Click line -> highlight all ranges at this line (in this file)
+                       // Click col  -> highlight this specific range
+
+                       file := pos.Filename()
+                       // Create a hash for the filename to use as a class
+                       hash := sha256.Sum256([]byte(file))
+                       fileID := "loc-" + hex.EncodeToString(hash[:6])
+                       lineID := fmt.Sprintf("%s-L%d", fileID, pos.Line())
+                       colID := fmt.Sprintf("%s-C%d", lineID, pos.Col())
+
+                       // File part: triggers fileID
+                       h.Printf("<span class=\"%s line-number\">%s</span>:", fileID, html.EscapeString(filepath.Base(file)))
+                       // Line part: triggers lineID (and fileID via class list)
+                       h.Printf("<span class=\"%s %s line-number\">%d</span>:", lineID, fileID, pos.Line())
+                       // Col part: triggers colID (and lineID, fileID)
+                       h.Printf("<span class=\"%s %s %s line-number\">%d</span>", colID, lineID, fileID, pos.Col())
+               })
+               h.Print("</span>")
+       }
+}
+
+const (
+       CSS = `<style>
+
+body {
+    font-size: 14px;
+    font-family: Arial, sans-serif;
+}
+
+h1 {
+    font-size: 18px;
+    display: inline-block;
+    margin: 0 1em .5em 0;
+}
+
+#helplink {
+    display: inline-block;
+}
+
+#help {
+    display: none;
+}
+
+table {
+    border: 1px solid black;
+    table-layout: fixed;
+    width: 300px;
+}
+
+th, td {
+    border: 1px solid black;
+    overflow: hidden;
+    width: 400px;
+    vertical-align: top;
+    padding: 5px;
+    position: relative;
+}
+
+.resizer {
+    display: inline-block;
+    background: transparent;
+    width: 10px;
+    height: 100%;
+    position: absolute;
+    right: 0;
+    top: 0;
+    cursor: col-resize;
+    z-index: 100;
+}
+
+td > h2 {
+    cursor: pointer;
+    font-size: 120%;
+    margin: 5px 0px 5px 0px;
+}
+
+td.collapsed {
+    font-size: 12px;
+    width: 12px;
+    border: 1px solid white;
+    padding: 2px;
+    cursor: pointer;
+    background: #fafafa;
+}
+
+td.collapsed div {
+    text-align: right;
+    transform: rotate(180deg);
+    writing-mode: vertical-lr;
+    white-space: pre;
+}
+
+pre {
+    font-family: Menlo, monospace;
+    font-size: 12px;
+}
+
+pre {
+    -moz-tab-size: 4;
+    -o-tab-size:   4;
+    tab-size:      4;
+}
+
+.allow-x-scroll {
+    overflow-x: scroll;
+}
+
+.ir-node {
+    cursor: cell;
+}
+
+.variable-name {
+    cursor: crosshair;
+}
+
+.line-number {
+    font-size: 11px;
+    cursor: crosshair;
+}
+
+body.darkmode {
+    background-color: rgb(21, 21, 21);
+    color: rgb(230, 255, 255);
+    opacity: 100%;
+}
+
+td.darkmode {
+    background-color: rgb(21, 21, 21);
+    border: 1px solid gray;
+}
+
+body.darkmode table, th {
+    border: 1px solid gray;
+}
+
+body.darkmode text {
+    fill: white;
+}
+
+.highlight-aquamarine     { background-color: aquamarine; color: black; }
+.highlight-coral          { background-color: coral; color: black; }
+.highlight-lightpink      { background-color: lightpink; color: black; }
+.highlight-lightsteelblue { background-color: lightsteelblue; color: black; }
+.highlight-palegreen      { background-color: palegreen; color: black; }
+.highlight-skyblue        { background-color: skyblue; color: black; }
+.highlight-lightgray      { background-color: lightgray; color: black; }
+.highlight-yellow         { background-color: yellow; color: black; }
+.highlight-lime           { background-color: lime; color: black; }
+.highlight-khaki          { background-color: khaki; color: black; }
+.highlight-aqua           { background-color: aqua; color: black; }
+.highlight-salmon         { background-color: salmon; color: black; }
+
+
+.outline-blue           { outline: #2893ff solid 2px; }
+.outline-red            { outline: red solid 2px; }
+.outline-blueviolet     { outline: blueviolet solid 2px; }
+.outline-darkolivegreen { outline: darkolivegreen solid 2px; }
+.outline-fuchsia        { outline: fuchsia solid 2px; }
+.outline-sienna         { outline: sienna solid 2px; }
+.outline-gold           { outline: gold solid 2px; }
+.outline-orangered      { outline: orangered solid 2px; }
+.outline-teal           { outline: teal solid 2px; }
+.outline-maroon         { outline: maroon solid 2px; }
+.outline-black          { outline: black solid 2px; }
+
+/* Capture alternative for outline-black and ellipse.outline-black when in dark mode */
+body.darkmode .outline-black        { outline: gray solid 2px; }
+
+</style>
+`
+
+       JS = `<script type="text/javascript">
+
+// Contains phase names which are expanded by default. Other columns are collapsed.
+let expandedDefault = [
+    "bloop",
+    "loopvar",
+    "escape",
+    "slice",
+    "walk",
+];
+if (history.state === null) {
+    history.pushState({expandedDefault}, "", location.href);
+}
+
+// ordered list of all available highlight colors
+var highlights = [
+    "highlight-aquamarine",
+    "highlight-coral",
+    "highlight-lightpink",
+    "highlight-lightsteelblue",
+    "highlight-palegreen",
+    "highlight-skyblue",
+    "highlight-lightgray",
+    "highlight-yellow",
+    "highlight-lime",
+    "highlight-khaki",
+    "highlight-aqua",
+    "highlight-salmon"
+];
+
+// state: which value is highlighted this color?
+var highlighted = {};
+for (var i = 0; i < highlights.length; i++) {
+    highlighted[highlights[i]] = "";
+}
+
+// ordered list of all available outline colors
+var outlines = [
+    "outline-blue",
+    "outline-red",
+    "outline-blueviolet",
+    "outline-darkolivegreen",
+    "outline-fuchsia",
+    "outline-sienna",
+    "outline-gold",
+    "outline-orangered",
+    "outline-teal",
+    "outline-maroon",
+    "outline-black"
+];
+
+// state: which value is outlined this color?
+var outlined = {};
+for (var i = 0; i < outlines.length; i++) {
+    outlined[outlines[i]] = "";
+}
+
+window.onload = function() {
+    if (history.state !== null) {
+        expandedDefault = history.state.expandedDefault;
+    }
+    if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
+        toggleDarkMode();
+        document.getElementById("dark-mode-button").checked = true;
+    }
+
+    var irElemClicked = function(elem, event, selections, selected) {
+        event.stopPropagation();
+
+        // find all values with the same name
+        var c = elem.classList.item(0);
+        var x = document.getElementsByClassName(c);
+
+        // if selected, remove selections from all of them
+        // otherwise, attempt to add
+
+        var remove = "";
+        for (var i = 0; i < selections.length; i++) {
+            var color = selections[i];
+            if (selected[color] == c) {
+                remove = color;
+                break;
+            }
+        }
+
+        if (remove != "") {
+            for (var i = 0; i < x.length; i++) {
+                x[i].classList.remove(remove);
+            }
+            selected[remove] = "";
+            return;
+        }
+
+        // we're adding a selection
+        // find first available color
+        var avail = "";
+        for (var i = 0; i < selections.length; i++) {
+            var color = selections[i];
+            if (selected[color] == "") {
+                avail = color;
+                break;
+            }
+        }
+        if (avail == "") {
+            alert("out of selection colors; go add more");
+            return;
+        }
+
+        // set that as the selection
+        for (var i = 0; i < x.length; i++) {
+            x[i].classList.add(avail);
+        }
+        selected[avail] = c;
+    };
+
+    var irValueClicked = function(event) {
+        irElemClicked(this, event, highlights, highlighted);
+    };
+
+    var irTreeClicked = function(event) {
+        irElemClicked(this, event, outlines, outlined);
+    };
+
+    var irValues = document.getElementsByClassName("ir-node");
+    for (var i = 0; i < irValues.length; i++) {
+        irValues[i].addEventListener('click', irTreeClicked);
+    }
+
+    var lines = document.getElementsByClassName("line-number");
+    for (var i = 0; i < lines.length; i++) {
+        lines[i].addEventListener('click', irValueClicked);
+    }
+
+    var variableNames = document.getElementsByClassName("variable-name");
+    for (var i = 0; i < variableNames.length; i++) {
+        variableNames[i].addEventListener('click', irValueClicked);
+    }
+
+    function toggler(phase) {
+        return function() {
+            toggle_cell(phase+'-col');
+            toggle_cell(phase+'-exp');
+            const i = expandedDefault.indexOf(phase);
+            if (i !== -1) {
+                expandedDefault.splice(i, 1);
+            } else {
+                expandedDefault.push(phase);
+            }
+            history.pushState({expandedDefault}, "", location.href);
+        };
+    }
+
+    function toggle_cell(id) {
+        var e = document.getElementById(id);
+        if (e.style.display == 'table-cell') {
+            e.style.display = 'none';
+        } else {
+            e.style.display = 'table-cell';
+        }
+    }
+
+    // Go through all columns and collapse needed phases.
+    const td = document.getElementsByTagName("td");
+    for (let i = 0; i < td.length; i++) {
+        const id = td[i].id;
+        const phase = id.substr(0, id.length-4);
+        let show = expandedDefault.indexOf(phase) !== -1
+
+        // If show == false, check to see if this is a combined column (multiple phases).
+        // If combined, check each of the phases to see if they are in our expandedDefaults.
+        // If any are found, that entire combined column gets shown.
+        if (!show) {
+            const combined = phase.split('--+--');
+            const len = combined.length;
+            if (len > 1) {
+                for (let i = 0; i < len; i++) {
+                    const num = expandedDefault.indexOf(combined[i]);
+                    if (num !== -1) {
+                        expandedDefault.splice(num, 1);
+                        if (expandedDefault.indexOf(phase) === -1) {
+                            expandedDefault.push(phase);
+                            show = true;
+                        }
+                    }
+                }
+            }
+        }
+        if (id.endsWith("-exp")) {
+            const h2Els = td[i].getElementsByTagName("h2");
+            const len = h2Els.length;
+            if (len > 0) {
+                for (let i = 0; i < len; i++) {
+                    h2Els[i].addEventListener('click', toggler(phase));
+                }
+            }
+        } else {
+            td[i].addEventListener('click', toggler(phase));
+        }
+        if (id.endsWith("-col") && show || id.endsWith("-exp") && !show) {
+            td[i].style.display = 'none';
+            continue;
+        }
+        td[i].style.display = 'table-cell';
+    }
+
+    var resizers = document.getElementsByClassName("resizer");
+    for (var i = 0; i < resizers.length; i++) {
+        var resizer = resizers[i];
+        resizer.addEventListener('mousedown', initDrag, false);
+    }
+};
+
+var startX, startWidth, resizableCol;
+
+function initDrag(e) {
+    resizableCol = this.parentElement;
+    startX = e.clientX;
+    startWidth = parseInt(document.defaultView.getComputedStyle(resizableCol).width, 10);
+    document.documentElement.addEventListener('mousemove', doDrag, false);
+    document.documentElement.addEventListener('mouseup', stopDrag, false);
+}
+
+function doDrag(e) {
+    resizableCol.style.width = (startWidth + e.clientX - startX) + 'px';
+}
+
+function stopDrag(e) {
+    document.documentElement.removeEventListener('mousemove', doDrag, false);
+    document.documentElement.removeEventListener('mouseup', stopDrag, false);
+}
+
+function toggle_visibility(id) {
+    var e = document.getElementById(id);
+    if (e.style.display == 'block') {
+        e.style.display = 'none';
+    } else {
+        e.style.display = 'block';
+    }
+}
+
+function toggleDarkMode() {
+    document.body.classList.toggle('darkmode');
+
+    // Collect all of the "collapsed" elements and apply dark mode on each collapsed column
+    const collapsedEls = document.getElementsByClassName('collapsed');
+    const len = collapsedEls.length;
+
+    for (let i = 0; i < len; i++) {
+        collapsedEls[i].classList.toggle('darkmode');
+    }
+}
+
+</script>
+`
+)
diff --git a/src/cmd/compile/internal/ir/html_test.go b/src/cmd/compile/internal/ir/html_test.go
new file mode 100644 (file)
index 0000000..2d88d50
--- /dev/null
@@ -0,0 +1,104 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package ir
+
+import (
+       "cmd/compile/internal/base"
+       "cmd/compile/internal/types"
+       "cmd/internal/obj"
+       "cmd/internal/src"
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+)
+
+func TestHTMLWriter(t *testing.T) {
+       // Initialize base.Ctxt to avoid panics
+       base.Ctxt = new(obj.Link)
+
+       // Setup a temporary directory for output
+       tmpDir := t.TempDir()
+
+       // Mock func
+       fn := &Func{
+               Nname: &Name{
+                       sym: &types.Sym{Name: "TestFunc"},
+               },
+       }
+       // Func embeds miniExpr, so we might need to set op if checked
+       fn.op = ODCLFUNC
+
+       // Create HTMLWriter
+       outFile := filepath.Join(tmpDir, "test.html")
+       w := NewHTMLWriter(outFile, fn, "")
+       if w == nil {
+               t.Fatalf("Failed to create HTMLWriter")
+       }
+
+       // Write a phase
+       w.WritePhase("phase1", "Phase 1")
+
+       // Register a file/line
+       posBase := src.NewFileBase("test.go", "test.go")
+       // base.Ctxt.PosTable.Register(posBase) -- Not needed/doesn't exist
+       pos := src.MakePos(posBase, 10, 1)
+
+       // Create a dummy node
+       n := &Name{
+               sym:   &types.Sym{Name: "VarX"},
+               Class: PAUTO,
+       }
+       n.op = ONAME
+       n.pos = base.Ctxt.PosTable.XPos(pos)
+
+       // Add another phase which actually dumps something interesting
+       fn.Body = []Node{n}
+       w.WritePhase("phase2", "Phase 2")
+
+       // Test escaping
+       n2 := &Name{
+               sym:   &types.Sym{Name: "<Bad>"},
+               Class: PAUTO,
+       }
+       n2.op = ONAME
+       fn.Body = []Node{n2}
+       w.WritePhase("phase3", "Phase 3")
+
+       w.Close()
+
+       // Verify file exists and has content
+       content, err := os.ReadFile(outFile)
+       if err != nil {
+               t.Fatalf("Failed to read output file: %v", err)
+       }
+
+       s := string(content)
+       if len(s) == 0 {
+               t.Errorf("Output file is empty")
+       }
+
+       // Check for Expected strings
+       expected := []string{
+               "<html>",
+               "Phase 1",
+               "Phase 2",
+               "Phase 2",
+               "VarX",
+               "NAME",
+               "&lt;Bad&gt;",
+               "resizer",
+               "loc-",
+               "line-number",
+               "sym-",
+               "variable-name",
+       }
+
+       for _, e := range expected {
+               if !strings.Contains(s, e) {
+                       t.Errorf("Output missing %q", e)
+               }
+       }
+}