gopls/internal/lsp/protocol: simplify ColumnMapper

This change decouples ColumnMapper from go/token.
Its TokFile field is now unexported and fully
encapsulated and will be removed in a follow-up;
it serves only as a line-number table.
ColumnMapper now provides only mapping between
byte offsets and columns, in three different
units (UTF-8, UTF-16, and runes).

Three operations that require both a Mapper and
a token.File--and require then to be consistent with
each other--have been moved to ParsedGoFile:
(Pos, PosRange, and RangeToSpanRange).
This is another step to keeping the use of token.Pos
close to its token.File or FileSet, and using
byte offsets and ColumnMappers more broadly.

MappedRange now holds a ParsedGoFile and (internally)
a start/end Pos pair, making it self-contained for all
conversions. (The File field is unfortunately public
for now due to one tricky use; fixing it would have
expanded this already large CL.)
I'm not sure whether MappedRange carries its weight;
I think it might be clearer for all users to simply
expand it out (i.e. hold a ColumnMapper and two byte
offsets), making one less creature in the zoo.
Numerous calls to NewMappedRange followed by .Range()
have been reduced to pgf.PosRange().

Also:
- New ColumnMapper methods:
    OffsetSpan
    OffsetPoint
- safetoken.Offsets(start, end) is the plural of Offset(pos).
- span.ToPosition renamed span.OffsetToLineCol8.
- span.NewTokenFile inlined into sole caller.
- avoid embedding of MappedRange, as it makes the references
  hard to see. (Embedded fields are both a def and a ref but
  gopls cross-references is confused by that.)
- findLinksInString uses offsets now.

Change-Id: I2c775e181e456604e2ce977d618b0f1ec8e76903
Reviewed-on: https://go-review.googlesource.com/c/tools/+/460615
Run-TryBot: Alan Donovan <adonovan@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
diff --git a/gopls/internal/lsp/cache/analysis.go b/gopls/internal/lsp/cache/analysis.go
index 61e868c..8bbe2ed 100644
--- a/gopls/internal/lsp/cache/analysis.go
+++ b/gopls/internal/lsp/cache/analysis.go
@@ -1024,7 +1024,7 @@
 				if end == token.NoPos {
 					end = start
 				}
-				rng, err := p.Mapper.PosRange(start, end)
+				rng, err := p.PosRange(start, end)
 				if err != nil {
 					return protocol.Location{}, err
 				}
diff --git a/gopls/internal/lsp/cache/check.go b/gopls/internal/lsp/cache/check.go
index cfac5ff..c52a2f8 100644
--- a/gopls/internal/lsp/cache/check.go
+++ b/gopls/internal/lsp/cache/check.go
@@ -683,7 +683,7 @@
 			}
 
 			for _, imp := range allImports[item] {
-				rng, err := source.NewMappedRange(imp.cgf.Mapper, imp.imp.Pos(), imp.imp.End()).Range()
+				rng, err := imp.cgf.PosRange(imp.imp.Pos(), imp.imp.End())
 				if err != nil {
 					return nil, err
 				}
diff --git a/gopls/internal/lsp/cache/errors.go b/gopls/internal/lsp/cache/errors.go
index 7ca4f07..6777193 100644
--- a/gopls/internal/lsp/cache/errors.go
+++ b/gopls/internal/lsp/cache/errors.go
@@ -86,17 +86,12 @@
 	if err != nil {
 		return nil, err
 	}
-	pos := pgf.Tok.Pos(e.Pos.Offset)
-	spn, err := span.NewRange(pgf.Tok, pos, pos).Span()
-	if err != nil {
-		return nil, err
-	}
-	rng, err := spanToRange(pkg, spn)
+	rng, err := pgf.Mapper.OffsetRange(e.Pos.Offset, e.Pos.Offset)
 	if err != nil {
 		return nil, err
 	}
 	return []*source.Diagnostic{{
-		URI:      spn.URI(),
+		URI:      pgf.URI,
 		Range:    rng,
 		Severity: protocol.SeverityError,
 		Source:   source.ParseError,
@@ -327,7 +322,7 @@
 	if !end.IsValid() || end == start {
 		end = analysisinternal.TypeErrorEndPos(fset, pgf.Src, start)
 	}
-	spn, err := span.FileSpan(pgf.Mapper.TokFile, start, end)
+	spn, err := span.FileSpan(pgf.Tok, start, end)
 	if err != nil {
 		return 0, span.Span{}, err
 	}
@@ -379,7 +374,11 @@
 		// Search file imports for the import that is causing the import cycle.
 		for _, imp := range cgf.File.Imports {
 			if imp.Path.Value == circImp {
-				spn, err := span.NewRange(cgf.Tok, imp.Pos(), imp.End()).Span()
+				start, end, err := safetoken.Offsets(cgf.Tok, imp.Pos(), imp.End())
+				if err != nil {
+					return msg, span.Span{}, false
+				}
+				spn, err := cgf.Mapper.OffsetSpan(start, end)
 				if err != nil {
 					return msg, span.Span{}, false
 				}
diff --git a/gopls/internal/lsp/cache/load.go b/gopls/internal/lsp/cache/load.go
index f79109a..b072aaf 100644
--- a/gopls/internal/lsp/cache/load.go
+++ b/gopls/internal/lsp/cache/load.go
@@ -391,10 +391,7 @@
 			if pgf, err := s.ParseGo(ctx, fh, source.ParseHeader); err == nil {
 				// Check that we have a valid `package foo` range to use for positioning the error.
 				if pgf.File.Package.IsValid() && pgf.File.Name != nil && pgf.File.Name.End().IsValid() {
-					pkgDecl := span.NewRange(pgf.Tok, pgf.File.Package, pgf.File.Name.End())
-					if spn, err := pkgDecl.Span(); err == nil {
-						rng, _ = pgf.Mapper.Range(spn)
-					}
+					rng, _ = pgf.PosRange(pgf.File.Package, pgf.File.Name.End())
 				}
 			}
 		case source.Mod:
diff --git a/gopls/internal/lsp/cache/mod.go b/gopls/internal/lsp/cache/mod.go
index 757bb5e..a3d207d 100644
--- a/gopls/internal/lsp/cache/mod.go
+++ b/gopls/internal/lsp/cache/mod.go
@@ -402,11 +402,12 @@
 		if pm.File.Module == nil {
 			return span.New(pm.URI, span.NewPoint(1, 1, 0), span.Point{}), false, nil
 		}
-		spn, err := spanFromPositions(pm.Mapper, pm.File.Module.Syntax.Start, pm.File.Module.Syntax.End)
+		syntax := pm.File.Module.Syntax
+		spn, err := pm.Mapper.OffsetSpan(syntax.Start.Byte, syntax.End.Byte)
 		return spn, false, err
 	}
 
-	spn, err := spanFromPositions(pm.Mapper, reference.Start, reference.End)
+	spn, err := pm.Mapper.OffsetSpan(reference.Start.Byte, reference.End.Byte)
 	return spn, true, err
 }
 
diff --git a/gopls/internal/lsp/cache/mod_tidy.go b/gopls/internal/lsp/cache/mod_tidy.go
index fa30df1..c9c02ca 100644
--- a/gopls/internal/lsp/cache/mod_tidy.go
+++ b/gopls/internal/lsp/cache/mod_tidy.go
@@ -8,7 +8,6 @@
 	"context"
 	"fmt"
 	"go/ast"
-	"go/token"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -263,7 +262,7 @@
 				if !ok {
 					return nil, fmt.Errorf("no missing module fix for %q (%q)", importPath, req.Mod.Path)
 				}
-				srcErr, err := missingModuleForImport(pgf.Tok, m, imp, req, fixes)
+				srcErr, err := missingModuleForImport(pgf, imp, req, fixes)
 				if err != nil {
 					return nil, err
 				}
@@ -423,16 +422,16 @@
 
 // missingModuleForImport creates an error for a given import path that comes
 // from a missing module.
-func missingModuleForImport(file *token.File, m *protocol.ColumnMapper, imp *ast.ImportSpec, req *modfile.Require, fixes []source.SuggestedFix) (*source.Diagnostic, error) {
+func missingModuleForImport(pgf *source.ParsedGoFile, imp *ast.ImportSpec, req *modfile.Require, fixes []source.SuggestedFix) (*source.Diagnostic, error) {
 	if req.Syntax == nil {
 		return nil, fmt.Errorf("no syntax for %v", req)
 	}
-	rng, err := m.PosRange(imp.Path.Pos(), imp.Path.End())
+	rng, err := pgf.PosRange(imp.Path.Pos(), imp.Path.End())
 	if err != nil {
 		return nil, err
 	}
 	return &source.Diagnostic{
-		URI:            m.URI,
+		URI:            pgf.URI,
 		Range:          rng,
 		Severity:       protocol.SeverityError,
 		Source:         source.ModTidyError,
@@ -441,25 +440,6 @@
 	}, nil
 }
 
-func spanFromPositions(m *protocol.ColumnMapper, s, e modfile.Position) (span.Span, error) {
-	toPoint := func(offset int) (span.Point, error) {
-		l, c, err := span.ToPosition(m.TokFile, offset)
-		if err != nil {
-			return span.Point{}, err
-		}
-		return span.NewPoint(l, c, offset), nil
-	}
-	start, err := toPoint(s.Byte)
-	if err != nil {
-		return span.Span{}, err
-	}
-	end, err := toPoint(e.Byte)
-	if err != nil {
-		return span.Span{}, err
-	}
-	return span.New(m.URI, start, end), nil
-}
-
 // parseImports parses the headers of the specified files and returns
 // the set of strings that appear in import declarations within
 // GoFiles. Errors are ignored.
diff --git a/gopls/internal/lsp/cache/parse.go b/gopls/internal/lsp/cache/parse.go
index 83f18da..7451cc3 100644
--- a/gopls/internal/lsp/cache/parse.go
+++ b/gopls/internal/lsp/cache/parse.go
@@ -202,17 +202,13 @@
 	}
 
 	return &source.ParsedGoFile{
-		URI:   fh.URI(),
-		Mode:  mode,
-		Src:   src,
-		Fixed: fixed,
-		File:  file,
-		Tok:   tok,
-		Mapper: &protocol.ColumnMapper{
-			URI:     fh.URI(),
-			TokFile: tok,
-			Content: src,
-		},
+		URI:      fh.URI(),
+		Mode:     mode,
+		Src:      src,
+		Fixed:    fixed,
+		File:     file,
+		Tok:      tok,
+		Mapper:   protocol.NewColumnMapper(fh.URI(), src),
 		ParseErr: parseErr,
 	}, nil
 }
@@ -900,11 +896,7 @@
 	}
 
 	// Try to extract a statement from the BadExpr.
-	start, err := safetoken.Offset(tok, bad.Pos())
-	if err != nil {
-		return
-	}
-	end, err := safetoken.Offset(tok, bad.End()-1)
+	start, end, err := safetoken.Offsets(tok, bad.Pos(), bad.End()-1)
 	if err != nil {
 		return
 	}
@@ -989,11 +981,7 @@
 	// Avoid doing tok.Offset(to) since that panics if badExpr ends at EOF.
 	// It also panics if the position is not in the range of the file, and
 	// badExprs may not necessarily have good positions, so check first.
-	fromOffset, err := safetoken.Offset(tok, from)
-	if err != nil {
-		return false
-	}
-	toOffset, err := safetoken.Offset(tok, to-1)
+	fromOffset, toOffset, err := safetoken.Offsets(tok, from, to-1)
 	if err != nil {
 		return false
 	}
@@ -1150,18 +1138,13 @@
 		}
 	}
 
