Repository: a-h/templ (go)

Issue:
templ errors if HTML attributes aren't quoted
**Describe the bug**

If I try run `templ` on this file:

```
package main

templ Hello() {
  <div id=main></div>
}
```

I get this error:

```
parsing error: <div>: malformed open element: line 3, col 7
```

Seems related to #116 / #125.

**Expected behavior**

`<div id=main></div>` is valid HTML ([this post discusses how unquoted attributed values are valid](https://mathiasbynens.be/notes/unquoted-attribute-values)), so I expected `templ` to compile it even if it's a little unusual.

That said I noticed that the [Elements documentation page](https://templ.guide/syntax-and-usage/elements) documents ways in which templ diverges from HTML ("Unlike HTML, templ requires that all HTML elements are closed..."), so if this is intentional it might be good to document it.

**`templ info` output**

```
(✓) os [ goos=darwin goarch=arm64 ]
(✓) go [ location=/opt/homebrew/bin/go version=go version go1.23.1 darwin/arm64 ]
(✓) gopls [ location=/Users/bork/go/bin/gopls version=golang.org/x/tools/gopls v0.8.4
    golang.org/x/tools/gopls@v0.8.4 h1:zGZsAXAb0LLws/Z+2BCWR17dkPHhIO2GYwYSSkeXX5c= ]
(✓) templ [ location=/Users/bork/go/bin/templ version=v0.2.778 ]
```

**Desktop (please complete the following information):**

 - OS: Mac OS
 - templ CLI version? v0.2.778
- Go version: 1.23
- `gopls` version: v0.8.4

Pull request description:
feat: support unquoted HTML attributes, closes #963
- **feat: support unquoted attribute values**
- **feat: add tests for unquoted attributes**

Closes #963


Patch:
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/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/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")
+			}
+		})
+	}
+}