blob: 140c674747d9a76cece3c00bd389102e090d8510 [file] [log] [blame]
// 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 (
"bytes"
"context"
"fmt"
"go/scanner"
"go/token"
"strings"
"golang.org/x/tools/gopls/internal/lsp/protocol"
"golang.org/x/tools/gopls/internal/lsp/source"
)
// 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) {
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 {
// pos is the pos-th character. if the cursor is at the beginning
// of the file, pos is 0. That is, we've only seen characters before pos
// 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 if lit != "" {
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
i, j := from, from
for ; i < len(lower) && j < len(pattern); j++ {
if pattern[j] == lower[i] {
i++
if i >= len(lower) {
return 0
}
}
}
if j < len(pattern) {
return 0
}
return 1
}