-	fromOffset, err := safetoken.Offset(tok, from)
+	fromOffset, toOffset, err := safetoken.Offsets(tok, from, to)
 	if err != nil {
 		return false
 	}
 	if !from.IsValid() || fromOffset >= len(src) {
 		return false
 	}
-
-	toOffset, err := safetoken.Offset(tok, to)
-	if err != nil {
-		return false
-	}
 	if !to.IsValid() || toOffset >= len(src) {
 		return false
 	}
diff --git a/gopls/internal/lsp/cmd/cmd.go b/gopls/internal/lsp/cmd/cmd.go
index 3aa74d0..c221913 100644
--- a/gopls/internal/lsp/cmd/cmd.go
+++ b/gopls/internal/lsp/cmd/cmd.go
@@ -11,7 +11,6 @@
 	"context"
 	"flag"
 	"fmt"
-	"go/token"
 	"io/ioutil"
 	"log"
 	"os"
@@ -385,8 +384,7 @@
 
 type cmdClient struct {
 	protocol.Server
-	app  *Application
-	fset *token.FileSet
+	app *Application
 
 	diagnosticsMu   sync.Mutex
 	diagnosticsDone chan struct{}
@@ -407,7 +405,6 @@
 	return &connection{
 		Client: &cmdClient{
 			app:   app,
-			fset:  token.NewFileSet(),
 			files: make(map[span.URI]*cmdFile),
 		},
 	}
@@ -541,19 +538,12 @@
 		c.files[uri] = file
 	}
 	if file.mapper == nil {
-		fname := uri.Filename()
-		content, err := ioutil.ReadFile(fname)
+		content, err := ioutil.ReadFile(uri.Filename())
 		if err != nil {
 			file.err = fmt.Errorf("getFile: %v: %v", uri, err)
 			return file
 		}
-		f := c.fset.AddFile(fname, -1, len(content))
-		f.SetLinesForContent(content)
-		file.mapper = &protocol.ColumnMapper{
-			URI:     uri,
-			TokFile: f,
-			Content: content,
-		}
+		file.mapper = protocol.NewColumnMapper(uri, content)
 	}
 	return file
 }
diff --git a/gopls/internal/lsp/code_action.go b/gopls/internal/lsp/code_action.go
index 0767d43..5e0a778 100644
--- a/gopls/internal/lsp/code_action.go
+++ b/gopls/internal/lsp/code_action.go
@@ -316,7 +316,7 @@
 	if err != nil {
 		return nil, fmt.Errorf("getting file for Identifier: %w", err)
 	}
-	srng, err := pgf.Mapper.RangeToSpanRange(rng)
+	srng, err := pgf.RangeToSpanRange(rng)
 	if err != nil {
 		return nil, err
 	}
diff --git a/gopls/internal/lsp/completion.go b/gopls/internal/lsp/completion.go
index c967c1f..e443a3c 100644
--- a/gopls/internal/lsp/completion.go
+++ b/gopls/internal/lsp/completion.go
@@ -60,6 +60,8 @@
 	// internal/span, as the latter treats end of file as the beginning of the
 	// next line, even when it's not newline-terminated. See golang/go#41029 for
 	// more details.
