gopls/internal/golang: add "Show assembly of f" code action

This CL adds a new code action to display the Go assembly
code produced for the enclosing function, and all its
nested functions. Each instruction is marked up with a
link that causes cursor navigation in the editor,
using the magic of LSP's showDocument.

Each page load causes a recompile. (A cold build may
take a few seconds in a large project.)

The architecture is determined by the view: for most
files it will be the default GOARCH, but for arch-tagged
files it will be the appropriate one.

+ Test
+ Release note

Fixes golang/go#67478

Change-Id: I51285215e9b27c510076c64eeb7b7ae3899f8a59
Reviewed-on: https://go-review.googlesource.com/c/tools/+/588395
Reviewed-by: Robert Findley <rfindley@google.com>
Auto-Submit: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/gopls/doc/commands.md b/gopls/doc/commands.md
index 69c3af8..357e349 100644
--- a/gopls/doc/commands.md
+++ b/gopls/doc/commands.md
@@ -140,6 +140,20 @@
 }
 ```
 
+## `gopls.assembly`: **Show disassembly of current function.**
+
+This command opens a web-based disassembly listing of the
+specified function symbol (plus any nested lambdas and defers).
+The machine architecture is determined by the view.
+
+Args:
+
+```
+string,
+string,
+string
+```
+
 ## `gopls.change_signature`: **Perform a "change signature" refactoring**
 
 This command is experimental, currently only supporting parameter removal.
diff --git a/gopls/doc/release/v0.16.0.md b/gopls/doc/release/v0.16.0.md
index dc004c4..ab54ae2 100644
--- a/gopls/doc/release/v0.16.0.md
+++ b/gopls/doc/release/v0.16.0.md
@@ -88,6 +88,31 @@
 ```
 TODO(dominikh/go-mode.el#436): add both of these to go-mode.el.
 
+### Show assembly
+
+Gopls offers a third web-based code action, "Show assembly for f",
+which displays an assembly listing of the function declaration
+enclosing the selected code, plus any nested functions (function
+literals, deferred calls).
+It invokes the compiler to generate the report.
+Reloading the page will update the report.
+
+The machine architecture is determined by the "view" or build
+configuration that gopls selects for the current file.
+This is usually the same as your machine's GOARCH unless you are
+working in a file with `go:build` tags for a different architecture.
+
+- TODO screenshot
+
+Gopls cannot yet display assembly for generic functions: generic
+functions are not fully compiled until they are instantiated, but any
+function declaration enclosing the selection cannot be an instantiated
+generic function.
+<!-- Clearly the ideal UX for generic functions is to use the function
+     symbol under the cursor, e.g. Vector[string], rather than the
+     enclosing function; but computing the name of the linker symbol
+     remains a challenge. -->
+
 ### `unusedwrite` analyzer
 
 The new
diff --git a/gopls/internal/doc/api.json b/gopls/internal/doc/api.json
index e170385..555c56a 100644
--- a/gopls/internal/doc/api.json
+++ b/gopls/internal/doc/api.json
@@ -961,6 +961,13 @@
 			"ResultDoc": "{\n\t// Holds changes to existing resources.\n\t\"changes\": map[golang.org/x/tools/gopls/internal/protocol.DocumentURI][]golang.org/x/tools/gopls/internal/protocol.TextEdit,\n\t// Depending on the client capability `workspace.workspaceEdit.resourceOperations` document changes\n\t// are either an array of `TextDocumentEdit`s to express changes to n different text documents\n\t// where each text document edit addresses a specific version of a text document. Or it can contain\n\t// above `TextDocumentEdit`s mixed with create, rename and delete file / folder operations.\n\t//\n\t// Whether a client supports versioned document edits is expressed via\n\t// `workspace.workspaceEdit.documentChanges` client capability.\n\t//\n\t// If a client neither supports `documentChanges` nor `workspace.workspaceEdit.resourceOperations` then\n\t// only plain `TextEdit`s using the `changes` property are supported.\n\t\"documentChanges\": []{\n\t\t\"TextDocumentEdit\": {\n\t\t\t\"textDocument\": { ... },\n\t\t\t\"edits\": { ... },\n\t\t},\n\t\t\"CreateFile\": {\n\t\t\t\"kind\": string,\n\t\t\t\"uri\": string,\n\t\t\t\"options\": { ... },\n\t\t\t\"ResourceOperation\": { ... },\n\t\t},\n\t\t\"RenameFile\": {\n\t\t\t\"kind\": string,\n\t\t\t\"oldUri\": string,\n\t\t\t\"newUri\": string,\n\t\t\t\"options\": { ... },\n\t\t\t\"ResourceOperation\": { ... },\n\t\t},\n\t\t\"DeleteFile\": {\n\t\t\t\"kind\": string,\n\t\t\t\"uri\": string,\n\t\t\t\"options\": { ... },\n\t\t\t\"ResourceOperation\": { ... },\n\t\t},\n\t},\n\t// A map of change annotations that can be referenced in `AnnotatedTextEdit`s or create, rename and\n\t// delete file / folder operations.\n\t//\n\t// Whether clients honor this property depends on the client capability `workspace.changeAnnotationSupport`.\n\t//\n\t// @since 3.16.0\n\t\"changeAnnotations\": map[string]golang.org/x/tools/gopls/internal/protocol.ChangeAnnotation,\n}"
 		},
 		{
+			"Command": "gopls.assembly",
+			"Title": "Show disassembly of current function.",
+			"Doc": "This command opens a web-based disassembly listing of the\nspecified function symbol (plus any nested lambdas and defers).\nThe machine architecture is determined by the view.",
+			"ArgDoc": "string,\nstring,\nstring",
+			"ResultDoc": ""
+		},
+		{
 			"Command": "gopls.change_signature",
 			"Title": "Perform a \"change signature\" refactoring",
 			"Doc": "This command is experimental, currently only supporting parameter removal.\nIts signature will certainly change in the future (pun intended).",
diff --git a/gopls/internal/golang/assembly.go b/gopls/internal/golang/assembly.go
new file mode 100644
index 0000000..cbb9512
--- /dev/null
+++ b/gopls/internal/golang/assembly.go
@@ -0,0 +1,166 @@
+// Copyright 2024 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 golang
+
+// This file produces the "Show GOARCH assembly of f" HTML report.
+//
+// See also:
+// - ./codeaction.go - computes the symbol and offers the CodeAction command.
+// - ../server/command.go - handles the command by opening a web page.
+// - ../server/server.go - handles the HTTP request and calls this function.
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"html"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"golang.org/x/tools/gopls/internal/cache"
+	"golang.org/x/tools/internal/gocommand"
+)
+
+// AssemblyHTML returns an HTML document containing an assembly listing of the selected function.
+//
+// TODO(adonovan):
+// - display a "Compiling..." message as a cold build can be slow.
+// - cross-link jumps and block labels, like github.com/aclements/objbrowse.
+func AssemblyHTML(ctx context.Context, snapshot *cache.Snapshot, pkg *cache.Package, symbol string, posURL PosURLFunc) ([]byte, error) {
+	// Compile the package with -S, and capture its stderr stream.
+	inv, cleanupInvocation, err := snapshot.GoCommandInvocation(false, &gocommand.Invocation{
+		Verb:       "build",
+		Args:       []string{"-gcflags=-S", "."},
+		WorkingDir: filepath.Dir(pkg.Metadata().CompiledGoFiles[0].Path()),
+	})
+	if err != nil {
+		return nil, err // e.g. failed to write overlays (rare)
+	}
+	defer cleanupInvocation()
+	_, stderr, err, _ := snapshot.View().GoCommandRunner().RunRaw(ctx, *inv)
+	if err != nil {
+		return nil, err // e.g. won't compile
+	}
+	content := stderr.String()
+
+	escape := html.EscapeString
+
+	// Produce the report.
+	// TODO(adonovan): factor with RenderPkgDoc, FreeSymbolsHTML
+	title := fmt.Sprintf("%s assembly for %s",
+		escape(snapshot.View().GOARCH()),
+		escape(symbol))
+	var buf bytes.Buffer
+	buf.WriteString(`<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <style>` + pkgDocStyle + `</style>
+  <title>` + escape(title) + `</title>
+  <script type='text/javascript'>
+// httpGET requests a URL for its effects only.
+function httpGET(url) {
+	var xhttp = new XMLHttpRequest();
+	xhttp.open("GET", url, true);
+	xhttp.send();
+	return false; // disable usual <a href=...> behavior
+}
+
+// Start a GET /hang request. If it ever completes, the server
+// has disconnected. Show a banner in that case.
+{
+	var x = new XMLHttpRequest();
+	x.open("GET", "/hang", true);
+	x.onloadend = () => {
+		document.getElementById("disconnected").style.display = 'block';
+	};
+	x.send();
+};
+  </script>
+</head>
+<body>
+<div id='disconnected'>Gopls server has terminated. Page is inactive.</div>
+<h1>` + title + `</h1>
+<p>
+  <a href='https://go.dev/doc/asm'>A Quick Guide to Go's Assembler</a>
+</p>
+<p>
+  Experimental. <a href='https://github.com/golang/go/issues/67478'>Contributions welcome!</a>
+</p>
+<p>
+  Click on a source line marker <code>L1234</code> to navigate your editor there.
+  (Beware: <a href='https://github.com/microsoft/vscode/issues/207634'>#207634</a>)
+</p>
+<p>
+  Reload the page to recompile.
+</p>
+<pre>
+`)
+
+	// sourceLink returns HTML for a link to open a file in the client editor.
+	// TODO(adonovan): factor with two other copies.
+	sourceLink := func(text, url string) string {
+		// The /open URL returns nothing but has the side effect
+		// of causing the LSP client to open the requested file.
+		// So we use onclick to prevent the browser from navigating.
+		// We keep the href attribute as it causes the <a> to render
+		// as a link: blue, underlined, with URL hover information.
+		return fmt.Sprintf(`<a href="%[1]s" onclick='return httpGET("%[1]s")'>%[2]s</a>`,
+			escape(url), text)
+	}
+
+	// insnRx matches an assembly instruction line.
+	// Submatch groups are: (offset-hex-dec, file-line-column, instruction).
+	insnRx := regexp.MustCompile(`^(\s+0x[0-9a-f ]+)\(([^)]*)\)\s+(.*)$`)
+
+	// Parse the functions of interest out of the listing.
+	// Each function is of the form:
+	//
+	//     symbol STEXT k=v...
+	//         0x0000 00000 (/file.go:123) NOP...
+	//         ...
+	//
+	// Allow matches of symbol, symbol.func1, symbol.deferwrap, etc.
+	on := false
+	for _, line := range strings.Split(content, "\n") {
+		// start of function symbol?
+		if strings.Contains(line, " STEXT ") {
+			on = strings.HasPrefix(line, symbol) &&
+				(line[len(symbol)] == ' ' || line[len(symbol)] == '.')
+		}
+		if !on {
+			continue // within uninteresting symbol
+		}
+
+		// In lines of the form
+		//   "\t0x0000 00000 (/file.go:123) NOP..."
+		// replace the "(/file.go:123)" portion with an "L0123" source link.
+		// Skip filenames of the form "<foo>".
+		if parts := insnRx.FindStringSubmatch(line); parts != nil {
+			link := "     " // if unknown
+			if file, linenum, ok := cutLast(parts[2], ":"); ok && !strings.HasPrefix(file, "<") {
+				if linenum, err := strconv.Atoi(linenum); err == nil {
+					text := fmt.Sprintf("L%04d", linenum)
+					link = sourceLink(text, posURL(file, linenum, 1))
+				}
+			}
+			fmt.Fprintf(&buf, "%s\t%s\t%s", escape(parts[1]), link, escape(parts[3]))
+		} else {
+			buf.WriteString(escape(line))
+		}
+		buf.WriteByte('\n')
+	}
+	return buf.Bytes(), nil
+}
+
+// cutLast is the "last" analogue of [strings.Cut].
+func cutLast(s, sep string) (before, after string, ok bool) {
+	if i := strings.LastIndex(s, sep); i >= 0 {
+		return s[:i], s[i+len(sep):], true
+	}
+	return s, "", false
+}
diff --git a/gopls/internal/golang/codeaction.go b/gopls/internal/golang/codeaction.go
index 2208822..07f777e 100644
--- a/gopls/internal/golang/codeaction.go
+++ b/gopls/internal/golang/codeaction.go
@@ -9,8 +9,10 @@
 	"encoding/json"
 	"fmt"
 	"go/ast"
