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

34
engines/cpp/README.md Normal file
View File

@@ -0,0 +1,34 @@
# C++ regex engine
This bridge executes patterns with the `std::wregex` implementation shipped in
Emscripten 6.0.4's libc++. The default grammar is the C++ standard library's
modified ECMAScript grammar; it is not presented as modern browser ECMAScript.
The fixed Embind entry points accept structured values only. User patterns,
subjects and replacement templates are passed as data and are never evaluated
as JavaScript or C++ source. The worker remains subject to Regex Tools' hard
timeout and result limits.
Replacement is streamed through the output byte limit with libc++
`match_results::format` default grammar. A load-time differential self-test
compares bounded fixture output with the native API; capture amplification
never materializes a whole per-match expansion.
Build with the pinned Emscripten checkout:
```sh
node scripts/cpp-engine-pack.mjs --empp /absolute/path/to/em++
node scripts/install-cpp-engine.mjs
```
The builder enables `wasm-ld --trace` and accepts only the reviewed archive
set. The generated `SOURCE-MANIFEST.json` records the exact Emscripten, emsdk
and LLVM identities; maps the selected Emscripten runtime, musl 1.2.6,
emmalloc, compiler-rt 22.1.8, libc++ 21.1.8 and libc++abi 21.1.8 sources to
their pinned legal files; and records that JavaScript exceptions select no
libunwind object. The libunwind legal text is retained so that exclusion stays
explicit and reviewable.
Native positions are Unicode code-point offsets because the bridge deliberately
uses 32-bit `wchar_t`; the TypeScript adapter preserves them and separately
normalizes editor ranges to UTF-16.

View File

