├── .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 | --------------------------------------------------------------------------------