feat: add multi-engine regex flavour support

This commit is contained in:
2026-07-27 11:43:51 +02:00
parent 7079cde15f
commit 7f3a91ad37
340 changed files with 643286 additions and 483 deletions

26
engines/go/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Go regexp WebAssembly engine
This directory is the fixed bridge around Go 1.26.5's standard-library
`regexp` package. It is compiled with the official `go1.26.5` toolchain for
`GOOS=js GOARCH=wasm`; `public/engines/go/wasm_exec.mjs` must come from that
same toolchain.
The bridge accepts JSON data only. The adapter measures the exact encoded
request size, and the native cap includes JSON's worst-case sixfold escaping
for every accepted string field. It implements native RE2-family Go syntax,
native UTF-8 byte offsets, `SubexpNames`, `Find(All)StringSubmatchIndex`, and
`Regexp.ExpandString` replacement templates. Application flags `i`, `m`, `s`
and `U` become Go inline flags; `g` selects native global iteration.
Replacement uses a bounded streaming implementation of `Regexp.ExpandString`
grammar. Load-time differential fixtures compare it with the native API, and
capture references stop expanding as soon as the UTF-8 output cap is reached.
The bounded bytes cross the JSON bridge as base64, so control-heavy output has
a fixed 4:3 transport expansion instead of JSON's content-dependent escaping.
Go deliberately excludes look-around and backreferences. Its global iterator
also ignores an empty match that abuts the preceding match. Those behaviours
are tested rather than emulated.
The bridge independently caps pattern, subject, match, capture-row and output
sizes. Replacement output is clipped only at a complete UTF-8 boundary.

5
engines/go/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.add-ideas.de/zemion/regex-tools/engines/go
go 1.26.0
toolchain go1.26.5

521
engines/go/main.go Normal file
View File

