├── .all-contributorsrc
├── .appveyor.yml
├── .github
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── go.yml
│ └── golangci-lint.yml
├── .gitignore
├── .golangci.yml
├── .markdownlint.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── cmd
└── liquid
│ ├── main.go
│ ├── main_test.go
│ └── testdata
│ ├── env.liquid
│ └── source.liquid
├── docs
└── TemplateStoreExample.md
├── drops.go
├── drops_test.go
├── engine.go
├── engine_examples_test.go
├── engine_test.go
├── evaluator
└── evaluator.go
├── expressions
├── builders.go
├── config.go
├── context.go
├── expressions.go
├── expressions.y
├── expressions_test.go
├── filters.go
├── filters_test.go
├── functional.go
├── functional_test.go
├── parser.go
├── parser_test.go
├── scanner.go
├── scanner.rl
├── scanner_test.go
├── statements.go
├── statements_test.go
└── y.go
├── filters
├── sort_filters.go
├── standard_filters.go
└── standard_filters_test.go
├── go.mod
├── go.sum
├── liquid.go
├── liquid_test.go
├── parser
├── ast.go
├── config.go
├── error.go
├── grammar.go
├── parser.go
├── parser_test.go
├── scanner.go
├── scanner_test.go
├── token.go
└── tokentype_string.go
├── render
├── blocks.go
├── blocks_test.go
├── compiler.go
├── compiler_test.go
├── config.go
├── context.go
├── context_test.go
├── error.go
├── file_template_store.go
├── node_context.go
├── nodes.go
├── render.go
├── render_test.go
├── tags.go
├── testdata
│ ├── render_file.txt
│ ├── render_file_runtime_error.txt
│ └── render_file_syntax_error.txt
└── trimwriter.go
├── scripts
├── coverage
└── shopify-liquid
├── tags
├── control_flow_tags.go
├── control_flow_tags_test.go
├── include_tag.go
├── include_tag_test.go
├── iteration_tags.go
├── iteration_tags_test.go
├── standard_tags.go
├── standard_tags_test.go
└── testdata
│ ├── include_target.html
│ └── include_target_2.html
├── template.go
├── template_test.go
└── values
├── arrays.go
├── call.go
├── call_test.go
├── compare.go
├── compare_test.go
├── convert.go
├── convert_test.go
├── docs.go
├── drop.go
├── drop_test.go
├── evaluator_test.go
├── mapslicevalue.go
├── parsedate.go
├── parsedate_test.go
├── predicates.go
├── predicates_test.go
├── range.go
├── sort.go
├── structvalue.go
├── structvalue_test.go
├── value.go
└── value_test.go
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "liquid",
3 | "projectOwner": "osteele",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": [
7 | "README.md"
8 | ],
9 | "imageSize": 100,
10 | "commit": true,
11 | "contributors": [
12 | {
13 | "login": "osteele",
14 | "name": "Oliver Steele",
15 | "avatar_url": "https://avatars2.githubusercontent.com/u/674?v=4",
16 | "profile": "https://osteele.com/",
17 | "contributions": [
18 | "code",
19 | "doc",
20 | "ideas",
21 | "infra",
22 | "review",
23 | "test"
24 | ]
25 | },
26 | {
27 | "login": "thessem",
28 | "name": "James Littlejohn",
29 | "avatar_url": "https://avatars0.githubusercontent.com/u/973593?v=4",
30 | "profile": "https://github.com/thessem",
31 | "contributions": [
32 | "code",
33 | "doc",
34 | "test"
35 | ]
36 | },
37 | {
38 | "login": "nsf",
39 | "name": "nsf",
40 | "avatar_url": "https://avatars2.githubusercontent.com/u/12567?v=4",
41 | "profile": "http://nosmileface.ru",
42 | "contributions": [
43 | "code",
44 | "test"
45 | ]
46 | },
47 | {
48 | "login": "Eun",
49 | "name": "Tobias Salzmann",
50 | "avatar_url": "https://avatars.githubusercontent.com/u/796084?v=4",
51 | "profile": "https://tobias.salzmann.berlin/",
52 | "contributions": [
53 | "code"
54 | ]
55 | },
56 | {
57 | "login": "bendoerr",
58 | "name": "Ben Doerr",
59 | "avatar_url": "https://avatars.githubusercontent.com/u/253068?v=4",
60 | "profile": "https://github.com/bendoerr",
61 | "contributions": [
62 | "code"
63 | ]
64 | },
65 | {
66 | "login": "danog",
67 | "name": "Daniil Gentili",
68 | "avatar_url": "https://avatars.githubusercontent.com/u/7339644?v=4",
69 | "profile": "https://daniil.it/",
70 | "contributions": [
71 | "code"
72 | ]
73 | },
74 | {
75 | "login": "carolynvs",
76 | "name": "Carolyn Van Slyck",
77 | "avatar_url": "https://avatars.githubusercontent.com/u/1368985?v=4",
78 | "profile": "https://github.com/carolynvs",
79 | "contributions": [
80 | "code"
81 | ]
82 | },
83 | {
84 | "login": "kke",
85 | "name": "Kimmo Lehto",
86 | "avatar_url": "https://avatars.githubusercontent.com/u/224971?v=4",
87 | "profile": "https://github.com/kke",
88 | "contributions": [
89 | "code"
90 | ]
91 | },
92 | {
93 | "login": "heyvito",
94 | "name": "Victor \"Vito\" Gama",
95 | "avatar_url": "https://avatars.githubusercontent.com/u/77198?v=4",
96 | "profile": "https://vito.io/",
97 | "contributions": [
98 | "code"
99 | ]
100 | }
101 | ],
102 | "commitConvention": "none"
103 | }
104 |
--------------------------------------------------------------------------------
/.appveyor.yml:
--------------------------------------------------------------------------------
1 | version: 0.2.0.{build}
2 |
3 | clone_folder: C:\GOPATH\src\github.com\osteele\liquid
4 |
5 | environment:
6 | GOPATH: C:\GOPATH
7 | GOVERSION: 1.8
8 |
9 | init:
10 | - set PATH=C:\go\bin;%GOPATH%;%PATH%
11 | - go version
12 | - go env
13 |
14 | install:
15 | - go get -t ./...
16 |
17 | build_script:
18 | - go test ./...
19 |
20 | platform: x64
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Checklist
2 |
3 | - [ ] I have searched the [issue list](https://github.com/osteele/liquid/issues)
4 | - [ ] I have tested my example against Shopify Liquid. (This isn't necessary if the actual behavior is a panic, or an error for which `IsTemplateError` returns false.)
5 |
6 | ## Expected Behavior
7 |
8 | ## Actual Behavior
9 |
10 | ## Detailed Description
11 |
12 | ## Possible Solution
13 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Checklist
2 |
3 | - [ ] I have read the contribution guidelines.
4 | - [ ] `make test` passes.
5 | - [ ] `make lint` passes.
6 | - [ ] New and changed code is covered by tests.
7 | - [ ] Performance improvements include benchmarks.
8 | - [ ] Changes match the *documented* (not just the *implemented*) behavior of Shopify.
9 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Build Status
2 |
3 | on:
4 | push:
5 | branches: [ main, master ]
6 | pull_request:
7 | branches: [ main, master ]
8 |
9 | jobs:
10 | test:
11 | strategy:
12 | matrix:
13 | go-version: [1.22.x]
14 | os: [ubuntu-latest, macos-latest, windows-latest]
15 | runs-on: ${{ matrix.os }}
16 |
17 | steps:
18 | - name: Install Go
19 | uses: actions/setup-go@v5
20 | with:
21 | go-version: ${{ matrix.go-version }}
22 |
23 | - name: Checkout
24 | uses: actions/checkout@v4
25 |
26 | - name: Build
27 | run: go build -v ./...
28 |
29 | - name: Test
30 | run: go test ./...
31 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on:
4 | push:
5 | branches: [ main, master ]
6 | pull_request:
7 | branches: [ main, master ]
8 |
9 | jobs:
10 | golangci:
11 | name: lint
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: golangci-lint
19 | uses: golangci/golangci-lint-action@v8
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.output
2 | *.out
3 | /liquid
4 | *.test
5 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | default: all
4 | disable:
5 | - cyclop
6 | - depguard
7 | - dupword
8 | - err113
9 | - errname
10 | - errorlint
11 | - exhaustive
12 | - exhaustruct
13 | - forcetypeassert
14 | - funlen
15 | - gochecknoglobals
16 | - gocognit
17 | - goconst
18 | - gocyclo
19 | - godot
20 | - godox
21 | - gosmopolitan
22 | - inamedparam
23 | - interfacebloat
24 | - ireturn
25 | - lll
26 | - maintidx
27 | - mnd
28 | - nestif
29 | - nlreturn
30 | - nolintlint
31 | - nonamedreturns
32 | - paralleltest
33 | - revive
34 | - testpackage
35 | - varnamelen
36 | - whitespace
37 | - wrapcheck
38 | - wsl
39 | exclusions:
40 | generated: lax
41 | presets:
42 | - comments
43 | - common-false-positives
44 | - legacy
45 | - std-error-handling
46 | rules:
47 | - linters:
48 | - staticcheck
49 | path: values/drop_test.go
50 | text: 'S1005:'
51 | - linters:
52 | - deadcode
53 | - gocritic
54 | - revive
55 | - staticcheck
56 | - unconvert
57 | - unused
58 | - varcheck
59 | path: expressions/scanner.go
60 | paths:
61 | - third_party$
62 | - builtin$
63 | - examples$
64 | formatters:
65 | enable:
66 | - gofmt
67 | - gofumpt
68 | - goimports
69 | exclusions:
70 | generated: lax
71 | paths:
72 | - third_party$
73 | - builtin$
74 | - examples$
75 | - expressions/scanner.go
76 |
77 | issues:
78 | fix: true
--------------------------------------------------------------------------------
/.markdownlint.yml:
--------------------------------------------------------------------------------
1 | default: true
2 | MD013:
3 | tables: false
4 | MD033: false
5 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Here's some ways to help:
4 |
5 | * Select an item from the [issues list](https://github.com/osteele/liquid/issues)
6 | * Search the sources for FIXME and TODO comments.
7 | * Improve the [code coverage](https://coveralls.io/github/osteele/liquid?branch=master).
8 |
9 | Review the [pull request template](https://github.com/osteele/liquid/blob/master/.github/PULL_REQUEST_TEMPLATE.md) before you get too far along on coding.
10 |
11 | A note on lint: `nolint: gocyclo` has been used to disable cyclomatic complexity checks on generated functions, hand-written parsers, and some of the generic interpreter functions. IMO this check isn't appropriate for those classes of functions. This isn't a license to disable cyclomatic complexity checks or lint in general.
12 |
13 | ## Cookbook
14 |
15 | ### Set up your machine
16 |
17 | Fork and clone the repo.
18 |
19 | [Install go](https://golang.org/doc/install#install). On macOS running Homebrew, `brew install go` is easier than the linked instructions.
20 |
21 | Install package dependencies and development tools:
22 |
23 | * `make setup`
24 |
25 | [Install golangci-lint](https://golangci-lint.run/usage/install/#local-installation).
26 | On macOS: `brew install golangci-lint`
27 |
28 | ### Test and Lint
29 |
30 | ```bash
31 | make pre-commit
32 | ```
33 |
34 | You can also do these individually:
35 |
36 | ```bash
37 | go test ./...
38 | make lint
39 | ```
40 |
41 | ### Preview the Documentation
42 |
43 | ```bash
44 | godoc -http=:6060
45 | open http://localhost:6060/pkg/github.com/osteele/liquid/
46 | ```
47 |
48 | ### Work on the Expression Parser and Lexer
49 |
50 | To work on the lexer, install Ragel. On macOS: `brew install ragel`.
51 |
52 | Do this after editing `scanner.rl` or `expressions.y`:
53 |
54 | ```bash
55 | go generate ./...
56 | ```
57 |
58 | Test just the scanner:
59 |
60 | ```bash
61 | cd expression
62 | ragel -Z scanner.rl && go test
63 | ```
64 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Oliver Steele
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | SOURCEDIR=.
2 | SOURCES := $(shell find $(SOURCEDIR) -name '*.go')
3 |
4 | LIB = liquid
5 | PACKAGE = github.com/osteele/liquid
6 | LDFLAGS=
7 |
8 | .DEFAULT_GOAL: ci
9 | .PHONY: ci clean coverage deps generate imports install lint pre-commit setup test help
10 |
11 | clean: ## remove binary files
12 | rm -f ${LIB} ${CMD}
13 |
14 | coverage: ## test the package, with coverage
15 | go test -cov ./...
16 |
17 | deps: ## list dependencies
18 | @go list -f '{{join .Deps "\n"}}' ./... | grep -v `go list -f '{{.ImportPath}}'` | grep '\.' | sort | uniq
19 |
20 | format: ## list dependencies
21 | @go fmt
22 |
23 | generate: ## re-generate lexers and parser
24 | go generate ./...
25 |
26 | imports: ## list imports
27 | @go list -f '{{join .Imports "\n"}}' ./... | grep -v `go list -f '{{.ImportPath}}'` | grep '\.' | sort | uniq
28 |
29 | lint: ## lint the package
30 | golangci-lint run
31 | @echo lint passed
32 |
33 | pre-commit: lint test ## lint and test the package
34 |
35 | setup: ## install dependencies and development tools
36 | go install golang.org/x/tools/cmd/stringer
37 | go install golang.org/x/tools/cmd/goyacc
38 | go get -t ./...
39 |
40 | test: ## test the package
41 | go test ./...
42 |
43 | # Source: https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
44 | help:
45 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
46 |
--------------------------------------------------------------------------------
/cmd/liquid/main.go:
--------------------------------------------------------------------------------
1 | // Package main defines a command-line interface to the Liquid engine.
2 | //
3 | // This command intended for testing and bug reports.
4 | //
5 | // Examples:
6 | //
7 | // echo '{{ "Hello " | append: "World" }}' | liquid
8 | // liquid source.tpl
9 | package main
10 |
11 | import (
12 | "errors"
13 | "flag"
14 | "fmt"
15 | "io"
16 | "os"
17 | "strings"
18 |
19 | "github.com/osteele/liquid"
20 | )
21 |
22 | // for testing
23 | var (
24 | stderr io.Writer = os.Stderr
25 | stdout io.Writer = os.Stdout
26 | stdin io.Reader = os.Stdin
27 | exit func(int) = os.Exit
28 | env func() []string = os.Environ
29 | bindings map[string]any = map[string]any{}
30 | strictVars bool
31 | )
32 |
33 | func main() {
34 | var err error
35 |
36 | cmdLine := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
37 | cmdLine.Usage = func() {
38 | fmt.Fprintf(stderr, "usage: %s [OPTIONS] [FILE]\n", cmdLine.Name())
39 | fmt.Fprint(stderr, "\nOPTIONS\n")
40 | cmdLine.PrintDefaults()
41 | }
42 |
43 | var bindEnvs bool
44 | cmdLine.BoolVar(&bindEnvs, "env", false, "bind environment variables")
45 | cmdLine.BoolVar(&strictVars, "strict", false, "enable strict variable mode in templates")
46 |
47 | err = cmdLine.Parse(os.Args[1:])
48 | if err != nil {
49 | if err == flag.ErrHelp {
50 | exit(0)
51 | return
52 | }
53 | fmt.Fprintln(stderr, err)
54 | exit(1)
55 | return
56 | }
57 |
58 | if bindEnvs {
59 | for _, e := range env() {
60 | pair := strings.SplitN(e, "=", 2)
61 | bindings[pair[0]] = pair[1]
62 | }
63 | }
64 |
65 | args := cmdLine.Args()
66 | switch len(args) {
67 | case 0:
68 | // use stdin
69 | case 1:
70 | stdin, err = os.Open(args[0])
71 | default:
72 | err = errors.New("too many arguments")
73 | }
74 |
75 | if err == nil {
76 | err = render()
77 | }
78 |
79 | if err != nil {
80 | fmt.Fprintln(stderr, err)
81 | exit(1)
82 | }
83 | }
84 |
85 | func render() error {
86 | buf, err := io.ReadAll(stdin)
87 | if err != nil {
88 | return err
89 | }
90 |
91 | e := liquid.NewEngine()
92 | if strictVars {
93 | e.StrictVariables()
94 | }
95 | tpl, err := e.ParseTemplate(buf)
96 | if err != nil {
97 | return err
98 | }
99 | out, err := tpl.Render(bindings)
100 | if err != nil {
101 | return err
102 | }
103 | _, err = stdout.Write(out)
104 | return err
105 | }
106 |
--------------------------------------------------------------------------------
/cmd/liquid/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestMain(t *testing.T) {
12 | oldArgs := os.Args
13 |
14 | defer func() {
15 | os.Args = oldArgs
16 | stderr = os.Stderr
17 | stdout = os.Stdout
18 | stdin = os.Stdin
19 | exit = os.Exit
20 | env = os.Environ
21 | bindings = map[string]any{}
22 | }()
23 |
24 | exit = func(n int) {
25 | t.Fatalf("exit called")
26 | }
27 |
28 | os.Args = []string{"liquid"}
29 |
30 | // stdin
31 | src := `{{ "Hello World" | downcase | split: " " | first | append: "!"}}`
32 | buf := &bytes.Buffer{}
33 | stdin = bytes.NewBufferString(src)
34 | stdout = buf
35 | main()
36 | require.Equal(t, "hello!", buf.String())
37 |
38 | // environment binding
39 | var envCalled bool
40 | env = func() []string {
41 | envCalled = true
42 | return []string{"TARGET=World"}
43 | }
44 | src = `Hello, {{ TARGET }}!`
45 | // without -e
46 | stdin = bytes.NewBufferString(src)
47 | buf = &bytes.Buffer{}
48 | stdout = buf
49 | os.Args = []string{"liquid"}
50 | main()
51 | require.False(t, envCalled)
52 | require.Equal(t, "Hello, !", buf.String())
53 | // with -e
54 | stdin = bytes.NewBufferString(src)
55 | buf = &bytes.Buffer{}
56 | stdout = buf
57 | os.Args = []string{"liquid", "--env"}
58 | main()
59 | require.True(t, envCalled)
60 | require.Equal(t, "Hello, World!", buf.String())
61 | bindings = make(map[string]any)
62 |
63 | // filename
64 | stdin = os.Stdin
65 | buf = &bytes.Buffer{}
66 | stdout = buf
67 | os.Args = []string{"liquid", "testdata/source.liquid"}
68 | main()
69 | require.Contains(t, buf.String(), "file system")
70 |
71 | // following tests test the exit code
72 | var exitCalled bool
73 | exitCode := 0
74 | exit = func(n int) { exitCalled = true; exitCode = n }
75 |
76 | // strict variables
77 | stdin = bytes.NewBufferString(src)
78 | buf = &bytes.Buffer{}
79 | stderr = buf
80 | os.Args = []string{"liquid", "--strict"}
81 | main()
82 | require.True(t, exitCalled)
83 | require.Equal(t, 1, exitCode)
84 | require.Equal(t, "Liquid error: undefined variable in {{ TARGET }}\n", buf.String())
85 |
86 | exitCode = 0
87 | os.Args = []string{"liquid", "testdata/source.liquid"}
88 | main()
89 | require.Equal(t, 0, exitCode)
90 |
91 | exitCode = 0
92 | // missing file
93 | buf = &bytes.Buffer{}
94 | stderr = buf
95 | os.Args = []string{"liquid", "testdata/missing_file"}
96 | main()
97 | require.Equal(t, 1, exitCode)
98 | // Darwin/Linux, and Windows, have different error messages
99 | require.Regexp(t, "no such file|cannot find the file", buf.String())
100 |
101 | exitCalled = false
102 | // --help
103 | buf = &bytes.Buffer{}
104 | stderr = buf
105 | os.Args = []string{"liquid", "--help"}
106 | main()
107 | require.Contains(t, buf.String(), "usage:")
108 | require.True(t, exitCalled)
109 | require.Equal(t, 0, exitCode)
110 |
111 | // --undefined-flag
112 | buf = &bytes.Buffer{}
113 | stderr = buf
114 | os.Args = []string{"liquid", "--undefined-flag"}
115 | main()
116 | require.Equal(t, 1, exitCode)
117 | require.Contains(t, buf.String(), "defined")
118 |
119 | // multiple args
120 | os.Args = []string{"liquid", "testdata/source.liquid", "file2"}
121 | buf = &bytes.Buffer{}
122 | stderr = buf
123 | main()
124 | require.Contains(t, buf.String(), "too many")
125 | require.Equal(t, 1, exitCode)
126 | }
127 |
--------------------------------------------------------------------------------
/cmd/liquid/testdata/env.liquid:
--------------------------------------------------------------------------------
1 | PWD={{ PWD }}
2 | HOME={{ HOME }}
3 | USER={{ USER }}
4 |
--------------------------------------------------------------------------------
/cmd/liquid/testdata/source.liquid:
--------------------------------------------------------------------------------
1 | {% capture horse %}file system{% endcapture %}Now I'm on a {{ horse }}!
--------------------------------------------------------------------------------
/docs/TemplateStoreExample.md:
--------------------------------------------------------------------------------
1 | # Template Store Example
2 |
3 | This document describes the implementation of an `TemplateStore` that uses an embedded file system as its storage type.
4 |
5 | Add a go file to your project with configuration properties and the ReadTemplate() implementation
6 |
7 | ```go
8 | package your_package_name
9 |
10 | import (
11 | "embed"
12 | "fmt"
13 | )
14 |
15 | type EmbeddedFileSystemTemplateStore struct {
16 | Folder embed.FS
17 | RootDir string
18 | }
19 |
20 | // implementation of ITemplateProvider
21 | func (tl *EmbeddedFileSystemTemplateStore) ReadTemplate(filename string) ([]byte, error) {
22 |
23 | fileName := fmt.Sprintf("%v/%v", tl.RootDir, filename)
24 | templateFile, _ := tl.Folder.ReadFile(fileName)
25 |
26 | return templateFile, nil
27 | }
28 |
29 | ```
30 | initialize your embedded folder. for details on go embedded package see [embed](https://pkg.go.dev/embed)
31 |
32 | ```go
33 |
34 | //go:embed all:templates
35 | var folder embed.FS
36 |
37 | ```
38 | create store and register with engine
39 |
40 | ```go
41 | // use the embedded file system loader for now.
42 | embedFileSystemTemplateStore := &your_package_name.EmbeddedFileSystemTemplateStore{
43 | Folder: folder,
44 | RootDir: "templates",
45 | }
46 |
47 | //create engine
48 | engine := liquid.NewEngine()
49 |
50 | //register with the engine
51 | engine.RegisterTemplateStore(embedFileSystemTemplateStore)
52 |
53 | //ready to go
54 | ```
--------------------------------------------------------------------------------
/drops.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | // Drop indicates that the object will present to templates as its ToLiquid value.
4 | type Drop interface {
5 | ToLiquid() any
6 | }
7 |
8 | // FromDrop returns returns object.ToLiquid() if object's type implement this function;
9 | // else the object itself.
10 | func FromDrop(object any) any {
11 | switch object := object.(type) {
12 | case Drop:
13 | return object.ToLiquid()
14 | default:
15 | return object
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/drops_test.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | type dropTest struct{}
12 |
13 | func (d dropTest) ToLiquid() any { return "drop" }
14 |
15 | func TestDrops(t *testing.T) {
16 | require.Equal(t, "drop", FromDrop(dropTest{}))
17 |
18 | require.Equal(t, "not a drop", FromDrop("not a drop"))
19 | }
20 |
21 | type redConvertible struct{}
22 |
23 | func (c redConvertible) ToLiquid() any {
24 | return map[string]any{
25 | "color": "red",
26 | }
27 | }
28 |
29 | func ExampleDrop_map() {
30 | // type redConvertible struct{}
31 | //
32 | // func (c redConvertible) ToLiquid() any {
33 | // return map[string]any{
34 | // "color": "red",
35 | // }
36 | // }
37 | engine := NewEngine()
38 | bindings := map[string]any{
39 | "car": redConvertible{},
40 | }
41 | template := `{{ car.color }}`
42 | out, err := engine.ParseAndRenderString(template, bindings)
43 | if err != nil {
44 | log.Fatalln(err)
45 | }
46 | fmt.Println(out)
47 | // Output: red
48 | }
49 |
50 | type car struct{ color, model string }
51 |
52 | func (c car) ToLiquid() any {
53 | return carDrop{c.model, c.color}
54 | }
55 |
56 | type carDrop struct {
57 | Model string
58 | Color string `liquid:"color"`
59 | }
60 |
61 | func (c carDrop) Drive() string {
62 | return "AWD"
63 | }
64 |
65 | func ExampleDrop_struct() {
66 | // type car struct{ color, model string }
67 | //
68 | // func (c car) ToLiquid() any {
69 | // return carDrop{c.model, c.color}
70 | // }
71 | //
72 | // type carDrop struct {
73 | // Model string
74 | // Color string `liquid:"color"`
75 | // }
76 | //
77 | // func (c carDrop) Drive() string {
78 | // return "AWD"
79 | // }
80 |
81 | engine := NewEngine()
82 | bindings := map[string]any{
83 | "car": car{"blue", "S85"},
84 | }
85 | template := `{{ car.color }} {{ car.Drive }} Model {{ car.Model }}`
86 | out, err := engine.ParseAndRenderString(template, bindings)
87 | if err != nil {
88 | log.Fatalln(err)
89 | }
90 | fmt.Println(out)
91 | // Output: blue AWD Model S85
92 | }
93 |
--------------------------------------------------------------------------------
/engine.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "io"
5 |
6 | "github.com/osteele/liquid/filters"
7 | "github.com/osteele/liquid/render"
8 | "github.com/osteele/liquid/tags"
9 | )
10 |
11 | // An Engine parses template source into renderable text.
12 | //
13 | // An engine can be configured with additional filters and tags.
14 | type Engine struct{ cfg render.Config }
15 |
16 | // NewEngine returns a new Engine.
17 | func NewEngine() *Engine {
18 | e := Engine{render.NewConfig()}
19 | filters.AddStandardFilters(&e.cfg)
20 | tags.AddStandardTags(e.cfg)
21 | return &e
22 | }
23 |
24 | // NewBasicEngine returns a new Engine without the standard filters or tags.
25 | func NewBasicEngine() *Engine {
26 | return &Engine{render.NewConfig()}
27 | }
28 |
29 | // RegisterBlock defines a block e.g. {% tag %}…{% endtag %}.
30 | func (e *Engine) RegisterBlock(name string, td Renderer) {
31 | e.cfg.AddBlock(name).Renderer(func(w io.Writer, ctx render.Context) error {
32 | s, err := td(ctx)
33 | if err != nil {
34 | return err
35 | }
36 | _, err = io.WriteString(w, s)
37 | return err
38 | })
39 | }
40 |
41 | // RegisterFilter defines a Liquid filter, for use as `{{ value | my_filter }}` or `{{ value | my_filter: arg }}`.
42 | //
43 | // A filter is a function that takes at least one input, and returns one or two outputs.
44 | // If it returns two outputs, the second must have type error.
45 | //
46 | // Examples:
47 | //
48 | // * https://github.com/osteele/liquid/blob/main/filters/standard_filters.go
49 | //
50 | // * https://github.com/osteele/gojekyll/blob/master/filters/filters.go
51 | func (e *Engine) RegisterFilter(name string, fn any) {
52 | e.cfg.AddFilter(name, fn)
53 | }
54 |
55 | // RegisterTag defines a tag e.g. {% tag %}.
56 | //
57 | // Further examples are in https://github.com/osteele/gojekyll/blob/master/tags/tags.go
58 | func (e *Engine) RegisterTag(name string, td Renderer) {
59 | // For simplicity, don't expose the two stage parsing/rendering process to clients.
60 | // Client tags do everything at runtime.
61 | e.cfg.AddTag(name, func(_ string) (func(io.Writer, render.Context) error, error) {
62 | return func(w io.Writer, ctx render.Context) error {
63 | s, err := td(ctx)
64 | if err != nil {
65 | return err
66 | }
67 | _, err = io.WriteString(w, s)
68 | return err
69 | }, nil
70 | })
71 | }
72 |
73 | func (e *Engine) RegisterTemplateStore(templateStore render.TemplateStore) {
74 | e.cfg.TemplateStore = templateStore
75 | }
76 |
77 | // StrictVariables causes the renderer to error when the template contains an undefined variable.
78 | func (e *Engine) StrictVariables() {
79 | e.cfg.StrictVariables = true
80 | }
81 |
82 | // ParseTemplate creates a new Template using the engine configuration.
83 | func (e *Engine) ParseTemplate(source []byte) (*Template, SourceError) {
84 | return newTemplate(&e.cfg, source, "", 0)
85 | }
86 |
87 | // ParseString creates a new Template using the engine configuration.
88 | func (e *Engine) ParseString(source string) (*Template, SourceError) {
89 | return e.ParseTemplate([]byte(source))
90 | }
91 |
92 | // ParseTemplateLocation is the same as ParseTemplate, except that the source location is used
93 | // for error reporting and for the {% include %} tag.
94 | //
95 | // The path and line number are used for error reporting.
96 | // The path is also the reference for relative pathnames in the {% include %} tag.
97 | func (e *Engine) ParseTemplateLocation(source []byte, path string, line int) (*Template, SourceError) {
98 | return newTemplate(&e.cfg, source, path, line)
99 | }
100 |
101 | // ParseAndRender parses and then renders the template.
102 | func (e *Engine) ParseAndRender(source []byte, b Bindings) ([]byte, SourceError) {
103 | tpl, err := e.ParseTemplate(source)
104 | if err != nil {
105 | return nil, err
106 | }
107 | return tpl.Render(b)
108 | }
109 |
110 | // ParseAndFRender parses and then renders the template into w.
111 | func (e *Engine) ParseAndFRender(w io.Writer, source []byte, b Bindings) SourceError {
112 | tpl, err := e.ParseTemplate(source)
113 | if err != nil {
114 | return err
115 | }
116 | return tpl.FRender(w, b)
117 | }
118 |
119 | // ParseAndRenderString is a convenience wrapper for ParseAndRender, that takes string input and returns a string.
120 | func (e *Engine) ParseAndRenderString(source string, b Bindings) (string, SourceError) {
121 | bs, err := e.ParseAndRender([]byte(source), b)
122 | if err != nil {
123 | return "", err
124 | }
125 | return string(bs), nil
126 | }
127 |
128 | // Delims sets the action delimiters to the specified strings, to be used in subsequent calls to
129 | // ParseTemplate, ParseTemplateLocation, ParseAndRender, or ParseAndRenderString. An empty delimiter
130 | // stands for the corresponding default: objectLeft = {{, objectRight = }}, tagLeft = {% , tagRight = %}
131 | func (e *Engine) Delims(objectLeft, objectRight, tagLeft, tagRight string) *Engine {
132 | e.cfg.Delims = []string{objectLeft, objectRight, tagLeft, tagRight}
133 | return e
134 | }
135 |
136 | // ParseTemplateAndCache is the same as ParseTemplateLocation, except that the
137 | // source location is used for error reporting and for the {% include %} tag.
138 | // If parsing is successful, provided source is then cached, and can be retrieved
139 | // by {% include %} tags, as long as there is not a real file in the provided path.
140 | //
141 | // The path and line number are used for error reporting.
142 | // The path is also the reference for relative pathnames in the {% include %} tag.
143 | func (e *Engine) ParseTemplateAndCache(source []byte, path string, line int) (*Template, SourceError) {
144 | t, err := e.ParseTemplateLocation(source, path, line)
145 | if err != nil {
146 | return t, err
147 | }
148 | e.cfg.Cache[path] = source
149 | return t, err
150 | }
151 |
--------------------------------------------------------------------------------
/engine_examples_test.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strconv"
7 | "strings"
8 |
9 | "github.com/osteele/liquid/render"
10 | )
11 |
12 | func Example() {
13 | engine := NewEngine()
14 | source := `
{{ page.title }}
`
15 | bindings := map[string]any{
16 | "page": map[string]string{
17 | "title": "Introduction",
18 | },
19 | }
20 | out, err := engine.ParseAndRenderString(source, bindings)
21 | if err != nil {
22 | log.Fatalln(err)
23 | }
24 | fmt.Println(out)
25 | // Output: Introduction
26 | }
27 |
28 | func ExampleEngine_ParseAndRenderString() {
29 | engine := NewEngine()
30 | source := `{{ hello | capitalize | append: " Mundo" }}`
31 | bindings := map[string]any{"hello": "hola"}
32 | out, err := engine.ParseAndRenderString(source, bindings)
33 | if err != nil {
34 | log.Fatalln(err)
35 | }
36 | fmt.Println(out)
37 | // Output: Hola Mundo
38 | }
39 |
40 | func ExampleEngine_ParseTemplate() {
41 | engine := NewEngine()
42 | source := `{{ hello | capitalize | append: " Mundo" }}`
43 | bindings := map[string]any{"hello": "hola"}
44 | tpl, err := engine.ParseString(source)
45 | if err != nil {
46 | log.Fatalln(err)
47 | }
48 | out, err := tpl.RenderString(bindings)
49 | if err != nil {
50 | log.Fatalln(err)
51 | }
52 | fmt.Println(out)
53 | // Output: Hola Mundo
54 | }
55 |
56 | func ExampleEngine_RegisterFilter() {
57 | engine := NewEngine()
58 | engine.RegisterFilter("has_prefix", strings.HasPrefix)
59 | template := `{{ title | has_prefix: "Intro" }}`
60 | bindings := map[string]any{
61 | "title": "Introduction",
62 | }
63 | out, err := engine.ParseAndRenderString(template, bindings)
64 | if err != nil {
65 | log.Fatalln(err)
66 | }
67 | fmt.Println(out)
68 | // Output: true
69 | }
70 |
71 | func ExampleEngine_RegisterFilter_optional_argument() {
72 | engine := NewEngine()
73 | // func(a, b int) int) would default the second argument to zero.
74 | // Then we can't tell the difference between {{ n | inc }} and
75 | // {{ n | inc: 0 }}. A function in the parameter list has a special
76 | // meaning as a default parameter.
77 | engine.RegisterFilter("inc", func(a int, b func(int) int) int {
78 | return a + b(1)
79 | })
80 | template := `10 + 1 = {{ m | inc }}; 20 + 5 = {{ n | inc: 5 }}`
81 | bindings := map[string]any{
82 | "m": 10,
83 | "n": "20",
84 | }
85 | out, err := engine.ParseAndRenderString(template, bindings)
86 | if err != nil {
87 | log.Fatalln(err)
88 | }
89 | fmt.Println(out)
90 | // Output: 10 + 1 = 11; 20 + 5 = 25
91 | }
92 |
93 | func ExampleEngine_RegisterTag() {
94 | engine := NewEngine()
95 | engine.RegisterTag("echo", func(c render.Context) (string, error) {
96 | return c.TagArgs(), nil
97 | })
98 | template := `{% echo hello world %}`
99 | out, err := engine.ParseAndRenderString(template, emptyBindings)
100 | if err != nil {
101 | log.Fatalln(err)
102 | }
103 | fmt.Println(out)
104 | // Output: hello world
105 | }
106 |
107 | func ExampleEngine_RegisterBlock() {
108 | engine := NewEngine()
109 | engine.RegisterBlock("length", func(c render.Context) (string, error) {
110 | s, err := c.InnerString()
111 | if err != nil {
112 | return "", err
113 | }
114 | n := len(s)
115 | return strconv.Itoa(n), nil
116 | })
117 |
118 | template := `{% length %}abc{% endlength %}`
119 | out, err := engine.ParseAndRenderString(template, emptyBindings)
120 | if err != nil {
121 | log.Fatalln(err)
122 | }
123 | fmt.Println(out)
124 | // Output: 3
125 | }
126 |
--------------------------------------------------------------------------------
/engine_test.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "strconv"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | var emptyBindings = map[string]any{}
16 |
17 | // There's a lot more tests in the filters and tags sub-packages.
18 | // This collects a minimal set for testing end-to-end.
19 | var liquidTests = []struct{ in, expected string }{
20 | {`{{ page.title }}`, "Introduction"},
21 | {`{% if x %}true{% endif %}`, "true"},
22 | {`{{ "upper" | upcase }}`, "UPPER"},
23 | }
24 |
25 | var testBindings = map[string]any{
26 | "x": 123,
27 | "ar": []string{"first", "second", "third"},
28 | "page": map[string]any{
29 | "title": "Introduction",
30 | },
31 | }
32 |
33 | func TestEngine_ParseAndRenderString(t *testing.T) {
34 | engine := NewEngine()
35 | for i, test := range liquidTests {
36 | t.Run(strconv.Itoa(i+1), func(t *testing.T) {
37 | out, err := engine.ParseAndRenderString(test.in, testBindings)
38 | require.NoErrorf(t, err, test.in)
39 | require.Equalf(t, test.expected, out, test.in)
40 | })
41 | }
42 | }
43 |
44 | func TestBasicEngine_ParseAndRenderString(t *testing.T) {
45 | engine := NewBasicEngine()
46 |
47 | t.Run("1", func(t *testing.T) {
48 | test := liquidTests[0]
49 | out, err := engine.ParseAndRenderString(test.in, testBindings)
50 | require.NoErrorf(t, err, test.in)
51 | require.Equalf(t, test.expected, out, test.in)
52 | })
53 |
54 | for i, test := range liquidTests[1:] {
55 | t.Run(strconv.Itoa(i+2), func(t *testing.T) {
56 | out, err := engine.ParseAndRenderString(test.in, testBindings)
57 | require.Errorf(t, err, test.in)
58 | require.Emptyf(t, out, test.in)
59 | })
60 | }
61 | }
62 |
63 | type capWriter struct {
64 | bytes.Buffer
65 | }
66 |
67 | func (c *capWriter) Write(bs []byte) (int, error) {
68 | return c.Buffer.Write([]byte(strings.ToUpper(string(bs))))
69 | }
70 |
71 | func TestEngine_ParseAndFRender(t *testing.T) {
72 | engine := NewEngine()
73 | for i, test := range liquidTests {
74 | t.Run(strconv.Itoa(i+1), func(t *testing.T) {
75 | wr := capWriter{}
76 | err := engine.ParseAndFRender(&wr, []byte(test.in), testBindings)
77 | require.NoErrorf(t, err, test.in)
78 | require.Equalf(t, strings.ToUpper(test.expected), wr.String(), test.in)
79 | })
80 | }
81 | }
82 |
83 | func TestEngine_ParseAndRenderString_ptr_to_hash(t *testing.T) {
84 | params := map[string]any{
85 | "message": &map[string]any{
86 | "Text": "hello",
87 | "jsonNumber": json.Number("123"),
88 | },
89 | }
90 | engine := NewEngine()
91 | template := "{{ message.Text }} {{message.jsonNumber}}"
92 | str, err := engine.ParseAndRenderString(template, params)
93 | require.NoError(t, err)
94 | require.Equal(t, "hello 123", str)
95 | }
96 |
97 | type testStruct struct{ Text string }
98 |
99 | func TestEngine_ParseAndRenderString_struct(t *testing.T) {
100 | params := map[string]any{
101 | "message": testStruct{
102 | Text: "hello",
103 | },
104 | }
105 | engine := NewEngine()
106 | template := "{{ message.Text }}"
107 | str, err := engine.ParseAndRenderString(template, params)
108 | require.NoError(t, err)
109 | require.Equal(t, "hello", str)
110 | }
111 |
112 | func TestEngine_ParseAndRender_errors(t *testing.T) {
113 | _, err := NewEngine().ParseAndRenderString("{{ syntax error }}", emptyBindings)
114 | require.Error(t, err)
115 | _, err = NewEngine().ParseAndRenderString("{% if %}", emptyBindings)
116 | require.Error(t, err)
117 | _, err = NewEngine().ParseAndRenderString("{% undefined_tag %}", emptyBindings)
118 | require.Error(t, err)
119 | _, err = NewEngine().ParseAndRenderString("{% a | undefined_filter %}", emptyBindings)
120 | require.Error(t, err)
121 | }
122 |
123 | func BenchmarkEngine_Parse(b *testing.B) {
124 | engine := NewEngine()
125 | buf := new(bytes.Buffer)
126 | for range 1000 {
127 | _, err := io.WriteString(buf, `if{% if true %}true{% elsif %}elsif{% else %}else{% endif %}`)
128 | require.NoError(b, err)
129 | _, err = io.WriteString(buf, `loop{% for item in array %}loop{% break %}{% endfor %}`)
130 | require.NoError(b, err)
131 | _, err = io.WriteString(buf, `case{% case value %}{% when a %}{% when b %{% endcase %}`)
132 | require.NoError(b, err)
133 | _, err = io.WriteString(buf, `expr{{ a and b }}{{ a add: b }}`)
134 | require.NoError(b, err)
135 | }
136 | s := buf.Bytes()
137 | b.ResetTimer()
138 | for range b.N {
139 | _, err := engine.ParseTemplate(s)
140 | require.NoError(b, err)
141 | }
142 | }
143 |
144 | func TestEngine_ParseTemplateAndCache(t *testing.T) {
145 | // Given two templates...
146 | templateA := []byte("Foo")
147 | templateB := []byte(`{% include "template_a.html" %}, Bar`)
148 |
149 | // Cache the first
150 | eng := NewEngine()
151 | _, err := eng.ParseTemplateAndCache(templateA, "template_a.html", 1)
152 | require.NoError(t, err)
153 |
154 | // ...and execute the second.
155 | result, err := eng.ParseAndRender(templateB, Bindings{})
156 | require.NoError(t, err)
157 | require.Equal(t, "Foo, Bar", string(result))
158 | }
159 |
160 | type MockTemplateStore struct{}
161 |
162 | func (tl *MockTemplateStore) ReadTemplate(filename string) ([]byte, error) {
163 | template := []byte(fmt.Sprintf("Message Text: {{ message.Text }} from: %v.", filename))
164 | return template, nil
165 | }
166 |
167 | func Test_template_store(t *testing.T) {
168 | template := []byte(`{% include "template.liquid" %}`)
169 | mockstore := &MockTemplateStore{}
170 | params := map[string]any{
171 | "message": testStruct{
172 | Text: "filename",
173 | },
174 | }
175 | engine := NewEngine()
176 | engine.RegisterTemplateStore(mockstore)
177 | out, _ := engine.ParseAndRenderString(string(template), params)
178 | require.Equal(t, "Message Text: filename from: template.liquid.", out)
179 | }
180 |
--------------------------------------------------------------------------------
/evaluator/evaluator.go:
--------------------------------------------------------------------------------
1 | // Package evaluator is an interim internal package that forwards to package values.
2 | package evaluator
3 |
4 | import (
5 | "reflect"
6 | "time"
7 |
8 | "github.com/osteele/liquid/values"
9 | )
10 |
11 | // Convert should be replaced by values.Convert.
12 | func Convert(value any, typ reflect.Type) (any, error) {
13 | return values.Convert(value, typ)
14 | }
15 |
16 | // MustConvertItem should be replaced by values.Convert.
17 | func MustConvertItem(item any, array any) any {
18 | return values.MustConvertItem(item, array)
19 | }
20 |
21 | // Sort should be replaced by values.
22 | func Sort(data []any) {
23 | values.Sort(data)
24 | }
25 |
26 | // SortByProperty should be replaced by values.SortByProperty
27 | func SortByProperty(data []any, key string, nilFirst bool) {
28 | values.SortByProperty(data, key, nilFirst)
29 | }
30 |
31 | // ParseDate should be replaced by values.SortByProperty
32 | func ParseDate(s string) (time.Time, error) {
33 | return values.ParseDate(s)
34 | }
35 |
--------------------------------------------------------------------------------
/expressions/builders.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "github.com/osteele/liquid/values"
5 | )
6 |
7 | func makeRangeExpr(startFn, endFn func(Context) values.Value) func(Context) values.Value {
8 | return func(ctx Context) values.Value {
9 | a := startFn(ctx).Int()
10 | b := endFn(ctx).Int()
11 | return values.ValueOf(values.NewRange(a, b))
12 | }
13 | }
14 |
15 | func makeContainsExpr(e1, e2 func(Context) values.Value) func(Context) values.Value {
16 | return func(ctx Context) values.Value {
17 | return values.ValueOf(e1(ctx).Contains(e2(ctx)))
18 | }
19 | }
20 |
21 | func makeFilter(fn valueFn, name string, args []valueFn) valueFn {
22 | return func(ctx Context) values.Value {
23 | result, err := ctx.ApplyFilter(name, fn, args)
24 | if err != nil {
25 | panic(FilterError{
26 | FilterName: name,
27 | Err: err,
28 | })
29 | }
30 | return values.ValueOf(result)
31 | }
32 | }
33 |
34 | func makeIndexExpr(sequenceFn, indexFn func(Context) values.Value) func(Context) values.Value {
35 | return func(ctx Context) values.Value {
36 | return sequenceFn(ctx).IndexValue(indexFn(ctx))
37 | }
38 | }
39 |
40 | func makeObjectPropertyExpr(objFn func(Context) values.Value, name string) func(Context) values.Value {
41 | index := values.ValueOf(name)
42 | return func(ctx Context) values.Value {
43 | return objFn(ctx).PropertyValue(index)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/expressions/config.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | // Config holds configuration information for expression interpretation.
4 | type Config struct {
5 | filters map[string]any
6 | }
7 |
8 | // NewConfig creates a new Config.
9 | func NewConfig() Config {
10 | return Config{}
11 | }
12 |
--------------------------------------------------------------------------------
/expressions/context.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import "github.com/osteele/liquid/values"
4 |
5 | // Context is the expression evaluation context. It maps variables names to values.
6 | type Context interface {
7 | ApplyFilter(string, valueFn, []valueFn) (any, error)
8 | // Clone returns a copy with a new variable binding map
9 | // (so that copy.Set does effect the source context.)
10 | Clone() Context
11 | Get(string) any
12 | Set(string, any)
13 | }
14 |
15 | type context struct {
16 | Config
17 | bindings map[string]any
18 | }
19 |
20 | // NewContext makes a new expression evaluation context.
21 | func NewContext(vars map[string]any, cfg Config) Context {
22 | return &context{cfg, vars}
23 | }
24 |
25 | func (ctx *context) Clone() Context {
26 | bindings := map[string]any{}
27 | for k, v := range ctx.bindings {
28 | bindings[k] = v
29 | }
30 | return &context{ctx.Config, bindings}
31 | }
32 |
33 | // Get looks up a variable value in the expression context.
34 | func (ctx *context) Get(name string) any {
35 | return values.ToLiquid(ctx.bindings[name])
36 | }
37 |
38 | // Set sets a variable value in the expression context.
39 | func (ctx *context) Set(name string, value any) {
40 | ctx.bindings[name] = value
41 | }
42 |
--------------------------------------------------------------------------------
/expressions/expressions.go:
--------------------------------------------------------------------------------
1 | // Package expressions is an internal package that parses and evaluates the expression language.
2 | //
3 | // This is the language that is used inside Liquid object and tags; e.g. "a.b[c]" in {{ a.b[c] }}, and "pages = site.pages | reverse" in {% assign pages = site.pages | reverse %}.
4 | package expressions
5 |
6 | import (
7 | "fmt"
8 | "runtime/debug"
9 |
10 | "github.com/osteele/liquid/values"
11 | )
12 |
13 | // TODO Expression and Closure are confusing names.
14 |
15 | // An Expression is a compiled expression.
16 | type Expression interface {
17 | // Evaluate evaluates an expression in a context.
18 | Evaluate(ctx Context) (any, error)
19 | }
20 |
21 | // A Closure is an expression within a lexical environment.
22 | // A closure may refer to variables that are not defined in the
23 | // environment. (Therefore it's not a technically a closure.)
24 | type Closure interface {
25 | // Bind creates a new closure with a new binding.
26 | Bind(name string, value any) Closure
27 | Evaluate() (any, error)
28 | }
29 |
30 | type closure struct {
31 | expr Expression
32 | context Context
33 | }
34 |
35 | func (c closure) Bind(name string, value any) Closure {
36 | ctx := c.context.Clone()
37 | ctx.Set(name, value)
38 | return closure{c.expr, ctx}
39 | }
40 |
41 | func (c closure) Evaluate() (any, error) {
42 | return c.expr.Evaluate(c.context)
43 | }
44 |
45 | type expression struct {
46 | evaluator func(Context) values.Value
47 | }
48 |
49 | func (e expression) Evaluate(ctx Context) (out any, err error) {
50 | defer func() {
51 | if r := recover(); r != nil {
52 | switch e := r.(type) {
53 | case values.TypeError:
54 | err = e
55 | case InterpreterError:
56 | err = e
57 | case UndefinedFilter:
58 | err = e
59 | case FilterError:
60 | err = e
61 | case error:
62 | panic(&rethrownError{e, debug.Stack()})
63 | default:
64 | panic(r)
65 | }
66 | }
67 | }()
68 | return e.evaluator(ctx).Interface(), nil
69 | }
70 |
71 | // rethrownError is for use in a re-thrown error from panic recovery.
72 | // When printed, it prints the original stacktrace.
73 | // This works around a frequent problem, that it's difficult to debug an error inside a filter
74 | // or ToLiquid implementation because Evaluate's recover replaces the stacktrace.
75 | type rethrownError struct {
76 | cause error
77 | stack []byte
78 | }
79 |
80 | func (e *rethrownError) Error() string {
81 | return fmt.Sprintf("%s\nOriginal stacktrace:\n%s\n", e.cause, string(e.stack))
82 | }
83 |
84 | func (e *rethrownError) Cause() error {
85 | return e.cause
86 | }
87 |
--------------------------------------------------------------------------------
/expressions/expressions.y:
--------------------------------------------------------------------------------
1 | %{
2 | package expressions
3 | import (
4 | "fmt"
5 | "github.com/osteele/liquid/values"
6 | )
7 |
8 | func init() {
9 | // This allows adding and removing references to fmt in the rules below,
10 | // without having to comment and un-comment the import statement above.
11 | _ = ""
12 | }
13 |
14 | %}
15 | %union {
16 | name string
17 | val any
18 | f func(Context) values.Value
19 | s string
20 | ss []string
21 | exprs []Expression
22 | cycle Cycle
23 | cyclefn func(string) Cycle
24 | loop Loop
25 | loopmods loopModifiers
26 | filter_params []valueFn
27 | }
28 | %type expr rel filtered cond
29 | %type filter_params
30 | %type exprs expr2
31 | %type cycle
32 | %type cycle2
33 | %type cycle3
34 | %type loop
35 | %type loop_modifiers
36 | %type string
37 | %token LITERAL
38 | %token IDENTIFIER KEYWORD PROPERTY
39 | %token ASSIGN CYCLE LOOP WHEN
40 | %token EQ NEQ GE LE IN AND OR CONTAINS DOTDOT
41 | %left '.' '|'
42 | %left '<' '>'
43 | %%
44 | start:
45 | cond ';' { yylex.(*lexer).val = $1 }
46 | | ASSIGN IDENTIFIER '=' cond ';' {
47 | yylex.(*lexer).Assignment = Assignment{$2, &expression{$4}}
48 | }
49 | | CYCLE cycle ';' { yylex.(*lexer).Cycle = $2 }
50 | | LOOP loop ';' { yylex.(*lexer).Loop = $2 }
51 | | WHEN exprs ';' { yylex.(*lexer).When = When{$2} }
52 | ;
53 |
54 | cycle: string cycle2 { $$ = $2($1) };
55 |
56 | cycle2:
57 | ':' string cycle3 {
58 | h, t := $2, $3
59 | $$ = func(g string) Cycle { return Cycle{g, append([]string{h}, t...)} }
60 | }
61 | | cycle3 {
62 | vals := $1
63 | $$ = func(h string) Cycle { return Cycle{Values: append([]string{h}, vals...)} }
64 | }
65 | ;
66 |
67 | cycle3:
68 | /* empty */ { $$ = []string{} }
69 | | ',' string cycle3 { $$ = append([]string{$2}, $3...) }
70 | ;
71 |
72 | exprs: expr expr2 { $$ = append([]Expression{&expression{$1}}, $2...) } ;
73 | expr2:
74 | /* empty */ { $$ = []Expression{} }
75 | | ',' expr expr2 { $$ = append([]Expression{&expression{$2}}, $3...) }
76 | ;
77 |
78 | string: LITERAL {
79 | s, ok := $1.(string)
80 | if !ok {
81 | panic(SyntaxError(fmt.Sprintf("expected a string for %q", $1)))
82 | }
83 | $$ = s
84 | };
85 |
86 | loop: IDENTIFIER IN filtered loop_modifiers {
87 | name, expr, mods := $1, $3, $4
88 | $$ = Loop{name, &expression{expr}, mods}
89 | }
90 | ;
91 |
92 | loop_modifiers: /* empty */ { $$ = loopModifiers{} }
93 | | loop_modifiers IDENTIFIER {
94 | switch $2 {
95 | case "reversed":
96 | $1.Reversed = true
97 | default:
98 | panic(SyntaxError(fmt.Sprintf("undefined loop modifier %q", $2)))
99 | }
100 | $$ = $1
101 | }
102 | | loop_modifiers KEYWORD expr {
103 | switch $2 {
104 | case "cols":
105 | $1.Cols = &expression{$3}
106 | case "limit":
107 | $1.Limit = &expression{$3}
108 | case "offset":
109 | $1.Offset = &expression{$3}
110 | default:
111 | panic(SyntaxError(fmt.Sprintf("undefined loop modifier %q", $2)))
112 | }
113 | $$ = $1
114 | }
115 | ;
116 |
117 | expr:
118 | LITERAL { val := $1; $$ = func(Context) values.Value { return values.ValueOf(val) } }
119 | | IDENTIFIER { name := $1; $$ = func(ctx Context) values.Value { return values.ValueOf(ctx.Get(name)) } }
120 | | expr PROPERTY { $$ = makeObjectPropertyExpr($1, $2) }
121 | | expr '[' expr ']' { $$ = makeIndexExpr($1, $3) }
122 | | '(' expr DOTDOT expr ')' { $$ = makeRangeExpr($2, $4) }
123 | | '(' cond ')' { $$ = $2 }
124 | ;
125 |
126 | filtered:
127 | expr
128 | | filtered '|' IDENTIFIER { $$ = makeFilter($1, $3, nil) }
129 | | filtered '|' KEYWORD filter_params { $$ = makeFilter($1, $3, $4) }
130 | ;
131 |
132 | filter_params:
133 | expr { $$ = []valueFn{$1} }
134 | | filter_params ',' expr
135 | { $$ = append($1, $3) }
136 |
137 | rel:
138 | filtered
139 | | expr EQ expr {
140 | fa, fb := $1, $3
141 | $$ = func(ctx Context) values.Value {
142 | a, b := fa(ctx), fb(ctx)
143 | return values.ValueOf(a.Equal(b))
144 | }
145 | }
146 | | expr NEQ expr {
147 | fa, fb := $1, $3
148 | $$ = func(ctx Context) values.Value {
149 | a, b := fa(ctx), fb(ctx)
150 | return values.ValueOf(!a.Equal(b))
151 | }
152 | }
153 | | expr '>' expr {
154 | fa, fb := $1, $3
155 | $$ = func(ctx Context) values.Value {
156 | a, b := fa(ctx), fb(ctx)
157 | return values.ValueOf(b.Less(a))
158 | }
159 | }
160 | | expr '<' expr {
161 | fa, fb := $1, $3
162 | $$ = func(ctx Context) values.Value {
163 | a, b := fa(ctx), fb(ctx)
164 | return values.ValueOf(a.Less(b))
165 | }
166 | }
167 | | expr GE expr {
168 | fa, fb := $1, $3
169 | $$ = func(ctx Context) values.Value {
170 | a, b := fa(ctx), fb(ctx)
171 | return values.ValueOf(b.Less(a) || a.Equal(b))
172 | }
173 | }
174 | | expr LE expr {
175 | fa, fb := $1, $3
176 | $$ = func(ctx Context) values.Value {
177 | a, b := fa(ctx), fb(ctx)
178 | return values.ValueOf(a.Less(b) || a.Equal(b))
179 | }
180 | }
181 | | expr CONTAINS expr { $$ = makeContainsExpr($1, $3) }
182 | ;
183 |
184 | cond:
185 | rel
186 | | cond AND rel {
187 | fa, fb := $1, $3
188 | $$ = func(ctx Context) values.Value {
189 | return values.ValueOf(fa(ctx).Test() && fb(ctx).Test())
190 | }
191 | }
192 | | cond OR rel {
193 | fa, fb := $1, $3
194 | $$ = func(ctx Context) values.Value {
195 | return values.ValueOf(fa(ctx).Test() || fb(ctx).Test())
196 | }
197 | }
198 | ;
199 |
--------------------------------------------------------------------------------
/expressions/expressions_test.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/osteele/liquid/values"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | var evaluatorTests = []struct {
14 | in string
15 | expected any
16 | }{
17 | // Literals
18 | {`12`, 12},
19 | {`12.3`, 12.3},
20 | {`true`, true},
21 | {`false`, false},
22 | {`'abc'`, "abc"},
23 | {`"abc"`, "abc"},
24 |
25 | // Variables
26 | {`n`, 123},
27 |
28 | // Attributes
29 | {`hash.a`, "first"},
30 | {`hash.b.c`, "d"},
31 | {`hash["b"].c`, "d"},
32 | {`hash.x`, nil},
33 | {`fruits.first`, "apples"},
34 | {`fruits.last`, "plums"},
35 | {`empty_list.first`, nil},
36 | {`empty_list.last`, nil},
37 | {`"abc".size`, 3},
38 | {`fruits.size`, 4},
39 | {`hash.size`, 3},
40 | {`hash_with_size_key.size`, "key_value"},
41 |
42 | // Indices
43 | {`array[1]`, "second"},
44 | {`array[-1]`, "third"}, // undocumented
45 | {`array[100]`, nil},
46 | {`hash[1]`, nil},
47 | {`hash.c[0]`, "r"},
48 |
49 | // Range
50 | {`(1..5)`, values.NewRange(1, 5)},
51 | {`(1..range.end)`, values.NewRange(1, 5)},
52 | {`(1..range["end"])`, values.NewRange(1, 5)},
53 | {`(range.begin..range.end)`, values.NewRange(1, 5)},
54 |
55 | // Expressions
56 | {`(1)`, 1},
57 | {`(n)`, 123},
58 |
59 | // Operators
60 | {`1 == 1`, true},
61 | {`1 == 2`, false},
62 | {`1.0 == 1.0`, true},
63 | {`1.0 == 2.0`, false},
64 | {`1.0 == 1`, true},
65 | {`1 == 1.0`, true},
66 | {`"a" == "a"`, true},
67 | {`"a" == "b"`, false},
68 |
69 | {`1 != 1`, false},
70 | {`1 != 2`, true},
71 | {`1.0 != 1.0`, false},
72 | {`1 != 1.0`, false},
73 | {`1 != 2.0`, true},
74 |
75 | {`1 < 2`, true},
76 | {`2 < 1`, false},
77 | {`1.0 < 2.0`, true},
78 | {`1.0 < 2`, true},
79 | {`1 < 2.0`, true},
80 | {`1.0 < 2`, true},
81 | {`"a" < "a"`, false},
82 | {`"a" < "b"`, true},
83 | {`"b" < "a"`, false},
84 |
85 | {`1 > 2`, false},
86 | {`2 > 1`, true},
87 |
88 | {`1 <= 1`, true},
89 | {`1 <= 2`, true},
90 | {`2 <= 1`, false},
91 | {`"a" <= "a"`, true},
92 | {`"a" <= "b"`, true},
93 | {`"b" <= "a"`, false},
94 |
95 | {`1 >= 1`, true},
96 | {`1 >= 2`, false},
97 | {`2 >= 1`, true},
98 |
99 | {`true and false`, false},
100 | {`true and true`, true},
101 | {`true and true and true`, true},
102 | {`false or false`, false},
103 | {`false or true`, true},
104 |
105 | {`"seafood" contains "foo"`, true},
106 | {`"seafood" contains "bar"`, false},
107 | {`array contains "first"`, true},
108 | {`interface_array contains "first"`, true},
109 | {`"foo" contains "missing"`, false},
110 | {`nil contains "missing"`, false},
111 |
112 | // filters
113 | {`"seafood" | length`, 8},
114 | }
115 |
116 | var evaluatorTestBindings = (map[string]any{
117 | "n": 123,
118 | "array": []string{"first", "second", "third"},
119 | "interface_array": []any{"first", "second", "third"},
120 | "empty_list": []any{},
121 | "fruits": []string{"apples", "oranges", "peaches", "plums"},
122 | "hash": map[string]any{
123 | "a": "first",
124 | "b": map[string]any{"c": "d"},
125 | "c": []string{"r", "g", "b"},
126 | },
127 | "hash_with_size_key": map[string]any{"size": "key_value"},
128 | "range": map[string]any{
129 | "begin": 1,
130 | "end": 5,
131 | },
132 | })
133 |
134 | func TestEvaluateString(t *testing.T) {
135 | cfg := NewConfig()
136 | cfg.AddFilter("length", strings.Count)
137 | ctx := NewContext(evaluatorTestBindings, cfg)
138 | for i, test := range evaluatorTests {
139 | t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
140 | val, err := EvaluateString(test.in, ctx)
141 | require.NoErrorf(t, err, test.in)
142 | require.Equalf(t, test.expected, val, test.in)
143 | })
144 | }
145 |
146 | _, err := EvaluateString("syntax error", ctx)
147 | require.Error(t, err)
148 |
149 | _, err = EvaluateString("1 | undefined_filter", ctx)
150 | require.Error(t, err)
151 |
152 | cfg.AddFilter("error", func(input any) (string, error) { return "", errors.New("test error") })
153 | _, err = EvaluateString("1 | error", ctx)
154 | require.Error(t, err)
155 | }
156 |
157 | func TestClosure(t *testing.T) {
158 | cfg := NewConfig()
159 | ctx := NewContext(map[string]any{"x": 1}, cfg)
160 | expr, err := Parse("x")
161 | require.NoError(t, err)
162 | c1 := closure{expr, ctx}
163 | c2 := c1.Bind("x", 2)
164 | x1, err := c1.Evaluate()
165 | require.NoError(t, err)
166 | x2, err := c2.Evaluate()
167 | require.NoError(t, err)
168 | require.Equal(t, 1, x1)
169 | require.Equal(t, 2, x2)
170 | }
171 |
--------------------------------------------------------------------------------
/expressions/filters.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 |
7 | "github.com/osteele/liquid/values"
8 | )
9 |
10 | // An InterpreterError is an error during expression interpretation.
11 | // It is used for errors in the input expression, to distinguish them
12 | // from implementation errors in the interpreter.
13 | type InterpreterError string
14 |
15 | func (e InterpreterError) Error() string { return string(e) }
16 |
17 | // UndefinedFilter is an error that the named filter is not defined.
18 | type UndefinedFilter string
19 |
20 | func (e UndefinedFilter) Error() string {
21 | return fmt.Sprintf("undefined filter %q", string(e))
22 | }
23 |
24 | // FilterError is the error returned by a filter when it is applied
25 | type FilterError struct {
26 | FilterName string
27 | Err error
28 | }
29 |
30 | func (e FilterError) Error() string {
31 | return fmt.Sprintf("error applying filter %q (%q)", e.FilterName, e.Err)
32 | }
33 |
34 | type valueFn func(Context) values.Value
35 |
36 | // AddFilter adds a filter to the filter dictionary.
37 | func (c *Config) AddFilter(name string, fn any) {
38 | rf := reflect.ValueOf(fn)
39 | switch {
40 | case rf.Kind() != reflect.Func:
41 | panic("a filter must be a function")
42 | case rf.Type().NumIn() < 1:
43 | panic("a filter function must have at least one input")
44 | case rf.Type().NumOut() < 1 || 2 < rf.Type().NumOut():
45 | panic("a filter must be have one or two outputs")
46 | // case rf.Type().Out(1).Implements(…):
47 | // panic(typeError("a filter's second output must be type error"))
48 | }
49 | if len(c.filters) == 0 {
50 | c.filters = make(map[string]any)
51 | }
52 | c.filters[name] = fn
53 | }
54 |
55 | var (
56 | closureType = reflect.TypeOf(closure{})
57 | interfaceType = reflect.TypeOf([]any{}).Elem()
58 | )
59 |
60 | func isClosureInterfaceType(t reflect.Type) bool {
61 | return closureType.ConvertibleTo(t) && !interfaceType.ConvertibleTo(t)
62 | }
63 |
64 | func (ctx *context) ApplyFilter(name string, receiver valueFn, params []valueFn) (any, error) {
65 | filter, ok := ctx.filters[name]
66 | if !ok {
67 | panic(UndefinedFilter(name))
68 | }
69 | fr := reflect.ValueOf(filter)
70 | args := []any{receiver(ctx).Interface()}
71 | for i, param := range params {
72 | if i+1 < fr.Type().NumIn() && isClosureInterfaceType(fr.Type().In(i+1)) {
73 | expr, err := Parse(param(ctx).Interface().(string))
74 | if err != nil {
75 | panic(err)
76 | }
77 | args = append(args, closure{expr, ctx})
78 | } else {
79 | args = append(args, param(ctx).Interface())
80 | }
81 | }
82 | out, err := values.Call(fr, args)
83 | if err != nil {
84 | if e, ok := err.(*values.CallParityError); ok {
85 | err = &values.CallParityError{NumArgs: e.NumArgs - 1, NumParams: e.NumParams - 1}
86 | }
87 | return nil, err
88 | }
89 | switch out := out.(type) {
90 | case []byte:
91 | return string(out), nil
92 | default:
93 | return out, nil
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/expressions/filters_test.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/osteele/liquid/values"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestContext_AddFilter(t *testing.T) {
12 | cfg := NewConfig()
13 | require.NotPanics(t, func() { cfg.AddFilter("f", func(int) int { return 0 }) })
14 | require.NotPanics(t, func() { cfg.AddFilter("f", func(int) (a int, e error) { return }) })
15 | require.Panics(t, func() { cfg.AddFilter("f", func() int { return 0 }) })
16 | require.Panics(t, func() { cfg.AddFilter("f", func(int) {}) })
17 | // require.Panics(t, func() { cfg.AddFilter("f", func(int) (a int, b int) { return }) })
18 | //nolint:staticcheck
19 | require.Panics(t, func() { cfg.AddFilter("f", func(int) (a int, e error, b int) { return }) })
20 | require.Panics(t, func() { cfg.AddFilter("f", 10) })
21 | }
22 |
23 | func TestContext_runFilter(t *testing.T) {
24 | cfg := NewConfig()
25 | constant := func(value any) valueFn {
26 | return func(Context) values.Value { return values.ValueOf(value) }
27 | }
28 | receiver := constant("self")
29 |
30 | // basic
31 | cfg.AddFilter("f1", func(s string) string {
32 | return "<" + s + ">"
33 | })
34 | ctx := NewContext(map[string]any{"x": 10}, cfg)
35 | out, err := ctx.ApplyFilter("f1", receiver, []valueFn{})
36 | require.NoError(t, err)
37 | require.Equal(t, "", out)
38 |
39 | // filter argument
40 | cfg.AddFilter("with_arg", func(a, b string) string {
41 | return fmt.Sprintf("(%s, %s)", a, b)
42 | })
43 | ctx = NewContext(map[string]any{"x": 10}, cfg)
44 | out, err = ctx.ApplyFilter("with_arg", receiver, []valueFn{constant("arg")})
45 | require.NoError(t, err)
46 | require.Equal(t, "(self, arg)", out)
47 |
48 | // TODO optional argument
49 | // TODO error return
50 |
51 | // extra argument
52 | _, err = ctx.ApplyFilter("with_arg", receiver, []valueFn{constant(1), constant(2)})
53 | require.Error(t, err)
54 | require.Contains(t, err.Error(), "wrong number of arguments")
55 | require.Contains(t, err.Error(), "given 2")
56 | require.Contains(t, err.Error(), "expected 1")
57 |
58 | // closure
59 | cfg.AddFilter("add", func(a, b int) int {
60 | return a + b
61 | })
62 | cfg.AddFilter("closure", func(a string, expr Closure) (string, error) {
63 | value, e := expr.Bind("y", 1).Evaluate()
64 | if e != nil {
65 | return "", e
66 | }
67 | return fmt.Sprintf("(%v, %v)", a, value), nil
68 | })
69 | ctx = NewContext(map[string]any{"x": 10}, cfg)
70 | out, err = ctx.ApplyFilter("closure", receiver, []valueFn{constant("x |add: y")})
71 | require.NoError(t, err)
72 | require.Equal(t, "(self, 11)", out)
73 | }
74 |
--------------------------------------------------------------------------------
/expressions/functional.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | type expressionWrapper struct {
4 | fn func(ctx Context) (any, error)
5 | }
6 |
7 | func (w expressionWrapper) Evaluate(ctx Context) (any, error) {
8 | return w.fn(ctx)
9 | }
10 |
11 | // Constant creates an expression that returns a constant value.
12 | func Constant(k any) Expression {
13 | return expressionWrapper{
14 | func(_ Context) (any, error) {
15 | return k, nil
16 | },
17 | }
18 | }
19 |
20 | // Not creates an expression that returns ! of the wrapped expression.
21 | func Not(e Expression) Expression {
22 | return expressionWrapper{
23 | func(ctx Context) (any, error) {
24 | value, err := e.Evaluate(ctx)
25 | if err != nil {
26 | return nil, err
27 | }
28 | return (value == nil || value == false), nil
29 | },
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/expressions/functional_test.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestConstant(t *testing.T) {
10 | ctx := NewContext(map[string]any{}, NewConfig())
11 | k := Constant(10)
12 | v, err := k.Evaluate(ctx)
13 | require.NoError(t, err)
14 | require.Equal(t, 10, v)
15 | }
16 |
17 | func TestNot(t *testing.T) {
18 | ctx := NewContext(map[string]any{}, NewConfig())
19 | k := Constant(10)
20 | v, err := Not(k).Evaluate(ctx)
21 | require.NoError(t, err)
22 | require.Equal(t, false, v)
23 | }
24 |
--------------------------------------------------------------------------------
/expressions/parser.go:
--------------------------------------------------------------------------------
1 | //go:generate ragel -Z scanner.rl
2 | //go:generate gofmt -w scanner.go
3 | //go:generate goyacc expressions.y
4 |
5 | package expressions
6 |
7 | import (
8 | "fmt"
9 |
10 | "github.com/osteele/liquid/values"
11 | )
12 |
13 | type parseValue struct {
14 | Assignment
15 | Cycle
16 | Loop
17 | When
18 | val func(Context) values.Value
19 | }
20 |
21 | // SyntaxError represents a syntax error. The yacc-generated compiler
22 | // doesn't use error returns; this lets us recognize them.
23 | type SyntaxError string
24 |
25 | func (e SyntaxError) Error() string { return string(e) }
26 |
27 | // Parse parses an expression string into an Expression.
28 | func Parse(source string) (expr Expression, err error) {
29 | p, err := parse(source)
30 | if err != nil {
31 | return nil, err
32 | }
33 | return &expression{p.val}, nil
34 | }
35 |
36 | func parse(source string) (p *parseValue, err error) {
37 | defer func() {
38 | if r := recover(); r != nil {
39 | switch e := r.(type) {
40 | case SyntaxError:
41 | err = e
42 | case UndefinedFilter:
43 | err = e
44 | default:
45 | panic(r)
46 | }
47 | }
48 | }()
49 | // FIXME hack to recognize EOF
50 | lex := newLexer([]byte(source + ";"))
51 | n := yyParse(lex)
52 | if n != 0 {
53 | return nil, SyntaxError(fmt.Errorf("syntax error in %q", source).Error())
54 | }
55 | return &lex.parseValue, nil
56 | }
57 |
58 | // EvaluateString is a wrapper for Parse and Evaluate.
59 | func EvaluateString(source string, ctx Context) (any, error) {
60 | expr, err := Parse(source)
61 | if err != nil {
62 | return nil, err
63 | }
64 | return expr.Evaluate(ctx)
65 | }
66 |
--------------------------------------------------------------------------------
/expressions/parser_test.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | var parseTests = []struct {
11 | in string
12 | expect any
13 | }{
14 | {`true`, true},
15 | {`false`, false},
16 | {`nil`, nil},
17 | {`2`, 2},
18 | {`"s"`, "s"},
19 | {`a`, 1},
20 | {`obj.prop`, 2},
21 | {`a | add: b`, 3},
22 | {`1 == 1`, true},
23 | {`1 != 1`, false},
24 | {`true and true`, true},
25 | }
26 |
27 | var parseErrorTests = []struct{ in, expected string }{
28 | {"a syntax error", "syntax error"},
29 | {`%assign a`, "syntax error"},
30 | {`%assign a 3`, "syntax error"},
31 | {`%cycle 'a' 'b'`, "syntax error"},
32 | {`%loop a in in`, "syntax error"},
33 | {`%when a b`, "syntax error"},
34 | }
35 |
36 | // Since the parser returns funcs, there's no easy way to test them except evaluation
37 | func TestParse(t *testing.T) {
38 | cfg := NewConfig()
39 | cfg.AddFilter("add", func(a, b int) int { return a + b })
40 | ctx := NewContext(map[string]any{
41 | "a": 1,
42 | "b": 2,
43 | "obj": map[string]int{"prop": 2},
44 | }, cfg)
45 | for i, test := range parseTests {
46 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
47 | expr, err := Parse(test.in)
48 | require.NoError(t, err, test.in)
49 | _ = expr
50 | value, err := expr.Evaluate(ctx)
51 | require.NoError(t, err, test.in)
52 | require.Equal(t, test.expect, value, test.in)
53 | })
54 | }
55 | }
56 |
57 | func TestParse_errors(t *testing.T) {
58 | for i, test := range parseErrorTests {
59 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
60 | expr, err := Parse(test.in)
61 | require.Nilf(t, expr, test.in)
62 | require.Errorf(t, err, test.in, test.in)
63 | require.Containsf(t, err.Error(), test.expected, test.in)
64 | })
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/expressions/scanner.rl:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import "strconv"
4 |
5 | %%{
6 | machine expression;
7 | write data;
8 | access lex.;
9 | variable p lex.p;
10 | variable pe lex.pe;
11 | }%%
12 |
13 | type lexer struct {
14 | parseValue
15 | data []byte
16 | p, pe, cs int
17 | ts, te, act int
18 | }
19 |
20 | func (l* lexer) token() string {
21 | return string(l.data[l.ts:l.te])
22 | }
23 |
24 | func newLexer(data []byte) *lexer {
25 | lex := &lexer{
26 | data: data,
27 | pe: len(data),
28 | }
29 | %% write init;
30 | return lex
31 | }
32 |
33 | func (lex *lexer) Lex(out *yySymType) int {
34 | eof := lex.pe
35 | tok := 0
36 |
37 | %%{
38 | action Bool {
39 | tok = LITERAL
40 | out.val = lex.token() == "true"
41 | fbreak;
42 | }
43 | action Identifier {
44 | tok = IDENTIFIER
45 | out.name = lex.token()
46 | fbreak;
47 | }
48 | action Int {
49 | tok = LITERAL
50 | n, err := strconv.ParseInt(lex.token(), 10, 64)
51 | if err != nil {
52 | panic(err)
53 | }
54 | out.val = int(n)
55 | fbreak;
56 | }
57 | action Float {
58 | tok = LITERAL
59 | n, err := strconv.ParseFloat(lex.token(), 64)
60 | if err != nil {
61 | panic(err)
62 | }
63 | out.val = n
64 | fbreak;
65 | }
66 | action String {
67 | tok = LITERAL
68 | // TODO unescape \x
69 | out.val = string(lex.data[lex.ts+1:lex.te-1])
70 | fbreak;
71 | }
72 | action Relation { tok = RELATION; out.name = lex.token(); fbreak; }
73 |
74 | identifier = (alpha | '_') . (alnum | '_' | '-')* '?'? ;
75 | # TODO is this the form for a property? (in which case can share w/ identifier)
76 | property = '.' (alpha | '_') . (alnum | '_' | '-')* '?' ? ;
77 | int = '-'? digit+ ;
78 | float = '-'? digit+ ('.' digit+)? ;
79 | string = '"' (any - '"')* '"' | "'" (any - "'")* "'" ; # TODO escapes
80 |
81 | main := |*
82 | # statement selectors, should match constants in parser.go
83 | "%assign " => { tok = ASSIGN; fbreak; };
84 | "{%cycle " => { tok = CYCLE; fbreak; };
85 | "%loop " => { tok = LOOP; fbreak; };
86 | "{%when " => { tok = WHEN; fbreak; };
87 |
88 | # literals
89 | int => Int;
90 | float => Float;
91 | string => String;
92 |
93 | # constants
94 | ("true" | "false") => Bool;
95 | "nil" => { tok = LITERAL; out.val = nil; fbreak; };
96 |
97 | # relations
98 | "==" => { tok = EQ; fbreak; };
99 | "!=" => { tok = NEQ; fbreak; };
100 | ">=" => { tok = GE; fbreak; };
101 | "<=" => { tok = LE; fbreak; };
102 | "and" => { tok = AND; fbreak; };
103 | "or" => { tok = OR; fbreak; };
104 | "contains" => { tok = CONTAINS; fbreak; };
105 |
106 | # keywords
107 | "in" => { tok = IN; fbreak; };
108 | ".." => { tok = DOTDOT; fbreak; };
109 |
110 | identifier ':' => { tok = KEYWORD; out.name = string(lex.data[lex.ts:lex.te-1]); fbreak; };
111 | identifier => Identifier;
112 | property => { tok = PROPERTY; out.name = string(lex.data[lex.ts+1:lex.te]); fbreak; };
113 |
114 | space+;
115 | any => { tok = int(lex.data[lex.ts]); fbreak; };
116 | *|;
117 |
118 | write exec;
119 | }%%
120 |
121 | return tok
122 | }
123 |
124 | func (lex *lexer) Error(e string) {
125 | // fmt.Println("scan error:", e)
126 | }
127 |
--------------------------------------------------------------------------------
/expressions/scanner_test.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | type testSymbol struct {
11 | tok int
12 | typ yySymType
13 | }
14 |
15 | func (s testSymbol) String() string {
16 | return fmt.Sprintf("%d:%v", s.tok, s.typ)
17 | }
18 |
19 | func scanExpression(data string) []testSymbol {
20 | var (
21 | lex = newLexer([]byte(data))
22 | symbols []testSymbol
23 | s yySymType
24 | )
25 | for {
26 | tok := lex.Lex(&s)
27 | if tok == 0 {
28 | break
29 | }
30 | symbols = append(symbols, testSymbol{tok, s})
31 | }
32 | return symbols
33 | }
34 |
35 | func TestLex(t *testing.T) {
36 | ts := scanExpression("abc > 123")
37 | require.Len(t, ts, 3)
38 | require.Equal(t, IDENTIFIER, ts[0].tok)
39 | require.Equal(t, "abc", ts[0].typ.name)
40 | require.Equal(t, LITERAL, ts[2].tok)
41 | require.Equal(t, 123, ts[2].typ.val)
42 |
43 | // verify these don't match "for", "or", or "false"
44 | ts = scanExpression("forage")
45 | require.Len(t, ts, 1)
46 | ts = scanExpression("orange")
47 | require.Len(t, ts, 1)
48 | ts = scanExpression("falsehood")
49 | require.Len(t, ts, 1)
50 |
51 | ts = scanExpression("a.b-c")
52 | require.Len(t, ts, 2)
53 | require.Equal(t, PROPERTY, ts[1].tok)
54 | require.Equal(t, "b-c", ts[1].typ.name)
55 |
56 | // literals
57 | ts = scanExpression(`true false nil 2 2.3 "abc" 'abc'`)
58 | require.Len(t, ts, 7)
59 | require.Equal(t, LITERAL, ts[0].tok)
60 | require.Equal(t, LITERAL, ts[1].tok)
61 | require.Equal(t, LITERAL, ts[2].tok)
62 | require.Equal(t, LITERAL, ts[3].tok)
63 | require.Equal(t, LITERAL, ts[4].tok)
64 | require.Equal(t, LITERAL, ts[5].tok)
65 | require.Equal(t, LITERAL, ts[6].tok)
66 | require.Equal(t, true, ts[0].typ.val)
67 | require.Equal(t, false, ts[1].typ.val)
68 | require.Nil(t, ts[2].typ.val)
69 | require.Equal(t, 2, ts[3].typ.val)
70 | //nolint:testifylint
71 | require.Equal(t, 2.3, ts[4].typ.val)
72 | require.Equal(t, "abc", ts[5].typ.val)
73 | require.Equal(t, "abc", ts[6].typ.val)
74 |
75 | // identifiers
76 | ts = scanExpression(`abc ab_c ab-c abc?`)
77 | require.Len(t, ts, 4)
78 | require.Equal(t, IDENTIFIER, ts[0].tok)
79 | require.Equal(t, IDENTIFIER, ts[1].tok)
80 | require.Equal(t, IDENTIFIER, ts[2].tok)
81 | require.Equal(t, IDENTIFIER, ts[3].tok)
82 | require.Equal(t, "abc", ts[0].typ.name)
83 | require.Equal(t, "ab_c", ts[1].typ.name)
84 | require.Equal(t, "ab-c", ts[2].typ.name)
85 | require.Equal(t, "abc?", ts[3].typ.name)
86 |
87 | ts = scanExpression(`{%cycle 'a', 'b'`)
88 | require.Len(t, ts, 4)
89 |
90 | ts = scanExpression(`%loop i in (3 .. 5)`)
91 | require.Len(t, ts, 8)
92 |
93 | // ts= scanExpression(`%loop i in (3..5)`)
94 | // require.Len(t, ts, 9)
95 | }
96 |
--------------------------------------------------------------------------------
/expressions/statements.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | // These strings match lexer tokens.
4 | const (
5 | AssignStatementSelector = "%assign "
6 | CycleStatementSelector = "{%cycle "
7 | LoopStatementSelector = "%loop "
8 | WhenStatementSelector = "{%when "
9 | )
10 |
11 | // A Statement is the result of parsing a string.
12 | type Statement struct{ parseValue }
13 |
14 | // Expression returns a statement's expression function.
15 | // func (s *Statement) Expression() Expression { return &expression{s.val} }
16 |
17 | // An Assignment is a parse of an {% assign %} statement
18 | type Assignment struct {
19 | Variable string
20 | ValueFn Expression
21 | }
22 |
23 | // A Cycle is a parse of an {% assign %} statement
24 | type Cycle struct {
25 | Group string
26 | Values []string
27 | }
28 |
29 | // A Loop is a parse of a {% loop %} statement
30 | type Loop struct {
31 | Variable string
32 | Expr Expression
33 | loopModifiers
34 | }
35 |
36 | type loopModifiers struct {
37 | Limit Expression
38 | Offset Expression
39 | Cols Expression
40 | Reversed bool
41 | }
42 |
43 | // A When is a parse of a {% when %} clause
44 | type When struct {
45 | Exprs []Expression
46 | }
47 |
48 | // ParseStatement parses an statement into an Expression that can evaluated to return a
49 | // structure specific to the statement.
50 | func ParseStatement(sel, source string) (*Statement, error) {
51 | p, err := parse(sel + source)
52 | if err != nil {
53 | return nil, err
54 | }
55 | return &Statement{*p}, nil
56 | }
57 |
--------------------------------------------------------------------------------
/expressions/statements_test.go:
--------------------------------------------------------------------------------
1 | package expressions
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestParseStatement(t *testing.T) {
10 | stmt, err := ParseStatement(AssignStatementSelector, "a = b")
11 | require.NoError(t, err)
12 | require.Equal(t, "a", stmt.Assignment.Variable)
13 | require.Implements(t, (*Expression)(nil), stmt.ValueFn)
14 |
15 | stmt, err = ParseStatement(AssignStatementSelector, "a = 1 == 1")
16 | require.NoError(t, err)
17 | require.Equal(t, "a", stmt.Assignment.Variable)
18 |
19 | stmt, err = ParseStatement(CycleStatementSelector, "'a', 'b'")
20 | require.NoError(t, err)
21 | require.Empty(t, stmt.Group)
22 | require.Len(t, stmt.Values, 2)
23 | require.Equal(t, []string{"a", "b"}, stmt.Values)
24 |
25 | stmt, err = ParseStatement(CycleStatementSelector, "'g': 'a', 'b'")
26 | require.NoError(t, err)
27 | require.Equal(t, "g", stmt.Group)
28 | require.Len(t, stmt.Values, 2)
29 | require.Equal(t, []string{"a", "b"}, stmt.Values)
30 |
31 | stmt, err = ParseStatement(LoopStatementSelector, "x in array reversed offset: 2 limit: 3")
32 | require.NoError(t, err)
33 | require.Equal(t, "x", stmt.Loop.Variable)
34 | require.True(t, stmt.Reversed)
35 |
36 | require.Nil(t, stmt.Cols)
37 | require.NotNil(t, stmt.Limit)
38 | require.Implements(t, (*Expression)(nil), stmt.Limit)
39 | require.NotNil(t, stmt.Offset)
40 | require.Implements(t, (*Expression)(nil), stmt.Offset)
41 |
42 | stmt, err = ParseStatement(WhenStatementSelector, "a, b")
43 | require.NoError(t, err)
44 | require.Len(t, stmt.Exprs, 2)
45 | }
46 |
--------------------------------------------------------------------------------
/filters/sort_filters.go:
--------------------------------------------------------------------------------
1 | package filters
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "sort"
7 | "strings"
8 |
9 | "github.com/osteele/liquid/values"
10 | )
11 |
12 | func sortFilter(array []any, key any) []any {
13 | result := make([]any, len(array))
14 | copy(result, array)
15 | if key == nil {
16 | values.Sort(result)
17 | } else {
18 | values.SortByProperty(result, fmt.Sprint(key), true)
19 | }
20 | return result
21 | }
22 |
23 | func sortNaturalFilter(array []any, key any) any {
24 | result := make([]any, len(array))
25 | copy(result, array)
26 | switch {
27 | case reflect.ValueOf(array).Len() == 0:
28 | case key != nil:
29 | sort.Sort(keySortable{result, func(m any) string {
30 | rv := reflect.ValueOf(m)
31 | if rv.Kind() != reflect.Map {
32 | return ""
33 | }
34 | ev := rv.MapIndex(reflect.ValueOf(key))
35 | if ev.CanInterface() {
36 | if s, ok := ev.Interface().(string); ok {
37 | return strings.ToLower(s)
38 | }
39 | }
40 | return ""
41 | }})
42 | case reflect.TypeOf(array[0]).Kind() == reflect.String:
43 | sort.Sort(keySortable{result, func(s any) string {
44 | return strings.ToUpper(s.(string))
45 | }})
46 | }
47 | return result
48 | }
49 |
50 | type keySortable struct {
51 | slice []any
52 | keyFn func(any) string
53 | }
54 |
55 | // Len is part of sort.Interface.
56 | func (s keySortable) Len() int {
57 | return len(s.slice)
58 | }
59 |
60 | // Swap is part of sort.Interface.
61 | func (s keySortable) Swap(i, j int) {
62 | a := s.slice
63 | a[i], a[j] = a[j], a[i]
64 | }
65 |
66 | // Less is part of sort.Interface.
67 | func (s keySortable) Less(i, j int) bool {
68 | k, sl := s.keyFn, s.slice
69 | a, b := k(sl[i]), k(sl[j])
70 | return a < b
71 | }
72 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/osteele/liquid
2 |
3 | go 1.23
4 |
5 | require (
6 | github.com/osteele/tuesday v1.0.3
7 | github.com/stretchr/testify v1.7.0
8 | gopkg.in/yaml.v2 v2.4.0
9 | )
10 |
11 | require (
12 | github.com/davecgh/go-spew v1.1.1 // indirect
13 | github.com/pmezard/go-difflib v1.0.0 // indirect
14 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
15 | )
16 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 | github.com/osteele/tuesday v1.0.3 h1:SrCmo6sWwSgnvs1bivmXLvD7Ko9+aJvvkmDjB5G4FTU=
5 | github.com/osteele/tuesday v1.0.3/go.mod h1:pREKpE+L03UFuR+hiznj3q7j3qB1rUZ4XfKejwWFF2M=
6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
9 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
10 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
13 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
14 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
16 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
17 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
18 |
--------------------------------------------------------------------------------
/liquid.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package liquid is a pure Go implementation of Shopify Liquid templates, developed for use in https://github.com/osteele/gojekyll.
3 |
4 | See the project README https://github.com/osteele/liquid for additional information and implementation status.
5 |
6 | The liquid package itself is versioned in gopkg.in. Subpackages have no compatibility guarantees. Except where specifically documented, the “public” entities of subpackages are intended only for use by the liquid package and its subpackages.
7 | */
8 | package liquid
9 |
10 | import (
11 | "github.com/osteele/liquid/render"
12 | "github.com/osteele/liquid/tags"
13 | )
14 |
15 | // Bindings is a map of variable names to values.
16 | //
17 | // Clients need not use this type. It is used solely for documentation. Callers can use instances
18 | // of map[string]any itself as argument values to functions declared with this parameter type.
19 | type Bindings map[string]any
20 |
21 | // A Renderer returns the rendered string for a block. This is the type of a tag definition.
22 | //
23 | // See the examples at Engine.RegisterTag and Engine.RegisterBlock.
24 | type Renderer func(render.Context) (string, error)
25 |
26 | // SourceError records an error with a source location and optional cause.
27 | //
28 | // SourceError does not depend on, but is compatible with, the causer interface of https://github.com/pkg/errors.
29 | type SourceError interface {
30 | error
31 | Cause() error
32 | Path() string
33 | LineNumber() int
34 | }
35 |
36 | // IterationKeyedMap returns a map whose {% for %} tag iteration values are its keys, instead of [key, value] pairs.
37 | // Use this to create a Go map with the semantics of a Ruby struct drop.
38 | func IterationKeyedMap(m map[string]any) tags.IterationKeyedMap {
39 | return m
40 | }
41 |
--------------------------------------------------------------------------------
/liquid_test.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestIterationKeyedMap(t *testing.T) {
12 | vars := map[string]any{
13 | "keyed_map": IterationKeyedMap(map[string]any{"a": 1, "b": 2}),
14 | }
15 | engine := NewEngine()
16 | tpl, err := engine.ParseTemplate([]byte(`{% for k in keyed_map %}{{ k }}={{ keyed_map[k] }}.{% endfor %}`))
17 | require.NoError(t, err)
18 | out, err := tpl.RenderString(vars)
19 | require.NoError(t, err)
20 | require.Equal(t, "a=1.b=2.", out)
21 | }
22 |
23 | func ExampleIterationKeyedMap() {
24 | vars := map[string]any{
25 | "map": map[string]any{"a": 1},
26 | "keyed_map": IterationKeyedMap(map[string]any{"a": 1}),
27 | }
28 | engine := NewEngine()
29 | out, err := engine.ParseAndRenderString(
30 | `{% for k in map %}{{ k[0] }}={{ k[1] }}.{% endfor %}`, vars)
31 | if err != nil {
32 | log.Fatal(err)
33 | }
34 | fmt.Println(out)
35 | out, err = engine.ParseAndRenderString(
36 | `{% for k in keyed_map %}{{ k }}={{ keyed_map[k] }}.{% endfor %}`, vars)
37 | if err != nil {
38 | log.Fatal(err)
39 | }
40 | fmt.Println(out)
41 | // Output: a=1.
42 | // a=1.
43 | }
44 |
--------------------------------------------------------------------------------
/parser/ast.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "github.com/osteele/liquid/expressions"
5 | )
6 |
7 | // ASTNode is a node of an AST.
8 | type ASTNode Locatable
9 |
10 | // ASTBlock represents a {% tag %}…{% endtag %}.
11 | type ASTBlock struct {
12 | Token
13 | syntax BlockSyntax
14 | Body []ASTNode // Body is the nodes before the first branch
15 | Clauses []*ASTBlock // E.g. else and elseif w/in an if
16 | }
17 |
18 | // ASTRaw holds the text between the start and end of a raw tag.
19 | type ASTRaw struct {
20 | Slices []string
21 | sourcelessNode
22 | }
23 |
24 | // ASTTag is a tag {% tag %} that is not a block start or end.
25 | type ASTTag struct {
26 | Token
27 | }
28 |
29 | // ASTText is a text span, that is rendered verbatim.
30 | type ASTText struct {
31 | Token
32 | }
33 |
34 | // ASTObject is an {{ object }} object.
35 | type ASTObject struct {
36 | Token
37 | Expr expressions.Expression
38 | }
39 |
40 | // ASTSeq is a sequence of nodes.
41 | type ASTSeq struct {
42 | Children []ASTNode
43 | sourcelessNode
44 | }
45 |
46 | // TrimDirection determines the trim direction of an ASTTrim object.
47 | type TrimDirection int
48 |
49 | const (
50 | Left TrimDirection = iota
51 | Right
52 | )
53 |
54 | // ASTTrim is a trim object.
55 | type ASTTrim struct {
56 | sourcelessNode
57 | TrimDirection
58 | }
59 |
60 | // It shouldn't be possible to get an error from one of these node types.
61 | // If it is, this needs to be re-thought to figure out where the source
62 | // location comes from.
63 | type sourcelessNode struct{}
64 |
65 | func (n *sourcelessNode) SourceLocation() SourceLoc {
66 | panic("unexpected call on sourceless node")
67 | }
68 |
69 | func (n *sourcelessNode) SourceText() string {
70 | panic("unexpected call on sourceless node")
71 | }
72 |
--------------------------------------------------------------------------------
/parser/config.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import "github.com/osteele/liquid/expressions"
4 |
5 | // A Config holds configuration information for parsing and rendering.
6 | type Config struct {
7 | expressions.Config
8 | Grammar Grammar
9 | Delims []string
10 | }
11 |
12 | // NewConfig creates a parser Config.
13 | func NewConfig(g Grammar) Config {
14 | return Config{Grammar: g}
15 | }
16 |
--------------------------------------------------------------------------------
/parser/error.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import "fmt"
4 |
5 | // An Error is a syntax error during template parsing.
6 | type Error interface {
7 | error
8 | Cause() error
9 | Path() string
10 | LineNumber() int
11 | }
12 |
13 | // A Locatable provides source location information for error reporting.
14 | type Locatable interface {
15 | SourceLocation() SourceLoc
16 | SourceText() string
17 | }
18 |
19 | // Errorf creates a parser.Error.
20 | func Errorf(loc Locatable, format string, a ...any) *sourceLocError { //nolint: golint
21 | return &sourceLocError{loc.SourceLocation(), loc.SourceText(), fmt.Sprintf(format, a...), nil}
22 | }
23 |
24 | // WrapError wraps its argument in a parser.Error if this argument is not already a parser.Error and is not locatable.
25 | func WrapError(err error, loc Locatable) Error {
26 | if err == nil {
27 | return nil
28 | }
29 | if e, ok := err.(Error); ok {
30 | // re-wrap the error, if the inner layer implemented the locatable interface
31 | // but didn't actually provide any information
32 | if e.Path() != "" || loc.SourceLocation().IsZero() {
33 | return e
34 | }
35 | if e.Cause() != nil {
36 | err = e.Cause()
37 | }
38 | }
39 | re := Errorf(loc, "%s", err)
40 | re.cause = err
41 | return re
42 | }
43 |
44 | type sourceLocError struct {
45 | SourceLoc
46 | context string
47 | message string
48 | cause error
49 | }
50 |
51 | func (e *sourceLocError) Cause() error {
52 | return e.cause
53 | }
54 |
55 | func (e *sourceLocError) Path() string {
56 | return e.Pathname
57 | }
58 |
59 | func (e *sourceLocError) LineNumber() int {
60 | return e.LineNo
61 | }
62 |
63 | func (e *sourceLocError) Error() string {
64 | line := ""
65 | if e.LineNo > 0 {
66 | line = fmt.Sprintf(" (line %d)", e.LineNo)
67 | }
68 | locative := " in " + e.context
69 | if e.Pathname != "" {
70 | locative = " in " + e.Pathname
71 | }
72 | return fmt.Sprintf("Liquid error%s: %s%s", line, e.message, locative)
73 | }
74 |
--------------------------------------------------------------------------------
/parser/grammar.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | // Grammar supplies the parser with syntax information about blocks.
4 | type Grammar interface {
5 | BlockSyntax(string) (BlockSyntax, bool)
6 | }
7 |
8 | // BlockSyntax supplies the parser with syntax information about blocks.
9 | type BlockSyntax interface {
10 | IsBlock() bool
11 | CanHaveParent(BlockSyntax) bool
12 | IsBlockEnd() bool
13 | IsBlockStart() bool
14 | IsClause() bool
15 | ParentTags() []string
16 | RequiresParent() bool
17 | TagName() string
18 | }
19 |
20 | // Grammar returns a configuration's grammar.
21 | // func (c *Config) Grammar() Grammar { return c }
22 |
--------------------------------------------------------------------------------
/parser/parser.go:
--------------------------------------------------------------------------------
1 | // Package parser is an internal package that parses template source into an abstract syntax tree.
2 | package parser
3 |
4 | import (
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/osteele/liquid/expressions"
9 | )
10 |
11 | // Parse parses a source template. It returns an AST root, that can be compiled and evaluated.
12 | func (c *Config) Parse(source string, loc SourceLoc) (ASTNode, Error) {
13 | tokens := Scan(source, loc, c.Delims)
14 | return c.parseTokens(tokens)
15 | }
16 |
17 | // Parse creates an AST from a sequence of tokens.
18 | func (c *Config) parseTokens(tokens []Token) (ASTNode, Error) { //nolint: gocyclo
19 | // a stack of control tag state, for matching nested {%if}{%endif%} etc.
20 | type frame struct {
21 | syntax BlockSyntax
22 | node *ASTBlock
23 | ap *[]ASTNode
24 | }
25 | var (
26 | g = c.Grammar
27 | root = &ASTSeq{} // root of AST; will be returned
28 | ap = &root.Children // newly-constructed nodes are appended here
29 | sd BlockSyntax // current block syntax definition
30 | bn *ASTBlock // current block node
31 | stack []frame // stack of blocks
32 | rawTag *ASTRaw // current raw tag
33 | inComment = false
34 | inRaw = false
35 | )
36 | for _, tok := range tokens {
37 | switch {
38 | // The parser needs to know about comment and raw, because tags inside
39 | // needn't match each other e.g. {%comment%}{%if%}{%endcomment%}
40 | // TODO is this true?
41 | case inComment:
42 | if tok.Type == TagTokenType && tok.Name == "endcomment" {
43 | inComment = false
44 | }
45 | case inRaw:
46 | if tok.Type == TagTokenType && tok.Name == "endraw" {
47 | inRaw = false
48 | } else {
49 | rawTag.Slices = append(rawTag.Slices, tok.Source)
50 | }
51 | case tok.Type == ObjTokenType:
52 | expr, err := expressions.Parse(tok.Args)
53 | if err != nil {
54 | return nil, WrapError(err, tok)
55 | }
56 | *ap = append(*ap, &ASTObject{tok, expr})
57 | case tok.Type == TextTokenType:
58 | *ap = append(*ap, &ASTText{Token: tok})
59 | case tok.Type == TagTokenType:
60 | if g == nil {
61 | return nil, Errorf(tok, "Grammar field is nil")
62 | }
63 | if cs, ok := g.BlockSyntax(tok.Name); ok {
64 | switch {
65 | case tok.Name == "comment":
66 | inComment = true
67 | case tok.Name == "raw":
68 | inRaw = true
69 | rawTag = &ASTRaw{}
70 | *ap = append(*ap, rawTag)
71 | case cs.RequiresParent() && (sd == nil || !cs.CanHaveParent(sd)):
72 | suffix := ""
73 | if sd != nil {
74 | suffix = "; immediate parent is " + sd.TagName()
75 | }
76 | return nil, Errorf(tok, "%s not inside %s%s", tok.Name, strings.Join(cs.ParentTags(), " or "), suffix)
77 | case cs.IsBlockStart():
78 | push := func() {
79 | stack = append(stack, frame{syntax: sd, node: bn, ap: ap})
80 | sd, bn = cs, &ASTBlock{Token: tok, syntax: cs}
81 | *ap = append(*ap, bn)
82 | }
83 | push()
84 | ap = &bn.Body
85 | case cs.IsClause():
86 | n := &ASTBlock{Token: tok, syntax: cs}
87 | bn.Clauses = append(bn.Clauses, n)
88 | ap = &n.Body
89 | case cs.IsBlockEnd():
90 | pop := func() {
91 | f := stack[len(stack)-1]
92 | stack = stack[:len(stack)-1]
93 | sd, bn, ap = f.syntax, f.node, f.ap
94 | }
95 | pop()
96 | default:
97 | panic(fmt.Errorf("block type %q", tok.Name))
98 | }
99 | } else {
100 | *ap = append(*ap, &ASTTag{tok})
101 | }
102 | case tok.Type == TrimLeftTokenType:
103 | *ap = append(*ap, &ASTTrim{TrimDirection: Left})
104 | case tok.Type == TrimRightTokenType:
105 | *ap = append(*ap, &ASTTrim{TrimDirection: Right})
106 | }
107 | }
108 | if bn != nil {
109 | return nil, Errorf(bn, "unterminated %q block", bn.Name)
110 | }
111 | return root, nil
112 | }
113 |
--------------------------------------------------------------------------------
/parser/parser_test.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | type (
12 | grammarFake struct{}
13 | blockSyntaxFake string
14 | )
15 |
16 | func (g grammarFake) BlockSyntax(w string) (BlockSyntax, bool) {
17 | return blockSyntaxFake(w), true
18 | }
19 |
20 | func (g blockSyntaxFake) IsBlock() bool { return true }
21 | func (g blockSyntaxFake) CanHaveParent(p BlockSyntax) bool {
22 | return string(g) == "end"+p.TagName() || (g == "else" && p.TagName() == "if")
23 | }
24 | func (g blockSyntaxFake) IsBlockEnd() bool { return strings.HasPrefix(string(g), "end") }
25 | func (g blockSyntaxFake) IsBlockStart() bool {
26 | return g == "for" || g == "if" || g == "unless"
27 | }
28 | func (g blockSyntaxFake) IsClause() bool { return g == "else" }
29 | func (g blockSyntaxFake) ParentTags() []string { return []string{"unless"} }
30 | func (g blockSyntaxFake) RequiresParent() bool { return g == "else" || g.IsBlockEnd() }
31 | func (g blockSyntaxFake) TagName() string { return string(g) }
32 |
33 | var parseErrorTests = []struct{ in, expected string }{
34 | {"{% if test %}", `unterminated "if" block`},
35 | {"{% if test %}{% endunless %}", "not inside unless"},
36 | // TODO tag syntax could specify statement type to catch these in parser
37 | // {"{{ syntax error }}", "syntax error"},
38 | // {"{% for syntax error %}{% endfor %}", "syntax error"},
39 | }
40 |
41 | var parserTests = []struct{ in string }{
42 | {`{% for item in list %}{% endfor %}`},
43 | {`{% if test %}{% else %}{% endif %}`},
44 | {`{% if test %}{% if test %}{% endif %}{% endif %}`},
45 | {`{% unless test %}{% endunless %}`},
46 | {`{% for item in list %}{% if test %}{% else %}{% endif %}{% endfor %}`},
47 | {`{% if true %}{% raw %}{% endraw %}{% endif %}`},
48 |
49 | {`{% comment %}{% if true %}{% endcomment %}`},
50 | {`{% raw %}{% if true %}{% endraw %}`},
51 | }
52 |
53 | func TestParseErrors(t *testing.T) {
54 | cfg := Config{Grammar: grammarFake{}}
55 | for i, test := range parseErrorTests {
56 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
57 | _, err := cfg.Parse(test.in, SourceLoc{})
58 | require.Errorf(t, err, test.in)
59 | require.Containsf(t, err.Error(), test.expected, test.in)
60 | })
61 | }
62 | }
63 |
64 | func TestParser(t *testing.T) {
65 | cfg := Config{Grammar: grammarFake{}}
66 | for i, test := range parserTests {
67 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
68 | _, err := cfg.Parse(test.in, SourceLoc{})
69 | require.NoError(t, err, test.in)
70 | })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/parser/scanner.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strings"
7 | )
8 |
9 | // Scan breaks a string into a sequence of Tokens.
10 | func Scan(data string, loc SourceLoc, delims []string) (tokens []Token) {
11 | // Apply defaults
12 | if len(delims) != 4 {
13 | delims = []string{"{{", "}}", "{%", "%}"}
14 | }
15 | tokenMatcher := formTokenMatcher(delims)
16 |
17 | // TODO error on unterminated {{ and {%
18 | // TODO probably an error when a tag contains a {{ or {%, at least outside of a string
19 | p, pe := 0, len(data)
20 | for _, m := range tokenMatcher.FindAllStringSubmatchIndex(data, -1) {
21 | ts, te := m[0], m[1]
22 | if p < ts {
23 | tokens = append(tokens, Token{Type: TextTokenType, SourceLoc: loc, Source: data[p:ts]})
24 | loc.LineNo += strings.Count(data[p:ts], "\n")
25 | }
26 | source := data[ts:te]
27 | switch {
28 | case data[ts:ts+len(delims[0])] == delims[0]:
29 | if source[2] == '-' {
30 | tokens = append(tokens, Token{
31 | Type: TrimLeftTokenType,
32 | })
33 | }
34 | tokens = append(tokens, Token{
35 | Type: ObjTokenType,
36 | SourceLoc: loc,
37 | Source: source,
38 | Args: data[m[2]:m[3]],
39 | })
40 | if source[len(source)-3] == '-' {
41 | tokens = append(tokens, Token{
42 | Type: TrimRightTokenType,
43 | })
44 | }
45 | case data[ts:ts+len(delims[2])] == delims[2]:
46 | if source[2] == '-' {
47 | tokens = append(tokens, Token{
48 | Type: TrimLeftTokenType,
49 | })
50 | }
51 | tok := Token{
52 | Type: TagTokenType,
53 | SourceLoc: loc,
54 | Source: source,
55 | Name: data[m[4]:m[5]],
56 | }
57 | if m[6] > 0 {
58 | tok.Args = data[m[6]:m[7]]
59 | }
60 | tokens = append(tokens, tok)
61 | if source[len(source)-3] == '-' {
62 | tokens = append(tokens, Token{
63 | Type: TrimRightTokenType,
64 | })
65 | }
66 | }
67 | loc.LineNo += strings.Count(source, "\n")
68 | p = te
69 | }
70 | if p < pe {
71 | tokens = append(tokens, Token{Type: TextTokenType, SourceLoc: loc, Source: data[p:]})
72 | }
73 | return tokens
74 | }
75 |
76 | func formTokenMatcher(delims []string) *regexp.Regexp {
77 | // On ending a tag we need to exclude anything that appears to be ending a tag that's nested
78 | // inside the tag. We form the exclusion expression here.
79 | // For example, if delims is default the exclusion expression is "[^%]|%[^}]".
80 | // If tagRight is "TAG!RIGHT" then expression is
81 | // [^T]|T[^A]|TA[^G]|TAG[^!]|TAG![^R]|TAG!R[^I]|TAG!RI[^G]|TAG!RIG[^H]|TAG!RIGH[^T]
82 | exclusion := make([]string, 0, len(delims[3]))
83 | for idx, val := range delims[3] {
84 | exclusion = append(exclusion, "[^"+string(val)+"]")
85 | if idx > 0 {
86 | exclusion[idx] = delims[3][0:idx] + exclusion[idx]
87 | }
88 | }
89 |
90 | tokenMatcher := regexp.MustCompile(
91 | fmt.Sprintf(`%s-?\s*(.+?)\s*-?%s|%s-?\s*(\w+)(?:\s+((?:%v)+?))?\s*-?%s`,
92 | // QuoteMeta will escape any of these that are regex commands
93 | regexp.QuoteMeta(delims[0]), regexp.QuoteMeta(delims[1]),
94 | regexp.QuoteMeta(delims[2]), strings.Join(exclusion, "|"), regexp.QuoteMeta(delims[3]),
95 | ),
96 | )
97 |
98 | return tokenMatcher
99 | }
100 |
--------------------------------------------------------------------------------
/parser/scanner_test.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | var scannerCountTests = []struct {
11 | in string
12 | len int
13 | }{
14 | {`{% tag arg %}`, 1},
15 | {`{% tag arg %}{% tag %}`, 2},
16 | {`{% tag arg %}{% tag arg %}{% tag %}`, 3},
17 | {`{% tag %}{% tag %}`, 2},
18 | {`{% tag arg %}{% tag arg %}{% tag %}{% tag %}`, 4},
19 | {`{{ expr }}`, 1},
20 | {`{{ expr arg }}`, 1},
21 | {`{{ expr }}{{ expr }}`, 2},
22 | {`{{ expr arg }}{{ expr arg }}`, 2},
23 | }
24 |
25 | func TestScan(t *testing.T) {
26 | scan := func(src string) []Token { return Scan(src, SourceLoc{}, nil) }
27 | tokens := scan("12")
28 | require.NotNil(t, tokens)
29 | require.Len(t, tokens, 1)
30 | require.Equal(t, TextTokenType, tokens[0].Type)
31 | require.Equal(t, "12", tokens[0].Source)
32 |
33 | tokens = scan("{{obj}}")
34 | require.NotNil(t, tokens)
35 | require.Len(t, tokens, 1)
36 | require.Equal(t, ObjTokenType, tokens[0].Type)
37 | require.Equal(t, "obj", tokens[0].Args)
38 |
39 | tokens = scan("{{ obj }}")
40 | require.NotNil(t, tokens)
41 | require.Len(t, tokens, 1)
42 | require.Equal(t, ObjTokenType, tokens[0].Type)
43 | require.Equal(t, "obj", tokens[0].Args)
44 |
45 | tokens = scan("{%tag args%}")
46 | require.NotNil(t, tokens)
47 | require.Len(t, tokens, 1)
48 | require.Equal(t, TagTokenType, tokens[0].Type)
49 | require.Equal(t, "tag", tokens[0].Name)
50 | require.Equal(t, "args", tokens[0].Args)
51 |
52 | tokens = scan("{% tag args %}")
53 | require.NotNil(t, tokens)
54 | require.Len(t, tokens, 1)
55 | require.Equal(t, TagTokenType, tokens[0].Type)
56 | require.Equal(t, "tag", tokens[0].Name)
57 | require.Equal(t, "args", tokens[0].Args)
58 |
59 | tokens = scan("pre{% tag args %}mid{{ object }}post")
60 | require.Equal(t, `[TextTokenType{"pre"} TagTokenType{Tag:"tag", Args:"args"} TextTokenType{"mid"} ObjTokenType{"object"} TextTokenType{"post"}]`, fmt.Sprint(tokens))
61 |
62 | for i, test := range scannerCountTests {
63 | t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
64 | tokens := scan(test.in)
65 | require.Len(t, tokens, test.len)
66 | })
67 | }
68 | }
69 |
70 | func TestScan_ws(t *testing.T) {
71 | // whitespace control
72 | scan := func(src string) []Token { return Scan(src, SourceLoc{}, nil) }
73 |
74 | wsTests := []struct {
75 | in string
76 | exp []Token
77 | }{
78 | {`{{ expr }}`, []Token{
79 | {
80 | Type: ObjTokenType,
81 | Args: "expr",
82 | Source: "{{ expr }}",
83 | },
84 | }},
85 | {`{{- expr }}`, []Token{
86 | {
87 | Type: TrimLeftTokenType,
88 | },
89 | {
90 | Type: ObjTokenType,
91 | Args: "expr",
92 | Source: "{{- expr }}",
93 | },
94 | }},
95 | {`{{ expr -}}`, []Token{
96 | {
97 | Type: ObjTokenType,
98 | Args: "expr",
99 | Source: "{{ expr -}}",
100 | },
101 | {
102 | Type: TrimRightTokenType,
103 | },
104 | }},
105 | {`{{- expr -}}`, []Token{
106 | {
107 | Type: TrimLeftTokenType,
108 | },
109 | {
110 | Type: ObjTokenType,
111 | Args: "expr",
112 | Source: "{{- expr -}}",
113 | },
114 | {
115 | Type: TrimRightTokenType,
116 | },
117 | }},
118 | {`{% tag arg %}`, []Token{
119 | {
120 | Type: TagTokenType,
121 | Name: "tag",
122 | Args: "arg",
123 | Source: "{% tag arg %}",
124 | },
125 | }},
126 | {`{%- tag arg %}`, []Token{
127 | {
128 | Type: TrimLeftTokenType,
129 | },
130 | {
131 | Type: TagTokenType,
132 | Name: "tag",
133 | Args: "arg",
134 | Source: "{%- tag arg %}",
135 | },
136 | }},
137 | {`{% tag arg -%}`, []Token{
138 | {
139 | Type: TagTokenType,
140 | Name: "tag",
141 | Args: "arg",
142 | Source: "{% tag arg -%}",
143 | },
144 | {
145 | Type: TrimRightTokenType,
146 | },
147 | }},
148 | }
149 | for i, test := range wsTests {
150 | t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
151 | tokens := scan(test.in)
152 | require.Equalf(t, test.exp, tokens, test.in)
153 | })
154 | }
155 | }
156 |
157 | var scannerCountTestsDelims = []struct {
158 | in string
159 | len int
160 | }{
161 | {`TAG*LEFT tag arg TAG!RIGHT`, 1},
162 | {`TAG*LEFT tag arg TAG!RIGHTTAG*LEFT tag TAG!RIGHT`, 2},
163 | {`TAG*LEFT tag arg TAG!RIGHTTAG*LEFT tag arg TAG!RIGHTTAG*LEFT tag TAG!RIGHT`, 3},
164 | {`TAG*LEFT tag TAG!RIGHTTAG*LEFT tag TAG!RIGHT`, 2},
165 | {`TAG*LEFT tag arg TAG!RIGHTTAG*LEFT tag arg TAG!RIGHTTAG*LEFT tag TAG!RIGHTTAG*LEFT tag TAG!RIGHT`, 4},
166 | {`OBJECT@LEFT expr OBJECT#RIGHT`, 1},
167 | {`OBJECT@LEFT expr arg OBJECT#RIGHT`, 1},
168 | {`OBJECT@LEFT expr OBJECT#RIGHTOBJECT@LEFT expr OBJECT#RIGHT`, 2},
169 | {`OBJECT@LEFT expr arg OBJECT#RIGHTOBJECT@LEFT expr arg OBJECT#RIGHT`, 2},
170 | }
171 |
172 | func TestScan_delims(t *testing.T) {
173 | scan := func(src string) []Token {
174 | return Scan(src, SourceLoc{}, []string{"OBJECT@LEFT", "OBJECT#RIGHT", "TAG*LEFT", "TAG!RIGHT"})
175 | }
176 | tokens := scan("12")
177 | require.NotNil(t, tokens)
178 | require.Len(t, tokens, 1)
179 | require.Equal(t, TextTokenType, tokens[0].Type)
180 | require.Equal(t, "12", tokens[0].Source)
181 |
182 | tokens = scan("OBJECT@LEFTobjOBJECT#RIGHT")
183 | require.NotNil(t, tokens)
184 | require.Len(t, tokens, 1)
185 | require.Equal(t, ObjTokenType, tokens[0].Type)
186 | require.Equal(t, "obj", tokens[0].Args)
187 |
188 | tokens = scan("OBJECT@LEFT obj OBJECT#RIGHT")
189 | require.NotNil(t, tokens)
190 | require.Len(t, tokens, 1)
191 | require.Equal(t, ObjTokenType, tokens[0].Type)
192 | require.Equal(t, "obj", tokens[0].Args)
193 |
194 | tokens = scan("TAG*LEFTtag argsTAG!RIGHT")
195 | require.NotNil(t, tokens)
196 | require.Len(t, tokens, 1)
197 | require.Equal(t, TagTokenType, tokens[0].Type)
198 | require.Equal(t, "tag", tokens[0].Name)
199 | require.Equal(t, "args", tokens[0].Args)
200 |
201 | tokens = scan("TAG*LEFT tag args TAG!RIGHT")
202 | require.NotNil(t, tokens)
203 | require.Len(t, tokens, 1)
204 | require.Equal(t, TagTokenType, tokens[0].Type)
205 | require.Equal(t, "tag", tokens[0].Name)
206 | require.Equal(t, "args", tokens[0].Args)
207 |
208 | tokens = scan("preTAG*LEFT tag args TAG!RIGHTmidOBJECT@LEFT object OBJECT#RIGHTpost")
209 | require.Equal(t, `[TextTokenType{"pre"} TagTokenType{Tag:"tag", Args:"args"} TextTokenType{"mid"} ObjTokenType{"object"} TextTokenType{"post"}]`, fmt.Sprint(tokens))
210 |
211 | for i, test := range scannerCountTestsDelims {
212 | t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
213 | tokens := scan(test.in)
214 | require.Len(t, tokens, test.len)
215 | })
216 | }
217 | }
218 |
--------------------------------------------------------------------------------
/parser/token.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import "fmt"
4 |
5 | // A Token is an object {{ a.b }}, a tag {% if a>b %}, or a text chunk (anything outside of {{}} and {%%}.)
6 | type Token struct {
7 | Type TokenType
8 | SourceLoc SourceLoc
9 | Name string // Name is the tag name of a tag Chunk. E.g. the tag name of "{% if 1 %}" is "if".
10 | Args string // Parameters is the tag arguments of a tag Chunk. E.g. the tag arguments of "{% if 1 %}" is "1".
11 | Source string // Source is the entirety of the token, including the "{{", "{%", etc. markers.
12 | }
13 |
14 | // TokenType is the type of a Chunk
15 | type TokenType int
16 |
17 | ////go:generate stringer -type=TokenType
18 |
19 | const (
20 | // TextTokenType is the type of a text Chunk
21 | TextTokenType TokenType = iota
22 | // TagTokenType is the type of a tag Chunk "{%…%}"
23 | TagTokenType
24 | // ObjTokenType is the type of an object Chunk "{{…}}"
25 | ObjTokenType
26 | // TrimLeftTokenType is the type of a left trim tag "-"
27 | TrimLeftTokenType
28 | // TrimRightTokenType is the type of a right trim tag "-"
29 | TrimRightTokenType
30 | )
31 |
32 | // SourceLoc contains a Token's source location. Pathname is in the local file
33 | // system; for example "dir/file.html" on Linux and macOS; "dir\file.html" on
34 | // Windows.
35 | type SourceLoc struct {
36 | Pathname string
37 | LineNo int
38 | }
39 |
40 | // SourceLocation returns the token's source location, for use in error reporting.
41 | func (c Token) SourceLocation() SourceLoc { return c.SourceLoc }
42 |
43 | // SourceText returns the token's source text, for use in error reporting.
44 | func (c Token) SourceText() string { return c.Source }
45 |
46 | // IsZero returns a boolean indicating whether the location doesn't have a set path.
47 | func (s SourceLoc) IsZero() bool {
48 | return s.Pathname == "" && s.LineNo == 0
49 | }
50 |
51 | func (c Token) String() string {
52 | switch c.Type {
53 | case TextTokenType:
54 | return fmt.Sprintf("%v{%#v}", c.Type, c.Source)
55 | case TagTokenType:
56 | return fmt.Sprintf("%v{Tag:%#v, Args:%#v}", c.Type, c.Name, c.Args)
57 | case ObjTokenType:
58 | return fmt.Sprintf("%v{%#v}", c.Type, c.Args)
59 | case TrimLeftTokenType, TrimRightTokenType:
60 | return "-"
61 | default:
62 | return fmt.Sprintf("%v{%#v}", c.Type, c.Source)
63 | }
64 | }
65 |
66 | func (s SourceLoc) String() string {
67 | if s.Pathname != "" {
68 | return fmt.Sprintf("%s:%d", s.Pathname, s.LineNo)
69 | }
70 | return fmt.Sprintf("line %d", s.LineNo)
71 | }
72 |
--------------------------------------------------------------------------------
/parser/tokentype_string.go:
--------------------------------------------------------------------------------
1 | // Code generated by "stringer -type=TokenType"; DO NOT EDIT.
2 |
3 | package parser
4 |
5 | import "fmt"
6 |
7 | const _TokenType_name = "TextTokenTypeTagTokenTypeObjTokenType"
8 |
9 | var _TokenType_index = [...]uint8{0, 13, 25, 37}
10 |
11 | func (i TokenType) String() string {
12 | if i < 0 || i >= TokenType(len(_TokenType_index)-1) {
13 | return fmt.Sprintf("TokenType(%d)", i)
14 | }
15 | return _TokenType_name[_TokenType_index[i]:_TokenType_index[i+1]]
16 | }
17 |
--------------------------------------------------------------------------------
/render/blocks.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "io"
5 | "sort"
6 |
7 | "github.com/osteele/liquid/parser"
8 | )
9 |
10 | // BlockCompiler builds a renderer for the tag instance.
11 | type BlockCompiler func(BlockNode) (func(io.Writer, Context) error, error)
12 |
13 | // blockSyntax tells the parser how to parse a control tag.
14 | type blockSyntax struct {
15 | name string
16 | isClauseTag, isEndTag bool
17 | startName string // for an end tag, the name of the correspondign start tag
18 | parents map[string]bool // if non-nil, must be an immediate clause of one of these
19 | parser BlockCompiler
20 | }
21 |
22 | func (s *blockSyntax) CanHaveParent(parent parser.BlockSyntax) bool {
23 | switch {
24 | case s.isClauseTag:
25 | return parent != nil && s.parents[parent.TagName()]
26 | case s.isEndTag:
27 | return parent != nil && parent.TagName() == s.startName
28 | default:
29 | return true
30 | }
31 | }
32 |
33 | func (s *blockSyntax) IsBlock() bool { return true }
34 | func (s *blockSyntax) IsBlockEnd() bool { return s.isEndTag }
35 | func (s *blockSyntax) IsBlockStart() bool { return !s.isClauseTag && !s.isEndTag }
36 | func (s *blockSyntax) IsClause() bool { return s.isClauseTag }
37 | func (s *blockSyntax) RequiresParent() bool { return s.isClauseTag || s.isEndTag }
38 |
39 | func (s *blockSyntax) ParentTags() (parents []string) {
40 | for k := range s.parents {
41 | parents = append(parents, k)
42 | }
43 | sort.Strings(parents)
44 | return
45 | }
46 | func (s *blockSyntax) TagName() string { return s.name }
47 |
48 | func (g grammar) addBlockDef(ct *blockSyntax) {
49 | if g.blockDefs[ct.name] != nil {
50 | panic("duplicate definition of " + ct.name)
51 | }
52 | g.blockDefs[ct.name] = ct
53 | }
54 |
55 | func (g grammar) findBlockDef(name string) (*blockSyntax, bool) {
56 | ct, found := g.blockDefs[name]
57 | return ct, found
58 | }
59 |
60 | // BlockSyntax is part of the Grammar interface.
61 | func (g grammar) BlockSyntax(name string) (parser.BlockSyntax, bool) {
62 | ct, found := g.blockDefs[name]
63 | return ct, found
64 | }
65 |
66 | type blockDefBuilder struct {
67 | grammar
68 | tag *blockSyntax
69 | }
70 |
71 | // AddBlock defines a control tag and its matching end tag.
72 | func (g grammar) AddBlock(name string) blockDefBuilder { //nolint: golint
73 | ct := &blockSyntax{name: name}
74 | g.addBlockDef(ct)
75 | g.addBlockDef(&blockSyntax{name: "end" + name, isEndTag: true, startName: name})
76 | return blockDefBuilder{g, ct}
77 | }
78 |
79 | // Clause tells the parser that the named tag can appear immediately between this tag and its end tag,
80 | // so long as it is not nested within any other control tag.
81 | func (b blockDefBuilder) Clause(name string) blockDefBuilder {
82 | if b.blockDefs[name] == nil {
83 | b.addBlockDef(&blockSyntax{name: name, isClauseTag: true})
84 | }
85 | c := b.blockDefs[name]
86 | if !c.isClauseTag {
87 | panic(name + " has already been defined as a non-clause")
88 | }
89 | if len(c.parents) == 0 {
90 | c.parents = make(map[string]bool)
91 | }
92 | c.parents[b.tag.name] = true
93 | return b
94 | }
95 |
96 | // SameSyntaxAs tells the parser that this tag has the same syntax as the named tag.
97 | // func (b blockDefBuilder) SameSyntaxAs(name string) blockDefBuilder {
98 | // rt := b.blockDefs[name]
99 | // if rt == nil {
100 | // panic(fmt.Errorf("undefined: %s", name))
101 | // }
102 | // b.tag.syntaxModel = rt
103 | // return b
104 | // }
105 |
106 | // Compiler sets the parser for a control tag definition.
107 | func (b blockDefBuilder) Compiler(fn BlockCompiler) {
108 | b.tag.parser = fn
109 | }
110 |
111 | // Renderer sets the render action for a control tag definition.
112 | func (b blockDefBuilder) Renderer(fn func(io.Writer, Context) error) {
113 | b.tag.parser = func(node BlockNode) (func(io.Writer, Context) error, error) {
114 | // TODO syntax error if there are arguments?
115 | return fn, nil
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/render/blocks_test.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestBlockSyntax(t *testing.T) {
10 | cfg := NewConfig()
11 | cfg.AddBlock("if").Clause("else")
12 | cfg.AddBlock("case").Clause("else")
13 | cfg.AddBlock("unless")
14 |
15 | require.Panics(t, func() { cfg.AddBlock("if") })
16 |
17 | g := cfg.grammar
18 | ifBlock, _ := g.findBlockDef("if")
19 | elseBlock, _ := g.findBlockDef("else")
20 | unlessBlock, _ := g.findBlockDef("unless")
21 | require.True(t, elseBlock.CanHaveParent(ifBlock))
22 | require.False(t, elseBlock.CanHaveParent(unlessBlock))
23 | require.Equal(t, []string{"case", "if"}, elseBlock.ParentTags())
24 | }
25 |
--------------------------------------------------------------------------------
/render/compiler.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/osteele/liquid/parser"
7 | )
8 |
9 | // Compile parses a source template. It returns an AST root, that can be evaluated.
10 | func (c *Config) Compile(source string, loc parser.SourceLoc) (Node, parser.Error) {
11 | root, err := c.Parse(source, loc)
12 | if err != nil {
13 | return nil, err
14 | }
15 | return c.compileNode(root)
16 | }
17 |
18 | // nolint: gocyclo
19 | func (c *Config) compileNode(n parser.ASTNode) (Node, parser.Error) {
20 | switch n := n.(type) {
21 | case *parser.ASTBlock:
22 | body, err := c.compileNodes(n.Body)
23 | if err != nil {
24 | return nil, err
25 | }
26 | branches, err := c.compileBlocks(n.Clauses)
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | cd, ok := c.findBlockDef(n.Name)
32 | if !ok {
33 | return nil, parser.Errorf(n, "undefined tag %q", n.Name)
34 | }
35 | node := BlockNode{
36 | Token: n.Token,
37 | Body: body,
38 | Clauses: branches,
39 | }
40 | if cd.parser != nil {
41 | r, err := cd.parser(node)
42 | if err != nil {
43 | return nil, parser.WrapError(err, n)
44 | }
45 | node.renderer = r
46 | }
47 | return &node, nil
48 | case *parser.ASTRaw:
49 | return &RawNode{n.Slices, sourcelessNode{}}, nil
50 | case *parser.ASTSeq:
51 | children, err := c.compileNodes(n.Children)
52 | if err != nil {
53 | return nil, err
54 | }
55 | return &SeqNode{children, sourcelessNode{}}, nil
56 | case *parser.ASTTag:
57 | if td, ok := c.FindTagDefinition(n.Name); ok {
58 | f, err := td(n.Args)
59 | if err != nil {
60 | return nil, parser.Errorf(n, "%s", err)
61 | }
62 | return &TagNode{n.Token, f}, nil
63 | }
64 | return nil, parser.Errorf(n, "undefined tag %q", n.Name)
65 | case *parser.ASTText:
66 | return &TextNode{n.Token}, nil
67 | case *parser.ASTObject:
68 | return &ObjectNode{n.Token, n.Expr}, nil
69 | case *parser.ASTTrim:
70 | return &TrimNode{TrimDirection: n.TrimDirection}, nil
71 | default:
72 | panic(fmt.Errorf("un-compilable node type %T", n))
73 | }
74 | }
75 |
76 | func (c *Config) compileBlocks(blocks []*parser.ASTBlock) ([]*BlockNode, parser.Error) {
77 | out := make([]*BlockNode, 0, len(blocks))
78 | for _, child := range blocks {
79 | compiled, err := c.compileNode(child)
80 | if err != nil {
81 | return nil, err
82 | }
83 | out = append(out, compiled.(*BlockNode))
84 | }
85 | return out, nil
86 | }
87 |
88 | func (c *Config) compileNodes(nodes []parser.ASTNode) ([]Node, parser.Error) {
89 | out := make([]Node, 0, len(nodes))
90 | for _, child := range nodes {
91 | compiled, err := c.compileNode(child)
92 | if err != nil {
93 | return nil, err
94 | }
95 | out = append(out, compiled)
96 | }
97 | return out, nil
98 | }
99 |
--------------------------------------------------------------------------------
/render/compiler_test.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 | "testing"
8 |
9 | "github.com/osteele/liquid/parser"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func addCompilerTestTags(s Config) {
14 | s.AddBlock("block").Compiler(func(c BlockNode) (func(io.Writer, Context) error, error) {
15 | return func(io.Writer, Context) error {
16 | return nil
17 | }, nil
18 | })
19 | s.AddBlock("error_block").Compiler(func(c BlockNode) (func(io.Writer, Context) error, error) {
20 | return nil, errors.New("block compiler error")
21 | })
22 | }
23 |
24 | var compilerErrorTests = []struct{ in, expected string }{
25 | {`{% undefined_tag %}`, "undefined tag"},
26 | {`{% error_block %}{% enderror_block %}`, "block compiler error"},
27 | {`{% block %}{% undefined_tag %}{% endblock %}`, "undefined tag"},
28 | // {`{% tag %}`, "tag compiler error"},
29 | // {`{%for syntax error%}{%endfor%}`, "syntax error"},
30 | }
31 |
32 | func TestCompile_errors(t *testing.T) {
33 | settings := NewConfig()
34 | addCompilerTestTags(settings)
35 | for i, test := range compilerErrorTests {
36 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
37 | _, err := settings.Compile(test.in, parser.SourceLoc{})
38 | require.Errorf(t, err, test.in)
39 | require.Containsf(t, err.Error(), test.expected, test.in)
40 | })
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/render/config.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "github.com/osteele/liquid/parser"
5 | )
6 |
7 | // Config holds configuration information for parsing and rendering.
8 | type Config struct {
9 | parser.Config
10 | grammar
11 | Cache map[string][]byte
12 | StrictVariables bool
13 | TemplateStore TemplateStore
14 | }
15 |
16 | type grammar struct {
17 | tags map[string]TagCompiler
18 | blockDefs map[string]*blockSyntax
19 | }
20 |
21 | // NewConfig creates a new Settings.
22 | // TemplateStore is initialized to a FileTemplateStore for backwards compatibility
23 | func NewConfig() Config {
24 | g := grammar{
25 | tags: map[string]TagCompiler{},
26 | blockDefs: map[string]*blockSyntax{},
27 | }
28 | return Config{
29 | Config: parser.NewConfig(g),
30 | grammar: g,
31 | Cache: map[string][]byte{},
32 | TemplateStore: &FileTemplateStore{},
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/render/context.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 | "strings"
8 |
9 | "github.com/osteele/liquid/parser"
10 |
11 | "github.com/osteele/liquid/expressions"
12 | )
13 |
14 | // Context provides the rendering context for a tag renderer.
15 | type Context interface {
16 | // Bindings returns the current lexical environment.
17 | Bindings() map[string]any
18 | // Get retrieves the value of a variable from the current lexical environment.
19 | Get(name string) any
20 | // Errorf creates a SourceError, that includes the source location.
21 | // Use this to distinguish errors in the template from implementation errors
22 | // in the template engine.
23 | Errorf(format string, a ...any) Error
24 | // Evaluate evaluates a compiled expression within the current lexical context.
25 | Evaluate(expressions.Expression) (any, error)
26 | // EvaluateString compiles and evaluates a string expression such as “x”, “x < 10", or “a.b | split | first | default: 10”, within the current lexical context.
27 | EvaluateString(string) (any, error)
28 | // ExpandTagArg renders the current tag argument string as a Liquid template.
29 | // It enables the implementation of tags such as Jekyll's "{% include {{ page.my_variable }} %}" andjekyll-avatar's "{% avatar {{page.author}} %}".
30 | ExpandTagArg() (string, error)
31 | // InnerString is the rendered content of the current block.
32 | // It's used in the implementation of the Liquid "capture" tag and the Jekyll "highlght" tag.
33 | InnerString() (string, error)
34 | // RenderBlock is used in the implementation of the built-in control flow tags.
35 | // It's not guaranteed stable.
36 | RenderBlock(io.Writer, *BlockNode) error
37 | // RenderChildren is used in the implementation of the built-in control flow tags.
38 | // It's not guaranteed stable.
39 | RenderChildren(io.Writer) Error
40 | // RenderFile parses and renders a template. It's used in the implementation of the {% include %} tag.
41 | // RenderFile does not cache the compiled template.
42 | RenderFile(string, map[string]any) (string, error)
43 | // Set updates the value of a variable in the current lexical environment.
44 | // It's used in the implementation of the {% assign %} and {% capture %} tags.
45 | Set(name string, value any)
46 | // SourceFile retrieves the value set by template.SetSourcePath.
47 | // It's used in the implementation of the {% include %} tag.
48 | SourceFile() string
49 | // TagArgs returns the text of the current tag, not including its name.
50 | // For example, the arguments to {% my_tag a b c %} would be “a b c”.
51 | TagArgs() string
52 | // TagName returns the name of the current tag; for example "my_tag" for {% my_tag a b c %}.
53 | TagName() string
54 | // WrapError creates a new error that records the source location from the current context.
55 | WrapError(err error) Error
56 | }
57 |
58 | type TemplateStore interface {
59 | ReadTemplate(templatename string) ([]byte, error)
60 | }
61 |
62 | type rendererContext struct {
63 | ctx nodeContext
64 | node *TagNode
65 | cn *BlockNode
66 | }
67 |
68 | type invalidLocation struct{}
69 |
70 | func (i invalidLocation) SourceLocation() parser.SourceLoc {
71 | return parser.SourceLoc{}
72 | }
73 |
74 | func (i invalidLocation) SourceText() string {
75 | return ""
76 | }
77 |
78 | var invalidLoc parser.Locatable = invalidLocation{}
79 |
80 | func (c rendererContext) Errorf(format string, a ...any) Error {
81 | switch {
82 | case c.node != nil:
83 | return renderErrorf(c.node, format, a...)
84 | case c.cn != nil:
85 | return renderErrorf(c.cn, format, a...)
86 | default:
87 | return renderErrorf(invalidLoc, format, a...)
88 | }
89 | }
90 |
91 | func (c rendererContext) WrapError(err error) Error {
92 | switch {
93 | case c.node != nil:
94 | return wrapRenderError(err, c.node)
95 | case c.cn != nil:
96 | return wrapRenderError(err, c.cn)
97 | default:
98 | return wrapRenderError(err, invalidLoc)
99 | }
100 | }
101 |
102 | func (c rendererContext) Evaluate(expr expressions.Expression) (out any, err error) {
103 | return c.ctx.Evaluate(expr)
104 | }
105 |
106 | // EvaluateString evaluates an expression within the template context.
107 | func (c rendererContext) EvaluateString(source string) (out any, err error) {
108 | return expressions.EvaluateString(source, expressions.NewContext(c.ctx.bindings, c.ctx.config.Config.Config))
109 | }
110 |
111 | // Bindings returns the current lexical environment.
112 | func (c rendererContext) Bindings() map[string]any {
113 | return c.ctx.bindings
114 | }
115 |
116 | // Get gets a variable value within an evaluation context.
117 | func (c rendererContext) Get(name string) any {
118 | return c.ctx.bindings[name]
119 | }
120 |
121 | func (c rendererContext) ExpandTagArg() (string, error) {
122 | args := c.TagArgs()
123 | if strings.Contains(args, "{{") {
124 | root, err := c.ctx.config.Compile(args, c.node.SourceLoc)
125 | if err != nil {
126 | return "", err
127 | }
128 | buf := new(bytes.Buffer)
129 | err = Render(root, buf, c.ctx.bindings, c.ctx.config)
130 | if err != nil {
131 | return "", err
132 | }
133 | return buf.String(), nil
134 | }
135 | return args, nil
136 | }
137 |
138 | // RenderBlock renders a node.
139 | func (c rendererContext) RenderBlock(w io.Writer, b *BlockNode) error {
140 | return c.ctx.RenderSequence(w, b.Body)
141 | }
142 |
143 | // RenderChildren renders the current node's children.
144 | func (c rendererContext) RenderChildren(w io.Writer) Error {
145 | if c.cn == nil {
146 | return nil
147 | }
148 | return c.ctx.RenderSequence(w, c.cn.Body)
149 | }
150 |
151 | func (c rendererContext) RenderFile(filename string, b map[string]any) (string, error) {
152 | source, err := c.ctx.config.TemplateStore.ReadTemplate(filename)
153 | if err != nil && os.IsNotExist(err) {
154 | // Is it cached?
155 | if cval, ok := c.ctx.config.Cache[filename]; ok {
156 | source = cval
157 | } else {
158 | return "", err
159 | }
160 | } else if err != nil {
161 | return "", err
162 | }
163 | root, err := c.ctx.config.Compile(string(source), c.node.SourceLoc)
164 | if err != nil {
165 | return "", err
166 | }
167 | bindings := map[string]any{}
168 | for k, v := range c.ctx.bindings {
169 | bindings[k] = v
170 | }
171 | for k, v := range b {
172 | bindings[k] = v
173 | }
174 | buf := new(bytes.Buffer)
175 | if err := Render(root, buf, bindings, c.ctx.config); err != nil {
176 | return "", err
177 | }
178 | return buf.String(), nil
179 | }
180 |
181 | // InnerString renders the children to a string.
182 | func (c rendererContext) InnerString() (string, error) {
183 | buf := new(bytes.Buffer)
184 | if err := c.RenderChildren(buf); err != nil {
185 | return "", err
186 | }
187 | return buf.String(), nil
188 | }
189 |
190 | // Set sets a variable value from an evaluation context.
191 | func (c rendererContext) Set(name string, value any) {
192 | c.ctx.bindings[name] = value
193 | }
194 |
195 | func (c rendererContext) SourceFile() string {
196 | switch {
197 | case c.node != nil:
198 | return c.node.SourceLoc.Pathname
199 | case c.cn != nil:
200 | return c.cn.SourceLoc.Pathname
201 | default:
202 | return ""
203 | }
204 | }
205 |
206 | func (c rendererContext) TagArgs() string {
207 | switch {
208 | case c.node != nil:
209 | return c.node.Args
210 | case c.cn != nil:
211 | return c.cn.Args
212 | default:
213 | return ""
214 | }
215 | }
216 |
217 | func (c rendererContext) TagName() string {
218 | switch {
219 | case c.node != nil:
220 | return c.node.Name
221 | case c.cn != nil:
222 | return c.cn.Name
223 | default:
224 | return ""
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/render/context_test.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "os"
9 | "testing"
10 |
11 | "github.com/osteele/liquid/parser"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func addContextTestTags(s Config) {
16 | s.AddTag("test_evaluate_string", func(string) (func(io.Writer, Context) error, error) {
17 | return func(w io.Writer, c Context) error {
18 | v, err := c.EvaluateString(c.TagArgs())
19 | if err != nil {
20 | return err
21 | }
22 | _, err = fmt.Fprint(w, v)
23 | return err
24 | }, nil
25 | })
26 | s.AddBlock("parse").Compiler(func(c BlockNode) (func(io.Writer, Context) error, error) {
27 | a := c.Args
28 | return func(w io.Writer, c Context) error {
29 | _, err := io.WriteString(w, a)
30 | return err
31 | }, nil
32 | })
33 | s.AddTag("test_tag_name", func(string) (func(io.Writer, Context) error, error) {
34 | return func(w io.Writer, c Context) error {
35 | _, err := io.WriteString(w, c.TagName())
36 | return err
37 | }, nil
38 | })
39 | s.AddTag("test_expand_tag_arg", func(string) (func(w io.Writer, c Context) error, error) {
40 | return func(w io.Writer, c Context) error {
41 | s, err := c.ExpandTagArg()
42 | if err != nil {
43 | return err
44 | }
45 | _, err = io.WriteString(w, s)
46 | return err
47 | }, nil
48 | })
49 | s.AddTag("test_render_file", func(filename string) (func(w io.Writer, c Context) error, error) {
50 | return func(w io.Writer, c Context) error {
51 | s, err := c.RenderFile(filename, map[string]any{"shadowed": 2})
52 | if err != nil {
53 | return err
54 | }
55 | _, err = io.WriteString(w, s)
56 | return err
57 | }, nil
58 | })
59 | s.AddBlock("test_block_sourcefile").Compiler(func(c BlockNode) (func(w io.Writer, c Context) error, error) {
60 | return func(w io.Writer, c Context) error {
61 | _, err := io.WriteString(w, c.SourceFile())
62 | return err
63 | }, nil
64 | })
65 | s.AddBlock("test_block_wraperror").Compiler(func(c BlockNode) (func(w io.Writer, c Context) error, error) {
66 | return func(w io.Writer, c Context) error {
67 | return c.WrapError(errors.New("giftwrapped"))
68 | }, nil
69 | })
70 | s.AddBlock("test_block_errorf").Compiler(func(c BlockNode) (func(w io.Writer, c Context) error, error) {
71 | return func(w io.Writer, c Context) error {
72 | return c.Errorf("giftwrapped")
73 | }, nil
74 | })
75 | }
76 |
77 | var contextTests = []struct{ in, out string }{
78 | {`{% parse args %}{% endparse %}`, "args"},
79 | {`{% test_evaluate_string x %}`, "123"},
80 | {`{% test_expand_tag_arg x %}`, "x"},
81 | {`{% test_expand_tag_arg {{x}} %}`, "123"},
82 | {`{% test_tag_name %}`, "test_tag_name"},
83 | {
84 | `{% test_render_file testdata/render_file.txt %}; unshadowed={{ shadowed }}`,
85 | "rendered shadowed=2; unshadowed=1",
86 | },
87 | {`{% test_block_sourcefile %}x{% endtest_block_sourcefile %}`, ``},
88 | }
89 |
90 | var contextErrorTests = []struct{ in, expect string }{
91 | {`{% test_evaluate_string syntax error %}`, "syntax error"},
92 | {`{% test_expand_tag_arg {{ syntax error }} %}`, "syntax error"},
93 | {`{% test_expand_tag_arg {{ x | undefined_filter }} %}`, "undefined filter"},
94 | {`{% test_render_file testdata/render_file_syntax_error.txt %}`, "syntax error"},
95 | {`{% test_render_file testdata/render_file_runtime_error.txt %}`, "undefined tag"},
96 | {`{% test_block_wraperror %}{% endtest_block_wraperror %}`, "giftwrapped"},
97 | {`{% test_block_errorf %}{% endtest_block_errorf %}`, "giftwrapped"},
98 | }
99 |
100 | var contextTestBindings = map[string]any{
101 | "x": 123,
102 | "shadowed": 1,
103 | }
104 |
105 | func TestContext(t *testing.T) {
106 | cfg := NewConfig()
107 | addContextTestTags(cfg)
108 | for i, test := range contextTests {
109 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
110 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
111 | require.NoErrorf(t, err, test.in)
112 | buf := new(bytes.Buffer)
113 | err = Render(root, buf, contextTestBindings, cfg)
114 | require.NoErrorf(t, err, test.in)
115 | require.Equalf(t, test.out, buf.String(), test.in)
116 | })
117 | }
118 | }
119 |
120 | func TestContext_errors(t *testing.T) {
121 | cfg := NewConfig()
122 | addContextTestTags(cfg)
123 | for i, test := range contextErrorTests {
124 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
125 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
126 | require.NoErrorf(t, err, test.in)
127 | err = Render(root, io.Discard, contextTestBindings, cfg)
128 | require.Errorf(t, err, test.in)
129 | require.Containsf(t, err.Error(), test.expect, test.in)
130 | })
131 | }
132 | }
133 |
134 | func TestContext_file_not_found_error(t *testing.T) {
135 | // Test the cause instead of looking for a string, since the error message is
136 | // different between Darwin and Linux ("no such file") and Windows ("The
137 | // system cannot find the file specified"), at least.
138 | //
139 | // Also see TestIncludeTag_file_not_found_error.
140 | cfg := NewConfig()
141 | addContextTestTags(cfg)
142 | root, err := cfg.Compile(`{% test_render_file testdata/missing_file %}`, parser.SourceLoc{})
143 | require.NoError(t, err)
144 | err = Render(root, io.Discard, contextTestBindings, cfg)
145 | require.Error(t, err)
146 | require.True(t, os.IsNotExist(err.Cause()))
147 | }
148 |
--------------------------------------------------------------------------------
/render/error.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "github.com/osteele/liquid/parser"
5 | )
6 |
7 | // An Error is an error during template rendering.
8 | type Error interface {
9 | Path() string
10 | LineNumber() int
11 | Cause() error
12 | Error() string
13 | }
14 |
15 | func renderErrorf(loc parser.Locatable, format string, a ...any) Error {
16 | return parser.Errorf(loc, format, a...)
17 | }
18 |
19 | func wrapRenderError(err error, loc parser.Locatable) Error {
20 | return parser.WrapError(err, loc)
21 | }
22 |
--------------------------------------------------------------------------------
/render/file_template_store.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "os"
5 | )
6 |
7 | type FileTemplateStore struct{}
8 |
9 | func (tl *FileTemplateStore) ReadTemplate(filename string) ([]byte, error) {
10 | source, err := os.ReadFile(filename)
11 | return source, err
12 | }
13 |
--------------------------------------------------------------------------------
/render/node_context.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "github.com/osteele/liquid/expressions"
5 | )
6 |
7 | // nodeContext provides the evaluation context for rendering the AST.
8 | //
9 | // This type has a clumsy name so that render.Context, in the public API, can
10 | // have a clean name that doesn't stutter.
11 | type nodeContext struct {
12 | bindings map[string]any
13 | config Config
14 | }
15 |
16 | // newNodeContext creates a new evaluation context.
17 | func newNodeContext(scope map[string]any, c Config) nodeContext {
18 | // The assign tag modifies the scope, so make a copy first.
19 | // TODO this isn't really the right place for this.
20 | vars := map[string]any{}
21 | for k, v := range scope {
22 | vars[k] = v
23 | }
24 | return nodeContext{vars, c}
25 | }
26 |
27 | // Evaluate evaluates an expression within the template context.
28 | func (c nodeContext) Evaluate(expr expressions.Expression) (out any, err error) {
29 | return expr.Evaluate(expressions.NewContext(c.bindings, c.config.Config.Config))
30 | }
31 |
--------------------------------------------------------------------------------
/render/nodes.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "io"
5 |
6 | "github.com/osteele/liquid/expressions"
7 | "github.com/osteele/liquid/parser"
8 | )
9 |
10 | // Node is a node of the render tree.
11 | type Node interface {
12 | SourceLocation() parser.SourceLoc // for error reporting
13 | SourceText() string // for error reporting
14 | render(*trimWriter, nodeContext) Error
15 | }
16 |
17 | // BlockNode represents a {% tag %}…{% endtag %}.
18 | type BlockNode struct {
19 | parser.Token
20 | renderer func(io.Writer, Context) error
21 | Body []Node
22 | Clauses []*BlockNode
23 | }
24 |
25 | // RawNode holds the text between the start and end of a raw tag.
26 | type RawNode struct {
27 | slices []string
28 | sourcelessNode
29 | }
30 |
31 | // TagNode renders itself via a render function that is created during parsing.
32 | type TagNode struct {
33 | parser.Token
34 | renderer func(io.Writer, Context) error
35 | }
36 |
37 | // TextNode is a text chunk, that is rendered verbatim.
38 | type TextNode struct {
39 | parser.Token
40 | }
41 |
42 | // ObjectNode is an {{ object }} object.
43 | type ObjectNode struct {
44 | parser.Token
45 | expr expressions.Expression
46 | }
47 |
48 | // SeqNode is a sequence of nodes.
49 | type SeqNode struct {
50 | Children []Node
51 | sourcelessNode
52 | }
53 |
54 | // TrimNode is a trim object.
55 | type TrimNode struct {
56 | sourcelessNode
57 | parser.TrimDirection
58 | }
59 |
60 | // FIXME requiring this is a bad design
61 | type sourcelessNode struct{}
62 |
63 | func (n *sourcelessNode) SourceLocation() parser.SourceLoc {
64 | panic("unexpected call on sourceless node")
65 | }
66 |
67 | func (n *sourcelessNode) SourceText() string {
68 | panic("unexpected call on sourceless node")
69 | }
70 |
--------------------------------------------------------------------------------
/render/render.go:
--------------------------------------------------------------------------------
1 | // Package render is an internal package that renders a compiled template parse tree.
2 | package render
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "io"
8 | "reflect"
9 | "time"
10 |
11 | "github.com/osteele/liquid/parser"
12 |
13 | "github.com/osteele/liquid/values"
14 | )
15 |
16 | // Render renders the render tree.
17 | func Render(node Node, w io.Writer, vars map[string]any, c Config) Error {
18 | tw := trimWriter{w: w}
19 | if err := node.render(&tw, newNodeContext(vars, c)); err != nil {
20 | return err
21 | }
22 | if _, err := tw.Flush(); err != nil {
23 | panic(err)
24 | }
25 | return nil
26 | }
27 |
28 | // RenderSequence renders a sequence of nodes.
29 | func (c nodeContext) RenderSequence(w io.Writer, seq []Node) Error {
30 | tw, ok := w.(*trimWriter)
31 | if !ok {
32 | tw = &trimWriter{w: w}
33 | }
34 | for _, n := range seq {
35 | if err := n.render(tw, c); err != nil {
36 | return err
37 | }
38 | }
39 | if _, err := tw.Flush(); err != nil {
40 | panic(err)
41 | }
42 | return nil
43 | }
44 |
45 | func (n *BlockNode) render(w *trimWriter, ctx nodeContext) Error {
46 | cd, ok := ctx.config.findBlockDef(n.Name)
47 | if !ok || cd.parser == nil {
48 | // this should have been detected during compilation; it's an implementation error if it happens here
49 | panic(fmt.Errorf("undefined tag %q", n.Name))
50 | }
51 | renderer := n.renderer
52 | if renderer == nil {
53 | panic(fmt.Errorf("unset renderer for %v", n))
54 | }
55 | err := renderer(w, rendererContext{ctx, nil, n})
56 | return wrapRenderError(err, n)
57 | }
58 |
59 | func (n *RawNode) render(w *trimWriter, ctx nodeContext) Error {
60 | for _, s := range n.slices {
61 | _, err := io.WriteString(w, s)
62 | if err != nil {
63 | return wrapRenderError(err, n)
64 | }
65 | }
66 | return nil
67 | }
68 |
69 | func (n *ObjectNode) render(w *trimWriter, ctx nodeContext) Error {
70 | value, err := ctx.Evaluate(n.expr)
71 | if err != nil {
72 | return wrapRenderError(err, n)
73 | }
74 | if value == nil && ctx.config.StrictVariables {
75 | return wrapRenderError(errors.New("undefined variable"), n)
76 | }
77 | if err := wrapRenderError(writeObject(w, value), n); err != nil {
78 | return err
79 | }
80 | return nil
81 | }
82 |
83 | func (n *SeqNode) render(w *trimWriter, ctx nodeContext) Error {
84 | for _, c := range n.Children {
85 | if err := c.render(w, ctx); err != nil {
86 | return err
87 | }
88 | }
89 | return nil
90 | }
91 |
92 | func (n *TagNode) render(w *trimWriter, ctx nodeContext) Error {
93 | err := wrapRenderError(n.renderer(w, rendererContext{ctx, n, nil}), n)
94 | return err
95 | }
96 |
97 | func (n *TextNode) render(w *trimWriter, _ nodeContext) Error {
98 | _, err := io.WriteString(w, n.Source)
99 | return wrapRenderError(err, n)
100 | }
101 |
102 | func (n *TrimNode) render(w *trimWriter, _ nodeContext) Error {
103 | if n.TrimDirection == parser.Left {
104 | return wrapRenderError(w.TrimLeft(), n)
105 | } else {
106 | w.TrimRight()
107 | return nil
108 | }
109 | }
110 |
111 | // writeObject writes a value used in an object node
112 | func writeObject(w io.Writer, value any) error {
113 | value = values.ToLiquid(value)
114 | if value == nil {
115 | return nil
116 | }
117 | switch value := value.(type) {
118 | case time.Time:
119 | _, err := io.WriteString(w, value.Format("2006-01-02 15:04:05 -0700"))
120 | return err
121 | case []byte:
122 | _, err := w.Write(value)
123 | return err
124 | // there used be a case on fmt.Stringer here, but fmt.Sprint produces better results than obj.Write
125 | // for instances of error and *string
126 | }
127 | rt := reflect.ValueOf(value)
128 | switch rt.Kind() {
129 | case reflect.Array, reflect.Slice:
130 | for i := range rt.Len() {
131 | item := rt.Index(i)
132 | if item.IsValid() {
133 | if err := writeObject(w, item.Interface()); err != nil {
134 | return err
135 | }
136 | }
137 | }
138 | return nil
139 | case reflect.Ptr:
140 | return writeObject(w, reflect.ValueOf(value).Elem())
141 | default:
142 | _, err := io.WriteString(w, fmt.Sprint(value))
143 | return err
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/render/render_test.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "testing"
9 | "time"
10 |
11 | e "github.com/osteele/liquid/expressions"
12 |
13 | "github.com/osteele/liquid/parser"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | var renderTests = []struct{ in, out string }{
18 | // literal representations
19 | {`{{ nil }}`, ""},
20 | {`{{ true }}`, "true"},
21 | {`{{ false }}`, "false"},
22 | {`{{ 12 }}`, "12"},
23 | {`{{ 12.3 }}`, "12.3"},
24 | {`{{ date }}`, "2015-07-17 15:04:05 +0000"},
25 | {`{{ "string" }}`, "string"},
26 | {`{{ array }}`, "firstsecondthird"},
27 |
28 | // variables and properties
29 | {`{{ int }}`, "123"},
30 | {`{{ page.title }}`, "Introduction"},
31 | {`{{ array[1] }}`, "second"},
32 |
33 | // whitespace control
34 | {` {{ 1 }} `, " 1 "},
35 | {` {{- 1 }} `, "1 "},
36 | {` {{ 1 -}} `, " 1"},
37 | {` {{- 1 -}} `, "1"},
38 | {` {{- nil -}} `, ""},
39 | {`x {{ 1 }} z`, "x 1 z"},
40 | {`x {{- 1 }} z`, "x1 z"},
41 | {`x {{ 1 -}} z`, "x 1z"},
42 | {`x {{- 1 -}} z`, "x1z"},
43 | {`x {{ nil }} z`, "x z"},
44 | {`x {{- nil }} z`, "x z"},
45 | {`x {{ nil -}} z`, "x z"},
46 | {`x {{- nil -}} z`, "xz"},
47 | {`x {% null %} z`, "x z"},
48 | {`x {%- null %} z`, "x z"},
49 | {`x {% null -%} z`, "x z"},
50 | {`x {%- null -%} z`, "xz"},
51 | {`x {% y %} z`, "x y z"},
52 | {`x {%- y %} z`, "xy z"},
53 | {`x {% y -%} z`, "x yz"},
54 | {`x {%- y -%} z`, "xyz"},
55 | {"x\n{% y %}\nz", "x\ny\nz"},
56 | {"x\n{%- y %}\nz", "xy\nz"},
57 | {"x\n{% y -%}\nz", "x\nyz"},
58 | {"x\n{% if true %}\ny\n{% endif %}\nz", "x\n\ny\n\nz"},
59 | {"x\n{%- if true %}\ny\n{% endif %}\nz", "x\ny\n\nz"},
60 | {"x\n{%- if true -%}\ny\n{% endif %}\nz", "xy\n\nz"},
61 | {"x\n{%- if true -%}\ny\n{%- endif %}\nz", "xy\nz"},
62 | {"x\n{%- if true -%}\ny\n{%- endif -%}\nz", "xyz"},
63 | }
64 |
65 | var renderStrictTests = []struct{ in, out string }{
66 | // literal representations
67 | {`{{ true }}`, "true"},
68 | {`{{ false }}`, "false"},
69 | {`{{ 12 }}`, "12"},
70 | {`{{ 12.3 }}`, "12.3"},
71 | {`{{ date }}`, "2015-07-17 15:04:05 +0000"},
72 | {`{{ "string" }}`, "string"},
73 | {`{{ array }}`, "firstsecondthird"},
74 |
75 | // variables and properties
76 | {`{{ int }}`, "123"},
77 | {`{{ page.title }}`, "Introduction"},
78 | {`{{ array[1] }}`, "second"},
79 | {`{{ invalid }}`, ""},
80 | }
81 |
82 | var renderErrorTests = []struct{ in, out string }{
83 | {`{% errblock %}{% enderrblock %}`, "errblock error"},
84 | }
85 |
86 | var renderTestBindings = map[string]any{
87 | "array": []string{"first", "second", "third"},
88 | "date": time.Date(2015, 7, 17, 15, 4, 5, 123456789, time.UTC),
89 | "int": 123,
90 | "sort_prop": []map[string]any{
91 | {"weight": 1},
92 | {"weight": 5},
93 | {"weight": 3},
94 | {"weight": nil},
95 | },
96 | // for examples from liquid docs
97 | "animals": []string{"zebra", "octopus", "giraffe", "Sally Snake"},
98 | "page": map[string]any{
99 | "title": "Introduction",
100 | },
101 | "pages": []map[string]any{
102 | {"category": "business"},
103 | {"category": "celebrities"},
104 | {},
105 | {"category": "lifestyle"},
106 | {"category": "sports"},
107 | {},
108 | {"category": "technology"},
109 | },
110 | }
111 |
112 | func TestRender(t *testing.T) {
113 | cfg := NewConfig()
114 | addRenderTestTags(cfg)
115 | for i, test := range renderTests {
116 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
117 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
118 | require.NoErrorf(t, err, test.in)
119 | buf := new(bytes.Buffer)
120 | err = Render(root, buf, renderTestBindings, cfg)
121 | require.NoErrorf(t, err, test.in)
122 | require.Equalf(t, test.out, buf.String(), test.in)
123 | })
124 | }
125 | }
126 |
127 | func TestRenderErrors(t *testing.T) {
128 | cfg := NewConfig()
129 | addRenderTestTags(cfg)
130 | for i, test := range renderErrorTests {
131 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
132 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
133 | require.NoErrorf(t, err, test.in)
134 | err = Render(root, io.Discard, renderTestBindings, cfg)
135 | require.Errorf(t, err, test.in)
136 | require.Containsf(t, err.Error(), test.out, test.in)
137 | })
138 | }
139 | }
140 |
141 | func TestRenderStrictVariables(t *testing.T) {
142 | cfg := NewConfig()
143 | cfg.StrictVariables = true
144 | addRenderTestTags(cfg)
145 | for i, test := range renderStrictTests {
146 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
147 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
148 | require.NoErrorf(t, err, test.in)
149 | buf := new(bytes.Buffer)
150 | err = Render(root, buf, renderTestBindings, cfg)
151 | if test.in == `{{ invalid }}` {
152 | require.Errorf(t, err, test.in)
153 | } else {
154 | require.NoErrorf(t, err, test.in)
155 | }
156 | require.Equalf(t, test.out, buf.String(), test.in)
157 | })
158 | }
159 | }
160 |
161 | func addRenderTestTags(cfg Config) {
162 | cfg.AddTag("y", func(string) (func(io.Writer, Context) error, error) {
163 | return func(w io.Writer, _ Context) error {
164 | _, err := io.WriteString(w, "y")
165 | return err
166 | }, nil
167 | })
168 | cfg.AddTag("null", func(string) (func(io.Writer, Context) error, error) {
169 | return func(io.Writer, Context) error { return nil }, nil
170 | })
171 | cfg.AddBlock("errblock").Compiler(func(c BlockNode) (func(io.Writer, Context) error, error) {
172 | return func(w io.Writer, c Context) error {
173 | return errors.New("errblock error")
174 | }, nil
175 | })
176 | cfg.AddBlock("if").Clause("else").Clause("elsif").Compiler(ifTagCompiler(true))
177 | }
178 |
179 | // this is copied from standard tags.
180 | func ifTagCompiler(polarity bool) func(BlockNode) (func(io.Writer, Context) error, error) { // nolint: gocyclo
181 | return func(node BlockNode) (func(io.Writer, Context) error, error) {
182 | type branchRec struct {
183 | test e.Expression
184 | body *BlockNode
185 | }
186 | expr, err := e.Parse(node.Args)
187 | if err != nil {
188 | return nil, err
189 | }
190 | if !polarity {
191 | expr = e.Not(expr)
192 | }
193 | branches := []branchRec{
194 | {expr, &node},
195 | }
196 | for _, c := range node.Clauses {
197 | test := e.Constant(true)
198 | switch c.Name {
199 | case "else":
200 | // TODO syntax error if this isn't the last branch
201 | case "elsif":
202 | t, err := e.Parse(c.Args)
203 | if err != nil {
204 | return nil, err
205 | }
206 | test = t
207 | }
208 | branches = append(branches, branchRec{test, c})
209 | }
210 | return func(w io.Writer, ctx Context) error {
211 | for _, b := range branches {
212 | value, err := ctx.Evaluate(b.test)
213 | if err != nil {
214 | return err
215 | }
216 | if value != nil && value != false {
217 | return ctx.RenderBlock(w, b.body)
218 | }
219 | }
220 | return nil
221 | }, nil
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/render/tags.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "io"
5 | )
6 |
7 | // TagCompiler is a function that parses the tag arguments, and returns a renderer.
8 | // TODO instead of using the bare function definition, use a structure that defines how to parse
9 | type TagCompiler func(expr string) (func(io.Writer, Context) error, error)
10 |
11 | // AddTag creates a tag definition.
12 | func (c *Config) AddTag(name string, td TagCompiler) {
13 | c.tags[name] = td
14 | }
15 |
16 | // FindTagDefinition looks up a tag definition.
17 | func (c *Config) FindTagDefinition(name string) (TagCompiler, bool) {
18 | td, ok := c.tags[name]
19 | return td, ok
20 | }
21 |
--------------------------------------------------------------------------------
/render/testdata/render_file.txt:
--------------------------------------------------------------------------------
1 | rendered shadowed={{ shadowed }}
--------------------------------------------------------------------------------
/render/testdata/render_file_runtime_error.txt:
--------------------------------------------------------------------------------
1 | {% undefined_tag %}
--------------------------------------------------------------------------------
/render/testdata/render_file_syntax_error.txt:
--------------------------------------------------------------------------------
1 | {{ syntax error }}
--------------------------------------------------------------------------------
/render/trimwriter.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "unicode"
7 | )
8 |
9 | // A trimWriter provides whitespace control around a wrapped io.Writer.
10 | // The caller should call TrimLeft(bool) and TrimRight(bool) respectively
11 | // before and after processing a tag or expression, and Flush() at completion.
12 | type trimWriter struct {
13 | w io.Writer
14 | buf bytes.Buffer
15 | trim bool
16 | }
17 |
18 | // Write writes b to the current buffer. If the trim flag is set,
19 | // a prefix whitespace trim on b is performed before writing it to
20 | // the buffer and the trim flag is unset. If the trim flag was not
21 | // set, the current buffer is flushed before b is written.
22 | // Write only returns the bytes written to w during a flush.
23 | func (tw *trimWriter) Write(b []byte) (n int, err error) {
24 | if tw.trim {
25 | b = bytes.TrimLeftFunc(b, unicode.IsSpace)
26 | tw.trim = false
27 | } else if n, err = tw.Flush(); err != nil {
28 | return n, err
29 | }
30 | _, err = tw.buf.Write(b)
31 | return
32 | }
33 |
34 | // TrimLeft trims all whitespaces before the trim node, i.e. the whitespace
35 | // suffix of the current buffer. It then writes the current buffer to w and
36 | // resets the buffer.
37 | func (tw *trimWriter) TrimLeft() error {
38 | _, err := tw.w.Write(bytes.TrimRightFunc(tw.buf.Bytes(), unicode.IsSpace))
39 | tw.buf.Reset()
40 | return err
41 | }
42 |
43 | // TrimRight sets the trim flag on the trimWriter. This will cause a prefix
44 | // whitespace trim on any subsequent write.
45 | func (tw *trimWriter) TrimRight() {
46 | tw.trim = true
47 | }
48 |
49 | // Flush flushes the current buffer into w.
50 | func (tw *trimWriter) Flush() (int, error) {
51 | if tw.buf.Len() > 0 {
52 | n, err := tw.buf.WriteTo(tw.w)
53 | tw.buf.Reset()
54 | return int(n), err
55 | }
56 | return 0, nil
57 | }
58 |
--------------------------------------------------------------------------------
/scripts/coverage:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | echo 'mode: set' > coverage.out
6 |
7 | for p in $(go list -f '{{.ImportPath}}' ./...); do
8 | rm -f package-coverage.out
9 | go test -coverprofile=package-coverage.out $p
10 | [[ -f package-coverage.out ]] && grep -v 'mode: set' package-coverage.out >> coverage.out
11 | rm -f package-coverage.out
12 | done
13 |
--------------------------------------------------------------------------------
/scripts/shopify-liquid:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require 'liquid'
3 |
4 | Liquid::Template.error_mode = :strict
5 |
6 | source = $stdin.read
7 | template = Liquid::Template.parse(source)
8 | out = template.render({}, { strict_filters: true })
9 | # for e in template.errors do
10 | # # $stderr.puts e
11 | # end
12 | $stdout.write(out)
13 |
--------------------------------------------------------------------------------
/tags/control_flow_tags.go:
--------------------------------------------------------------------------------
1 | package tags
2 |
3 | import (
4 | "io"
5 |
6 | e "github.com/osteele/liquid/expressions"
7 | "github.com/osteele/liquid/render"
8 | "github.com/osteele/liquid/values"
9 | )
10 |
11 | type caseInterpreter interface {
12 | body() *render.BlockNode
13 | test(any, render.Context) (bool, error)
14 | }
15 | type exprCase struct {
16 | e.When
17 | b *render.BlockNode
18 | }
19 |
20 | func (c exprCase) body() *render.BlockNode { return c.b }
21 |
22 | func (c exprCase) test(caseValue any, ctx render.Context) (bool, error) {
23 | for _, expr := range c.Exprs {
24 | whenValue, err := ctx.Evaluate(expr)
25 | if err != nil {
26 | return false, err
27 | }
28 | if values.Equal(caseValue, whenValue) {
29 | return true, nil
30 | }
31 | }
32 | return false, nil
33 | }
34 |
35 | type elseCase struct{ b *render.BlockNode }
36 |
37 | func (c elseCase) body() *render.BlockNode { return c.b }
38 |
39 | func (c elseCase) test(any, render.Context) (bool, error) { return true, nil }
40 |
41 | func caseTagCompiler(node render.BlockNode) (func(io.Writer, render.Context) error, error) {
42 | // TODO syntax error on non-empty node.Body
43 | expr, err := e.Parse(node.Args)
44 | if err != nil {
45 | return nil, err
46 | }
47 | cases := []caseInterpreter{}
48 | for _, clause := range node.Clauses {
49 | switch clause.Name {
50 | case "when":
51 | stmt, err := e.ParseStatement(e.WhenStatementSelector, clause.Args)
52 | if err != nil {
53 | return nil, err
54 | }
55 | cases = append(cases, exprCase{stmt.When, clause})
56 | default: // should be a check for "else", but I like the metacircularity
57 | cases = append(cases, elseCase{clause})
58 | }
59 | }
60 | return func(w io.Writer, ctx render.Context) error {
61 | sel, err := ctx.Evaluate(expr)
62 | if err != nil {
63 | return err
64 | }
65 | for _, clause := range cases {
66 | b, err := clause.test(sel, ctx)
67 | if err != nil {
68 | return err
69 | }
70 | if b {
71 | return ctx.RenderBlock(w, clause.body())
72 | }
73 | }
74 | return nil
75 | }, nil
76 | }
77 |
78 | func ifTagCompiler(polarity bool) func(render.BlockNode) (func(io.Writer, render.Context) error, error) { //nolint: gocyclo
79 | return func(node render.BlockNode) (func(io.Writer, render.Context) error, error) {
80 | type branchRec struct {
81 | test e.Expression
82 | body *render.BlockNode
83 | }
84 | expr, err := e.Parse(node.Args)
85 | if err != nil {
86 | return nil, err
87 | }
88 | if !polarity {
89 | expr = e.Not(expr)
90 | }
91 | branches := []branchRec{
92 | {expr, &node},
93 | }
94 | for _, c := range node.Clauses {
95 | test := e.Constant(true)
96 | switch c.Name {
97 | case "else":
98 | // TODO syntax error if this isn't the last branch
99 | case "elsif":
100 | t, err := e.Parse(c.Args)
101 | if err != nil {
102 | return nil, err
103 | }
104 | test = t
105 | }
106 | branches = append(branches, branchRec{test, c})
107 | }
108 | return func(w io.Writer, ctx render.Context) error {
109 | for _, b := range branches {
110 | value, err := ctx.Evaluate(b.test)
111 | if err != nil {
112 | return err
113 | }
114 | if value != nil && value != false {
115 | return ctx.RenderBlock(w, b.body)
116 | }
117 | }
118 | return nil
119 | }, nil
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/tags/control_flow_tags_test.go:
--------------------------------------------------------------------------------
1 | package tags
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "testing"
9 |
10 | "github.com/osteele/liquid/parser"
11 | "github.com/osteele/liquid/render"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | var cfTagTests = []struct{ in, expected string }{
16 | // case
17 | {`{% case 1 %}{% when 1 %}a{% when 2 %}b{% endcase %}`, "a"},
18 | {`{% case 2 %}{% when 1 %}a{% when 2 %}b{% endcase %}`, "b"},
19 | {`{% case 3 %}{% when 1 %}a{% when 2 %}b{% endcase %}`, ""},
20 | // else
21 | {`{% case 1 %}{% when 1 %}a{% else %}b{% endcase %}`, "a"},
22 | {`{% case 2 %}{% when 1 %}a{% else %}b{% endcase %}`, "b"},
23 | // disjunction
24 | {`{% case 1 %}{% when 1,2 %}a{% else %}b{% endcase %}`, "a"},
25 | {`{% case 2 %}{% when 1,2 %}a{% else %}b{% endcase %}`, "a"},
26 | {`{% case 3 %}{% when 1,2 %}a{% else %}b{% endcase %}`, "b"},
27 |
28 | // if
29 | {`{% if true %}true{% endif %}`, "true"},
30 | {`{% if false %}false{% endif %}`, ""},
31 | {`{% if 0 %}true{% endif %}`, "true"},
32 | {`{% if 1 %}true{% endif %}`, "true"},
33 | {`{% if x %}true{% endif %}`, "true"},
34 | {`{% if y %}true{% endif %}`, ""},
35 | {`{% if true %}true{% endif %}`, "true"},
36 | {`{% if false %}false{% endif %}`, ""},
37 | {`{% if true %}true{% else %}false{% endif %}`, "true"},
38 | {`{% if false %}false{% else %}true{% endif %}`, "true"},
39 | {`{% if true %}0{% elsif true %}1{% else %}2{% endif %}`, "0"},
40 | {`{% if false %}0{% elsif true %}1{% else %}2{% endif %}`, "1"},
41 | {`{% if false %}0{% elsif false %}1{% else %}2{% endif %}`, "2"},
42 |
43 | // unless
44 | {`{% unless true %}false{% endunless %}`, ""},
45 | {`{% unless false %}true{% endunless %}`, "true"},
46 | {`{% unless true %}true{% else %}false{% endunless %}`, "false"},
47 | }
48 |
49 | var cfTagCompilationErrorTests = []struct{ in, expected string }{
50 | {`{% if syntax error %}{% endif %}`, "syntax error"},
51 | {`{% if true %}{% elsif syntax error %}{% endif %}`, "syntax error"},
52 | {`{% case syntax error %}{% when 1 %}{% endcase %}`, "syntax error"},
53 | }
54 |
55 | var cfTagErrorTests = []struct{ in, expected string }{
56 | {`{% if a | undefined_filter %}{% endif %}`, "undefined filter"},
57 | {`{% if false %}{% elsif a | undefined_filter %}{% endif %}`, "undefined filter"},
58 | {`{% case 1 %}{% when 1 %}{% error %}{% endcase %}`, "tag render error"},
59 | {`{% case a | undefined_filter %}{% when 1 %}{% endcase %}`, "undefined filter"},
60 | }
61 |
62 | func TestControlFlowTags(t *testing.T) {
63 | cfg := render.NewConfig()
64 | AddStandardTags(cfg)
65 | for i, test := range cfTagTests {
66 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
67 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
68 | require.NoErrorf(t, err, test.in)
69 | buf := new(bytes.Buffer)
70 | err = render.Render(root, buf, tagTestBindings, cfg)
71 | require.NoErrorf(t, err, test.in)
72 | require.Equalf(t, test.expected, buf.String(), test.in)
73 | })
74 | }
75 | }
76 |
77 | func TestControlFlowTags_errors(t *testing.T) {
78 | cfg := render.NewConfig()
79 | AddStandardTags(cfg)
80 | cfg.AddTag("error", func(string) (func(io.Writer, render.Context) error, error) {
81 | return func(io.Writer, render.Context) error {
82 | return errors.New("tag render error")
83 | }, nil
84 | })
85 |
86 | for i, test := range cfTagCompilationErrorTests {
87 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
88 | _, err := cfg.Compile(test.in, parser.SourceLoc{})
89 | require.Errorf(t, err, test.in)
90 | require.Contains(t, err.Error(), test.expected, test.in)
91 | })
92 | }
93 | for i, test := range cfTagErrorTests {
94 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
95 | root, err := cfg.Compile(test.in, parser.SourceLoc{})
96 | require.NoErrorf(t, err, test.in)
97 | err = render.Render(root, io.Discard, tagTestBindings, cfg)
98 | require.Errorf(t, err, test.in)
99 | require.Contains(t, err.Error(), test.expected, test.in)
100 | })
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/tags/include_tag.go:
--------------------------------------------------------------------------------
1 | package tags
2 |
3 | import (
4 | "io"
5 | "path/filepath"
6 |
7 | "github.com/osteele/liquid/render"
8 | )
9 |
10 | func includeTag(source string) (func(io.Writer, render.Context) error, error) {
11 | return func(w io.Writer, ctx render.Context) error {
12 | // It might be more efficient to add a context interface to render bytes
13 | // to a writer. The status quo keeps the interface light at the expense of some overhead
14 | // here.
15 | value, err := ctx.EvaluateString(ctx.TagArgs())
16 | if err != nil {
17 | return err
18 | }
19 | rel, ok := value.(string)
20 | if !ok {
21 | return ctx.Errorf("include requires a string argument; got %v", value)
22 | }
23 | filename := filepath.Join(filepath.Dir(ctx.SourceFile()), rel)
24 | s, err := ctx.RenderFile(filename, map[string]any{})
25 | if err != nil {
26 | return err
27 | }
28 | _, err = io.WriteString(w, s)
29 | return err
30 | }, nil
31 | }
32 |
--------------------------------------------------------------------------------
/tags/include_tag_test.go:
--------------------------------------------------------------------------------
1 | package tags
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/osteele/liquid/parser"
11 | "github.com/osteele/liquid/render"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | var includeTestBindings = map[string]any{
16 | "test": true,
17 | "var": "value",
18 | }
19 |
20 | func TestIncludeTag(t *testing.T) {
21 | config := render.NewConfig()
22 | loc := parser.SourceLoc{Pathname: "testdata/include_source.html", LineNo: 1}
23 | AddStandardTags(config)
24 |
25 | // basic functionality
26 | root, err := config.Compile(`{% include "include_target.html" %}`, loc)
27 | require.NoError(t, err)
28 | buf := new(bytes.Buffer)
29 | err = render.Render(root, buf, includeTestBindings, config)
30 | require.NoError(t, err)
31 | require.Equal(t, "include target", strings.TrimSpace(buf.String()))
32 |
33 | // tag and variable
34 | root, err = config.Compile(`{% include "include_target_2.html" %}`, loc)
35 | require.NoError(t, err)
36 | buf = new(bytes.Buffer)
37 | err = render.Render(root, buf, includeTestBindings, config)
38 | require.NoError(t, err)
39 | require.Equal(t, "test value", strings.TrimSpace(buf.String()))
40 |
41 | // errors
42 | root, err = config.Compile(`{% include 10 %}`, loc)
43 | require.NoError(t, err)
44 | err = render.Render(root, io.Discard, includeTestBindings, config)
45 | require.Error(t, err)
46 | require.Contains(t, err.Error(), "requires a string")
47 | }
48 |
49 | func TestIncludeTag_file_not_found_error(t *testing.T) {
50 | config := render.NewConfig()
51 | loc := parser.SourceLoc{Pathname: "testdata/include_source.html", LineNo: 1}
52 | AddStandardTags(config)
53 |
54 | // See the comment in TestIncludeTag_file_not_found_error.
55 | root, err := config.Compile(`{% include "missing_file.html" %}`, loc)
56 | require.NoError(t, err)
57 | err = render.Render(root, io.Discard, includeTestBindings, config)
58 | require.Error(t, err)
59 | require.True(t, os.IsNotExist(err.Cause()))
60 | }
61 |
62 | func TestIncludeTag_cached_value_handling(t *testing.T) {
63 | config := render.NewConfig()
64 | // missing-file.html does not exist in the testdata directory.
65 | config.Cache["testdata/missing-file.html"] = []byte("include-content")
66 | config.Cache["testdata\\missing-file.html"] = []byte("include-content")
67 | loc := parser.SourceLoc{Pathname: "testdata/include_source.html", LineNo: 1}
68 | AddStandardTags(config)
69 |
70 | root, err := config.Compile(`{% include "missing-file.html" %}`, loc)
71 | require.NoError(t, err)
72 | buf := new(bytes.Buffer)
73 | err = render.Render(root, buf, includeTestBindings, config)
74 | require.NoError(t, err)
75 | require.Equal(t, "include-content", strings.TrimSpace(buf.String()))
76 | }
77 |
--------------------------------------------------------------------------------
/tags/standard_tags.go:
--------------------------------------------------------------------------------
1 | // Package tags is an internal package that defines the standard Liquid tags.
2 | package tags
3 |
4 | import (
5 | "io"
6 |
7 | "github.com/osteele/liquid/expressions"
8 | "github.com/osteele/liquid/render"
9 | )
10 |
11 | // AddStandardTags defines the standard Liquid tags.
12 | func AddStandardTags(c render.Config) {
13 | c.AddTag("assign", assignTag)
14 | c.AddTag("include", includeTag)
15 |
16 | // blocks
17 | // The parser only recognize the comment and raw tags if they've been defined,
18 | // but it ignores any syntax specified here.
19 | c.AddTag("break", breakTag)
20 | c.AddTag("continue", continueTag)
21 | c.AddTag("cycle", cycleTag)
22 | c.AddBlock("capture").Compiler(captureTagCompiler)
23 | c.AddBlock("case").Clause("when").Clause("else").Compiler(caseTagCompiler)
24 | c.AddBlock("comment")
25 | c.AddBlock("for").Clause("else").Compiler(loopTagCompiler)
26 | c.AddBlock("if").Clause("else").Clause("elsif").Compiler(ifTagCompiler(true))
27 | c.AddBlock("raw")
28 | c.AddBlock("tablerow").Compiler(loopTagCompiler)
29 | c.AddBlock("unless").Clause("else").Compiler(ifTagCompiler(false))
30 | }
31 |
32 | func assignTag(source string) (func(io.Writer, render.Context) error, error) {
33 | stmt, err := expressions.ParseStatement(expressions.AssignStatementSelector, source)
34 | if err != nil {
35 | return nil, err
36 | }
37 | return func(w io.Writer, ctx render.Context) error {
38 | value, err := ctx.Evaluate(stmt.ValueFn)
39 | if err != nil {
40 | return err
41 | }
42 | _ = value
43 | ctx.Set(stmt.Assignment.Variable, value)
44 | return nil
45 | }, nil
46 | }
47 |
48 | func captureTagCompiler(node render.BlockNode) (func(io.Writer, render.Context) error, error) {
49 | // TODO verify syntax
50 | varname := node.Args
51 | return func(w io.Writer, ctx render.Context) error {
52 | s, err := ctx.InnerString()
53 | if err != nil {
54 | return err
55 | }
56 | ctx.Set(varname, s)
57 | return nil
58 | }, nil
59 | }
60 |
--------------------------------------------------------------------------------
/tags/standard_tags_test.go:
--------------------------------------------------------------------------------
1 | package tags
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 | "testing"
8 |
9 | "github.com/osteele/liquid/parser"
10 | "github.com/osteele/liquid/render"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | var parseErrorTests = []struct{ in, expected string }{
15 | {"{% undefined_tag %}", "undefined tag"},
16 | {"{% assign v x y z %}", "syntax error"},
17 | {"{% if syntax error %}", `unterminated "if" block`},
18 | // TODO once expression parsing is moved to template parse stage
19 | // {"{% if syntax error %}{% endif %}", "syntax error"},
20 | // {"{% for a in ar undefined %}{{ a }} {% endfor %}", "TODO"},
21 | }
22 |
23 | var tagTests = []struct{ in, expected string }{
24 | // variable tags
25 | {`{% assign av = 1 %}{{ av }}`, "1"},
26 | {`{% assign av = obj.a %}{{ av }}`, "1"},
27 | {`{% assign av = (1..5) %}{{ av }}`, "{1 5}"},
28 | {`{% capture x %}captured{% endcapture %}{{ x }}`, "captured"},
29 |
30 | // TODO research whether Liquid requires matching interior tags
31 | {`{% comment %}{{ a }}{% undefined_tag %}{% endcomment %}`, ""},
32 |
33 | // TODO research whether Liquid requires matching interior tags
34 | {`pre{% raw %}{{ a }}{% undefined_tag %}{% endraw %}post`, "pre{{ a }}{% undefined_tag %}post"},
35 | {`pre{% raw %}{% if false %}anyway-{% endraw %}post`, "pre{% if false %}anyway-post"},
36 | }
37 |
38 | var tagErrorTests = []struct{ in, expected string }{
39 | {`{% assign av = x | undefined_filter %}`, "undefined filter"},
40 | }
41 |
42 | // this is also used in the other test files
43 | var tagTestBindings = map[string]any{
44 | "x": 123,
45 | "obj": map[string]any{
46 | "a": 1,
47 | },
48 | "animals": []string{"zebra", "octopus", "giraffe", "Sally Snake"},
49 | "pages": []map[string]any{
50 | {"category": "business"},
51 | {"category": "celebrities"},
52 | {},
53 | {"category": "lifestyle"},
54 | {"category": "sports"},
55 | {},
56 | {"category": "technology"},
57 | },
58 | "sort_prop": []map[string]any{
59 | {"weight": 1},
60 | {"weight": 5},
61 | {"weight": 3},
62 | {"weight": nil},
63 | },
64 | "page": map[string]any{
65 | "title": "Introduction",
66 | },
67 | }
68 |
69 | func TestStandardTags_parse_errors(t *testing.T) {
70 | settings := render.NewConfig()
71 | AddStandardTags(settings)
72 | for i, test := range parseErrorTests {
73 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
74 | root, err := settings.Compile(test.in, parser.SourceLoc{})
75 | require.Nilf(t, root, test.in)
76 | require.Errorf(t, err, test.in)
77 | require.Containsf(t, err.Error(), test.expected, test.in)
78 | })
79 | }
80 | }
81 |
82 | func TestStandardTags(t *testing.T) {
83 | config := render.NewConfig()
84 | AddStandardTags(config)
85 | for i, test := range tagTests {
86 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
87 | root, err := config.Compile(test.in, parser.SourceLoc{})
88 | require.NoErrorf(t, err, test.in)
89 | buf := new(bytes.Buffer)
90 | err = render.Render(root, buf, tagTestBindings, config)
91 | require.NoErrorf(t, err, test.in)
92 | require.Equalf(t, test.expected, buf.String(), test.in)
93 | })
94 | }
95 | }
96 |
97 | func TestStandardTags_render_errors(t *testing.T) {
98 | config := render.NewConfig()
99 | AddStandardTags(config)
100 | for i, test := range tagErrorTests {
101 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
102 | root, err := config.Compile(test.in, parser.SourceLoc{})
103 | require.NoErrorf(t, err, test.in)
104 | err = render.Render(root, io.Discard, tagTestBindings, config)
105 | require.Errorf(t, err, test.in)
106 | require.Containsf(t, err.Error(), test.expected, test.in)
107 | })
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/tags/testdata/include_target.html:
--------------------------------------------------------------------------------
1 | include target
--------------------------------------------------------------------------------
/tags/testdata/include_target_2.html:
--------------------------------------------------------------------------------
1 | {%- assign myVar = "test" -%}test {% if test %}{{ var }}{% endif %}
--------------------------------------------------------------------------------
/template.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "bytes"
5 | "io"
6 |
7 | "github.com/osteele/liquid/parser"
8 | "github.com/osteele/liquid/render"
9 | )
10 |
11 | // A Template is a compiled Liquid template. It knows how to evaluate itself within a variable binding environment, to create a rendered byte slice.
12 | //
13 | // Use Engine.ParseTemplate to create a template.
14 | type Template struct {
15 | root render.Node
16 | cfg *render.Config
17 | }
18 |
19 | func newTemplate(cfg *render.Config, source []byte, path string, line int) (*Template, SourceError) {
20 | loc := parser.SourceLoc{Pathname: path, LineNo: line}
21 | root, err := cfg.Compile(string(source), loc)
22 | if err != nil {
23 | return nil, err
24 | }
25 | return &Template{root, cfg}, nil
26 | }
27 |
28 | // GetRoot returns the root node of the abstract syntax tree (AST) representing
29 | // the parsed template.
30 | func (t *Template) GetRoot() render.Node {
31 | return t.root
32 | }
33 |
34 | // Render executes the template with the specified variable bindings.
35 | func (t *Template) Render(vars Bindings) ([]byte, SourceError) {
36 | buf := new(bytes.Buffer)
37 | err := render.Render(t.root, buf, vars, *t.cfg)
38 | if err != nil {
39 | return nil, err
40 | }
41 | return buf.Bytes(), nil
42 | }
43 |
44 | // FRender executes the template with the specified variable bindings and renders it into w.
45 | func (t *Template) FRender(w io.Writer, vars Bindings) SourceError {
46 | err := render.Render(t.root, w, vars, *t.cfg)
47 | if err != nil {
48 | return err
49 | }
50 | return nil
51 | }
52 |
53 | // RenderString is a convenience wrapper for Render, that has string input and output.
54 | func (t *Template) RenderString(b Bindings) (string, SourceError) {
55 | bs, err := t.Render(b)
56 | if err != nil {
57 | return "", err
58 | }
59 | return string(bs), nil
60 | }
61 |
--------------------------------------------------------------------------------
/template_test.go:
--------------------------------------------------------------------------------
1 | package liquid
2 |
3 | import (
4 | "fmt"
5 | "sync"
6 | "testing"
7 |
8 | "github.com/osteele/liquid/render"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestTemplate_GetRoot(t *testing.T) {
14 | root := &render.SeqNode{}
15 | tmpl := Template{root: root}
16 | require.Same(t, root, tmpl.GetRoot())
17 | }
18 |
19 | func TestTemplate_RenderString(t *testing.T) {
20 | engine := NewEngine()
21 | tpl, err := engine.ParseTemplate([]byte(`{{ "hello world" | capitalize }}`))
22 | require.NoError(t, err)
23 | out, err := tpl.RenderString(testBindings)
24 | require.NoError(t, err)
25 | require.Equal(t, "Hello world", out)
26 | }
27 |
28 | func TestTemplate_SetSourcePath(t *testing.T) {
29 | engine := NewEngine()
30 | engine.RegisterTag("sourcepath", func(c render.Context) (string, error) {
31 | return c.SourceFile(), nil
32 | })
33 | tpl, err := engine.ParseTemplateLocation([]byte(`{% sourcepath %}`), "source.md", 1)
34 | require.NoError(t, err)
35 | out, err := tpl.RenderString(testBindings)
36 | require.NoError(t, err)
37 | require.Equal(t, "source.md", out)
38 |
39 | src := []byte(`{{ n | undefined_filter }}`)
40 | t1, err := engine.ParseTemplateLocation(src, "path1", 1)
41 | require.NoError(t, err)
42 | t2, err := engine.ParseTemplateLocation(src, "path2", 1)
43 | require.NoError(t, err)
44 | _, err = t1.Render(Bindings{})
45 | require.Error(t, err)
46 | require.Equal(t, "path1", err.Path())
47 | _, err = t2.Render(Bindings{})
48 | require.Error(t, err)
49 | require.Equal(t, "path2", err.Path())
50 | }
51 |
52 | func TestTemplate_Parse_race(t *testing.T) {
53 | var (
54 | engine = NewEngine()
55 | count = 10
56 | wg sync.WaitGroup
57 | )
58 | for i := range count {
59 | wg.Add(1)
60 | go func(i int) {
61 | path := fmt.Sprintf("path %d", i)
62 | _, err := engine.ParseTemplateLocation([]byte("{{ syntax error }}"), path, i)
63 | assert.Error(t, err)
64 | assert.Equal(t, path, err.Path())
65 | wg.Done()
66 | }(i)
67 | }
68 | wg.Wait()
69 | }
70 |
71 | func TestTemplate_Render_race(t *testing.T) {
72 | src := []byte(`{{ n | undefined_filter }}`)
73 | engine := NewEngine()
74 |
75 | var (
76 | count = 10
77 | paths = make([]string, count)
78 | ts = make([]*Template, count)
79 | wg sync.WaitGroup
80 | )
81 | for i := range count {
82 | paths[i] = fmt.Sprintf("path %d", i)
83 | wg.Add(1)
84 | go func(i int) {
85 | defer wg.Done()
86 | var err error
87 | ts[i], err = engine.ParseTemplateLocation(src, paths[i], i)
88 | assert.NoError(t, err)
89 | }(i)
90 | }
91 | wg.Wait()
92 |
93 | var wg2 sync.WaitGroup
94 | for i := range count {
95 | wg2.Add(1)
96 | go func(i int) {
97 | defer wg2.Done()
98 | _, err := ts[i].Render(Bindings{})
99 | assert.Error(t, err)
100 | assert.Equal(t, paths[i], err.Path())
101 | }(i)
102 | }
103 | wg2.Wait()
104 | }
105 |
106 | func BenchmarkTemplate_Render(b *testing.B) {
107 | engine := NewEngine()
108 | bindings := Bindings{"a": "string value"}
109 | tpl, err := engine.ParseString(`{% for i in (1..1000) %}{% if i > 500 %}{{a}}{% else %}0{% endif %}{% endfor %}`)
110 | if err != nil {
111 | b.Fatal(err)
112 | }
113 | b.ResetTimer()
114 | for range b.N {
115 | _, err := tpl.Render(bindings)
116 | require.NoError(b, err)
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/values/arrays.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "reflect"
5 | "unicode/utf8"
6 | )
7 |
8 | // TODO Length is now only used by the "size" filter.
9 | // Maybe it should go somewhere else.
10 |
11 | // Length returns the length of a string or array. In keeping with Liquid semantics,
12 | // and contra Go, it does not return the size of a map.
13 | func Length(value any) int {
14 | value = ToLiquid(value)
15 | ref := reflect.ValueOf(value)
16 | switch ref.Kind() {
17 | case reflect.Array, reflect.Slice:
18 | return ref.Len()
19 | case reflect.String:
20 | return utf8.RuneCountInString(ref.String())
21 | default:
22 | return 0
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/values/call.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | )
7 |
8 | // Call applies a function to arguments, converting them as necessary.
9 | //
10 | // The conversion follows Liquid (Ruby?) semantics, which are more aggressive than
11 | // Go conversion.
12 | //
13 | // The function should return one or two values; the second value,
14 | // if present, should be an error.
15 | func Call(fn reflect.Value, args []any) (any, error) {
16 | in, err := convertCallArguments(fn, args)
17 | if err != nil {
18 | return nil, err
19 | }
20 | results := fn.Call(in)
21 | return convertCallResults(results)
22 | }
23 |
24 | // A CallParityError is a mismatch between the argument and parameter counts.
25 | type CallParityError struct{ NumArgs, NumParams int }
26 |
27 | func (e *CallParityError) Error() string {
28 | return fmt.Sprintf("wrong number of arguments (given %d, expected %d)", e.NumArgs, e.NumParams)
29 | }
30 |
31 | func convertCallResults(results []reflect.Value) (any, error) {
32 | if len(results) > 1 && results[1].Interface() != nil {
33 | switch e := results[1].Interface().(type) {
34 | case error:
35 | return nil, e
36 | default:
37 | panic(e)
38 | }
39 | }
40 | return results[0].Interface(), nil
41 | }
42 |
43 | // Convert args to match the input types of function fn.
44 | func convertCallArguments(fn reflect.Value, args []any) (results []reflect.Value, err error) {
45 | rt := fn.Type()
46 | if len(args) > rt.NumIn() && !rt.IsVariadic() {
47 | return nil, &CallParityError{NumArgs: len(args), NumParams: rt.NumIn()}
48 | }
49 | if rt.IsVariadic() {
50 | numArgs, minArgs := len(args), rt.NumIn()-1
51 | if numArgs < minArgs {
52 | numArgs = minArgs
53 | }
54 | results = make([]reflect.Value, numArgs)
55 | } else {
56 | results = make([]reflect.Value, rt.NumIn())
57 | }
58 | for i, arg := range args {
59 | var typ reflect.Type
60 | if rt.IsVariadic() && i >= rt.NumIn()-1 {
61 | typ = rt.In(rt.NumIn() - 1).Elem()
62 | } else {
63 | typ = rt.In(i)
64 | }
65 | switch {
66 | case isDefaultFunctionType(typ):
67 | results[i] = makeConstantFunction(typ, arg)
68 | case arg == nil:
69 | results[i] = reflect.Zero(typ)
70 | default:
71 | results[i] = reflect.ValueOf(MustConvert(arg, typ))
72 | }
73 | }
74 |
75 | // create zeros and default functions for parameters without arguments
76 | for i := len(args); i < len(results); i++ {
77 | typ := rt.In(i)
78 | switch {
79 | case isDefaultFunctionType(typ):
80 | results[i] = makeIdentityFunction(typ)
81 | default:
82 | results[i] = reflect.Zero(typ)
83 | }
84 | }
85 | return results, err
86 | }
87 |
88 | func isDefaultFunctionType(typ reflect.Type) bool {
89 | return typ.Kind() == reflect.Func && typ.NumIn() == 1 && typ.NumOut() == 1
90 | }
91 |
92 | func makeConstantFunction(typ reflect.Type, arg any) reflect.Value {
93 | return reflect.MakeFunc(typ, func(args []reflect.Value) []reflect.Value {
94 | return []reflect.Value{reflect.ValueOf(MustConvert(arg, typ.Out(0)))}
95 | })
96 | }
97 |
98 | func makeIdentityFunction(typ reflect.Type) reflect.Value {
99 | return reflect.MakeFunc(typ, func(args []reflect.Value) []reflect.Value {
100 | return args
101 | })
102 | }
103 |
--------------------------------------------------------------------------------
/values/call_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "errors"
5 | "reflect"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestCall(t *testing.T) {
13 | fn := func(a, b string) string {
14 | return a + "," + b + "."
15 | }
16 | value, err := Call(reflect.ValueOf(fn), []any{5, 10})
17 | require.NoError(t, err)
18 | require.Equal(t, "5,10.", value)
19 |
20 | // extra arguments (variadic)
21 | fnVaridic := func(a string, b ...string) string {
22 | return a + "," + strings.Join(b, ",") + "."
23 | }
24 | value, err = Call(reflect.ValueOf(fnVaridic), []any{5, 10})
25 | require.NoError(t, err)
26 | require.Equal(t, "5,10.", value)
27 | value, err = Call(reflect.ValueOf(fnVaridic), []any{5, 10, 15, 20})
28 | require.NoError(t, err)
29 | require.Equal(t, "5,10,15,20.", value)
30 |
31 | // extra arguments (non variadic)
32 | _, err = Call(reflect.ValueOf(fn), []any{5, 10, 20})
33 | require.Error(t, err)
34 | require.Contains(t, err.Error(), "wrong number of arguments")
35 | require.Contains(t, err.Error(), "given 3")
36 | require.Contains(t, err.Error(), "expected 2")
37 |
38 | // error return
39 | fn2 := func(int) (int, error) { return 0, errors.New("expected error") }
40 | _, err = Call(reflect.ValueOf(fn2), []any{2})
41 | require.Error(t, err)
42 | require.Contains(t, err.Error(), "expected error")
43 | }
44 |
45 | func TestCall_optional(t *testing.T) {
46 | fn := func(a string, b func(string) string) string {
47 | return a + "," + b("default") + "."
48 | }
49 | value, err := Call(reflect.ValueOf(fn), []any{5})
50 | require.NoError(t, err)
51 | require.Equal(t, "5,default.", value)
52 |
53 | value, err = Call(reflect.ValueOf(fn), []any{5, 10})
54 | require.NoError(t, err)
55 | require.Equal(t, "5,10.", value)
56 | }
57 |
58 | func TestCall_variadic(t *testing.T) {
59 | fn := func(sep func(string) string, args ...string) string {
60 | return "[" + strings.Join(args, sep(",")) + "]"
61 | }
62 |
63 | value, err := Call(reflect.ValueOf(fn), []any{",", "a"})
64 | require.NoError(t, err)
65 | require.Equal(t, "[a]", value)
66 |
67 | value, err = Call(reflect.ValueOf(fn), []any{",", "a", "b"})
68 | require.NoError(t, err)
69 | require.Equal(t, "[a,b]", value)
70 |
71 | value, err = Call(reflect.ValueOf(fn), []any{","})
72 | require.NoError(t, err)
73 | require.Equal(t, "[]", value)
74 |
75 | value, err = Call(reflect.ValueOf(fn), []any{})
76 | require.NoError(t, err)
77 | require.Equal(t, "[]", value)
78 | }
79 |
--------------------------------------------------------------------------------
/values/compare.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "reflect"
5 | )
6 |
7 | var (
8 | int64Type = reflect.TypeOf(int64(0))
9 | float64Type = reflect.TypeOf(float64(0))
10 | )
11 |
12 | // Equal returns a bool indicating whether a == b after conversion.
13 | func Equal(a, b any) bool { //nolint: gocyclo
14 | a, b = ToLiquid(a), ToLiquid(b)
15 | if a == nil || b == nil {
16 | return a == b
17 | }
18 | ra, rb := reflect.ValueOf(a), reflect.ValueOf(b)
19 | switch joinKind(ra.Kind(), rb.Kind()) {
20 | case reflect.Array, reflect.Slice:
21 | if ra.Len() != rb.Len() {
22 | return false
23 | }
24 | for i := range ra.Len() {
25 | if !Equal(ra.Index(i).Interface(), rb.Index(i).Interface()) {
26 | return false
27 | }
28 | }
29 | return true
30 | case reflect.Bool:
31 | return ra.Bool() == rb.Bool()
32 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
33 | return ra.Convert(int64Type).Int() == rb.Convert(int64Type).Int()
34 | case reflect.Float32, reflect.Float64:
35 | return ra.Convert(float64Type).Float() == rb.Convert(float64Type).Float()
36 | case reflect.String:
37 | return ra.String() == rb.String()
38 | case reflect.Ptr:
39 | if rb.Kind() == reflect.Ptr && (ra.IsNil() || rb.IsNil()) {
40 | return ra.IsNil() == rb.IsNil()
41 | }
42 | return a == b
43 | default:
44 | return a == b
45 | }
46 | }
47 |
48 | // Less returns a bool indicating whether a < b.
49 | func Less(a, b any) bool {
50 | a, b = ToLiquid(a), ToLiquid(b)
51 | if a == nil || b == nil {
52 | return false
53 | }
54 | ra, rb := reflect.ValueOf(a), reflect.ValueOf(b)
55 | switch joinKind(ra.Kind(), rb.Kind()) {
56 | case reflect.Bool:
57 | return !ra.Bool() && rb.Bool()
58 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
59 | return ra.Convert(int64Type).Int() < rb.Convert(int64Type).Int()
60 | case reflect.Float32, reflect.Float64:
61 | return ra.Convert(float64Type).Float() < rb.Convert(float64Type).Float()
62 | case reflect.String:
63 | return ra.String() < rb.String()
64 | default:
65 | return false
66 | }
67 | }
68 |
69 | func joinKind(a, b reflect.Kind) reflect.Kind { //nolint: gocyclo
70 | if a == b {
71 | return a
72 | }
73 | switch a {
74 | case reflect.Array, reflect.Slice:
75 | if b == reflect.Array || b == reflect.Slice {
76 | return reflect.Slice
77 | }
78 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
79 | if isIntKind(b) {
80 | return reflect.Int64
81 | }
82 | if isFloatKind(b) {
83 | return reflect.Float64
84 | }
85 | case reflect.Float32, reflect.Float64:
86 | if isIntKind(b) || isFloatKind(b) {
87 | return reflect.Float64
88 | }
89 | }
90 | return reflect.Invalid
91 | }
92 |
93 | func isIntKind(k reflect.Kind) bool {
94 | switch k {
95 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
96 | return true
97 | default:
98 | return false
99 | }
100 | }
101 |
102 | func isFloatKind(k reflect.Kind) bool {
103 | switch k {
104 | case reflect.Float32, reflect.Float64:
105 | return true
106 | default:
107 | return false
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/values/compare_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | var (
11 | eqTestObj = struct{ a, b int }{1, 2}
12 | eqArrayTestObj = [2]int{1, 2}
13 | )
14 |
15 | var eqTests = []struct {
16 | a, b any
17 | expected bool
18 | }{
19 | {nil, nil, true},
20 | {nil, 1, false},
21 | {1, nil, false},
22 | {false, false, true},
23 | {false, true, false},
24 | {0, 1, false},
25 | {1, 1, true},
26 | {1.0, 1.0, true},
27 | {1, 1.0, true},
28 | {1, 2.0, false},
29 | {1.0, 1, true},
30 | {"a", "b", false},
31 | {"a", "a", true},
32 | {int8(2), int16(2), true}, // TODO
33 | // {uint8(2), int8(2), true}, // FIXME
34 | {eqArrayTestObj, eqArrayTestObj[:], true},
35 | {[]string{"a"}, []string{"a"}, true},
36 | {[]string{"a"}, []string{"a", "b"}, false},
37 | {[]string{"a", "b"}, []string{"a"}, false},
38 | {[]string{"a", "b"}, []string{"a", "b"}, true},
39 | {[]string{"a", "b"}, []string{"a", "c"}, false},
40 | {[]any{1.0, 2}, []any{1, 2.0}, true},
41 | {eqTestObj, eqTestObj, true},
42 | }
43 |
44 | func TestEqual(t *testing.T) {
45 | for i, test := range eqTests {
46 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
47 | value := Equal(test.a, test.b)
48 | require.Equalf(t, test.expected, value, "%#v == %#v", test.a, test.b)
49 | })
50 | }
51 | }
52 |
53 | func TestEqual_ptr(t *testing.T) {
54 | var (
55 | n int
56 | f float64
57 | pn *int
58 | pf *float64
59 | s struct{}
60 | )
61 | require.True(t, Equal(&s, &s))
62 | require.True(t, Equal(&n, &n))
63 | require.False(t, Equal(&n, &f))
64 |
65 | // // null pointers
66 | require.True(t, Equal(pn, pn))
67 | require.False(t, Equal(pn, &n))
68 | // null pointers should compare equal, even if they're different types
69 | require.True(t, Equal(pn, pf))
70 | // require.True(t, Equal(pn, nil)) // TODO
71 | // require.True(t, Equal(nil, pn)) // TODO
72 | }
73 |
--------------------------------------------------------------------------------
/values/convert_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "sort"
7 | "testing"
8 | "time"
9 |
10 | yaml "gopkg.in/yaml.v2"
11 |
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | type redConvertible struct{}
16 |
17 | func (c redConvertible) ToLiquid() any {
18 | return "red"
19 | }
20 |
21 | var convertTests = []struct {
22 | value, expected any
23 | }{
24 | {nil, false},
25 | {false, 0},
26 | {false, int(0)},
27 | {false, int8(0)},
28 | {false, int16(0)},
29 | {false, int32(0)},
30 | {false, int64(0)},
31 | {false, uint(0)},
32 | {false, uint8(0)},
33 | {false, uint16(0)},
34 | {false, uint32(0)},
35 | {false, uint64(0)},
36 | {true, 1},
37 | {true, int(1)},
38 | {true, int8(1)},
39 | {true, int16(1)},
40 | {true, int32(1)},
41 | {true, int64(1)},
42 | {true, uint(1)},
43 | {true, uint8(1)},
44 | {true, uint16(1)},
45 | {true, uint32(1)},
46 | {true, uint64(1)},
47 | {false, false},
48 | {true, true},
49 | {true, "true"},
50 | {false, "false"},
51 | {0, true},
52 | {2, 2},
53 | {2, "2"},
54 | {2, 2.0},
55 | {"", true},
56 | {"2", int(2)},
57 | {"2", int8(2)},
58 | {"2", int16(2)},
59 | {"2", int32(2)},
60 | {"2", int64(2)},
61 | {"2", uint(2)},
62 | {"2", uint8(2)},
63 | {"2", uint16(2)},
64 | {"2", uint32(2)},
65 | {"2", uint64(2)},
66 | {"2", 2},
67 | {"2", 2.0},
68 | {"2.0", 2.0},
69 | {"2.1", 2.1},
70 | {"2.1", float32(2.1)},
71 | {"2.1", float64(2.1)},
72 | {"string", "string"},
73 | {[]any{1, 2}, []any{1, 2}},
74 | {[]int{1, 2}, []int{1, 2}},
75 | {[]int{1, 2}, []any{1, 2}},
76 | {[]any{1, 2}, []int{1, 2}},
77 | {[]int{1, 2}, []string{"1", "2"}},
78 | {yaml.MapSlice{{Key: 1, Value: 1}}, []any{1}},
79 | {yaml.MapSlice{{Key: 1, Value: 1}}, []string{"1"}},
80 | {yaml.MapSlice{{Key: 1, Value: "a"}}, []string{"a"}},
81 | {yaml.MapSlice{{Key: 1, Value: "a"}}, map[any]any{1: "a"}},
82 | {yaml.MapSlice{{Key: 1, Value: "a"}}, map[int]string{1: "a"}},
83 | {yaml.MapSlice{{Key: 1, Value: "a"}}, map[string]string{"1": "a"}},
84 | {yaml.MapSlice{{Key: "a", Value: 1}}, map[string]string{"a": "1"}},
85 | {yaml.MapSlice{{Key: "a", Value: nil}}, map[string]any{"a": nil}},
86 | {yaml.MapSlice{{Key: nil, Value: 1}}, map[any]string{nil: "1"}},
87 | {Range{1, 5}, []any{1, 2, 3, 4, 5}},
88 | {Range{0, 0}, []any{0}},
89 | // {"March 14, 2016", time.Now(), timeMustParse("2016-03-14T00:00:00Z")},
90 | {redConvertible{}, "red"},
91 | }
92 |
93 | var convertErrorTests = []struct {
94 | value, proto any
95 | expected []string
96 | }{
97 | {map[string]bool{"k": true}, map[int]bool{}, []string{"map key"}},
98 | {map[string]string{"k": "v"}, map[string]int{}, []string{"map element"}},
99 | {map[any]any{"k": "v"}, map[string]int{}, []string{"map element"}},
100 | {"notanumber", int(0), []string{"can't convert string", "to type int"}},
101 | {"notanumber", uint(0), []string{"can't convert string", "to type uint"}},
102 | {"notanumber", float64(0), []string{"can't convert string", "to type float64"}},
103 | }
104 |
105 | func TestConvert(t *testing.T) {
106 | for i, test := range convertTests {
107 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
108 | typ := reflect.TypeOf(test.expected)
109 | name := fmt.Sprintf("Convert %#v -> %v", test.value, typ)
110 | value, err := Convert(test.value, typ)
111 | require.NoErrorf(t, err, name)
112 | require.Equalf(t, test.expected, value, name)
113 | })
114 | }
115 | }
116 |
117 | func TestConvert_errors(t *testing.T) {
118 | for i, test := range convertErrorTests {
119 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
120 | typ := reflect.TypeOf(test.proto)
121 | name := fmt.Sprintf("Convert %#v -> %v", test.value, typ)
122 | _, err := Convert(test.value, typ)
123 | require.Errorf(t, err, name)
124 | for _, expected := range test.expected {
125 | require.Containsf(t, err.Error(), expected, name)
126 | }
127 | })
128 | }
129 | }
130 |
131 | func TestConvert_map(t *testing.T) {
132 | typ := reflect.TypeOf(map[string]string{})
133 | v, err := Convert(map[any]any{"key": "value"}, typ)
134 | require.NoError(t, err)
135 | m, ok := v.(map[string]string)
136 | require.True(t, ok)
137 | require.Equal(t, "value", m["key"])
138 | }
139 |
140 | func TestConvert_map_synonym(t *testing.T) {
141 | type VariableMap map[any]any
142 | typ := reflect.TypeOf(map[string]string{})
143 | v, err := Convert(VariableMap{"key": "value"}, typ)
144 | require.NoError(t, err)
145 | m, ok := v.(map[string]string)
146 | require.True(t, ok)
147 | require.Equal(t, "value", m["key"])
148 | }
149 |
150 | func TestConvert_map_to_array(t *testing.T) {
151 | typ := reflect.TypeOf([]string{})
152 | v, err := Convert(map[int]string{1: "b", 2: "a"}, typ)
153 | require.NoError(t, err)
154 | array, ok := v.([]string)
155 | require.True(t, ok)
156 | sort.Strings(array)
157 | require.Equal(t, []string{"a", "b"}, array)
158 | }
159 |
160 | // func TestConvert_ptr(t *testing.T) {
161 | // typ := reflect.PtrTo(reflect.TypeOf(""))
162 | // v, err := Convert("a", typ)
163 | // require.NoError(t, err)
164 | // ptr, ok := v.(*string)
165 | // fmt.Printf("%#v %T\n", v, v)
166 | // require.True(t, ok)
167 | // require.NotNil(t, ptr)
168 | // require.Equal(t, "ab", *ptr)
169 | // }
170 |
171 | func TestMustConvert(t *testing.T) {
172 | typ := reflect.TypeOf("")
173 | v := MustConvert(2, typ)
174 | require.Equal(t, "2", v)
175 |
176 | typ = reflect.TypeOf(2)
177 | require.Panics(t, func() { MustConvert("x", typ) })
178 | }
179 |
180 | func TestMustConvertItem(t *testing.T) {
181 | v := MustConvertItem(2, []string{})
182 | require.Equal(t, "2", v)
183 |
184 | require.Panics(t, func() { MustConvertItem("x", []int{}) })
185 | }
186 |
187 | func timeMustParse(s string) time.Time {
188 | t, err := time.Parse(time.RFC3339, s)
189 | if err != nil {
190 | panic(err)
191 | }
192 | return t
193 | }
194 |
--------------------------------------------------------------------------------
/values/docs.go:
--------------------------------------------------------------------------------
1 | // Package values is an internal package that defines methods such as sorting, comparison, and type conversion, that apply to interface types.
2 | //
3 | // It is similar to, and makes heavy use of, the reflect package.
4 | //
5 | // Since the intent is to provide runtime services for the Liquid expression interpreter,
6 | // this package does not implement "generic" generics.
7 | // It attempts to implement Liquid semantics (which are largely Ruby semantics).
8 | package values
9 |
--------------------------------------------------------------------------------
/values/drop.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | type drop interface {
8 | ToLiquid() any
9 | }
10 |
11 | // ToLiquid converts an object to Liquid, if it implements the Drop interface.
12 | func ToLiquid(value any) any {
13 | switch value := value.(type) {
14 | case drop:
15 | return value.ToLiquid()
16 | default:
17 | return value
18 | }
19 | }
20 |
21 | type dropWrapper struct {
22 | d drop
23 | v Value
24 | sync.Once
25 | }
26 |
27 | func (w *dropWrapper) Resolve() Value {
28 | w.Do(func() { w.v = ValueOf(w.d.ToLiquid()) })
29 | return w.v
30 | }
31 |
32 | func (w *dropWrapper) Equal(o Value) bool { return w.Resolve().Equal(o) }
33 | func (w *dropWrapper) Less(o Value) bool { return w.Resolve().Less(o) }
34 | func (w *dropWrapper) IndexValue(i Value) Value { return w.Resolve().IndexValue(i) }
35 | func (w *dropWrapper) Contains(o Value) bool { return w.Resolve().Contains(o) }
36 | func (w *dropWrapper) Int() int { return w.Resolve().Int() }
37 | func (w *dropWrapper) Interface() any { return w.Resolve().Interface() }
38 | func (w *dropWrapper) PropertyValue(k Value) Value { return w.Resolve().PropertyValue(k) }
39 | func (w *dropWrapper) Test() bool { return w.Resolve().Test() }
40 |
--------------------------------------------------------------------------------
/values/drop_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | type testDrop struct{ proxy any }
10 |
11 | func (d testDrop) ToLiquid() any { return d.proxy }
12 |
13 | func TestToLiquid(t *testing.T) {
14 | require.Equal(t, 2, ToLiquid(2))
15 | require.Equal(t, 3, ToLiquid(testDrop{3}))
16 | }
17 |
18 | func TestValue_drop(t *testing.T) {
19 | dv := ValueOf(testDrop{"seafood"})
20 | require.Equal(t, "seafood", dv.Interface())
21 | require.True(t, dv.Contains(ValueOf("foo")))
22 | require.True(t, dv.Contains(ValueOf(testDrop{"foo"})))
23 | require.Equal(t, 7, dv.PropertyValue(ValueOf("size")).Interface())
24 | }
25 |
26 | func TestDrop_Resolve_race(t *testing.T) {
27 | d := ValueOf(testDrop{1})
28 | values := make(chan int, 2)
29 | for range 2 {
30 | go func() { values <- d.Int() }()
31 | }
32 | for range 2 {
33 | require.Equal(t, 1, <-values)
34 | }
35 | }
36 |
37 | func BenchmarkDrop_Resolve_1(b *testing.B) {
38 | d := ValueOf(testDrop{1})
39 |
40 | for range b.N {
41 | _ = d.Int()
42 | }
43 | }
44 |
45 | func BenchmarkDrop_Resolve_2(b *testing.B) {
46 | for range b.N {
47 | d := ValueOf(testDrop{1})
48 | _ = d.Int()
49 | }
50 | }
51 |
52 | func BenchmarkDrop_Resolve_3(b *testing.B) {
53 | for range b.N {
54 | d := ValueOf(testDrop{1})
55 | values := make(chan int, 10)
56 | for i := cap(values); i > 0; i-- {
57 | values <- d.Int()
58 | }
59 | for i := cap(values); i > 0; i-- {
60 | //lint:ignore S1005 TODO look up how else to read the values
61 | _ = <-values
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/values/evaluator_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | var lessTests = []struct {
11 | a, b any
12 | expected bool
13 | }{
14 | {nil, nil, false},
15 | {false, true, true},
16 | {false, false, false},
17 | {false, nil, false},
18 | {nil, false, false},
19 | {0, 1, true},
20 | {1, 0, false},
21 | {1, 1, false},
22 | {1, 2.1, true},
23 | {1.1, 2, true},
24 | {2.1, 1, false},
25 | {"a", "b", true},
26 | {"b", "a", false},
27 | {[]string{"a"}, []string{"a"}, false},
28 | }
29 |
30 | func TestLess(t *testing.T) {
31 | for i, test := range lessTests {
32 | t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
33 | value := Less(test.a, test.b)
34 | require.Equalf(t, test.expected, value, "%#v < %#v", test.a, test.b)
35 | })
36 | }
37 | }
38 |
39 | func TestLength(t *testing.T) {
40 | require.Equal(t, 3, Length([]int{1, 2, 3}))
41 | require.Equal(t, 3, Length("abc"))
42 | require.Equal(t, 0, Length(map[string]int{"a": 1}))
43 | }
44 |
45 | func TestSort(t *testing.T) {
46 | array := []any{2, 1}
47 | Sort(array)
48 | require.Equal(t, []any{1, 2}, array)
49 |
50 | array = []any{"b", "a"}
51 | Sort(array)
52 | require.Equal(t, []any{"a", "b"}, array)
53 |
54 | array = []any{
55 | map[string]any{"key": 20},
56 | map[string]any{"key": 10},
57 | map[string]any{},
58 | }
59 | SortByProperty(array, "key", true)
60 | require.Nil(t, array[0].(map[string]any)["key"])
61 | require.Equal(t, 10, array[1].(map[string]any)["key"])
62 | require.Equal(t, 20, array[2].(map[string]any)["key"])
63 | }
64 |
--------------------------------------------------------------------------------
/values/mapslicevalue.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | yaml "gopkg.in/yaml.v2"
5 | )
6 |
7 | type mapSliceValue struct {
8 | slice yaml.MapSlice
9 | valueEmbed
10 | }
11 |
12 | // func (v mapSliceValue) Equal(o Value) bool { return v.slice == o.Interface() }
13 | func (v mapSliceValue) Interface() any { return v.slice }
14 |
15 | func (v mapSliceValue) Contains(elem Value) bool {
16 | e := elem.Interface()
17 | for _, item := range v.slice {
18 | if e == item.Key {
19 | return true
20 | }
21 | }
22 | return false
23 | }
24 |
25 | func (v mapSliceValue) IndexValue(index Value) Value {
26 | e := index.Interface()
27 | for _, item := range v.slice {
28 | if e == item.Key {
29 | return ValueOf(item.Value)
30 | }
31 | }
32 | return nilValue
33 | }
34 |
35 | func (v mapSliceValue) PropertyValue(index Value) Value {
36 | result := v.IndexValue(index)
37 | if result == nilValue && index.Interface() == sizeKey {
38 | result = ValueOf(len(v.slice))
39 | }
40 | return result
41 | }
42 |
--------------------------------------------------------------------------------
/values/parsedate.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "reflect"
5 | "time"
6 | )
7 |
8 | var zeroTime time.Time
9 |
10 | var dateLayouts = []string{
11 | // from the Go library
12 | time.ANSIC, // "Mon Jan _2 15:04:05 2006"
13 | time.UnixDate, // "Mon Jan _2 15:04:05 MST 2006"
14 | time.RubyDate, // "Mon Jan 02 15:04:05 -0700 2006"
15 | time.RFC822, // "02 Jan 06 15:04 MST"
16 | time.RFC822Z, // "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
17 | time.RFC850, // "Monday, 02-Jan-06 15:04:05 MST"
18 | time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
19 | time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
20 | time.RFC3339, // "2006-01-02T15:04:05Z07:00"
21 |
22 | // ISO 8601
23 | "2006-01-02T15:04:05-07:00", // this is also XML Schema
24 | "2006-01-02T15:04:05Z",
25 | "2006-01-02",
26 | "20060102T150405Z",
27 |
28 | // from Ruby's Time.parse docs
29 | "Mon, 02 Jan 2006 15:04:05 -0700", // "RFC822" -- but not really
30 |
31 | // From Jekyll docs
32 | "02 January 2006", // Jekyll long string
33 | "02 Jan 2006", // Jekyll short string
34 |
35 | // observed in the wild; plus some variants
36 | "2006-01-02 15:04:05 -07:00",
37 | "2006-01-02 15:04:05 -0700",
38 | "2006-01-02 15:04:05 MST",
39 | "2006-01-02 15:04:05",
40 | "2006-01-02 15:04",
41 | "January 2, 2006",
42 | "January 2 2006",
43 | "Jan 2, 2006",
44 | "Jan 2 2006",
45 | }
46 |
47 | // ParseDate tries a few heuristics to parse a date from a string
48 | func ParseDate(s string) (time.Time, error) {
49 | if s == "now" {
50 | return time.Now(), nil
51 | }
52 | for _, layout := range dateLayouts {
53 | t, err := time.ParseInLocation(layout, s, time.Local)
54 | if err == nil {
55 | return t, nil
56 | }
57 | }
58 | return zeroTime, conversionError("", s, reflect.TypeOf(zeroTime))
59 | }
60 |
--------------------------------------------------------------------------------
/values/parsedate_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestConstant(t *testing.T) {
10 | dt, err := ParseDate("now")
11 | require.NoError(t, err)
12 | require.True(t, dt.After(timeMustParse("1970-01-01T00:00:00Z")))
13 |
14 | dt, err = ParseDate("2017-07-09 10:40:00 UTC")
15 | require.NoError(t, err)
16 | require.Equal(t, timeMustParse("2017-07-09T10:40:00Z"), dt)
17 | }
18 |
--------------------------------------------------------------------------------
/values/predicates.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "reflect"
5 | )
6 |
7 | // IsEmpty returns a bool indicating whether the value is empty according to Liquid semantics.
8 | func IsEmpty(value any) bool {
9 | value = ToLiquid(value)
10 | if value == nil {
11 | return false
12 | }
13 | r := reflect.ValueOf(value)
14 | switch r.Kind() {
15 | case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
16 | return r.Len() == 0
17 | case reflect.Bool:
18 | return !r.Bool()
19 | default:
20 | return false
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/values/predicates_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestIsEmpty(t *testing.T) {
10 | require.True(t, IsEmpty(false))
11 | require.False(t, IsEmpty(true))
12 | require.True(t, IsEmpty([]string{}))
13 | require.True(t, IsEmpty(map[string]any{}))
14 | require.False(t, IsEmpty([]string{""}))
15 | require.False(t, IsEmpty(map[string]any{"k": "v"}))
16 | }
17 |
--------------------------------------------------------------------------------
/values/range.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | // A Range is the range of integers from b to e inclusive.
4 | type Range struct {
5 | b, e int
6 | }
7 |
8 | // NewRange returns a new Range
9 | func NewRange(b, e int) Range {
10 | return Range{b, e}
11 | }
12 |
13 | // Len is in the iteration interface
14 | func (r Range) Len() int { return r.e + 1 - r.b }
15 |
16 | // Index is in the iteration interface
17 | func (r Range) Index(i int) any { return r.b + i }
18 |
19 | // AsArray converts the range into an array.
20 | func (r Range) AsArray() []any {
21 | a := make([]any, 0, r.Len())
22 | for i := r.b; i <= r.e; i++ {
23 | a = append(a, i)
24 | }
25 | return a
26 | }
27 |
--------------------------------------------------------------------------------
/values/sort.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "reflect"
5 | "sort"
6 | )
7 |
8 | // Sort any []any value.
9 | func Sort(data []any) {
10 | sort.Sort(genericSortable(data))
11 | }
12 |
13 | type genericSortable []any
14 |
15 | // Len is part of sort.Interface.
16 | func (s genericSortable) Len() int {
17 | return len(s)
18 | }
19 |
20 | // Swap is part of sort.Interface.
21 | func (s genericSortable) Swap(i, j int) {
22 | s[i], s[j] = s[j], s[i]
23 | }
24 |
25 | // Less is part of sort.Interface.
26 | func (s genericSortable) Less(i, j int) bool {
27 | return Less(s[i], s[j])
28 | }
29 |
30 | // SortByProperty sorts maps on their key indices.
31 | func SortByProperty(data []any, key string, nilFirst bool) {
32 | sort.Sort(sortableByProperty{data, key, nilFirst})
33 | }
34 |
35 | type sortableByProperty struct {
36 | data []any
37 | key string
38 | nilFirst bool
39 | }
40 |
41 | // Len is part of sort.Interface.
42 | func (s sortableByProperty) Len() int {
43 | return len(s.data)
44 | }
45 |
46 | // Swap is part of sort.Interface.
47 | func (s sortableByProperty) Swap(i, j int) {
48 | data := s.data
49 | data[i], data[j] = data[j], data[i]
50 | }
51 |
52 | // Less is part of sort.Interface.
53 | func (s sortableByProperty) Less(i, j int) bool {
54 | // index returns the value at s.key, if in is a map that contains this key
55 | index := func(i int) any {
56 | value := ToLiquid(s.data[i])
57 | rt := reflect.ValueOf(value)
58 | if rt.Kind() == reflect.Map && rt.Type().Key().Kind() == reflect.String {
59 | elem := rt.MapIndex(reflect.ValueOf(s.key))
60 | if elem.IsValid() {
61 | return elem.Interface()
62 | }
63 | }
64 | return nil
65 | }
66 | a, b := index(i), index(j)
67 | switch {
68 | case a == nil && b == nil:
69 | return false
70 | case a == nil:
71 | return s.nilFirst
72 | case b == nil:
73 | return !s.nilFirst
74 | }
75 | return Less(a, b)
76 | }
77 |
--------------------------------------------------------------------------------
/values/structvalue.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "reflect"
5 | )
6 |
7 | type structValue struct{ wrapperValue }
8 |
9 | func (sv structValue) IndexValue(index Value) Value {
10 | return sv.PropertyValue(index)
11 | }
12 |
13 | func (sv structValue) Contains(elem Value) bool {
14 | name, ok := elem.Interface().(string)
15 | if !ok {
16 | return false
17 | }
18 | st := reflect.TypeOf(sv.value)
19 | if st.Kind() == reflect.Ptr {
20 | if _, found := st.MethodByName(name); found {
21 | return true
22 | }
23 | st = st.Elem()
24 | }
25 | if _, found := st.MethodByName(name); found {
26 | return true
27 | }
28 | if _, found := sv.findField(name); found {
29 | return true
30 | }
31 | return false
32 | }
33 |
34 | func (sv structValue) PropertyValue(index Value) Value {
35 | name, ok := index.Interface().(string)
36 | if !ok {
37 | return nilValue
38 | }
39 | sr := reflect.ValueOf(sv.value)
40 | st := reflect.TypeOf(sv.value)
41 | if st.Kind() == reflect.Ptr {
42 | if _, found := st.MethodByName(name); found {
43 | m := sr.MethodByName(name)
44 | return sv.invoke(m)
45 | }
46 | st = st.Elem()
47 | sr = sr.Elem()
48 | if !sr.IsValid() {
49 | return nilValue
50 | }
51 | }
52 | if _, ok := st.MethodByName(name); ok {
53 | m := sr.MethodByName(name)
54 | return sv.invoke(m)
55 | }
56 | if field, ok := sv.findField(name); ok {
57 | fv := sr.FieldByName(field.Name)
58 | if fv.Kind() == reflect.Func {
59 | return sv.invoke(fv)
60 | }
61 | return ValueOf(fv.Interface())
62 | }
63 | return nilValue
64 | }
65 |
66 | const tagKey = "liquid"
67 |
68 | // like FieldByName, but obeys `liquid:"name"` tags
69 | func (sv structValue) findField(name string) (*reflect.StructField, bool) {
70 | sr := reflect.TypeOf(sv.value)
71 | if sr.Kind() == reflect.Ptr {
72 | sr = sr.Elem()
73 | }
74 | if field, ok := sr.FieldByName(name); ok {
75 | if _, ok := field.Tag.Lookup(tagKey); !ok {
76 | return &field, true
77 | }
78 | }
79 | for i, n := 0, sr.NumField(); i < n; i++ {
80 | field := sr.Field(i)
81 | if field.Tag.Get(tagKey) == name {
82 | return &field, true
83 | }
84 | }
85 | return nil, false
86 | }
87 |
88 | func (sv structValue) invoke(fv reflect.Value) Value {
89 | if fv.IsNil() {
90 | return nilValue
91 | }
92 | mt := fv.Type()
93 | if mt.NumIn() > 0 || mt.NumOut() > 2 {
94 | return nilValue
95 | }
96 | results := fv.Call([]reflect.Value{})
97 | if len(results) > 1 && !results[1].IsNil() {
98 | panic(results[1].Interface())
99 | }
100 | return ValueOf(results[0].Interface())
101 | }
102 |
--------------------------------------------------------------------------------
/values/structvalue_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | //nolint:recvcheck
11 | type testValueStruct struct {
12 | F int
13 | Nest *testValueStruct
14 | Renamed int `liquid:"name"`
15 | Omitted int `liquid:"-"`
16 | F1 func() int
17 | F2 func() (int, error)
18 | F2e func() (int, error)
19 | }
20 |
21 | func (tv testValueStruct) M1() int { return 3 }
22 | func (tv testValueStruct) M2() (int, error) { return 4, nil }
23 | func (tv testValueStruct) M2e() (int, error) { return 4, errors.New("expected error") }
24 |
25 | func (tv *testValueStruct) PM1() int { return 3 }
26 | func (tv *testValueStruct) PM2() (int, error) { return 4, nil }
27 | func (tv *testValueStruct) PM2e() (int, error) { return 4, errors.New("expected error") }
28 |
29 | func TestValue_struct(t *testing.T) {
30 | s := ValueOf(testValueStruct{
31 | F: -1,
32 | Nest: &testValueStruct{F: -2},
33 | Renamed: 100,
34 | Omitted: 200,
35 | F1: func() int { return 1 },
36 | F2: func() (int, error) { return 2, nil },
37 | F2e: func() (int, error) { return 0, errors.New("expected error") },
38 | })
39 |
40 | // fields
41 | require.True(t, s.Contains(ValueOf("F")))
42 | require.True(t, s.Contains(ValueOf("F1")))
43 | require.Equal(t, -1, s.PropertyValue(ValueOf("F")).Interface())
44 |
45 | // Nesting
46 | require.Equal(t, -2, s.PropertyValue(ValueOf("Nest")).PropertyValue(ValueOf("F")).Interface())
47 | require.Nil(t, s.PropertyValue(ValueOf("Nest")).PropertyValue(ValueOf("Nest")).PropertyValue(ValueOf("F")).Interface())
48 |
49 | // field tags
50 | require.False(t, s.Contains(ValueOf("Renamed")))
51 | require.False(t, s.Contains(ValueOf("Omitted")))
52 | require.True(t, s.Contains(ValueOf("name")))
53 | require.Nil(t, s.PropertyValue(ValueOf("Renamed")).Interface())
54 | require.Nil(t, s.PropertyValue(ValueOf("Omitted")).Interface())
55 | require.Equal(t, 100, s.PropertyValue(ValueOf("name")).Interface())
56 |
57 | // func fields
58 | require.Equal(t, 1, s.PropertyValue(ValueOf("F1")).Interface())
59 | require.Equal(t, 2, s.PropertyValue(ValueOf("F2")).Interface())
60 | require.Panics(t, func() { s.PropertyValue(ValueOf("F2e")) })
61 |
62 | // methods
63 | require.Equal(t, 3, s.PropertyValue(ValueOf("M1")).Interface())
64 | require.Equal(t, 4, s.PropertyValue(ValueOf("M2")).Interface())
65 | require.Panics(t, func() { s.PropertyValue(ValueOf("M2e")) })
66 | require.Equal(t, -1, s.IndexValue(ValueOf("F")).Interface())
67 | }
68 |
69 | func TestValue_struct_ptr(t *testing.T) {
70 | p := ValueOf(&testValueStruct{
71 | F: -1,
72 | F1: func() int { return 1 },
73 | })
74 |
75 | // fields
76 | require.True(t, p.Contains(ValueOf("F")))
77 | require.True(t, p.Contains(ValueOf("F1")))
78 | require.Equal(t, -1, p.PropertyValue(ValueOf("F")).Interface())
79 |
80 | // func fields
81 | require.Equal(t, 1, p.PropertyValue(ValueOf("F1")).Interface())
82 |
83 | // members
84 | require.Equal(t, 3, p.PropertyValue(ValueOf("M1")).Interface())
85 | require.Equal(t, 4, p.PropertyValue(ValueOf("M2")).Interface())
86 | require.Panics(t, func() { p.PropertyValue(ValueOf("M2e")) })
87 |
88 | // pointer members
89 | require.Equal(t, 3, p.PropertyValue(ValueOf("PM1")).Interface())
90 | require.Equal(t, 4, p.PropertyValue(ValueOf("PM2")).Interface())
91 | require.Panics(t, func() { p.PropertyValue(ValueOf("PM2e")) })
92 | }
93 |
--------------------------------------------------------------------------------
/values/value.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "strings"
7 |
8 | yaml "gopkg.in/yaml.v2"
9 | )
10 |
11 | // A Value is a Liquid runtime value.
12 | type Value interface {
13 | // Value retrieval
14 | Interface() any
15 | Int() int
16 |
17 | // Comparison
18 | Equal(Value) bool
19 | Less(Value) bool
20 |
21 | Contains(Value) bool
22 | IndexValue(Value) Value
23 | PropertyValue(Value) Value
24 |
25 | // Predicate
26 | Test() bool
27 | }
28 |
29 | // ValueOf returns a Value that wraps its argument.
30 | // If the argument is already a Value, it returns this.
31 | func ValueOf(value any) Value { //nolint: gocyclo
32 | // interned values
33 | switch value {
34 | case nil:
35 | return nilValue
36 | case true:
37 | return trueValue
38 | case false:
39 | return falseValue
40 | case 0:
41 | return zeroValue
42 | case 1:
43 | return oneValue
44 | }
45 | // interfaces
46 | switch v := value.(type) {
47 | case drop:
48 | return &dropWrapper{d: v}
49 | case yaml.MapSlice:
50 | return mapSliceValue{slice: v}
51 | case Value:
52 | return v
53 | }
54 | switch reflect.TypeOf(value).Kind() {
55 | case reflect.Ptr:
56 | rv := reflect.ValueOf(value)
57 | if rv.IsNil() {
58 | return nilValue
59 | }
60 | if rv.Type().Elem().Kind() == reflect.Struct {
61 | return structValue{wrapperValue{value}}
62 | }
63 | return ValueOf(rv.Elem().Interface())
64 | case reflect.String:
65 | return stringValue{wrapperValue{value}}
66 | case reflect.Array, reflect.Slice:
67 | return arrayValue{wrapperValue{value}}
68 | case reflect.Map:
69 | return mapValue{wrapperValue{value}}
70 | case reflect.Struct:
71 | return structValue{wrapperValue{value}}
72 | default:
73 | return wrapperValue{value}
74 | }
75 | }
76 |
77 | const (
78 | firstKey = "first"
79 | lastKey = "last"
80 | sizeKey = "size"
81 | )
82 |
83 | // embed this in a struct to "inherit" default implementations of the Value interface
84 | type valueEmbed struct{}
85 |
86 | func (v valueEmbed) Equal(Value) bool { return false }
87 | func (v valueEmbed) Less(Value) bool { return false }
88 | func (v valueEmbed) IndexValue(Value) Value { return nilValue }
89 | func (v valueEmbed) Contains(Value) bool { return false }
90 | func (v valueEmbed) Int() int { panic(conversionError("", v, reflect.TypeOf(1))) }
91 | func (v valueEmbed) PropertyValue(Value) Value { return nilValue }
92 | func (v valueEmbed) Test() bool { return true }
93 |
94 | // A wrapperValue wraps a Go value.
95 | type wrapperValue struct{ value any }
96 |
97 | func (v wrapperValue) Equal(other Value) bool { return Equal(v.value, other.Interface()) }
98 | func (v wrapperValue) Less(other Value) bool { return Less(v.value, other.Interface()) }
99 | func (v wrapperValue) IndexValue(Value) Value { return nilValue }
100 | func (v wrapperValue) Contains(Value) bool { return false }
101 | func (v wrapperValue) Interface() any { return v.value }
102 | func (v wrapperValue) PropertyValue(Value) Value { return nilValue }
103 | func (v wrapperValue) Test() bool { return v.value != nil && v.value != false }
104 |
105 | func (v wrapperValue) Int() int {
106 | if n, ok := v.value.(int); ok {
107 | return n
108 | }
109 | panic(conversionError("", v.value, reflect.TypeOf(1)))
110 | }
111 |
112 | // interned values
113 | var (
114 | nilValue = wrapperValue{nil}
115 | falseValue = wrapperValue{false}
116 | trueValue = wrapperValue{true}
117 | zeroValue = wrapperValue{0}
118 | oneValue = wrapperValue{1}
119 | )
120 |
121 | // container values
122 | type (
123 | arrayValue struct{ wrapperValue }
124 | mapValue struct{ wrapperValue }
125 | stringValue struct{ wrapperValue }
126 | )
127 |
128 | func (av arrayValue) Contains(ev Value) bool {
129 | ar := reflect.ValueOf(av.value)
130 | e := ev.Interface()
131 | l := ar.Len()
132 | for i := range l {
133 | if Equal(ar.Index(i).Interface(), e) {
134 | return true
135 | }
136 | }
137 | return false
138 | }
139 |
140 | func (av arrayValue) IndexValue(iv Value) Value {
141 | ar := reflect.ValueOf(av.value)
142 | var n int
143 | switch ix := iv.Interface().(type) {
144 | case int:
145 | n = ix
146 | case float32:
147 | // Ruby array indexing truncates floats
148 | n = int(ix)
149 | case float64:
150 | n = int(ix)
151 | default:
152 | return nilValue
153 | }
154 | if n < 0 {
155 | n += ar.Len()
156 | }
157 | if 0 <= n && n < ar.Len() {
158 | return ValueOf(ar.Index(n).Interface())
159 | }
160 | return nilValue
161 | }
162 |
163 | func (av arrayValue) PropertyValue(iv Value) Value {
164 | ar := reflect.ValueOf(av.value)
165 | switch iv.Interface() {
166 | case firstKey:
167 | if ar.Len() > 0 {
168 | return ValueOf(ar.Index(0).Interface())
169 | }
170 | case lastKey:
171 | if ar.Len() > 0 {
172 | return ValueOf(ar.Index(ar.Len() - 1).Interface())
173 | }
174 | case sizeKey:
175 | return ValueOf(ar.Len())
176 | }
177 | return nilValue
178 | }
179 |
180 | func (mv mapValue) Contains(iv Value) bool {
181 | mr := reflect.ValueOf(mv.value)
182 | ir := reflect.ValueOf(iv.Interface())
183 | if ir.IsValid() && mr.Type().Key() == ir.Type() {
184 | return mr.MapIndex(ir).IsValid()
185 | }
186 | return false
187 | }
188 |
189 | func (mv mapValue) IndexValue(iv Value) Value {
190 | mr := reflect.ValueOf(mv.value)
191 | ir := reflect.ValueOf(iv.Interface())
192 | kt := mr.Type().Key()
193 | if ir.IsValid() && ir.Type().ConvertibleTo(kt) && ir.Type().Comparable() {
194 | er := mr.MapIndex(ir.Convert(kt))
195 | if er.IsValid() {
196 | return ValueOf(er.Interface())
197 | }
198 | }
199 | return nilValue
200 | }
201 |
202 | func (mv mapValue) PropertyValue(iv Value) Value {
203 | mr := reflect.ValueOf(mv.Interface())
204 | ir := reflect.ValueOf(iv.Interface())
205 | if !ir.IsValid() {
206 | return nilValue
207 | }
208 | er := mr.MapIndex(ir)
209 | switch {
210 | case er.IsValid():
211 | return ValueOf(er.Interface())
212 | case iv.Interface() == sizeKey:
213 | return ValueOf(mr.Len())
214 | default:
215 | return nilValue
216 | }
217 | }
218 |
219 | func (sv stringValue) Contains(substr Value) bool {
220 | s, ok := substr.Interface().(string)
221 | if !ok {
222 | s = fmt.Sprint(substr.Interface())
223 | }
224 | return strings.Contains(sv.value.(string), s)
225 | }
226 |
227 | func (sv stringValue) PropertyValue(iv Value) Value {
228 | if iv.Interface() == sizeKey {
229 | return ValueOf(len(sv.value.(string)))
230 | }
231 | return nilValue
232 | }
233 |
--------------------------------------------------------------------------------
/values/value_test.go:
--------------------------------------------------------------------------------
1 | package values
2 |
3 | import (
4 | "testing"
5 |
6 | yaml "gopkg.in/yaml.v2"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestValue_Interface(t *testing.T) {
12 | nv := ValueOf(nil)
13 | iv := ValueOf(123)
14 | var ni *int = nil
15 | require.Nil(t, nv.Interface())
16 | require.Equal(t, true, ValueOf(true).Interface())
17 | require.Equal(t, false, ValueOf(false).Interface())
18 | require.Equal(t, 123, iv.Interface())
19 | require.Equal(t, ValueOf(ni), nilValue)
20 | }
21 |
22 | func TestValue_Equal(t *testing.T) {
23 | iv := ValueOf(123)
24 | require.True(t, iv.Equal(ValueOf(123)))
25 | require.True(t, iv.Equal(ValueOf(123.0)))
26 | }
27 |
28 | func TestValue_Less(t *testing.T) {
29 | iv := ValueOf(123)
30 | require.False(t, iv.Less(ValueOf(100)))
31 | require.True(t, iv.Less(ValueOf(200)))
32 | require.False(t, iv.Less(ValueOf(100.5)))
33 | require.True(t, iv.Less(ValueOf(200.5)))
34 |
35 | sv := ValueOf("b")
36 | require.False(t, sv.Less(ValueOf("a")))
37 | require.True(t, sv.Less(ValueOf("c")))
38 | }
39 |
40 | func TestValue_Int(t *testing.T) {
41 | nv := ValueOf(nil)
42 | iv := ValueOf(123)
43 | require.Equal(t, 123, iv.Int())
44 | require.Panics(t, func() { nv.Int() })
45 | }
46 |
47 | func TestValue_IndexValue(t *testing.T) {
48 | require.Nil(t, ValueOf(nil).PropertyValue(ValueOf("first")).Interface())
49 | require.Nil(t, ValueOf(false).PropertyValue(ValueOf("first")).Interface())
50 | require.Nil(t, ValueOf(12).PropertyValue(ValueOf("first")).Interface())
51 |
52 | // empty array
53 | empty := ValueOf([]string{})
54 | require.Nil(t, empty.IndexValue(ValueOf(0)).Interface())
55 | require.Nil(t, empty.IndexValue(ValueOf(-1)).Interface())
56 |
57 | // array
58 | lv := ValueOf([]string{"first", "second", "third"})
59 | require.Equal(t, "first", lv.IndexValue(ValueOf(0)).Interface())
60 | require.Equal(t, "third", lv.IndexValue(ValueOf(-1)).Interface())
61 | require.Equal(t, "second", lv.IndexValue(ValueOf(1.0)).Interface())
62 | require.Equal(t, "second", lv.IndexValue(ValueOf(1.1)).Interface())
63 | require.Nil(t, lv.IndexValue(ValueOf(nil)).Interface())
64 |
65 | // string map
66 | hv := ValueOf(map[string]any{"key": "value"})
67 | require.Equal(t, "value", hv.IndexValue(ValueOf("key")).Interface())
68 | require.Nil(t, hv.IndexValue(ValueOf("missing_key")).Interface())
69 | require.Nil(t, hv.IndexValue(ValueOf(nil)).Interface())
70 |
71 | // interface map
72 | hv = ValueOf(map[any]any{"key": "value"})
73 | require.Equal(t, "value", hv.IndexValue(ValueOf("key")).Interface())
74 | require.Nil(t, hv.IndexValue(ValueOf(nil)).Interface())
75 | require.Nil(t, hv.IndexValue(ValueOf([]string{})).Interface())
76 | require.Nil(t, hv.IndexValue(ValueOf(struct{}{})).Interface())
77 |
78 | // ptr to map
79 | hashPtr := ValueOf(&map[string]any{"key": "value"})
80 | require.Equal(t, "value", hashPtr.IndexValue(ValueOf("key")).Interface())
81 | require.Nil(t, hashPtr.IndexValue(ValueOf("missing_key")).Interface())
82 | require.Nil(t, hashPtr.IndexValue(ValueOf(nil)).Interface())
83 |
84 | // MapSlice
85 | msv := ValueOf(yaml.MapSlice{{Key: "key", Value: "value"}})
86 | require.Equal(t, "value", msv.IndexValue(ValueOf("key")).Interface())
87 | require.Nil(t, msv.IndexValue(ValueOf("missing_key")).Interface())
88 | require.Nil(t, msv.IndexValue(ValueOf(nil)).Interface())
89 | }
90 |
91 | func TestValue_PropertyValue(t *testing.T) {
92 | // empty array
93 | empty := ValueOf([]string{})
94 | require.Nil(t, empty.PropertyValue(ValueOf("first")).Interface())
95 | require.Nil(t, empty.PropertyValue(ValueOf("last")).Interface())
96 |
97 | // array
98 | lv := ValueOf([]string{"first", "second", "third"})
99 | require.Equal(t, "first", lv.PropertyValue(ValueOf("first")).Interface())
100 | require.Equal(t, "third", lv.PropertyValue(ValueOf("last")).Interface())
101 | require.Nil(t, lv.PropertyValue(ValueOf(nil)).Interface())
102 |
103 | // string map
104 | hv := ValueOf(map[string]any{"key": "value"})
105 | require.Equal(t, "value", hv.PropertyValue(ValueOf("key")).Interface())
106 | require.Nil(t, hv.PropertyValue(ValueOf("missing_key")).Interface())
107 | require.Nil(t, hv.PropertyValue(ValueOf(nil)).Interface())
108 |
109 | // interface map
110 | hv = ValueOf(map[any]any{"key": "value"})
111 | require.Equal(t, "value", hv.PropertyValue(ValueOf("key")).Interface())
112 |
113 | // ptr to map
114 | hashPtr := ValueOf(&map[string]any{"key": "value"})
115 | require.Equal(t, "value", hashPtr.PropertyValue(ValueOf("key")).Interface())
116 | require.Nil(t, hashPtr.PropertyValue(ValueOf("missing_key")).Interface())
117 |
118 | // MapSlice
119 | msv := ValueOf(yaml.MapSlice{{Key: "key", Value: "value"}})
120 | require.Equal(t, "value", msv.PropertyValue(ValueOf("key")).Interface())
121 | require.Nil(t, msv.PropertyValue(ValueOf("missing_key")).Interface())
122 | require.Nil(t, msv.PropertyValue(ValueOf(nil)).Interface())
123 | }
124 |
125 | func TestValue_Contains(t *testing.T) {
126 | // array
127 | require.True(t, ValueOf([]int{1, 2}).Contains(ValueOf(2)))
128 | require.False(t, ValueOf([]int{1, 2}).Contains(ValueOf(3)))
129 |
130 | av := ValueOf([]string{"first", "second", "third"})
131 | require.True(t, av.Contains(ValueOf("first")))
132 | require.False(t, av.Contains(ValueOf("missing")))
133 | require.False(t, av.Contains(ValueOf(nil)))
134 |
135 | require.True(t, ValueOf([]any{nil}).Contains(ValueOf(nil)))
136 |
137 | // string
138 | sv := ValueOf("seafood")
139 | require.True(t, sv.Contains(ValueOf("foo")))
140 | require.False(t, sv.Contains(ValueOf("bar")))
141 | require.False(t, sv.Contains(ValueOf(nil)))
142 |
143 | // string contains stringifies its argument
144 | require.True(t, ValueOf("seaf00d").Contains(ValueOf(0)))
145 |
146 | // map
147 | hv := ValueOf(map[string]any{"key": "value"})
148 | require.True(t, hv.Contains(ValueOf("key")))
149 | require.False(t, hv.Contains(ValueOf("missing_key")))
150 | require.False(t, hv.Contains(ValueOf(nil)))
151 |
152 | // MapSlice
153 | msv := ValueOf(yaml.MapSlice{{Key: "key", Value: "value"}})
154 | require.True(t, msv.Contains(ValueOf("key")))
155 | require.False(t, msv.Contains(ValueOf("missing_key")))
156 | require.False(t, msv.Contains(ValueOf(nil)))
157 | }
158 |
159 | func TestValue_PropertyValue_size(t *testing.T) {
160 | require.Nil(t, ValueOf(nil).PropertyValue(ValueOf("size")).Interface())
161 | require.Nil(t, ValueOf(false).PropertyValue(ValueOf("size")).Interface())
162 | require.Nil(t, ValueOf(12).PropertyValue(ValueOf("size")).Interface())
163 |
164 | // string
165 | require.Equal(t, 7, ValueOf("seafood").PropertyValue(ValueOf("size")).Interface())
166 |
167 | // empty list
168 | empty := ValueOf([]string{})
169 | require.Equal(t, 0, empty.PropertyValue(ValueOf("size")).Interface())
170 |
171 | // list
172 | av := ValueOf([]string{"first", "second", "third"})
173 | require.Equal(t, 3, av.PropertyValue(ValueOf("size")).Interface())
174 |
175 | // hash
176 | hv := ValueOf(map[string]any{"key": "value"})
177 | require.Equal(t, 1, hv.PropertyValue(ValueOf("size")).Interface())
178 |
179 | // hash with "size" key
180 | withSizeKey := ValueOf(map[string]any{"size": "value"})
181 | require.Equal(t, "value", withSizeKey.IndexValue(ValueOf("size")).Interface())
182 |
183 | // hash pointer
184 | hashPtr := ValueOf(&map[string]any{"key": "value"})
185 | require.Equal(t, 1, hashPtr.PropertyValue(ValueOf("size")).Interface())
186 |
187 | // MapSlice
188 | msv := ValueOf(yaml.MapSlice{{Key: "key", Value: "value"}})
189 | require.Equal(t, 1, msv.PropertyValue(ValueOf("size")).Interface())
190 |
191 | // MapSlice with "size" key
192 | msv = ValueOf(yaml.MapSlice{{Key: "size", Value: "value"}})
193 | require.Equal(t, "value", msv.PropertyValue(ValueOf("size")).Interface())
194 | }
195 |
--------------------------------------------------------------------------------