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

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 {}
}