+	"go/types"
 	"strings"
 
+	"golang.org/x/tools/go/ast/astutil"
 	"golang.org/x/tools/gopls/internal/analysis/fillstruct"
 	"golang.org/x/tools/gopls/internal/analysis/fillswitch"
 	"golang.org/x/tools/gopls/internal/cache"
@@ -24,6 +26,7 @@
 	"golang.org/x/tools/gopls/internal/util/slices"
 	"golang.org/x/tools/internal/event"
 	"golang.org/x/tools/internal/imports"
+	"golang.org/x/tools/internal/typesinternal"
 )
 
 // CodeActions returns all wanted code actions (edits and other
@@ -118,7 +121,7 @@
 			if err != nil {
 				return nil, err
 			}
-			// For implementation, see commandHandler.showFreeSymbols.
+			// For implementation, see commandHandler.FreeSymbols.
 			actions = append(actions, protocol.CodeAction{
 				Title:   cmd.Title,
 				Kind:    protocol.GoFreeSymbols,
@@ -130,6 +133,7 @@
 	// Code actions requiring type information.
 	if want[protocol.RefactorRewrite] ||
 		want[protocol.RefactorInline] ||
+		want[protocol.GoAssembly] ||
 		want[protocol.GoTest] {
 		pkg, pgf, err := NarrowestPackageForFile(ctx, snapshot, fh.URI())
 		if err != nil {
@@ -160,6 +164,14 @@
 			}
 			actions = append(actions, fixes...)
 		}
+
+		if want[protocol.GoAssembly] {
+			fixes, err := getGoAssemblyAction(snapshot.View(), pkg, pgf, rng)
+			if err != nil {
+				return nil, err
+			}
+			actions = append(actions, fixes...)
+		}
 	}
 	return actions, nil
 }
