Skip to content

Commit 17c4b98

Browse files
authored
lsp: add action and handlers for support vscode-based opa-explorer (#1862)
Signed-off-by: Stephan Renatus <stephan.renatus@gmail.com>
1 parent 349fe5a commit 17c4b98

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

internal/lsp/server.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/open-policy-agent/regal/bundle"
2727
"github.com/open-policy-agent/regal/internal/capabilities"
2828
"github.com/open-policy-agent/regal/internal/compile"
29+
"github.com/open-policy-agent/regal/internal/explorer"
2930
rio "github.com/open-policy-agent/regal/internal/io"
3031
"github.com/open-policy-agent/regal/internal/io/files"
3132
"github.com/open-policy-agent/regal/internal/lsp/bundles"
@@ -569,6 +570,35 @@ func (l *LanguageServer) StartCommandWorker(ctx context.Context) {
569570
case <-ctx.Done():
570571
return
571572
case params := <-l.commandRequest:
573+
// Handle regal.explorer separately as VS Code sends map arguments
574+
if params.Command == "regal.explorer" {
575+
var explorerArgs types.ExplorerCommandArgs
576+
577+
if len(params.Arguments) > 0 {
578+
arg, ok := params.Arguments[0].(map[string]any)
579+
if !ok {
580+
l.log.Message("expected explorer argument to be a map, got %T", params.Arguments[0])
581+
582+
continue
583+
}
584+
585+
explorerArgs = types.ExplorerCommandArgs{
586+
Target: util.GetMapValue[string](arg, "target"),
587+
Strict: util.GetMapValue[bool](arg, "strict"),
588+
Annotations: util.GetMapValue[bool](arg, "annotations"),
589+
Print: util.GetMapValue[bool](arg, "print"),
590+
Format: util.GetMapValue[bool](arg, "format"),
591+
}
592+
}
593+
594+
if err := l.handleExplorerCommand(ctx, explorerArgs); err != nil {
595+
l.log.Message("failed to handle explorer command: %s", err)
596+
}
597+
598+
continue
599+
}
600+
601+
// Handle all other commands (they use string arguments)
572602
var (
573603
editParams *types.ApplyWorkspaceEditParams
574604
args types.CommandArgs
@@ -1919,6 +1949,7 @@ func (l *LanguageServer) handleInitialize(ctx context.Context, params types.Init
19191949
Commands: []string{
19201950
"regal.debug",
19211951
"regal.eval",
1952+
"regal.explorer",
19221953
"regal.fix.opa-fmt",
19231954
"regal.fix.use-rego-v1",
19241955
"regal.fix.use-assignment-operator",
@@ -2283,6 +2314,68 @@ func (l *LanguageServer) regalContext(fileURI string, _ *rego.Requirements) *reg
22832314
}
22842315
}
22852316

2317+
func (l *LanguageServer) handleExplorerCommand(ctx context.Context, args types.ExplorerCommandArgs) error {
2318+
if args.Target == "" {
2319+
l.log.Message("expected command target, got empty string")
2320+
2321+
return errors.New("target file URI is required")
2322+
}
2323+
2324+
contents, ok := l.cache.GetFileContents(args.Target)
2325+
if !ok {
2326+
return fmt.Errorf("could not get file contents for uri %q", args.Target)
2327+
}
2328+
2329+
path := l.toRelativePath(args.Target)
2330+
2331+
compileResults := explorer.CompilerStages(
2332+
path,
2333+
contents,
2334+
args.Strict,
2335+
args.Annotations,
2336+
args.Print,
2337+
)
2338+
2339+
stages := make([]types.ExplorerStageResult, 0, len(compileResults))
2340+
hasErrors := false
2341+
2342+
for _, cs := range compileResults {
2343+
stage := types.ExplorerStageResult{
2344+
Name: cs.Stage,
2345+
Error: cs.Error != "",
2346+
}
2347+
2348+
if cs.Error != "" {
2349+
hasErrors = true
2350+
stage.Output = cs.Error
2351+
} else {
2352+
if args.Format {
2353+
stage.Output = cs.FormattedResult()
2354+
} else if cs.Result != nil {
2355+
stage.Output = cs.Result.String()
2356+
}
2357+
}
2358+
2359+
stages = append(stages, stage)
2360+
}
2361+
2362+
responseParams := types.ExplorerResult{
2363+
Stages: stages,
2364+
}
2365+
2366+
if !hasErrors {
2367+
if plan, err := explorer.Plan(ctx, path, contents, args.Print); err == nil {
2368+
responseParams.Plan = plan
2369+
}
2370+
}
2371+
2372+
if err := l.conn.Notify(ctx, "regal/showExplorerResult", responseParams); err != nil {
2373+
return fmt.Errorf("regal/showExplorerResult notification failed: %w", err)
2374+
}
2375+
2376+
return nil
2377+
}
2378+
22862379
func (l *LanguageServer) handleEvalCommand(ctx context.Context, args types.CommandArgs) error {
22872380
if args.Target == "" || args.Query == "" {
22882381
l.log.Message("expected command target and query, got target %q, query %q", args.Target, args.Query)

internal/lsp/server_command_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,118 @@ allow if {
154154
})
155155
}
156156
}
157+
158+
func TestExecuteCommandExplorer(t *testing.T) {
159+
t.Parallel()
160+
161+
ctx, cancel := context.WithCancel(t.Context())
162+
163+
defer func() {
164+
time.Sleep(200 * time.Millisecond)
165+
cancel()
166+
}()
167+
168+
receivedNotifications := make(chan map[string]any, defaultBufferedChannelSize)
169+
170+
createExplorerNotificationTestHandler := func(
171+
t *testing.T,
172+
receivedNotifications chan map[string]any,
173+
) func(_ context.Context, _ *jsonrpc2.Conn, _ *jsonrpc2.Request) (result any, err error) {
174+
t.Helper()
175+
176+
return func(_ context.Context, _ *jsonrpc2.Conn, req *jsonrpc2.Request) (result any, err error) {
177+
if req.Method == "regal/showExplorerResult" {
178+
if req.Params == nil {
179+
t.Fatal("expected notification params to be non-nil")
180+
}
181+
182+
var notificationData map[string]any
183+
if err := encoding.JSON().Unmarshal(*req.Params, &notificationData); err != nil {
184+
t.Fatalf("failed to unmarshal notification params: %s", err)
185+
}
186+
187+
receivedNotifications <- notificationData
188+
189+
return nil, nil
190+
}
191+
192+
if req.Method == "workspace/applyEdit" {
193+
return map[string]any{"applied": true}, nil
194+
}
195+
196+
return struct{}{}, nil
197+
}
198+
}
199+
200+
tempDir := t.TempDir()
201+
clientHandler := createExplorerNotificationTestHandler(t, receivedNotifications)
202+
ls, connClient := createAndInitServer(t, ctx, tempDir, clientHandler)
203+
204+
go ls.StartCommandWorker(ctx)
205+
206+
content := `package test
207+
208+
allow if {
209+
1 == 1
210+
}
211+
`
212+
213+
mainRegoURI := uri.FromPath(clients.IdentifierGoTest, filepath.Join(tempDir, "test.rego"))
214+
ls.cache.SetFileContents(mainRegoURI, content)
215+
216+
executeParams := types.ExecuteCommandParams{
217+
Command: "regal.explorer",
218+
Arguments: []any{
219+
map[string]any{
220+
"target": mainRegoURI,
221+
"strict": false,
222+
"annotations": false,
223+
"print": false,
224+
"format": true,
225+
},
226+
},
227+
}
228+
229+
var executeResponse any
230+
231+
testutil.NoErr(connClient.Call(ctx, "workspace/executeCommand", executeParams, &executeResponse))(t)
232+
233+
timeout := time.NewTimer(determineTimeout())
234+
defer timeout.Stop()
235+
236+
select {
237+
case notification := <-receivedNotifications:
238+
if _, ok := notification["stages"]; !ok {
239+
t.Fatal("expected notification to contain 'stages' field")
240+
}
241+
242+
stages, ok := notification["stages"].([]any)
243+
if !ok {
244+
t.Fatalf("expected 'stages' to be a slice, got %T", notification["stages"])
245+
}
246+
247+
if len(stages) == 0 {
248+
t.Fatal("expected at least one compilation stage")
249+
}
250+
251+
firstStage, ok := stages[0].(map[string]any)
252+
if !ok {
253+
t.Fatalf("expected first stage to be a map, got %T", stages[0])
254+
}
255+
256+
if _, ok := firstStage["name"]; !ok {
257+
t.Fatal("expected stage to have 'name' field")
258+
}
259+
260+
if _, ok := firstStage["output"]; !ok {
261+
t.Fatal("expected stage to have 'output' field")
262+
}
263+
264+
if _, ok := firstStage["error"]; !ok {
265+
t.Fatal("expected stage to have 'error' field")
266+
}
267+
268+
case <-timeout.C:
269+
t.Fatal("timeout waiting for regal/showExplorerResult notification")
270+
}
271+
}

internal/lsp/types/types.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,5 +575,24 @@ type (
575575
Full bool `json:"full,omitempty"`
576576
}
577577

578+
ExplorerCommandArgs struct {
579+
Target string `json:"target"`
580+
Strict bool `json:"strict,omitempty"`
581+
Annotations bool `json:"annotations,omitempty"`
582+
Print bool `json:"print,omitempty"`
583+
Format bool `json:"format,omitempty"`
584+
}
585+
586+
ExplorerStageResult struct {
587+
Name string `json:"name"`
588+
Output string `json:"output"`
589+
Error bool `json:"error"`
590+
}
591+
592+
ExplorerResult struct {
593+
Stages []ExplorerStageResult `json:"stages"`
594+
Plan string `json:"plan,omitempty"`
595+
}
596+
578597
iuint interface{ ~int | ~uint }
579598
)

internal/util/util.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,20 @@ func Pointer[T any](v T) *T {
270270
return &v
271271
}
272272

273+
// GetMapValue extracts a typed value from a map[string]any, returning the value if the type matched,
274+
// or the zero values of correct type.
275+
func GetMapValue[T any](m map[string]any, key string) T {
276+
if val, ok := m[key]; ok {
277+
if typed, ok := val.(T); ok {
278+
return typed
279+
}
280+
}
281+
282+
var zero T
283+
284+
return zero
285+
}
286+
273287
// AnySliceTo converts a slice of any to a slice of T, returning an error if any element cannot be casted.
274288
func AnySliceTo[T any](in []any) ([]T, error) {
275289
out := make([]T, 0, len(in))

0 commit comments

Comments
 (0)