Skip to content

Commit acd22f7

Browse files
authored
Add support for using input.yaml in Evaluate code lens (#1269)
This addresses a request filed in the VS Code extension: open-policy-agent/vscode-opa#308 Sadly this doesn't yet work for the debug feature as OPA currently only will do JSON decoding in that path, so next step is to submit a fix for that there. Signed-off-by: Anders Eknert <[email protected]>
1 parent abd9504 commit acd22f7

File tree

7 files changed

+113
-103
lines changed

7 files changed

+113
-103
lines changed

.gitignore

+5-2
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ dist/
66
/regal.exe
77

88
# These two files are used by the Regal evaluation Code Lens, where input.json
9-
# defines the input to use for evaluation, and output.json is where the output
10-
# ends up unless the client supports presenting it in a different way.
9+
# (or input.yaml) defines the input to use for evaluation, and output.json is
10+
# where the output ends up unless the client supports presenting it in a
11+
# different way.
1112
input.json
13+
input.yaml
14+
1215
output.json
1316

1417
build/node_modules/

docs/language-server.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,11 @@ the way it did, or where rule evaluation failed.
173173
src={require('./assets/lsp/evalcodelensprint.png').default}
174174
alt="Screenshot of evaluation with print call performed via code lens"/>
175175

176-
Policy evaluation often depends on **input**. This can be provided via an `input.json` file which Regal will search
177-
for first in the same directory as the policy file evaluated. If not found there, Regal will proceed to search each
178-
parent directory up until the workspace root directory. It is recommended to add `input.json` to your `.gitignore`
179-
file so that you can work freely with evaluation in any directory without having your input accidentally committed.
176+
Policy evaluation often depends on **input**. This can be provided via an `input.json` or `input.yaml` file which
177+
Regal will search for first in the same directory as the policy file evaluated. If not found there, Regal will proceed
178+
to search each parent directory up until the workspace root directory. It is recommended to add `input.json/yaml` to
179+
your `.gitignore` file so that you can work freely with evaluation in any directory without having your input
180+
accidentally committed.
180181

181182
#### Editor support
182183

internal/io/io.go

+47-7
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"github.com/anderseknert/roast/pkg/encoding"
13+
"gopkg.in/yaml.v3"
1314

1415
"github.com/open-policy-agent/opa/bundle"
1516
"github.com/open-policy-agent/opa/loader/filter"
@@ -94,22 +95,61 @@ func ExcludeTestFilter() filter.LoaderFilter {
9495
}
9596
}
9697

