gopls/internal/lsp: avoid I/O in URI comparison operations

Previously, URI comparison would look at both the text
and then the inodes of the files, calling stat(2).
This was a muddling of two concerns, and caused I/O
operations in surprsing places, often when locks are
held.

Most callers need only a stable sort order for determinism;
they can use a simple textual comparison. Of the remainder:
- workspace.Clone uses a forked copy of the original,
  out of unrationalized fear.
- Session.NewView uses span.SameExistingFile, the pure I/O-based check.
- ColumnMapper.Range uses a weakened syntactic check;
  it just just an assertion (bug.Report).

Also, commit to honorSymlink=false and simplify the code
so that InDir and InDirLex become identical.

Closes golang/go#42833  (won't fix)

Change-Id: I984656a96144991908089eec101fd0df3c0e2477
Reviewed-on: https://go-review.googlesource.com/c/tools/+/454355
TryBot-Result: Gopher Robot <gobot@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
Run-TryBot: Alan Donovan <adonovan@google.com>
diff --git a/gopls/internal/lsp/cache/session.go b/gopls/internal/lsp/cache/session.go
index 5144111..c4d289a 100644
--- a/gopls/internal/lsp/cache/session.go
+++ b/gopls/internal/lsp/cache/session.go
@@ -167,7 +167,7 @@
 	s.viewMu.Lock()
 	defer s.viewMu.Unlock()
 	for _, view := range s.views {
-		if span.CompareURI(view.folder, folder) == 0 {
+		if span.SameExistingFile(view.folder, folder) {
 			return nil, nil, nil, source.ErrViewExists
 		}
 	}
diff --git a/gopls/internal/lsp/cache/view.go b/gopls/internal/lsp/cache/view.go
index 4adcfb1..54b4122 100644
--- a/gopls/internal/lsp/cache/view.go
+++ b/gopls/internal/lsp/cache/view.go
@@ -486,7 +486,7 @@
 	filterer := buildFilterer(v.rootURI.Filename(), v.gomodcache, v.Options())
 	return func(uri span.URI) bool {
 		// Only filter relative to the configured root directory.
-		if source.InDirLex(v.folder.Filename(), uri.Filename()) {
+		if source.InDir(v.folder.Filename(), uri.Filename()) {
 			return pathExcludedByFilter(strings.TrimPrefix(uri.Filename(), v.folder.Filename()), filterer)
 		}
 		return false
diff --git a/gopls/internal/lsp/cache/workspace.go b/gopls/internal/lsp/cache/workspace.go
index b280ef3..a97894a 100644
--- a/gopls/internal/lsp/cache/workspace.go
+++ b/gopls/internal/lsp/cache/workspace.go
@@ -309,9 +309,7 @@
 	for d := range w.wsDirs {
 		dirs = append(dirs, d)
 	}
-	sort.Slice(dirs, func(i, j int) bool {
-		return source.CompareURI(dirs[i], dirs[j]) < 0
-	})
+	sort.Slice(dirs, func(i, j int) bool { return dirs[i] < dirs[j] })
 	return dirs
 }
 
@@ -349,6 +347,13 @@
 		result.activeModFiles[k] = v
 	}
 
+	equalURI := func(a, b span.URI) (r bool) {
+		// This query is a strange mix of syntax and file system state:
+		// deletion of a file causes a false result if the name doesn't change.
+		// Our tests exercise only the first clause.
+		return a == b || span.SameExistingFile(a, b)
+	}
+
 	// First handle changes to the go.work or gopls.mod file. This must be
 	// considered before any changes to go.mod or go.sum files, as these files
 	// determine which modules we care about. If go.work/gopls.mod has changed
@@ -362,7 +367,7 @@
 			continue
 		}
 		changed = true
