1
0
mirror of https://github.com/danog/liquid.git synced 2024-11-30 05:58:59 +01:00
This commit is contained in:
Oliver Steele 2017-06-25 11:23:20 -04:00
commit 58395a8ab4
8 changed files with 415 additions and 0 deletions

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# Go Liquid Template Parser
`goliquid` is a Go implementation of the [Shopify Liquid template language](https://shopify.github.io/liquid/tags/variable/), for use in [Gojekyll](https://github.com/osteele/gojekyll).
## Status
- [ ] Basics
- [ ] Constants
- [ ] Variables
- [ ] Operators
- [ ] Arrays
- [ ] Whitespace Control
- [ ] Tags
- [ ] Comment
- [ ] Control Flow
- [ ] Iteration
- [ ] for
- [ ] limit, offset, range, reversed
- [ ] break, continue
- [ ] loop variables
- [ ] tablerow
- [ ] cycle
- [ ] Raw
- [ ] Variable
- [ ] Assign
- [ ] Capture
- [ ] Filters
## Install
`go get -u github.com/osteele/goliquid`
## Develop
```bash
go get golang.org/x/tools/cmd/stringer
```
```bash
go generate
```

64
ast.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"io"
yaml "gopkg.in/yaml.v2"
)
type AST interface {
Render(io.Writer, Context) error
// String() string
}
type ASTSeq struct {
Children []AST
}
type ASTChunks struct {
chunks []Chunk
}
type ASTText struct {
chunk Chunk
}
type ASTObject struct {
chunk Chunk
}
type ASTControlTag struct {
chunk Chunk
cd *ControlTagDefinition
body []AST
branches []*ASTControlTag
}
func (n ASTSeq) String() string {
b, err := yaml.Marshal(n)
if err != nil {
panic(err)
}
return string(b)
}
func (n ASTChunks) MarshalYAML() (interface{}, error) {
return map[string]interface{}{"leaf": n.chunks}, nil
}
func (n ASTControlTag) MarshalYAML() (interface{}, error) {
return map[string]map[string]interface{}{
n.cd.Name: {
"args": n.chunk.Args,
"body": n.body,
"branches": n.branches,
}}, nil
}
func (n ASTText) MarshalYAML() (interface{}, error) {
return n.chunk.MarshalYAML()
}
func (n ASTObject) MarshalYAML() (interface{}, error) {
return n.chunk.MarshalYAML()
}

67
chunk_parser.go Normal file
View File

@ -0,0 +1,67 @@
package main
import (
"fmt"
)
func Parse(chunks []Chunk) (AST, error) {
type frame struct {
cd *ControlTagDefinition
cn *ASTControlTag
ap *[]AST
}
var (
root = &ASTSeq{}
ap = &root.Children // pointer to current node accumulation slice
ccd *ControlTagDefinition
ccn *ASTControlTag
stack []frame // stack of control structures
)
for _, c := range chunks {
switch c.Type {
case ObjChunk:
*ap = append(*ap, &ASTObject{chunk: c})
case TextChunk:
*ap = append(*ap, &ASTText{chunk: c})
case TagChunk:
if cd, ok := FindControlDefinition(c.Tag); ok {
switch {
case cd.RequiresParent() && cd.Parent != ccd:
suffix := ""
if ccd != nil {
suffix = "; immediate parent is " + ccd.Name
}
return nil, fmt.Errorf("%s not inside %s%s", cd.Name, cd.Parent.Name, suffix)
case cd.IsStartTag():
stack = append(stack, frame{cd: ccd, cn: ccn, ap: ap})
ccd, ccn = cd, &ASTControlTag{chunk: c, cd: cd}
*ap = append(*ap, ccn)
ap = &ccn.body
case cd.IsBranchTag:
n := &ASTControlTag{chunk: c, cd: cd}
ccn.branches = append(ccn.branches, n)
ap = &n.body
case cd.IsEndTag:
f := stack[len(stack)-1]
ccd, ccn, ap, stack = f.cd, f.cn, f.ap, stack[:len(stack)-1]
}
} else if len(*ap) > 0 {
switch n := ((*ap)[len(*ap)-1]).(type) {
case *ASTChunks:
n.chunks = append(n.chunks, c)
default:
*ap = append(*ap, &ASTChunks{chunks: []Chunk{c}})
}
} else {
*ap = append(*ap, &ASTChunks{chunks: []Chunk{c}})
}
}
}
if ccd != nil {
return nil, fmt.Errorf("unterminated %s tag", ccd.Name)
}
if len(root.Children) == 1 {
return root.Children[0], nil
}
return root, nil
}

