internal/lsp/template: implement completions for template files

The suggesteds completions are based on a superficial parse of all
the template files in the package. The code errs on the side of too
many suggestions.

Change-Id: If956ad548327be25517878aab70802cf62d42a50
Reviewed-on: https://go-review.googlesource.com/c/tools/+/341649
Trust: Peter Weinberger <pjw@google.com>
Run-TryBot: Peter Weinberger <pjw@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go
index 4bec6cd..4523d34 100644
--- a/internal/lsp/completion.go
+++ b/internal/lsp/completion.go
@@ -33,7 +33,12 @@
 	case source.Mod:
 		candidates, surrounding = nil, nil
 	case source.Tmpl:
-		candidates, surrounding, err = template.Completion(ctx, snapshot, fh, params.Position, params.Context)
+		var cl *protocol.CompletionList
+		cl, err = template.Completion(ctx, snapshot, fh, params.Position, params.Context)
+		if err != nil {
+			break // use common error handling, candidates==nil
+		}
+		return cl, nil
 	}
 	if err != nil {
 		event.Error(ctx, "no completions found", err, tag.Position.Of(params.Position))
diff --git a/internal/lsp/template/completion.go b/internal/lsp/template/completion.go
index a593bf5..4ec7623 100644
--- a/internal/lsp/template/completion.go
+++ b/internal/lsp/template/completion.go
@@ -5,17 +5,294 @@
 package template
 
 import (
+	"bytes"
 	"context"
 	"fmt"
+	"go/scanner"
+	"go/token"
+	"strings"
 
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
-	"golang.org/x/tools/internal/lsp/source/completion"
 )
 
-func Completion(ctx context.Context, snapshot source.Snapshot, fh source.VersionedFileHandle, pos protocol.Position, context protocol.CompletionContext) ([]completion.CompletionItem, *completion.Selection, error) {
+// information needed for completion
+type completer struct {
+	p      *Parsed
+	pos    protocol.Position
+	offset int // offset of the start of the Token
+	ctx    protocol.CompletionContext
+	syms   map[string]symbol
+}
+
+func Completion(ctx context.Context, snapshot source.Snapshot, fh source.VersionedFileHandle, pos protocol.Position, context protocol.CompletionContext) (*protocol.CompletionList, error) {
 	if skipTemplates(snapshot) {
-		return nil, nil, nil
+		return nil, nil
 	}
-	return nil, nil, fmt.Errorf("implement template completion")
+	all := New(snapshot.Templates())
+	var start int // the beginning of the Token (completed or not)
+	syms := make(map[string]symbol)
+	var p *Parsed
+	for fn, fc := range all.files {
+		// collect symbols from all template files
+		filterSyms(syms, fc.symbols)
+		if fn.Filename() != fh.URI().Filename() {
+			continue
+		}
+		if start = inTemplate(fc, pos); start == -1 {
+			return nil, nil
+		}
+		p = fc
+	}
+	if p == nil {
+		// this cannot happen unless the search missed a template file
+		return nil, fmt.Errorf("%s not found", fh.FileIdentity().URI.Filename())
+	}
+	c := completer{
+		p:      p,
+		pos:    pos,
+		offset: start + len(Left),
+		ctx:    context,
+		syms:   syms,
+	}
+	return c.complete()
+}
+
+func filterSyms(syms map[string]symbol, ns []symbol) {
+	for _, xsym := range ns {
+		switch xsym.kind {
+		case protocol.Method, protocol.Package, protocol.Boolean, protocol.Namespace,
+			protocol.Function:
+			syms[xsym.name] = xsym // we don't care which symbol we get
+		case protocol.Variable:
+			if xsym.name != "dot" {
+				syms[xsym.name] = xsym
+			}
+		case protocol.Constant:
+			if xsym.name == "nil" {
+				syms[xsym.name] = xsym
+			}
+		}
+	}
+}
+
+// return the starting position of the enclosing token, or -1 if none
+func inTemplate(fc *Parsed, pos protocol.Position) int {
+	// 1. pos might be in a Token, return tk.Start
+	// 2. pos might be after an elided but before a Token, return elided
+	// 3. return -1 for false
+	offset := fc.FromPosition(pos)
+	// this could be a binary search, as the tokens are ordered
+	for _, tk := range fc.tokens {
+		if tk.Start <= offset && offset < tk.End {
+			return tk.Start
+		}
+	}
+	for _, x := range fc.elided {
+		if x > offset {
+			// fc.elided is sorted
+			break
+		}
+		// If the interval [x,offset] does not contain Left or Right
+		// then provide completions. (do we need the test for Right?)
+		if !bytes.Contains(fc.buf[x:offset], []byte(Left)) && !bytes.Contains(fc.buf[x:offset], []byte(Right)) {
+			return x
+		}
+	}
+	return -1
+}
+
+var (
+	keywords = []string{"if", "with", "else", "block", "range", "template", "end}}", "end"}
+	globals  = []string{"and", "call", "html", "index", "slice", "js", "len", "not", "or",
+		"urlquery", "printf", "println", "print", "eq", "ne", "le", "lt", "ge", "gt"}
+)
+
+// find the completions. start is the offset of either the Token enclosing pos, or where
+// the incomplete token starts.
+// The error return is always nil.
+func (c *completer) complete() (*protocol.CompletionList, error) {
+	ans := &protocol.CompletionList{IsIncomplete: true, Items: []protocol.CompletionItem{}}
+	start := c.p.FromPosition(c.pos)
+	sofar := c.p.buf[c.offset:start]
+	if len(sofar) == 0 || sofar[len(sofar)-1] == ' ' || sofar[len(sofar)-1] == '\t' {
+		return ans, nil
+	}
+	// sofar could be parsed by either c.analyzer() or scan(). The latter is precise
+	// and slower, but fast enough
+	words := scan(sofar)
+	// 1. if pattern starts $, show variables
+	// 2. if pattern starts ., show methods (and . by itself?)
+	// 3. if len(words) == 1, show firstWords (but if it were a |, show functions and globals)
+	// 4. ...? (parenthetical expressions, arguments, ...) (packages, namespaces, nil?)
+	if len(words) == 0 {
+		return nil, nil // if this happens, why were we called?
+	}
+	pattern := string(words[len(words)-1])
+	if pattern[0] == '$' {
+		// should we also return a raw "$"?
+		for _, s := range c.syms {
+			if s.kind == protocol.Variable && weakMatch(s.name, pattern) > 0 {
+				ans.Items = append(ans.Items, protocol.CompletionItem{
+					Label:  s.name,
+					Kind:   protocol.VariableCompletion,
+					Detail: "Variable",
+				})
+			}
+		}
+		return ans, nil
+	}
+	if pattern[0] == '.' {
+		for _, s := range c.syms {
+			if s.kind == protocol.Method && weakMatch("."+s.name, pattern) > 0 {
+				ans.Items = append(ans.Items, protocol.CompletionItem{
+					Label:  s.name,
+					Kind:   protocol.MethodCompletion,
+					Detail: "Method/member",
+				})
+			}
+		}
+		return ans, nil
+	}
+	// could we get completion attempts in strings or numbers, and if so, do we care?
+	// globals
+	for _, kw := range globals {
+		if weakMatch(kw, string(pattern)) != 0 {
+			ans.Items = append(ans.Items, protocol.CompletionItem{
+				Label:  kw,
+				Kind:   protocol.KeywordCompletion,
+				Detail: "Function",
+			})
+		}
+	}
+	// and functions
+	for _, s := range c.syms {
+		if s.kind == protocol.Function && weakMatch(s.name, pattern) != 0 {
+			ans.Items = append(ans.Items, protocol.CompletionItem{
+				Label:  s.name,
+				Kind:   protocol.FunctionCompletion,
+				Detail: "Function",
+			})
+		}
+	}
+	// keywords if we're at the beginning
+	if len(words) <= 1 || len(words[len(words)-2]) == 1 && words[len(words)-2][0] == '|' {
+		for _, kw := range keywords {
+			if weakMatch(kw, string(pattern)) != 0 {
+				ans.Items = append(ans.Items, protocol.CompletionItem{
+					Label:  kw,
+					Kind:   protocol.KeywordCompletion,
+					Detail: "keyword",
+				})
+			}
+		}
+	}
+	return ans, nil
+}
+
+// someday think about comments, strings, backslashes, etc
+// this would repeat some of the template parsing, but because the user is typing
+// there may be no parse tree here.
+// (go/scanner will report 2 tokens for $a, as $ is not a legal go identifier character)
+// (go/scanner is about 2.7 times more expensive)
+func (c *completer) analyze(buf []byte) [][]byte {
+	// we want to split on whitespace and before dots
+	var working []byte
+	var ans [][]byte
+	for _, ch := range buf {
+		if ch == '.' && len(working) > 0 {
+			ans = append(ans, working)
+			working = []byte{'.'}
+			continue
+		}
+		if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
+			if len(working) > 0 {
+				ans = append(ans, working)
+				working = []byte{}
+				continue
+			}
+		}
+		working = append(working, ch)
+	}
+	if len(working) > 0 {
+		ans = append(ans, working)
+	}
+	ch := buf[len(buf)-1]
+	if ch == ' ' || ch == '\t' {
+		// avoid completing on whitespace
+		ans = append(ans, []byte{ch})
+	}
+	return ans
+}
+
+// version of c.analyze that uses go/scanner.
+func scan(buf []byte) []string {
+	fset := token.NewFileSet()
+	fp := fset.AddFile("", -1, len(buf))
+	var sc scanner.Scanner
+	sc.Init(fp, buf, func(pos token.Position, msg string) {}, scanner.ScanComments)
+	ans := make([]string, 0, 10) // preallocating gives a measurable savings
+	for {
+		_, tok, lit := sc.Scan() // tok is an int
+		if tok == token.EOF {
+			break // done
+		} else if tok == token.SEMICOLON && lit == "\n" {
+			continue // don't care, but probably can't happen
+		} else if tok == token.PERIOD {
+			ans = append(ans, ".") // lit is empty
+		} else if tok == token.IDENT && len(ans) > 0 && ans[len(ans)-1] == "." {
+			ans[len(ans)-1] = "." + lit
+		} else if tok == token.IDENT && len(ans) > 0 && ans[len(ans)-1] == "$" {
+			ans[len(ans)-1] = "$" + lit
+		} else {
+			ans = append(ans, lit)
+		}
+	}
+	return ans
+}
+
+// pattern is what the user has typed
+func weakMatch(choice, pattern string) float64 {
+	lower := strings.ToLower(choice)
+	// for now, use only lower-case everywhere
+	pattern = strings.ToLower(pattern)
+	// The first char has to match
+	if pattern[0] != lower[0] {
+		return 0
+	}
+	// If they start with ., then the second char has to match
+	from := 1
+	if pattern[0] == '.' {
+		if len(pattern) < 2 {
+			return 1 // pattern just a ., so it matches
+		}
+		if pattern[1] != lower[1] {
+			return 0
+		}
+		from = 2
+	}
+	// check that all the characters of pattern occur as a subsequence of choice
+	for i, j := from, from; j < len(pattern); j++ {
+		if pattern[j] == lower[i] {
+			i++
+			if i >= len(lower) {
+				return 0
+			}
+		}
+	}
+	return 1
+}
+
+// for debug printing
+func strContext(c protocol.CompletionContext) string {
+	switch c.TriggerKind {
+	case protocol.Invoked:
+		return "invoked"
+	case protocol.TriggerCharacter:
+		return fmt.Sprintf("triggered(%s)", c.TriggerCharacter)
+	case protocol.TriggerForIncompleteCompletions:
+		// gopls doesn't seem to handle these explicitly anywhere
+		return "incomplete"
+	}
+	return fmt.Sprintf("?%v", c)
 }
diff --git a/internal/lsp/template/completion_test.go b/internal/lsp/template/completion_test.go
new file mode 100644
index 0000000..7d17ab1
--- /dev/null
+++ b/internal/lsp/template/completion_test.go
@@ -0,0 +1,98 @@
+// 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 template
+
+import (
+	"log"
+	"sort"
+	"strings"
+	"testing"
+
+	"golang.org/x/tools/internal/lsp/protocol"
+)
+
+func init() {
+	log.SetFlags(log.Lshortfile)
+}
+
+type tparse struct {
+	marked string   // ^ shows where to ask for completions. (The user just typed the following character.)
+	wanted []string // expected completions
+}
+
+// Test completions in templates that parse enough (if completion needs symbols)
+func TestParsed(t *testing.T) {
+	var tests = []tparse{
+		{"{{^if}}", []string{"index", "if"}},
+		{"{{if .}}{{^e {{end}}", []string{"eq", "end}}", "else", "end"}},
+		{"{{foo}}{{^f", []string{"foo"}},
+		{"{{^$}}", []string{"$"}},
+		{"{{$x:=4}}{{^$", []string{"$x"}},
+		{"{{$x:=4}}{{$^ ", []string{}},
+		{"{{len .Modified}}{{^.Mo", []string{"Modified"}},
+		{"{{len .Modified}}{{.m^f", []string{"Modified"}},
+		{"{{^$ }}", []string{"$"}},
+		{"{{$a =3}}{{^$", []string{"$a"}},
+		// .two is not good here: fix someday
+		{`{{.Modified}}{{^.{{if $.one.two}}xxx{{end}}`, []string{"Modified", "one", "two"}},
+		{`{{.Modified}}{{.^o{{if $.one.two}}xxx{{end}}`, []string{"one"}},
+		{"{{.Modiifed}}{{.one.^t{{if $.one.two}}xxx{{end}}", []string{"two"}},
+		{`{{block "foo" .}}{{^i`, []string{"index", "if"}},
+		{"{{i^n{{Internal}}", []string{"index", "Internal", "if"}},
+		// simple number has no completions
+		{"{{4^e", []string{}},
+		// simple string has no completions
+		{"{{`^e", []string{}},
+		{"{{`No ^i", []string{}}, // example of why go/scanner is used
+		{"{{xavier}}{{12. ^x", []string{"xavier"}},
+	}
+	for _, tx := range tests {
+		c := testCompleter(t, tx)
+		ans, err := c.complete()
+		if err != nil {
+			t.Fatal(err)
+		}
+		var v []string
+		for _, a := range ans.Items {
+			v = append(v, a.Label)
+		}
+		if len(v) != len(tx.wanted) {
+			t.Errorf("%q: got %v, wanted %v", tx.marked, v, tx.wanted)
+			continue
+		}
+		sort.Strings(tx.wanted)
+		sort.Strings(v)
+		for i := 0; i < len(v); i++ {
+			if tx.wanted[i] != v[i] {
+				t.Errorf("%q at %d: got %v, wanted %v", tx.marked, i, v, tx.wanted)
+				break
+			}
+		}
+	}
+}
+
+func testCompleter(t *testing.T, tx tparse) *completer {
+	t.Helper()
+	col := strings.Index(tx.marked, "^") + 1
+	offset := strings.LastIndex(tx.marked[:col], string(Left))
+	if offset < 0 {
+		t.Fatalf("no {{ before ^: %q", tx.marked)
+	}
+	buf := strings.Replace(tx.marked, "^", "", 1)
+	p := parseBuffer([]byte(buf))
+	if p.ParseErr != nil {
+		log.Printf("%q: %v", tx.marked, p.ParseErr)
+	}
+	syms := make(map[string]symbol)
+	filterSyms(syms, p.symbols)
+	c := &completer{
+		p:      p,
+		pos:    protocol.Position{Line: 0, Character: uint32(col)},
+		offset: offset + len(Left),
+		ctx:    protocol.CompletionContext{TriggerKind: protocol.Invoked},
+		syms:   syms,
+	}
+	return c
+}
diff --git a/internal/lsp/template/parse.go b/internal/lsp/template/parse.go
index 25c80b5..0ad8fab 100644
--- a/internal/lsp/template/parse.go
+++ b/internal/lsp/template/parse.go
@@ -357,6 +357,10 @@
 // FromPosition translates a protocol.Position into an offset into the template
 func (p *Parsed) FromPosition(x protocol.Position) int {
 	l, c := int(x.Line), int(x.Character)
+	if l >= len(p.nls) || p.nls[l]+1 >= len(p.buf) {
+		// paranoia to avoid panic. return the largest offset
+		return len(p.buf)
+	}
 	line := p.buf[p.nls[l]+1:]
 	cnt := 0
 	for w := range string(line) {