@@ -521,3 +533,81 @@
 		Command: &cmd,
 	}}, nil
 }
+
+// getGoAssemblyAction returns any "Show assembly for f" code actions for the selection.
+func getGoAssemblyAction(view *cache.View, pkg *cache.Package, pgf *parsego.File, rng protocol.Range) ([]protocol.CodeAction, error) {
+	start, end, err := pgf.RangePos(rng)
+	if err != nil {
+		return nil, err
+	}
+
+	// Find the enclosing toplevel function or method,
+	// and compute its symbol name (e.g. "pkgpath.(T).method").
+	// The report will show this method and all its nested
+	// functions (FuncLit, defers, etc).
+	//
+	// TODO(adonovan): this is no good for generics, since they
+	// will always be uninstantiated when they enclose the cursor.
+	// Instead, we need to query the func symbol under the cursor,
+	// rather than the enclosing function. It may be an explicitly
+	// or implicitly instantiated generic, and it may be defined
+	// in another package, though we would still need to compile
+	// the current package to see its assembly. The challenge,
+	// however, is that computing the linker name for a generic
+	// symbol is quite tricky. Talk with the compiler team for
+	// ideas.
+	//
+	// TODO(adonovan): think about a smoother UX for jumping
+	// directly to (say) a lambda of interest.
+	// Perhaps we could scroll to STEXT for the innermost
+	// enclosing nested function?
+	var actions []protocol.CodeAction
+	path, _ := astutil.PathEnclosingInterval(pgf.File, start, end)
+	if len(path) >= 2 { // [... FuncDecl File]
+		if decl, ok := path[len(path)-2].(*ast.FuncDecl); ok {
+			if fn, ok := pkg.TypesInfo().Defs[decl.Name].(*types.Func); ok {
+				sig := fn.Type().(*types.Signature)
+
+				// Compute the linker symbol of the enclosing function.
+				var sym strings.Builder
+				if fn.Pkg().Name() == "main" {
+					sym.WriteString("main")
+				} else {
+					sym.WriteString(fn.Pkg().Path())
+				}
+				sym.WriteString(".")
+				if sig.Recv() != nil {
+					if isPtr, named := typesinternal.ReceiverNamed(sig.Recv()); named != nil {
+						sym.WriteString("(")
+						if isPtr {
+							sym.WriteString("*")
+						}
+						sym.WriteString(named.Obj().Name())
+						sym.WriteString(").")
+					}
+				}
+				sym.WriteString(fn.Name())
+
+				if fn.Name() != "_" && // blank functions are not compiled
+					(fn.Name() != "init" || sig.Recv() != nil) && // init functions aren't linker functions
+					sig.TypeParams() == nil && sig.RecvTypeParams() == nil { // generic => no assembly
+					cmd, err := command.NewAssemblyCommand(
+						fmt.Sprintf("Show %s assembly for %s", view.GOARCH(), decl.Name),
+						view.ID(),
+						string(pkg.Metadata().ID),
+						sym.String())
+					if err != nil {
+						return nil, err
+					}
+					// For handler, see commandHandler.Assembly.
+					actions = append(actions, protocol.CodeAction{
+						Title:   cmd.Title,
+						Kind:    protocol.GoAssembly,
+						Command: &cmd,
+					})
+				}
+			}
+		}
+	}
+	return actions, nil
+}
diff --git a/gopls/internal/protocol/codeactionkind.go b/gopls/internal/protocol/codeactionkind.go
index f3d953f..d493b45 100644
--- a/gopls/internal/protocol/codeactionkind.go
+++ b/gopls/internal/protocol/codeactionkind.go
@@ -74,9 +74,10 @@
 // instead of == for CodeActionKinds throughout gopls.
 // See golang/go#40438 for related discussion.
 const (
-	GoTest        CodeActionKind = "goTest"
+	GoAssembly    CodeActionKind = "source.assembly"
 	GoDoc         CodeActionKind = "source.doc"
 	GoFreeSymbols CodeActionKind = "source.freesymbols"
+	GoTest        CodeActionKind = "goTest" // TODO(adonovan): rename "source.test"
 )
 
 // CodeActionUnknownTrigger indicates that the trigger for a
