Add namespaces and include tag, improve whitespace handling

This commit is contained in:
Elara 2023-10-29 15:47:47 -07:00
parent fb7010dae3
commit 76c0a48b87
12 changed files with 372 additions and 162 deletions

View File

@ -30,16 +30,15 @@ Salix's syntax is similar to Leaf and (in my opinion at least), it's much more f
### API Usage ### API Usage
```go ```go
t, err := salix.New(). t, err := salix.New().ParseFile("example.salix.txt")
WithVarMap(vars).
WithFuncMap(funcs).
WithEscapeHTML(true).
ParseFile("example.salix.txt")
if err != nil { if err != nil {
panic(err) panic(err)
} }
err = t.Execute(os.Stdout) err = t.WithVarMap(vars).
WithFuncMap(funcs).
WithEscapeHTML(true).
Execute(os.Stdout)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -56,15 +56,14 @@ func main() {
"page": Page{Title: "Users"}, "page": Page{Title: "Users"},
} }
t, err := salix.New(). t, err := salix.New().ParseString("readme.salix.html", tmpl)
WithVarMap(vars).
WithEscapeHTML(true).
ParseString("readme.salix.html", tmpl)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
err = t.Execute(os.Stdout) err = t.WithVarMap(vars).
WithEscapeHTML(true).
Execute(os.Stdout)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }

View File

@ -23,13 +23,12 @@ func main() {
} }
t, err := salix.New(). t, err := salix.New().
WithVarMap(vars).
ParseString("replybot.salix.txt", tmpl) ParseString("replybot.salix.txt", tmpl)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
err = t.Execute(os.Stdout) err = t.WithVarMap(vars).Execute(os.Stdout)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }

View File

