Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
*.out
/liquid
*.test
.example-repositories
457 changes: 457 additions & 0 deletions .plans/implementation-checklist.md

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions .plans/old/global-variable-segments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# globalVariableSegments — Implementation Plan

> **Status: ✅ IMPLEMENTED** — all tests passing. See `analysis.go`, `render/analysis.go`, `expressions/analysis.go`, `tags/analyzers.go`.

## Goal

Implement static variable analysis equivalent to `globalVariableSegmentsSync()` in LiquidJS:
given a parsed template, return all global variable paths (coming from the outer scope) without executing the template.

```
{{ customer.first_name }} {% assign x = "hello" %} {{ order.total }}
→ [["customer", "first_name"], ["order", "total"]]
```

`x` does not appear: it is defined within the template itself via `assign`.

---

## Architecture: mirror LiquidJS

In LiquidJS, analysis and rendering are **completely separate** layers. Each tag implements optional methods (`arguments()`, `localScope()`, `blockScope()`) that provide expressions/names for analysis — without ever calling rendering code.

In Go Liquid, tag compilers already parse all necessary expressions:
- `ifTagCompiler` calls `e.Parse(node.Args)` → has the condition `Expression`
- `loopTagCompiler` calls `ParseStatement(LoopStatementSelector, ...)` → has `stmt.Expr` and `stmt.Variable`
- `makeAssignTag` calls `ParseStatement(AssignStatementSelector, ...)` → has `stmt.ValueFn` and `stmt.Variable`

This information exists — it's just locked inside opaque closures. The solution: have each tag register its analysis separately, preserving the already-parsed `Expression`s.

---

## Implementation architecture

### Approach: analysis registered alongside the compiler (non-breaking)

Add a parallel "analyzers" registry in the config, completely separate from the existing compilers. No existing signature changes. Built-in tags register their analyzer; custom tags (via `RegisterTag`) have no analysis by default — reasonable behavior identical to LiquidJS.

```
tags/standard_tags.go today:
c.AddTag("assign", makeAssignTag(c))
c.AddBlock("for").Compiler(loopTagCompiler)

with analysis:
c.AddTag("assign", makeAssignTag(c))
c.AddTagAnalyzer("assign", makeAssignAnalyzer()) ← new, separate

c.AddBlock("for").Compiler(loopTagCompiler)
c.AddBlockAnalyzer("for", loopBlockAnalyzer) ← new, separate
```

In `render/compiler.go`, when compiling a node, populate `Analysis` if an analyzer exists:
```go
case *parser.ASTTag:
f, err := td(n.Args)
var analysis NodeAnalysis
if analyzer, ok := c.findTagAnalyzer(n.Name); ok {
analysis = analyzer(n.Args)
}
return &TagNode{n.Token, f, analysis}, nil
```

---

## Files and changes

### 1. `expressions/analysis.go` — new file

Internal `trackingContext` and `trackingValue` types. `trackingContext` implements the `Context` interface and intercepts `Get(name)` returning a `trackingValue` that records property access chains. Used by `computeVariables(evaluator)` to collect all paths referenced by an expression.

### 2. `expressions/expressions.go` — new method on the interface

Add `Variables() [][]string` to the `Expression` interface. Implemented lazily with `sync.Once` on the `expression` struct so it works for both expressions created via `Parse()` and those created internally by the yacc parser.

Note: `expressions/y.go` (generated) was updated to use named field syntax `&expression{evaluator: f}` to accommodate the new fields.

### 3. `render/analysis.go` — analysis types and AST walker

```go
type NodeAnalysis struct {
Arguments []expressions.Expression // variable references "used" by this node
LocalScope []string // variables DEFINED in current scope (assign, capture)
BlockScope []string // variables added to scope for BODY only (for loop var)
}

type TagAnalyzer func(args string) NodeAnalysis
type BlockAnalyzer func(node BlockNode) NodeAnalysis

type AnalysisResult struct {
Globals [][]string // variable paths from outer scope
All [][]string // all variable paths (including locals)
}

func Analyze(root Node) AnalysisResult
```

