Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gopls/internal/golang/completion: fix completion behavior when the source has syntax errors #477

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions gopls/internal/golang/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,9 @@ type completionContext struct {

// packageCompletion is true if we are completing a package name.
packageCompletion bool

// removeBeforePeriod is true if we are removing content before the period.
removeBeforePeriod bool
}

// A Selection represents the cursor position and surrounding identifier.
Expand Down Expand Up @@ -734,6 +737,12 @@ func (c *completer) containingIdent(src []byte) *ast.Ident {
// is a keyword. This improves completion after an "accidental
// keyword", e.g. completing to "variance" in "someFunc(var<>)".
return fakeIdent
} else if c.isCompletionAllowed() && tkn == token.IDENT {
// Use manually extracted token even when syntax is incomplete
// or contains errors. This provides better developer experience.
// Only for certain conditions: when completion is allowed and
// the token is an IDENT.
return fakeIdent
}

return nil
Expand All @@ -743,6 +752,7 @@ func (c *completer) containingIdent(src []byte) *ast.Ident {
func (c *completer) scanToken(contents []byte) (token.Pos, token.Token, string) {
tok := c.pkg.FileSet().File(c.pos)

var prdPos token.Pos
var s scanner.Scanner
s.Init(tok, contents, nil, 0)
for {
Expand All @@ -751,6 +761,14 @@ func (c *completer) scanToken(contents []byte) (token.Pos, token.Token, string)
return token.NoPos, token.ILLEGAL, ""
}

if tkn == token.PERIOD {
prdPos = tknPos
}
// Set removeBeforePeriod to true if cursor is:
// - Right after the period (e.g., "foo.")
// - One or more characters after the period (e.g., "foo.b", "foo.bar")
c.completionContext.removeBeforePeriod = tknPos == prdPos || tknPos == prdPos+1

if len(lit) > 0 && tknPos <= c.pos && c.pos <= tknPos+token.Pos(len(lit)) {
return tknPos, tkn, lit
}
Expand Down Expand Up @@ -3283,3 +3301,25 @@ func forEachPackageMember(content []byte, f func(tok token.Token, id *ast.Ident,
}
}
}

// isCompletionAllowed checks whether completion is allowed even
// if the current source is incomplete or contains syntax errors.
func (c *completer) isCompletionAllowed() bool {
// Examples
//
// BlockStmt:
// minimum, maximum := 0, 0
// minimum, max
// BadExpr:
// math.Sqrt(,0)
// math.Ab
// CallExpr:
// value := 0
// fmt.Println("value:" val)
switch c.path[0].(type) {
case *ast.BlockStmt, *ast.BadExpr, *ast.CallExpr:
return true
default:
return false
}
}
9 changes: 9 additions & 0 deletions gopls/internal/golang/completion/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ func (c *completer) item(ctx context.Context, cand candidate) (CompletionItem, e
}
}

// If source contains syntax errors and removeBeforePeriod is true,
// update the label and insert text with the content after the period.
if c.isCompletionAllowed() && c.completionContext.removeBeforePeriod {
_, afterPeriod, found := strings.Cut(insert, ".")
if found {
label = afterPeriod
insert = afterPeriod
}
}
snip.WriteText(insert)

switch obj := obj.(type) {
Expand Down
187 changes: 186 additions & 1 deletion gopls/internal/test/integration/completion/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ func F3[K comparable, V any](map[K]V, chan V) {}
}