+	// TODO(adonovan): make completion retain the pgf.Mapper
+	// so we can convert to rng without reading.
 	src, err := fh.Read()
 	if err != nil {
 		return nil, err
diff --git a/gopls/internal/lsp/definition.go b/gopls/internal/lsp/definition.go
index d2ad474..d83512a 100644
--- a/gopls/internal/lsp/definition.go
+++ b/gopls/internal/lsp/definition.go
@@ -58,13 +58,13 @@
 	if ident.Type.Object == nil {
 		return nil, fmt.Errorf("no type definition for %s", ident.Name)
 	}
-	identRange, err := ident.Type.Range()
+	identRange, err := ident.Type.MappedRange.Range()
 	if err != nil {
 		return nil, err
 	}
 	return []protocol.Location{
 		{
-			URI:   protocol.URIFromSpanURI(ident.Type.URI()),
+			URI:   protocol.URIFromSpanURI(ident.Type.MappedRange.URI()),
 			Range: identRange,
 		},
 	}, nil
diff --git a/gopls/internal/lsp/diagnostics.go b/gopls/internal/lsp/diagnostics.go
index 863c142..6ec7a08 100644
--- a/gopls/internal/lsp/diagnostics.go
+++ b/gopls/internal/lsp/diagnostics.go
@@ -577,7 +577,7 @@
 	if !pgf.File.Name.Pos().IsValid() {
 		return nil
 	}
-	rng, err := pgf.Mapper.PosRange(pgf.File.Name.Pos(), pgf.File.Name.End())
+	rng, err := pgf.PosRange(pgf.File.Name.Pos(), pgf.File.Name.End())
 	if err != nil {
 		return nil
 	}
diff --git a/gopls/internal/lsp/folding_range.go b/gopls/internal/lsp/folding_range.go
index 4a2d828..86469d3 100644
--- a/gopls/internal/lsp/folding_range.go
+++ b/gopls/internal/lsp/folding_range.go
@@ -28,7 +28,7 @@
 func toProtocolFoldingRanges(ranges []*source.FoldingRangeInfo) ([]protocol.FoldingRange, error) {
 	result := make([]protocol.FoldingRange, 0, len(ranges))
 	for _, info := range ranges {
-		rng, err := info.Range()
+		rng, err := info.MappedRange.Range()
 		if err != nil {
 			return nil, err
 		}
diff --git a/gopls/internal/lsp/link.go b/gopls/internal/lsp/link.go
index 011f0e4..e7bdb60 100644
--- a/gopls/internal/lsp/link.go
+++ b/gopls/internal/lsp/link.go
@@ -17,8 +17,8 @@
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
+	"golang.org/x/tools/gopls/internal/lsp/safetoken"
 	"golang.org/x/tools/gopls/internal/lsp/source"
-	"golang.org/x/tools/gopls/internal/span"
 	"golang.org/x/tools/internal/event"
 	"golang.org/x/tools/internal/event/tag"
 )
@@ -48,7 +48,6 @@
 	if err != nil {
 		return nil, err
 	}
-	tokFile := pm.Mapper.TokFile
 
 	var links []protocol.DocumentLink
 	for _, req := range pm.File.Require {
@@ -60,16 +59,15 @@
 			continue
 		}
 		dep := []byte(req.Mod.Path)
-		s, e := req.Syntax.Start.Byte, req.Syntax.End.Byte
-		i := bytes.Index(pm.Mapper.Content[s:e], dep)
+		start, end := req.Syntax.Start.Byte, req.Syntax.End.Byte
+		i := bytes.Index(pm.Mapper.Content[start:end], dep)
 		if i == -1 {
 			continue
 		}
 		// Shift the start position to the location of the
 		// dependency within the require statement.
-		start, end := tokFile.Pos(s+i), tokFile.Pos(s+i+len(dep))
 		target := source.BuildLink(snapshot.View().Options().LinkTarget, "mod/"+req.Mod.String(), "")
-		l, err := toProtocolLink(tokFile, pm.Mapper, target, start, end)
+		l, err := toProtocolLink(pm.Mapper, target, start+i, start+i+len(dep))
 		if err != nil {
 			return nil, err
 		}
@@ -89,8 +87,7 @@
 		}
 		for _, section := range [][]modfile.Comment{comments.Before, comments.Suffix, comments.After} {
 			for _, comment := range section {
-				start := tokFile.Pos(comment.Start.Byte)
-				l, err := findLinksInString(urlRegexp, comment.Token, start, tokFile, pm.Mapper)
+				l, err := findLinksInString(urlRegexp, comment.Token, comment.Start.Byte, pm.Mapper)
 				if err != nil {
 					return nil, err
 				}
@@ -145,11 +142,13 @@
 				urlPath = strings.Replace(urlPath, m.Module.Path, m.Module.Path+"@"+m.Module.Version, 1)
 			}
 
-			// Account for the quotation marks in the positions.
-			start := imp.Path.Pos() + 1
-			end := imp.Path.End() - 1
+			start, end, err := safetoken.Offsets(pgf.Tok, imp.Path.Pos(), imp.Path.End())
+			if err != nil {
+				return nil, err
+			}
 			targetURL := source.BuildLink(view.Options().LinkTarget, urlPath, "")
-			l, err := toProtocolLink(pgf.Tok, pgf.Mapper, targetURL, start, end)
+			// Account for the quotation marks in the positions.
+			l, err := toProtocolLink(pgf.Mapper, targetURL, start+len(`"`), end-len(`"`))
 			if err != nil {
 				return nil, err
 			}
@@ -173,7 +172,11 @@
 		return true
 	})
 	for _, s := range str {
-		l, err := findLinksInString(urlRegexp, s.Value, s.Pos(), pgf.Tok, pgf.Mapper)
+		strOffset, err := safetoken.Offset(pgf.Tok, s.Pos())
+		if err != nil {
+			return nil, err
+		}
+		l, err := findLinksInString(urlRegexp, s.Value, strOffset, pgf.Mapper)
 		if err != nil {
 			return nil, err
 		}
@@ -183,7 +186,11 @@
 	// Gather links found in comments.
 	for _, commentGroup := range pgf.File.Comments {
 		for _, comment := range commentGroup.List {
-			l, err := findLinksInString(urlRegexp, comment.Text, comment.Pos(), pgf.Tok, pgf.Mapper)
+			commentOffset, err := safetoken.Offset(pgf.Tok, comment.Pos())
+			if err != nil {
+				return nil, err
+			}
+			l, err := findLinksInString(urlRegexp, comment.Text, commentOffset, pgf.Mapper)
 			if err != nil {
 				return nil, err
 			}
@@ -203,13 +210,11 @@
 }
 
 // urlRegexp is the user-supplied regular expression to match URL.
-// tokFile may be a throwaway File for non-Go files.
-func findLinksInString(urlRegexp *regexp.Regexp, src string, pos token.Pos, tokFile *token.File, m *protocol.ColumnMapper) ([]protocol.DocumentLink, error) {
+// srcOffset is the start offset of 'src' within m's file.
+func findLinksInString(urlRegexp *regexp.Regexp, src string, srcOffset int, m *protocol.ColumnMapper) ([]protocol.DocumentLink, error) {
 	var links []protocol.DocumentLink
 	for _, index := range urlRegexp.FindAllIndex([]byte(src), -1) {
 		start, end := index[0], index[1]
-		startPos := token.Pos(int(pos) + start)
-		endPos := token.Pos(int(pos) + end)
 		link := src[start:end]
 		linkURL, err := url.Parse(link)
 		// Fallback: Linkify IP addresses as suggested in golang/go#18824.
@@ -227,7 +232,8 @@
 		if !acceptedSchemes[linkURL.Scheme] {
 			continue
 		}
-		l, err := toProtocolLink(tokFile, m, linkURL.String(), startPos, endPos)
+
+		l, err := toProtocolLink(m, linkURL.String(), srcOffset+start, srcOffset+end)
 		if err != nil {
 			return nil, err
 		}
@@ -237,15 +243,13 @@
 	r := getIssueRegexp()
 	for _, index := range r.FindAllIndex([]byte(src), -1) {
 		start, end := index[0], index[1]
-		startPos := token.Pos(int(pos) + start)
-		endPos := token.Pos(int(pos) + end)
 		matches := r.FindStringSubmatch(src)
 		if len(matches) < 4 {
 			continue
 		}
 		org, repo, number := matches[1], matches[2], matches[3]
 		targetURL := fmt.Sprintf("https://github.com/%s/%s/issues/%s", org, repo, number)
-		l, err := toProtocolLink(tokFile, m, targetURL, startPos, endPos)
+		l, err := toProtocolLink(m, targetURL, srcOffset+start, srcOffset+end)
 		if err != nil {
 			return nil, err
 		}
@@ -266,12 +270,8 @@
 	issueRegexp *regexp.Regexp
 )
 
-func toProtocolLink(tokFile *token.File, m *protocol.ColumnMapper, targetURL string, start, end token.Pos) (protocol.DocumentLink, error) {
-	spn, err := span.NewRange(tokFile, start, end).Span()
-	if err != nil {
-		return protocol.DocumentLink{}, err
-	}
-	rng, err := m.Range(spn)
+func toProtocolLink(m *protocol.ColumnMapper, targetURL string, start, end int) (protocol.DocumentLink, error) {
+	rng, err := m.OffsetRange(start, end)
 	if err != nil {
 		return protocol.DocumentLink{}, err
 	}
diff --git a/gopls/internal/lsp/protocol/span.go b/gopls/internal/lsp/protocol/span.go
index 78e180c..160b925 100644
--- a/gopls/internal/lsp/protocol/span.go
+++ b/gopls/internal/lsp/protocol/span.go
@@ -8,12 +8,12 @@
 //
 // Imports: source  --> lsppos  -->  protocol  -->  span  -->  token
 //
-// source.MappedRange = (span.Range, protocol.ColumnMapper)
+// source.MappedRange = (*ParsedGoFile, start/end token.Pos)
 //
 // lsppos.TokenMapper = (token.File, lsppos.Mapper)
 // lsppos.Mapper = (line offset table, content)
 //
-// protocol.ColumnMapper = (URI, token.File, content)
+// protocol.ColumnMapper = (URI, Content). Does all offset <=> column conversions.
 // protocol.Location = (URI, protocol.Range)
 // protocol.Range = (start, end Position)
 // protocol.Position = (line, char uint32) 0-based UTF-16
@@ -24,12 +24,19 @@
 //
 // token.Pos
 // token.FileSet
+// token.File
 // offset int
 //
-// TODO(adonovan): simplify this picture. Eliminate the optionality of
-// span.{Span,Point}'s position and offset fields: work internally in
-// terms of offsets (like span.Range), and require a mapper to convert
-// them to protocol (UTF-16) line/col form.
+// TODO(adonovan): simplify this picture:
+//   - Eliminate the optionality of span.{Span,Point}'s position and offset fields?
+//   - Move span.Range to package safetoken. Can we eliminate it?
+//     Without a ColumnMapper it's not really self-contained.
+//     It is mostly used by completion. Given access to complete.mapper,
+//     it could use a pair byte offsets instead.
+//   - Merge lsppos.Mapper and protocol.ColumnMapper.
+//   - Replace all uses of lsppos.TokenMapper by the underlying ParsedGoFile,
+//     which carries a token.File and a ColumnMapper.
+//   - Then delete lsppos package.
 
 package protocol
 
@@ -41,35 +48,52 @@
 	"strings"
 	"unicode/utf8"
 
-	"golang.org/x/tools/gopls/internal/lsp/safetoken"
 	"golang.org/x/tools/gopls/internal/span"
 	"golang.org/x/tools/internal/bug"
 )
 
-// A ColumnMapper maps between UTF-8 oriented positions (e.g. token.Pos,
-// span.Span) and the UTF-16 oriented positions used by the LSP.
+// A ColumnMapper wraps the content of a file and provides mapping
+// from byte offsets to and from other notations of position:
+//
+//   - (line, col8) pairs, where col8 is a 1-based UTF-8 column number (bytes),
+//     as used by go/token;
+//
+//   - (line, col16) pairs, where col16 is a 1-based UTF-16 column number,
+//     as used by the LSP protocol;
+//
+//   - (line, colRune) pairs, where colRune is a rune index, as used by ParseWork.
+//
+// This type does not depend on or use go/token-based representations.
+// Use safetoken to map between token.Pos <=> byte offsets.
 type ColumnMapper struct {
 	URI     span.URI
-	TokFile *token.File
 	Content []byte
 
-	// File content is only really needed for UTF-16 column
-	// computation, which could be be achieved more compactly.
-	// For example, one could record only the lines for which
-	// UTF-16 columns differ from the UTF-8 ones, or only the
-	// indices of the non-ASCII characters.
+	// This field provides a line-number table, nothing more.
+	// The public API of ColumnMapper doesn't mention go/token,
+	// nor should it. It need not be consistent with any
+	// other token.File or FileSet.
 	//
-	// TODO(adonovan): consider not retaining the entire file
-	// content, or at least not exposing the fact that we
-	// currently retain it.
+	// TODO(adonovan): eliminate this field in a follow-up
+	// by inlining the line-number table. Then merge this
+	// type with the nearly identical lsspos.Mapper.
+	//
+	// TODO(adonovan): opt: quick experiments suggest that
+	// ColumnMappers are created for thousands of files but the
+	// m.lines field is accessed only for a small handful.
+	// So it would make sense to allocate it lazily.
+	lines *token.File
 }
 
 // NewColumnMapper creates a new column mapper for the given uri and content.
 func NewColumnMapper(uri span.URI, content []byte) *ColumnMapper {
-	tf := span.NewTokenFile(uri.Filename(), content)
+	fset := token.NewFileSet()
+	tf := fset.AddFile(uri.Filename(), -1, len(content))
+	tf.SetLinesForContent(content)
+
 	return &ColumnMapper{
 		URI:     uri,
-		TokFile: tf,
+		lines:   tf,
 		Content: content,
 	}
 }
@@ -105,7 +129,7 @@
 		return Range{}, bug.Errorf("column mapper is for file %q instead of %q", m.URI, s.URI())
 	}
 
-	s, err := s.WithOffset(m.TokFile)
+	s, err := s.WithOffset(m.lines)
 	if err != nil {
 		return Range{}, err
 	}
@@ -135,17 +159,20 @@
 	return Range{Start: startPosition, End: endPosition}, nil
 }
 
-// PosRange returns a protocol Range for the token.Pos interval Content[start:end].
-func (m *ColumnMapper) PosRange(start, end token.Pos) (Range, error) {
-	startOffset, err := safetoken.Offset(m.TokFile, start)
-	if err != nil {
-		return Range{}, fmt.Errorf("start: %v", err)
+// OffsetSpan converts a pair of byte offsets to a Span.
+func (m *ColumnMapper) OffsetSpan(start, end int) (span.Span, error) {
+	if start > end {
+		return span.Span{}, fmt.Errorf("start offset (%d) > end (%d)", start, end)
 	}
-	endOffset, err := safetoken.Offset(m.TokFile, end)
+	startPoint, err := m.OffsetPoint(start)
 	if err != nil {
-		return Range{}, fmt.Errorf("end: %v", err)
+		return span.Span{}, err
 	}
-	return m.OffsetRange(startOffset, endOffset)
+	endPoint, err := m.OffsetPoint(end)
+	if err != nil {
+		return span.Span{}, err
+	}
+	return span.New(m.URI, startPoint, endPoint), nil
 }
 
 // Position returns the protocol position for the specified point,
@@ -160,14 +187,14 @@
 // OffsetPosition returns the protocol position of the specified
 // offset within m.Content.
 func (m *ColumnMapper) OffsetPosition(offset int) (Position, error) {
-	// We use span.ToPosition for its "line+1 at EOF" workaround.
-	line, _, err := span.ToPosition(m.TokFile, offset)
+	// We use span.OffsetToLineCol8 for its "line+1 at EOF" workaround.
+	line, _, err := span.OffsetToLineCol8(m.lines, offset)
 	if err != nil {
 		return Position{}, fmt.Errorf("OffsetPosition: %v", err)
 	}
 	// If that workaround executed, skip the usual column computation.
 	char := 0
-	if offset != m.TokFile.Size() {
+	if offset != m.lines.Size() {
 		char = m.utf16Column(offset)
 	}
 	return Position{
@@ -224,24 +251,7 @@
 	if err != nil {
 		return span.Span{}, err
 	}
-	return span.New(m.URI, start, end).WithAll(m.TokFile)
-}
-
-func (m *ColumnMapper) RangeToSpanRange(r Range) (span.Range, error) {
-	spn, err := m.RangeSpan(r)
-	if err != nil {
-		return span.Range{}, err
-	}
-	return spn.Range(m.TokFile)
-}
-
-// Pos returns the token.Pos of protocol position p within the mapped file.
-func (m *ColumnMapper) Pos(p Position) (token.Pos, error) {
-	start, err := m.Point(p)
-	if err != nil {
-		return token.NoPos, err
-	}
-	return safetoken.Pos(m.TokFile, start.Offset())
+	return span.New(m.URI, start, end).WithAll(m.lines)
 }
 
 // Offset returns the utf-8 byte offset of p within the mapped file.
@@ -253,13 +263,23 @@
 	return start.Offset(), nil
 }
 
+// OffsetPoint returns the span.Point for the given byte offset.
+func (m *ColumnMapper) OffsetPoint(offset int) (span.Point, error) {
+	// We use span.ToPosition for its "line+1 at EOF" workaround.
+	line, col8, err := span.OffsetToLineCol8(m.lines, offset)
+	if err != nil {
+		return span.Point{}, fmt.Errorf("OffsetPoint: %v", err)
+	}
+	return span.NewPoint(line, col8, offset), nil
+}
+
 // Point returns a span.Point for the protocol position p within the mapped file.
 // The resulting point has a valid Position and Offset.
 func (m *ColumnMapper) Point(p Position) (span.Point, error) {
 	line := int(p.Line) + 1
 
 	// Find byte offset of start of containing line.
-	offset, err := span.ToOffset(m.TokFile, line, 1)
+	offset, err := span.ToOffset(m.lines, line, 1)
 	if err != nil {
 		return span.Point{}, err
 	}
diff --git a/gopls/internal/lsp/references.go b/gopls/internal/lsp/references.go
index 6f4e3ee..390e290 100644
--- a/gopls/internal/lsp/references.go
+++ b/gopls/internal/lsp/references.go
@@ -27,12 +27,12 @@
 	}
 	var locations []protocol.Location
 	for _, ref := range references {
-		refRange, err := ref.Range()
+		refRange, err := ref.MappedRange.Range()
 		if err != nil {
 			return nil, err
 		}
 		locations = append(locations, protocol.Location{
-			URI:   protocol.URIFromSpanURI(ref.URI()),
+			URI:   protocol.URIFromSpanURI(ref.MappedRange.URI()),
 			Range: refRange,
 		})
 	}
diff --git a/gopls/internal/lsp/safetoken/safetoken.go b/gopls/internal/lsp/safetoken/safetoken.go
index fa9c675..29cc1b1 100644
--- a/gopls/internal/lsp/safetoken/safetoken.go
+++ b/gopls/internal/lsp/safetoken/safetoken.go
@@ -41,6 +41,19 @@
 	return int(pos) - f.Base(), nil
 }
 
+// Offsets returns Offset(start) and Offset(end).
+func Offsets(f *token.File, start, end token.Pos) (int, int, error) {
+	startOffset, err := Offset(f, start)
+	if err != nil {
+		return 0, 0, fmt.Errorf("start: %v", err)
+	}
+	endOffset, err := Offset(f, end)
+	if err != nil {
+		return 0, 0, fmt.Errorf("end: %v", err)
+	}
+	return startOffset, endOffset, nil
+}
+
 // Pos returns f.Pos(offset), but first checks that the offset is
 // non-negative and not larger than the size of the file.
 func Pos(f *token.File, offset int) (token.Pos, error) {
diff --git a/gopls/internal/lsp/selection_range.go b/gopls/internal/lsp/selection_range.go
index 314f224..b7a0cdb 100644
--- a/gopls/internal/lsp/selection_range.go
+++ b/gopls/internal/lsp/selection_range.go
@@ -41,7 +41,7 @@
 
 	result := make([]protocol.SelectionRange, len(params.Positions))
 	for i, protocolPos := range params.Positions {
-		pos, err := pgf.Mapper.Pos(protocolPos)
+		pos, err := pgf.Pos(protocolPos)
 		if err != nil {
 			return nil, err
 		}
@@ -51,7 +51,7 @@
 		tail := &result[i] // tail of the Parent linked list, built head first
 
 		for j, node := range path {
-			rng, err := pgf.Mapper.PosRange(node.Pos(), node.End())
+			rng, err := pgf.PosRange(node.Pos(), node.End())
 			if err != nil {
 				return nil, err
 			}
diff --git a/gopls/internal/lsp/semantic.go b/gopls/internal/lsp/semantic.go
index 4117eb7..728f61d 100644
--- a/gopls/internal/lsp/semantic.go
+++ b/gopls/internal/lsp/semantic.go
@@ -182,8 +182,7 @@
 		return
 	}
 	// want a line and column from start (in LSP coordinates). Ignore line directives.
-	rng := source.NewMappedRange(e.pgf.Mapper, start, start+token.Pos(leng))
-	lspRange, err := rng.Range()
+	lspRange, err := e.pgf.PosRange(start, start+token.Pos(leng))
 	if err != nil {
 		event.Error(e.ctx, "failed to convert to range", err)
 		return
diff --git a/gopls/internal/lsp/source/call_hierarchy.go b/gopls/internal/lsp/source/call_hierarchy.go
index 076aed0..cce8a13 100644
--- a/gopls/internal/lsp/source/call_hierarchy.go
+++ b/gopls/internal/lsp/source/call_hierarchy.go
@@ -86,12 +86,12 @@
 	// once in the result but highlight all calls using FromRanges (ranges at which the calls occur)
 	var incomingCalls = map[protocol.Location]*protocol.CallHierarchyIncomingCall{}
 	for _, ref := range refs {
-		refRange, err := ref.Range()
+		refRange, err := ref.MappedRange.Range()
 		if err != nil {
 			return nil, err
 		}
 
-		callItem, err := enclosingNodeCallItem(snapshot, ref.pkg, ref.URI(), ref.ident.NamePos)
+		callItem, err := enclosingNodeCallItem(snapshot, ref.pkg, ref.MappedRange.URI(), ref.ident.NamePos)
 		if err != nil {
 			event.Error(ctx, "error getting enclosing node", err, tag.Method.Of(ref.Name))
 			continue
@@ -155,7 +155,7 @@
 		nameStart, nameEnd = funcLit.Type.Func, funcLit.Type.Params.Pos()
 		kind = protocol.Function
 	}
-	rng, err := NewMappedRange(pgf.Mapper, nameStart, nameEnd).Range()
+	rng, err := pgf.PosRange(nameStart, nameEnd)
 	if err != nil {
 		return protocol.CallHierarchyItem{}, err
 	}
@@ -199,7 +199,7 @@
 	if len(identifier.Declaration.MappedRange) == 0 {
 		return nil, nil
 	}
-	callExprs, err := collectCallExpressions(identifier.Declaration.MappedRange[0].m, node)
+	callExprs, err := collectCallExpressions(identifier.Declaration.MappedRange[0].File, node)
 	if err != nil {
 		return nil, err
 	}
@@ -208,7 +208,7 @@
 }
 
 // collectCallExpressions collects call expression ranges inside a function.
-func collectCallExpressions(mapper *protocol.ColumnMapper, node ast.Node) ([]protocol.Range, error) {
+func collectCallExpressions(pgf *ParsedGoFile, node ast.Node) ([]protocol.Range, error) {
 	type callPos struct {
 		start, end token.Pos
 	}
@@ -238,7 +238,7 @@
 
 	callRanges := []protocol.Range{}
 	for _, call := range callPositions {
-		callRange, err := NewMappedRange(mapper, call.start, call.end).Range()
+		callRange, err := pgf.PosRange(call.start, call.end)
 		if err != nil {
 			return nil, err
 		}
diff --git a/gopls/internal/lsp/source/code_lens.go b/gopls/internal/lsp/source/code_lens.go
index c87f416..f929256 100644
--- a/gopls/internal/lsp/source/code_lens.go
+++ b/gopls/internal/lsp/source/code_lens.go
@@ -67,7 +67,7 @@
 			return nil, err
 		}
 		// add a code lens to the top of the file which runs all benchmarks in the file
-		rng, err := NewMappedRange(pgf.Mapper, pgf.File.Package, pgf.File.Package).Range()
+		rng, err := pgf.PosRange(pgf.File.Package, pgf.File.Package)
 		if err != nil {
 			return nil, err
 		}
@@ -111,7 +111,7 @@
 			continue
 		}
 
-		rng, err := NewMappedRange(pgf.Mapper, fn.Pos(), fn.End()).Range()
+		rng, err := pgf.PosRange(fn.Pos(), fn.End())
 		if err != nil {
 			return out, err
 		}
@@ -177,7 +177,7 @@
 			if !strings.HasPrefix(l.Text, ggDirective) {
 				continue
 			}
-			rng, err := NewMappedRange(pgf.Mapper, l.Pos(), l.Pos()+token.Pos(len(ggDirective))).Range()
+			rng, err := pgf.PosRange(l.Pos(), l.Pos()+token.Pos(len(ggDirective)))
 			if err != nil {
 				return nil, err
 			}
@@ -214,7 +214,7 @@
 	if c == nil {
 		return nil, nil
 	}
-	rng, err := NewMappedRange(pgf.Mapper, c.Pos(), c.End()).Range()
+	rng, err := pgf.PosRange(c.Pos(), c.End())
 	if err != nil {
 		return nil, err
 	}
@@ -235,7 +235,7 @@
 		// Without a package name we have nowhere to put the codelens, so give up.
 		return nil, nil
 	}
-	rng, err := NewMappedRange(pgf.Mapper, pgf.File.Package, pgf.File.Package).Range()
+	rng, err := pgf.PosRange(pgf.File.Package, pgf.File.Package)
 	if err != nil {
 		return nil, err
 	}
diff --git a/gopls/internal/lsp/source/completion/completion.go b/gopls/internal/lsp/source/completion/completion.go
index 19d16a1..4d2da2e 100644
--- a/gopls/internal/lsp/source/completion/completion.go
+++ b/gopls/internal/lsp/source/completion/completion.go
@@ -291,6 +291,10 @@
 	content string
 	cursor  token.Pos // relative to rng.TokFile
 	rng     span.Range
+	// TODO(adonovan): keep the ColumnMapper (completer.mapper)
+	// nearby so we can convert rng to protocol form without
+	// needing to read the file again, as the sole caller of
+	// Selection.Range() must currently do.
 }
 
 func (p Selection) Content() string {
@@ -441,7 +445,7 @@
 		}
 		return items, surrounding, nil
 	}
-	pos, err := pgf.Mapper.Pos(protoPos)
+	pos, err := pgf.Pos(protoPos)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/gopls/internal/lsp/source/completion/package.go b/gopls/internal/lsp/source/completion/package.go
index 70d98df..de2b75d 100644
--- a/gopls/internal/lsp/source/completion/package.go
+++ b/gopls/internal/lsp/source/completion/package.go
@@ -37,7 +37,7 @@
 		return nil, nil, err
 	}
 
-	pos, err := pgf.Mapper.Pos(position)
+	pos, err := pgf.Pos(position)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/gopls/internal/lsp/source/completion/util.go b/gopls/internal/lsp/source/completion/util.go
index 72877a3..4b6ec09 100644
--- a/gopls/internal/lsp/source/completion/util.go
+++ b/gopls/internal/lsp/source/completion/util.go
@@ -312,13 +312,9 @@
 }
 
 func (c *completer) editText(from, to token.Pos, newText string) ([]protocol.TextEdit, error) {
-	start, err := safetoken.Offset(c.tokFile, from)
+	start, end, err := safetoken.Offsets(c.tokFile, from, to)
 	if err != nil {
-		return nil, err // can't happen: from came from c
-	}
-	end, err := safetoken.Offset(c.tokFile, to)
-	if err != nil {
-		return nil, err // can't happen: to came from c
+		return nil, err // can't happen: from/to came from c
 	}
 	return source.ToProtocolEdits(c.mapper, []diff.Edit{{
 		Start: start,
diff --git a/gopls/internal/lsp/source/extract.go b/gopls/internal/lsp/source/extract.go
index 0ac18e1..31a8598 100644
--- a/gopls/internal/lsp/source/extract.go
+++ b/gopls/internal/lsp/source/extract.go
@@ -134,11 +134,7 @@
 // line of code on which the insertion occurs.
 func calculateIndentation(content []byte, tok *token.File, insertBeforeStmt ast.Node) (string, error) {
 	line := tok.Line(insertBeforeStmt.Pos())
-	lineOffset, err := safetoken.Offset(tok, tok.LineStart(line))
-	if err != nil {
-		return "", err
-	}
-	stmtOffset, err := safetoken.Offset(tok, insertBeforeStmt.Pos())
+	lineOffset, stmtOffset, err := safetoken.Offsets(tok, tok.LineStart(line), insertBeforeStmt.Pos())
 	if err != nil {
 		return "", err
 	}
@@ -405,11 +401,7 @@
 
 	// We put the selection in a constructed file. We can then traverse and edit
 	// the extracted selection without modifying the original AST.
-	startOffset, err := safetoken.Offset(tok, rng.Start)
-	if err != nil {
-		return nil, err
-	}
-	endOffset, err := safetoken.Offset(tok, rng.End)
+	startOffset, endOffset, err := safetoken.Offsets(tok, rng.Start, rng.End)
 	if err != nil {
 		return nil, err
 	}
@@ -605,11 +597,7 @@
 
 	// We're going to replace the whole enclosing function,
 	// so preserve the text before and after the selected block.
-	outerStart, err := safetoken.Offset(tok, outer.Pos())
-	if err != nil {
-		return nil, err
-	}
-	outerEnd, err := safetoken.Offset(tok, outer.End())
+	outerStart, outerEnd, err := safetoken.Offsets(tok, outer.Pos(), outer.End())
 	if err != nil {
 		return nil, err
 	}
diff --git a/gopls/internal/lsp/source/fix.go b/gopls/internal/lsp/source/fix.go
index 34a6fe8..873db7f 100644
--- a/gopls/internal/lsp/source/fix.go
+++ b/gopls/internal/lsp/source/fix.go
@@ -15,6 +15,7 @@
 	"golang.org/x/tools/gopls/internal/lsp/analysis/fillstruct"
 	"golang.org/x/tools/gopls/internal/lsp/analysis/undeclaredname"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
+	"golang.org/x/tools/gopls/internal/lsp/safetoken"
 	"golang.org/x/tools/gopls/internal/span"
 	"golang.org/x/tools/internal/bug"
 )
@@ -93,6 +94,10 @@
 		if !end.IsValid() {
 			end = edit.Pos
 		}
+		startOffset, endOffset, err := safetoken.Offsets(tokFile, edit.Pos, end)
+		if err != nil {
+			return nil, err
+		}
 		fh, err := snapshot.GetVersionedFile(ctx, span.URIFromPath(tokFile.Name()))
 		if err != nil {
 			return nil, err
@@ -109,12 +114,13 @@
 			}
 			editsPerFile[fh.URI()] = te
 		}
+		// TODO(adonovan): once FileHandle has a ColumnMapper, eliminate this.
 		content, err := fh.Read()
 		if err != nil {
 			return nil, err
 		}
-		m := protocol.ColumnMapper{URI: fh.URI(), TokFile: tokFile, Content: content}
-		rng, err := m.PosRange(edit.Pos, end)
+		m := protocol.NewColumnMapper(fh.URI(), content)
+		rng, err := m.OffsetRange(startOffset, endOffset)
 		if err != nil {
 			return nil, err
 		}
@@ -137,7 +143,7 @@
 	if err != nil {
 		return nil, span.Range{}, nil, nil, nil, nil, fmt.Errorf("getting file for Identifier: %w", err)
 	}
-	rng, err := pgf.Mapper.RangeToSpanRange(pRng)
+	rng, err := pgf.RangeToSpanRange(pRng)
 	if err != nil {
 		return nil, span.Range{}, nil, nil, nil, nil, err
 	}
diff --git a/gopls/internal/lsp/source/folding_range.go b/gopls/internal/lsp/source/folding_range.go
index dacb5ae..01107fd 100644
--- a/gopls/internal/lsp/source/folding_range.go
+++ b/gopls/internal/lsp/source/folding_range.go
@@ -16,8 +16,8 @@
 
 // FoldingRangeInfo holds range and kind info of folding for an ast.Node
 type FoldingRangeInfo struct {
-	MappedRange
-	Kind protocol.FoldingRangeKind
+	MappedRange MappedRange
+	Kind        protocol.FoldingRangeKind
 }
 
 // FoldingRange gets all of the folding range for f.
@@ -42,10 +42,10 @@
 	}
 
 	// Get folding ranges for comments separately as they are not walked by ast.Inspect.
-	ranges = append(ranges, commentsFoldingRange(pgf.Mapper, pgf.File)...)
+	ranges = append(ranges, commentsFoldingRange(pgf)...)
 
 	visit := func(n ast.Node) bool {
-		rng := foldingRangeFunc(pgf.Tok, pgf.Mapper, n, lineFoldingOnly)
+		rng := foldingRangeFunc(pgf, n, lineFoldingOnly)
 		if rng != nil {
 			ranges = append(ranges, rng)
 		}
@@ -55,8 +55,8 @@
 	ast.Inspect(pgf.File, visit)
 
 	sort.Slice(ranges, func(i, j int) bool {
-		irng, _ := ranges[i].Range()
-		jrng, _ := ranges[j].Range()
+		irng, _ := ranges[i].MappedRange.Range()
+		jrng, _ := ranges[j].MappedRange.Range()
 		return protocol.CompareRange(irng, jrng) < 0
 	})
 
@@ -64,7 +64,7 @@
 }
 
 // foldingRangeFunc calculates the line folding range for ast.Node n
-func foldingRangeFunc(tokFile *token.File, m *protocol.ColumnMapper, n ast.Node, lineFoldingOnly bool) *FoldingRangeInfo {
+func foldingRangeFunc(pgf *ParsedGoFile, n ast.Node, lineFoldingOnly bool) *FoldingRangeInfo {
 	// TODO(suzmue): include trailing empty lines before the closing
 	// parenthesis/brace.
 	var kind protocol.FoldingRangeKind
@@ -76,7 +76,7 @@
 		if num := len(n.List); num != 0 {
 			startList, endList = n.List[0].Pos(), n.List[num-1].End()
 		}
-		start, end = validLineFoldingRange(tokFile, n.Lbrace, n.Rbrace, startList, endList, lineFoldingOnly)
+		start, end = validLineFoldingRange(pgf.Tok, n.Lbrace, n.Rbrace, startList, endList, lineFoldingOnly)
 	case *ast.CaseClause:
 		// Fold from position of ":" to end.
 		start, end = n.Colon+1, n.End()
@@ -92,7 +92,7 @@
 		if num := len(n.List); num != 0 {
 			startList, endList = n.List[0].Pos(), n.List[num-1].End()
 		}
-		start, end = validLineFoldingRange(tokFile, n.Opening, n.Closing, startList, endList, lineFoldingOnly)
+		start, end = validLineFoldingRange(pgf.Tok, n.Opening, n.Closing, startList, endList, lineFoldingOnly)
 	case *ast.GenDecl:
 		// If this is an import declaration, set the kind to be protocol.Imports.
 		if n.Tok == token.IMPORT {
@@ -103,7 +103,7 @@
 		if num := len(n.Specs); num != 0 {
 			startSpecs, endSpecs = n.Specs[0].Pos(), n.Specs[num-1].End()
 		}
-		start, end = validLineFoldingRange(tokFile, n.Lparen, n.Rparen, startSpecs, endSpecs, lineFoldingOnly)
+		start, end = validLineFoldingRange(pgf.Tok, n.Lparen, n.Rparen, startSpecs, endSpecs, lineFoldingOnly)
 	case *ast.BasicLit:
 		// Fold raw string literals from position of "`" to position of "`".
 		if n.Kind == token.STRING && len(n.Value) >= 2 && n.Value[0] == '`' && n.Value[len(n.Value)-1] == '`' {
@@ -115,7 +115,7 @@
 		if num := len(n.Elts); num != 0 {
 			startElts, endElts = n.Elts[0].Pos(), n.Elts[num-1].End()
 		}
-		start, end = validLineFoldingRange(tokFile, n.Lbrace, n.Rbrace, startElts, endElts, lineFoldingOnly)
+		start, end = validLineFoldingRange(pgf.Tok, n.Lbrace, n.Rbrace, startElts, endElts, lineFoldingOnly)
 	}
 
 	// Check that folding positions are valid.
@@ -123,11 +123,11 @@
 		return nil
 	}
 	// in line folding mode, do not fold if the start and end lines are the same.
-	if lineFoldingOnly && tokFile.Line(start) == tokFile.Line(end) {
+	if lineFoldingOnly && pgf.Tok.Line(start) == pgf.Tok.Line(end) {
 		return nil
 	}
 	return &FoldingRangeInfo{
-		MappedRange: NewMappedRange(m, start, end),
+		MappedRange: NewMappedRange(pgf, start, end),
 		Kind:        kind,
 	}
 }
@@ -157,9 +157,9 @@
 // commentsFoldingRange returns the folding ranges for all comment blocks in file.
 // The folding range starts at the end of the first line of the comment block, and ends at the end of the
 // comment block and has kind protocol.Comment.
-func commentsFoldingRange(m *protocol.ColumnMapper, file *ast.File) (comments []*FoldingRangeInfo) {
-	tokFile := m.TokFile
-	for _, commentGrp := range file.Comments {
+func commentsFoldingRange(pgf *ParsedGoFile) (comments []*FoldingRangeInfo) {
+	tokFile := pgf.Tok
+	for _, commentGrp := range pgf.File.Comments {
 		startGrpLine, endGrpLine := tokFile.Line(commentGrp.Pos()), tokFile.Line(commentGrp.End())
 		if startGrpLine == endGrpLine {
 			// Don't fold single line comments.
@@ -176,7 +176,7 @@
 		}
 		comments = append(comments, &FoldingRangeInfo{
 			// Fold from the end of the first line comment to the end of the comment block.
-			MappedRange: NewMappedRange(m, endLinePos, commentGrp.End()),
+			MappedRange: NewMappedRange(pgf, endLinePos, commentGrp.End()),
 			Kind:        protocol.Comment,
 		})
 	}
diff --git a/gopls/internal/lsp/source/format.go b/gopls/internal/lsp/source/format.go
index 6662137..a272218 100644
--- a/gopls/internal/lsp/source/format.go
+++ b/gopls/internal/lsp/source/format.go
@@ -199,7 +199,7 @@
 		fixedData = append(fixedData, '\n') // ApplyFixes may miss the newline, go figure.
 	}
 	edits := snapshot.View().Options().ComputeEdits(left, string(fixedData))
-	return protocolEditsFromSource([]byte(left), edits, pgf.Mapper.TokFile)
+	return protocolEditsFromSource([]byte(left), edits)
 }
 
 // importPrefix returns the prefix of the given file content through the final
@@ -314,7 +314,7 @@
 
 // protocolEditsFromSource converts text edits to LSP edits using the original
 // source.
-func protocolEditsFromSource(src []byte, edits []diff.Edit, tf *token.File) ([]protocol.TextEdit, error) {
+func protocolEditsFromSource(src []byte, edits []diff.Edit) ([]protocol.TextEdit, error) {
 	m := lsppos.NewMapper(src)
 	var result []protocol.TextEdit
 	for _, edit := range edits {
diff --git a/gopls/internal/lsp/source/highlight.go b/gopls/internal/lsp/source/highlight.go
index d073fff..9bd90e5 100644
--- a/gopls/internal/lsp/source/highlight.go
+++ b/gopls/internal/lsp/source/highlight.go
@@ -28,7 +28,7 @@
 		return nil, fmt.Errorf("getting package for Highlight: %w", err)
 	}
 
-	pos, err := pgf.Mapper.Pos(position)
+	pos, err := pgf.Pos(position)
 	if err != nil {
 		return nil, err
 	}
diff --git a/gopls/internal/lsp/source/hover.go b/gopls/internal/lsp/source/hover.go
index dd6ce40..2cf369b 100644
--- a/gopls/internal/lsp/source/hover.go
+++ b/gopls/internal/lsp/source/hover.go
@@ -83,7 +83,7 @@
 	if err != nil {
 		return nil, err
 	}
-	rng, err := ident.Range()
+	rng, err := ident.MappedRange.Range()
 	if err != nil {
 		return nil, err
 	}
@@ -104,11 +104,7 @@
 	ctx, done := event.Start(ctx, "source.hoverRune")
 	defer done()
 
-	r, mrng, err := findRune(ctx, snapshot, fh, position)
-	if err != nil {
-		return nil, err
-	}
-	rng, err := mrng.Range()
+	r, rng, err := findRune(ctx, snapshot, fh, position)
 	if err != nil {
 		return nil, err
 	}
@@ -138,18 +134,18 @@
 var ErrNoRuneFound = errors.New("no rune found")
 
 // findRune returns rune information for a position in a file.
-func findRune(ctx context.Context, snapshot Snapshot, fh FileHandle, position protocol.Position) (rune, MappedRange, error) {
+func findRune(ctx context.Context, snapshot Snapshot, fh FileHandle, position protocol.Position) (rune, protocol.Range, error) {
 	fh, err := snapshot.GetFile(ctx, fh.URI())
 	if err != nil {
-		return 0, MappedRange{}, err
+		return 0, protocol.Range{}, err
 	}
 	pgf, err := snapshot.ParseGo(ctx, fh, ParseFull)
 	if err != nil {
-		return 0, MappedRange{}, err
+		return 0, protocol.Range{}, err
 	}
-	pos, err := pgf.Mapper.Pos(position)
+	pos, err := pgf.Pos(position)
 	if err != nil {
-		return 0, MappedRange{}, err
+		return 0, protocol.Range{}, err
 	}
 
 	// Find the basic literal enclosing the given position, if there is one.
@@ -166,7 +162,7 @@
 		return lit == nil // descend unless target is found
 	})
 	if lit == nil {
-		return 0, MappedRange{}, ErrNoRuneFound
+		return 0, protocol.Range{}, ErrNoRuneFound
 	}
 
 	var r rune
@@ -177,26 +173,26 @@
 		if err != nil {
 			// If the conversion fails, it's because of an invalid syntax, therefore
 			// there is no rune to be found.
-			return 0, MappedRange{}, ErrNoRuneFound
+			return 0, protocol.Range{}, ErrNoRuneFound
 		}
 		r, _ = utf8.DecodeRuneInString(s)
 		if r == utf8.RuneError {
-			return 0, MappedRange{}, fmt.Errorf("rune error")
+			return 0, protocol.Range{}, fmt.Errorf("rune error")
 		}
 		start, end = lit.Pos(), lit.End()
 	case token.INT:
 		// It's an integer, scan only if it is a hex litteral whose bitsize in
 		// ranging from 8 to 32.
 		if !(strings.HasPrefix(lit.Value, "0x") && len(lit.Value[2:]) >= 2 && len(lit.Value[2:]) <= 8) {
-			return 0, MappedRange{}, ErrNoRuneFound
+			return 0, protocol.Range{}, ErrNoRuneFound
 		}
 		v, err := strconv.ParseUint(lit.Value[2:], 16, 32)
 		if err != nil {
-			return 0, MappedRange{}, err
+			return 0, protocol.Range{}, err
 		}
 		r = rune(v)
 		if r == utf8.RuneError {
-			return 0, MappedRange{}, fmt.Errorf("rune error")
+			return 0, protocol.Range{}, fmt.Errorf("rune error")
 		}
 		start, end = lit.Pos(), lit.End()
 	case token.STRING:
@@ -205,17 +201,17 @@
 		var found bool
 		litOffset, err := safetoken.Offset(pgf.Tok, lit.Pos())
 		if err != nil {
-			return 0, MappedRange{}, err
+			return 0, protocol.Range{}, err
 		}
 		offset, err := safetoken.Offset(pgf.Tok, pos)
 		if err != nil {
-			return 0, MappedRange{}, err
+			return 0, protocol.Range{}, err
 		}
 		for i := offset - litOffset; i > 0; i-- {
 			// Start at the cursor position and search backward for the beginning of a rune escape sequence.
 			rr, _ := utf8.DecodeRuneInString(lit.Value[i:])
 			if rr == utf8.RuneError {
-				return 0, MappedRange{}, fmt.Errorf("rune error")
+				return 0, protocol.Range{}, fmt.Errorf("rune error")
 			}
 			if rr == '\\' {
 				// Got the beginning, decode it.
@@ -223,7 +219,7 @@
 				r, _, tail, err = strconv.UnquoteChar(lit.Value[i:], '"')
 				if err != nil {
 					// If the conversion fails, it's because of an invalid syntax, therefore is no rune to be found.
-					return 0, MappedRange{}, ErrNoRuneFound
+					return 0, protocol.Range{}, ErrNoRuneFound
 				}
 				// Only the rune escape sequence part of the string has to be highlighted, recompute the range.
 				runeLen := len(lit.Value) - (int(i) + len(tail))
@@ -235,12 +231,16 @@
 		}
 		if !found {
 			// No escape sequence found
-			return 0, MappedRange{}, ErrNoRuneFound
+			return 0, protocol.Range{}, ErrNoRuneFound
 		}
 	default:
-		return 0, MappedRange{}, ErrNoRuneFound
+		return 0, protocol.Range{}, ErrNoRuneFound
 	}
-	return r, NewMappedRange(pgf.Mapper, start, end), nil
+	rng, err := pgf.PosRange(start, end)
+	if err != nil {
+		return 0, protocol.Range{}, err
+	}
+	return r, rng, nil
 }
 
 func HoverIdentifier(ctx context.Context, i *IdentifierInfo) (*HoverJSON, error) {
@@ -580,11 +580,7 @@
 			var spec ast.Spec
 			for _, s := range node.Specs {
 				// Avoid panics by guarding the calls to token.Offset (golang/go#48249).
-				start, err := safetoken.Offset(fullTok, s.Pos())
-				if err != nil {
-					return nil, err
-				}
-				end, err := safetoken.Offset(fullTok, s.End())
+				start, end, err := safetoken.Offsets(fullTok, s.Pos(), s.End())
 				if err != nil {
 					return nil, err
 				}
diff --git a/gopls/internal/lsp/source/identifier.go b/gopls/internal/lsp/source/identifier.go
index ad826da..bfd853f 100644
--- a/gopls/internal/lsp/source/identifier.go
+++ b/gopls/internal/lsp/source/identifier.go
@@ -24,13 +24,13 @@
 
 // IdentifierInfo holds information about an identifier in Go source.
 type IdentifierInfo struct {
-	Name     string
-	Snapshot Snapshot // only needed for .View(); TODO(adonovan): reduce.
-	MappedRange
+	Name        string
+	Snapshot    Snapshot // only needed for .View(); TODO(adonovan): reduce.
+	MappedRange MappedRange
 
 	Type struct {
-		MappedRange
-		Object types.Object
+		MappedRange MappedRange // TODO(adonovan): strength-reduce to a protocol.Location
+		Object      types.Object
 	}
 
 	Inferred *types.Signature
@@ -83,7 +83,7 @@
 	if err != nil {
 		return nil, err
 	}
-	pos, err := pgf.Mapper.Pos(position)
+	pos, err := pgf.Pos(position)
 	if err != nil {
 		return nil, err
 	}
@@ -114,24 +114,15 @@
 	// Special case for package declarations, since they have no
 	// corresponding types.Object.
 	if ident == file.Name {
-		rng, err := posToMappedRange(pkg, file.Name.Pos(), file.Name.End())
-		if err != nil {
-			return nil, err
-		}
-		var declAST *ast.File
+		rng := NewMappedRange(pgf, file.Name.Pos(), file.Name.End())
+		// If there's no package documentation, just use current file.
+		decl := pgf
 		for _, pgf := range pkg.CompiledGoFiles() {
 			if pgf.File.Doc != nil {
-				declAST = pgf.File
+				decl = pgf
 			}
 		}
-		// If there's no package documentation, just use current file.
-		if declAST == nil {
-			declAST = file
-		}
-		declRng, err := posToMappedRange(pkg, declAST.Name.Pos(), declAST.Name.End())
-		if err != nil {
-			return nil, err
-		}
+		declRng := NewMappedRange(decl, decl.File.Name.Pos(), decl.File.Name.End())
 		return &IdentifierInfo{
 			Name:        file.Name.Name,
 			ident:       file.Name,
@@ -140,7 +131,7 @@
 			qf:          qf,
 			Snapshot:    snapshot,
 			Declaration: Declaration{
-				node:        declAST.Name,
+				node:        decl.File.Name,
 				MappedRange: []MappedRange{declRng},
 			},
 		}, nil
@@ -199,7 +190,7 @@
 
 		// The builtin package isn't in the dependency graph, so the usual
 		// utilities won't work here.
-		rng := NewMappedRange(builtin.Mapper, decl.Pos(), decl.Pos()+token.Pos(len(result.Name)))
+		rng := NewMappedRange(builtin, decl.Pos(), decl.Pos()+token.Pos(len(result.Name)))
 		result.Declaration.MappedRange = append(result.Declaration.MappedRange, rng)
 		return result, nil
 	}
@@ -240,7 +231,7 @@
 			}
 			name := method.Names[0].Name
 			result.Declaration.node = method
-			rng := NewMappedRange(builtin.Mapper, method.Pos(), method.Pos()+token.Pos(len(name)))
+			rng := NewMappedRange(builtin, method.Pos(), method.Pos()+token.Pos(len(name)))
 			result.Declaration.MappedRange = append(result.Declaration.MappedRange, rng)
 			return result, nil
 		}
diff --git a/gopls/internal/lsp/source/inlay_hint.go b/gopls/internal/lsp/source/inlay_hint.go
index ade2f5f..3958e4b 100644
--- a/gopls/internal/lsp/source/inlay_hint.go
+++ b/gopls/internal/lsp/source/inlay_hint.go
@@ -111,7 +111,7 @@
 	start, end := pgf.File.Pos(), pgf.File.End()
 	if pRng.Start.Line < pRng.End.Line || pRng.Start.Character < pRng.End.Character {
 		// Adjust start and end for the specified range.
-		rng, err := pgf.Mapper.RangeToSpanRange(pRng)
+		rng, err := pgf.RangeToSpanRange(pRng)
 		if err != nil {
 			return nil, err
 		}
diff --git a/gopls/internal/lsp/source/references.go b/gopls/internal/lsp/source/references.go
index a1dcc54..c7529e8 100644
--- a/gopls/internal/lsp/source/references.go
+++ b/gopls/internal/lsp/source/references.go
@@ -23,8 +23,8 @@
 
 // ReferenceInfo holds information about reference to an identifier in Go source.
 type ReferenceInfo struct {
-	Name string
-	MappedRange
+	Name          string
+	MappedRange   MappedRange
 	ident         *ast.Ident
 	obj           types.Object
 	pkg           Package
@@ -74,7 +74,7 @@
 					if rdep.DepsByImpPath[UnquoteImportPath(imp)] == targetPkg.ID {
 						refs = append(refs, &ReferenceInfo{
 							Name:        pgf.File.Name.Name,
-							MappedRange: NewMappedRange(f.Mapper, imp.Pos(), imp.End()),
+							MappedRange: NewMappedRange(f, imp.Pos(), imp.End()),
 						})
 					}
 				}
@@ -93,7 +93,7 @@
 			}
 			refs = append(refs, &ReferenceInfo{
 				Name:        pgf.File.Name.Name,
-				MappedRange: NewMappedRange(f.Mapper, f.File.Name.Pos(), f.File.Name.End()),
+				MappedRange: NewMappedRange(f, f.File.Name.Pos(), f.File.Name.End()),
 			})
 		}
 
@@ -120,7 +120,7 @@
 	}
 	sort.Slice(toSort, func(i, j int) bool {
 		x, y := toSort[i], toSort[j]
-		if cmp := strings.Compare(string(x.URI()), string(y.URI())); cmp != 0 {
+		if cmp := strings.Compare(string(x.MappedRange.URI()), string(y.MappedRange.URI())); cmp != 0 {
 			return cmp < 0
 		}
 		return x.ident.Pos() < y.ident.Pos()
@@ -137,8 +137,8 @@
 		return nil, false, err
 	}
 	// Careful: because we used ParseHeader,
-	// Mapper.Pos(ppos) may be beyond EOF => (0, err).
-	pos, _ := pgf.Mapper.Pos(ppos)
+	// pgf.Pos(ppos) may be beyond EOF => (0, err).
+	pos, _ := pgf.Pos(ppos)
 	return pgf, pgf.File.Name.Pos() <= pos && pos <= pgf.File.Name.End(), nil
 }
 
@@ -261,11 +261,11 @@
 	if includeInterfaceRefs && !isType {
 		// TODO(adonovan): opt: don't go back into the position domain:
 		// we have complete type information already.
-		declRange, err := declIdent.Range()
+		declRange, err := declIdent.MappedRange.Range()
 		if err != nil {
 			return nil, err
 		}
-		fh, err := snapshot.GetFile(ctx, declIdent.URI())
+		fh, err := snapshot.GetFile(ctx, declIdent.MappedRange.URI())
 		if err != nil {
 			return nil, err
 		}
diff --git a/gopls/internal/lsp/source/rename.go b/gopls/internal/lsp/source/rename.go
index 37fb2d5..8cb50e8 100644
--- a/gopls/internal/lsp/source/rename.go
+++ b/gopls/internal/lsp/source/rename.go
@@ -107,7 +107,7 @@
 		}
 
 		// Return the location of the package declaration.
-		rng, err := pgf.Mapper.PosRange(pgf.File.Name.Pos(), pgf.File.Name.End())
+		rng, err := pgf.PosRange(pgf.File.Name.Pos(), pgf.File.Name.End())
 		if err != nil {
 			return nil, err, err
 		}
@@ -434,7 +434,7 @@
 		if f.File.Name == nil {
 			continue // no package declaration
 		}
-		rng, err := f.Mapper.PosRange(f.File.Name.Pos(), f.File.Name.End())
+		rng, err := f.PosRange(f.File.Name.Pos(), f.File.Name.End())
 		if err != nil {
 			return err
 		}
@@ -493,8 +493,7 @@
 				}
 
 				// Create text edit for the import path (string literal).
-				impPathMappedRange := NewMappedRange(f.Mapper, imp.Path.Pos(), imp.Path.End())
-				rng, err := impPathMappedRange.Range()
+				rng, err := f.PosRange(imp.Path.Pos(), imp.Path.End())
 				if err != nil {
 					return err
 				}
@@ -667,7 +666,7 @@
 		return nil, err
 	}
 	for _, ref := range r.refs {
-		refSpan, err := ref.Span()
+		refSpan, err := ref.MappedRange.Span()
 		if err != nil {
 			return nil, err
 		}
@@ -726,8 +725,7 @@
 				for _, locs := range docRegexp.FindAllIndex([]byte(line), -1) {
 					// The File.Offset static check complains
 					// even though these uses are manifestly safe.
-					start, _ := safetoken.Offset(tokFile, lineStart+token.Pos(locs[0]))
-					end, _ := safetoken.Offset(tokFile, lineStart+token.Pos(locs[1]))
+					start, end, _ := safetoken.Offsets(tokFile, lineStart+token.Pos(locs[0]), lineStart+token.Pos(locs[1]))
 					result[uri] = append(result[uri], diff.Edit{
 						Start: start,
 						End:   end,
@@ -813,15 +811,14 @@
 	// Replace the portion (possibly empty) of the spec before the path:
 	//     local "path"      or      "path"
 	//   ->      <-                -><-
-	rng := span.NewRange(tokFile, spec.Pos(), spec.Path.Pos())
-	spn, err := rng.Span()
+	start, end, err := safetoken.Offsets(tokFile, spec.Pos(), spec.Path.Pos())
 	if err != nil {
 		return nil, err
 	}
 
 	return &diff.Edit{
-		Start: spn.Start().Offset(),
-		End:   spn.End().Offset(),
+		Start: start,
+		End:   end,
 		New:   newText,
 	}, nil
 }
diff --git a/gopls/internal/lsp/source/signature_help.go b/gopls/internal/lsp/source/signature_help.go
index 3f12d90..a751b29 100644
--- a/gopls/internal/lsp/source/signature_help.go
+++ b/gopls/internal/lsp/source/signature_help.go
@@ -24,7 +24,7 @@
 	if err != nil {
 		return nil, 0, fmt.Errorf("getting file for SignatureHelp: %w", err)
 	}
-	pos, err := pgf.Mapper.Pos(position)
+	pos, err := pgf.Pos(position)
 	if err != nil {
 		return nil, 0, err
 	}
diff --git a/gopls/internal/lsp/source/source_test.go b/gopls/internal/lsp/source/source_test.go
index ed8d9a4..fccb91e 100644
--- a/gopls/internal/lsp/source/source_test.go
+++ b/gopls/internal/lsp/source/source_test.go
@@ -431,11 +431,11 @@
 }
 
 func conflict(t *testing.T, a, b *source.FoldingRangeInfo) bool {
-	arng, err := a.Range()
+	arng, err := a.MappedRange.Range()
 	if err != nil {
 		t.Fatal(err)
 	}
-	brng, err := b.Range()
+	brng, err := b.MappedRange.Range()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -450,7 +450,7 @@
 	// to preserve the offsets.
 	for i := len(ranges) - 1; i >= 0; i-- {
 		fRange := ranges[i]
-		spn, err := fRange.Span()
+		spn, err := fRange.MappedRange.Span()
 		if err != nil {
 			return "", err
 		}
@@ -552,7 +552,7 @@
 		t.Fatal(err)
 	}
 	if d.IsType {
-		rng, err = ident.Type.Range()
+		rng, err = ident.Type.MappedRange.Range()
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -725,7 +725,7 @@
 			}
 			got := make(map[span.Span]bool)
 			for _, refInfo := range refs {
-				refSpan, err := refInfo.Span()
+				refSpan, err := refInfo.MappedRange.Span()
 				if err != nil {
 					t.Fatal(err)
 				}
diff --git a/gopls/internal/lsp/source/stub.go b/gopls/internal/lsp/source/stub.go
index 2568bd0..0d94d48 100644
--- a/gopls/internal/lsp/source/stub.go
+++ b/gopls/internal/lsp/source/stub.go
@@ -109,12 +109,11 @@
 
 	// Return the diff.
 	diffs := snapshot.View().Options().ComputeEdits(string(parsedConcreteFile.Src), source.String())
-	tf := parsedConcreteFile.Mapper.TokFile
 	var edits []analysis.TextEdit
 	for _, edit := range diffs {
 		edits = append(edits, analysis.TextEdit{
-			Pos:     tf.Pos(edit.Start),
-			End:     tf.Pos(edit.End),
+			Pos:     parsedConcreteFile.Tok.Pos(edit.Start),
+			End:     parsedConcreteFile.Tok.Pos(edit.End),
 			NewText: []byte(edit.New),
 		})
 	}
@@ -223,7 +222,7 @@
 	if err != nil {
 		return nil, 0, err
 	}
-	rng, err := spn.Range(pgf.Mapper.TokFile)
+	rng, err := spn.Range(pgf.Tok)
 	if err != nil {
 		return nil, 0, err
 	}
diff --git a/gopls/internal/lsp/source/util.go b/gopls/internal/lsp/source/util.go
index face4c9..0b7f3e4 100644
--- a/gopls/internal/lsp/source/util.go
+++ b/gopls/internal/lsp/source/util.go
@@ -20,66 +20,46 @@
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
 	"golang.org/x/tools/gopls/internal/lsp/safetoken"
 	"golang.org/x/tools/gopls/internal/span"
-	"golang.org/x/tools/internal/bug"
 	"golang.org/x/tools/internal/typeparams"
 )
 
-// MappedRange provides mapped protocol.Range for a span.Range, accounting for
-// UTF-16 code points.
+// A MappedRange represents an interval within a parsed Go file and
+// has the ability to convert to protocol.Range or span.Span form.
 //
-// TOOD(adonovan): eliminate this type. Replace all uses by an
-// explicit pair (span.Range, protocol.ColumnMapper), and an operation
-// to map both to a protocol.Range.
+// TOOD(adonovan): eliminate this type by inlining it: make callers
+// hold the triple themselves, or convert to Mapper + start/end offsets
+// and hold that.
 type MappedRange struct {
-	spanRange span.Range             // the range in the compiled source (package.CompiledGoFiles)
-	m         *protocol.ColumnMapper // a mapper of the edited source (package.GoFiles)
+	// TODO(adonovan): eliminate sole tricky direct use of this,
+	// which is entangled with IdentifierInfo.Declaration.node.
+	File       *ParsedGoFile
+	start, end token.Pos
 }
 
 // NewMappedRange returns a MappedRange for the given file and
-// start/end positions, which must be valid within m.TokFile.
-func NewMappedRange(m *protocol.ColumnMapper, start, end token.Pos) MappedRange {
-	return MappedRange{
-		spanRange: span.NewRange(m.TokFile, start, end),
-		m:         m,
-	}
+// start/end positions, which must be valid within the file.
+func NewMappedRange(pgf *ParsedGoFile, start, end token.Pos) MappedRange {
+	_ = span.NewRange(pgf.Tok, start, end) // just for assertions
+	return MappedRange{File: pgf, start: start, end: end}
 }
 
 // Range returns the LSP range in the edited source.
-//
-// See the documentation of NewMappedRange for information on edited vs
-// compiled source.
 func (s MappedRange) Range() (protocol.Range, error) {
-	if s.m == nil {
-		return protocol.Range{}, bug.Errorf("invalid range")
-	}
-	spn, err := span.FileSpan(s.spanRange.TokFile, s.spanRange.Start, s.spanRange.End)
-	if err != nil {
-		return protocol.Range{}, err
-	}
-	return s.m.Range(spn)
+	return s.File.PosRange(s.start, s.end)
 }
 
-// Span returns the span corresponding to the mapped range in the edited
-// source.
-//
-// See the documentation of NewMappedRange for information on edited vs
-// compiled source.
+// Span returns the span corresponding to the mapped range in the edited source.
 func (s MappedRange) Span() (span.Span, error) {
-	// In the past, some code-paths have relied on Span returning an error if s
-	// is the zero value (i.e. s.m is nil). But this should be treated as a bug:
-	// observe that s.URI() would panic in this case.
-	if s.m == nil {
-		return span.Span{}, bug.Errorf("invalid range")
+	start, end, err := safetoken.Offsets(s.File.Tok, s.start, s.end)
+	if err != nil {
+		return span.Span{}, err
 	}
-	return span.FileSpan(s.spanRange.TokFile, s.spanRange.Start, s.spanRange.End)
+	return s.File.Mapper.OffsetSpan(start, end)
 }
 
 // URI returns the URI of the edited file.
-//
-// See the documentation of NewMappedRange for information on edited vs
-// compiled source.
 func (s MappedRange) URI() span.URI {
-	return s.m.URI
+	return s.File.Mapper.URI
 }
 
 func IsGenerated(ctx context.Context, snapshot Snapshot, uri span.URI) bool {
@@ -126,6 +106,10 @@
 
 // posToMappedRange returns the MappedRange for the given [start, end) span,
 // which must be among the transitive dependencies of pkg.
+//
+// TODO(adonovan): many of the callers need only the ParsedGoFile so
+// that they can call pgf.PosRange(pos, end) to get a Range; they
+// don't actually need a MappedRange.
 func posToMappedRange(pkg Package, pos, end token.Pos) (MappedRange, error) {
 	if !pos.IsValid() {
 		return MappedRange{}, fmt.Errorf("invalid start position")
@@ -139,7 +123,7 @@
 	if err != nil {
 		return MappedRange{}, err
 	}
-	return NewMappedRange(pgf.Mapper, pos, end), nil
+	return NewMappedRange(pgf, pos, end), nil
 }
 
 // FindPackageFromPos returns the Package for the given position, which must be
diff --git a/gopls/internal/lsp/source/view.go b/gopls/internal/lsp/source/view.go
index 0b73c74..21e9969 100644
--- a/gopls/internal/lsp/source/view.go
+++ b/gopls/internal/lsp/source/view.go
@@ -23,6 +23,7 @@
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/tools/gopls/internal/govulncheck"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
+	"golang.org/x/tools/gopls/internal/lsp/safetoken"
 	"golang.org/x/tools/gopls/internal/span"
 	"golang.org/x/tools/internal/event/label"
 	"golang.org/x/tools/internal/event/tag"
@@ -380,6 +381,33 @@
 	ParseErr scanner.ErrorList
 }
 
+// Pos returns the token.Pos of protocol position p within the file.
+func (pgf *ParsedGoFile) Pos(p protocol.Position) (token.Pos, error) {
+	point, err := pgf.Mapper.Point(p)
+	if err != nil {
+		return token.NoPos, err
+	}
+	return safetoken.Pos(pgf.Tok, point.Offset())
+}
+
+// PosRange returns a protocol Range for the token.Pos interval in this file.
+func (pgf *ParsedGoFile) PosRange(start, end token.Pos) (protocol.Range, error) {
+	startOffset, endOffset, err := safetoken.Offsets(pgf.Tok, start, end)
+	if err != nil {
+		return protocol.Range{}, err
+	}
+	return pgf.Mapper.OffsetRange(startOffset, endOffset)
+}
+
+// RangeToSpanRange parses a protocol Range back into the go/token domain.
+func (pgf *ParsedGoFile) RangeToSpanRange(r protocol.Range) (span.Range, error) {
+	spn, err := pgf.Mapper.RangeSpan(r)
+	if err != nil {
+		return span.Range{}, err
+	}
+	return spn.Range(pgf.Tok)
+}
+
 // A ParsedModule contains the results of parsing a go.mod file.
 type ParsedModule struct {
 	URI         span.URI
diff --git a/gopls/internal/lsp/work/completion.go b/gopls/internal/lsp/work/completion.go
index 623d2ce..b3682e1 100644
--- a/gopls/internal/lsp/work/completion.go
+++ b/gopls/internal/lsp/work/completion.go
@@ -8,15 +8,14 @@
 	"context"
 	"errors"
 	"fmt"
-	"go/token"
 	"os"
 	"path/filepath"
 	"sort"
 	"strings"
 
-	"golang.org/x/tools/internal/event"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
 	"golang.org/x/tools/gopls/internal/lsp/source"
+	"golang.org/x/tools/internal/event"
 )
 
 func Completion(ctx context.Context, snapshot source.Snapshot, fh source.VersionedFileHandle, position protocol.Position) (*protocol.CompletionList, error) {
@@ -28,18 +27,17 @@
 	if err != nil {
 		return nil, fmt.Errorf("getting go.work file handle: %w", err)
 	}
-	pos, err := pw.Mapper.Pos(position)
+	cursor, err := pw.Mapper.Offset(position)
 	if err != nil {
-		return nil, fmt.Errorf("computing cursor position: %w", err)
+		return nil, fmt.Errorf("computing cursor offset: %w", err)
 	}
 
 	// Find the use statement the user is in.
-	cursor := pos - 1
 	use, pathStart, _ := usePath(pw, cursor)
 	if use == nil {
 		return &protocol.CompletionList{}, nil
 	}
-	completingFrom := use.Path[:cursor-token.Pos(pathStart)]
+	completingFrom := use.Path[:cursor-pathStart]
 
 	// We're going to find the completions of the user input
 	// (completingFrom) by doing a walk on the innermost directory
diff --git a/gopls/internal/lsp/work/hover.go b/gopls/internal/lsp/work/hover.go
index 641028b..a29d59c 100644
--- a/gopls/internal/lsp/work/hover.go
+++ b/gopls/internal/lsp/work/hover.go
@@ -8,7 +8,6 @@
 	"bytes"
 	"context"
 	"fmt"
-	"go/token"
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
@@ -30,14 +29,14 @@
 	if err != nil {
 		return nil, fmt.Errorf("getting go.work file handle: %w", err)
 	}
-	pos, err := pw.Mapper.Pos(position)
+	offset, err := pw.Mapper.Offset(position)
 	if err != nil {
-		return nil, fmt.Errorf("computing cursor position: %w", err)
+		return nil, fmt.Errorf("computing cursor offset: %w", err)
 	}
 
 	// Confirm that the cursor is inside a use statement, and then find
 	// the position of the use statement's directory path.
-	use, pathStart, pathEnd := usePath(pw, pos)
+	use, pathStart, pathEnd := usePath(pw, offset)
 
 	// The cursor position is not on a use statement.
 	if use == nil {
@@ -70,7 +69,7 @@
 	}, nil
 }
 
-func usePath(pw *source.ParsedWorkFile, pos token.Pos) (use *modfile.Use, pathStart, pathEnd int) {
+func usePath(pw *source.ParsedWorkFile, offset int) (use *modfile.Use, pathStart, pathEnd int) {
 	for _, u := range pw.File.Use {
 		path := []byte(u.Path)
 		s, e := u.Syntax.Start.Byte, u.Syntax.End.Byte
@@ -82,7 +81,7 @@
 		// Shift the start position to the location of the
 		// module directory within the use statement.
 		pathStart, pathEnd = s+i, s+i+len(path)
-		if token.Pos(pathStart) <= pos && pos <= token.Pos(pathEnd) {
+		if pathStart <= offset && offset <= pathEnd {
 			return u, pathStart, pathEnd
 		}
 	}
diff --git a/gopls/internal/regtest/misc/failures_test.go b/gopls/internal/regtest/misc/failures_test.go
index b016966..f996514 100644
--- a/gopls/internal/regtest/misc/failures_test.go
+++ b/gopls/internal/regtest/misc/failures_test.go
@@ -50,7 +50,7 @@
 func TestFailingDiagnosticClearingOnEdit(t *testing.T) {
 	t.Skip("line directives //line ")
 	// badPackageDup contains a duplicate definition of the 'a' const.
-	// This is a minor variant of TestDiagnosticClearingOnEditfrom from
+	// This is a minor variant of TestDiagnosticClearingOnEdit from
 	// diagnostics_test.go, with a line directive, which makes no difference.
 	const badPackageDup = `
 -- go.mod --
diff --git a/gopls/internal/span/span.go b/gopls/internal/span/span.go
index 0c24a2d..36629f0 100644
--- a/gopls/internal/span/span.go
+++ b/gopls/internal/span/span.go
@@ -36,9 +36,9 @@
 }
 
 type point struct {
-	Line   int `json:"line"`
-	Column int `json:"column"`
-	Offset int `json:"offset"`
+	Line   int `json:"line"`   // 1-based line number
+	Column int `json:"column"` // 1-based, UTF-8 codes (bytes)
+	Offset int `json:"offset"` // 0-based byte offset
 }
 
 // Invalid is a span that reports false from IsValid
@@ -274,12 +274,12 @@
 }
 
 func (p *point) updatePosition(tf *token.File) error {
-	line, col, err := ToPosition(tf, p.Offset)
+	line, col8, err := OffsetToLineCol8(tf, p.Offset)
 	if err != nil {
 		return err
 	}
 	p.Line = line
-	p.Column = col
+	p.Column = col8
 	return nil
 }
 
diff --git a/gopls/internal/span/token.go b/gopls/internal/span/token.go
index ca78d67..2e71cba 100644
--- a/gopls/internal/span/token.go
+++ b/gopls/internal/span/token.go
@@ -15,6 +15,8 @@
 // Range represents a source code range in token.Pos form.
 // It also carries the token.File that produced the positions, so that it is
 // self contained.
+//
+// TODO(adonovan): move to safetoken.Range (but the Range.Span function must stay behind).
 type Range struct {
 	TokFile    *token.File // non-nil
 	Start, End token.Pos   // both IsValid()
@@ -55,14 +57,6 @@
 	}
 }
 
-// NewTokenFile returns a token.File for the given file content.
-func NewTokenFile(filename string, content []byte) *token.File {
-	fset := token.NewFileSet()
-	f := fset.AddFile(filename, -1, len(content))
-	f.SetLinesForContent(content)
-	return f
-}
-
 // IsPoint returns true if the range represents a single point.
 func (r Range) IsPoint() bool {
 	return r.Start == r.End
@@ -95,8 +89,6 @@
 		if err != nil {
 			return Span{}, err
 		}
-		// In the presence of line directives, a single File can have sections from
-		// multiple file names.
 		if endFilename != startFilename {
 			return Span{}, fmt.Errorf("span begins in file %q but ends in %q", startFilename, endFilename)
 		}
@@ -160,11 +152,13 @@
 	}, nil
 }
 
-// ToPosition converts a byte offset in the file corresponding to tf into
+// OffsetToLineCol8 converts a byte offset in the file corresponding to tf into
 // 1-based line and utf-8 column indexes.
-func ToPosition(tf *token.File, offset int) (int, int, error) {
-	_, line, col, err := positionFromOffset(tf, offset)
-	return line, col, err
+//
+// TODO(adonovan): move to safetoken package for consistency?
+func OffsetToLineCol8(tf *token.File, offset int) (int, int, error) {
+	_, line, col8, err := positionFromOffset(tf, offset)
+	return line, col8, err
 }
 
 // ToOffset converts a 1-based line and utf-8 column index into a byte offset