Skip to content

Commit d36555e

Browse files
authored
Package ref import suggestions in Rego (#1698)
Rewrite the last Go completion provider in Rego! The next step is to get tid of the now pointless Manager/Policy Go completion handlers, and integrate Rego completions in the main Rego router. This is a little more involved though, as the input format currently used for completion providers is quite different from what other Rego handlers are provided. Which in itself is a good reason for why we'll want to fix it... but will follow up with another PR for that later, so that this can be reviewed in isolation. Signed-off-by: Anders Eknert <anders@eknert.com>
1 parent ceb890b commit d36555e

12 files changed

Lines changed: 232 additions & 458 deletions

File tree

bundle/regal/lsp/completion/main.rego

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
# under regal.lsp.completion.providers
66
package regal.lsp.completion
77

8+
import data.regal.util
9+
10+
# regal ignore:pointless-import
11+
import data.regal.lsp.completion.location
12+
813
# METADATA
914
# entrypoint: true
1015
result["response"] := items
@@ -13,6 +18,28 @@ result["response"] := items
1318
# description: main entry point for completion suggestions
1419
# entrypoint: true
1520
items contains object.union(completion, {"_regal": {"provider": provider}}) if {
21+
# exit early if caret position is inside a comment. We currently don't have any provider
22+
# where doing completions inside of a comment makes sense. Behavior is also editor-specific:
23+
# - Zed: always on, with no way to disable
24+
# - VSCode: disabled but can be enabled with "editor.quickSuggestions.comments" setting
25+
not inside_comment
26+
1627
some provider, completion
1728
data.regal.lsp.completion.providers[provider].items[completion]
1829
}
30+
31+
# METADATA
32+
# description: |
33+
# checks if the current position is inside a comment
34+
inside_comment if {
35+
position := location.to_position(input.regal.context.location)
36+
37+
# avoid unmarshalling every comment location but only one that starts
38+
# with the line number of the current position
39+
line := sprintf("%d:", [position.line + 1])
40+
41+
some comment in data.workspace.parsed[input.regal.file.uri].comments
42+
43+
startswith(comment.location, line)
44+
util.to_location_no_text(comment.location).col <= position.character + 1
45+
}

bundle/regal/lsp/completion/main_test.rego

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,29 @@ test_completion_entrypoint if {
77

88
items == {{"_regal": {"provider": "test"}, "foo": "bar"}}
99
}
10+
11+
test_inside_comment if {
12+
_data := {"file:///example.rego": {"comments": [
13+
{"location": "2:1:2:10"},
14+
{"location": "4:1:4:10"},
15+
]}}
16+
_input := {"regal": {
17+
"context": {"location": {"row": 4, "col": 5}},
18+
"file": {"uri": "file:///example.rego"},
19+
}}
20+
21+
completion.inside_comment with input as _input with data.workspace.parsed as _data
22+
}
23+
24+
test_not_inside_comment if {
25+
_data := {"file:///example.rego": {"comments": [
26+
{"location": "2:1:2:10"},
27+
{"location": "4:8:4:10"},
28+
]}}
29+
_input := {"regal": {
30+
"context": {"location": {"row": 4, "col": 5}},
31+
"file": {"uri": "file:///example.rego"},
32+
}}
33+
34+
not completion.inside_comment with input as _input with data.workspace.parsed as _data
35+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# METADATA
2+
# description: Completion suggestions for importing available packages
3+
package regal.lsp.completion.providers.packagerefs
4+
5+
import data.regal.ast
6+
7+
import data.regal.lsp.completion.kind
8+
import data.regal.lsp.completion.location
9+
10+
# METADATA
11+
# description: suggest packages matching typed import ref
12+
items contains item if {
13+
position := location.to_position(input.regal.context.location)
14+
line := input.regal.file.lines[position.line]
15+
16+
startswith(line, "import ")
17+
18+
ref := location.ref_at(line, input.regal.context.location.col)
19+
20+
startswith(ref.text, "d")
21+
22+
some i, path in _paths_sorted
23+
24+
startswith(path, ref.text)
25+
26+
item := {
27+
"label": path,
28+
"kind": kind.module,
29+
"detail": "package",
30+
"textEdit": {
31+
"range": location.word_range(ref, position),
32+
"newText": path,
33+
},
34+
# tell clients to sort paths first by the number of path components (shortest first),
35+
# and only then alphabetically (done in _paths_by_num_parts and _paths_sorted)
36+
"sortText": sprintf("%03d", [i]),
37+
}
38+
}
39+
40+
_package_paths contains str if {
41+
some uri
42+
path := data.workspace.parsed[uri].package.path
43+
44+
uri != input.regal.file.uri # don't suggest the package of the current file
45+
not endswith(regal.last(path).value, "_test") # importing tests makes no sense
46+
47+
str := ast.ref_to_string(path)
48+
}
49+
50+
# regal ignore:prefer-set-or-object-rule
51+
_paths_by_num_parts := {num_parts: sort(paths) |
52+
some i
53+
num_parts := strings.count(_package_paths[i], ".")
54+
paths := [path |
55+
some j
56+
strings.count(_package_paths[j], ".") == num_parts
57+
path := _package_paths[j]
58+
]
59+
}
60+
61+
_paths_sorted := [path |
62+
some i in sort(object.keys(_paths_by_num_parts))
63+
some path in _paths_by_num_parts[i]
64+
]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package regal.lsp.completion.providers.packagerefs_test
2+
3+
import data.regal.lsp.completion.providers.packagerefs
4+
5+
test_all_package_refs_sugggested_for_import if {
6+
items := packagerefs.items with data.workspace.parsed as _workspace_parsed with input as {"regal": {
7+
"context": {"location": {"row": 3, "col": 9}},
8+
"file": {
9+
"uri": "file:///example.rego",
10+
"lines": [
11+
"package foo.bar",
12+
"",
13+
"import d",
14+
],
15+
},
16+
}}
17+
18+
# 6 suggestions minus the current package and one test package
19+
# also note how the sortText attribute hints to the client to sort not by
20+
# the label but by the value calculated based on the number of path components
21+
# (shortest first) and then alphabetically
22+
items == {
23+
_suggestion("data.bar", "000", [2, 7, 2, 8]),
24+
_suggestion("data.baz", "001", [2, 7, 2, 8]),
25+
_suggestion("data.foo.baz", "002", [2, 7, 2, 8]),
26+
_suggestion("data.foo.baz.again", "003", [2, 7, 2, 8]),
27+
}
28+
}
29+
30+
test_matching_package_refs_sugggested_for_import if {
31+
items := packagerefs.items with data.workspace.parsed as _workspace_parsed with input as {"regal": {
32+
"context": {"location": {"row": 3, "col": 14}},
33+
"file": {
34+
"uri": "file:///example.rego",
35+
"lines": [
36+
"package foo.bar",
37+
"",
38+
"import data.f",
39+
],
40+
},
41+
}}
42+
43+
items == {
44+
_suggestion("data.foo.baz", "002", [2, 7, 2, 13]),
45+
_suggestion("data.foo.baz.again", "003", [2, 7, 2, 13]),
46+
}
47+
}
48+
49+
_to_path(str) := [{"type": _type(i), "value": value} | some i, value in split(str, ".")]
50+
51+
_type(0) := "var"
52+
_type(x) := "string" if x > 0
53+
54+
_suggestion(label, sort_text, range) := {
55+
"detail": "package",
56+
"kind": 9,
57+
"label": label,
58+
"sortText": sort_text,
59+
"textEdit": {
60+
"newText": label,
61+
"range": {
62+
"start": {"line": range[0], "character": range[1]},
63+
"end": {"line": range[2], "character": range[3]},
64+
},
65+
},
66+
}
67+
68+
_workspace_parsed := {
69+
"file:///example.rego": {"package": {"path": _to_path("data.foo.bar")}},
70+
"file:///other.rego": {"package": {"path": _to_path("data.foo.baz")}},
71+
"file:///again.rego": {"package": {"path": _to_path("data.foo.baz.again")}},
72+
"file:///test.rego": {"package": {"path": _to_path("data.foo.baz.again_test")}},
73+
"file:///more.rego": {"package": {"path": _to_path("data.bar")}},
74+
"file:///last.rego": {"package": {"path": _to_path("data.baz")}},
75+
}

internal/lsp/cache/cache.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,6 @@ type Cache struct {
4949
// when a file is successfully parsed, the number of lines in the file is stored
5050
// here. This is used to gracefully fail when exiting unparsable files.
5151
successfulParseLineCounts *concurrent.Map[string, int]
52-
53-
// fileRefs is a map of file URI to refs that are defined in that file. These are
54-
// intended to be used for completions in other files.
55-
// fileRefs is expected to be updated when a file is successfully parsed.
56-
fileRefs *concurrent.Map[string, map[string]types.Ref]
5752
}
5853

5954
func NewCache() *Cache {
@@ -66,7 +61,6 @@ func NewCache() *Cache {
6661
diagnosticsParseErrors: concurrent.MapOf(make(map[string][]types.Diagnostic)),
6762
builtinPositionsFile: concurrent.MapOf(make(map[string]map[uint][]types.BuiltinPosition)),
6863
keywordLocationsFile: concurrent.MapOf(make(map[string]map[uint][]types.KeywordLocation)),
69-
fileRefs: concurrent.MapOf(make(map[string]map[string]types.Ref)),
7064
successfulParseLineCounts: concurrent.MapOf(make(map[string]int)),
7165
}
7266
}
@@ -166,11 +160,6 @@ func (c *Cache) Rename(oldKey, newKey string) {
166160
c.keywordLocationsFile.Delete(oldKey)
167161
}
168162

169-
if refs, ok := c.fileRefs.Get(oldKey); ok {
170-
c.fileRefs.Set(newKey, refs)
171-
c.fileRefs.Delete(oldKey)
172-
}
173-
174163
if lineCount, ok := c.successfulParseLineCounts.Get(oldKey); ok {
175164
c.successfulParseLineCounts.Set(newKey, lineCount)
176165
c.successfulParseLineCounts.Delete(oldKey)
@@ -282,20 +271,6 @@ func (c *Cache) GetKeywordLocations(fileURI string) (map[uint][]types.KeywordLoc
282271
return c.keywordLocationsFile.Get(fileURI)
283272
}
284273

285-
func (c *Cache) SetFileRefs(fileURI string, items map[string]types.Ref) {
286-
c.fileRefs.Set(fileURI, items)
287-
}
288-
289-
func (c *Cache) GetFileRefs(fileURI string) map[string]types.Ref {
290-
refs, _ := c.fileRefs.Get(fileURI)
291-
292-
return refs
293-
}
294-
295-
func (c *Cache) GetAllFileRefs() map[string]map[string]types.Ref {
296-
return c.fileRefs.Clone()
297-
}
298-
299274
func (c *Cache) GetSuccessfulParseLineCount(fileURI string) (int, bool) {
300275
return c.successfulParseLineCounts.Get(fileURI)
301276
}
@@ -315,7 +290,6 @@ func (c *Cache) Delete(fileURI string) {
315290
c.diagnosticsParseErrors.Delete(fileURI)
316291
c.builtinPositionsFile.Delete(fileURI)
317292
c.keywordLocationsFile.Delete(fileURI)
318-
c.fileRefs.Delete(fileURI)
319293
c.successfulParseLineCounts.Delete(fileURI)
320294
}
321295

internal/lsp/completions/manager.go

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,83 +8,35 @@ import (
88

99
"github.com/open-policy-agent/regal/internal/lsp/cache"
1010
"github.com/open-policy-agent/regal/internal/lsp/completions/providers"
11-
"github.com/open-policy-agent/regal/internal/lsp/rego"
1211
"github.com/open-policy-agent/regal/internal/lsp/rego/query"
1312
"github.com/open-policy-agent/regal/internal/lsp/types"
13+
"github.com/open-policy-agent/regal/internal/util"
1414
)
1515

1616
type Manager struct {
17-
c *cache.Cache
18-
opts *ManagerOptions
19-
providers []Provider
20-
}
21-
22-
type ManagerOptions struct{}
23-
24-
type Provider interface {
25-
Run(context.Context, *cache.Cache, types.CompletionParams, *providers.Options) ([]types.CompletionItem, error)
26-
Name() string
27-
}
28-
29-
func NewManager(c *cache.Cache, opts *ManagerOptions) *Manager {
30-
return &Manager{c: c, opts: opts}
17+
c *cache.Cache
18+
policy *providers.Policy
3119
}
3220

3321
func NewDefaultManager(ctx context.Context, c *cache.Cache, store storage.Store, qc *query.Cache) *Manager {
34-
m := NewManager(c, &ManagerOptions{})
35-
36-
m.RegisterProvider(&providers.PackageRefs{})
37-
m.RegisterProvider(providers.NewPolicy(ctx, store, qc))
38-
39-
return m
22+
return &Manager{c: c, policy: providers.NewPolicy(ctx, store, qc)}
4023
}
4124

4225
func (m *Manager) Run(
4326
ctx context.Context,
4427
params types.CompletionParams,
4528
opts *providers.Options,
4629
) ([]types.CompletionItem, error) {
47-
if m.isInsideOfComment(params) {
48-
// Exit early if caret position is inside a comment. We currently don't have any provider
49-
// where doing completions inside of a comment makes much sense. Behavior is also editor-specific:
50-
// - Zed: always on, with no way to disable
51-
// - VSCode: disabled but can be enabled with "editor.quickSuggestions.comments" setting
52-
return []types.CompletionItem{}, nil
30+
completions, err := m.policy.Run(ctx, m.c, params, opts)
31+
if err != nil {
32+
return nil, fmt.Errorf("error running completion provider: %w", err)
5333
}
5434

55-
var completionsList []types.CompletionItem
56-
57-
for _, provider := range m.providers {
58-
providerCompletions, err := provider.Run(ctx, m.c, params, opts)
59-
if err != nil {
60-
return nil, fmt.Errorf("error running completion provider: %w", err)
61-
}
62-
63-
for _, completion := range providerCompletions {
64-
completion.Regal = nil
65-
completionsList = append(completionsList, completion)
66-
}
67-
}
68-
69-
return completionsList, nil
35+
return util.Map(completions, removeMetadata), nil
7036
}
7137

72-
func (m *Manager) RegisterProvider(provider Provider) {
73-
m.providers = append(m.providers, provider)
74-
}
75-
76-
func (m *Manager) isInsideOfComment(params types.CompletionParams) bool {
77-
if module, ok := m.c.GetModule(params.TextDocument.URI); ok {
78-
for _, comment := range module.Comments {
79-
cp := rego.PositionFromLocation(comment.Location)
80-
81-
if cp.Line == params.Position.Line {
82-
if cp.Character <= params.Position.Character {
83-
return true
84-
}
85-
}
86-
}
87-
}
38+
func removeMetadata(item types.CompletionItem) types.CompletionItem {
39+
item.Regal = nil
8840

89-
return false
41+
return item
9042
}

0 commit comments

Comments
 (0)