72
chunk_scanner.go Normal file
View File

@ -0,0 +1,72 @@
package main
import (
"fmt"
"regexp"
)
type Chunk struct {
Type ChunkType
SourceInfo SourceInfo
Source, Tag, Args string
}
type SourceInfo struct {
Pathname string
lineNo int
}
type ChunkType int
//go:generate stringer -type=ChunkType
const (
TextChunk ChunkType = iota
TagChunk
ObjChunk
)
var chunkMatcher = regexp.MustCompile(`{{\s*(.+?)\s*}}|{%\s*(\w+)(?:\s+(.+?))?\s*%}`)
// MarshalYAML, for debugging
func (c Chunk) MarshalYAML() (interface{}, error) {
switch c.Type {
case TextChunk:
return map[string]interface{}{"text": c.Source}, nil
case TagChunk:
return map[string]interface{}{"tag": c.Tag, "args": c.Args}, nil
case ObjChunk:
return map[string]interface{}{"obj": c.Tag}, nil
default:
return nil, fmt.Errorf("unknown chunk tag type: %v", c.Type)
}
}
func ScanChunks(data string, pathname string) []Chunk {
var (
sourceInfo = SourceInfo{pathname, 0}
out = make([]Chunk, 0)
p, pe = 0, len(data)
matches = chunkMatcher.FindAllStringSubmatchIndex(data, -1)
)
for _, m := range matches {
ts, te := m[0], m[1]
if p < ts {
out = append(out, Chunk{TextChunk, sourceInfo, data[p:ts], "", ""})
}
switch data[ts+1] {
case '{':
out = append(out, Chunk{ObjChunk, sourceInfo, data[ts:te], data[m[2]:m[3]], ""})
case '%':
var args string
if m[6] > 0 {
args = data[m[6]:m[7]]
}
out = append(out, Chunk{TagChunk, sourceInfo, data[ts:te], data[m[4]:m[5]], args})
}
p = te
}
if p < pe {
out = append(out, Chunk{TextChunk, sourceInfo, data[p:], "", ""})
}
return out
}

16
chunktype_string.go Normal file
View File

@ -0,0 +1,16 @@
// Code generated by "stringer -type=ChunkType"; DO NOT EDIT.
package main
import "fmt"
const _ChunkType_name = "TextChunkTagChunkObjChunk"
var _ChunkType_index = [...]uint8{0, 9, 17, 25}
func (i ChunkType) String() string {
if i < 0 || i >= ChunkType(len(_ChunkType_index)-1) {
return fmt.Sprintf("ChunkType(%d)", i)
}
return _ChunkType_name[_ChunkType_index[i]:_ChunkType_index[i+1]]
}

72
control_tags.go Normal file
View File

