Skip to content

Commit 1810242

Browse files
committed
feat: implement literal argument handling in pipeline operations
1 parent 11f9346 commit 1810242

5 files changed

Lines changed: 175 additions & 65 deletions

File tree

README.md

Lines changed: 72 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,20 @@ go get github.com/MontFerret/cssx
2121
package main
2222

2323
import (
24-
"fmt"
24+
"fmt"
2525

26-
"github.com/MontFerret/cssx"
26+
"github.com/MontFerret/cssx"
2727
)
2828

2929
func main() {
30-
pipeline, err := cssx.Compile(":text(:first(h1))")
31-
if err != nil {
32-
panic(err)
33-
}
34-
35-
for _, op := range pipeline.Ops {
36-
fmt.Printf("%s: %+v\n", op.Kind, op)
37-
}
30+
pipeline, err := cssx.Compile(`:attr("href", :first(a.cta))`)
31+
if err != nil {
32+
panic(err)
33+
}
34+
35+
for _, op := range pipeline.Ops {
36+
fmt.Printf("%s: %+v\n", op.Kind, op)
37+
}
3838
}
3939
```
4040

@@ -48,22 +48,26 @@ Humans write nested pseudo calls like:
4848

4949
cssx compiles them into a linear postfix pipeline:
5050

51-
- native CSS selector steps (`Select`)
52-
- pseudo/function steps (`Call`)
53-
- constants (`Str`, `Num`)
51+
- selector steps (`Select`)
52+
- call steps (`Call`)
53+
- literal call args embedded in each `Call.Args`
5454

5555
Example:
5656

5757
Input:
5858

5959
```
60-
:text(:first(h1))
60+
:attr("href", :first(a.cta))
6161
```
6262

6363
Pipeline:
6464

6565
```
66-
[Select("h1"), Call("first",1), Call("text",1)]
66+
[
67+
Select("a.cta"),
68+
Call("first", Arity:1, Args:[]),
69+
Call("attr", Arity:1, Args:[String("href")]),
70+
]
6771
```
6872

6973
## Syntax
@@ -113,37 +117,56 @@ Rules:
113117
Compiles to:
114118

115119
```
116-
[Select(".section .item"), Num(2), Call("nth",1), Call("text",0)]
120+
[
121+
Select(".section .item"),
122+
Call("nth", Arity:0, Args:[Number(2)]),
123+
Call("text", Arity:0, Args:[]),
124+
]
117125
```
118126

119-
## Supported Examples
127+
## IR Contract
128+
129+
### `Arity`
120130

121-
Plain selectors:
131+
`Op.Arity` is the number of non-literal expression args consumed from the stack.
122132

123-
- `.product` -> `[Select(".product")]`
124-
- `.section .item` -> `[Select(".section .item")]`
133+
### `Args`
125134

126-
Single pseudo:
135+
`Op.Args` contains only literal arguments (`string` or `number`) in source order.
127136

128-
- `:count(.product)` -> `[Select(".product"), Call("count",1)]`
129-
- `:first(section)` -> `[Select("section"), Call("first",1)]`
137+
### `literals-first` rule
130138

131-
Nested pseudos:
139+
Literal args must come before expression args inside a call.
132140

133-
- `:text(:first(h1))` -> `[Select("h1"), Call("first",1), Call("text",1)]`
134-
- `:attr("href", :first(a.cta))` -> `[Str("href"), Select("a.cta"), Call("first",1), Call("attr",2)]`
141+
Valid:
135142

136-
Selectors with commas inside:
143+
- `:foo("x", 2, :bar(a))`
137144

138-
- `:first(a[href*="x,y"])` -> `[Select("a[href*="x,y"]"), Call("first",1)]`
145+
Invalid:
139146

140-
Mixed readability:
147+
- `:foo(:bar(a), "x")`
148+
- `:foo(1, :bar(a), 2)`
141149

142-
- `:text(:nth(2, .section .item))` -> `[Num(2), Select(".section .item"), Call("nth",2), Call("text",1)]`
150+
Invalid calls fail at compile time with:
143151

144-
Pipeline sugar:
152+
- `literal args must precede expression args`
145153

146-
- `.section .item >> :nth(2) >> :text()` -> `[Select(".section .item"), Num(2), Call("nth",1), Call("text",0)]`
154+
## Supported Examples
155+
156+
- `.product`
157+
- `[Select(".product")]`
158+
- `:count(.product)`
159+
- `[Select(".product"), Call("count", Arity:1, Args:[])]`
160+
- `:text(:first(h1))`
161+
- `[Select("h1"), Call("first", Arity:1, Args:[]), Call("text", Arity:1, Args:[])]`
162+
- `:attr("href", :first(a.cta))`
163+
- `[Select("a.cta"), Call("first", Arity:1, Args:[]), Call("attr", Arity:1, Args:[String("href")])]`
164+
- `:first(a[href*="x,y"])`
165+
- `[Select("a[href*=\"x,y\"]"), Call("first", Arity:1, Args:[])]`
166+
- `:text(:nth(2, .section .item))`
167+
- `[Select(".section .item"), Call("nth", Arity:1, Args:[Number(2)]), Call("text", Arity:1, Args:[])]`
168+
- `.section .item >> :nth(2) >> :text()`
169+
- `[Select(".section .item"), Call("nth", Arity:0, Args:[Number(2)]), Call("text", Arity:0, Args:[])]`
147170

148171
## Public API
149172

@@ -167,36 +190,42 @@ Key types:
167190

168191
```go
169192
type Pipeline struct {
170-
Ops []Op
193+
Ops []Op
171194
}
172195

