gopls/internal/regtest: add regression tests for template diagnostics

Add some additional regressions tests for our loading of templates based
on language ID and/or the configured templateExtensions. Move these
tests to a new regtest package "templates", so that they may be easily
run together.

Fixes golang/vscode-go#1957

Change-Id: Ic83454725e9aec41b3c1f5202bb68d97cc73c839
Reviewed-on: https://go-review.googlesource.com/c/tools/+/378394
Trust: Robert Findley <rfindley@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
diff --git a/gopls/internal/regtest/misc/template_test.go b/gopls/internal/regtest/misc/template_test.go
deleted file mode 100644
index 6d1419a..0000000
--- a/gopls/internal/regtest/misc/template_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright 2021 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package misc
-
-import (
-	"strings"
-	"testing"
-
-	"golang.org/x/tools/internal/lsp/protocol"
-	. "golang.org/x/tools/internal/lsp/regtest"
-)
-
-const filesA = `
--- go.mod --
-module mod.com
-
-go 1.12
--- b.gotmpl --
-{{define "A"}}goo{{end}}
--- a.tmpl --
-{{template "A"}}
-`
-
-func TestSuffixes(t *testing.T) {
-	WithOptions(
-		EditorConfig{
-			AllExperiments: true,
-			Settings: map[string]interface{}{
-				"templateExtensions": []string{"tmpl", "gotmpl"},
-			},
-		},
-	).Run(t, filesA, func(t *testing.T, env *Env) {
-		env.OpenFile("a.tmpl")
-		x := env.RegexpSearch("a.tmpl", `A`)
-		file, pos := env.GoToDefinition("a.tmpl", x)
-		refs := env.References(file, pos)
-		if len(refs) != 2 {
-			t.Fatalf("got %v reference(s), want 2", len(refs))
-		}
-		// make sure we got one from b.gotmpl
-		want := env.Sandbox.Workdir.URI("b.gotmpl")
-		if refs[0].URI != want && refs[1].URI != want {
-			t.Errorf("failed to find reference to %s", shorten(want))
-			for i, r := range refs {
-				t.Logf("%d: URI:%s %v", i, shorten(r.URI), r.Range)
-			}
-		}
-
-		content, npos := env.Hover(file, pos)
-		if pos != npos {
-			t.Errorf("pos? got %v, wanted %v", npos, pos)
-		}
-		if content.Value != "template A defined" {
-			t.Errorf("got %s, wanted 'template A defined", content.Value)
-		}
-	})
-}
-
-// shorten long URIs
-func shorten(fn protocol.DocumentURI) string {
-	if len(fn) <= 20 {
-		return string(fn)
-	}
-	pieces := strings.Split(string(fn), "/")
-	if len(pieces) < 2 {
-		return string(fn)
-	}
-	j := len(pieces)
-	return pieces[j-2] + "/" + pieces[j-1]
-}
-
-// Hover,  SemTok, Diagnose with errors
-// and better coverage
diff --git a/gopls/internal/regtest/template/template_test.go b/gopls/internal/regtest/template/template_test.go
new file mode 100644
index 0000000..6f4ec01
--- /dev/null
+++ b/gopls/internal/regtest/template/template_test.go
@@ -0,0 +1,150 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package template
+
+import (
+	"strings"
+	"testing"
+
+	"golang.org/x/tools/gopls/internal/hooks"
+	"golang.org/x/tools/internal/lsp/protocol"
+	. "golang.org/x/tools/internal/lsp/regtest"
+)
+
+func TestMain(m *testing.M) {
+	Main(m, hooks.Options)
+}
+
+func TestTemplatesFromExtensions(t *testing.T) {
+	const files = `
+-- go.mod --
+module mod.com
+
+go 1.12
+-- hello.tmpl --
+{{range .Planets}}
+Hello {{}} <-- missing body
+{{end}}
+`
+
+	WithOptions(
+		EditorConfig{
+			Settings: map[string]interface{}{
+				"templateExtensions": []string{"tmpl"},
+			},
+		},
+	).Run(t, files, func(t *testing.T, env *Env) {
+		// TODO: can we move this diagnostic onto {{}}?
+		env.Await(env.DiagnosticAtRegexp("hello.tmpl", "()Hello {{}}"))
+		env.WriteWorkspaceFile("hello.tmpl", "{{range .Planets}}\nHello {{.}}\n{{end}}")
+		env.Await(EmptyDiagnostics("hello.tmpl"))
+	})
+}
+
+func TestTemplatesFromLangID(t *testing.T) {
+	const files = `
+-- go.mod --
+module mod.com
+
+go 1.12
+`
+
+	Run(t, files, func(t *testing.T, env *Env) {
+		env.CreateBuffer("hello.tmpl", "")
+		env.Await(
+			OnceMet(
+				env.DoneWithOpen(),
+				NoDiagnostics("hello.tmpl"), // Don't get spurious errors for empty templates.
+			),
+		)
+		env.SetBufferContent("hello.tmpl", "{{range .Planets}}\nHello {{}}\n{{end}}")
+		env.Await(env.DiagnosticAtRegexp("hello.tmpl", "()Hello {{}}"))
+		env.RegexpReplace("hello.tmpl", "{{}}", "{{.}}")
+		env.Await(EmptyOrNoDiagnostics("hello.tmpl"))
+	})
+}
+
+func TestClosingTemplatesMakesDiagnosticsDisappear(t *testing.T) {
+	const files = `
+-- go.mod --
+module mod.com
+
+go 1.12
+-- hello.tmpl --
+{{range .Planets}}
+Hello {{}} <-- missing body
+{{end}}
+`
+
+	Run(t, files, func(t *testing.T, env *Env) {
+		env.OpenFile("hello.tmpl")
+		env.Await(env.DiagnosticAtRegexp("hello.tmpl", "()Hello {{}}"))
+		// Since we don't have templateExtensions configured, closing hello.tmpl
+		// should make its diagnostics disappear.
+		env.CloseBuffer("hello.tmpl")
+		env.Await(EmptyDiagnostics("hello.tmpl"))
+	})
+}
+
+func TestMultipleSuffixes(t *testing.T) {
+	const files = `
+-- go.mod --
+module mod.com
+
+go 1.12
+-- b.gotmpl --
+{{define "A"}}goo{{end}}
+-- a.tmpl --
+{{template "A"}}
+`
+
+	WithOptions(
+		EditorConfig{
+			Settings: map[string]interface{}{
+				"templateExtensions": []string{"tmpl", "gotmpl"},
+			},
+		},
+	).Run(t, files, func(t *testing.T, env *Env) {
+		env.OpenFile("a.tmpl")
+		x := env.RegexpSearch("a.tmpl", `A`)
+		file, pos := env.GoToDefinition("a.tmpl", x)
+		refs := env.References(file, pos)
+		if len(refs) != 2 {
+			t.Fatalf("got %v reference(s), want 2", len(refs))
+		}
+		// make sure we got one from b.gotmpl
+		want := env.Sandbox.Workdir.URI("b.gotmpl")
+		if refs[0].URI != want && refs[1].URI != want {
+			t.Errorf("failed to find reference to %s", shorten(want))
+			for i, r := range refs {
+				t.Logf("%d: URI:%s %v", i, shorten(r.URI), r.Range)
+			}
+		}
+
+		content, npos := env.Hover(file, pos)
+		if pos != npos {
+			t.Errorf("pos? got %v, wanted %v", npos, pos)
+		}
+		if content.Value != "template A defined" {
+			t.Errorf("got %s, wanted 'template A defined", content.Value)
+		}
+	})
+}
+
+// shorten long URIs
+func shorten(fn protocol.DocumentURI) string {
+	if len(fn) <= 20 {
+		return string(fn)
+	}
+	pieces := strings.Split(string(fn), "/")
+	if len(pieces) < 2 {
+		return string(fn)
+	}
+	j := len(pieces)
+	return pieces[j-2] + "/" + pieces[j-1]
+}
+
+// Hover,  SemTok, Diagnose with errors
+// and better coverage
diff --git a/internal/lsp/fake/editor.go b/internal/lsp/fake/editor.go
index fe7d4b5..e47a1bf 100644
--- a/internal/lsp/fake/editor.go
+++ b/internal/lsp/fake/editor.go
@@ -9,6 +9,7 @@
 	"context"
 	"fmt"
 	"os"
