gopls: add assembly DocumentHighlight, fix label scoping

This adds DocumentHighlight for Go assembly files, and fixes a bug where
  label definitions were not properly scoped to their enclosing TEXT function.

  - Dispatch file.Asm in DocumentHighlight to goasm.Highlight.
  - For a symbol or label under the cursor, same-name occurrences are
    highlighted: definitions (TEXT, GLOBL, labels) as Write, references as Read.
  - For a register, all occurrences within the enclosing TEXT function are
    highlighted, approximating its def/use chain.
  - Scope label definitions to the enclosing TEXT function, so Definition on
    a label reference no longer jumps to a same-named label in a different
    function.

  For golang/go#71754

Change-Id: I450d689f4cfc7052736dd5c5957375d2ae16484b
GitHub-Last-Rev: 1e43c451da62f148f49408f4aec3c8a0056abc11
GitHub-Pull-Request: golang/tools#661
Reviewed-on: https://go-review.googlesource.com/c/tools/+/804760
Reviewed-by: Alan Donovan <adonovan@google.com>
Auto-Submit: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Alex Putman <aputman@golang.org>
diff --git a/gopls/doc/features/assembly.md b/gopls/doc/features/assembly.md
index 7302999..f6074e8 100644
--- a/gopls/doc/features/assembly.md
+++ b/gopls/doc/features/assembly.md
@@ -11,21 +11,39 @@
 directory containing at least one `*.go` file, then the `.s` file is
 Go assembly, and its appropriate language server is gopls.
 
-Only Definition (`textDocument/definition`) requests are currently
-supported. For example, a Definition request on the `sigpanic`
-symbol in this file in GOROOT/src/runtime/asm.s:
+The following requests are currently supported:
 
-```asm
-	JMP	·sigpanic<ABIInternal>(SB)
-```
+- Definition (`textDocument/definition`): on a reference to a symbol,
+  returns the location of its declaration. For example, a Definition
+  request on the `sigpanic` symbol in this file in
+  GOROOT/src/runtime/asm.s:
 
-returns the location of the function declaration in
-GOROOT/src/runtime/signal_unix.go:
+  ```asm
+  	JMP	·sigpanic<ABIInternal>(SB)
+  ```
 
-```go
-//go:linkname sigpanic
-func sigpanic() {
-```
+  returns the location of the function declaration in
+  GOROOT/src/runtime/signal_unix.go:
+
+  ```go
+  //go:linkname sigpanic
+  func sigpanic() {
+  ```
+
+- References (`textDocument/references`): finds all references to the
+  symbol under the cursor, in both Go and assembly files within the
+  same package.
+
+- Hover (`textDocument/hover`): reports the signature and doc comment
+  of the symbol's Go declaration.
+
+- DocumentHighlight (`textDocument/documentHighlight`): highlights all
+  occurrences of the symbol, control label, or machine register under
+  the cursor. Labels and registers are scoped to the enclosing TEXT
+  function, and occurrences are classified as reads or writes.
+  Register highlighting requires the file name to carry a GOARCH
+  suffix (e.g. `foo_amd64.s`) and currently supports x86 (amd64, 386)
+  and arm64.
 
 See also issue https://go.dev/issue/71754, which tracks the development of LSP
-features in Go assembly files.
\ No newline at end of file
+features in Go assembly files.
diff --git a/gopls/doc/release/v0.24.0.md b/gopls/doc/release/v0.24.0.md
index 9951ac8..f4d0db3 100644
--- a/gopls/doc/release/v0.24.0.md
+++ b/gopls/doc/release/v0.24.0.md
@@ -41,6 +41,12 @@
 over a symbol reports the signature and doc comment of its Go
 declaration.
 