@ -1,4 +1,5 @@
#if(len(matches) > 1):Imgur links were#else:An Imgur link was#!if detected in your #(type). Here are links to the same #if(len(matches) > 1):locations#else:location#!if on alternative frontends that protect your privacy. #if(len(matches) > 1):Imgur links were#else:An Imgur link was#!if detected in your #(type). Here are links to the same #if(len(matches) > 1):locations#else:location#!if on alternative frontends that protect your privacy.
#for(i, match in matches): #for(i, match in matches):
#if(len(matches) > 1):Link #(i+1):#!if #if(len(matches) > 1):Link #(i+1):#!if
- [imgur.artemislena.eu](https://imgur.artemislena.eu/#(match[1])) - [imgur.artemislena.eu](https://imgur.artemislena.eu/#(match[1]))

View File

@ -9,7 +9,7 @@ import (
var ErrForTagInvalidArgs = errors.New("invalid arguments in for tag") var ErrForTagInvalidArgs = errors.New("invalid arguments in for tag")
// forTag represents a #if tag within a Salix template // forTag represents a #for tag within a Salix template
type forTag struct{} type forTag struct{}
func (ft forTag) Run(tc *TagContext, block, args []ast.Node) error { func (ft forTag) Run(tc *TagContext, block, args []ast.Node) error {

View File

@ -1,31 +0,0 @@
package salix
import (
"reflect"
"strings"
)
var defaultFuncs = map[string]any{
"len": tmplLen,
"toUpper": strings.ToUpper,
"toLower": strings.ToLower,
"hasPrefix": strings.HasPrefix,
"trimPrefix": strings.TrimPrefix,
"hasSuffix": strings.HasSuffix,
"trimSuffix": strings.TrimSuffix,
"trimSpace": strings.TrimSpace,
"equalFold": strings.EqualFold,
"count": strings.Count,
"split": strings.Split,
"join": strings.Join,
}
func tmplLen(v any) int {
val := reflect.ValueOf(v)
switch val.Kind() {
case reflect.Array, reflect.Slice, reflect.String, reflect.Map:
return val.Len()
default:
return -1
}
}

38
include_tag.go Normal file
View File

@ -0,0 +1,38 @@
package salix
import (
"errors"
"go.elara.ws/salix/internal/ast"
)
var (
ErrIncludeInvalidArgs = errors.New("include expects one string argument")
ErrNoSuchTemplate = errors.New("no such template")
)
// forTag represents an #include tag within a Salix template
type includeTag struct{}
func (it includeTag) Run(tc *TagContext, block, args []ast.Node) error {
if len(args) != 1 {
return ErrIncludeInvalidArgs
}
val, err := tc.GetValue(args[0], nil)
if err != nil {
return err
}
name, ok := val.(string)
if !ok {
return ErrIncludeInvalidArgs
}
tmpl, ok := tc.t.ns.GetTemplate(name)
if !ok {
return ErrNoSuchTemplate
}
return tc.Execute(tmpl.ast, nil)
}

78
namespace.go Normal file
View File

@ -0,0 +1,78 @@
package salix
import (
"reflect"
"sync"
)
// Namespace represents a collection of templates that can include each other
type Namespace struct {
mu sync.Mutex
tmpls map[string]*Template
vars map[string]reflect.Value
tags map[string]Tag
}
// New returns a new template namespace
func New() *Namespace {
return &Namespace{
tmpls: map[string]*Template{},
vars: map[string]reflect.Value{},
tags: map[string]Tag{},
}
}
// WithVarMap sets the namespace's variable map to m
func (n *Namespace) WithVarMap(m map[string]any) *Namespace {
n.mu.Lock()
defer n.mu.Unlock()
n.vars = map[string]reflect.Value{}
if m != nil {
for k, v := range m {
n.vars[k] = reflect.ValueOf(v)
}
}
return n
}
// WithTagMap sets the namespace's tag map to m
func (n *Namespace) WithTagMap(m map[string]Tag) *Namespace {
n.mu.Lock()
defer n.mu.Unlock()
if m != nil {
n.tags = m
} else {
n.tags = map[string]Tag{}
}
return n
}
// GetTemplate tries to get a template from the namespace's template map.
// If it finds the template, it returns the template and true. If it
// doesn't find it, it returns nil and false.
func (n *Namespace) GetTemplate(name string) (*Template, bool) {
n.mu.Lock()
defer n.mu.Unlock()
t, ok := n.tmpls[name]
return t, ok
}
// getVar tries to get a variable from the namespace's variable map
func (n *Namespace) getVar(name string) (reflect.Value, bool) {
n.mu.Lock()
defer n.mu.Unlock()
v, ok := n.vars[name]
return v, ok
}
// getTag tries to get a tag from the namespace's tag map
func (n *Namespace) getTag(name string) (Tag, bool) {
n.mu.Lock()
defer n.mu.Unlock()
t, ok := n.tags[name]
return t, ok
}

140
parse.go
View File

@ -3,36 +3,52 @@ package salix
import ( import (
"bytes" "bytes"
"io" "io"
"io/fs"
"os" "os"
"reflect"
"strings" "strings"
"go.elara.ws/salix/internal/ast" "go.elara.ws/salix/internal/ast"
"go.elara.ws/salix/internal/parser" "go.elara.ws/salix/internal/parser"
) )
// Parse parses a salix template from r. If the reader has a Name method // NamedReader is a reader with a name
// that returns a string, that will be used as the filename. type NamedReader interface {
func (t *Template) Parse(r io.Reader) (*Template, error) { io.Reader
fname := "<input>" Name() string
if r, ok := r.(interface{ Name() string }); ok {
fname = r.Name()
}
return t.ParseWithFilename(fname, r)
} }
// ParseWithFilename parses a salix template from r, using the given filename. // Parse parses a salix template from a NamedReader, which is an io.Reader
func (t *Template) ParseWithFilename(filename string, r io.Reader) (*Template, error) { // with a Name method that returns a string. os.File implements NamedReader.
astVal, err := parser.ParseReader(filename, r) func (n *Namespace) Parse(r NamedReader) (*Template, error) {
return n.ParseWithName(r.Name(), r)
}
// ParseWithFilename parses a salix template from r, using the given name.
func (n *Namespace) ParseWithName(name string, r io.Reader) (*Template, error) {
astVal, err := parser.ParseReader(name, r)
if err != nil { if err != nil {
return nil, err return nil, err
} }
t.file = filename
t.ast = astVal.([]ast.Node) t := &Template{
ns: n,
name: name,
ast: astVal.([]ast.Node),
tags: map[string]Tag{},
vars: map[string]reflect.Value{},
}
performWhitespaceMutations(t.ast)
n.mu.Lock()
defer n.mu.Unlock()
n.tmpls[name] = t
return t, nil return t, nil
} }
// ParseFile parses the file at path as a salix template // ParseFile parses the file at path as a salix template. It uses the path as the name.
func (t *Template) ParseFile(path string) (*Template, error) { func (t *Namespace) ParseFile(path string) (*Template, error) {
fl, err := os.Open(path) fl, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return nil, err
@ -41,12 +57,98 @@ func (t *Template) ParseFile(path string) (*Template, error) {
return t.Parse(fl) return t.Parse(fl)
} }
// ParseFile parses a file at the given path in a filesystem. It uses the path as the name.
func (t *Namespace) ParseFS(fsys fs.FS, path string) (*Template, error) {
fl, err := fsys.Open(path)
if err != nil {
return nil, err
}
defer fl.Close()
return t.ParseWithName(path, fl)
}
// ParseString parses a string using the given filename. // ParseString parses a string using the given filename.
func (t *Template) ParseString(filename, tmpl string) (*Template, error) { func (t *Namespace) ParseString(filename, tmpl string) (*Template, error) {
return t.ParseWithFilename(filename, strings.NewReader(tmpl)) return t.ParseWithName(filename, strings.NewReader(tmpl))
} }
// ParseString parses bytes using the given filename. // ParseString parses bytes using the given filename.
func (t *Template) ParseBytes(filename string, tmpl []byte) (*Template, error) { func (t *Namespace) ParseBytes(filename string, tmpl []byte) (*Template, error) {
return t.ParseWithFilename(filename, bytes.NewReader(tmpl)) return t.ParseWithName(filename, bytes.NewReader(tmpl))
}
// performWhitespaceMutations mutates nodes in the AST to remove
// whitespace where it isn't needed.
func performWhitespaceMutations(nodes []ast.Node) {
// lastTag keeps track of which line the
// last tag was found on, so that if an end
// tag was found on the same line, we know it's inline
// and we don't need to handle its whitespace.
lastTag := 0
for i := 0; i < len(nodes); i++ {
switch node := nodes[i].(type) {
case ast.Tag:
// If the node has no body, it's an inline tag,
// so we don't need to handle any whitespace around it.
if !node.HasBody {
continue
}
handleWhitespace(nodes, i)
lastTag = node.Position.Line
case ast.EndTag:
if lastTag != node.Position.Line {
handleWhitespace(nodes, i)
}
}
}
}
// handleWhitespace mutates nodes above and below tags and end tags
// to remove the unneeded whitespace around them.
func handleWhitespace(nodes []ast.Node, i int) {
lastIndex := len(nodes) - 1
var prevNode ast.Text
var nextNode ast.Text
if i != 0 {
if node, ok := nodes[i-1].(ast.Text); ok {
prevNode = node
}
}
if i != lastIndex {
if node, ok := nodes[i+1].(ast.Text); ok {
nextNode = node
}
}
if prevNode.Data != nil && bytes.Contains(nextNode.Data, []byte{'\n'}) {
prevNode.Data = trimWhitespaceSuffix(prevNode.Data)
nodes[i-1] = prevNode
}
if nextNode.Data != nil {
nextNode.Data = bytes.TrimPrefix(nextNode.Data, []byte{'\n'})
nodes[i+1] = nextNode
}
}
// trimWhitespaceSuffix removes everything up to and including the first newline
// it finds, starting from the end of the slice. If a non-whitespace character is
// encountered before a newline, the data is returned unmodified.
func trimWhitespaceSuffix(data []byte) []byte {
// Start from the end of the slice
for i := len(data) - 1; i >= 0; i-- {
if data[i] == '\n' {
// If a newline is found, return the slice without the newline and anything after
return data[:i+1]
} else if data[i] != ' ' && data[i] != '\t' && data[i] != '\r' {
// If a non-whitespace character is found, return the original slice
return data
}
}
// If no newline or non-whitespace character is found, return the original slice
return data
} }

179
salix.go
View File

@ -1,12 +1,10 @@
package salix package salix
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"html" "html"
"io" "io"
"maps"
"reflect" "reflect"
"go.elara.ws/salix/internal/ast" "go.elara.ws/salix/internal/ast"
@ -31,87 +29,77 @@ var (
ErrFuncSecondReturnType = errors.New("the second return value of a template function must be an error") ErrFuncSecondReturnType = errors.New("the second return value of a template function must be an error")
) )
// HTML represents unescaped HTML strings
type HTML string type HTML string
// Template represents a Salix template // Template represents a Salix template
type Template struct { type Template struct {
file string ns *Namespace
name string
ast []ast.Node ast []ast.Node
escapeHTML bool escapeHTML bool
tags map[string]Tag tags map[string]Tag
funcs map[string]reflect.Value vars map[string]reflect.Value
vars map[string]reflect.Value
} }
// New creates a new base template with the default // WithVarMap returns a copy of the template with its variable map set to m.
// tags and functions added. func (t *Template) WithVarMap(m map[string]any) *Template {
func New() *Template { newTmpl := &Template{
t := &Template{ ns: t.ns,
funcs: map[string]reflect.Value{}, name: t.name,
vars: map[string]reflect.Value{},
tags: map[string]Tag{},
}
return t.WithTagMap(defaultTags).WithFuncMap(defaultFuncs)
}
// Clone returns a shallow clone of a template.
func (t *Template) Clone() *Template {
return &Template{
file: t.file,
ast: t.ast, ast: t.ast,
escapeHTML: t.escapeHTML, escapeHTML: t.escapeHTML,
tags: maps.Clone(t.tags), tags: t.tags,
funcs: maps.Clone(t.funcs), vars: map[string]reflect.Value{},
vars: maps.Clone(t.vars),
} }
}
// WithFuncMap adds all the functions in m to the template's
// global function map. If a function with the given name already
// exists, it will be overwritten.
func (t *Template) WithFuncMap(m map[string]any) *Template {
if m != nil { if m != nil {
for name, fn := range m { for k, v := range m {
t.funcs[name] = reflect.ValueOf(fn) newTmpl.vars[k] = reflect.ValueOf(v)
} }
} }
return t
return newTmpl
} }
// WithVarMap adds all the variables in m to the template's // WithTagMap returns a copy of the template with its tag map set to m.
// global variable map. If a variable with the given name already
// exists, it will be overwritten.
func (t *Template) WithVarMap(m map[string]any) *Template {
if m != nil {
for name, val := range m {
t.vars[name] = reflect.ValueOf(val)
}
}
return t
}
// WithTagMap adds all the tags in m to the template's
// global tag map. If a tag with the given name already
// exists, it will be overwritten.
func (t *Template) WithTagMap(m map[string]Tag) *Template { func (t *Template) WithTagMap(m map[string]Tag) *Template {
if m != nil { // Make sure the tag map is never nil to avoid panics
for name, tag := range m { if m == nil {
t.tags[name] = tag m = map[string]Tag{}
} }
return &Template{
ns: t.ns,
name: t.name,
ast: t.ast,
escapeHTML: t.escapeHTML,
tags: m,
vars: t.vars,
} }
return t
} }
// WithEscapeHTML enables or disables HTML escaping. // WithEscapeHTML returns a copy of the template with HTML escaping enabled or disabled.
// The HTML escaping functionality is NOT context-aware. // The HTML escaping functionality is NOT context-aware.
// Using the HTML type allows you to get around the escaping. // Using the HTML type allows you to get around the escaping if needed.
func (t *Template) WithEscapeHTML(b bool) *Template { func (t *Template) WithEscapeHTML(b bool) *Template {
t.escapeHTML = true t.escapeHTML = true
return t return &Template{
ns: t.ns,
name: t.name,
ast: t.ast,
escapeHTML: b,
tags: t.tags,
vars: t.vars,
}
} }
// Execute executes a parsed template and writes // Execute executes a parsed template and writes
@ -181,15 +169,10 @@ func (t *Template) getBlock(nodes []ast.Node, offset, startLine int, name string
if node.Name.Value == name { if node.Name.Value == name {
tagAmount-- tagAmount--
} }
// Once tagAmount is zero (all the tags of the same name) // Once tagAmount is zero (all the tags of the same name
// have been closed with an end tag, we can handle our newlines // have been closed with an end tag), we can return
// and return the nodes we've accumulated. // the nodes we've accumulated.
if tagAmount == 0 { if tagAmount == 0 {
// If the end tag is on the same line as the start tag,
// we don't need to remove any newlines.
if node.Position.Line != startLine {
t.handleNewlines(nodes, i)
}
return out return out
} else { } else {
out = append(out, node) out = append(out, node)
@ -207,7 +190,11 @@ func (t *Template) getValue(node ast.Node, local map[string]any) (any, error) {
case ast.Value: case ast.Value:
return t.unwrapASTValue(node, local) return t.unwrapASTValue(node, local)
case ast.Ident: case ast.Ident:
return t.getVar(node, local) val, err := t.getVar(node, local)
if err != nil {
return nil, err
}
return val.Interface(), nil
case ast.String: case ast.String:
return node.Value, nil return node.Value, nil
case ast.Float: case ast.Float:
@ -254,56 +241,58 @@ func (t *Template) unwrapASTValue(node ast.Value, local map[string]any) (any, er
// getVar tries to get a variable from the local map. If it's not found, // getVar tries to get a variable from the local map. If it's not found,
// it'll try the global variable map. If it doesn't exist in either map, // it'll try the global variable map. If it doesn't exist in either map,
// it will return an error. // it will return an error.
func (t *Template) getVar(id ast.Ident, local map[string]any) (any, error) { func (t *Template) getVar(id ast.Ident, local map[string]any) (reflect.Value, error) {
if local != nil { if local != nil {
v, ok := local[id.Value] v, ok := local[id.Value]
if ok { if ok {
return v, nil return reflect.ValueOf(v), nil
} }
} }
v, ok := t.vars[id.Value] v, ok := t.vars[id.Value]
if !ok { if ok {
return nil, t.posError(id, "%w: %s", ErrNoSuchVar, id.Value) return v, nil
} }
return v.Interface(), nil v, ok = t.ns.getVar(id.Value)
if ok {
return v, nil
}
v, ok = globalVars[id.Value]
if ok {
return v, nil
}
return reflect.Value{}, t.posError(id, "%w: %s", ErrNoSuchVar, id.Value)
} }
// handleNewlines removes newlines above and below the given index func (t *Template) getTag(name string) (Tag, bool) {
// in the nodes slice. tag, ok := t.tags[name]
func (t *Template) handleNewlines(nodes []ast.Node, i int) { if ok {
if i != 0 { return tag, true
if node, ok := nodes[i-1].(ast.Text); ok {
ni := bytes.LastIndexByte(node.Data, '\n')
if ni != -1 {
node.Data = node.Data[:ni]
}
nodes[i-1] = node
}
} }
lastIndex := len(nodes) - 1 tag, ok = t.ns.getTag(name)
if i != lastIndex { if ok {
if node, ok := nodes[i+1].(ast.Text); ok { return tag, true
ni := bytes.IndexByte(node.Data, '\n')
if ni != -1 {
node.Data = node.Data[ni:]
}
nodes[i+1] = node
}
} }
tag, ok = globalTags[name]
if ok {
return tag, true
}
return nil, false
} }
// execTag executes a tag // execTag executes a tag
func (t *Template) execTag(node ast.Tag, w io.Writer, nodes []ast.Node, i int, local map[string]any) (newOffset int, err error) { func (t *Template) execTag(node ast.Tag, w io.Writer, nodes []ast.Node, i int, local map[string]any) (newOffset int, err error) {
tag, ok := t.tags[node.Name.Value] tag, ok := t.getTag(node.Name.Value)
if !ok { if !ok {
return 0, t.posError(node, "%w: %s", ErrNoSuchTag, node.Name.Value) return 0, t.posError(node, "%w: %s", ErrNoSuchTag, node.Name.Value)
} }
t.handleNewlines(nodes, i)
var block []ast.Node var block []ast.Node
if node.HasBody { if node.HasBody {
block = t.getBlock(nodes, i+1, node.Position.Line, node.Name.Value) block = t.getBlock(nodes, i+1, node.Position.Line, node.Name.Value)
@ -322,8 +311,8 @@ func (t *Template) execTag(node ast.Tag, w io.Writer, nodes []ast.Node, i int, l
// execFuncCall executes a function call // execFuncCall executes a function call
func (t *Template) execFuncCall(fc ast.FuncCall, local map[string]any) (any, error) { func (t *Template) execFuncCall(fc ast.FuncCall, local map[string]any) (any, error) {
fn, ok := t.funcs[fc.Name.Value] fn, err := t.getVar(fc.Name, local)
if !ok { if err != nil {
return nil, t.posError(fc, "%w: %s", ErrNoSuchFunc, fc.Name.Value) return nil, t.posError(fc, "%w: %s", ErrNoSuchFunc, fc.Name.Value)
} }
return t.execFunc(fn, fc, fc.Params, local) return t.execFunc(fn, fc, fc.Params, local)
@ -439,7 +428,7 @@ func (t *Template) execFunc(fn reflect.Value, node ast.Node, args []ast.Node, lo
} }
func (t *Template) posError(n ast.Node, format string, v ...any) error { func (t *Template) posError(n ast.Node, format string, v ...any) error {
return ast.PosError(n, t.file, format, v...) return ast.PosError(n, t.name, format, v...)
} }
func validateFunc(t reflect.Type) error { func validateFunc(t reflect.Type) error {

11
tags.go
View File

@ -6,25 +6,30 @@ import (
"go.elara.ws/salix/internal/ast" "go.elara.ws/salix/internal/ast"
) )
// Tag represents a tag in a Salix template
type Tag interface { type Tag interface {
Run(tc *TagContext, block, args []ast.Node) error Run(tc *TagContext, block, args []ast.Node) error
} }
var defaultTags = map[string]Tag{ var globalTags = map[string]Tag{
"if": ifTag{}, "if": ifTag{},
"for": forTag{}, "for": forTag{},
"include": includeTag{},
} }
// TagContext is passed to Tag implementations to allow them to control the interpreter
type TagContext struct { type TagContext struct {
w io.Writer w io.Writer
t *Template t *Template
local map[string]any local map[string]any
} }
// Execute runs the interpreter on the given AST nodes, with the given local variables.
func (tc *TagContext) Execute(nodes []ast.Node, local map[string]any) error { func (tc *TagContext) Execute(nodes []ast.Node, local map[string]any) error {
return tc.t.execute(tc.w, nodes, mergeMap(tc.local, local)) return tc.t.execute(tc.w, nodes, mergeMap(tc.local, local))
} }
// GetValue evaluates the given AST node using the given local variables.
func (tc *TagContext) GetValue(node ast.Node, local map[string]any) (any, error) { func (tc *TagContext) GetValue(node ast.Node, local map[string]any) (any, error) {
return tc.t.getValue(node, mergeMap(tc.local, local)) return tc.t.getValue(node, mergeMap(tc.local, local))
} }

31
vars.go Normal file
View File

@ -0,0 +1,31 @@
package salix
import (
"reflect"
"strings"
)
var globalVars = map[string]reflect.Value{
"len": reflect.ValueOf(tmplLen),
"toUpper": reflect.ValueOf(strings.ToUpper),
"toLower": reflect.ValueOf(strings.ToLower),
"hasPrefix": reflect.ValueOf(strings.HasPrefix),
"trimPrefix": reflect.ValueOf(strings.TrimPrefix),
"hasSuffix": reflect.ValueOf(strings.HasSuffix),
"trimSuffix": reflect.ValueOf(strings.TrimSuffix),
"trimSpace": reflect.ValueOf(strings.TrimSpace),
"equalFold": reflect.ValueOf(strings.EqualFold),
"count": reflect.ValueOf(strings.Count),
"split": reflect.ValueOf(strings.Split),
"join": reflect.ValueOf(strings.Join),
}
func tmplLen(v any) int {
val := reflect.ValueOf(v)
switch val.Kind() {
case reflect.Array, reflect.Slice, reflect.String, reflect.Map:
return val.Len()
default:
return -1
}
}