diff --git a/internal/lsp/source/completion.go b/internal/lsp/source/completion.go index ebde3a2d3..33f1fcd5d 100644 --- a/internal/lsp/source/completion.go +++ b/internal/lsp/source/completion.go @@ -10,6 +10,7 @@ import ( "go/token" "go/types" "strings" + "time" "golang.org/x/tools/go/ast/astutil" "golang.org/x/tools/internal/imports" @@ -122,6 +123,12 @@ const ( // lowScore indicates an irrelevant or not useful completion item. lowScore float64 = 0.01 + + // completionBudget is the soft latency goal for completion requests. Most + // requests finish in a couple milliseconds, but in some cases deep + // completions can take much longer. As we use up our budget we dynamically + // reduce the search scope to ensure we return timely results. + completionBudget = 100 * time.Millisecond ) // matcher matches a candidate's label against the user input. @@ -202,6 +209,10 @@ type completer struct { // mapper converts the positions in the file from which the completion originated. mapper *protocol.ColumnMapper + + // startTime is when we started processing this completion request. It does + // not include any time the request spent in the queue. + startTime time.Time } type compLitInfo struct { @@ -259,7 +270,7 @@ func (c *completer) setSurrounding(ident *ast.Ident) { // Fuzzy matching shares the "useDeepCompletions" config flag, so if deep completions // are enabled then also enable fuzzy matching. - if c.deepState.enabled { + if c.deepState.maxDepth != 0 { c.matcher = fuzzy.NewMatcher(c.surrounding.Prefix(), fuzzy.Symbol) } else { c.matcher = prefixMatcher(strings.ToLower(c.surrounding.Prefix())) @@ -306,6 +317,12 @@ func (c *completer) found(obj types.Object, score float64, imp *imports.ImportIn c.seen[obj] = true } + // If we are running out of budgeted time we must limit our search for deep + // completion candidates. + if c.shouldPrune() { + return + } + cand := candidate{ obj: obj, score: score, @@ -375,6 +392,8 @@ func Completion(ctx context.Context, view View, f GoFile, pos protocol.Position, ctx, done := trace.StartSpan(ctx, "source.Completion") defer done() + startTime := time.Now() + pkg, err := f.GetPackage(ctx) if err != nil { return nil, nil, err @@ -443,9 +462,12 @@ func Completion(ctx context.Context, view View, f GoFile, pos protocol.Position, matcher: prefixMatcher(""), methodSetCache: make(map[methodSetKey]*types.MethodSet), mapper: m, + startTime: startTime, } - c.deepState.enabled = opts.DeepComplete + if opts.DeepComplete { + c.deepState.maxDepth = -1 + } // Set the filter surrounding. if ident, ok := path[0].(*ast.Ident); ok { diff --git a/internal/lsp/source/deep_completion.go b/internal/lsp/source/deep_completion.go index 2ef01a841..3e2e6e8a6 100644 --- a/internal/lsp/source/deep_completion.go +++ b/internal/lsp/source/deep_completion.go @@ -7,6 +7,7 @@ package source import ( "go/types" "strings" + "time" ) // Limit deep completion results because in most cases there are too many @@ -17,8 +18,9 @@ const MaxDeepCompletions = 3 // "deep completion" refers to searching into objects' fields and methods to // find more completion candidates. type deepCompletionState struct { - // enabled is true if deep completions are enabled. - enabled bool + // maxDepth limits the deep completion search depth. 0 means + // disabled and -1 means unlimited. + maxDepth int // chain holds the traversal path as we do a depth-first search through // objects' members looking for exact type matches. @@ -31,6 +33,10 @@ type deepCompletionState struct { // highScores tracks the highest deep candidate scores we have found // so far. This is used to avoid work for low scoring deep candidates. highScores [MaxDeepCompletions]float64 + + // candidateCount is the count of unique deep candidates encountered + // so far. + candidateCount int } // push pushes obj onto our search stack. @@ -85,10 +91,51 @@ func (c *completer) inDeepCompletion() bool { return len(c.deepState.chain) > 0 } +// shouldPrune returns whether we should prune the current deep +// candidate search to reduce the overall search scope. The +// maximum search depth is reduced gradually as we use up our +// completionBudget. +func (c *completer) shouldPrune() bool { + if !c.inDeepCompletion() { + return false + } + + c.deepState.candidateCount++ + + // Check our remaining budget every 1000 candidates. + if c.deepState.candidateCount%1000 == 0 { + spent := float64(time.Since(c.startTime)) / float64(completionBudget) + + switch { + case spent >= 0.90: + // We are close to exhausting our budget. Disable deep completions. + c.deepState.maxDepth = 0 + case spent >= 0.75: + // We are running out of budget, reduce max depth again. + c.deepState.maxDepth = 2 + case spent >= 0.5: + // We have used half our budget, reduce max depth again. + c.deepState.maxDepth = 3 + case spent >= 0.25: + // We have used a good chunk of our budget, so start limiting our search. + // By default the search depth is unlimited, so this limit, while still + // generous, is normally a huge reduction in search scope that will result + // in our search completing very soon. + c.deepState.maxDepth = 4 + } + } + + if c.deepState.maxDepth >= 0 { + return len(c.deepState.chain) >= c.deepState.maxDepth + } + + return false +} + // deepSearch searches through obj's subordinate objects for more // completion items. func (c *completer) deepSearch(obj types.Object) { - if !c.deepState.enabled { + if c.deepState.maxDepth == 0 { return }