+Gopls now supports the `textDocument/documentHighlight` request in Go
+assembly files: all occurrences of the symbol, label, or machine
+register under the cursor are highlighted within the enclosing TEXT
+function, with definitions classified as writes and references as
+reads.
+
 ## Analysis features
 
 <!-- TODO Gopls is now using staticcheck [v0.8.0-rc1](https://github.com/dominikh/go-tools/releases/tag/2026.2rc1). -->
diff --git a/gopls/internal/goasm/highlight.go b/gopls/internal/goasm/highlight.go
new file mode 100644
index 0000000..ad0b7c5
--- /dev/null
+++ b/gopls/internal/goasm/highlight.go
@@ -0,0 +1,483 @@
+// Copyright 2025 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 goasm
+
+import (
+	"bytes"
+	"context"
+	"strings"
+
+	"golang.org/x/tools/gopls/internal/cache"
+	"golang.org/x/tools/gopls/internal/file"
+	"golang.org/x/tools/gopls/internal/protocol"
+	"golang.org/x/tools/gopls/internal/util/asm"
+	"golang.org/x/tools/internal/event"
+)
+
+// Highlight handles the textDocument/documentHighlight request for Go
+// assembly files.
+//
+// If the cursor is on a symbol identifier, all occurrences of the same
+// name in the file are highlighted: definitions (TEXT, GLOBL) as Write,
+// references as Read. Control labels are function-scoped, so for a label
+// only occurrences within the enclosing TEXT function are highlighted.
+//
+// If the cursor is on a machine register, all occurrences of that
+// register within the enclosing TEXT function are highlighted,
+// approximating its def/use chain: occurrences classified as
+// definitions are Write, uses are Read. Register highlighting requires
+// a GOARCH file name suffix (e.g. *_amd64.s, *_arm64.s).
+func Highlight(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, rng protocol.Range) ([]protocol.DocumentHighlight, error) {
+	ctx, done := event.Start(ctx, "goasm.Highlight")
+	defer done()
+
+	content, err := fh.Content()
+	if err != nil {
+		return nil, err
+	}
+
+	asmFile := asm.Parse(fh.URI(), content)
+
+	start, end, err := asmFile.Mapper.RangeOffsets(rng)
+	if err != nil {
+		return nil, err
+	}
+
+	// Identifier (symbol or label) under the cursor?
+	if found := asmFile.IdentAt(start, end); found != nil {
+		return highlightIdents(asmFile, found)
+	}
+
+	// Register under the cursor?
+	return highlightRegister(asmFile, start)
+}
+
+// highlightIdents highlights every identifier with the same name as
+// found. Definitions are Write; references are Read. If the name denotes
+// a control label (it has a label definition in the file), only
+// occurrences within the enclosing TEXT function are highlighted, since
+// labels are function-scoped and the same label name may be reused in
+// different functions.
+func highlightIdents(file *asm.File, found *asm.Ident) ([]protocol.DocumentHighlight, error) {
+	// Heuristic: if the name is used as a label anywhere in the file,
+	// assume every occurrence is a label. A label and a global symbol
+	// sharing a name is implausible in practice, so per-occurrence
+	// disambiguation is not worth the cost.
+	lo, hi := 0, len(file.Mapper.Content)
+	for _, id := range file.Idents {
+		if id.Kind == asm.Label && id.Name == found.Name {
+			lo, hi = file.FunctionRange(found.Offset)
+			break
+		}
+	}
+
+	var highlights []protocol.DocumentHighlight
+	for _, id := range file.Idents {
+		if id.Name != found.Name || !(lo <= id.Offset && id.Offset < hi) {
+			continue
+		}
+		idRange, err := file.IdentRange(id)
+		if err != nil {
+			return nil, err
+		}
+		kind := protocol.Read
+		if id.Kind == asm.Text || id.Kind == asm.Global || id.Kind == asm.Label {
+			kind = protocol.Write
+		}
+		highlights = append(highlights, protocol.DocumentHighlight{
+			Range: idRange,
+			Kind:  kind,
+		})
+	}
+	return highlights, nil
+}
+
+// highlightRegister highlights all occurrences of the register under the
+// cursor within the enclosing TEXT function.
+func highlightRegister(file *asm.File, offset int) ([]protocol.DocumentHighlight, error) {
+	content := file.Mapper.Content
+	arch := fileArch(file.Mapper.URI.Base())
+	if arch == "" {
+		return nil, nil
+	}
+	word, wordStart := wordAt(content, offset)
+	if word == "" || !isRegisterWord(word) || inComment(content, offset) {
+		return nil, nil
+	}
+	// The first word on a line is the mnemonic, not a register.
+	if isLineStart(content, wordStart) {
+		return nil, nil
+	}
+
+	funcStart, funcEnd := file.FunctionRange(offset)
+	var highlights []protocol.DocumentHighlight
+	pos := funcStart
+	for pos < funcEnd {
+		i := bytes.Index(content[pos:funcEnd], []byte(word))
+		if i < 0 {
+			break
+		}
+		absOff := pos + i
+		pos = absOff + len(word)
+		// Skip occurrences inside comments, within a larger word
+		// (e.g. "AX" in "MAX"), or at the start of a line (mnemonic).
+		if inComment(content, absOff) ||
+			!isWordBoundary(content, absOff, absOff+len(word)) ||
+			isLineStart(content, absOff) {
+			continue
+		}
+		rng, err := file.Mapper.OffsetRange(absOff, absOff+len(word))
+		if err != nil {
+			return nil, err
+		}
+		highlights = append(highlights, protocol.DocumentHighlight{
+			Range: rng,
+			Kind:  registerKind(content, absOff, arch),
+		})
+	}
+	return highlights, nil
+}
+
+// fileArch returns the GOARCH suffix of an assembly file's base name. It
+// returns "" for unsupported architectures and for file names without a
+// GOARCH suffix (which select their architecture using build constraints);
+// register highlighting is not yet supported for those files.
+func fileArch(base string) string {
+	name, ok := strings.CutSuffix(base, ".s")
+	if !ok {
+		return ""
+	}
+	if i := strings.LastIndexByte(name, '_'); i >= 0 {
+		arch := name[i+1:]
+		switch arch {
+		case "386", "amd64", "arm64":
+			return arch
+		}
+	}
+	return ""
+}
+
+// registerKind classifies the register occurrence at offset (a byte
+// offset within content) as Read or Write, where arch is the file's
+// GOARCH ("386", "amd64", or "arm64"). It follows the Plan 9
+// assembly convention that the destination operand is the last
+// operand: a register in the last operand is a definition (Write), and a
+// register in any earlier operand is a use (Read), with three exceptions:
+//
+//   - A register inside parentheses is part of a memory address operand
+//     (as in (AX) or 8(AX)(BX*4)) and is always Read, even in the
+//     destination operand of a store. A parenthesized register pair,
+//     such as the (R4, R5) of arm64 LDP/STP, is not a memory address
+//     but a single operand in its own right, and is classified by its
+//     position like any other operand; see enclosingGroup.
+//   - Comparison and test instructions (x86: CMP, TEST, COMIS*, UCOMIS*,
+//     BT*; arm64: CMP, CMN, TST) have no destination, so all their
+//     operands are Read.
+//   - Single-operand instructions write their operand only in a few
+//     cases, all x86-specific: POP stores into it, INC/DEC/NEG/NOT/BSWAP
+//     update it in place, and SETcc sets it to 0 or 1. For all others
+//     (PUSH, MUL/DIV, ...) the operand is a source and is Read.
+//
+// Implicit register operands are not modeled — for example MUL/DIV
+// clobber DX:AX, CALL may clobber CX, and the post-increment forms such
+// as arm64 LDP.P update their base register — so occurrences in such
+// instructions may be misclassified.
+//
+// TODO(golang/go#71754): model implicit operands.
+//
+// TODO(golang/go#71754): consider linking instruction mnemonics to
+// their CPU documentation (e.g. https://www.felixcloutier.com/x86/movzx).
+func registerKind(content []byte, offset int, arch string) protocol.DocumentHighlightKind {
+	// Find the line containing offset.
+	lineStart := offset
+	for lineStart > 0 && content[lineStart-1] != '\n' {
+		lineStart--
+	}
+	lineEnd := offset
+	for lineEnd < len(content) && content[lineEnd] != '\n' {
+		lineEnd++
+	}
+	line := content[lineStart:lineEnd]
+	// Strip a trailing comment so its commas and parentheses are not
+	// mistaken for operand syntax.
+	if i := bytes.Index(line, []byte("//")); i >= 0 {
+		line = line[:i]
+	}
+
+	// A register inside parentheses is a memory address: always Read,
+	// unless the parentheses enclose a register pair, in which case the
+	// pair is classified by its position as a whole.
+	rel := offset - lineStart
+	if open, isPair := enclosingGroup(line, rel); open >= 0 {
+		if !isPair {
+			return protocol.Read
+		}
+		rel = open // classify the pair by the position of its '('
+	}
+
+	// Identify the mnemonic: the first non-space token on the line.
+	i := 0
+	for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
+		i++
+	}
+	mnStart := i
+	for i < len(line) && line[i] != ' ' && line[i] != '\t' && line[i] != ',' {
+		i++
+	}
+	mnemonic := string(line[mnStart:i])
+
+	if isCompareMnemonic(arch, mnemonic) {
+		return protocol.Read
+	}
+
+	// The operand list starts after the mnemonic. Count top-level commas to
+	// determine which operand the occurrence is in; the last operand is the
+	// destination.
+	operandArea := line[i:]
+	relMatch := min(max(rel-i, 0), len(operandArea))
+	commaBefore := topLevelCommas(operandArea[:relMatch])
+	totalCommas := topLevelCommas(operandArea)
+	if totalCommas == 0 {
+		// Single-operand instruction. The write cases below are
+		// all x86-specific; on other architectures the operand is
+		// always a source.
+		if arch != "386" && arch != "amd64" {
+			return protocol.Read
+		}
+		m := strings.ToUpper(mnemonic)
+		if strings.HasPrefix(m, "SET") { // SETcc; also avoids trimSizeSuffix("SETEQ") = "SETE"
+			return protocol.Write
+		}
+		switch trimSizeSuffix(m) {
+		case "POP", "INC", "DEC", "NEG", "NOT", "BSWAP":
+			return protocol.Write
+		}
+		return protocol.Read
+	}
+	if commaBefore >= totalCommas {
+		return protocol.Write
+	}
+	return protocol.Read
+}
+
+// enclosingGroup reports whether index rel of line is inside a
+// parenthesized group, returning the index of the group's '(', or -1 if
+// there is none, and whether the group is a register pair such as the
+// (R4, R5) of arm64 LDP/STP.
+//
+// A group is taken to be a register pair if it contains a comma at its
+// own nesting level and its '(' does not immediately follow an
+// identifier. Memory address operands contain no comma at that level (as
+// in (AX) or 8(AX)(BX*4)), and in a macro invocation the '(' is glued to
+// the macro name (as in QR(V0, V4, V8, V12)).
+func enclosingGroup(line []byte, rel int) (int, bool) {
+	// Find the innermost unclosed '(' before rel.
+	open := -1
+	for depth, j := 0, rel-1; j >= 0 && open < 0; j-- {
+		switch line[j] {
+		case ')':
+			depth++
+		case '(':
+			if depth == 0 {
+				open = j
+			} else {
+				depth--
+			}
+		}
+	}
+	if open < 0 {
+		return -1, false
+	}
+	if open > 0 && isWordByte(line[open-1]) {
+		return open, false // macro invocation
+	}
+	depth := 0
+	for j := open; j < len(line); j++ {
+		switch line[j] {
+		case '(':
+			depth++
+		case ')':
+			if depth--; depth == 0 {
+				return open, false // closed with no comma of its own
+			}
+		case ',':
+			if depth == 1 {
+				return open, true
+			}
+		}
+	}
+	return open, false // unterminated
+}
+
+// topLevelCommas counts the commas of s that are not nested within
+// parentheses, that is, the operand separators of an instruction.
+func topLevelCommas(s []byte) int {
+	n, depth := 0, 0
+	for _, b := range s {
+		switch b {
+		case '(':
+			depth++
+		case ')':
+			if depth > 0 {
+				depth--
+			}
+		case ',':
+			if depth == 0 {
+				n++
+			}
+		}
+	}
+	return n
+}
+
+// isCompareMnemonic reports whether mnemonic is a comparison or test
+// instruction on arch, whose operands are all reads (no destination).
+// On arm64 these are CMP/CMN/TST (the prefixes cover the W width
+// variants). On x86, BT (bit test) only reads its destination operand
+// to set flags, so it is a comparison; BTS/BTR/BTC are read-modify-
+// write and are not — their destination is classified as Write by the
+// default rule. CMPXCHG/CMPXCHG8B/CMPXCHG16B are read-modify-write and
+// are excluded from CMP prefix matching for the same reason.
+//
+// The x86 predicate comparisons CMPPD/CMPPS/CMPSD/CMPSS do have a
+// destination, and it is their second operand rather than their last
+// (as in CMPPD X1, X2, $7), so both this function and the default rule
+// misclassify it as Read. Their mnemonics are also ambiguous: CMPSD and
+// CMPSS name both these instructions and the operand-free string
+// comparisons.
+func isCompareMnemonic(arch, mnemonic string) bool {
+	m := strings.ToUpper(mnemonic)
+	switch arch {
+	case "arm64":
+		return strings.HasPrefix(m, "CMP") ||
+			strings.HasPrefix(m, "CMN") ||
+			strings.HasPrefix(m, "TST")
+	case "386", "amd64":
+		// Continue below with the x86 cases.
+	default:
+		return false
+	}
+	if trimSizeSuffix(m) == "BT" {
+		return true
+	}
+	// CMPXCHG has CMP prefix but is read-modify-write, not a compare.
+	if strings.HasPrefix(m, "CMPXCHG") {
+		return false
+	}
+	switch {
+	case strings.HasPrefix(m, "CMP"),
+		strings.HasPrefix(m, "TEST"),
+		strings.HasPrefix(m, "COM"),
+		strings.HasPrefix(m, "UCOM"):
+		return true
+	}
+	return false
+}
+
+// trimSizeSuffix strips a single trailing size suffix (B/W/L/Q) from an
+// instruction mnemonic, e.g. "CMPQ" -> "CMP", "BTB" -> "BT", "BTS" -> "BTS".
+func trimSizeSuffix(m string) string {
+	if len(m) > 0 {
+		switch m[len(m)-1] {
+		case 'B', 'W', 'L', 'Q':
+			return m[:len(m)-1]
+		}
+	}
+	return m
+}
+
+// inComment reports whether offset falls within a // line comment.
+// Like [asm.Parse], it does not recognize /* */ block comments.
+func inComment(content []byte, offset int) bool {
+	lineStart := offset
+	for lineStart > 0 && content[lineStart-1] != '\n' {
+		lineStart--
+	}
+	return bytes.Contains(content[lineStart:offset], []byte("//"))
+}
+
+// isWordBoundary reports whether content[start:end], which contains
+// only word bytes and is nonempty, is a whole word: the bytes
+// immediately before start and after end are not word bytes.
+func isWordBoundary(content []byte, start, end int) bool {
+	if start > 0 && isWordByte(content[start-1]) {
+		return false
+	}
+	if end < len(content) && isWordByte(content[end]) {
+		return false
+	}
+	return true
+}
+
+// wordAt returns the maximal run of ASCII word bytes ([A-Za-z0-9])
+// containing pos, together with its start offset. If the run is empty
+// (pos is on a non-word byte whose left neighbor is also a non-word
+// byte), wordAt returns ("", pos).
+//
+// Precondition: 0 <= pos <= len(content).
+func wordAt(content []byte, pos int) (string, int) {
+	start := pos
+	for start > 0 && isWordByte(content[start-1]) {
+		start--
+	}
+	end := pos
+	for end < len(content) && isWordByte(content[end]) {
+		end++
+	}
+	return string(content[start:end]), start
+}
+
+func isWordByte(b byte) bool {
+	return (b >= 'A' && b <= 'Z') ||
+		(b >= 'a' && b <= 'z') ||
+		(b >= '0' && b <= '9') ||
+		b == '_'
+}
+
+// isRegisterWord reports whether word looks like a machine register name:
+// 2-3 ASCII uppercase letters/digits with at least one letter. (Requiring
+// a letter excludes numeric immediates such as "123".) The pseudo-
+// registers SB, SP, FP, and PC are excluded because they appear in almost
+// every operand, so highlighting them would be noise rather than signal.
+func isRegisterWord(word string) bool {
+	if len(word) < 2 || len(word) > 3 {
+		return false
+	}
+	switch word {
+	case "SB", "SP", "FP", "PC":
+		return false
+	}
+	hasLetter := false
+	for i := 0; i < len(word); i++ {
+		c := word[i]
+		switch {
+		case 'A' <= c && c <= 'Z':
+			hasLetter = true
+		case !('0' <= c && c <= '9'):
+			return false
+		}
+	}
+	return hasLetter
+}
+
+// isLineStart reports whether offset begins a line, i.e. it is preceded
+// only by whitespace or the start of the file. Callers use it to reject
+// instruction mnemonics, which assumes each line holds at most one
+// instruction and that it is not preceded by a label; neither holds for
+// "label: RET" or for instructions separated by ';', though no such line
+// appears in GOROOT for the architectures supported by fileArch.
+func isLineStart(content []byte, offset int) bool {
+	for i := offset - 1; i >= 0; i-- {
+		switch content[i] {
+		case '\n':
+			return true
+		case ' ', '\t':
+			continue
+		default:
+			return false
+		}
+	}
+	return true // beginning of file
+}
diff --git a/gopls/internal/goasm/resolve.go b/gopls/internal/goasm/resolve.go
index 9067bf5..fe9fb8e 100644
--- a/gopls/internal/goasm/resolve.go
+++ b/gopls/internal/goasm/resolve.go
@@ -35,7 +35,8 @@
 
 	// localDef is the defining identifier in the assembly file for a local
 	// symbol — a label, a bare TEXT/GLOBL symbol, or a current-package