func TestPackageMemberCompletionAfterSyntaxError(t *testing.T) {
// Update: not broken anymore, fixed.
// This test documents the current broken behavior due to golang/go#58833.
const src = `
-- go.mod --
Expand Down Expand Up @@ -623,7 +624,7 @@ func main() {
// (In VSCode, "Abs" wrongly appears in the completion menu.)
// This is a consequence of poor error recovery in the parser
// causing "math.Ldex" to become a BadExpr.
want := "package main\n\nimport \"math\"\n\nfunc main() {\n\tmath.Sqrt(,0)\n\tmath.Ldexmath.Abs(${1:})\n}\n"
want := "package main\n\nimport \"math\"\n\nfunc main() {\n\tmath.Sqrt(,0)\n\tmath.Ldexp(${1:})\n}\n"
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("unimported completion (-want +got):\n%s", diff)
}
Expand Down Expand Up @@ -999,3 +1000,187 @@ func Join() {}
}
})
}

func TestCompletionAfterSyntaxError(t *testing.T) {
const files = `
-- go.mod --
module mod.com

go 1.14

-- test1.go --
package main

func test1() {
minimum := 0
maximum := 0
minimum, max
}

-- test2.go --
package main

import "math"

func test2() {
math.Sqrt(0), abs
}

-- test3.go --
package main

import "math"

func test3() {
math.Sqrt(0), math.ab
}

-- test4.go --
package main

type person struct {
name string
age int
}

func test4() {
p := person{}
p.name, age
}

-- test5.go --
package main

type person struct {
name string
age int
}

func test5() {
p := person{}
p.name, p.ag
}

-- test6.go --
package main

import "math"

func test6() {
math.Sqrt(,0)
abs
}

-- test7.go --
package main

import "math"

func test7() {
math.Sqrt(,0)
math.ab
}

-- test8.go --
package main

func test8() {
minimum := 0
fmt.Println("minimum:" min)
}

-- test9.go --
package main

type person struct {
name string
age int
}

func test9() {
p := person{}
fmt.Println("name:" p.na)
}
`
tests := []struct {
name string
file string
re string
want string
}{
{
name: "test 1 variable completion after comma",
file: "test1.go",
re: ", max()",
want: "package main\n\nfunc test1() {\n\tminimum := 0\n\tmaximum := 0\n\tminimum, maximum\n}\n\n",
},
{
name: "test 2 package member completion after comma",
file: "test2.go",
re: "abs()",
want: "package main\n\nimport \"math\"\n\nfunc test2() {\n\tmath.Sqrt(0), math.Abs(${1:})\n}\n\n",
},
{
name: "test 3 package member completion after comma with period",
file: "test3.go",
re: "math.ab()",
want: "package main\n\nimport \"math\"\n\nfunc test3() {\n\tmath.Sqrt(0), math.Abs(${1:})\n}\n\n",
},
{
name: "test 4 struct field completion after comma",
file: "test4.go",
re: ", age()",
want: "package main\n\ntype person struct {\n\tname string\n\tage int\n}\n\nfunc test4() {\n\tp := person{}\n\tp.name, p.age\n}\n\n",
},
{
name: "test 5 struct field completion after comma with period",
file: "test5.go",
re: "p.ag()",
want: "package main\n\ntype person struct {\n\tname string\n\tage int\n}\n\nfunc test5() {\n\tp := person{}\n\tp.name, p.age\n}\n\n",
},
{
name: "test 6 package member completion after BadExpr",
file: "test6.go",
re: "abs()",
want: "package main\n\nimport \"math\"\n\nfunc test6() {\n\tmath.Sqrt(,0)\n\tmath.Abs(${1:})\n}\n\n",
},
{
name: "test 7 package member completion after BadExpr with period",
file: "test7.go",
re: "math.ab()",
want: "package main\n\nimport \"math\"\n\nfunc test7() {\n\tmath.Sqrt(,0)\n\tmath.Abs(${1:})\n}\n\n",
},
{
name: "test 8 variable completion after missing comma",
file: "test8.go",
re: ":\" min()",
want: "package main\n\nfunc test8() {\n\tminimum := 0\n\tfmt.Println(\"minimum:\" minimum)\n}\n\n",
},
{
name: "test 9 struct field completion after missing comma with period",
file: "test9.go",
re: "p.na()",
want: "package main\n\ntype person struct {\n\tname string\n\tage int\n}\n\nfunc test9() {\n\tp := person{}\n\tfmt.Println(\"name:\" p.name)\n}\n",
},
}

Run(t, files, func(t *testing.T, env *Env) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
env.OpenFile(test.file)
env.Await(env.DoneWithOpen())
loc := env.RegexpSearch(test.file, test.re)
completions := env.Completion(loc)
if len(completions.Items) == 0 {
t.Fatalf("no completion items")
}
env.AcceptCompletion(loc, completions.Items[0])
env.Await(env.DoneWithChange())
got := env.BufferText(test.file)
if diff := cmp.Diff(test.want, got); diff != "" {
t.Errorf("incorrect completion (-want +got):\n%s", diff)
}
})
}
})
}