Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 83 additions & 24 deletions pkg/script/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,22 @@ func (e ParseError) Unwrap() error { return e.Err }

// === Participle grammar ====================================================
//
// The grammar is intentionally minimal for the MVP:
// The grammar supports sequential pipelines and parenthesized parallel
// fan-out, matching the original internal/agentscript grammar:
//
// script = block+
// block = backend mode "(" expression ")"
// backend = "memory" | "temporal"
// mode = "static" | "dynamic"
// expression = pipeline
// pipeline = call ( ">=>" call )*
// call = IDENT ( STRING )*
// script = block+
// block = backend mode "(" pipeline ")"
// backend = "memory" | "temporal"
// mode = "static" | "dynamic"
// pipeline = stage ( ">=>" stage )*
// stage = call | group
// group = "(" pipeline ( "<*>" pipeline )* ")"
// call = IDENT ( STRING )*
//
// Parallel ("<*>") is intentionally NOT in the MVP grammar — the AST
// can represent it but the parser won't produce it yet.
// A group with one inner pipeline is just grouping/precedence; a group
// with two or more "<*>"-separated pipelines is a parallel fan-out. This
// lets ( a <*> b ) >=> merge and arbitrary nesting parse, exactly as the
// original runtime grammar did.

// parsedScript is the participle-level root.
type parsedScript struct {
Expand All @@ -89,33 +93,46 @@ type parsedBlock struct {
CloseP struct{} `")"`
}

// parsedPipeline matches: <call> ( ">=>" <call> )*
//
// Note: this only supports pipelines of calls in the MVP. Once Parallel
// and grouping syntax are added, this becomes a more general expression
// production.
// parsedPipeline matches: <stage> ( ">=>" <stage> )*
type parsedPipeline struct {
First *parsedCall `@@`
Rest []*parsedCall `( ">=>" @@ )*`
First *parsedStage `@@`
Rest []*parsedStage `( ">=>" @@ )*`
}

// parsedStage is one element of a pipeline: either a call or a
// parenthesized group (which may be a parallel fan-out).
type parsedStage struct {
Group *parsedGroup ` @@`
Call *parsedCall `| @@`
}

// parsedGroup matches: "(" <pipeline> ( "<*>" <pipeline> )* ")"
// One branch ⇒ grouping; two or more ⇒ parallel fan-out.
type parsedGroup struct {
Open struct{} `"("`
First *parsedPipeline `@@`
Branches []*parsedPipeline `( "<*>" @@ )*`
Close struct{} `")"`
}

// parsedCall matches: <ident> <string>*
//
// Identifier is anything matching the Ident token; arguments are
// double-quoted strings. The MVP accepts zero or more string args; the
// double-quoted strings. The parser accepts zero or more string args; the
// resolver enforces arity later.
type parsedCall struct {
Name string `@Ident`
Args []string `@String*`
}

// scriptLexer defines tokens. Order matters: longer/more-specific
// tokens come first so they beat shorter prefixes (e.g. ">=>" wins
// against ">" if we ever add it).
// tokens come first so they beat shorter prefixes (e.g. ">=>" and "<*>"
// win against single-char punctuation).
var scriptLexer = lexer.MustSimple([]lexer.SimpleRule{
{Name: "Comment", Pattern: `//[^\n]*`},
{Name: "Whitespace", Pattern: `[ \t\r\n]+`},
{Name: "Arrow", Pattern: `>=>`},
{Name: "Fanout", Pattern: `<\*>`},
{Name: "Punct", Pattern: `[()]`},
{Name: "String", Pattern: `"[^"]*"`},
{Name: "Ident", Pattern: `[a-zA-Z_][a-zA-Z0-9_]*`},
Expand Down Expand Up @@ -198,21 +215,63 @@ func pipelineToAST(pp *parsedPipeline) (ast.Node, error) {
return nil, fmt.Errorf("empty block body")
}
stages := make([]ast.Node, 0, 1+len(pp.Rest))
first, err := callToAST(pp.First)
first, err := stageToAST(pp.First)
if err != nil {
return nil, fmt.Errorf("stage 0: %w", err)
}
stages = append(stages, first)
for i, pc := range pp.Rest {
c, err := callToAST(pc)
for i, ps := range pp.Rest {
s, err := stageToAST(ps)
if err != nil {
return nil, fmt.Errorf("stage %d: %w", i+1, err)
}
stages = append(stages, c)
stages = append(stages, s)
}
return ast.Pipeline{Stages: stages}, nil
}

// stageToAST converts one pipeline stage — either a call or a
// parenthesized group — into an AST node.
func stageToAST(ps *parsedStage) (ast.Node, error) {
switch {
case ps == nil:
return nil, fmt.Errorf("nil stage")
case ps.Group != nil:
return groupToAST(ps.Group)
case ps.Call != nil:
return callToAST(ps.Call)
default:
return nil, fmt.Errorf("empty stage")
}
}

// groupToAST converts a parenthesized group. One inner pipeline ⇒ just
// that pipeline (grouping/precedence). Two or more "<*>"-separated
// pipelines ⇒ a Parallel fan-out whose branches are those pipelines.
func groupToAST(pg *parsedGroup) (ast.Node, error) {
if pg == nil || pg.First == nil {
return nil, fmt.Errorf("empty group")
}
first, err := pipelineToAST(pg.First)
if err != nil {
return nil, fmt.Errorf("group branch 0: %w", err)
}
if len(pg.Branches) == 0 {
// Pure grouping — unwrap to the inner pipeline node.
return first, nil
}
branches := make([]ast.Node, 0, 1+len(pg.Branches))
branches = append(branches, first)
for i, pb := range pg.Branches {
b, err := pipelineToAST(pb)
if err != nil {
return nil, fmt.Errorf("group branch %d: %w", i+1, err)
}
branches = append(branches, b)
}
return ast.Parallel{Branches: branches}, nil
}

func callToAST(pc *parsedCall) (ast.Node, error) {
if pc == nil || pc.Name == "" {
return nil, fmt.Errorf("missing call name")
Expand Down
84 changes: 84 additions & 0 deletions pkg/script/parse_parallel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package script_test

import (
"context"
"testing"

"github.com/vinodhalaharvi/agentscript/pkg/script"
"github.com/vinodhalaharvi/agentscript/pkg/script/ast"
)

// The new parser MUST support parenthesized parallel <*>, matching the
// original internal/agentscript grammar. This is a hard regression guard:
// <*> worked in the original runtime and must work in pkg/script too.
func TestParse_ParallelForms(t *testing.T) {
cases := []string{
`memory static ( ( a "x" <*> b "y" ) )`,
`memory static ( ( a "x" <*> b "y" <*> c "z" ) )`,
`memory static ( ( a "x" <*> b "y" ) >=> merge )`,
`memory static ( ( a >=> b <*> c >=> d ) >=> merge >=> e "q" )`,
`temporal static ( ( echo "x" <*> echo "y" ) )`,
}
for _, src := range cases {
if _, err := script.Parse(context.Background(), script.Source(src)); err != nil {
t.Errorf("parse failed for %q: %v", src, err)
}
}
}

// The parser must produce an ast.Parallel node for a multi-branch group.
// Block bodies are always wrapped in a Pipeline (the uniform invariant),
// so for `( ( a <*> b ) )` the body is a one-stage Pipeline whose stage
// is the Parallel.
func TestParse_ProducesParallelNode(t *testing.T) {
a, err := script.Parse(context.Background(), script.Source(`memory static ( ( a "x" <*> b "y" ) )`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(a.Blocks) != 1 {
t.Fatalf("blocks = %d", len(a.Blocks))
}
pipe, ok := a.Blocks[0].Body.(ast.Pipeline)
if !ok {
t.Fatalf("body should be ast.Pipeline, got %T", a.Blocks[0].Body)
}
if len(pipe.Stages) != 1 {
t.Fatalf("stages = %d, want 1", len(pipe.Stages))
}
par, ok := pipe.Stages[0].(ast.Parallel)
if !ok {
t.Fatalf("stage should be ast.Parallel, got %T", pipe.Stages[0])
}
if len(par.Branches) != 2 {
t.Errorf("branches = %d, want 2", len(par.Branches))
}
}

// A single-branch group is just grouping — its body must not contain a
// Parallel node.
func TestParse_SingleGroupIsNotParallel(t *testing.T) {
a, err := script.Parse(context.Background(), script.Source(`memory static ( ( a "x" ) )`))
if err != nil {
t.Fatalf("parse: %v", err)
}
pipe, ok := a.Blocks[0].Body.(ast.Pipeline)
if !ok {
t.Fatalf("body should be ast.Pipeline, got %T", a.Blocks[0].Body)
}
if _, isPar := pipe.Stages[0].(ast.Parallel); isPar {
t.Error("single-branch group must not be a Parallel")
}
}

// End to end: a parallel program parses AND resolves against the full
// registry (the complex grammar the CLI examples use).
func TestParse_ComplexGrammarResolves(t *testing.T) {
src := `memory static ( ( search "x" >=> analyze "s" <*> search "y" >=> analyze "s" ) >=> merge >=> ask "who wins?" )`
a, err := script.Parse(context.Background(), script.Source(src))
if err != nil {
t.Fatalf("parse: %v", err)
}
if _, err := script.Resolve(context.Background(), script.CompleteRegistry(), a); err != nil {
t.Fatalf("resolve: %v", err)
}
}
Loading