diff --git a/gopls/internal/protocol/command/command_gen.go b/gopls/internal/protocol/command/command_gen.go
index 5e267af..2ba73db 100644
--- a/gopls/internal/protocol/command/command_gen.go
+++ b/gopls/internal/protocol/command/command_gen.go
@@ -28,6 +28,7 @@
 	AddImport               Command = "gopls.add_import"
 	AddTelemetryCounters    Command = "gopls.add_telemetry_counters"
 	ApplyFix                Command = "gopls.apply_fix"
+	Assembly                Command = "gopls.assembly"
 	ChangeSignature         Command = "gopls.change_signature"
 	CheckUpgrades           Command = "gopls.check_upgrades"
 	DiagnoseFiles           Command = "gopls.diagnose_files"
@@ -66,6 +67,7 @@
 	AddImport,
 	AddTelemetryCounters,
 	ApplyFix,
+	Assembly,
 	ChangeSignature,
 	CheckUpgrades,
 	DiagnoseFiles,
@@ -125,6 +127,14 @@
 			return nil, err
 		}
 		return s.ApplyFix(ctx, a0)
+	case Assembly:
+		var a0 string
+		var a1 string
+		var a2 string
+		if err := UnmarshalArgs(params.Arguments, &a0, &a1, &a2); err != nil {
+			return nil, err
+		}
+		return nil, s.Assembly(ctx, a0, a1, a2)
 	case ChangeSignature:
 		var a0 ChangeSignatureArgs
 		if err := UnmarshalArgs(params.Arguments, &a0); err != nil {
@@ -350,6 +360,18 @@
 	}, nil
 }
 
