Repository: gohugoio/hugo (language: go)

Issue:
Add a function like `findRE` that will return all sub/group matching.
Related topic: https://discourse.gohugo.io/t/regular-expressions-sub-matching-groups-not-supported/15368

I would like to create functions that parse and return some stuff of HTML like following.

```html
<ul>
  <li><a href="#foo">Foo</a></li>
  <li><a href="#bar">Bar</a></li>
</ul>
```

```go
pattern := `<a.*href="(.+)">(.+)</a>`
```

Result (something like this)

```json
[
  [
    "<a href=\"#foo\">Foo</a>",
    "#foo"
    "Foo"
  ],
  [
    "<a href=\"#bar\">Bar</a>",
    "#bar"
    "Bar"
  ]
]
```


Discussion / hints:
I believe that [FindAllStringSubmatch](https://pkg.go.dev/regexp#Regexp.FindAllStringSubmatch) provides the desired functionality, but I am concerned that site developers will have a difficult time wrapping their heads around this.

For your specific example, you can do this:

```
{{ $str := `
  <ul>
    <li><a href="#foo">Foo</a></li>
    <li><a href="#bar">Bar</a></li>
  </ul>
`}}


{{ $s := slice }}
{{ range $match := findRE `<a.+a>` $str }}
  {{ $s = $s | append (
    slice (
      slice
        $match
        (replaceRE `.+"(.+)".+` "$1" $match)
        (replaceRE `.*>(.+)<.+` "$1" $match)
      )
    )
  }}
{{ end }}
```

The resulting data structure (jsonified)...

```
[
  [
    "\u003ca href=\"#foo\"\u003eFoo\u003c/a\u003e",
    "#foo",
    "Foo"
  ],
  [
    "\u003ca href=\"#bar\"\u003eBar\u003c/a\u003e",
    "#bar",
    "Bar"
  ]
]
```


@jmooring Thank you for the workaround, but I'm concerned about the performance between `FindAllStringSubmatch` and the `findRE` +  N x `replaceRE`. I think it is worthwhile if the former has obvious performance. But I don't know how to benchmark.

> but I am concerned that site developers will have a difficult time wrapping their heads around this.

I think it's related to the documentations.
I don't think we need to benchmark to to show the value, and I have needed this myself in some cases.

Fix patch:
diff --git a/tpl/internal/templatefuncsRegistry.go b/tpl/internal/templatefuncsRegistry.go
index da9aa5d292b..84e0e25faf4 100644
--- a/tpl/internal/templatefuncsRegistry.go
+++ b/tpl/internal/templatefuncsRegistry.go
@@ -66,6 +66,11 @@ func (t *TemplateFuncsNamespace) AddMethodMapping(m any, aliases []string, examp
 
 	name := methodToName(m)
 
+	// Rewrite §§ to ` in example commands.
+	for i, e := range examples {
+		examples[i][0] = strings.ReplaceAll(e[0], "§§", "`")
+	}
+
 	// sanity check
 	for _, e := range examples {
 		if e[0] == "" {
diff --git a/tpl/strings/init.go b/tpl/strings/init.go
index 0a4d8776267..503ec6a25e2 100644
--- a/tpl/strings/init.go
+++ b/tpl/strings/init.go
@@ -78,12 +78,22 @@ func init() {
 			[]string{"findRE"},
 			[][2]string{
 				{
-					`{{ findRE "[G|g]o" "Hugo is a static side generator written in Go." "1" }}`,
+					`{{ findRE "[G|g]o" "Hugo is a static side generator written in Go." 1 }}`,
 					`[go]`,
 				},
 			},
 		)
 
+		ns.AddMethodMapping(ctx.FindRESubmatch,
+			[]string{"findRESubmatch"},
+			[][2]string{
+				{
+					`{{ findRESubmatch §§<a\s*href="(.+?)">(.+?)</a>§§ §§<li><a href="#foo">Foo</a></li> <li><a href="#bar">Bar</a></li>§§ | print | safeHTML }}`,
+					"[[<a href=\"#foo\">Foo</a> #foo Foo] [<a href=\"#bar\">Bar</a> #bar Bar]]",
+				},
+			},
+		)
+
 		ns.AddMethodMapping(ctx.HasPrefix,
 			[]string{"hasPrefix"},
 			[][2]string{
diff --git a/tpl/strings/regexp.go b/tpl/strings/regexp.go
index 5b6a812d444..84b015ffb31 100644
--- a/tpl/strings/regexp.go
+++ b/tpl/strings/regexp.go
@@ -45,6 +45,31 @@ func (ns *Namespace) FindRE(expr string, content any, limit ...any) ([]string, e
 	return re.FindAllString(conv, lim), nil
 }
 
+// FindRESubmatch returns returns a slice of strings holding the text of the leftmost match of the regular expression in s and the matches, if any, of its subexpressions.
+//
+// By default all matches will be included. The number of matches can be limited with the optional limit parameter. A return value of nil indicates no match.
+func (ns *Namespace) FindRESubmatch(expr string, content any, limit ...any) ([][]string, error) {
+	re, err := reCache.Get(expr)
+	if err != nil {
+		return nil, err
+	}
+
+	conv, err := cast.ToStringE(content)
+	if err != nil {
+		return nil, err
+	}
+	n := -1
+	if len(limit) > 0 {
+		n, err = cast.ToIntE(limit[0])
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return re.FindAllStringSubmatch(conv, n), nil
+
+}
+
 // ReplaceRE returns a copy of s, replacing all matches of the regular
 // expression pattern with the replacement text repl. The number of replacements
 // can be limited with an optional fourth parameter.


Test patch:
diff --git a/tpl/strings/regexp_test.go b/tpl/strings/regexp_test.go
index 9ac098c171a..b6ec5cb9ed5 100644
--- a/tpl/strings/regexp_test.go
+++ b/tpl/strings/regexp_test.go
@@ -50,6 +50,39 @@ func TestFindRE(t *testing.T) {
 	}
 }
 
+func TestFindRESubmatch(t *testing.T) {
+	t.Parallel()
+	c := qt.New(t)
+
+	for _, test := range []struct {
+		expr    string
+		content any
+		limit   any
+		expect  any
+	}{
+		{`<a\s*href="(.+?)">(.+?)</a>`, `<li><a href="#foo">Foo</a></li><li><a href="#bar">Bar</a></li>`, -1, [][]string{
+			{"<a href=\"#foo\">Foo</a>", "#foo", "Foo"},
+			{"<a href=\"#bar\">Bar</a>", "#bar", "Bar"},
+		}},
+		// Some simple cases.
+		{"([G|g]o)", "Hugo is a static site generator written in Go.", -1, [][]string{{"go", "go"}, {"Go", "Go"}}},
+		{"([G|g]o)", "Hugo is a static site generator written in Go.", 1, [][]string{{"go", "go"}}},
+
+		// errors
+		{"([G|go", "Hugo is a static site generator written in Go.", nil, false},
+		{"([G|g]o)", t, nil, false},
+	} {
+		result, err := ns.FindRESubmatch(test.expr, test.content, test.limit)
+
+		if b, ok := test.expect.(bool); ok && !b {
+			c.Assert(err, qt.Not(qt.IsNil))
+			continue
+		}
+
+		c.Assert(err, qt.IsNil)
+		c.Check(result, qt.DeepEquals, test.expect)
+	}
+}
 func TestReplaceRE(t *testing.T) {
 	t.Parallel()
 	c := qt.New(t)