@@ -0,0 +1,419 @@
#include <emscripten/bind.h>
#include <emscripten/version.h>
#include <cstdint>
#include <regex>
#include <stdexcept>
#include <string>
namespace {
using emscripten::val;
constexpr int kBridgeAbiVersion = 1;
constexpr std::size_t kMaximumCaptureGroups = 1000;
std::wstring decode_utf8(const std::string& input) {
std::wstring output;
output.reserve(input.size());
for (std::size_t index = 0; index < input.size();) {
const auto first = static_cast<unsigned char>(input[index]);
std::uint32_t code_point = 0;
std::size_t width = 0;
if (first <= 0x7f) {
code_point = first;
width = 1;
} else if ((first & 0xe0) == 0xc0) {
code_point = first & 0x1f;
width = 2;
} else if ((first & 0xf0) == 0xe0) {
code_point = first & 0x0f;
width = 3;
} else if ((first & 0xf8) == 0xf0) {
code_point = first & 0x07;
width = 4;
} else {
throw std::invalid_argument("Input is not valid UTF-8.");
}
if (index + width > input.size()) {
throw std::invalid_argument("Input ends inside a UTF-8 sequence.");
}
for (std::size_t offset = 1; offset < width; ++offset) {
const auto continuation =
static_cast<unsigned char>(input[index + offset]);
if ((continuation & 0xc0) != 0x80) {
throw std::invalid_argument("Input contains invalid UTF-8.");
}
code_point = (code_point << 6) | (continuation & 0x3f);
}
const bool overlong = (width == 2 && code_point < 0x80) ||
(width == 3 && code_point < 0x800) ||
(width == 4 && code_point < 0x10000);
if (overlong || code_point > 0x10ffff ||
(code_point >= 0xd800 && code_point <= 0xdfff)) {
throw std::invalid_argument("Input contains invalid Unicode.");
}
output.push_back(static_cast<wchar_t>(code_point));
index += width;
}
return output;
}
void append_utf8_code_point(std::string& output, std::uint32_t code_point) {
if (code_point <= 0x7f) {
output.push_back(static_cast<char>(code_point));
} else if (code_point <= 0x7ff) {
output.push_back(static_cast<char>(0xc0 | (code_point >> 6)));
output.push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
} else if (code_point <= 0xffff) {
output.push_back(static_cast<char>(0xe0 | (code_point >> 12)));
output.push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
output.push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
} else {
output.push_back(static_cast<char>(0xf0 | (code_point >> 18)));
output.push_back(
static_cast<char>(0x80 | ((code_point >> 12) & 0x3f)));
output.push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
output.push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
}
}
std::string encode_utf8(const std::wstring& input) {
std::string output;
output.reserve(input.size());
for (const wchar_t character : input) {
append_utf8_code_point(output, static_cast<std::uint32_t>(character));
}
return output;
}
class BoundedOutput {
public:
explicit BoundedOutput(std::size_t maximum_bytes)
: maximum_bytes_(maximum_bytes) {}
void append(
std::wstring::const_iterator first,
std::wstring::const_iterator last) {
if (truncated_) return;
for (; first != last; ++first) {
std::string encoded;
append_utf8_code_point(
encoded, static_cast<std::uint32_t>(*first));
if (value_.size() + encoded.size() > maximum_bytes_) {
truncated_ = true;
return;
}
value_ += encoded;
}
}
void append(const std::wstring& value) {
append(value.cbegin(), value.cend());
}
const std::string& value() const { return value_; }
bool truncated() const { return truncated_; }
private:
std::size_t maximum_bytes_;
std::string value_;
bool truncated_ = false;
};
template <typename Iterator>
void append_native_format_bounded(
BoundedOutput& output,
const std::match_results<Iterator>& match,
const std::wstring& replacement) {
for (std::size_t index = 0;
index < replacement.size() && !output.truncated();
++index) {
if (replacement[index] != L'$' || index + 1 == replacement.size()) {
output.append(
replacement.cbegin() + index,
replacement.cbegin() + index + 1);
continue;
}
const wchar_t next = replacement[index + 1];
if (next == L'$') {
output.append(
replacement.cbegin() + index + 1,
replacement.cbegin() + index + 2);
++index;
} else if (next == L'&') {
output.append(match[0].first, match[0].second);
++index;
} else if (next == L'`') {
output.append(match.prefix().first, match.prefix().second);
++index;
} else if (next == L'\'') {
output.append(match.suffix().first, match.suffix().second);
++index;
} else if (next >= L'0' && next <= L'9') {
std::size_t capture = static_cast<std::size_t>(next - L'0');
++index;
if (index + 1 < replacement.size() &&
replacement[index + 1] >= L'0' &&
replacement[index + 1] <= L'9') {
capture =
capture * 10 +
static_cast<std::size_t>(replacement[index + 1] - L'0');
++index;
}
output.append(match[capture].first, match[capture].second);
} else {
output.append(
replacement.cbegin() + index,
replacement.cbegin() + index + 1);
}
}
}
bool replacement_expansion_self_test() {
const std::wstring subject = L"xab!";
const std::wregex expression(L"(a)(b)?");
std::wsmatch match;
if (!std::regex_search(subject, match, expression)) return false;
const std::wstring fixtures[] = {
L"<$1:$2:$&>",
L"$$-$`-$'",
L"$0-$00-$01-$99",
L"$x-$",
};
for (const auto& replacement : fixtures) {
BoundedOutput bounded(4096);
append_native_format_bounded(bounded, match, replacement);
if (bounded.truncated() ||
bounded.value() != encode_utf8(match.format(replacement))) {
return false;
}
}
const std::wstring large_subject(4096, L'a');
const std::wregex large_expression(L"(a+)");
std::wsmatch large_match;
if (!std::regex_search(
large_subject, large_match, large_expression)) {
return false;
}
std::wstring amplifying_replacement;
amplifying_replacement.reserve(65'536);
for (std::size_t index = 0; index < 32'768; ++index) {
amplifying_replacement += L"$1";
}
BoundedOutput adversarial(5);
append_native_format_bounded(
adversarial, large_match, amplifying_replacement);
return adversarial.truncated() && adversarial.value() == "aaaaa";
}
val failure(
const std::string& phase,
const std::string& code,
const std::string& message) {
val result = val::object();
result.set("abi", kBridgeAbiVersion);
result.set("ok", false);
result.set("phase", phase);
result.set("code", code);
result.set(
"message",
message.size() <= 1024 ? message : message.substr(0, 1024));
return result;
}
std::regex_constants::syntax_option_type syntax_options(
const val& request) {
using namespace std::regex_constants;
auto result = syntax_option_type{};
const val options = request["options"];
std::string grammar = "ECMAScript";
const val configured_grammar = options["grammar"];
if (!configured_grammar.isUndefined()) {
grammar = configured_grammar.as<std::string>();
}
if (grammar == "ECMAScript") {
result = ECMAScript;
} else if (grammar == "basic") {
result = basic;
} else if (grammar == "extended") {
result = extended;
} else if (grammar == "awk") {
result = awk;
} else if (grammar == "grep") {
result = grep;
} else if (grammar == "egrep") {
result = egrep;
} else {
throw std::invalid_argument("Unsupported std::regex grammar.");
}
const std::string flags = request["flags"].as<std::string>();
if (flags.find('i') != std::string::npos) result |= icase;
if (flags.find('n') != std::string::npos) result |= nosubs;
if (flags.find('o') != std::string::npos) result |= optimize;
if (flags.find('c') != std::string::npos) result |= collate;
return result;
}
template <typename Iterator>
val match_record(
const std::match_results<Iterator>& match,
Iterator subject_begin) {
val record = val::object();
val span = val::array();
span.call<void>(
"push",
static_cast<double>(std::distance(subject_begin, match[0].first)));
span.call<void>(
"push",
static_cast<double>(std::distance(subject_begin, match[0].second)));
record.set("span", span);
val captures = val::array();
for (std::size_t group = 1; group < match.size(); ++group) {
if (!match[group].matched) {
captures.call<void>("push", val::null());
continue;
}
val capture = val::array();
capture.call<void>(
"push",
static_cast<double>(
std::distance(subject_begin, match[group].first)));
capture.call<void>(
"push",
static_cast<double>(
std::distance(subject_begin, match[group].second)));
captures.call<void>("push", capture);
}
record.set("captures", captures);
return record;
}
val cpp_regex_identity() {
val identity = val::object();
identity.set("implementation", "libc++ std::wregex");
identity.set("cplusplus", static_cast<double>(__cplusplus));
#ifdef __EMSCRIPTEN_MAJOR__
identity.set(
"emscripten",
std::to_string(__EMSCRIPTEN_MAJOR__) + "." +
std::to_string(__EMSCRIPTEN_MINOR__) + "." +
std::to_string(__EMSCRIPTEN_TINY__));
#else
identity.set("emscripten", "unknown");
#endif
identity.set("wcharBits", static_cast<double>(sizeof(wchar_t) * 8));
identity.set(
"replacementSelfTest", replacement_expansion_self_test());
return identity;
}
val cpp_regex_run(const val& request) {
try {
if (request["abi"].as<int>() != kBridgeAbiVersion) {
return failure("bridge", "unsupported-abi", "Unsupported bridge ABI.");
}
const std::string operation = request["operation"].as<std::string>();
if (operation != "execute" && operation != "replace") {
return failure(
"bridge", "invalid-operation", "Unsupported bridge operation.");
}
const std::wstring pattern =
decode_utf8(request["pattern"].as<std::string>());
const std::wstring subject =
decode_utf8(request["subject"].as<std::string>());
const std::wstring replacement =
operation == "replace"
? decode_utf8(request["replacement"].as<std::string>())
: std::wstring();
const std::string flags = request["flags"].as<std::string>();
const bool global = flags.find('g') != std::string::npos;
const auto maximum_matches =
request["maximumMatches"].as<std::uint32_t>();
const auto maximum_capture_rows =
request["maximumCaptureRows"].as<std::uint32_t>();
const auto maximum_output_bytes =
operation == "replace"
? request["maximumOutputBytes"].as<std::uint32_t>()
: 1U;
std::wregex expression;
try {
expression.assign(pattern, syntax_options(request));
} catch (const std::regex_error& error) {
return failure("compile", "regex-error", error.what());
}
const std::size_t group_count = expression.mark_count();
if (group_count > kMaximumCaptureGroups) {
return failure(
"compile",
"capture-group-limit",
"The pattern exceeds the 1,000 capture-group bridge limit.");
}
val matches = val::array();
std::size_t match_count = 0;
std::size_t capture_rows = 0;
bool results_truncated = false;
BoundedOutput output(maximum_output_bytes);
auto next_output = subject.cbegin();
using Iterator = std::wstring::const_iterator;
std::regex_iterator<Iterator> current(
subject.cbegin(), subject.cend(), expression);
const std::regex_iterator<Iterator> end;
for (; current != end; ++current) {
const auto& match = *current;
const std::size_t rows = std::max<std::size_t>(1, group_count);
if (match_count >= maximum_matches ||
capture_rows + rows > maximum_capture_rows) {
results_truncated = true;
break;
}
matches.call<void>(
"push", match_record(match, subject.cbegin()));
++match_count;
capture_rows += rows;
if (operation == "replace") {
output.append(next_output, match[0].first);
append_native_format_bounded(output, match, replacement);
next_output = match[0].second;
}
if (!global) {
++current;
break;
}
}
if (operation == "replace") {
output.append(next_output, subject.cend());
}
val result = val::object();
result.set("abi", kBridgeAbiVersion);
result.set("ok", true);
result.set("groupCount", static_cast<double>(group_count));
result.set("groupNames", val::array());
result.set("matches", matches);
result.set("resultsTruncated", results_truncated);
if (operation == "replace") {
result.set("output", output.value());
result.set(
"outputBytes", static_cast<double>(output.value().size()));
result.set("outputTruncated", output.truncated());
}
return result;
} catch (const std::exception& error) {
return failure("bridge", "bridge-error", error.what());
} catch (...) {
return failure(
"bridge", "bridge-error", "Unknown C++ bridge failure.");
}
}
} // namespace
EMSCRIPTEN_BINDINGS(regex_tools_cpp_bridge) {
emscripten::function("regexToolsCppIdentity", &cpp_regex_identity);
emscripten::function("regexToolsCppRun", &cpp_regex_run);
}