Repository: a-h/templ (go)

Issue:
cmd: rebuild and refresh when a go file is changed in `--watch` mode
I have tried the example given in the docs: https://templ.guide/commands-and-tools/hot-reload

When trying to comment-out for example the "/" route, and then saving the `main.go` file, I don't see any reloads in my terminal and the route also still exists (when I refresh in the browser it still sends me the page).

I didn't find any related issues here and it's unclear to me how to debug this since no error or anything is logged.

The only thing that maybe causes this (given that it works for others (?)) is, that I am developing on NixOS.

I am using the following `flake.nix`:

```nix
{
  description = "Go proj flake";

  inputs.nixpkgs.url = "github:nixos/nixpkgs";  
  inputs.flake-utils.url = "github:numtide/flake-utils";

  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs { inherit system; };
      in
      {
        devShells.default = pkgs.mkShell
          {
            buildInputs = with pkgs;[
                go
                templ
                gcc
            ];
          };
      }
    );
}
```

I use it via `nix develop` and run the given example command: `templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."`


Pull request description:
fix(breaking): trigger rebuild on changes to *.go files, fixes #646

Patch:
diff --git a/.version b/.version
index e91a1a9..5158f20 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.2.806
\ No newline at end of file
+0.2.808
\ No newline at end of file
diff --git a/README.md b/README.md
index 9957ac2..c477b7c 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,13 @@ go tool cover -func coverage.out | grep total
 gotestsum --watch -- -coverprofile=coverage.out
 ```
 
+### test-fuzz
+
+```sh
+./parser/v2/fuzz.sh
+./parser/v2/goexpression/fuzz.sh
+```
+
 ### benchmark
 
 Run benchmarks.
diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go
index edbf0c4..3f17074 100644
--- a/cmd/templ/generatecmd/cmd.go
+++ b/cmd/templ/generatecmd/cmd.go
@@ -9,6 +9,7 @@ import (
 	"net/url"
 	"path"
 	"path/filepath"
+	"regexp"
 	"runtime"
 	"sync"
 	"sync/atomic"
@@ -25,7 +26,9 @@ import (
 	"github.com/fsnotify/fsnotify"
 )
 
-func NewGenerate(log *slog.Logger, args Arguments) (g *Generate) {
+const defaultWatchPattern = `(.+\.go$)|(.+\.templ$)|(.+_templ\.txt$)`
+
+func NewGenerate(log *slog.Logger, args Arguments) (g *Generate, err error) {
 	g = &Generate{
 		Log:  log,
 		Args: &args,
@@ -33,12 +36,20 @@ func NewGenerate(log *slog.Logger, args Arguments) (g *Generate) {
 	if g.Args.WorkerCount == 0 {
 		g.Args.WorkerCount = runtime.NumCPU()
 	}
-	return g
+	if g.Args.WatchPattern == "" {
+		g.Args.WatchPattern = defaultWatchPattern
+	}
+	g.WatchPattern, err = regexp.Compile(g.Args.WatchPattern)
+	if err != nil {
+		return nil, fmt.Errorf("failed to compile watch pattern %q: %w", g.Args.WatchPattern, err)
+	}
+	return g, nil
 }
 
 type Generate struct {
-	Log  *slog.Logger
-	Args *Arguments
+	Log          *slog.Logger
+	Args         *Arguments
+	WatchPattern *regexp.Regexp
 }
 
 type GenerationEvent struct {
@@ -143,7 +154,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 			slog.String("path", cmd.Args.Path),
 			slog.Bool("devMode", cmd.Args.Watch),
 		)
-		if err := watcher.WalkFiles(ctx, cmd.Args.Path, events); err != nil {
+		if err := watcher.WalkFiles(ctx, cmd.Args.Path, cmd.WatchPattern, events); err != nil {
 			cmd.Log.Error("WalkFiles failed, exiting", slog.Any("error", err))
 			errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)}
 			return
@@ -153,7 +164,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 			return
 		}
 		cmd.Log.Info("Watching files")
-		rw, err := watcher.Recursive(ctx, cmd.Args.Path, events, errs)
+		rw, err := watcher.Recursive(ctx, cmd.Args.Path, cmd.WatchPattern, events, errs)
 		if err != nil {
 			cmd.Log.Error("Recursive watcher setup failed, exiting", slog.Any("error", err))
 			errs <- FatalError{Err: fmt.Errorf("failed to setup recursive watcher: %w", err)}
@@ -187,7 +198,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 			cmd.Args.Lazy,
 		)
 		errorCount.Store(0)
-		if err := watcher.WalkFiles(ctx, cmd.Args.Path, events); err != nil {
+		if err := watcher.WalkFiles(ctx, cmd.Args.Path, cmd.WatchPattern, events); err != nil {
 			cmd.Log.Error("Post dev mode WalkFiles failed", slog.Any("error", err))
 			errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)}
 			return
diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go
index 538f820..4d2cbf5 100644
--- a/cmd/templ/generatecmd/main.go
+++ b/cmd/templ/generatecmd/main.go
@@ -13,6 +13,7 @@ type Arguments struct {
 	FileWriter                      FileWriterFunc
 	Path                            string
 	Watch                           bool
+	WatchPattern                    string
 	OpenBrowser                     bool
 	Command                         string
 	ProxyBind                       string
@@ -30,5 +31,9 @@ type Arguments struct {
 }
 
 func Run(ctx context.Context, log *slog.Logger, args Arguments) (err error) {
-	return NewGenerate(log, args).Run(ctx)
+	g, err := NewGenerate(log, args)
+	if err != nil {
+		return err
+	}
+	return g.Run(ctx)
 }
diff --git a/cmd/templ/generatecmd/watcher/watch.go b/cmd/templ/generatecmd/watcher/watch.go
index f97149c..8665603 100644
--- a/cmd/templ/generatecmd/watcher/watch.go
+++ b/cmd/templ/generatecmd/watcher/watch.go
@@ -6,6 +6,7 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"sync"
 	"time"
@@ -16,6 +17,7 @@ import (
 func Recursive(
 	ctx context.Context,
 	path string,
+	watchPattern *regexp.Regexp,
 	out chan fsnotify.Event,
 	errors chan error,
 ) (w *RecursiveWatcher, err error) {
@@ -23,20 +25,25 @@ func Recursive(
 	if err != nil {
 		return nil, err
 	}
-	w = &RecursiveWatcher{
-		ctx:    ctx,
-		w:      fsnw,
-		Events: out,
-		Errors: errors,
-		timers: make(map[timerKey]*time.Timer),
-	}
+	w = NewRecursiveWatcher(ctx, fsnw, watchPattern, out, errors)
 	go w.loop()
 	return w, w.Add(path)
 }
 
+func NewRecursiveWatcher(ctx context.Context, w *fsnotify.Watcher, watchPattern *regexp.Regexp, events chan fsnotify.Event, errors chan error) *RecursiveWatcher {
+	return &RecursiveWatcher{
+		ctx:          ctx,
+		w:            w,
+		WatchPattern: watchPattern,
+		Events:       events,
+		Errors:       errors,
+		timers:       make(map[timerKey]*time.Timer),
+	}
+}
+
 // WalkFiles walks the file tree rooted at path, sending a Create event for each
 // file it encounters.
-func WalkFiles(ctx context.Context, path string, out chan fsnotify.Event) (err error) {
+func WalkFiles(ctx context.Context, path string, watchPattern *regexp.Regexp, out chan fsnotify.Event) (err error) {
 	rootPath := path
 	fileSystem := os.DirFS(rootPath)
 	return fs.WalkDir(fileSystem, ".", func(path string, info os.DirEntry, err error) error {
@@ -50,7 +57,7 @@ func WalkFiles(ctx context.Context, path string, out chan fsnotify.Event) (err e
 		if info.IsDir() && shouldSkipDir(absPath) {
 			return filepath.SkipDir
 		}
-		if !shouldIncludeFile(absPath) {
+		if !watchPattern.MatchString(absPath) {
 			return nil
 		}
 		out <- fsnotify.Event{
@@ -61,26 +68,14 @@ func WalkFiles(ctx context.Context, path string, out chan fsnotify.Event) (err e
 	})
 }
 
-func shouldIncludeFile(name string) bool {
-	if strings.HasSuffix(name, ".templ") {
-		return true
-	}
-	if strings.HasSuffix(name, "_templ.go") {
-		return true
-	}
-	if strings.HasSuffix(name, "_templ.txt") {
-		return true
-	}
-	return false
-}
-
 type RecursiveWatcher struct {
-	ctx     context.Context
-	w       *fsnotify.Watcher
-	Events  chan fsnotify.Event
-	Errors  chan error
-	timerMu sync.Mutex
-	timers  map[timerKey]*time.Timer
+	ctx          context.Context
+	w            *fsnotify.Watcher
+	WatchPattern *regexp.Regexp
+	Events       chan fsnotify.Event
+	Errors       chan error
+	timerMu      sync.Mutex
+	timers       map[timerKey]*time.Timer
 }
 
 type timerKey struct {
@@ -114,7 +109,7 @@ func (w *RecursiveWatcher) loop() {
 				}
 			}
 			// Only notify on templ related files.
-			if !shouldIncludeFile(event.Name) {
+			if !w.WatchPattern.MatchString(event.Name) {
 				continue
 			}
 			tk := timerKeyFromEvent(event)
diff --git a/cmd/templ/main.go b/cmd/templ/main.go
index c265c24..e24041a 100644
--- a/cmd/templ/main.go
+++ b/cmd/templ/main.go
@@ -157,6 +157,8 @@ Args:
     Set to true to include the current time in the generated code.
   -watch
     Set to true to watch the path for changes and regenerate code.
+  -watch-pattern <regexp>
+    Set the regexp pattern of files that will be watched for changes. (default: '(.+\.go$)|(.+\.templ$)|(.+_templ\.txt$)')
   -cmd <cmd>
     Set the command to run after generating code.
   -proxy
diff --git a/get-version/main.go b/get-version/main.go
index 86d4712..f538f9f 100644
--- a/get-version/main.go
+++ b/get-version/main.go
@@ -25,5 +25,5 @@ func main() {
 	}
 
 	// The current commit isn't the one we're about to commit.
-	fmt.Printf("0.2.%d", count+1)
+	fmt.Printf("0.3.%d", count+1)
 }
diff --git a/parser/v2/elementparser.go b/parser/v2/elementparser.go
index 3c3e73d..b9ecf4b 100644
--- a/parser/v2/elementparser.go
+++ b/parser/v2/elementparser.go
@@ -98,11 +98,41 @@ var (
 	})
 )
 
+type attributeValueParser struct {
+	EqualsAndQuote parse.Parser[string]
+	Suffix         parse.Parser[string]
+	UseSingleQuote bool
+}
+
+func (avp attributeValueParser) Parse(pi *parse.Input) (value string, ok bool, err error) {
+	start := pi.Index()
+	if _, ok, err = avp.EqualsAndQuote.Parse(pi); err != nil || !ok {
+		return
+	}
+	if value, ok, err = parse.StringUntil(avp.Suffix).Parse(pi); err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+	if _, ok, err = avp.Suffix.Parse(pi); err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+	return value, true, nil
+}
+
 // Constant attribute.
 var (
-	attributeConstantValueParser            = parse.StringUntil(parse.Rune('"'))
-	attributeConstantValueSingleQuoteParser = parse.StringUntil(parse.Rune('\''))
-	constantAttributeParser                 = parse.Func(func(pi *parse.Input) (attr ConstantAttribute, ok bool, err error) {
+	attributeValueParsers = []attributeValueParser{
+		// Double quoted.
+		{EqualsAndQuote: parse.String(`="`), Suffix: parse.String(`"`), UseSingleQuote: false},
+		// Single quoted.
+		{EqualsAndQuote: parse.String(`='`), Suffix: parse.String(`'`), UseSingleQuote: true},
+		// Unquoted.
+		// A valid unquoted attribute value in HTML is any string of text that is not an empty string,
+		// and that doesn’t contain spaces, tabs, line feeds, form feeds, carriage returns, ", ', `, =, <, or >.
+		{EqualsAndQuote: parse.String("="), Suffix: parse.Any(parse.RuneIn(" \t\n\r\"'`=<>/"), parse.EOF[string]()), UseSingleQuote: false},
+	}
+	constantAttributeParser = parse.Func(func(pi *parse.Input) (attr ConstantAttribute, ok bool, err error) {
 		start := pi.Index()
 
 		// Optional whitespace leader.
@@ -117,38 +147,30 @@ var (
 		}
 		attr.NameRange = NewRange(pi.PositionAt(pi.Index()-len(attr.Name)), pi.Position())
 
-		// ="
-		result, ok, err := parse.Or(parse.String(`="`), parse.String(`='`)).Parse(pi)
-		if err != nil || !ok {
-			pi.Seek(start)
-			return
-		}
-
-		valueParser := attributeConstantValueParser
-		closeParser := parse.String(`"`)
-		if result.B.OK {
-			valueParser = attributeConstantValueSingleQuoteParser
-			closeParser = parse.String(`'`)
-			attr.SingleQuote = true
+		for _, p := range attributeValueParsers {
+			attr.Value, ok, err = p.Parse(pi)
+			if err != nil {
+				pos := pi.Position()
+				if pErr, isParseError := err.(parse.ParseError); isParseError {
+					pos = pErr.Pos
+				}
+				return attr, false, parse.Error(fmt.Sprintf("%s: %v", attr.Name, err), pos)
+			}
+			if ok {
+				attr.SingleQuote = p.UseSingleQuote
+				break
+			}
 		}
 
-		// Attribute value.
-		if attr.Value, ok, err = valueParser.Parse(pi); err != nil || !ok {
+		if !ok {
 			pi.Seek(start)
-			return
+			return attr, false, nil
 		}
 
 		attr.Value = html.UnescapeString(attr.Value)
-		// Only use single quotes if actually required, due to double quote in the value (prefer double quotes).
-		if attr.SingleQuote && !strings.Contains(attr.Value, "\"") {
-			attr.SingleQuote = false
-		}
 
-		// " - closing quote.
-		if _, ok, err = closeParser.Parse(pi); err != nil || !ok {
-			err = parse.Error(fmt.Sprintf("missing closing quote on attribute %q", attr.Name), pi.Position())
-			return
-		}
+		// Only use single quotes if actually required, due to double quote in the value (prefer double quotes).
+		attr.SingleQuote = attr.SingleQuote && strings.Contains(attr.Value, "\"")
 
 		return attr, true, nil
 	})
diff --git a/parser/v2/fuzz.sh b/parser/v2/fuzz.sh
new file mode 100755
index 0000000..cee8f72
--- /dev/null
+++ b/parser/v2/fuzz.sh
@@ -0,0 +1,2 @@
+echo Element
+go test -fuzz=FuzzElement -fuzztime=120s
diff --git a/parser/v2/goexpression/scanner.go b/parser/v2/goexpression/scanner.go
index fb9b8a9..82ad94a 100644
--- a/parser/v2/goexpression/scanner.go
+++ b/parser/v2/goexpression/scanner.go
@@ -82,6 +82,16 @@ func (ep *ExpressionParser) Insert(
 	defer func() {
 		ep.Previous = tok
 	}()
+
+	// If we've reach the end of the file, terminate reading.
+	if tok == token.EOF {
+		// If the EOF was reached, but we're not at the top level, we must have an unbalanced expression.
+		if !ep.isTopLevel() {
+			return true, ErrUnbalanced{ep.Stack.Pop()}
+		}
+		return true, nil
+	}
+
 	// Handle function literals e.g. func() { fmt.Println("Hello") }
 	// By pushing the current depth onto the stack, we prevent stopping
 	// until we've closed the function.


Test patch:
diff --git a/cmd/templ/generatecmd/main_test.go b/cmd/templ/generatecmd/main_test.go
index 9b0b109..62c5c20 100644
--- a/cmd/templ/generatecmd/main_test.go
+++ b/cmd/templ/generatecmd/main_test.go
@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"os"
 	"path"
+	"regexp"
 	"testing"
 
 	"github.com/a-h/templ/cmd/templ/testproject"
@@ -42,3 +43,74 @@ func TestGenerate(t *testing.T) {
 		}
 	})
 }
