Skip to content

Commit 9cf180c

Browse files
authored
lsp: Implement selection ranges and linked editing range (#1722)
Selection ranges provide smart selection of code that can expand and shrink based on knowledge of the AST rather than simple text properties like hyphens or whitespace. I didn't think this would be particularly important, but having it used it for some time while working on this feature, this is definitely a feature that I now wouldn't want to be without. While not likely to be used much outside of rules, ranges also expand in both package declarations and imports. Linked editing range OTOH was mostly implemented to try out the feature, and at this point has not proven to be particularly useful, and the feature is therefore kept behind a new `REGAL_EXPERIMENTAL` flag for now. More info about that in the docs included in this PR. Also: - Remove the "log only once" logic in the development bundle loader, as it's no longer flooding the logs - Use shared schema for all `TextDocumentPosition` params handlers - Document all environment variables available for development and debugging purposes Signed-off-by: Anders Eknert <anders@eknert.com>
1 parent 73b8c65 commit 9cf180c

23 files changed

Lines changed: 825 additions & 59 deletions

.vscode/settings.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,10 @@
1212
"go.buildFlags": [
1313
"-tags",
1414
"e2e"
15-
]
15+
],
16+
"[rego]": {
17+
// This is better handled by Regal's selection ranges implementation
18+
"editor.smartSelect.selectLeadingAndTrailingWhitespace": false,
19+
"editor.smartSelect.selectSubwords": false
20+
}
1621
}

bundle/bundle.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@ var (
2424
//go:embed *
2525
regalBundle embed.FS
2626

27-
lastErrMsg = atomic.Pointer[string]{}
28-
successLogOnce = sync.OnceFunc(func() {
29-
fmt.Fprintln(os.Stderr, "Successfully loaded development bundle")
30-
})
27+
lastErrMsg = atomic.Pointer[string]{}
3128
)
3229