173196
type Op struct {
174-
Kind OpKind
175-
Selector string
176-
Name string
177-
Arity int
178-
Str string
179-
Num float64
197+
Kind OpKind
198+
Selector string
199+
Name string
200+
Arity int
201+
Args []CallArg
202+
}
203+
204+
type CallArg struct {
205+
Kind CallArgKind
206+
Str string
207+
Num float64
180208
}
181209

182210
type ParseError struct {
183-
Message string
184-
Pos int // byte offset
211+
Message string
212+
Pos int // byte offset
185213
}
186214
```
187215

188216
## Error Handling
189217

190-
All syntax errors return `*ParseError` with a byte offset position.
218+
All syntax errors and compile-time IR validation errors return `*ParseError` with a byte offset position.
191219

192220
Common error cases:
193221

194222
- `:` (missing identifier)
195223
- `:text(` (unterminated call)
196-
- `:text(:first(h1)` (missing `)`)
224+
- `:text(:first(h1)` (missing `)`)
197225
- `:attr("href" :first(a))` (missing comma)
198226
- empty input (whitespace only)
199227
- pipeline stage without `:name(...)`
228+
- mixed call arg order violating literals-first
200229

201230
## Non-Goals
202231

bench_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ var benchInputs = []struct {
1616
}
1717

1818
func BenchmarkParse(b *testing.B) {
19+
b.ReportAllocs()
1920
for _, tc := range benchInputs {
2021
b.Run(tc.name, func(b *testing.B) {
22+
b.ReportAllocs()
2123
for i := 0; i < b.N; i++ {
2224
if _, err := Parse(tc.input); err != nil {
2325
b.Fatal(err)
@@ -28,6 +30,7 @@ func BenchmarkParse(b *testing.B) {
2830
}
2931

3032
func BenchmarkBuildPipeline(b *testing.B) {
33+
b.ReportAllocs()
3134
asts := make([]AST, len(benchInputs))
3235
for i, tc := range benchInputs {
3336
ast, err := Parse(tc.input)
@@ -41,6 +44,7 @@ func BenchmarkBuildPipeline(b *testing.B) {
4144
for i, tc := range benchInputs {
4245
ast := asts[i]
4346
b.Run(tc.name, func(b *testing.B) {
47+
b.ReportAllocs()
4448
for i := 0; i < b.N; i++ {
4549
if _, err := BuildPipeline(ast); err != nil {
4650
b.Fatal(err)
@@ -51,8 +55,10 @@ func BenchmarkBuildPipeline(b *testing.B) {
5155
}
5256

5357
func BenchmarkCompile(b *testing.B) {
58+
b.ReportAllocs()
5459
for _, tc := range benchInputs {
5560
b.Run(tc.name, func(b *testing.B) {
61+
b.ReportAllocs()
5662
for i := 0; i < b.N; i++ {
5763
if _, err := Compile(tc.input); err != nil {
5864
b.Fatal(err)

parser_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ func TestParsePipelineSugar(t *testing.T) {
4747
}
4848
}
4949

50+
func TestParseMixedArgOrderAllowed(t *testing.T) {
51+
ast, err := Parse(":foo(:bar(a), \"x\")")
52+
if err != nil {
53+
t.Fatalf("unexpected parse error: %v", err)
54+
}
55+
if _, ok := ast.Expr.(*CallExpr); !ok {
56+
t.Fatalf("expected CallExpr, got %T", ast.Expr)
57+
}
58+
}
59+
5060
func TestParseErrors(t *testing.T) {
5161
cases := []string{
5262
":",

pipeline.go

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ type OpKind int
88
const (
99
OpSelect OpKind = iota
1010
OpCall
11-
OpStr
12-
OpNum
1311
)
1412

1513
func (k OpKind) String() string {
@@ -18,23 +16,33 @@ func (k OpKind) String() string {
1816
return "Select"
1917
case OpCall:
2018
return "Call"
21-
case OpStr:
22-
return "Str"
23-
case OpNum:
24-
return "Num"
2519
default:
2620
return fmt.Sprintf("OpKind(%d)", int(k))
2721
}
2822
}
2923

24+
// CallArgKind identifies the kind of a call literal argument.
25+
type CallArgKind int
26+
27+
const (
28+
CallArgString CallArgKind = iota
29+
CallArgNumber
30+
)
31+
32+
// CallArg is an embedded literal argument for a call operation.
33+
type CallArg struct {
34+
Kind CallArgKind
35+
Str string
36+
Num float64
37+
}
38+
3039
// Op is a single pipeline operation in postfix order.
3140
type Op struct {
3241
Kind OpKind
3342
Selector string
3443
Name string
3544
Arity int
36-
Str string
37-
Num float64
45+
Args []CallArg
3846
}
3947

4048
// Pipeline is a linear postfix pipeline representation.
@@ -59,16 +67,11 @@ func buildExpr(expr Expr, p *Pipeline) error {
5967
case *SelectorExpr:
6068
p.Ops = append(p.Ops, Op{Kind: OpSelect, Selector: e.Raw})
6169
case *StringLit:
62-
p.Ops = append(p.Ops, Op{Kind: OpStr, Str: e.Value})
70+
return &ParseError{Message: "literal values are only allowed as call arguments", Pos: e.Pos()}
6371
case *NumberLit:
64-
p.Ops = append(p.Ops, Op{Kind: OpNum, Num: e.Value})
72+
return &ParseError{Message: "literal values are only allowed as call arguments", Pos: e.Pos()}
6573
case *CallExpr:
66-
for _, arg := range e.Args {
67-
if err := buildExpr(arg, p); err != nil {
68-
return err
69-
}
70-
}
71-
p.Ops = append(p.Ops, Op{Kind: OpCall, Name: e.Name, Arity: len(e.Args)})
74+
return buildCall(e, p)
7275
case *PipelineExpr:
7376
if e.Base == nil {
7477
return &ParseError{Message: "pipeline missing base selector", Pos: e.Pos()}
@@ -87,6 +90,33 @@ func buildExpr(expr Expr, p *Pipeline) error {
8790
return nil
8891
}
8992

93+
func buildCall(call *CallExpr, p *Pipeline) error {
94+
op := Op{Kind: OpCall, Name: call.Name}
95+
seenExpr := false
96+
for _, arg := range call.Args {
97+
switch a := arg.(type) {
98+
case *StringLit:
99+
if seenExpr {
100+
return &ParseError{Message: "literal args must precede expression args", Pos: a.Pos()}
101+
}
102+
op.Args = append(op.Args, CallArg{Kind: CallArgString, Str: a.Value})
103+
case *NumberLit:
104+
if seenExpr {
105+
return &ParseError{Message: "literal args must precede expression args", Pos: a.Pos()}
106+
}
107+
op.Args = append(op.Args, CallArg{Kind: CallArgNumber, Num: a.Value})
108+
default:
109+
seenExpr = true
110+
if err := buildExpr(arg, p); err != nil {
111+
return err
112+
}
113+
op.Arity++
114+
}
115+
}
116+
p.Ops = append(p.Ops, op)
117+
return nil
118+
}
119+
90120
// Compile parses input and builds a pipeline.
91121
func Compile(input string) (Pipeline, error) {
92122
ast, err := Parse(input)

0 commit comments

Comments
 (0)