+
+func TestDefaultWatchPattern(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		matches bool
+	}{
+		{
+			name:    "empty file names do not match",
+			input:   "",
+			matches: false,
+		},
+		{
+			name:    "*_templ.txt matches, Windows",
+			input:   `C:\Users\adrian\github.com\a-h\templ\cmd\templ\testproject\strings_templ.txt`,
+			matches: true,
+		},
+		{
+			name:    "*_templ.txt matches, Unix",
+			input:   "/Users/adrian/github.com/a-h/templ/cmd/templ/testproject/strings_templ.txt",
+			matches: true,
+		},
+		{
+			name:    "*.templ files match, Windows",
+			input:   `C:\Users\adrian\github.com\a-h\templ\cmd\templ\testproject\templates.templ`,
+			matches: true,
+		},
+		{
+			name:    "*.templ files match, Unix",
+			input:   "/Users/adrian/github.com/a-h/templ/cmd/templ/testproject/templates.templ",
+			matches: true,
+		},
+		{
+			name:    "*_templ.go files match, Windows",
+			input:   `C:\Users\adrian\github.com\a-h\templ\cmd\templ\testproject\templates_templ.go`,
+			matches: true,
+		},
+		{
+			name:    "*_templ.go files match, Unix",
+			input:   "/Users/adrian/github.com/a-h/templ/cmd/templ/testproject/templates_templ.go",
+			matches: true,
+		},
+		{
+			name:    "*.go files match, Windows",
+			input:   `C:\Users\adrian\github.com\a-h\templ\cmd\templ\testproject\templates.go`,
+			matches: true,
+		},
+		{
+			name:    "*.go files match, Unix",
+			input:   "/Users/adrian/github.com/a-h/templ/cmd/templ/testproject/templates.go",
+			matches: true,
+		},
+		{
+			name:    "*.css files do not match",
+			input:   "/Users/adrian/github.com/a-h/templ/cmd/templ/testproject/templates.css",
+			matches: false,
+		},
+	}
+	wpRegexp, err := regexp.Compile(defaultWatchPattern)
+	if err != nil {
+		t.Fatalf("failed to compile default watch pattern: %v", err)
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+			if wpRegexp.MatchString(test.input) != test.matches {
+				t.Fatalf("expected match of %q to be %v", test.input, test.matches)
+			}
+		})
+	}
+}
diff --git a/cmd/templ/generatecmd/watcher/watch_test.go b/cmd/templ/generatecmd/watcher/watch_test.go
index e90180d..39a560b 100644
--- a/cmd/templ/generatecmd/watcher/watch_test.go
+++ b/cmd/templ/generatecmd/watcher/watch_test.go
@@ -2,6 +2,8 @@ package watcher
 
 import (
 	"context"
+	"fmt"
+	"regexp"
 	"testing"
 	"time"
 
@@ -10,14 +12,16 @@ import (
 
 func TestWatchDebouncesDuplicates(t *testing.T) {
 	ctx, cancel := context.WithCancel(context.Background())
-	rw := &RecursiveWatcher{
-		ctx: ctx,
-		w: &fsnotify.Watcher{
-			Events: make(chan fsnotify.Event),
-		},
-		Events: make(chan fsnotify.Event, 2),
-		timers: make(map[timerKey]*time.Timer),
+	w := &fsnotify.Watcher{
+		Events: make(chan fsnotify.Event),
 	}
+	events := make(chan fsnotify.Event, 2)
+	errors := make(chan error)
+	watchPattern, err := regexp.Compile(".*")
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to compile watch pattern: %w", err))
+	}
+	rw := NewRecursiveWatcher(ctx, w, watchPattern, events, errors)
 	go func() {
 		rw.w.Events <- fsnotify.Event{Name: "test.templ"}
 		rw.w.Events <- fsnotify.Event{Name: "test.templ"}
@@ -60,14 +64,16 @@ func TestWatchDoesNotDebounceDifferentEvents(t *testing.T) {
 	}
 	for _, test := range tests {
 		ctx, cancel := context.WithCancel(context.Background())
-		rw := &RecursiveWatcher{
-			ctx: ctx,
-			w: &fsnotify.Watcher{
-				Events: make(chan fsnotify.Event),
-			},
-			Events: make(chan fsnotify.Event, 2),
-			timers: make(map[timerKey]*time.Timer),
+		w := &fsnotify.Watcher{
+			Events: make(chan fsnotify.Event),
 		}
+		events := make(chan fsnotify.Event, 2)
+		errors := make(chan error)
+		watchPattern, err := regexp.Compile(".*")
+		if err != nil {
+			t.Fatal(fmt.Errorf("failed to compile watch pattern: %w", err))
+		}
+		rw := NewRecursiveWatcher(ctx, w, watchPattern, events, errors)
 		go func() {
 			rw.w.Events <- test.event1
 			rw.w.Events <- test.event2
@@ -93,14 +99,16 @@ func TestWatchDoesNotDebounceDifferentEvents(t *testing.T) {
 
 func TestWatchDoesNotDebounceSeparateEvents(t *testing.T) {
 	ctx, cancel := context.WithCancel(context.Background())
-	rw := &RecursiveWatcher{
-		ctx: ctx,
-		w: &fsnotify.Watcher{
-			Events: make(chan fsnotify.Event),
-		},
-		Events: make(chan fsnotify.Event, 2),
-		timers: make(map[timerKey]*time.Timer),
+	w := &fsnotify.Watcher{
+		Events: make(chan fsnotify.Event),
+	}
+	events := make(chan fsnotify.Event, 2)
+	errors := make(chan error)
+	watchPattern, err := regexp.Compile(".*")
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to compile watch pattern: %w", err))
 	}
+	rw := NewRecursiveWatcher(ctx, w, watchPattern, events, errors)
 	go func() {
 		rw.w.Events <- fsnotify.Event{Name: "test.templ"}
 		<-time.After(200 * time.Millisecond)
diff --git a/parser/v2/elementparser_test.go b/parser/v2/elementparser_test.go
index 0c7796d..19050c1 100644
--- a/parser/v2/elementparser_test.go
+++ b/parser/v2/elementparser_test.go
@@ -531,6 +531,19 @@ if test {
 				},
 			},
 		},
+		{
+			name:   "unquoted attributes are supported",
+			input:  ` data=123`,
+			parser: StripType(constantAttributeParser),
+			expected: ConstantAttribute{
+				Name:  "data",
+				Value: "123",
+				NameRange: Range{
+					From: Position{Index: 1, Line: 0, Col: 1},
+					To:   Position{Index: 5, Line: 0, Col: 5},
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		tt := tt
@@ -1569,6 +1582,77 @@ amount is charged</div>`,
 				},
 			},
 		},
+		{
+			name:  "element: self-closing with unquoted attribute",
+			input: `<hr noshade=noshade/>`,
+			expected: Element{
+				Name: "hr",
+				NameRange: Range{
+					From: Position{Index: 1, Line: 0, Col: 1},
+					To:   Position{Index: 3, Line: 0, Col: 3},
+				},
+				Attributes: []Attribute{
+					ConstantAttribute{
+						Name:  "noshade",
+						Value: "noshade",
+						NameRange: Range{
+							From: Position{Index: 4, Line: 0, Col: 4},
+							To:   Position{Index: 11, Line: 0, Col: 11},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:  "element: self-closing with unquoted and other attributes",
+			input: `<hr noshade=noshade disabled other-attribute={ false } />`,
+			expected: Element{
+				Name: "hr",
+				NameRange: Range{
+					From: Position{Index: 1, Line: 0, Col: 1},
+					To:   Position{Index: 3, Line: 0, Col: 3},
+				},
+				Attributes: []Attribute{
+					ConstantAttribute{
+						Name:  "noshade",
+						Value: "noshade",
+						NameRange: Range{
+							From: Position{Index: 4, Line: 0, Col: 4},
+							To:   Position{Index: 11, Line: 0, Col: 11},
+						},
+					},
+					BoolConstantAttribute{
+						Name: "disabled",
+						NameRange: Range{
+							From: Position{Index: 20, Line: 0, Col: 20},
+							To:   Position{Index: 28, Line: 0, Col: 28},
+						},
+					},
+					ExpressionAttribute{
+						Name: "other-attribute",
+						NameRange: Range{
+							From: Position{Index: 29, Line: 0, Col: 29},
+							To:   Position{Index: 44, Line: 0, Col: 44},
+						},
+						Expression: Expression{
+							Value: "false",
+							Range: Range{
+								From: Position{
+									Index: 47,
+									Line:  0,
+									Col:   47,
+								},
+								To: Position{
+									Index: 52,
+									Line:  0,
+									Col:   52,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		tt := tt
@@ -1702,3 +1786,21 @@ func TestBigElement(t *testing.T) {
 		t.Errorf("unexpected failure to parse")
 	}
 }
+
+func FuzzElement(f *testing.F) {
+	seeds := []string{
+		`<br>`,
+		`<a href="test" unquoted=unquoted/>`,
+		`<input value={ "test" }/>`,
+		`<div>{ "test" }</div>`,
+		`<a unquoted=unquoted href="test" unquoted=unquoted>Test</a>`,
+	}
+
+	for _, tc := range seeds {
+		f.Add(tc)
+	}
+
+	f.Fuzz(func(t *testing.T, input string) {
+		_, _, _ = element.Parse(parse.NewInput(input))
+	})
+}
diff --git a/parser/v2/goexpression/parse_test.go b/parser/v2/goexpression/parse_test.go
index 702b807..1d0c71c 100644
--- a/parser/v2/goexpression/parse_test.go
+++ b/parser/v2/goexpression/parse_test.go
@@ -354,6 +354,18 @@ var expressionTests = []testInput{
 		name:  "string concat",
 		input: `direction + "newest"`,
 	},
+	{
+		name: "function call",
+		input: `SplitRule(types.GroupMember{
+    UserID:   uuid.NewString(),
+    Username: "user me",
+}, []types.GroupMember{
+    {
+    UserID:   uuid.NewString(),
+    Username: "user 1",
+    },
+})`,
+	},
 }
 
 func TestExpression(t *testing.T) {
diff --git a/parser/v2/templelementparser_test.go b/parser/v2/templelementparser_test.go
index 478620d..ec5c584 100644
--- a/parser/v2/templelementparser_test.go
+++ b/parser/v2/templelementparser_test.go
@@ -449,3 +449,36 @@ func TestTemplElementExpressionParser(t *testing.T) {
 		})
 	}
 }
+
+func TestTemplElementExpressionParserFailures(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+	}{
+		{
+			name: "templelement: missing closing brace",
+			input: `@SplitRule(types.GroupMember{
+    UserID:   uuid.NewString(),
+    Username: "user me",
+}, []types.GroupMember{
+    {
+    UserID:   uuid.NewString(),
+    Username: "user 1",
+    },
+`,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			input := parse.NewInput(tt.input)
+			_, ok, err := templElementExpression.Parse(input)
+			if err == nil {
+				t.Fatalf("expected an error")
+			}
+			if ok {
+				t.Fatalf("expected a failure")
+			}
+		})
+	}
+}