+func NewAssemblyCommand(title string, a0 string, a1 string, a2 string) (protocol.Command, error) {
+	args, err := MarshalArgs(a0, a1, a2)
+	if err != nil {
+		return protocol.Command{}, err
+	}
+	return protocol.Command{
+		Title:     title,
+		Command:   Assembly.String(),
+		Arguments: args,
+	}, nil
+}
+
 func NewChangeSignatureCommand(title string, a0 ChangeSignatureArgs) (protocol.Command, error) {
 	args, err := MarshalArgs(a0)
 	if err != nil {
diff --git a/gopls/internal/protocol/command/interface.go b/gopls/internal/protocol/command/interface.go
index e8d6d7d..9f76add 100644
--- a/gopls/internal/protocol/command/interface.go
+++ b/gopls/internal/protocol/command/interface.go
@@ -252,6 +252,13 @@
 	// block of code depends on, perhaps as a precursor to
 	// extracting it into a separate function.
 	FreeSymbols(context.Context, protocol.DocumentURI, protocol.Range) error
+
+	// Assembly: Show disassembly of current function.
+	//
+	// This command opens a web-based disassembly listing of the
+	// specified function symbol (plus any nested lambdas and defers).
+	// The machine architecture is determined by the view.
+	Assembly(_ context.Context, viewID, packageID, symbol string) error
 }
 
 type RunTestsArgs struct {
diff --git a/gopls/internal/server/code_action.go b/gopls/internal/server/code_action.go
index 45f230e..7252aa9 100644
--- a/gopls/internal/server/code_action.go
+++ b/gopls/internal/server/code_action.go
@@ -139,7 +139,10 @@
 		if golang.IsGenerated(ctx, snapshot, uri) {
 			actions = slices.DeleteFunc(actions, func(a protocol.CodeAction) bool {
 				switch a.Kind {
-				case protocol.GoTest, protocol.GoDoc, protocol.GoFreeSymbols:
+				case protocol.GoTest,
+					protocol.GoDoc,
+					protocol.GoFreeSymbols,
+					protocol.GoAssembly:
 					return false // read-only query
 				}
 				return true // potential write operation
diff --git a/gopls/internal/server/command.go b/gopls/internal/server/command.go
index 0a64d1c..04febab 100644
--- a/gopls/internal/server/command.go
+++ b/gopls/internal/server/command.go
@@ -104,7 +104,7 @@
 // be populated, depending on which configuration is set. See comments in-line
 // for details.
 type commandDeps struct {
-	snapshot *cache.Snapshot    // present if cfg.forURI was set
+	snapshot *cache.Snapshot    // present if cfg.forURI or forView was set
 	fh       file.Handle        // present if cfg.forURI was set
 	work     *progress.WorkDone // present if cfg.progress was set
 }
@@ -1466,6 +1466,9 @@
 }
 
 func (c *commandHandler) FreeSymbols(ctx context.Context, uri protocol.DocumentURI, rng protocol.Range) error {
+	// TODO(adonovan): simplify, following Assembly, by putting the
+	// viewID in the command so that c.run isn't necessary.
+	// (freesymbolsURL needs only a viewID, not a view.)
 	return c.run(ctx, commandConfig{
 		forURI: uri,
 	}, func(ctx context.Context, deps commandDeps) error {
@@ -1481,3 +1484,13 @@
 		return nil
 	})
 }
+
+func (c *commandHandler) Assembly(ctx context.Context, viewID, packageID, symbol string) error {
+	web, err := c.s.getWeb()
+	if err != nil {
+		return err
+	}
+	url := web.assemblyURL(viewID, packageID, symbol)
+	openClientBrowser(ctx, c.s.client, url)
+	return nil
+}
diff --git a/gopls/internal/server/server.go b/gopls/internal/server/server.go
index 3418215..2c0c34a 100644
--- a/gopls/internal/server/server.go
+++ b/gopls/internal/server/server.go
@@ -205,6 +205,8 @@
 //
 //	open?file=%s&line=%d&col=%d       - open a file
 //	pkg/PKGPATH?view=%s               - show doc for package in a given view
+//	assembly?pkg=%s&view=%s&symbol=%s - show assembly of specified func symbol
+//	freesymbols?file=%s&range=%d:%d:%d:%d:&view=%s - show report of free symbols
 type web struct {
 	server *http.Server
 	addr   url.URL // "http://127.0.0.1:PORT/gopls/SECRET"
@@ -315,7 +317,7 @@
 		// Get snapshot of specified view.
 		view, err := s.session.View(req.Form.Get("view"))
 		if err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
+			http.Error(w, err.Error(), http.StatusNotFound)
 			return
 		}
 		snapshot, release, err := view.Snapshot()
@@ -410,6 +412,55 @@
 		w.Write(html)
 	})
 
+	// The /assembly?pkg=...&view=...&symbol=... handler shows
+	// the assembly of the current function.
+	webMux.HandleFunc("/assembly", func(w http.ResponseWriter, req *http.Request) {
+		ctx := req.Context()
+		if err := req.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		// Get parameters.
+		var (
+			viewID = req.Form.Get("view")
+			pkgID  = metadata.PackageID(req.Form.Get("pkg"))
+			symbol = req.Form.Get("symbol")
+		)
+		if viewID == "" || pkgID == "" || symbol == "" {
+			http.Error(w, "/assembly requires view, pkg, symbol", http.StatusBadRequest)
+			return
+		}
+
+		// Get snapshot of specified view.
+		view, err := s.session.View(viewID)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusNotFound)
+			return
+		}
+		snapshot, release, err := view.Snapshot()
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		defer release()
+
+		pkgs, err := snapshot.TypeCheck(ctx, pkgID)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		pkg := pkgs[0]
+
+		// Produce report.
+		html, err := golang.AssemblyHTML(ctx, snapshot, pkg, symbol, web.openURL)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.Write(html)
+	})
+
 	return web, nil
 }
 