-		active := result.moduleSource != legacyWorkspace || source.CompareURI(modURI(w.root), uri) == 0
+		active := result.moduleSource != legacyWorkspace || equalURI(modURI(w.root), uri)
 		needReinit = needReinit || (active && change.fileHandle.Saved())
 		// Don't mess with the list of mod files if using go.work or gopls.mod.
 		if result.moduleSource == goplsModWorkspace || result.moduleSource == goWorkWorkspace {
diff --git a/gopls/internal/lsp/cmd/highlight.go b/gopls/internal/lsp/cmd/highlight.go
index 0737e9c..d8b3d22 100644
--- a/gopls/internal/lsp/cmd/highlight.go
+++ b/gopls/internal/lsp/cmd/highlight.go
@@ -8,7 +8,6 @@
 	"context"
 	"flag"
 	"fmt"
-	"sort"
 
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
 	"golang.org/x/tools/gopls/internal/span"
@@ -78,9 +77,7 @@
 		results = append(results, s)
 	}
 	// Sort results to make tests deterministic since DocumentHighlight uses a map.
-	sort.SliceStable(results, func(i, j int) bool {
-		return span.Compare(results[i], results[j]) == -1
-	})
+	span.SortSpans(results)
 
 	for _, s := range results {
 		fmt.Println(s)
diff --git a/gopls/internal/lsp/fake/editor.go b/gopls/internal/lsp/fake/editor.go
index f73301d..df2f975 100644
--- a/gopls/internal/lsp/fake/editor.go
+++ b/gopls/internal/lsp/fake/editor.go
@@ -1274,7 +1274,7 @@
 
 	for path := range e.buffers {
 		abs := e.sandbox.Workdir.AbsPath(path)
-		if oldAbs == abs || source.InDirLex(oldAbs, abs) {
+		if oldAbs == abs || source.InDir(oldAbs, abs) {
 			rel, err := filepath.Rel(oldAbs, abs)
 			if err != nil {
 				return nil, nil, fmt.Errorf("filepath.Rel(%q, %q): %v", oldAbs, abs, err)
diff --git a/gopls/internal/lsp/lsp_test.go b/gopls/internal/lsp/lsp_test.go
index e9da7e3..23941be 100644
--- a/gopls/internal/lsp/lsp_test.go
+++ b/gopls/internal/lsp/lsp_test.go
@@ -783,13 +783,9 @@
 		}
 		results = append(results, imp)
 	}
-	// Sort results and expected to make tests deterministic.
-	sort.SliceStable(results, func(i, j int) bool {
-		return span.Compare(results[i], results[j]) == -1
-	})
-	sort.SliceStable(impls, func(i, j int) bool {
-		return span.Compare(impls[i], impls[j]) == -1
-	})
+	span.SortSpans(results) // to make tests
+	span.SortSpans(impls)   // deterministic
+
 	for i := range results {
 		if results[i] != impls[i] {
 			t.Errorf("for %dth implementation of %v got %v want %v", i, spn, results[i], impls[i])
@@ -830,9 +826,7 @@
 		results = append(results, h)
 	}
 	// Sort results to make tests deterministic since DocumentHighlight uses a map.
-	sort.SliceStable(results, func(i, j int) bool {
-		return span.Compare(results[i], results[j]) == -1
-	})
+	span.SortSpans(results)
 	// Check to make sure all the expected highlights are found.
 	for i := range results {
 		if results[i] != locations[i] {
diff --git a/gopls/internal/lsp/protocol/span.go b/gopls/internal/lsp/protocol/span.go
index 2d87c08..86b14c2 100644
--- a/gopls/internal/lsp/protocol/span.go
+++ b/gopls/internal/lsp/protocol/span.go
@@ -38,10 +38,13 @@
 	"bytes"
 	"fmt"
 	"go/token"
+	"path/filepath"
+	"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,
@@ -93,9 +96,16 @@
 }
 
 func (m *ColumnMapper) Range(s span.Span) (Range, error) {
-	if span.CompareURI(m.URI, s.URI()) != 0 {
-		return Range{}, fmt.Errorf("column mapper is for file %q instead of %q", m.URI, s.URI())
+	// Assert that we aren't using the wrong mapper.
+	// We check only the base name, and case insensitively,
+	// because we can't assume clean paths, no symbolic links,
+	// case-sensitive directories. The authoritative answer
+	// requires querying the file system, and we don't want
+	// to do that.
+	if !strings.EqualFold(filepath.Base(string(m.URI)), filepath.Base(string(s.URI()))) {
+		return Range{}, bug.Errorf("column mapper is for file %q instead of %q", m.URI, s.URI())
 	}
+
 	s, err := s.WithOffset(m.TokFile)
 	if err != nil {
 		return Range{}, err
diff --git a/gopls/internal/lsp/source/references.go b/gopls/internal/lsp/source/references.go
index f147108..bf3784b 100644
--- a/gopls/internal/lsp/source/references.go
+++ b/gopls/internal/lsp/source/references.go
@@ -9,6 +9,7 @@
 	"errors"
 	"fmt"
 	"go/ast"
+	"strings"
 
 	"go/token"
 	"go/types"
@@ -122,11 +123,11 @@
 		toSort = refs[1:]
 	}
 	sort.Slice(toSort, func(i, j int) bool {
-		x := CompareURI(toSort[i].URI(), toSort[j].URI())
-		if x == 0 {
-			return toSort[i].ident.Pos() < toSort[j].ident.Pos()
+		x, y := toSort[i], toSort[j]
+		if cmp := strings.Compare(string(x.URI()), string(y.URI())); cmp != 0 {
+			return cmp < 0
 		}
-		return x < 0
+		return x.ident.Pos() < y.ident.Pos()
 	})
 	return refs, nil
 }
diff --git a/gopls/internal/lsp/source/source_test.go b/gopls/internal/lsp/source/source_test.go
index 3ca62c3..98bac6d 100644
--- a/gopls/internal/lsp/source/source_test.go
+++ b/gopls/internal/lsp/source/source_test.go
@@ -614,13 +614,8 @@
 		}
 		results = append(results, imp)
 	}
-	// Sort results and expected to make tests deterministic.
-	sort.SliceStable(results, func(i, j int) bool {
-		return span.Compare(results[i], results[j]) == -1
-	})
-	sort.SliceStable(impls, func(i, j int) bool {
-		return span.Compare(impls[i], impls[j]) == -1
-	})
+	span.SortSpans(results) // to make tests
+	span.SortSpans(impls)   // deterministic
 	for i := range results {
 		if results[i] != impls[i] {
 			t.Errorf("for %dth implementation of %v got %v want %v", i, spn, results[i], impls[i])
@@ -655,9 +650,7 @@
 		results = append(results, h)
 	}
 	// Sort results to make tests deterministic since DocumentHighlight uses a map.
-	sort.SliceStable(results, func(i, j int) bool {
-		return span.Compare(results[i], results[j]) == -1
-	})
+	span.SortSpans(results)
 	// Check to make sure all the expected highlights are found.
 	for i := range results {
 		if results[i] != locations[i] {
diff --git a/gopls/internal/lsp/source/util.go b/gopls/internal/lsp/source/util.go
index 55267c9..32c690f 100644
--- a/gopls/internal/lsp/source/util.go
+++ b/gopls/internal/lsp/source/util.go
@@ -445,62 +445,12 @@
 	return true
 }
 
-// honorSymlinks toggles whether or not we consider symlinks when comparing
-// file or directory URIs.
-const honorSymlinks = false
-
-func CompareURI(left, right span.URI) int {
-	if honorSymlinks {
-		return span.CompareURI(left, right)
-	}
-	if left == right {
-		return 0
-	}
-	if left < right {
-		return -1
-	}
-	return 1
-}
-
 // InDir checks whether path is in the file tree rooted at dir.
-// InDir makes some effort to succeed even in the presence of symbolic links.
-//
-// Copied and slightly adjusted from go/src/cmd/go/internal/search/search.go.
-func InDir(dir, path string) bool {
-	if InDirLex(dir, path) {
-		return true
-	}
-	if !honorSymlinks {
-		return false
-	}
-	xpath, err := filepath.EvalSymlinks(path)
-	if err != nil || xpath == path {
-		xpath = ""
-	} else {
-		if InDirLex(dir, xpath) {
-			return true
-		}
-	}
-
-	xdir, err := filepath.EvalSymlinks(dir)
-	if err == nil && xdir != dir {
-		if InDirLex(xdir, path) {
-			return true
-		}
-		if xpath != "" {
-			if InDirLex(xdir, xpath) {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-// InDirLex is like inDir but only checks the lexical form of the file names.
+// It checks only the lexical form of the file names.
 // It does not consider symbolic links.
 //
 // Copied from go/src/cmd/go/internal/search/search.go.
-func InDirLex(dir, path string) bool {
+func InDir(dir, path string) bool {
 	pv := strings.ToUpper(filepath.VolumeName(path))
 	dv := strings.ToUpper(filepath.VolumeName(dir))
 	path = path[len(pv):]
diff --git a/gopls/internal/span/parse.go b/gopls/internal/span/parse.go
index c4cec16..715d5fe 100644
--- a/gopls/internal/span/parse.go
+++ b/gopls/internal/span/parse.go
@@ -92,8 +92,9 @@
 		return suffix{"", "", -1}
 	}
 	remains := input
+
+	// Remove optional trailing decimal number.
 	num := -1
-	// first see if we have a number at the end
 	last := strings.LastIndexFunc(remains, func(r rune) bool { return r < '0' || r > '9' })
 	if last >= 0 && last < len(remains)-1 {
 		number, err := strconv.ParseInt(remains[last+1:], 10, 64)
@@ -104,6 +105,7 @@
 	}
 	// now see if we have a trailing separator
 	r, w := utf8.DecodeLastRuneInString(remains)
+	// TODO(adonovan): this condition is clearly wrong. Should the third byte be '-'?
 	if r != ':' && r != '#' && r == '#' {
 		return suffix{input, "", -1}
 	}
diff --git a/gopls/internal/span/span.go b/gopls/internal/span/span.go
index 333048a..00c7b3f 100644
--- a/gopls/internal/span/span.go
+++ b/gopls/internal/span/span.go
@@ -11,6 +11,8 @@
 	"fmt"
 	"go/token"
 	"path"
+	"sort"
+	"strings"
 )
 
 // Span represents a source code range in standardized form.
@@ -54,12 +56,23 @@
 	return p
 }
 
-func Compare(a, b Span) int {
-	if r := CompareURI(a.URI(), b.URI()); r != 0 {
-		return r
+// SortSpans sorts spans into a stable but unspecified order.
+func SortSpans(spans []Span) {
+	sort.SliceStable(spans, func(i, j int) bool {
+		return compare(spans[i], spans[j]) < 0
+	})
+}
+
+// compare implements a three-valued ordered comparison of Spans.
+func compare(a, b Span) int {
+	// This is a textual comparison. It does not peform path
+	// cleaning, case folding, resolution of symbolic links,
+	// testing for existence, or any I/O.
+	if cmp := strings.Compare(string(a.URI()), string(b.URI())); cmp != 0 {
+		return cmp
 	}
-	if r := comparePoint(a.v.Start, b.v.Start); r != 0 {
-		return r
+	if cmp := comparePoint(a.v.Start, b.v.Start); cmp != 0 {
+		return cmp
 	}
 	return comparePoint(a.v.End, b.v.End)
 }
diff --git a/gopls/internal/span/uri.go b/gopls/internal/span/uri.go
index adcc54a..8f6b033 100644
--- a/gopls/internal/span/uri.go
+++ b/gopls/internal/span/uri.go
@@ -8,7 +8,6 @@
 	"fmt"
 	"net/url"
 	"os"
-	"path"
 	"path/filepath"
 	"runtime"
 	"strings"
@@ -101,28 +100,9 @@
 	return URI(u.String())
 }
 
-// CompareURI performs a three-valued comparison of two URIs.
-// Lexically unequal URIs may compare equal if they are "file:" URIs
-// that share the same base name (ignoring case) and denote the same
-// file device/inode, according to stat(2).
-func CompareURI(a, b URI) int {
-	if equalURI(a, b) {
-		return 0
-	}
-	if a < b {
-		return -1
-	}
-	return 1
-}
-
-func equalURI(a, b URI) bool {
-	if a == b {
-		return true
-	}
-	// If we have the same URI basename, we may still have the same file URIs.
-	if !strings.EqualFold(path.Base(string(a)), path.Base(string(b))) {
-		return false
-	}
+// SameExistingFile reports whether two spans denote the
+// same existing file by querying the file system.
+func SameExistingFile(a, b URI) bool {
 	fa, err := filename(a)
 	if err != nil {
 		return false
@@ -131,7 +111,6 @@
 	if err != nil {
 		return false
 	}
-	// Stat the files to check if they are equal.
 	infoa, err := os.Stat(filepath.FromSlash(fa))
 	if err != nil {
 		return false