diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 4bec6cda99..4523d34e2a 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -33,7 +33,12 @@ func (s *Server) completion(ctx context.Context, params *protocol.CompletionPara 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 a593bf5732..4ec7623ba9 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) { - if skipTemplates(snapshot) { - return nil, nil, nil - } - return nil, nil, fmt.Errorf("implement template completion") +// 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 + } + 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 0000000000..7d17ab1eba --- /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 25c80b596e..0ad8fabdcb 100644 --- a/internal/lsp/template/parse.go +++ b/internal/lsp/template/parse.go @@ -357,6 +357,10 @@ func (p *Parsed) Range(x, length int) protocol.Range { // 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) {