@@ -456,6 +507,17 @@
 		"")
 }
 
+// assemblyURL returns the URL of an assembly listing of the specified function symbol.
+func (w *web) assemblyURL(viewID, packageID, symbol string) protocol.URI {
+	return w.url(
+		"assembly",
+		fmt.Sprintf("view=%s&pkg=%s&symbol=%s",
+			url.QueryEscape(viewID),
+			url.QueryEscape(packageID),
+			url.QueryEscape(symbol)),
+		"")
+}
+
 // url returns a URL by joining a relative path, an (encoded) query,
 // and an (unencoded) fragment onto the authenticated base URL of the
 // web server.
diff --git a/gopls/internal/settings/default.go b/gopls/internal/settings/default.go
index e280c75..fec64e3 100644
--- a/gopls/internal/settings/default.go
+++ b/gopls/internal/settings/default.go
@@ -49,6 +49,7 @@
 						protocol.RefactorRewrite:       true,
 						protocol.RefactorInline:        true,
 						protocol.RefactorExtract:       true,
+						protocol.GoAssembly:            true,
 						protocol.GoDoc:                 true,
 						protocol.GoFreeSymbols:         true,
 					},
diff --git a/gopls/internal/test/integration/misc/codeactions_test.go b/gopls/internal/test/integration/misc/codeactions_test.go
index 1a169c6..09629b4 100644
--- a/gopls/internal/test/integration/misc/codeactions_test.go
+++ b/gopls/internal/test/integration/misc/codeactions_test.go
@@ -23,12 +23,12 @@
 module example.com
 go 1.19
 
