Skip to content

Commit ba24c3b

Browse files
Resolve global prompts
1 parent 8def511 commit ba24c3b

File tree

4 files changed

+147
-5
lines changed

4 files changed

+147
-5
lines changed

internal/syntax/resolver/resolver.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,16 @@ func (r *Resolver) Resolve(in ast.File) (spec.File, error) {
4848
Requests: []spec.Request{},
4949
}
5050

51-
var err error
52-
5351
for _, statement := range in.Statements {
54-
file, err = r.resolveStatement(file, statement)
52+
newFile, err := r.resolveFileStatement(file, statement)
5553
if err != nil {
5654
// If we can't resolve this one, try carrying on. This ensures we provide
5755
// multiple diagnostics for the user rather than one at a time
5856
continue
5957
}
58+
59+
// Update the file
60+
file = newFile
6061
}
6162

6263
// We've had diagnostics reported somewhere during resolving so correctly
@@ -93,7 +94,7 @@ func (r *Resolver) errorf(node ast.Node, format string, a ...any) {
9394

9495
// resolveStatement resolves a generic [ast.Statement], modifying the file and returning
9596
// the new version.
96-
func (r *Resolver) resolveStatement(file spec.File, statement ast.Statement) (spec.File, error) {
97+
func (r *Resolver) resolveFileStatement(file spec.File, statement ast.Statement) (spec.File, error) {
9798
var err error
9899

99100
switch stmt := statement.(type) {
@@ -102,9 +103,14 @@ func (r *Resolver) resolveStatement(file spec.File, statement ast.Statement) (sp
102103
if err != nil {
103104
return spec.File{}, ErrResolve
104105
}
106+
case ast.PromptStatement:
107+
file, err = r.resolveGlobalPromptStatement(file, stmt)
108+
if err != nil {
109+
return spec.File{}, ErrResolve
110+
}
105111

106112
default:
107-
return spec.File{}, fmt.Errorf("unhandled ast statement: %T", stmt)
113+
return file, fmt.Errorf("unhandled ast statement: %T", stmt)
108114
}
109115

110116
return file, nil
@@ -161,6 +167,32 @@ func (r *Resolver) resolveGlobalVarStatement(file spec.File, statement ast.VarSt
161167
return file, nil
162168
}
163169

170+
// resolveGlobalPromptStatement resolves a top level file @prompt statement and
171+
// adds it to the file, returning the new file containing the prompt.
172+
func (r *Resolver) resolveGlobalPromptStatement(file spec.File, statement ast.PromptStatement) (spec.File, error) {
173+
name := statement.Ident.Name
174+
175+
prompt := spec.Prompt{
176+
Name: name,
177+
Description: statement.Description.Value,
178+
}
179+
180+
if _, exists := file.Prompts[name]; exists {
181+
r.errorf(statement, "prompt %s already declared", name)
182+
return spec.File{}, ErrResolve
183+
}
184+
185+
// Shouldn't need this because file is declared top level with all this
186+
// initialised but let's not panic if we can help it
187+
if file.Prompts == nil {
188+
file.Prompts = make(map[string]spec.Prompt)
189+
}
190+
191+
file.Prompts[statement.Ident.Name] = prompt
192+
193+
return file, nil
194+
}
195+
164196
// resolveExpression resolves an [ast.Expression].
165197
func (r *Resolver) resolveExpression(expression ast.Expression) (string, error) {
166198
switch expr := expression.(type) {

internal/syntax/resolver/resolver_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import (
55
"flag"
66
"os"
77
"path/filepath"
8+
"reflect"
9+
"slices"
810
"strings"
911
"testing"
1012

1113
"go.followtheprocess.codes/test"
1214
"go.followtheprocess.codes/txtar"
15+
"go.followtheprocess.codes/zap/internal/spec"
1316
"go.followtheprocess.codes/zap/internal/syntax"
1417
"go.followtheprocess.codes/zap/internal/syntax/parser/v2"
1518
"go.followtheprocess.codes/zap/internal/syntax/resolver"
@@ -143,3 +146,60 @@ func testFailHandler(tb testing.TB) syntax.ErrorHandler {
143146
tb.Fatalf("%s: %s", pos, msg)
144147
}
145148
}
149+
150+
// TODO(@FollowTheProcess): Benchmark the resolver once it's complete, use the same full.http file
151+
// as the parser benchmark.
152+
153+
func FuzzResolver(f *testing.F) {
154+
// Get all valid .http source from testdata for the corpus
155+
validPattern := filepath.Join("testdata", "valid", "*.txtar")
156+
validFiles, err := filepath.Glob(validPattern)
157+
test.Ok(f, err)
158+
159+
// Invalid ones too!
160+
invalidPattern := filepath.Join("testdata", "invalid", "*.txtar")
161+
invalidFiles, err := filepath.Glob(invalidPattern)
162+
test.Ok(f, err)
163+
164+
files := slices.Concat(validFiles, invalidFiles)
165+
166+
defer goleak.VerifyNone(f)
167+
168+
for _, file := range files {
169+
archive, err := txtar.ParseFile(file)
170+
test.Ok(f, err)
171+
172+
src, ok := archive.Read("src.http")
173+
test.True(f, ok, test.Context("%s missing src.http", file))
174+
175+
// Add the src to the fuzz corpus
176+
f.Add(src)
177+
}
178+
179+
// This also fuzzes the parser (again) but realistically there's no way around that. It's also
180+
// not a bad thing as the parser produces partial trees in error cases to aid error reporting
181+
// so we need to be able to handle these.
182+
183+
// Property: The resolver never panics or loops indefinitely, fuzz by default will
184+
// catch both of these
185+
f.Fuzz(func(t *testing.T, src string) {
186+
parser, err := parser.New("fuzz", strings.NewReader(src), nil)
187+
test.Ok(t, err)
188+
189+
parsed, _ := parser.Parse() //nolint:errcheck // Just checking for panics and infinite loops
190+
191+
res := resolver.New(parsed.Name)
192+
193+
resolved, err := res.Resolve(parsed)
194+
// Property: If there is an error, the file should be the zero spec.File{}
195+
if err != nil {
196+
if !reflect.DeepEqual(resolved, spec.File{}) {
197+
// Marshal it as JSON for readability
198+
resolvedJSON, err := json.MarshalIndent(resolved, "", " ")
199+
test.Ok(t, err)
200+
201+
t.Fatalf("got a non-zero spec.File{} in err != nil case:\n\n%s\n\n", resolvedJSON)
202+
}
203+
}
204+
})
205+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Declaring prompts multiple times with the same id is an error.
2+
3+
-- src.http --
4+
@prompt id User id
5+
@prompt id A different id
6+
@prompt other Something else
7+
@prompt no-description
8+
@prompt other Something else again
9+
-- diagnostics.json --
10+
[
11+
{
12+
"file": "redeclared-prompts.txtar",
13+
"msg": "prompt id already declared",
14+
"highlight": {
15+
"start": 19,
16+
"end": 44
17+
}
18+
},
19+
{
20+
"file": "redeclared-prompts.txtar",
21+
"msg": "prompt other already declared",
22+
"highlight": {
23+
"start": 97,
24+
"end": 131
25+
}
26+
}
27+
]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Top level prompts
2+
3+
-- src.http --
4+
@prompt id User id
5+
@prompt other Something else
6+
@prompt no-description
7+
-- want.json --
8+
{
9+
"name": "global-prompts.txtar",
10+
"prompts": {
11+
"id": {
12+
"name": "id",
13+
"description": "User id"
14+
},
15+
"no-description": {
16+
"name": "no-description"
17+
},
18+
"other": {
19+
"name": "other",
20+
"description": "Something else"
21+
}
22+
}
23+
}

0 commit comments

Comments
 (0)