97-
// FindInput finds input.json file in workspace closest to the file, and returns
98-
// both the location and the reader.
99-
func FindInput(file string, workspacePath string) (string, io.Reader) {
98+
// FindInput finds input.json or input.yaml file in workspace closest to the file, and returns
99+
// both the location and the contents of the file (as map), or an empty string and nil if not found.
100+
// Note that:
101+
// - This function doesn't do error handling. If the file can't be read, nothing is returned.
102+
// - While the input data theoritcally could be anything JSON/YAML value, we only support an object.
103+
func FindInput(file string, workspacePath string) (string, map[string]any) {
100104
relative := strings.TrimPrefix(file, workspacePath)
101105
components := strings.Split(filepath.Dir(relative), string(filepath.Separator))
102106

107+
var (
108+
inputPath string
109+
content []byte
110+
)
111+
103112
for i := range components {
104-
inputPath := filepath.Join(workspacePath, filepath.Join(components[:len(components)-i]...), "input.json")
113+
current := components[:len(components)-i]
114+
115+
inputPathJSON := filepath.Join(workspacePath, filepath.Join(current...), "input.json")
116+
117+
f, err := os.Open(inputPathJSON)
118+
if err == nil {
119+
inputPath = inputPathJSON
120+
content, _ = io.ReadAll(f)
105121

106-
f, err := os.Open(inputPath)
122+
break
123+
}
124+
125+
inputPathYAML := filepath.Join(workspacePath, filepath.Join(current...), "input.yaml")
126+
127+
f, err = os.Open(inputPathYAML)
107128
if err == nil {
108-
return inputPath, f
129+
inputPath = inputPathYAML
130+
content, _ = io.ReadAll(f)
131+
132+
break
133+
}
134+
}
135+
136+
if inputPath == "" || content == nil {
137+
return "", nil
138+
}
139+
140+
var input map[string]any
141+
142+
if strings.HasSuffix(inputPath, ".json") {
143+
if err := encoding.JSON().Unmarshal(content, &input); err != nil {
144+
return "", nil
145+
}
146+
} else if strings.HasSuffix(inputPath, ".yaml") {
147+
if err := yaml.Unmarshal(content, &input); err != nil {
148+
return "", nil
109149
}
110150
}
111151

112-
return "", nil
152+
return inputPath, input
113153
}
114154

115155
func IsSkipWalkDirectory(info files.DirEntry) bool {

internal/lsp/completions/providers/policy.go

+5-12
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ package providers
22

33
import (
44
"context"
5-
"encoding/json"
65
"errors"
76
"fmt"
8-
"io"
97
"os"
108

119
"github.com/open-policy-agent/opa/ast"
@@ -70,20 +68,15 @@ func (p *Policy) Run(
7068
inputContext["path_separator"] = string(os.PathSeparator)
7169

7270
workspacePath := uri.ToPath(opts.ClientIdentifier, opts.RootURI)
73-
inputDotJSONPath, inputDotJSONReader := rio.FindInput(
71+
72+
inputDotJSONPath, inputDotJSONContent := rio.FindInput(
7473
uri.ToPath(opts.ClientIdentifier, params.TextDocument.URI),
7574
workspacePath,
7675
)
7776

78-
if inputDotJSONReader != nil {
79-
inputDotJSON := make(map[string]any)
80-
81-
if bs, err := io.ReadAll(inputDotJSONReader); err == nil {
82-
if err = json.Unmarshal(bs, &inputDotJSON); err == nil {
83-
inputContext["input_dot_json_path"] = inputDotJSONPath
84-
inputContext["input_dot_json"] = inputDotJSON
85-
}
86-
}
77+
if inputDotJSONPath != "" && inputDotJSONContent != nil {
78+
inputContext["input_dot_json_path"] = inputDotJSONPath
79+
inputContext["input_dot_json"] = inputDotJSONContent
8780
}
8881

8982
input, err := rego2.ToInput(

internal/lsp/eval.go

+3-19
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"io"
87
"strings"
98

10-
"github.com/anderseknert/roast/pkg/encoding"
11-
129
"github.com/open-policy-agent/opa/ast"
1310
"github.com/open-policy-agent/opa/bundle"
1411
"github.com/open-policy-agent/opa/rego"
@@ -24,7 +21,7 @@ import (
2421
func (l *LanguageServer) Eval(
2522
ctx context.Context,
2623
query string,
27-
input io.Reader,
24+
input map[string]any,
2825
printHook print.Hook,
2926
dataBundles map[string]bundle.Bundle,
3027
) (rego.ResultSet, error) {
@@ -88,20 +85,7 @@ func (l *LanguageServer) Eval(
8885
}
8986

9087
if input != nil {
91-
inputMap := make(map[string]any)
92-
93-
in, err := io.ReadAll(input)
94-
if err != nil {
95-
return nil, fmt.Errorf("failed reading input: %w", err)
96-
}
97-
98-
json := encoding.JSON()
99-
100-
if err = json.Unmarshal(in, &inputMap); err != nil {
101-
return nil, fmt.Errorf("failed unmarshalling input: %w", err)
102-
}
103-
104-
return pq.Eval(ctx, rego.EvalInput(inputMap)) //nolint:wrapcheck
88+
return pq.Eval(ctx, rego.EvalInput(input)) //nolint:wrapcheck
10589
}
10690

10791
return pq.Eval(ctx) //nolint:wrapcheck
@@ -116,7 +100,7 @@ type EvalPathResult struct {
116100
func (l *LanguageServer) EvalWorkspacePath(
117101
ctx context.Context,
118102
query string,
119-
input io.Reader,
103+
input map[string]any,
120104
) (EvalPathResult, error) {
121105
resultQuery := "result := " + query
122106

internal/lsp/eval_test.go

+40-45
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ package lsp
22

33
import (
44
"context"
5-
"io"
5+
"maps"
66
"os"
77
"slices"
8-
"strings"
98
"testing"
109

1110
rio "github.com/styrainc/regal/internal/io"
@@ -49,7 +48,9 @@ func TestEvalWorkspacePath(t *testing.T) {
4948
ls.cache.SetModule("file://policy1.rego", module1)
5049
ls.cache.SetModule("file://policy2.rego", module2)
5150

52-
input := strings.NewReader(`{"exists": true}`)
51+
input := map[string]any{
52+
"exists": true,
53+
}
5354

5455
res, err := ls.EvalWorkspacePath(context.TODO(), "data.policy1.allow", input)
5556
if err != nil {
@@ -71,7 +72,7 @@ func TestEvalWorkspacePathInternalData(t *testing.T) {
7172
&LanguageServerOptions{LogWriter: logger, LogLevel: log.LevelDebug},
7273
)
7374

74-
res, err := ls.EvalWorkspacePath(context.TODO(), "object.keys(data.internal)", strings.NewReader("{}"))
75+
res, err := ls.EvalWorkspacePath(context.TODO(), "object.keys(data.internal)", map[string]any{})
7576
if err != nil {
7677
t.Fatal(err)
7778
}
@@ -104,35 +105,50 @@ func TestEvalWorkspacePathInternalData(t *testing.T) {
104105
func TestFindInput(t *testing.T) {
105106
t.Parallel()
106107

107-
tmpDir := t.TempDir()
108+
cases := []struct {
109+
fileType string
110+
fileContent string
111+
}{
112+
{"json", `{"x": true}`},
113+
{"yaml", "x: true"},
114+
}
108115

109-
workspacePath := tmpDir + "/workspace"
110-
file := tmpDir + "/workspace/foo/bar/baz.rego"
116+
for _, tc := range cases {
117+
t.Run(tc.fileType, func(t *testing.T) {
118+
t.Parallel()
111119

112-
if err := os.MkdirAll(workspacePath+"/foo/bar", 0o755); err != nil {
113-
t.Fatal(err)
114-
}
120+
tmpDir := t.TempDir()
115121

116-
if readInputString(t, file, workspacePath) != "" {
117-
t.Fatalf("did not expect to find input.json")
118-
}
122+
workspacePath := tmpDir + "/workspace"
123+
file := tmpDir + "/workspace/foo/bar/baz.rego"
119124

120-
content := `{"x": 1}`
125+
if err := os.MkdirAll(workspacePath+"/foo/bar", 0o755); err != nil {
126+
t.Fatal(err)
127+
}
121128

122-
createWithContent(t, tmpDir+"/workspace/foo/bar/input.json", content)
129+
path, content := rio.FindInput(file, workspacePath)
130+
if path != "" || content != nil {
131+
t.Fatalf("did not expect to find input.%s", tc.fileType)
132+
}
123133

124-
if res := readInputString(t, file, workspacePath); res != content {
125-
t.Errorf("expected input at %s, got %s", content, res)
126-
}
134+
createWithContent(t, tmpDir+"/workspace/foo/bar/input."+tc.fileType, tc.fileContent)
127135

128-
if err := os.Remove(tmpDir + "/workspace/foo/bar/input.json"); err != nil {
129-
t.Fatal(err)
130-
}
136+
path, content = rio.FindInput(file, workspacePath)
137+
if path != workspacePath+"/foo/bar/input."+tc.fileType || !maps.Equal(content, map[string]any{"x": true}) {
138+
t.Errorf(`expected input {"x": true} at, got %s`, content)
139+
}
140+
141+
if err := os.Remove(tmpDir + "/workspace/foo/bar/input." + tc.fileType); err != nil {
142+
t.Fatal(err)
143+
}
131144

132-
createWithContent(t, tmpDir+"/workspace/input.json", content)
145+
createWithContent(t, tmpDir+"/workspace/input."+tc.fileType, tc.fileContent)
133146

134-
if res := readInputString(t, file, workspacePath); res != content {
135-
t.Errorf("expected input at %s, got %s", content, res)
147+
path, content = rio.FindInput(file, workspacePath)
148+
if path != workspacePath+"/input."+tc.fileType || !maps.Equal(content, map[string]any{"x": true}) {
149+
t.Errorf(`expected input {"x": true} at, got %s`, content)
150+
}
151+
})
136152
}
137153
}
138154

@@ -150,24 +166,3 @@ func createWithContent(t *testing.T, path string, content string) {
150166
t.Fatal(err)
151167
}
152168
}
153-
154-
func readInputString(t *testing.T, file, workspacePath string) string {
155-
t.Helper()
156-
157-
_, input := rio.FindInput(file, workspacePath)
158-
159-
if input == nil {
160-
return ""
161-
}
162-
163-
bs, err := io.ReadAll(input)
164-
if err != nil {
165-
t.Fatal(err)
166-
}
167-
168-
if bs == nil {
169-
return ""
170-
}
171-
172-
return string(bs)
173-
}

internal/lsp/server.go

+8-14
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
package lsp
33

44
import (
5-
"bytes"
65
"context"
76
"errors"
87
"fmt"
@@ -820,33 +819,28 @@ func (l *LanguageServer) StartCommandWorker(ctx context.Context) { // nolint:mai
820819
// if there are none, then it's a package evaluation
821820
ruleHeadLocations := allRuleHeadLocations[path]
822821

823-
var input io.Reader
822+
var inputMap map[string]any
824823

825824
// When the first comment in the file is `regal eval: use-as-input`, the AST of that module is
826-
// used as the input rather than the contents of input.json. This is a development feature for
825+
// used as the input rather than the contents of input.json/yaml. This is a development feature for
827826
// working on rules (built-in or custom), allowing querying the AST of the module directly.
828827
if len(currentModule.Comments) > 0 && regalEvalUseAsInputComment.Match(currentModule.Comments[0].Text) {
829-
inputMap, err := rparse.PrepareAST(file, currentContents, currentModule)
828+
inputMap, err = rparse.PrepareAST(file, currentContents, currentModule)
830829
if err != nil {
831830
l.logf(log.LevelMessage, "failed to prepare module: %s", err)
832831

833832
break
834833
}
834+
} else {
835+
// Normal mode — try to find the input.json/yaml file in the workspace and use as input
836+
_, inputMap = rio.FindInput(uri.ToPath(l.clientIdentifier, file), l.workspacePath())
835837

836-
bs, err := encoding.JSON().Marshal(inputMap)
837-
if err != nil {
838-
l.logf(log.LevelMessage, "failed to marshal module: %s", err)
839-
838+
if inputMap == nil {
840839
break
841840
}
842-
843-
input = bytes.NewReader(bs)
844-
} else {
845-
// Normal mode — try to find the input.json file in the workspace and use as input
846-
_, input = rio.FindInput(uri.ToPath(l.clientIdentifier, file), l.workspacePath())
847841
}
848842

849-
result, err := l.EvalWorkspacePath(ctx, path, input)
843+
result, err := l.EvalWorkspacePath(ctx, path, inputMap)
850844
if err != nil {
851845
fmt.Fprintf(os.Stderr, "failed to evaluate workspace path: %v\n", err)
852846

0 commit comments

Comments
 (0)