--- src.go --
+-- src/a.go --
 package a
 
 func f() { g() }
 func g() {}
--- gen.go --
+-- gen/a.go --
 // Code generated by hand; DO NOT EDIT.
 package a
 
@@ -62,12 +62,14 @@
 			}
 		}
 
-		check("src.go",
+		check("src/a.go",
+			protocol.GoAssembly,
 			protocol.GoDoc,
 			protocol.GoFreeSymbols,
 			protocol.RefactorExtract,
 			protocol.RefactorInline)
-		check("gen.go",
+		check("gen/a.go",
+			protocol.GoAssembly,
 			protocol.GoDoc,
 			protocol.GoFreeSymbols)
 	})
diff --git a/gopls/internal/test/integration/misc/webserver_test.go b/gopls/internal/test/integration/misc/webserver_test.go
index 851cc68..22f65ec 100644
--- a/gopls/internal/test/integration/misc/webserver_test.go
+++ b/gopls/internal/test/integration/misc/webserver_test.go
@@ -9,12 +9,14 @@
 	"io"
 	"net/http"
 	"regexp"
+	"runtime"
 	"strings"
 	"testing"
 
 	"golang.org/x/tools/gopls/internal/protocol"
 	"golang.org/x/tools/gopls/internal/protocol/command"
 	. "golang.org/x/tools/gopls/internal/test/integration"
