gopls/internal/template: use protocol.Mapper and simplify

This CL replaces all the complex bookkeeping logic with
the existing protocol.Mapper. This fixes the associated
bug.

Details:
- eliminate parsed.{nls,lastnl,check,nonASCII}.
- use UTF-16 or byte offsets, never runes.
- propagate all Mapper errors upwards and handle them properly.
- eliminate unnecessary "multiline" token distinction
  and alternative logic. Mapper works fine.
- remove tests that reduced to tests of Mapper.
- remove append to file.Handle.Content buffer (a data race).

The only behavior changes to tests are:
- the extent of a string token "foo" now includes its quote marks
- the length of an identifier or literal is in bytes, not runes

Also:
- use clearer variable names.
- mark existing comments as TODO where appropriate.
- move symAtPosition
- rename findWordAt -> wordAt

Fixes golang/go#74635

Change-Id: Ia25b7dcbe28e3bc472ae103bd85f71e3c09e3a30
Reviewed-on: https://go-review.googlesource.com/c/tools/+/688937
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Alan Donovan <adonovan@google.com>
diff --git a/gopls/internal/protocol/semtok/semtok.go b/gopls/internal/protocol/semtok/semtok.go
index 86332d3..9922839 100644
--- a/gopls/internal/protocol/semtok/semtok.go
+++ b/gopls/internal/protocol/semtok/semtok.go
@@ -9,8 +9,8 @@
 
 // A Token provides the extent and semantics of a token.
 type Token struct {
-	Line, Start uint32
-	Len         uint32
+	Line, Start uint32 // 0-based UTF-16 index
+	Len         uint32 // in UTF-16 codes
 	Type        Type
 	Modifiers   []Modifier
 }
diff --git a/gopls/internal/template/completion.go b/gopls/internal/template/completion.go
index acd3be5..fb09ba4 100644
--- a/gopls/internal/template/completion.go
+++ b/gopls/internal/template/completion.go
@@ -31,15 +31,17 @@
 	var start int // the beginning of the Token (completed or not)
 	syms := make(map[string]symbol)
 	var p *parsed