-	// symbol without a Go declaration. It is nil if none was found.
+	// symbol without a Go declaration. It is nil if none was found. For a
+	// label, only the enclosing TEXT function is searched.
 	localDef *asm.Ident
 }
 
@@ -73,12 +74,7 @@
 	// Find the identifier under the cursor.
 	// Use the selection range so that haphazard selections that
 	// happen to start in an identifier don't produce spurious matches.
-	for _, id := range res.file.Idents {
-		if id.Offset <= start && end <= id.End() {
-			res.found = &id
-			break
-		}
-	}
+	res.found = res.file.IdentAt(start, end)
 	if res.found == nil {
 		return res, nil
 	}
@@ -121,10 +117,23 @@
 	// symbol, or a package-qualified symbol without a Go declaration — in
 	// the assembly file.
 	if res.obj == nil {
+		// Labels are function-scoped: a label definition matches only
+		// within the enclosing TEXT function, so that a jump doesn't land
+		// on a same-named label in another function.
+		lo, hi := res.file.FunctionRange(res.found.Offset)
 		for _, id := range res.file.Idents {
-			if id.Name == res.found.Name &&
-				(id.Kind == asm.Text || id.Kind == asm.Global || id.Kind == asm.Label) {
+			if id.Name != res.found.Name {
+				continue
+			}
+			switch id.Kind {
+			case asm.Text, asm.Global:
 				res.localDef = &id
+			case asm.Label:
+				if lo <= id.Offset && id.Offset < hi {
+					res.localDef = &id
+				}
+			}
+			if res.localDef != nil {
 				break
 			}
 		}
diff --git a/gopls/internal/server/highlight.go b/gopls/internal/server/highlight.go
index 83b2660..b5aa4bc 100644
--- a/gopls/internal/server/highlight.go
+++ b/gopls/internal/server/highlight.go
@@ -8,6 +8,7 @@
 	"context"
 
 	"golang.org/x/tools/gopls/internal/file"
+	"golang.org/x/tools/gopls/internal/goasm"
 	"golang.org/x/tools/gopls/internal/golang"
 	"golang.org/x/tools/gopls/internal/label"
 	"golang.org/x/tools/gopls/internal/protocol"
@@ -26,6 +27,8 @@
 	defer release()
 
 	switch snapshot.FileKind(fh) {
+	case file.Asm:
+		return goasm.Highlight(ctx, snapshot, fh, params.Range)
 	case file.Tmpl:
 		return template.Highlight(ctx, snapshot, fh, params.Range)
 	case file.Go:
diff --git a/gopls/internal/test/marker/testdata/definition/asm.txt b/gopls/internal/test/marker/testdata/definition/asm.txt
index da42b5e..9a45d84 100644
--- a/gopls/internal/test/marker/testdata/definition/asm.txt
+++ b/gopls/internal/test/marker/testdata/definition/asm.txt
@@ -35,3 +35,19 @@
 package b
 
 func B() {} //@ loc(bB, "B")
+
+-- a/asm2.s --
+// Labels are function-scoped: the same label name in different TEXT
+// functions denotes different labels.
+
+TEXT ·f1(SB), $0-0
+onlyf1:
+loop:				//@ loc(f1loop, "loop")
+	JMP	loop		//@ def("loop", f1loop)
+	RET
+
+TEXT ·f2(SB), $0-0
+loop:				//@ loc(f2loop, "loop")
+	JMP	loop		//@ def("loop", f2loop)
+	JMP	onlyf1		//@ def("onlyf1") // defined only in f1: no definition here
+	RET
diff --git a/gopls/internal/test/marker/testdata/highlight/asm.txt b/gopls/internal/test/marker/testdata/highlight/asm.txt
new file mode 100644
index 0000000..ffb3aca
--- /dev/null
+++ b/gopls/internal/test/marker/testdata/highlight/asm.txt
@@ -0,0 +1,147 @@
+Test of documentHighlight for assembly files.
+
+-- example/highlight_text.s --
+TEXT ·foo(SB), $0-0 //@hiloc(defFoo, "·foo", write)
+    CALL ·foo(SB)   //@hiloc(refFoo, "·foo", read)
+//@highlightall(defFoo, refFoo)
+
+-- example/highlight_global.s --
+GLOBL ·bar(SB), $8     //@hiloc(defBar, "·bar", write)
+
+TEXT ·useBar(SB), $0-0
+    MOVQ ·bar(SB), AX  //@hiloc(refBar, "·bar", read)
+//@highlightall(defBar, refBar)
+
+-- example/highlight_label.s --
+TEXT ·loopDemo(SB), $0-0
+loop:        //@hiloc(defLoop, "loop", write)
+    JMP loop //@hiloc(refLoop, "loop", read)
+//@highlightall(defLoop, refLoop)
+
+-- example/highlight_register_amd64.s --
+TEXT ·useReg(SB), $0-0
+    MOVQ AX, BX //@hiloc(ax1, "AX", read)
+    ADDQ CX, AX //@hiloc(ax2, "AX", write)
+//@highlightall(ax1, ax2)
+
+-- example/highlight_register_scope_amd64.s --
+TEXT ·f1(SB), $0-0
+    MOVQ AX, BX //@hiloc(axF1, "AX", read)
+
+TEXT ·f2(SB), $0-0
+    MOVQ AX, CX
+//@highlightall(axF1)
+
+-- example/highlight_compare_amd64.s --
+TEXT ·cmpDemo(SB), $0-0
+    CMPQ AX, BX //@hiloc(cmpAX, "AX", read)
+//@highlightall(cmpAX)
+
+-- example/highlight_label_scope.s --
+TEXT ·f1(SB), $0-0
+loop:        //@hiloc(f1def, "loop", write)
+    JMP loop //@hiloc(f1ref, "loop", read)
+//@highlightall(f1def, f1ref)
+
+TEXT ·f2(SB), $0-0
+loop:        //@hiloc(f2def, "loop", write)
+    JMP loop //@hiloc(f2ref, "loop", read)
+//@highlightall(f2def, f2ref)
+
+-- example/highlight_mem_operand_amd64.s --
+TEXT ·storeLoad(SB), $0-0
+    MOVQ AX, (BX) //@hiloc(bxStore, "BX", read)
+    MOVQ (AX), BX //@hiloc(bxLoad, "BX", write)
+//@highlightall(bxStore, bxLoad)
+
+-- example/highlight_stack_amd64.s --
+TEXT ·stack(SB), $0-0
+    PUSHQ AX //@hiloc(axPush, "AX", read)
+    POPQ AX  //@hiloc(axPop, "AX", write)
+//@highlightall(axPush, axPop)
+
+-- example/highlight_register_386.s --
+TEXT ·reg386(SB), $0-0
+    POPL AX     //@hiloc(axPop386, "AX", write)
+    CMPL CX, AX //@hiloc(axCmp386, "AX", read)
+//@highlightall(axPop386, axCmp386)
+
+-- example/highlight_immediate_amd64.s --
+TEXT ·imm(SB), $0-0
+    MOVQ $123, AX //@hiloc(imm, "123", read)
+//@highlight(imm)
+
+-- example/highlight_comment_amd64.s --
+TEXT ·c(SB), $0-0
+    MOVQ AX, BX
+    // ADDQ AX, CX //@hiloc(commentAX, "AX", read)
+//@highlight(commentAX)
+
+-- example/highlight_mnemonic_amd64.s --
+TEXT ·mn(SB), $0-0
+    MOVQ AX, BX //@hiloc(mnemonic, "MOVQ", read)
+//@highlight(mnemonic)
+
+-- example/highlight_bt_amd64.s --
+TEXT ·btDemo(SB), $0-0
+    BTQ $3, AX //@hiloc(btAX, "AX", read)
+//@highlightall(btAX)
+
+-- example/highlight_setcc_amd64.s --
+TEXT ·setcc(SB), $0-0
+    SETEQ AL //@hiloc(alSet, "AL", write)
+//@highlightall(alSet)
+
+-- example/highlight_bswap_amd64.s --
+TEXT ·bswap(SB), $0-0
+    BSWAPL AX //@hiloc(axBswap, "AX", write)
+//@highlightall(axBswap)
+
+-- example/highlight_cmpxchg_amd64.s --
+TEXT ·xchg(SB), $0-0
+    CMPXCHGQ AX, BX //@hiloc(bxXchg, "BX", write)
+//@highlightall(bxXchg)
+
+-- example/highlight_bare_text_amd64.s --
+TEXT ·f(SB), $0-0
+    MOVQ AX, BX //@hiloc(axBare1, "AX", read)
+TEXT
+    MOVQ AX, CX //@hiloc(axBare2, "AX", read)
+//@highlightall(axBare1, axBare2)
+
+-- example/highlight_register_noarch.s --
+TEXT ·noArch(SB), $0-0
+    MOVQ AX, BX //@hiloc(axNoArch, "AX", read)
+//@highlight(axNoArch)
+
+-- example/highlight_arm64.s --
+TEXT ·armDemo(SB), $0-0
+    MOVD R1, R0 //@hiloc(r0mov, "R0", write)
+    CMP R0, R1  //@hiloc(r0cmp, "R0", read)
+    TST $1, R0  //@hiloc(r0tst, "R0", read)
+//@highlightall(r0mov, r0cmp, r0tst)
+
+-- example/highlight_regpair_arm64.s --
+TEXT ·pair(SB), $0-0
+    LDP (0*8)(R0), (R4, R5) //@hiloc(r4load, "R4", write)
+    STP (R4, R5), (0*8)(R0) //@hiloc(r4store, "R4", read)
+//@highlightall(r4load, r4store)
+
+-- example/highlight_macro_arm64.s --
+#define QR(a, b, c, d) VADD a, b, c
+TEXT ·macro(SB), $0-0
+    QR(V0, V4, V8, V12) //@hiloc(v12, "V12", read)
+//@highlightall(v12)
+
+-- example/highlight_macro_amd64.s --
+#define QR(a, b, c, d) PADDL a, b
+TEXT ·macro(SB), $0-0
+    QR(X0, X4, X8, X12) //@hiloc(x12, "X12", read)
+//@highlightall(x12)
+
+-- example/highlight_xmm_amd64.s --
+TEXT ·xmm(SB), $0-0
+    MOVOU X0, X1 //@hiloc(x1mov, "X1", write)
+    PXOR X1, X0  //@hiloc(x1xor, "X1", read)
+//@highlightall(x1mov, x1xor)
+
diff --git a/gopls/internal/util/asm/parse.go b/gopls/internal/util/asm/parse.go
index 233c0c4..765eb5f 100644
--- a/gopls/internal/util/asm/parse.go
+++ b/gopls/internal/util/asm/parse.go
@@ -9,6 +9,7 @@
 	"bufio"
 	"bytes"
 	"fmt"
+	"sort"
 	"strings"
 	"unicode"
 
@@ -45,6 +46,8 @@
 
 // A file represents a parsed file of Go assembly language.
 type File struct {
+	// Idents holds the identifiers of the file, ordered by Offset;
+	// [File.IdentAt] relies on that order for its binary search.
 	Idents []Ident
 
 	Mapper *protocol.Mapper
@@ -63,6 +66,55 @@
 	return f.Mapper.OffsetLocation(ident.Offset, ident.Offset+ident.OrigLen)
 }
 
+// IdentAt returns the identifier containing the byte range [start, end),
+// or nil if none. Because [File.Idents] are ordered by Offset, the
+// lookup uses a binary search.
+func (f *File) IdentAt(start, end int) *Ident {
+	// Find the last identifier whose Offset <= start.
+	idx := sort.Search(len(f.Idents), func(i int) bool {
+		return f.Idents[i].Offset > start
+	})
+	if idx == 0 {
+		return nil
+	}
+	id := &f.Idents[idx-1]
+	if end <= id.End() {
+		return id
+	}
+	return nil
+}
+
+// FunctionRange returns the byte range [start, end) of the TEXT function
+// enclosing offset: start is the beginning of the line containing the
+// enclosing TEXT directive, end is the beginning of the line containing
+// the next TEXT directive, or len(content) if there is none. If offset
+// precedes the first TEXT directive, the range covers from 0 to the
+// first TEXT directive.
+//
+// TEXT directives are taken from the parsed file rather than re-detected
+// here, so that scoping stays consistent with the identifiers the parser
+// reports (e.g. a bare "TEXT" line with no symbol is not a boundary).
+func (f *File) FunctionRange(offset int) (int, int) {
+	content := f.Mapper.Content
+	funcStart, funcEnd := 0, len(content)
+	for i := range f.Idents {
+		id := &f.Idents[i]
+		if id.Kind != Text {
+			continue
+		}
+		lineStart := id.Offset
+		for lineStart > 0 && content[lineStart-1] != '\n' {
+			lineStart--
+		}
+		if lineStart > offset {
+			funcEnd = lineStart
+			break
+		}
+		funcStart = lineStart
+	}
+	return funcStart, funcEnd
+}
+
 // Ident represents an identifier in an assembly file.
 type Ident struct {
 	Name    string // symbol name (after correcting [·∕]); Name[0]='.' => current package