@@ -0,0 +1,521 @@
// Regex Tools Go engine bridge.
//
// The WebAssembly module exposes two fixed callbacks. User-controlled values
// enter only through JSON data and are handed to the standard-library regexp
// package; no Go or JavaScript source is evaluated.
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"runtime"
"sort"
"strings"
"syscall/js"
"unicode"
"unicode/utf8"
)
const (
bridgeABIVersion = 1
expectedGoVersion = "go1.26.5"
maximumPatternBytes = 256 * 1024
maximumSubjectBytes = 16 * 1024 * 1024
maximumReplacementBytes = 256 * 1024
maximumMatches = 10_000
maximumCaptureRows = 100_000
maximumCaptureGroups = 1_000
maximumOutputBytes = 64 * 1024 * 1024
maximumRequestJSONBytes = 6*(maximumSubjectBytes+maximumPatternBytes+maximumReplacementBytes) + 64*1024
maximumErrorMessageBytes = 1_024
bridgeInstallerGlobalName = "__regexToolsInstallGoBridge"
)
type bridgeRequest struct {
ABI int `json:"abi"`
Operation string `json:"operation"`
Pattern string `json:"pattern"`
Flags string `json:"flags"`
Options map[string]json.RawMessage `json:"options"`
Subject string `json:"subject"`
ScanAll bool `json:"scanAll"`
MaximumMatches int `json:"maximumMatches"`
MaximumCaptureRows int `json:"maximumCaptureRows"`
Replacement *string `json:"replacement,omitempty"`
MaximumOutputBytes *int `json:"maximumOutputBytes,omitempty"`
}
type bridgeFailure struct {
ABI int `json:"abi"`
OK bool `json:"ok"`
Phase string `json:"phase"`
Code string `json:"code"`
Message string `json:"message"`
Position *int `json:"position,omitempty"`
}
type nativeMatch struct {
Span [2]int `json:"span"`
Captures []*[2]int `json:"captures"`
}
type bridgeSuccess struct {
ABI int `json:"abi"`
OK bool `json:"ok"`
GroupCount int `json:"groupCount"`
GroupNames [][2]any `json:"groupNames"`
Matches []nativeMatch `json:"matches"`
ResultsTruncated bool `json:"resultsTruncated"`
OutputBase64 *string `json:"outputBase64,omitempty"`
OutputBytes *int `json:"outputBytes,omitempty"`
OutputTruncated *bool `json:"outputTruncated,omitempty"`
}
type identityEnvelope struct {
ABI int `json:"abi"`
OK bool `json:"ok"`
Identity map[string]any `json:"identity"`
}
func boundedMessage(value string) string {
if len(value) <= maximumErrorMessageBytes {
return value
}
end := maximumErrorMessageBytes - len("…")
for end > 0 && !utf8.ValidString(value[:end]) {
end--
}
return value[:end] + "…"
}
func marshal(value any) string {
encoded, err := json.Marshal(value)
if err == nil {
return string(encoded)
}
fallback, _ := json.Marshal(bridgeFailure{
ABI: bridgeABIVersion,
OK: false,
Phase: "bridge",
Code: "serialization-error",
Message: "The Go bridge could not serialize its bounded result.",
})
return string(fallback)
}
func failure(phase, code string, err any) string {
return marshal(bridgeFailure{
ABI: bridgeABIVersion,
OK: false,
Phase: phase,
Code: code,
Message: boundedMessage(fmt.Sprint(err)),
})
}
func decodeRequest(encoded string) (bridgeRequest, string) {
var request bridgeRequest
if len(encoded) > maximumRequestJSONBytes {
return request, failure("bridge", "request-limit", "The encoded request exceeds the bridge limit.")
}
decoder := json.NewDecoder(strings.NewReader(encoded))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
return request, failure("bridge", "invalid-request", err)
}
if decoder.More() {
return request, failure("bridge", "invalid-request", "The encoded request contains trailing JSON values.")
}
return request, ""
}
func validateRequest(request bridgeRequest) string {
if request.ABI != bridgeABIVersion {
return failure("bridge", "abi-mismatch", "The bridge ABI version is unsupported.")
}
if request.Operation != "execute" && request.Operation != "replace" {
return failure("bridge", "invalid-operation", "The bridge operation is unsupported.")
}
if len(request.Options) != 0 {
return failure("bridge", "unsupported-option", "Go regexp does not accept engine options.")
}
if !utf8.ValidString(request.Pattern) || len(request.Pattern) > maximumPatternBytes {
return failure("bridge", "pattern-limit", "The pattern is invalid UTF-8 or exceeds the bridge limit.")
}
if !utf8.ValidString(request.Subject) || len(request.Subject) > maximumSubjectBytes {
return failure("bridge", "subject-limit", "The subject is invalid UTF-8 or exceeds the bridge limit.")
}
if request.MaximumMatches < 1 || request.MaximumMatches > maximumMatches ||
request.MaximumCaptureRows < 1 || request.MaximumCaptureRows > maximumCaptureRows {
return failure("bridge", "result-limit", "The requested result bounds are outside the bridge contract.")
}
seenFlags := make(map[rune]bool)
for _, flag := range request.Flags {
if !strings.ContainsRune("gimsU", flag) || seenFlags[flag] {
return failure("bridge", "unsupported-flag", "The request contains an unsupported or duplicate Go flag.")
}
seenFlags[flag] = true
}
if request.Operation == "execute" {
if request.Replacement != nil || request.MaximumOutputBytes != nil {
return failure("bridge", "invalid-request", "Execution requests cannot contain replacement fields.")
}
return ""
}
if request.Replacement == nil || request.MaximumOutputBytes == nil {
return failure("bridge", "invalid-request", "Replacement requests require a template and output bound.")
}
if !utf8.ValidString(*request.Replacement) || len(*request.Replacement) > maximumReplacementBytes {
return failure("bridge", "replacement-limit", "The replacement is invalid UTF-8 or exceeds the bridge limit.")
}
if *request.MaximumOutputBytes < 1 || *request.MaximumOutputBytes > maximumOutputBytes {
return failure("bridge", "output-limit", "The output bound is outside the bridge contract.")
}
return ""
}
func compilePattern(pattern, flags string) (*regexp.Regexp, error) {
var modifiers strings.Builder
for _, flag := range "imsU" {
if strings.ContainsRune(flags, flag) {
modifiers.WriteRune(flag)
}
}
if modifiers.Len() != 0 {
pattern = "(?" + modifiers.String() + ")" + pattern
}
return regexp.Compile(pattern)
}
func materializeMatch(indices []int, groupCount int) nativeMatch {
record := nativeMatch{
Span: [2]int{indices[0], indices[1]},
Captures: make([]*[2]int, groupCount),
}
for group := 0; group < groupCount; group++ {
start := indices[2+group*2]
end := indices[3+group*2]
if start >= 0 {
span := [2]int{start, end}
record.Captures[group] = &span
}
}
return record
}
func collectMatches(
compiled *regexp.Regexp,
subject string,
global bool,
maximumMatchCount int,
maximumRows int,
) ([][]int, bool) {
rowsPerMatch := compiled.NumSubexp()
if rowsPerMatch < 1 {
rowsPerMatch = 1
}
allowed := maximumMatchCount
if rowBound := maximumRows / rowsPerMatch; rowBound < allowed {
allowed = rowBound
}
probeLimit := allowed + 1
var indices [][]int
if global {
indices = compiled.FindAllStringSubmatchIndex(subject, probeLimit)
} else if candidate := compiled.FindStringSubmatchIndex(subject); candidate != nil {
indices = [][]int{candidate}
}
if len(indices) <= allowed {
return indices, false
}
return indices[:allowed], true
}
type boundedOutput struct {
buffer bytes.Buffer
maximum int
truncated bool
}
func (output *boundedOutput) append(value string) {
if output.truncated || value == "" {
return
}
remaining := output.maximum - output.buffer.Len()
if len(value) <= remaining {
output.buffer.WriteString(value)
return
}
end := remaining
for end > 0 && !utf8.ValidString(value[:end]) {
end--
}
output.buffer.WriteString(value[:end])
output.truncated = true
}
func extractReplacementReference(value string) (name string, number int, rest string, ok bool) {
if value == "" {
return
}
braced := false
if value[0] == '{' {
braced = true
value = value[1:]
}
index := 0
for index < len(value) {
character, size := utf8.DecodeRuneInString(value[index:])
if !unicode.IsLetter(character) && !unicode.IsDigit(character) && character != '_' {
break
}
index += size
}
if index == 0 {
return
}
name = value[:index]
if braced {
if index >= len(value) || value[index] != '}' {
return
}
index++
}
number = 0
for position := 0; position < len(name); position++ {
if name[position] < '0' || name[position] > '9' || number >= 1e8 {
number = -1
break
}
number = number*10 + int(name[position]) - '0'
}
if name[0] == '0' && len(name) > 1 {
number = -1
}
rest = value[index:]
ok = true
return
}
func expandReplacementBounded(
output *boundedOutput,
compiled *regexp.Regexp,
template, subject string,
match []int,
) {
for len(template) > 0 && !output.truncated {
before, after, found := strings.Cut(template, "$")
if !found {
break
}
output.append(before)
if output.truncated {
return
}
template = after
if template != "" && template[0] == '$' {
output.append("$")
template = template[1:]
continue
}
name, number, rest, ok := extractReplacementReference(template)
if !ok {
output.append("$")
continue
}
template = rest
if number >= 0 {
if 2*number+1 < len(match) && match[2*number] >= 0 {
output.append(subject[match[2*number]:match[2*number+1]])
}
continue
}
for index, candidate := range compiled.SubexpNames() {
if name == candidate && 2*index+1 < len(match) && match[2*index] >= 0 {
output.append(subject[match[2*index]:match[2*index+1]])
break
}
}
}
output.append(template)
}
func replaceBounded(
compiled *regexp.Regexp,
subject, replacement string,
indices [][]int,
maximumBytes int,
) (string, int, bool) {
output := boundedOutput{maximum: maximumBytes}
cursor := 0
for _, match := range indices {
output.append(subject[cursor:match[0]])
expandReplacementBounded(&output, compiled, replacement, subject, match)
cursor = match[1]
}
output.append(subject[cursor:])
value := output.buffer.Bytes()
return base64.StdEncoding.EncodeToString(value), len(value), output.truncated
}
func run(encoded string) string {
request, requestError := decodeRequest(encoded)
if requestError != "" {
return requestError
}
if validationError := validateRequest(request); validationError != "" {
return validationError
}
compiled, err := compilePattern(request.Pattern, request.Flags)
if err != nil {
return failure("compile", "compile-error", err)
}
groupCount := compiled.NumSubexp()
if groupCount > maximumCaptureGroups {
return failure(
"compile",
"capture-group-limit",
fmt.Sprintf("The pattern declares %d capture groups; the bridge limit is %d.", groupCount, maximumCaptureGroups),
)
}
names := make([][2]any, 0)
for number, name := range compiled.SubexpNames() {
if number > 0 && name != "" {
names = append(names, [2]any{number, name})
}
}
sort.Slice(names, func(left, right int) bool {
return names[left][0].(int) < names[right][0].(int)
})
global := strings.ContainsRune(request.Flags, 'g')
indices, resultsTruncated := collectMatches(
compiled,
request.Subject,
global,
request.MaximumMatches,
request.MaximumCaptureRows,
)
records := make([]nativeMatch, len(indices))
for index, match := range indices {
records[index] = materializeMatch(match, groupCount)
}
result := bridgeSuccess{
ABI: bridgeABIVersion,
OK: true,
GroupCount: groupCount,
GroupNames: names,
Matches: records,
ResultsTruncated: resultsTruncated,
}
if request.Operation == "replace" {
outputBase64, outputBytes, outputTruncated := replaceBounded(
compiled,
request.Subject,
*request.Replacement,
indices,
*request.MaximumOutputBytes,
)
result.OutputBase64 = &outputBase64
result.OutputBytes = &outputBytes
result.OutputTruncated = &outputTruncated
}
return marshal(result)
}
func selfTest() bool {
if runtime.Version() != expectedGoVersion {
return false
}
compiled, err := regexp.Compile(`(?P<word>😀+)`)
if err != nil {
return false
}
match := compiled.FindStringSubmatchIndex("x😀😀")
if len(match) != 4 || match[0] != 1 || match[1] != 9 ||
match[2] != 1 || match[3] != 9 || compiled.SubexpNames()[1] != "word" {
return false
}
for _, template := range []string{
`${word}-$1-$$`,
`$1x|${1}x`,
`${missing}|$|${word`,
`$01|${01}|$1234567890`,
} {
expected := string(compiled.ExpandString(nil, template, "x😀😀", match))
bounded := boundedOutput{maximum: 4 * 1024}
expandReplacementBounded(&bounded, compiled, template, "x😀😀", match)
if bounded.truncated || bounded.buffer.String() != expected {
return false
}
}
adversarial := boundedOutput{maximum: 5}
expandReplacementBounded(
&adversarial,
compiled,
strings.Repeat("$word", 32*1024),
"x😀😀",
match,
)
if !adversarial.truncated || adversarial.buffer.String() != "😀" {
return false
}
empty := regexp.MustCompile("a*").FindAllStringIndex("baaab", -1)
return len(empty) == 3 &&
empty[0][0] == 0 && empty[0][1] == 0 &&
empty[1][0] == 1 && empty[1][1] == 4 &&
empty[2][0] == 5 && empty[2][1] == 5
}
func identity() string {
return marshal(identityEnvelope{
ABI: bridgeABIVersion,
OK: true,
Identity: map[string]any{
"bridge": "regex-tools-go-json",
"bridgeVersion": bridgeABIVersion,
"engine": "Go standard-library regexp",
"engineVersion": "1.26.5",
"goVersion": runtime.Version(),
"offsetUnit": "utf8-byte",
"package": "regexp",
"selfTest": selfTest(),
},
})
}
var retainedCallbacks []js.Func
func main() {
identityCallback := js.FuncOf(func(_ js.Value, arguments []js.Value) any {
if len(arguments) != 0 {
return failure("bridge", "invalid-request", "The identity callback does not accept arguments.")
}
return identity()
})
runCallback := js.FuncOf(func(_ js.Value, arguments []js.Value) (result any) {
defer func() {
if recovered := recover(); recovered != nil {
result = failure("bridge", "panic", "The Go bridge stopped an unexpected internal panic.")
}
}()
if len(arguments) != 1 || arguments[0].Type() != js.TypeString {
return failure("bridge", "invalid-request", "The run callback requires one JSON string.")
}
return run(arguments[0].String())
})
retainedCallbacks = []js.Func{identityCallback, runCallback}
installer := js.Global().Get(bridgeInstallerGlobalName)
if installer.Type() != js.TypeFunction {
panic("Regex Tools Go bridge installer is unavailable")
}
installer.Invoke(identityCallback, runCallback)
select {}
}