-	for fn, fc := range all.files {
+	for uri, fc := range all.files {
 		// collect symbols from all template files
 		filterSyms(syms, fc.symbols)
-		if fn.Path() != fh.URI().Path() {
+		if uri.Path() != fh.URI().Path() {
 			continue
 		}
-		if start = inTemplate(fc, pos); start == -1 {
-			return nil, nil
+		offset, err := enclosingTokenStart(fc, pos)
+		if err != nil {
+			return nil, err
 		}
+		start = offset
 		p = fc
 	}
 	if p == nil {
@@ -74,20 +76,26 @@
 	}
 }
 
-// return the starting position of the enclosing token, or -1 if none
-func inTemplate(fc *parsed, pos protocol.Position) int {
+// enclosingTokenStart returns the start offset of the enclosing token.
+// A (-1, non-nil) result indicates "no enclosing token".
+func enclosingTokenStart(fc *parsed, pos protocol.Position) (int, error) {
 	// pos is the pos-th character. if the cursor is at the beginning
 	// of the file, pos is 0. That is, we've only seen characters before pos
 	// 1. pos might be in a Token, return tk.Start
 	// 2. pos might be after an elided but before a Token, return elided
 	// 3. return -1 for false
-	offset := fc.fromPosition(pos)
-	// this could be a binary search, as the tokens are ordered
+	offset, err := fc.mapper.PositionOffset(pos)
+	if err != nil {
+		return 0, err
+	}
+
+	// TODO: opt: this could be a binary search, as the tokens are ordered
 	for _, tk := range fc.tokens {
 		if tk.start+len(lbraces) <= offset && offset+len(rbraces) <= tk.end {
-			return tk.start
+			return tk.start, nil
 		}
 	}
+
 	for _, x := range fc.elided {
 		if x+len(lbraces) > offset {
 			// fc.elided is sorted, and x is the position where a '{{' was replaced
@@ -98,10 +106,10 @@
 		// If the interval [x,offset] does not contain Left or Right
 		// then provide completions. (do we need the test for Right?)
 		if !bytes.Contains(fc.buf[x:offset], lbraces) && !bytes.Contains(fc.buf[x:offset], rbraces) {
-			return x
+			return x, nil
 		}
 	}
-	return -1
+	return -1, fmt.Errorf("no token enclosing %d", pos)
 }
 
 var (
@@ -115,7 +123,10 @@
 // The error return is always nil.
 func (c *completer) complete() (*protocol.CompletionList, error) {
 	ans := &protocol.CompletionList{IsIncomplete: true, Items: []protocol.CompletionItem{}}
-	start := c.p.fromPosition(c.pos)
+	start, err := c.p.mapper.PositionOffset(c.pos)
+	if err != nil {
+		return ans, err
+	}
 	sofar := c.p.buf[c.offset:start]
 	if len(sofar) == 0 || sofar[len(sofar)-1] == ' ' || sofar[len(sofar)-1] == '\t' {
 		return ans, nil
diff --git a/gopls/internal/template/completion_test.go b/gopls/internal/template/completion_test.go
index 8863e05..279864a 100644
--- a/gopls/internal/template/completion_test.go
+++ b/gopls/internal/template/completion_test.go
@@ -19,13 +19,13 @@
 
 type tparse struct {
 	marked string   // ^ shows where to ask for completions. (The user just typed the following character.)
-	wanted []string // expected completions
+	wanted []string // expected completions; nil => no enclosing token
 }
 
 // Test completions in templates that parse enough (if completion needs symbols)
 // Seen characters up to the ^
 func TestParsed(t *testing.T) {
-	var tests = []tparse{
+	for _, test := range []tparse{
 		{"{{x}}{{12. xx^", nil}, // https://github.com/golang/go/issues/50430
 		{`<table class="chroma" data-new-comment-url="{{if $.PageIsPullFiles}}{{$.Issue.HTMLURL}}/files/reviews/new_comment{{else}}{{$.CommitHTML}}/new_comment^{{end}}">`, nil},
 		{"{{i^f}}", []string{"index", "if"}},
@@ -50,53 +50,56 @@
 		{"{{`e^", []string{}},
 		{"{{`No i^", []string{}}, // example of why go/scanner is used
 		{"{{xavier}}{{12. x^", []string{"xavier"}},
-	}
-	for _, tx := range tests {
-		c := testCompleter(t, tx)
-		var v []string
-		if c != nil {
-			ans, _ := c.complete()
-			for _, a := range ans.Items {
-				v = append(v, a.Label)
+	} {
+		t.Run("", func(t *testing.T) {
+			var got []string
+			if c := testCompleter(t, test); c != nil {
+				ans, _ := c.complete()
+				for _, a := range ans.Items {
+					got = append(got, a.Label)
+				}
 			}
-		}
-		if len(v) != len(tx.wanted) {
-			t.Errorf("%q: got %q, wanted %q %d,%d", tx.marked, v, tx.wanted, len(v), len(tx.wanted))
-			continue
-		}
-		sort.Strings(tx.wanted)
-		sort.Strings(v)
-		for i := 0; i < len(v); i++ {
-			if tx.wanted[i] != v[i] {
-				t.Errorf("%q at %d: got %v, wanted %v", tx.marked, i, v, tx.wanted)
-				break
+			if len(got) != len(test.wanted) {
+				t.Fatalf("%q: got %q, wanted %q %d,%d", test.marked, got, test.wanted, len(got), len(test.wanted))
 			}
-		}
+			sort.Strings(test.wanted)
+			sort.Strings(got)
+			for i := 0; i < len(got); i++ {
+				if test.wanted[i] != got[i] {
+					t.Fatalf("%q at %d: got %v, wanted %v", test.marked, i, got, test.wanted)
+				}
+			}
+		})
 	}
 }
 
 func testCompleter(t *testing.T, tx tparse) *completer {
-	t.Helper()
 	// seen chars up to ^
-	col := strings.Index(tx.marked, "^")
+	offset := strings.Index(tx.marked, "^")
 	buf := strings.Replace(tx.marked, "^", "", 1)
-	p := parseBuffer([]byte(buf))
-	pos := protocol.Position{Line: 0, Character: uint32(col)}
+	p := parseBuffer("", []byte(buf))
 	if p.parseErr != nil {
-		log.Printf("%q: %v", tx.marked, p.parseErr)
+		t.Logf("%q: %v", tx.marked, p.parseErr)
 	}
-	offset := inTemplate(p, pos)
-	if offset == -1 {
-		return nil
+	pos, err := p.mapper.OffsetPosition(offset)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	start, err := enclosingTokenStart(p, pos)
+	if err != nil {
+		if start == -1 {
+			return nil // no enclosing token
+		}
+		t.Fatal(err)
 	}
 	syms := make(map[string]symbol)
 	filterSyms(syms, p.symbols)
-	c := &completer{
+	return &completer{
 		p:      p,
-		pos:    protocol.Position{Line: 0, Character: uint32(col)},
-		offset: offset + len(lbraces),
+		pos:    pos,
+		offset: start + len(lbraces),
 		ctx:    protocol.CompletionContext{TriggerKind: protocol.Invoked},
 		syms:   syms,
 	}
-	return c
 }
diff --git a/gopls/internal/template/highlight.go b/gopls/internal/template/highlight.go
index 9e458ff..8a8244d 100644
--- a/gopls/internal/template/highlight.go
+++ b/gopls/internal/template/highlight.go
@@ -19,29 +19,34 @@
 	if err != nil {
 		return nil, err
 	}
-	p := parseBuffer(buf)
-	pos := p.fromPosition(loc)
-	var ans []protocol.DocumentHighlight
+	p := parseBuffer(fh.URI(), buf)
+	pos, err := p.mapper.PositionOffset(loc)
+	if err != nil {
+		return nil, err
+	}
+
 	if p.parseErr == nil {
 		for _, s := range p.symbols {
-			if s.start <= pos && pos < s.start+s.length {
+			if s.start <= pos && pos < s.start+s.len {
 				return markSymbols(p, s)
 			}
 		}
 	}
+
 	// these tokens exist whether or not there was a parse error
 	// (symbols require a successful parse)
 	for _, tok := range p.tokens {
 		if tok.start <= pos && pos < tok.end {
-			wordAt := findWordAt(p, pos)
+			wordAt := wordAt(p.buf, pos)
 			if len(wordAt) > 0 {
 				return markWordInToken(p, wordAt)
 			}
 		}
 	}
-	// find the 'word' at pos, etc: someday
+
+	// TODO: find the 'word' at pos, etc: someday
 	// until then we get the default action, which doesn't respect word boundaries
-	return ans, nil
+	return nil, nil
 }
 
 func markSymbols(p *parsed, sym symbol) ([]protocol.DocumentHighlight, error) {
@@ -52,8 +57,12 @@
 			if s.vardef {
 				kind = protocol.Write
 			}
+			rng, err := p.mapper.OffsetRange(s.offsets())
+			if err != nil {
+				return nil, err
+			}
 			ans = append(ans, protocol.DocumentHighlight{
-				Range: p._range(s.start, s.length),
+				Range: rng,
 				Kind:  kind,
 			})
 		}
@@ -69,10 +78,14 @@
 		return nil, fmt.Errorf("%q: unmatchable word (%v)", wordAt, err)
 	}
 	for _, tok := range p.tokens {
-		got := pat.FindAllIndex(p.buf[tok.start:tok.end], -1)
-		for i := range got {
+		matches := pat.FindAllIndex(p.buf[tok.start:tok.end], -1)
+		for _, match := range matches {
+			rng, err := p.mapper.OffsetRange(match[0], match[1])
+			if err != nil {
+				return nil, err
+			}
 			ans = append(ans, protocol.DocumentHighlight{
-				Range: p._range(got[i][0], got[i][1]-got[i][0]),
+				Range: rng,
 				Kind:  protocol.Text,
 			})
 		}
@@ -80,18 +93,20 @@
 	return ans, nil
 }
 
-var wordRe = regexp.MustCompile(`[$]?\w+$`)
-var moreRe = regexp.MustCompile(`^[$]?\w+`)
-
-// findWordAt finds the word the cursor is in (meaning in or just before)
-func findWordAt(p *parsed, pos int) string {
-	if pos >= len(p.buf) {
-		return "" // can't happen, as we are called with pos < tok.End
+// wordAt returns the word the cursor is in (meaning in or just before)
+func wordAt(buf []byte, pos int) string {
+	if pos >= len(buf) {
+		return ""
 	}
-	after := moreRe.Find(p.buf[pos:])
+	after := moreRe.Find(buf[pos:])
 	if len(after) == 0 {
 		return "" // end of the word
 	}
-	got := wordRe.Find(p.buf[:pos+len(after)])
+	got := wordRe.Find(buf[:pos+len(after)])
 	return string(got)
 }
+
+var (
+	wordRe = regexp.MustCompile(`[$]?\w+$`)
+	moreRe = regexp.MustCompile(`^[$]?\w+`)
+)
diff --git a/gopls/internal/template/implementations.go b/gopls/internal/template/implementations.go
index 303ccbb..7c69c01 100644
--- a/gopls/internal/template/implementations.go
+++ b/gopls/internal/template/implementations.go
@@ -36,46 +36,61 @@
 	// snapshot's template files
 	buf, err := fh.Content()
 	if err != nil {
-		// Is a Diagnostic with no Range useful? event.Error also?
+		// TODO: Is a Diagnostic with no Range useful? event.Error also?
 		msg := fmt.Sprintf("failed to read %s (%v)", fh.URI().Path(), err)
-		d := cache.Diagnostic{Message: msg, Severity: protocol.SeverityError, URI: fh.URI(),
-			Source: cache.TemplateError}
-		return []*cache.Diagnostic{&d}
+		return []*cache.Diagnostic{{
+			Message:  msg,
+			Severity: protocol.SeverityError,
+			URI:      fh.URI(),
+			Source:   cache.TemplateError,
+		}}
 	}
-	p := parseBuffer(buf)
+	p := parseBuffer(fh.URI(), buf)
 	if p.parseErr == nil {
 		return nil
 	}
-	unknownError := func(msg string) []*cache.Diagnostic {
-		s := fmt.Sprintf("malformed template error %q: %s", p.parseErr.Error(), msg)
-		d := cache.Diagnostic{
-			Message: s, Severity: protocol.SeverityError, Range: p._range(p.nls[0], 1),
-			URI: fh.URI(), Source: cache.TemplateError}
-		return []*cache.Diagnostic{&d}
+
+	errorf := func(format string, args ...any) []*cache.Diagnostic {
+		msg := fmt.Sprintf("malformed template error %q: %s",
+			p.parseErr.Error(),
+			fmt.Sprintf(format, args))
+		rng, err := p.mapper.OffsetRange(0, 1) // first UTF-16 code
+		if err != nil {
+			rng = protocol.Range{} // start of file
+		}
+		return []*cache.Diagnostic{{
+			Message:  msg,
+			Severity: protocol.SeverityError,
+			Range:    rng,
+			URI:      fh.URI(),
+			Source:   cache.TemplateError,
+		}}
 	}
+
 	// errors look like `template: :40: unexpected "}" in operand`
 	// so the string needs to be parsed
 	matches := errRe.FindStringSubmatch(p.parseErr.Error())
 	if len(matches) != 3 {
-		msg := fmt.Sprintf("expected 3 matches, got %d (%v)", len(matches), matches)
-		return unknownError(msg)
+		return errorf("expected 3 matches, got %d (%v)", len(matches), matches)
 	}
 	lineno, err := strconv.Atoi(matches[1])
 	if err != nil {
-		msg := fmt.Sprintf("couldn't convert %q to int, %v", matches[1], err)
-		return unknownError(msg)
+		return errorf("couldn't convert %q to int, %v", matches[1], err)
 	}
 	msg := matches[2]
-	d := cache.Diagnostic{Message: msg, Severity: protocol.SeverityError,
-		Source: cache.TemplateError}
-	start := p.nls[lineno-1]
-	if lineno < len(p.nls) {
-		size := p.nls[lineno] - start
-		d.Range = p._range(start, size)
-	} else {
-		d.Range = p._range(start, 1)
+
+	// Compute the range for the whole (1-based) line.
+	rng, err := lineRange(p.mapper, lineno)
+	if err != nil {
+		return errorf("invalid position: %v", err)
 	}
-	return []*cache.Diagnostic{&d}
+
+	return []*cache.Diagnostic{{
+		Message:  msg,
+		Severity: protocol.SeverityError,
+		Range:    rng,
+		Source:   cache.TemplateError,
+	}}
 }
 
 // Definition finds the definitions of the symbol at loc. It
@@ -91,12 +106,16 @@
 	ans := []protocol.Location{}
 	// PJW: this is probably a pattern to abstract
 	a := parseSet(snapshot.Templates())
-	for k, p := range a.files {
+	for _, p := range a.files {
 		for _, s := range p.symbols {
 			if !s.vardef || s.name != sym {
 				continue
 			}
-			ans = append(ans, k.Location(p._range(s.start, s.length)))
+			loc, err := p.mapper.OffsetLocation(s.offsets())
+			if err != nil {
+				return nil, err
+			}
+			ans = append(ans, loc)
 		}
 	}
 	return ans, nil
@@ -104,44 +123,60 @@
 
 func Hover(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, position protocol.Position) (*protocol.Hover, error) {
 	sym, p, err := symAtPosition(fh, position)
-	if sym == nil || err != nil {
+	if err != nil {
 		return nil, err
 	}
-	ans := protocol.Hover{Range: p._range(sym.start, sym.length), Contents: protocol.MarkupContent{Kind: protocol.Markdown}}
+
+	var value string
 	switch sym.kind {
 	case protocol.Function:
-		ans.Contents.Value = fmt.Sprintf("function: %s", sym.name)
+		value = fmt.Sprintf("function: %s", sym.name)
 	case protocol.Variable:
-		ans.Contents.Value = fmt.Sprintf("variable: %s", sym.name)
+		value = fmt.Sprintf("variable: %s", sym.name)
 	case protocol.Constant:
-		ans.Contents.Value = fmt.Sprintf("constant %s", sym.name)
+		value = fmt.Sprintf("constant %s", sym.name)
 	case protocol.Method: // field or method
-		ans.Contents.Value = fmt.Sprintf("%s: field or method", sym.name)
+		value = fmt.Sprintf("%s: field or method", sym.name)
 	case protocol.Package: // template use, template def (PJW: do we want two?)
-		ans.Contents.Value = fmt.Sprintf("template %s\n(add definition)", sym.name)
+		value = fmt.Sprintf("template %s\n(add definition)", sym.name)
 	case protocol.Namespace:
-		ans.Contents.Value = fmt.Sprintf("template %s defined", sym.name)
+		value = fmt.Sprintf("template %s defined", sym.name)
 	case protocol.Number:
-		ans.Contents.Value = "number"
+		value = "number"
 	case protocol.String:
-		ans.Contents.Value = "string"
+		value = "string"
 	case protocol.Boolean:
-		ans.Contents.Value = "boolean"
+		value = "boolean"
 	default:
-		ans.Contents.Value = fmt.Sprintf("oops, sym=%#v", sym)
+		value = fmt.Sprintf("oops, sym=%#v", sym)
 	}
-	return &ans, nil
+
+	rng, err := p.mapper.OffsetRange(sym.offsets())
+	if err != nil {
+		return nil, err
+	}
+
+	return &protocol.Hover{
+		Range: rng,
+		Contents: protocol.MarkupContent{
+			Kind:  protocol.Markdown,
+			Value: value,
+		},
+	}, nil
 }
 
 func References(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, params *protocol.ReferenceParams) ([]protocol.Location, error) {
 	sym, _, err := symAtPosition(fh, params.Position)
-	if sym == nil || err != nil || sym.name == "" {
+	if err != nil {
 		return nil, err
 	}
+	if sym.name == "" {
+		return nil, fmt.Errorf("no symbol at position")
+	}
 	ans := []protocol.Location{}
 
 	a := parseSet(snapshot.Templates())
-	for k, p := range a.files {
+	for _, p := range a.files {
 		for _, s := range p.symbols {
 			if s.name != sym.name {
 				continue
@@ -149,10 +184,14 @@
 			if s.vardef && !params.Context.IncludeDeclaration {
 				continue
 			}
-			ans = append(ans, k.Location(p._range(s.start, s.length)))
+			loc, err := p.mapper.OffsetLocation(s.offsets())
+			if err != nil {
+				return nil, err
+			}
+			ans = append(ans, loc)
 		}
 	}
-	// do these need to be sorted? (a.files is a map)
+	// TODO: do these need to be sorted? (a.files is a map)
 	return ans, nil
 }
 
@@ -165,47 +204,54 @@
 	if err != nil {
 		return nil, err
 	}
-	p := parseBuffer(buf)
+	p := parseBuffer(fh.URI(), buf)
 
 	var items []semtok.Token
-	add := func(line, start, len uint32) {
-		if len == 0 {
-			return // vscode doesn't like 0-length Tokens
-		}
-		// TODO(adonovan): don't ignore the rng restriction, if any.
-		items = append(items, semtok.Token{
-			Line:  line,
-			Start: start,
-			Len:   len,
-			Type:  semtok.TokMacro,
-		})
-	}
-
 	for _, t := range p.tokens {
-		if t.multiline {
-			la, ca := p.lineCol(t.start)
-			lb, cb := p.lineCol(t.end)
-			add(la, ca, p.runeCount(la, ca, 0))
-			for l := la + 1; l < lb; l++ {
-				add(l, 0, p.runeCount(l, 0, 0))
-			}
-			add(lb, 0, p.runeCount(lb, 0, cb))
-			continue
+		if t.start == t.end {
+			continue // vscode doesn't like 0-length tokens
 		}
-		sz, err := p.tokenSize(t)
+		pos, err := p.mapper.OffsetPosition(t.start)
 		if err != nil {
 			return nil, err
 		}
-		line, col := p.lineCol(t.start)
-		add(line, col, uint32(sz))
+		// TODO(adonovan): don't ignore the rng restriction, if any.
+		items = append(items, semtok.Token{
+			Line:  pos.Line,
+			Start: pos.Character,
+			Len:   uint32(protocol.UTF16Len(p.buf[t.start:t.end])),
+			Type:  semtok.TokMacro,
+		})
 	}
-	ans := &protocol.SemanticTokens{
+	return &protocol.SemanticTokens{
 		Data: semtok.Encode(items, nil, nil),
 		// for small cache, some day. for now, the LSP client ignores this
 		// (that is, when the LSP client starts returning these, we can cache)
 		ResultID: fmt.Sprintf("%v", time.Now()),
-	}
-	return ans, nil
+	}, nil
 }
 
-// still need to do rename, etc
+// TODO: still need to do rename, etc
+
+func symAtPosition(fh file.Handle, posn protocol.Position) (*symbol, *parsed, error) {
+	buf, err := fh.Content()
+	if err != nil {
+		return nil, nil, err
+	}
+	p := parseBuffer(fh.URI(), buf)
+	offset, err := p.mapper.PositionOffset(posn)
+	if err != nil {
+		return nil, nil, err
+	}
+	var syms []symbol
+	for _, s := range p.symbols {
+		if s.start <= offset && offset < s.start+s.len {
+			syms = append(syms, s)
+		}
+	}
+	if len(syms) == 0 {
+		return nil, p, fmt.Errorf("no symbol found")
+	}
+	sym := syms[0]
+	return &sym, p, nil
+}
diff --git a/gopls/internal/template/parse.go b/gopls/internal/template/parse.go
index 5cb825c..2050b32 100644
--- a/gopls/internal/template/parse.go
+++ b/gopls/internal/template/parse.go
@@ -10,20 +10,16 @@
 
 import (
 	"bytes"
-	"context"
 	"fmt"
 	"io"
 	"log"
 	"regexp"
-	"runtime"
 	"sort"
 	"text/template"
 	"text/template/parse"
-	"unicode/utf8"
 
 	"golang.org/x/tools/gopls/internal/file"
 	"golang.org/x/tools/gopls/internal/protocol"
-	"golang.org/x/tools/internal/event"
 )
 
 var (
@@ -32,11 +28,11 @@
 )
 
 type parsed struct {
-	buf    []byte   //contents
-	lines  [][]byte // needed?, other than for debugging?
-	elided []int    // offsets where Left was replaced by blanks
+	buf    []byte // contents
+	mapper *protocol.Mapper
+	elided []int // offsets where lbraces was replaced by blanks
 
-	// tokens are matched Left-Right pairs, computed before trying to parse
+	// tokens are matched lbraces-rbraces pairs, computed before trying to parse
 	tokens []token
 
 	// result of parsing
@@ -44,19 +40,11 @@
 	parseErr error
 	symbols  []symbol
 	stack    []parse.Node // used while computing symbols
-
-	// for mapping from offsets in buf to LSP coordinates
-	// See FromPosition() and LineCol()
-	nls      []int // offset of newlines before each line (nls[0]==-1)
-	lastnl   int   // last line seen
-	check    int   // used to decide whether to use lastnl or search through nls
-	nonASCII bool  // are there any non-ascii runes in buf?
 }
 
-// token is a single {{...}}. More precisely, Left...Right
+// A token is a single {{...}}.
 type token struct {
-	start, end int // offset from start of template
-	multiline  bool
+	start, end int // 0-based byte offset from start of template
 }
 
 // set contains the Parse of all the template files
@@ -70,43 +58,27 @@
 // TODO(adonovan): why doesn't parseSet return an error?
 func parseSet(tmpls map[protocol.DocumentURI]file.Handle) *set {
 	all := make(map[protocol.DocumentURI]*parsed)
-	for k, v := range tmpls {
-		buf, err := v.Content()
-		if err != nil { // PJW: decide what to do with these errors
-			log.Printf("failed to read %s (%v)", v.URI().Path(), err)
+	for uri, fh := range tmpls {
+		buf, err := fh.Content()
+		if err != nil {
+			// TODO(pjw): decide what to do with these errors
+			log.Printf("failed to read %s (%v)", fh.URI().Path(), err)
 			continue
 		}
-		all[k] = parseBuffer(buf)
+		all[uri] = parseBuffer(uri, buf)
 	}
 	return &set{files: all}
 }
 
-func parseBuffer(buf []byte) *parsed {
+func parseBuffer(uri protocol.DocumentURI, buf []byte) *parsed {
 	ans := &parsed{
-		buf:   buf,
-		check: -1,
-		nls:   []int{-1},
+		buf:    buf,
+		mapper: protocol.NewMapper(uri, buf),
 	}
 	if len(buf) == 0 {
 		return ans
 	}
-	// how to compute allAscii...
-	for _, b := range buf {
-		if b >= utf8.RuneSelf {
-			ans.nonASCII = true
-			break
-		}
-	}
-	if buf[len(buf)-1] != '\n' {
-		ans.buf = append(buf, '\n')
-	}
-	for i, p := range ans.buf {
-		if p == '\n' {
-			ans.nls = append(ans.nls, i)
-		}
-	}
 	ans.setTokens() // ans.buf may be a new []byte
-	ans.lines = bytes.Split(ans.buf, []byte{'\n'})
 	t, err := template.New("").Parse(string(ans.buf))
 	if err != nil {
 		funcs := make(template.FuncMap)
@@ -132,7 +104,7 @@
 		if t.Name() != "" {
 			// defining a template. The pos is just after {{define...}} (or {{block...}}?)
 			at, sz := ans.findLiteralBefore(int(t.Root.Pos))
-			s := symbol{start: at, length: sz, name: t.Name(), kind: protocol.Namespace, vardef: true}
+			s := symbol{start: at, len: sz, name: t.Name(), kind: protocol.Namespace, vardef: true}
 			ans.symbols = append(ans.symbols, s)
 		}
 	}
@@ -151,8 +123,7 @@
 }
 
 // findLiteralBefore locates the first preceding string literal
-// returning its position and length in buf
-// or returns -1 if there is none.
+// returning its offset and length in buf or (-1, 0) if there is none.
 // Assume double-quoted string rather than backquoted string for now.
 func (p *parsed) findLiteralBefore(pos int) (int, int) {
 	left, right := -1, -1
@@ -211,15 +182,11 @@
 			}
 			if bytes.HasPrefix(p.buf[n:], rbraces) {
 				right := n + len(rbraces)
-				tok := token{
-					start:     left,
-					end:       right,
-					multiline: bytes.Contains(p.buf[left:right], []byte{'\n'}),
-				}
+				tok := token{start: left, end: right}
 				p.tokens = append(p.tokens, tok)
 				state = Start
 			}
-			// If we see (unquoted) Left then the original left is probably the user
+			// If we see (unquoted) lbraces then the original left is probably the user
 			// typing. Suppress the original left
 			if bytes.HasPrefix(p.buf[n:], lbraces) {
 				p.elideAt(left)
@@ -236,7 +203,7 @@
 	}
 	// this error occurs after typing {{ at the end of the file
 	if state != Start {
-		// Unclosed Left. remove the Left at left
+		// Unclosed lbraces. remove the lbraces at left
 		p.elideAt(left)
 	}
 }
@@ -245,11 +212,9 @@
 	if p.elided == nil {
 		// p.buf is the same buffer that v.Read() returns, so copy it.
 		// (otherwise the next time it's parsed, elided information is lost)
-		b := make([]byte, len(p.buf))
-		copy(b, p.buf)
-		p.buf = b
+		p.buf = bytes.Clone(p.buf)
 	}
-	for i := 0; i < len(lbraces); i++ {
+	for i := range lbraces {
 		p.buf[left+i] = ' '
 	}
 	p.elided = append(p.elided, left)
@@ -264,140 +229,24 @@
 	return backSlashes%2 == 1
 }
 
-// TODO(adonovan): the next 100 lines could perhaps replaced by use of protocol.Mapper.
+// lineRange returns the range for the entire specified (1-based) line.
+func lineRange(m *protocol.Mapper, line int) (protocol.Range, error) {
+	posn := protocol.Position{Line: uint32(line - 1)}
 
-func (p *parsed) utf16len(buf []byte) int {
-	cnt := 0
-	if !p.nonASCII {
-		return len(buf)
-	}
-	// we need a utf16len(rune), but we don't have it
-	for _, r := range string(buf) {
-		cnt++
-		if r >= 1<<16 {
-			cnt++
-		}
-	}
-	return cnt
-}
-
-func (p *parsed) tokenSize(t token) (int, error) {
-	if t.multiline {
-		return -1, fmt.Errorf("TokenSize called with Multiline token %#v", t)
-	}
-	ans := p.utf16len(p.buf[t.start:t.end])
-	return ans, nil
-}
-
-// runeCount counts runes in line l, from col s to e
-// (e==0 for end of line. called only for multiline tokens)
-func (p *parsed) runeCount(l, s, e uint32) uint32 {
-	start := p.nls[l] + 1 + int(s)
-	end := p.nls[l] + 1 + int(e)
-	if e == 0 || end > p.nls[l+1] {
-		end = p.nls[l+1]
-	}
-	return uint32(utf8.RuneCount(p.buf[start:end]))
-}
-
-// lineCol converts from a 0-based byte offset to 0-based line, col. col in runes
-func (p *parsed) lineCol(x int) (uint32, uint32) {
-	if x < p.check {
-		p.lastnl = 0
-	}
-	p.check = x
-	for i := p.lastnl; i < len(p.nls); i++ {
-		if p.nls[i] <= x {
-			continue
-		}
-		p.lastnl = i
-		var count int
-		if i > 0 && x == p.nls[i-1] { // \n
-			count = 0
-		} else {
-			count = p.utf16len(p.buf[p.nls[i-1]+1 : x])
-		}
-		return uint32(i - 1), uint32(count)
-	}
-	if x == len(p.buf)-1 { // trailing \n
-		return uint32(len(p.nls) - 1), 0
-	}
-	// shouldn't happen
-	for i := 1; i < 4; i++ {
-		_, f, l, ok := runtime.Caller(i)
-		if !ok {
-			break
-		}
-		log.Printf("%d: %s:%d", i, f, l)
-	}
-
-	msg := fmt.Errorf("LineCol off the end, %d of %d, nls=%v, %q", x, len(p.buf), p.nls, p.buf[x:])
-	event.Error(context.Background(), "internal error", msg)
-	return 0, 0
-}
-
-// position produces a protocol.position from an offset in the template
-func (p *parsed) position(pos int) protocol.Position {
-	line, col := p.lineCol(pos)
-	return protocol.Position{Line: line, Character: col}
-}
-
-func (p *parsed) _range(x, length int) protocol.Range {
-	line, col := p.lineCol(x)
-	ans := protocol.Range{
-		Start: protocol.Position{Line: line, Character: col},
-		End:   protocol.Position{Line: line, Character: col + uint32(length)},
-	}
-	return ans
-}
-
-// fromPosition translates a protocol.Position into an offset into the template
-func (p *parsed) fromPosition(x protocol.Position) int {
-	l, c := int(x.Line), int(x.Character)
-	if l >= len(p.nls) || p.nls[l]+1 >= len(p.buf) {
-		// paranoia to avoid panic. return the largest offset
-		return len(p.buf)
-	}
-	line := p.buf[p.nls[l]+1:]
-	cnt := 0
-	for w := range string(line) {
-		if cnt >= c {
-			return w + p.nls[l] + 1
-		}
-		cnt++
-	}
-	// do we get here? NO
-	pos := int(x.Character) + p.nls[int(x.Line)] + 1
-	event.Error(context.Background(), "internal error", fmt.Errorf("surprise %#v", x))
-	return pos
-}
-
-func symAtPosition(fh file.Handle, loc protocol.Position) (*symbol, *parsed, error) {
-	buf, err := fh.Content()
+	// start of line
+	start, err := m.PositionOffset(posn)
 	if err != nil {
-		return nil, nil, err
+		return protocol.Range{}, err
 	}
-	p := parseBuffer(buf)
-	pos := p.fromPosition(loc)
-	syms := p.symsAtPos(pos)
-	if len(syms) == 0 {
-		return nil, p, fmt.Errorf("no symbol found")
-	}
-	if len(syms) > 1 {
-		log.Printf("Hover: %d syms, not 1 %v", len(syms), syms)
-	}
-	sym := syms[0]
-	return &sym, p, nil
-}
 
-func (p *parsed) symsAtPos(pos int) []symbol {
-	ans := []symbol{}
-	for _, s := range p.symbols {
-		if s.start <= pos && pos < s.start+s.length {
-			ans = append(ans, s)
-		}
+	// end of line (or file)
+	posn.Line++
+	end := len(m.Content) // EOF
+	if offset, err := m.PositionOffset(posn); err != nil {
+		end = offset - len("\n")
 	}
-	return ans
+
+	return m.OffsetRange(start, end)
 }
 
 // -- debugging --
@@ -417,8 +266,12 @@
 		return
 	}
 	at := func(pos parse.Pos) string {
-		line, col := wr.p.lineCol(int(pos))
-		return fmt.Sprintf("(%d)%v:%v", pos, line, col)
+		offset := int(pos)
+		posn, err := wr.p.mapper.OffsetPosition(offset)
+		if err != nil {
+			return fmt.Sprintf("<bad pos %d: %v>", pos, err)
+		}
+		return fmt.Sprintf("(%d)%v:%v", pos, posn.Line, posn.Character)
 	}
 	switch x := n.(type) {
 	case *parse.ActionNode:
diff --git a/gopls/internal/template/parse_test.go b/gopls/internal/template/parse_test.go
index 507e39e..dc023f3 100644
--- a/gopls/internal/template/parse_test.go
+++ b/gopls/internal/template/parse_test.go
@@ -4,51 +4,64 @@
 
 package template
 
-import (
-	"strings"
-	"testing"
-)
+import "testing"
 
-type datum struct {
-	buf  string
-	cnt  int
-	syms []string // the symbols in the parse of buf
-}
-
-var tmpl = []datum{{`
+func TestSymbols(t *testing.T) {
+	for i, test := range []struct {
+		buf       string
+		wantNamed int      // expected number of named templates
+		syms      []string // expected symbols (start, len, name, kind, def?)
+	}{
+		{`
 {{if (foo .X.Y)}}{{$A := "hi"}}{{.Z $A}}{{else}}
 {{$A.X 12}}
 {{foo (.X.Y) 23 ($A.Zü)}}
-{{end}}`, 1, []string{"{7,3,foo,Function,false}", "{12,1,X,Method,false}",
-	"{14,1,Y,Method,false}", "{21,2,$A,Variable,true}", "{26,2,,String,false}",
-	"{35,1,Z,Method,false}", "{38,2,$A,Variable,false}",
-	"{53,2,$A,Variable,false}", "{56,1,X,Method,false}", "{57,2,,Number,false}",
-	"{64,3,foo,Function,false}", "{70,1,X,Method,false}",
-	"{72,1,Y,Method,false}", "{75,2,,Number,false}", "{80,2,$A,Variable,false}",
-	"{83,2,Zü,Method,false}", "{94,3,,Constant,false}"}},
-
-	{`{{define "zzz"}}{{.}}{{end}}
-{{template "zzz"}}`, 2, []string{"{10,3,zzz,Namespace,true}", "{18,1,dot,Variable,false}",
-		"{41,3,zzz,Package,false}"}},
-
-	{`{{block "aaa" foo}}b{{end}}`, 2, []string{"{9,3,aaa,Namespace,true}",
-		"{9,3,aaa,Package,false}", "{14,3,foo,Function,false}", "{19,1,,Constant,false}"}},
-	{"", 0, nil},
-}
-
-func TestSymbols(t *testing.T) {
-	for i, x := range tmpl {
-		got := parseBuffer([]byte(x.buf))
+{{end}}`, 1, []string{
+			"{7,3,foo,Function,false}",
+			"{12,1,X,Method,false}",
+			"{14,1,Y,Method,false}",
+			"{21,2,$A,Variable,true}",
+			"{26,4,,String,false}",
+			"{35,1,Z,Method,false}",
+			"{38,2,$A,Variable,false}",
+			"{53,2,$A,Variable,false}",
+			"{56,1,X,Method,false}",
+			"{57,2,,Number,false}",
+			"{64,3,foo,Function,false}",
+			"{70,1,X,Method,false}",
+			"{72,1,Y,Method,false}",
+			"{75,2,,Number,false}",
+			"{80,2,$A,Variable,false}",
+			"{83,3,Zü,Method,false}",
+			"{94,3,,Constant,false}",
+		}},
+		{`{{define "zzz"}}{{.}}{{end}}
+{{template "zzz"}}`, 2, []string{
+			"{10,3,zzz,Namespace,true}",
+			"{18,1,dot,Variable,false}",
+			"{41,3,zzz,Package,false}",
+		}},
+		{`{{block "aaa" foo}}b{{end}}`, 2, []string{
+			"{9,3,aaa,Namespace,true}",
+			"{9,3,aaa,Package,false}",
+			"{14,3,foo,Function,false}",
+			"{19,1,,Constant,false}",
+		}},
+		{"", 0, nil},
+		{`{{/* this is
+a comment */}}`, 1, nil}, // https://go.dev/issue/74635
+	} {
+		got := parseBuffer("", []byte(test.buf))
 		if got.parseErr != nil {
-			t.Errorf("error:%v", got.parseErr)
+			t.Error(got.parseErr)
 			continue
 		}
-		if len(got.named) != x.cnt {
-			t.Errorf("%d: got %d, expected %d", i, len(got.named), x.cnt)
+		if len(got.named) != test.wantNamed {
+			t.Errorf("%d: got %d, expected %d", i, len(got.named), test.wantNamed)
 		}
 		for n, s := range got.symbols {
-			if s.String() != x.syms[n] {
-				t.Errorf("%d: got %s, expected %s", i, s.String(), x.syms[n])
+			if s.String() != test.syms[n] {
+				t.Errorf("%d: got %s, expected %s", i, s.String(), test.syms[n])
 			}
 		}
 	}
@@ -58,174 +71,29 @@
 	want := []string{"", "", "$A", "$A", "", "", "", "", "", "",
 		"", "", "", "if", "if", "", "$A", "$A", "", "",
 		"B", "", "", "end", "end", "end", "", "", ""}
-	p := parseBuffer([]byte("{{$A := .}}{{if $A}}B{{end}}"))
-	for i := 0; i < len(p.buf); i++ {
-		got := findWordAt(p, i)
+	buf := []byte("{{$A := .}}{{if $A}}B{{end}}")
+	for i := 0; i < len(buf); i++ {
+		got := wordAt(buf, i)
 		if got != want[i] {
 			t.Errorf("for %d, got %q, wanted %q", i, got, want[i])
 		}
 	}
 }
 
-func TestNLS(t *testing.T) {
-	buf := `{{if (foÜx .X.Y)}}{{$A := "hi"}}{{.Z $A}}{{else}}
-	{{$A.X 12}}
-	{{foo (.X.Y) 23 ($A.Z)}}
-	{{end}}
-	`
-	p := parseBuffer([]byte(buf))
-	if p.parseErr != nil {
-		t.Fatal(p.parseErr)
-	}
-	// line 0 doesn't have a \n in front of it
-	for i := 1; i < len(p.nls)-1; i++ {
-		if buf[p.nls[i]] != '\n' {
-			t.Errorf("line %d got %c", i, buf[p.nls[i]])
-		}
-	}
-	// fake line at end of file
-	if p.nls[len(p.nls)-1] != len(buf) {
-		t.Errorf("got %d expected %d", p.nls[len(p.nls)-1], len(buf))
-	}
-}
-
-func TestLineCol(t *testing.T) {
-	buf := `{{if (foÜx .X.Y)}}{{$A := "hi"}}{{.Z $A}}{{else}}
-	{{$A.X 12}}
-	{{foo (.X.Y) 23 ($A.Z)}}
-	{{end}}`
-	if false {
-		t.Error(buf)
-	}
-	for n, cx := range tmpl {
-		buf := cx.buf
-		p := parseBuffer([]byte(buf))
-		if p.parseErr != nil {
-			t.Fatal(p.parseErr)
-		}
-		type loc struct {
-			offset int
-			l, c   uint32
-		}
-		saved := []loc{}
-		// forwards
-		var lastl, lastc uint32
-		for offset := range buf {
-			l, c := p.lineCol(offset)
-			saved = append(saved, loc{offset, l, c})
-			if l > lastl {
-				lastl = l
-				if c != 0 {
-					t.Errorf("line %d, got %d instead of 0", l, c)
-				}
-			}
-			if c > lastc {
-				lastc = c
-			}
-		}
-		lines := strings.Split(buf, "\n")
-		mxlen := -1
-		for _, l := range lines {
-			if len(l) > mxlen {
-				mxlen = len(l)
-			}
-		}
-		if int(lastl) != len(lines)-1 && int(lastc) != mxlen {
-			// lastl is 0 if there is only 1 line(?)
-			t.Errorf("expected %d, %d, got %d, %d for case %d", len(lines)-1, mxlen, lastl, lastc, n)
-		}
-		// backwards
-		for j := len(saved) - 1; j >= 0; j-- {
-			s := saved[j]
-			xl, xc := p.lineCol(s.offset)
-			if xl != s.l || xc != s.c {
-				t.Errorf("at offset %d(%d), got (%d,%d), expected (%d,%d)", s.offset, j, xl, xc, s.l, s.c)
-			}
-		}
-	}
-}
-
-func TestLineColNL(t *testing.T) {
-	buf := "\n\n\n\n\n"
-	p := parseBuffer([]byte(buf))
-	if p.parseErr != nil {
-		t.Fatal(p.parseErr)
-	}
-	for i := 0; i < len(buf); i++ {
-		l, c := p.lineCol(i)
-		if c != 0 || int(l) != i+1 {
-			t.Errorf("got (%d,%d), expected (%d,0)", l, c, i)
-		}
-	}
-}
-
-func TestPos(t *testing.T) {
-	buf := `
-	{{if (foÜx .X.Y)}}{{$A := "hi"}}{{.Z $A}}{{else}}
-	{{$A.X 12}}
-	{{foo (.X.Y) 23 ($A.Z)}}
-	{{end}}`
-	p := parseBuffer([]byte(buf))
-	if p.parseErr != nil {
-		t.Fatal(p.parseErr)
-	}
-	for pos, r := range buf {
-		if r == '\n' {
-			continue
-		}
-		x := p.position(pos)
-		n := p.fromPosition(x)
-		if n != pos {
-			// once it's wrong, it will be wrong forever
-			t.Fatalf("at pos %d (rune %c) got %d {%#v]", pos, r, n, x)
-		}
-
-	}
-}
-
-func TestLen(t *testing.T) {
-	data := []struct {
-		cnt int
-		v   string
-	}{{1, "a"}, {1, "膈"}, {4, "😆🥸"}, {7, "3😀4567"}}
-	p := &parsed{nonASCII: true}
-	for _, d := range data {
-		got := p.utf16len([]byte(d.v))
-		if got != d.cnt {
-			t.Errorf("%v, got %d wanted %d", d, got, d.cnt)
-		}
-	}
-}
-
-func TestUtf16(t *testing.T) {
-	buf := `
-	{{if (foÜx .X.Y)}}😀{{$A := "hi"}}{{.Z $A}}{{else}}
-	{{$A.X 12}}
-	{{foo (.X.Y) 23 ($A.Z)}}
-	{{end}}`
-	p := parseBuffer([]byte(buf))
-	if p.nonASCII == false {
-		t.Error("expected nonASCII to be true")
-	}
-}
-
-type ttest struct {
-	tmpl      string
-	tokCnt    int
-	elidedCnt int8
-}
-
 func TestQuotes(t *testing.T) {
-	tsts := []ttest{
+	for _, s := range []struct {
+		tmpl      string
+		tokCnt    int
+		elidedCnt int8
+	}{
 		{"{{- /*comment*/ -}}", 1, 0},
 		{"{{/*`\ncomment\n`*/}}", 1, 0},
 		//{"{{foo\nbar}}\n", 1, 0}, // this action spanning lines parses in 1.16
 		{"{{\"{{foo}}{{\"}}", 1, 0},
 		{"{{\n{{- when}}", 1, 1},          // corrected
 		{"{{{{if .}}xx{{\n{{end}}", 2, 2}, // corrected
-	}
-	for _, s := range tsts {
-		p := parseBuffer([]byte(s.tmpl))
+	} {
+		p := parseBuffer("", []byte(s.tmpl))
 		if len(p.tokens) != s.tokCnt {
 			t.Errorf("%q: got %d tokens, expected %d", s, len(p.tokens), s.tokCnt)
 		}
diff --git a/gopls/internal/template/symbols.go b/gopls/internal/template/symbols.go
index 72add91..00745c2 100644
--- a/gopls/internal/template/symbols.go
+++ b/gopls/internal/template/symbols.go
@@ -9,7 +9,6 @@
 	"context"
 	"fmt"
 	"text/template/parse"
-	"unicode/utf8"
 
 	"golang.org/x/tools/gopls/internal/cache"
 	"golang.org/x/tools/gopls/internal/file"
@@ -19,8 +18,8 @@
 
 // in local coordinates, to be translated to protocol.DocumentSymbol
 type symbol struct {
-	start  int // for sorting
-	length int // in runes (unicode code points)
+	start  int // 0-based byte offset, for sorting
+	len    int // of source, in bytes
 	name   string
 	kind   protocol.SymbolKind
 	vardef bool // is this a variable definition?
@@ -28,8 +27,12 @@
 	// no children yet, and selection range is the same as range
 }
 
+func (s symbol) offsets() (start, end int) {
+	return s.start, s.start + s.len
+}
+
 func (s symbol) String() string {
-	return fmt.Sprintf("{%d,%d,%s,%s,%v}", s.start, s.length, s.name, s.kind, s.vardef)
+	return fmt.Sprintf("{%d,%d,%s,%s,%v}", s.start, s.len, s.name, s.kind, s.vardef)
 }
 
 // for FieldNode or VariableNode (or ChainNode?)
@@ -80,7 +83,7 @@
 		if f[0] == '$' {
 			kind = protocol.Variable
 		}
-		sym := symbol{name: f, kind: kind, start: at, length: utf8.RuneCountInString(f)}
+		sym := symbol{name: f, kind: kind, start: at, len: len(f)}
 		if kind == protocol.Variable && len(p.stack) > 1 {
 			if pipe, ok := p.stack[len(p.stack)-2].(*parse.PipeNode); ok {
 				for _, y := range pipe.Decl {
@@ -118,7 +121,7 @@
 	case *parse.BoolNode:
 		// need to compute the length from the value
 		msg := fmt.Sprintf("%v", x.True)
-		p.symbols = append(p.symbols, symbol{start: int(x.Pos), length: len(msg), kind: protocol.Boolean})
+		p.symbols = append(p.symbols, symbol{start: int(x.Pos), len: len(msg), kind: protocol.Boolean})
 	case *parse.BranchNode:
 		nxt(x.Pipe)
 		nxt(x.List)
@@ -133,13 +136,12 @@
 	//case *parse.CommentNode: // go 1.16
 	//	log.Printf("implement %d", x.Type())
 	case *parse.DotNode:
-		sym := symbol{name: "dot", kind: protocol.Variable, start: int(x.Pos), length: 1}
+		sym := symbol{name: "dot", kind: protocol.Variable, start: int(x.Pos), len: 1}
 		p.symbols = append(p.symbols, sym)
 	case *parse.FieldNode:
 		p.symbols = append(p.symbols, p.fields(x.Ident, x)...)
 	case *parse.IdentifierNode:
-		sym := symbol{name: x.Ident, kind: protocol.Function, start: int(x.Pos),
-			length: utf8.RuneCountInString(x.Ident)}
+		sym := symbol{name: x.Ident, kind: protocol.Function, start: int(x.Pos), len: len(x.Ident)}
 		p.symbols = append(p.symbols, sym)
 	case *parse.IfNode:
 		nxt(&x.BranchNode)
@@ -150,11 +152,11 @@
 			}
 		}
 	case *parse.NilNode:
-		sym := symbol{name: "nil", kind: protocol.Constant, start: int(x.Pos), length: 3}
+		sym := symbol{name: "nil", kind: protocol.Constant, start: int(x.Pos), len: 3}
 		p.symbols = append(p.symbols, sym)
 	case *parse.NumberNode:
 		// no name; ascii
-		p.symbols = append(p.symbols, symbol{start: int(x.Pos), length: len(x.Text), kind: protocol.Number})
+		p.symbols = append(p.symbols, symbol{start: int(x.Pos), len: len(x.Text), kind: protocol.Number})
 	case *parse.PipeNode:
 		if x == nil { // {{template "foo"}}
 			return
@@ -169,25 +171,23 @@
 		nxt(&x.BranchNode)
 	case *parse.StringNode:
 		// no name
-		sz := utf8.RuneCountInString(x.Text)
-		p.symbols = append(p.symbols, symbol{start: int(x.Pos), length: sz, kind: protocol.String})
-	case *parse.TemplateNode: // invoking a template
-		// x.Pos points to the quote before the name
-		p.symbols = append(p.symbols, symbol{name: x.Name, kind: protocol.Package, start: int(x.Pos) + 1,
-			length: utf8.RuneCountInString(x.Name)})
+		p.symbols = append(p.symbols, symbol{start: int(x.Pos), len: len(x.Quoted), kind: protocol.String})
+	case *parse.TemplateNode:
+		// invoking a template, e.g. {{define "foo"}}
+		// x.Pos is the index of "foo".
+		// The logic below assumes that the literal is trivial.
+		p.symbols = append(p.symbols, symbol{name: x.Name, kind: protocol.Package, start: int(x.Pos) + len(`"`), len: len(x.Name)})
 		nxt(x.Pipe)
 	case *parse.TextNode:
 		if len(x.Text) == 1 && x.Text[0] == '\n' {
 			break
 		}
 		// nothing to report, but build one for hover
-		sz := utf8.RuneCount(x.Text)
-		p.symbols = append(p.symbols, symbol{start: int(x.Pos), length: sz, kind: protocol.Constant})
+		p.symbols = append(p.symbols, symbol{start: int(x.Pos), len: len(x.Text), kind: protocol.Constant})
 	case *parse.VariableNode:
 		p.symbols = append(p.symbols, p.fields(x.Ident, x)...)
 	case *parse.WithNode:
 		nxt(&x.BranchNode)
-
 	}
 	pop()
 }
@@ -199,33 +199,35 @@
 	if err != nil {
 		return nil, err
 	}
-	p := parseBuffer(buf)
+	p := parseBuffer(fh.URI(), buf)
 	if p.parseErr != nil {
 		return nil, p.parseErr
 	}
 	var ans []protocol.DocumentSymbol
-	for _, s := range p.symbols {
-		if s.kind == protocol.Constant {
+	for _, sym := range p.symbols {
+		if sym.kind == protocol.Constant {
 			continue
 		}
-		d := kindStr(s.kind)
-		if d == "Namespace" {
-			d = "Template"
+		detail := kindStr(sym.kind)
+		if detail == "Namespace" {
+			detail = "Template"
 		}
-		if s.vardef {
-			d += "(def)"
+		if sym.vardef {
+			detail += "(def)"
 		} else {
-			d += "(use)"
+			detail += "(use)"
 		}
-		r := p._range(s.start, s.length)
-		y := protocol.DocumentSymbol{
-			Name:           s.name,
-			Detail:         d,
-			Kind:           s.kind,
-			Range:          r,
-			SelectionRange: r, // or should this be the entire {{...}}?
+		rng, err := p.mapper.OffsetRange(sym.offsets())
+		if err != nil {
+			return nil, err
 		}
-		ans = append(ans, y)
+		ans = append(ans, protocol.DocumentSymbol{
+			Name:           sym.name,
+			Detail:         detail,
+			Kind:           sym.kind,
+			Range:          rng,
+			SelectionRange: rng, // or should this be the entire {{...}}?
+		})
 	}
 	return ans, nil
 }
diff --git a/gopls/internal/test/integration/template/template_test.go b/gopls/internal/test/integration/template/template_test.go
index 22eff5e..0cf3592 100644
--- a/gopls/internal/test/integration/template/template_test.go
+++ b/gopls/internal/test/integration/template/template_test.go
@@ -53,8 +53,7 @@
 }
 
 func TestMultilineTokensAgain(t *testing.T) {
-	t.Skip("skipping due to go.dev/issue/74635")
-
+	// Regression tests for a crash; see go.dev/issue/74635.
 	const files = `
 -- go.mod --
 module mod.com