+	"path"
 	"path/filepath"
 	"regexp"
 	"strings"
@@ -114,6 +115,14 @@
 	// Whether to edit files with windows line endings.
 	WindowsLineEndings bool
 
+	// Map of language ID -> regexp to match, used to set the file type of new
+	// buffers. Applied as an overlay on top of the following defaults:
+	//  "go" -> ".*\.go"
+	//  "go.mod" -> "go\.mod"
+	//  "go.sum" -> "go\.sum"
+	//  "gotmpl" -> ".*tmpl"
+	FileAssociations map[string]string
+
 	// Settings holds arbitrary additional settings to apply to the gopls config.
 	// TODO(rfindley): replace existing EditorConfig fields with Settings.
 	Settings map[string]interface{}
@@ -378,21 +387,6 @@
 	return e.createBuffer(ctx, path, false, content)
 }
 
-func textDocumentItem(wd *Workdir, buf buffer) protocol.TextDocumentItem {
-	uri := wd.URI(buf.path)
-	languageID := ""
-	if strings.HasSuffix(buf.path, ".go") {
-		// TODO: what about go.mod files? What is their language ID?
-		languageID = "go"
-	}
-	return protocol.TextDocumentItem{
-		URI:        uri,
-		LanguageID: languageID,
-		Version:    int32(buf.version),
-		Text:       buf.text(),
-	}
-}
-
 // CreateBuffer creates a new unsaved buffer corresponding to the workdir path,
 // containing the given textual content.
 func (e *Editor) CreateBuffer(ctx context.Context, path, content string) error {
@@ -410,7 +404,13 @@
 	e.mu.Lock()
 	defer e.mu.Unlock()
 	e.buffers[path] = buf
-	item := textDocumentItem(e.sandbox.Workdir, buf)
+
+	item := protocol.TextDocumentItem{
+		URI:        e.sandbox.Workdir.URI(buf.path),
+		LanguageID: e.languageID(buf.path),
+		Version:    int32(buf.version),
+		Text:       buf.text(),
+	}
 
 	if e.Server != nil {
 		if err := e.Server.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{
@@ -425,6 +425,29 @@
 	return nil
 }
 
+var defaultFileAssociations = map[string]*regexp.Regexp{
+	"go":     regexp.MustCompile(`^.*\.go$`), // '$' is important: don't match .gotmpl!
+	"go.mod": regexp.MustCompile(`^go\.mod$`),
+	"go.sum": regexp.MustCompile(`^go\.sum$`),
+	"gotmpl": regexp.MustCompile(`^.*tmpl$`),
+}
+
+func (e *Editor) languageID(p string) string {
+	base := path.Base(p)
+	for lang, re := range e.Config.FileAssociations {
+		re := regexp.MustCompile(re)
+		if re.MatchString(base) {
+			return lang
+		}
+	}
+	for lang, re := range defaultFileAssociations {
+		if re.MatchString(base) {
+			return lang
+		}
+	}
+	return ""
+}
+
 // lines returns line-ending agnostic line representation of content.
 func lines(content string) []string {
 	lines := strings.Split(content, "\n")