cmd/compile: enhance astdump flag to also generate HTML

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>
This commit is contained in:
David Chase 2026-01-30 08:46:05 +02:00
parent 835d6d42c4
commit ae7b257f24
4 changed files with 1080 additions and 0 deletions

View 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.

View 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

View file

@ -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>
`
)

View file

@ -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)
}
}
}