+	"golang.org/x/tools/internal/testenv"
 )
 
 // TestWebServer exercises the web server created on demand
@@ -295,6 +297,81 @@
 	})
 }
 
+// TestAssembly is a basic test of the web-based assembly listing.
+func TestAssembly(t *testing.T) {
+	testenv.NeedsGo1Point(t, 22) // for up-to-date assembly listing
+
+	const files = `
+-- go.mod --
+module example.com
+
+-- a/a.go --
+package a
+
+func f() {
+	println("hello")
+	defer println("world")
+}
+
+func g() {
+	println("goodbye")
+}
+`
+	Run(t, files, func(t *testing.T, env *Env) {
+		env.OpenFile("a/a.go")
+
+		// Invoke the "Show assembly" code action to start the server.
+		loc := env.RegexpSearch("a/a.go", "println")
+		actions, err := env.Editor.CodeAction(env.Ctx, loc, nil)
+		if err != nil {
+			t.Fatalf("CodeAction: %v", err)
+		}
+		const wantTitle = "Show " + runtime.GOARCH + " assembly for f"
+		var action *protocol.CodeAction
+		for _, a := range actions {
+			if a.Title == wantTitle {
+				action = &a
+				break
+			}
+		}
+		if action == nil {
+			t.Fatalf("can't find action with Title %s, only %#v",
+				wantTitle, actions)
+		}
+
+		// Execute the command.
+		// Its side effect should be a single showDocument request.
+		params := &protocol.ExecuteCommandParams{
+			Command:   action.Command.Command,
+			Arguments: action.Command.Arguments,
+		}
+		var result command.DebuggingResult
+		env.ExecuteCommand(params, &result)
+		doc := shownDocument(t, env, "http:")
+		if doc == nil {
+			t.Fatalf("no showDocument call had 'file:' prefix")
+		}
+		t.Log("showDocument(package doc) URL:", doc.URI)
+
+		// Get the report and do some minimal checks for sensible results.
+		// Use only portable instructions below!
+		report := get(t, doc.URI)
+		checkMatch(t, true, report, `TEXT.*example.com/a.f`)
+		checkMatch(t, true, report, `CALL	runtime.printlock`)
+		checkMatch(t, true, report, `CALL	runtime.printstring`)
+		checkMatch(t, true, report, `CALL	runtime.printunlock`)
+		checkMatch(t, true, report, `CALL	example.com/a.f.deferwrap1`)
+		checkMatch(t, true, report, `RET`)
+		checkMatch(t, true, report, `CALL	runtime.morestack_noctxt`)
+
+		// Nested functions are also shown.
+		checkMatch(t, true, report, `TEXT.*example.com/a.f.deferwrap1`)
+
+		// But other functions are not.
+		checkMatch(t, false, report, `TEXT.*example.com/a.g`)
+	})
+}
+
 // shownDocument returns the first shown document matching the URI prefix.
 // It may be nil.
 //