### 4. `render/nodes.go` — add Analysis field

`Analysis NodeAnalysis` added to `TagNode` and `BlockNode`. `GetExpr()` added to `ObjectNode`.

### 5. `render/config.go` — analyzer registries

`tagAnalyzers`/`blockAnalyzers` maps added to `grammar`. Registration methods `AddTagAnalyzer` and `AddBlockAnalyzer` added to `Config`.

### 6. `render/compiler.go` — populate Analysis at compile time

`Analysis` is populated when compiling `ASTTag` and `ASTBlock` nodes, if an analyzer is registered.

### 7. `tags/analyzers.go` + `tags/standard_tags.go` — register built-in analyzers

Analyzers for: `assign`, `capture`, `for`, `tablerow`, `if`, `unless`, `case`.

### 8. `analysis.go` (root) — public API

```go
func (e *Engine) GlobalVariableSegments(t *Template) ([]VariableSegment, error)
func (e *Engine) VariableSegments(t *Template) ([]VariableSegment, error)
func (t *Template) GlobalVariableSegments() ([]VariableSegment, error)
func (t *Template) VariableSegments() ([]VariableSegment, error)
```

---

## Edge cases

| Case | Behavior |
|---|---|
| `{{ x }}` | `[["x"]]` |
| `{{ x.a.b }}` | `[["x", "a", "b"]]` |
| `{% assign y = x.val %}{{ y }}` | `[["x", "val"]]` — y is local |
| `{% for item in list %}{{ item.name }}{% endfor %}` | `[["list"]]` — item is local |
| `{% if cond %}{{ a }}{% else %}{{ b }}{% endif %}` | `[["cond"], ["a"], ["b"]]` |
| `{{ x \| upcase }}` | `[["x"]]` — filters don't change the path |
| `{% capture buf %}{{ x }}{% endcapture %}` | `[["x"]]` — buf is local |
| `{% assign x = 1 %}{{ x }}` | `[]` — x is local |
| `{% case status %}{% when "active" %}{{ a }}{% endcase %}` | `[["status"], ["a"]]` |

---

## Approach comparison: original vs this