@ -0,0 +1,72 @@
package main
import (
"fmt"
"io"
)
func init() {
loopTags := []string{"break", "continue", "cycle"}
DefineControlTag("comment").Action(unimplementedControlTag)
DefineControlTag("if").Branch("else").Branch("elseif").Action(unimplementedControlTag)
DefineControlTag("unless").Action(unimplementedControlTag)
DefineControlTag("case").Branch("when").Action(unimplementedControlTag)
DefineControlTag("for").Governs(loopTags).Action(unimplementedControlTag)
DefineControlTag("tablerow").Governs(loopTags).Action(unimplementedControlTag)
DefineControlTag("capture").Action(unimplementedControlTag)
}
// ControlTagDefinitions is a map of tag names to control tag definitions.
var ControlTagDefinitions = map[string]*ControlTagDefinition{}
// ControlTagAction runs the interpreter.
type ControlTagAction func(io.Writer, Context) error
// ControlTagDefinition tells the parser how to parse control tags.
type ControlTagDefinition struct {
Name string
IsBranchTag bool
IsEndTag bool
Parent *ControlTagDefinition
}
func (c *ControlTagDefinition) RequiresParent() bool {
return c.IsBranchTag || c.IsEndTag
}
func (c *ControlTagDefinition) IsStartTag() bool {
return !c.IsBranchTag && !c.IsEndTag
}
// DefineControlTag defines a control tag and its matching end tag.
func DefineControlTag(name string) *ControlTagDefinition {
ct := &ControlTagDefinition{Name: name}
addControlTagDefinition(ct)
addControlTagDefinition(&ControlTagDefinition{Name: "end" + name, IsEndTag: true, Parent: ct})
return ct
}
func FindControlDefinition(name string) (*ControlTagDefinition, bool) {
ct, found := ControlTagDefinitions[name]
return ct, found
}
func addControlTagDefinition(ct *ControlTagDefinition) {
ControlTagDefinitions[ct.Name] = ct
}
func (ct *ControlTagDefinition) Branch(name string) *ControlTagDefinition {
addControlTagDefinition(&ControlTagDefinition{Name: name, IsBranchTag: true, Parent: ct})
return ct
}
func (ct *ControlTagDefinition) Governs(_ []string) *ControlTagDefinition {
return ct
}
func (ct *ControlTagDefinition) Action(_ ControlTagAction) {
}
func unimplementedControlTag(io.Writer, Context) error {
return fmt.Errorf("unimplementedControlTag")
}

49
liquid_test.go Normal file
View File

@ -0,0 +1,49 @@
//go:generate ragel -Z liquid.rl
package main
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/require"
)
var tests = []struct{ in, out string }{
{"pre</head>post", "pre:insertion:</head>post"},
{"pre:insertion:</head>post", "pre:insertion:</head>post"},
{"post", ":insertion:post"},
}
func TestLiquid(t *testing.T) {
tokens := ScanChunks("pre{%if 1%}left{{x}}right{%endif%}post", "")
// fmt.Println("tokens =", tokens)
ast, err := Parse(tokens)
require.NoError(t, err)
fmt.Println("ast =", ast)
err = ast.Render(os.Stdout, nil)
require.NoError(t, err)
fmt.Println()
require.True(t, true)
return
for _, test := range chunkTests {
tokens := ScanChunks(test.in, "")
ast, err := Parse(tokens)
require.NoError(t, err)
actual := ast.Render(os.Stdout, nil)
require.Equal(t, test.expected, actual)
}
}
type chunkTest struct {
in string
expected string
}
var chunkTests = []chunkTest{
chunkTest{"{{var}}", "value"},
chunkTest{"{{x}}", "1"},
}

34
render.go Normal file
View File

@ -0,0 +1,34 @@
package main
import "io"
type Context interface{}
func (n *ASTSeq) Render(w io.Writer, ctx Context) error {
for _, c := range n.Children {
if err := c.Render(w, ctx); err != nil {
return err
}
}
return nil
}
func (n *ASTChunks) Render(w io.Writer, _ Context) error {
_, err := w.Write([]byte("{chunks}"))
return err
}
func (n *ASTText) Render(w io.Writer, _ Context) error {
_, err := w.Write([]byte(n.chunk.Source))
return err
}
func (n *ASTControlTag) Render(w io.Writer, _ Context) error {
_, err := w.Write([]byte("{control}"))
return err
}
func (n *ASTObject) Render(w io.Writer, _ Context) error {
_, err := w.Write([]byte("{object}"))
return err
}