3330
type devMode struct {
@@ -92,8 +89,6 @@ func (dm *devMode) Reload() {
9289
for _, c := range dm.subscribers {
9390
c <- struct{}{}
9491
}
95-
96-
successLogOnce()
9792
}
9893

9994
func (dm *devMode) Subscribe(c chan struct{}) {

bundle/regal/lsp/documenthighlight/documenthighlight.rego

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
# - https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentHighlight
88
# schemas:
99
# - input: schema.regal.lsp.common
10-
# - input.params: schema.regal.lsp.documenthighlight
10+
# - input.params: schema.regal.lsp.textdocumentposition
1111
package regal.lsp.documenthighlight
1212

1313
import data.regal.ast
14-
import data.regal.lsp.completion.location
14+
import data.regal.lsp.util.find
1515
import data.regal.lsp.util.location as uloc
1616
import data.regal.util
1717

@@ -22,18 +22,18 @@ result["response"] := items
2222
# METADATA
2323
# description: Highlights a function args in position
2424
items contains item if {
25-
[arg, _] := _arg_at_position
25+
[arg, _] := find.arg_at_position
2626

2727
item := {
28-
"range": uloc.to_range(util.to_location_object(arg.location)),
28+
"range": uloc.parse_range(arg.location),
2929
"kind": 2, # Write
3030
}
3131
}
3232

3333
# METADATA
3434
# description: Highlights function arg references in function body when clicked
3535
items contains item if {
36-
[arg, i] := _arg_at_position
36+
[arg, i] := find.arg_at_position
3737

3838
some expr in ast.found.expressions[sprintf("%d", [i])]
3939

@@ -43,23 +43,23 @@ items contains item if {
4343
value.value == arg.value
4444

4545
item := {
46-
"range": uloc.to_range(util.to_location_object(value.location)),
46+
"range": uloc.parse_range(value.location),
4747
"kind": 3, # Read
4848
}
4949
}
5050

5151
# METADATA
5252
# description: Highlights function arg references in head value body when clicked
5353
items contains item if {
54-
[arg, i] := _arg_at_position
54+
[arg, i] := find.arg_at_position
5555

5656
walk(data.workspace.parsed[input.params.textDocument.uri].rules[i].head.value, [_, value])
5757

5858
value.type == "var"
5959
value.value == arg.value
6060

6161
item := {
62-
"range": uloc.to_range(util.to_location_object(value.location)),
62+
"range": uloc.parse_range(value.location),
6363
"kind": 3, # Read
6464
}
6565
}
@@ -117,23 +117,6 @@ items contains item if {
117117
}
118118
}
119119

120-
_arg_at_position := [arg, i] if {
121-
text := input.regal.file.lines[input.params.position.line]
122-
word := location.word_at(text, input.params.position.character)
123-
124-
some i, rule in data.workspace.parsed[input.params.textDocument.uri].rules
125-
some arg in rule.head.args
126-
127-
arg.type == "var"
128-
arg.value == word.text
129-
130-
loc := util.to_location_object(arg.location)
131-
132-
input.params.position.line + 1 == loc.row
133-
input.params.position.character >= loc.col - 1
134-
input.params.position.character <= (loc.col - 1) + count(arg.value)
135-
}
136-
137120
_find_annotation(module, row) := annotation if {
138121
util.to_location_row(module.package.annotations[0].location) == row
139122

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# METADATA
2+
# description: |
3+
# Linked editing ranges allow having a local rename *on type* reflect in multiple places. This arguably a potentialy
4+
# confusing feature, and seems to be off by default in at least VS Code, so unless we find strong use-cases for it, we
5+
# should not invest much effort into polishing this. Currently we only provide experimental support of linked editing
6+
# ranges for:
7+
# - function arguments and references to them in the function head and body
8+
# related_resources:
9+
# - https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_linkedEditingRange
10+
# schemas:
11+
# - input: schema.regal.lsp.common
12+
# - input.params: schema.regal.lsp.textdocumentposition
13+
package regal.lsp.linkededitingrange
14+
15+
import data.regal.ast
16+
import data.regal.util
17+
18+
import data.regal.lsp.util.find
19+
import data.regal.lsp.util.location as uloc
20+
21+
# METADATA
22+
# entrypoint: true
23+
default result.response.ranges := set()
24+
25+
# This is currently an experimental feature kept behind a flag to not accidedntally result in a poor
26+
# user experience for anyone who perhaps mistakenly enabled linked editing in their editor. Handler, and
27+
# the code kept here, to allow us to quickly test out the feature for different use-cases later.
28+
result.response.ranges := ranges if util.parse_bool(opa.runtime().env.REGAL_EXPERIMENTAL)
29+
30+
# METADATA
31+
# description: Link a function args in position
32+
ranges contains range if {
33+
[arg, _] := find.arg_at_position
34+
35+
range := uloc.parse_range(arg.location)
36+
}
37+
38+
# METADATA
39+
# description: Link function arg references in function body to arg
40+
ranges contains range if {
41+
[arg, i] := find.arg_at_position
42+
43+
some expr in ast.found.expressions[sprintf("%d", [i])]
44+
45+
walk(expr, [_, value])
46+
47+
value.type == "var"
48+
value.value == arg.value
49+
50+
range := uloc.parse_range(value.location)
51+
}
52+
53+
# METADATA
54+
# description: Link function arg references in head value to arg
55+
ranges contains range if {
56+
[arg, i] := find.arg_at_position
57+
58+
walk(data.workspace.parsed[input.params.textDocument.uri].rules[i].head.value, [_, value])
59+
60+
value.type == "var"
61+
value.value == arg.value
62+
63+
range := uloc.parse_range(value.location)
64+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package regal.lsp.linkededitingrange_test
2+
3+
import data.regal.lsp.linkededitingrange
4+
5+
test_linked_editing_range_function_arg if {
6+
file_content := `package p
7+
8+
foo(bar, baz) := baz if {
9+
bar == baz
10+
}`
11+
12+
# querying 'ranges' directly to test without the experimental flag logic
13+
ranges := linkededitingrange.ranges with input as text_document_position(2, 6)
14+
with input.regal.file.lines as split(file_content, "\n")
15+
with data.workspace.parsed["file://p.rego"] as regal.parse_module("p.rego", file_content)
16+
17+
expected_ranges := {
18+
# function arg 'bar' in function head
19+
{
20+
"start": {"line": 2, "character": 4},
21+
"end": {"line": 2, "character": 7},
22+
},
23+
# function arg 'bar' reference in function body
24+
{
25+
"start": {"line": 3, "character": 4},
26+
"end": {"line": 3, "character": 7},
27+
},
28+
}
29+
30+
ranges == expected_ranges
31+
}
32+
33+
test_linked_editing_range_disabled_without_flag if {
34+
file_content := `package p
35+
36+
foo(bar, baz) := baz if {
37+
bar == baz
38+
}`
39+
40+
# querying 'ranges' directly to test without the experimental flag logic
41+
ranges := linkededitingrange.result.response.ranges with input as text_document_position(2, 6)
42+
with input.regal.file.lines as split(file_content, "\n")
43+
with data.workspace.parsed["file://p.rego"] as regal.parse_module("p.rego", file_content)
44+
45+
ranges == set()
46+
}
47+
48+
test_linked_editing_range_enabled_with_flag_set_to_true if {
49+
file_content := `package p
50+
51+
foo(bar, baz) := baz if {
52+
bar == baz
53+
}`
54+
55+
# querying 'ranges' directly to test without the experimental flag logic
56+
ranges := linkededitingrange.result.response.ranges with input as text_document_position(2, 6)
57+
with input.regal.file.lines as split(file_content, "\n")
58+
with data.workspace.parsed["file://p.rego"] as regal.parse_module("p.rego", file_content)
59+
with opa.runtime as {"env": {"REGAL_EXPERIMENTAL": "true"}}
60+
61+
expected_ranges := {
62+
# function arg 'bar' in function head
63+
{
64+
"start": {"line": 2, "character": 4},
65+
"end": {"line": 2, "character": 7},
66+
},
67+
# function arg 'bar' reference in function body
68+
{
69+
"start": {"line": 3, "character": 4},
70+
"end": {"line": 3, "character": 7},
71+
},
72+
}
73+
74+
ranges == expected_ranges
75+
}
76+
77+
text_document_position(line, character) := {"params": {
78+
"textDocument": {"uri": "file://p.rego"},
79+
"position": {
80+
"line": line,
81+
"character": character,
82+
},
83+
}}

0 commit comments

Comments
 (0)