| Aspect | Original (tracking ctx at analysis time) | This (analyzer registry) |
|---|---|---|
| Re-parse of source text | On every analysis call | Never (done once at compile) |
| Tag modifications | None | Register analyzers |
| Extensibility for custom tags | Automatic (works by accident) | Opt-in (no analysis by default) |
| Alignment with LiquidJS | Low | High |
| Walker complexity | Higher (knows each tag's syntax) | Lower (generic walker) |
| Failure point | Re-parse may diverge from compile | Single parse → consistent |

---

## Implementation order (completed)

1. ✅ `expressions/analysis.go`: `trackingContext`, `trackingValue` (internal)
2. ✅ `expressions/expressions.go`: `Variables() [][]string` on interface + `sync.Once` on `expression` struct; `expressions/y.go` updated to named fields
3. ✅ `render/analysis.go`: `NodeAnalysis`, `TagAnalyzer`, `BlockAnalyzer`, walker, `Analyze()`
4. ✅ `render/nodes.go`: `Analysis NodeAnalysis` on `TagNode` and `BlockNode`; `GetExpr()` on `ObjectNode`
5. ✅ `render/config.go`: `tagAnalyzers`/`blockAnalyzers` maps + registration methods
6. ✅ `render/compiler.go`: populate `Analysis` when compiling nodes
7. ✅ `tags/analyzers.go` + `tags/standard_tags.go`: built-in analyzers
8. ✅ `analysis.go` (root): public API (`Engine.GlobalVariableSegments`, `Engine.VariableSegments`, `Template.*`)
9. ✅ `analysis_test.go`: 17 edge case tests — all passing

---

## Out of scope

- Following `{% include %}` into sub-templates
- Tracking dynamic indices (`x[i]` where `i` is a variable)
- `assign` with Jekyll dot notation
- Implementing stubs (`Variables`, `GlobalVariables`, etc.)
211 changes: 211 additions & 0 deletions .plans/old/pre-impl-swarm-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# Swarm Plan — Foundations (PRE)

> This document covers **only the shared dependencies** that need to exist before the items in [implementation-checklist.md](implementation-checklist.md) can be executed independently by agents.
>
> Each PRE is a technical foundation, not a user-visible feature.
> After all PREs are completed, each item in the implementation-checklist can be delegated to an independent agent.

---

## Dependency diagram

```
PRE-A (expression layer)
└─→ checklist: keyword arg filters (default, compact, uniq)
└─→ checklist: empty/blank literals [with PRE-F]
└─→ checklist: string escapes, <>, not, case/when or

PRE-B (filter context-aware) ⚠️ coordinate with PRE-A in expressions/context.go
└─→ checklist: _exp filters (where_exp, reject_exp, group_by_exp, find_exp, find_index_exp, has_exp)

PRE-C (isolated sub-context)
└─→ PRE-D
└─→ checklist: render tag
└─→ checklist: layout/block

PRE-D (globals layer) [dep: PRE-C]
└─→ checklist: render tag (globals propagation)
└─→ checklist: globals engine option

PRE-E (error type system)
└─→ checklist: B4, B6, exported error types

PRE-F (EmptyDrop/BlankDrop)
└─→ checklist: empty/blank literals [with PRE-A]

PRE-G (echo tag) [trivial — can be done by the same agent as liquid tag]
└─→ checklist: liquid tag multi-line

All other checklist items have no PRE deps.
```

---

## Wave 0-α — 6 Foundations in parallel

These six PREs are independent of each other and can be developed by different agents simultaneously.

---

### PRE-A · Expression layer: scanner, parser, yacc

**What it is:** A set of changes in `expressions/scanner.rl` and `expressions/expressions.y` that affect the code generation pipeline (`ragel` → `scanner.go`, `goyacc` → `y.go`). Since they produce shared derived files, **all work in this layer must be done by a single agent** to avoid merge conflicts.

**Changes grouped here:**
- Keyword args in filter calls: `filter: val, key: val2` (argument parser)
- `empty` and `blank` as scanner keywords (not as variable names)
- String escape sequences: `\n`, `\"`, `\'` inside string literals
- `<>` operator as alias for `!=`
- Unary `not` operator in the yacc grammar
- `or` in `when` of `case/when`

**Files in scope:**
```
expressions/scanner.rl (ragel source — edit this)
expressions/scanner.go (generated by ragel — regenerate after editing .rl)
expressions/expressions.y (goyacc source — edit this)
expressions/y.go (generated by goyacc — regenerate after editing .y)
expressions/parser.go (may need adjustments)
expressions/context.go (⚠️ potential conflict with PRE-B — see conflicts section)
```

**Unblocks** (in implementation-checklist.md):
- `default: fallback, allow_false: true`
- `compact: "field"` and `uniq: "field"`
- `empty` and `blank` as special literals (with PRE-F)
- String escapes: `\n`, `\"`, `\'`
- `<>` as alias for `!=`
- Unary `not` operator
- `case/when` with `or`

---

### PRE-B · Context-aware filter infrastructure

**What it is:** Currently filters have the signature `func(value any, args ...any) (any, error)` with no access to the render context. The `_exp` filters need to evaluate Liquid expressions per array item, which requires access to `render.Context`. A mechanism for registering filters that receive the context as an extra parameter is needed.

**Suggested approach:** new registration type `AddContextFilter(name string, fn func(ctx Context, value any, args ...any) any)`. In the internal dispatch (`expressions/context.go`, `ApplyFilter`), check whether the filter is "context-aware" and inject the context if so.

**Files in scope:**
```
expressions/context.go (ApplyFilter — ⚠️ conflict with PRE-A)
expressions/functional.go (filter dispatch / wrapping)
render/config.go (AddFilter / AddContextFilter)
```

**Unblocks** (in implementation-checklist.md):
- `where_exp`, `reject_exp`, `group_by_exp`, `find_exp`, `find_index_exp`, `has_exp`

---

### PRE-C · Isolated sub-context in render.Context

**What it is:** When using the `render` tag, the rendered template should not inherit variables from the parent — only what is explicitly passed. Currently `nodeContext` does `maps.Copy` of the bindings (the comment in the code even marks `TODO: this isn't really the right place for this`). Needs a `SpawnIsolated(bindings map[string]any) nodeContext` method in `render/node_context.go`.

**Difference between include and render:**
- `include`: shares full scope (current behavior)
- `render`: isolated scope — only explicitly passed args are visible in the partial

**Files in scope:**
```
render/node_context.go (add SpawnIsolated)
render/context.go (expose via Context interface if necessary)
```

**Unblocks** (in implementation-checklist.md):
- `render` tag (isolated scope)
- `layout`/`block` (template inheritance)
- Also prerequisite for PRE-D

---

### PRE-E · Exported error type system

**What it is:** Today all render and parse errors return generic types or `SourceError` without distinction of origin. Distinct exported types are needed to allow programmatic handling by the caller.

**Types to define:**
```go
type ParseError struct { SourceError }
type RenderError struct { SourceError }
type UndefinedVariableError struct { ... } // must include the literal variable name
type ZeroDivisionError struct { ... }
```

**Files in scope:**
```
render/error.go
parser/error.go
```

**Unblocks** (in implementation-checklist.md):
- B4: distinct error types
- B6: variable error messages (the `UndefinedVariableError` with the literal variable name solves part of the problem)
- `ZeroDivisionError` in `modulo` and `divided_by`
- `SyntaxError`, `ArgumentError`, `ContextError`

---

### PRE-F · EmptyDrop and BlankDrop (values layer)

**What it is:** The `values/` layer needs two singletons with special comparison semantics. The scanner part (making `empty` and `blank` recognized as keywords instead of variable names) stays in PRE-A. This PRE covers only the corresponding Go types.

**Semantics:**
- `empty`: compares as equal to `""`, `[]any{}`, `map{}` (any "empty" value)
- `blank`: like `empty`, plus `nil`, `false`, strings with only whitespace

**Files in scope:**
```
values/value.go (or new values/emptydrop.go)
values/compare.go (Equal must recognize EmptyDrop/BlankDrop)
values/predicates.go (IsBlank, IsEmpty)
```

**Unblocks** (in implementation-checklist.md):
- `empty` and `blank` as special literals (with PRE-A)
- `EmptyDrop`, `BlankDrop` as exported singletons in the public API

---

### PRE-G · `echo` tag

**What it is:** Register the `echo` tag that evaluates an expression and writes to the writer — identical to `{{ expr }}` but as a tag. It is the smallest PRE but it blocks the multi-line `liquid` tag, which depends on `echo` to produce output line by line.

**Files in scope:**
```
tags/standard_tags.go
```

**Unblocks** (in implementation-checklist.md):
- `liquid` multi-line tag

---

## Wave 0-β — Sequential foundation

---

### PRE-D · Globals layer · [dep: PRE-C]

**What it is:** A level of variables that persists through `SpawnIsolated()` — unlike the bindings scope which is cut in a `render` tag sub-context. The caller passes globals via `engine.SetGlobals(map[string]any{})` and they are accessible in any partial rendered via `render` tag.

**Lookup behavior:** `bindings[key]` → if not found, `globals[key]`.

**Files in scope:**
```
render/node_context.go (add globals field, propagate in SpawnIsolated)
render/config.go (optional: globals in Config)
engine.go (SetGlobals / GetGlobals)
```

**Unblocks** (in implementation-checklist.md):
- `render` tag with globals propagation
- `globals` as engine option

---

## Known conflicts between PREs

| Risk | PREs | File | Resolution |
|------|------|------|-----------|
| High | PRE-A ↔ PRE-B | `expressions/context.go` (`ApplyFilter`) | Do PRE-A first; PRE-B applies on top |
| Low | PRE-C ↔ PRE-D | `render/node_context.go` | Sequential by design (PRE-D depends on PRE-C) |
Loading