diff --git a/README.md b/README.md index bb3eb9f..c5f0f7e 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,15 @@ Salix's syntax is similar to Leaf and (in my opinion at least), it's much more f ### API Usage ```go -t, err := salix.New(). - WithVarMap(vars). - WithFuncMap(funcs). - WithEscapeHTML(true). - ParseFile("example.salix.txt") +t, err := salix.New().ParseFile("example.salix.txt") if err != nil { panic(err) } -err = t.Execute(os.Stdout) +err = t.WithVarMap(vars). + WithFuncMap(funcs). + WithEscapeHTML(true). + Execute(os.Stdout) if err != nil { panic(err) } diff --git a/examples/readme/main.go b/examples/readme/main.go index 480f382..82f9ee7 100644 --- a/examples/readme/main.go +++ b/examples/readme/main.go @@ -56,15 +56,14 @@ func main() { "page": Page{Title: "Users"}, } - t, err := salix.New(). - WithVarMap(vars). - WithEscapeHTML(true). - ParseString("readme.salix.html", tmpl) + t, err := salix.New().ParseString("readme.salix.html", tmpl) if err != nil { log.Fatalln(err) } - err = t.Execute(os.Stdout) + err = t.WithVarMap(vars). + WithEscapeHTML(true). + Execute(os.Stdout) if err != nil { log.Fatalln(err) } diff --git a/examples/replybot/main.go b/examples/replybot/main.go index 7fd668c..b61c6eb 100644 --- a/examples/replybot/main.go +++ b/examples/replybot/main.go @@ -23,13 +23,12 @@ func main() { } t, err := salix.New(). - WithVarMap(vars). ParseString("replybot.salix.txt", tmpl) if err != nil { log.Fatalln(err) } - err = t.Execute(os.Stdout) + err = t.WithVarMap(vars).Execute(os.Stdout) if err != nil { log.Fatalln(err) } diff --git a/examples/replybot/replybot.salix.txt b/examples/replybot/replybot.salix.txt index b1e5c86..c89a6db 100644 --- a/examples/replybot/replybot.salix.txt +++ b/examples/replybot/replybot.salix.txt @@ -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. + #for(i, match in matches): #if(len(matches) > 1):Link #(i+1):#!if - [imgur.artemislena.eu](https://imgur.artemislena.eu/#(match[1])) diff --git a/for_tag.go b/for_tag.go index cbd980c..fd80186 100644 --- a/for_tag.go +++ b/for_tag.go @@ -9,7 +9,7 @@ import ( 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{} func (ft forTag) Run(tc *TagContext, block, args []ast.Node) error { diff --git a/funcs.go b/funcs.go deleted file mode 100644 index 9fea2a3..0000000 --- a/funcs.go +++ /dev/null @@ -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 - } -} diff --git a/include_tag.go b/include_tag.go new file mode 100644 index 0000000..e439d14 --- /dev/null +++ b/include_tag.go @@ -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) +} diff --git a/namespace.go b/namespace.go new file mode 100644 index 0000000..31e08d1 --- /dev/null +++ b/namespace.go @@ -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 +} diff --git a/parse.go b/parse.go index 245d550..2e8c7c6 100644 --- a/parse.go +++ b/parse.go @@ -3,36 +3,52 @@ package salix import ( "bytes" "io" + "io/fs" "os" + "reflect" "strings" "go.elara.ws/salix/internal/ast" "go.elara.ws/salix/internal/parser" ) -// Parse parses a salix template from r. If the reader has a Name method -// that returns a string, that will be used as the filename. -func (t *Template) Parse(r io.Reader) (*Template, error) { - fname := "" - if r, ok := r.(interface{ Name() string }); ok { - fname = r.Name() - } - return t.ParseWithFilename(fname, r) +// NamedReader is a reader with a name +type NamedReader interface { + io.Reader + Name() string } -// ParseWithFilename parses a salix template from r, using the given filename. -func (t *Template) ParseWithFilename(filename string, r io.Reader) (*Template, error) { - astVal, err := parser.ParseReader(filename, r) +// Parse parses a salix template from a NamedReader, which is an io.Reader +// with a Name method that returns a string. os.File implements NamedReader. +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 { 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 } -// ParseFile parses the file at path as a salix template -func (t *Template) ParseFile(path string) (*Template, error) { +// ParseFile parses the file at path as a salix template. It uses the path as the name. +func (t *Namespace) ParseFile(path string) (*Template, error) { fl, err := os.Open(path) if err != nil { return nil, err @@ -41,12 +57,98 @@ func (t *Template) ParseFile(path string) (*Template, error) { 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. -func (t *Template) ParseString(filename, tmpl string) (*Template, error) { - return t.ParseWithFilename(filename, strings.NewReader(tmpl)) +func (t *Namespace) ParseString(filename, tmpl string) (*Template, error) { + return t.ParseWithName(filename, strings.NewReader(tmpl)) } // ParseString parses bytes using the given filename. -func (t *Template) ParseBytes(filename string, tmpl []byte) (*Template, error) { - return t.ParseWithFilename(filename, bytes.NewReader(tmpl)) +func (t *Namespace) ParseBytes(filename string, tmpl []byte) (*Template, error) { + 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 } diff --git a/salix.go b/salix.go index 5fff137..bad3499 100644 --- a/salix.go +++ b/salix.go @@ -1,12 +1,10 @@ package salix import ( - "bytes" "errors" "fmt" "html" "io" - "maps" "reflect" "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") ) +// HTML represents unescaped HTML strings type HTML string // Template represents a Salix template type Template struct { - file string + ns *Namespace + name string ast []ast.Node escapeHTML bool - tags map[string]Tag - funcs map[string]reflect.Value - vars map[string]reflect.Value + tags map[string]Tag + vars map[string]reflect.Value } -// New creates a new base template with the default -// tags and functions added. -func New() *Template { - t := &Template{ - funcs: map[string]reflect.Value{}, - 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, +// WithVarMap returns a copy of the template with its variable map set to m. +func (t *Template) WithVarMap(m map[string]any) *Template { + newTmpl := &Template{ + ns: t.ns, + name: t.name, ast: t.ast, escapeHTML: t.escapeHTML, - tags: maps.Clone(t.tags), - funcs: maps.Clone(t.funcs), - vars: maps.Clone(t.vars), + tags: t.tags, + vars: map[string]reflect.Value{}, } -} -// 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 { - for name, fn := range m { - t.funcs[name] = reflect.ValueOf(fn) + for k, v := range m { + newTmpl.vars[k] = reflect.ValueOf(v) } } - return t + + return newTmpl } -// WithVarMap adds all the variables in m to the template's -// 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. +// WithTagMap returns a copy of the template with its tag map set to m. func (t *Template) WithTagMap(m map[string]Tag) *Template { - if m != nil { - for name, tag := range m { - t.tags[name] = tag - } + // Make sure the tag map is never nil to avoid panics + if m == nil { + 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. -// 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 { 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 @@ -181,15 +169,10 @@ func (t *Template) getBlock(nodes []ast.Node, offset, startLine int, name string if node.Name.Value == name { tagAmount-- } - // Once tagAmount is zero (all the tags of the same name) - // have been closed with an end tag, we can handle our newlines - // and return the nodes we've accumulated. + // Once tagAmount is zero (all the tags of the same name + // have been closed with an end tag), we can return + // the nodes we've accumulated. 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 } else { out = append(out, node) @@ -207,7 +190,11 @@ func (t *Template) getValue(node ast.Node, local map[string]any) (any, error) { case ast.Value: return t.unwrapASTValue(node, local) 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: return node.Value, nil 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, // it'll try the global variable map. If it doesn't exist in either map, // 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 { v, ok := local[id.Value] if ok { - return v, nil + return reflect.ValueOf(v), nil } } v, ok := t.vars[id.Value] - if !ok { - return nil, t.posError(id, "%w: %s", ErrNoSuchVar, id.Value) + if ok { + 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 -// in the nodes slice. -func (t *Template) handleNewlines(nodes []ast.Node, i int) { - if i != 0 { - 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 - } +func (t *Template) getTag(name string) (Tag, bool) { + tag, ok := t.tags[name] + if ok { + return tag, true } - lastIndex := len(nodes) - 1 - if i != lastIndex { - if node, ok := nodes[i+1].(ast.Text); ok { - ni := bytes.IndexByte(node.Data, '\n') - if ni != -1 { - node.Data = node.Data[ni:] - } - nodes[i+1] = node - } + tag, ok = t.ns.getTag(name) + if ok { + return tag, true } + + tag, ok = globalTags[name] + if ok { + return tag, true + } + + return nil, false } // 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) { - tag, ok := t.tags[node.Name.Value] + tag, ok := t.getTag(node.Name.Value) if !ok { return 0, t.posError(node, "%w: %s", ErrNoSuchTag, node.Name.Value) } - t.handleNewlines(nodes, i) - var block []ast.Node if node.HasBody { 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 func (t *Template) execFuncCall(fc ast.FuncCall, local map[string]any) (any, error) { - fn, ok := t.funcs[fc.Name.Value] - if !ok { + fn, err := t.getVar(fc.Name, local) + if err != nil { return nil, t.posError(fc, "%w: %s", ErrNoSuchFunc, fc.Name.Value) } 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 { - return ast.PosError(n, t.file, format, v...) + return ast.PosError(n, t.name, format, v...) } func validateFunc(t reflect.Type) error { diff --git a/tags.go b/tags.go index 21acc78..3046266 100644 --- a/tags.go +++ b/tags.go @@ -6,25 +6,30 @@ import ( "go.elara.ws/salix/internal/ast" ) +// Tag represents a tag in a Salix template type Tag interface { Run(tc *TagContext, block, args []ast.Node) error } -var defaultTags = map[string]Tag{ - "if": ifTag{}, - "for": forTag{}, +var globalTags = map[string]Tag{ + "if": ifTag{}, + "for": forTag{}, + "include": includeTag{}, } +// TagContext is passed to Tag implementations to allow them to control the interpreter type TagContext struct { w io.Writer t *Template 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 { 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) { return tc.t.getValue(node, mergeMap(tc.local, local)) } diff --git a/vars.go b/vars.go new file mode 100644 index 0000000..8aaddf5 --- /dev/null +++ b/vars.go @@ -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 + } +}