mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
Ragel -G2 trigger matching engine, replaces std::regex hot path
Aho-Corasick automaton matches all literal trigger patterns in a single O(n) pass over incoming text. Glob patterns (* ? . ^ $) use a backtracking matcher. Complex regex patterns fall back to std::regex. Generated trigger_match.c committed for Windows builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d79fea37c
commit
c3f0ffbfc6
7 changed files with 1985 additions and 36 deletions
|
|
@ -7,32 +7,77 @@
|
|||
void Macro::compile() {
|
||||
if (trigger.empty()) {
|
||||
compiled = false;
|
||||
regex_fallback = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
trigger_re = std::regex(trigger, std::regex::ECMAScript | std::regex::icase);
|
||||
|
||||
// Check if this pattern needs std::regex fallback.
|
||||
regex_fallback = trigger_needs_regex(trigger.c_str()) != 0;
|
||||
|
||||
if (regex_fallback) {
|
||||
// Complex pattern: use std::regex.
|
||||
try {
|
||||
trigger_re = std::regex(trigger,
|
||||
std::regex::ECMAScript | std::regex::icase);
|
||||
compiled = true;
|
||||
} catch (...) {
|
||||
compiled = false;
|
||||
}
|
||||
} else {
|
||||
// Literal or glob: handled by trigger_set engine.
|
||||
compiled = true;
|
||||
} catch (...) {
|
||||
compiled = false;
|
||||
}
|
||||
}
|
||||
|
||||
MacroDB::MacroDB() {
|
||||
ts_ = trigger_set_create();
|
||||
}
|
||||
|
||||
MacroDB::~MacroDB() {
|
||||
trigger_set_free(ts_);
|
||||
}
|
||||
|
||||
void MacroDB::rebuild_trigger_set() {
|
||||
if (!ts_dirty_) return;
|
||||
|
||||
// Tear down and recreate.
|
||||
trigger_set_free(ts_);
|
||||
ts_ = trigger_set_create();
|
||||
next_trigger_id_ = 0;
|
||||
|
||||
for (auto& m : macros_) {
|
||||
if (m.trigger.empty() || m.regex_fallback) {
|
||||
m.trigger_id = -1;
|
||||
continue;
|
||||
}
|
||||
m.trigger_id = next_trigger_id_++;
|
||||
trigger_set_add(ts_, m.trigger_id,
|
||||
m.trigger.c_str(), TRIGGER_ICASE);
|
||||
}
|
||||
|
||||
trigger_set_compile(ts_);
|
||||
ts_dirty_ = false;
|
||||
}
|
||||
|
||||
void MacroDB::define(Macro m) {
|
||||
m.compile();
|
||||
// Replace existing macro with same name
|
||||
// Replace existing macro with same name.
|
||||
for (auto& existing : macros_) {
|
||||
if (existing.name == m.name) {
|
||||
existing = std::move(m);
|
||||
ts_dirty_ = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
macros_.push_back(std::move(m));
|
||||
ts_dirty_ = true;
|
||||
}
|
||||
|
||||
bool MacroDB::undef(const std::string& name) {
|
||||
for (auto it = macros_.begin(); it != macros_.end(); ++it) {
|
||||
if (it->name == name) {
|
||||
macros_.erase(it);
|
||||
ts_dirty_ = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -48,16 +93,48 @@ Macro* MacroDB::find(const std::string& name) {
|
|||
|
||||
std::vector<Macro*> MacroDB::match_triggers(const std::string& text) {
|
||||
std::vector<Macro*> result;
|
||||
for (auto& m : macros_) {
|
||||
if (!m.compiled || m.trigger.empty()) continue;
|
||||
if (m.shots == 0) continue;
|
||||
try {
|
||||
if (std::regex_search(text, m.trigger_re)) {
|
||||
result.push_back(&m);
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
// Rebuild the trigger set if triggers changed since last compile.
|
||||
if (ts_dirty_) {
|
||||
rebuild_trigger_set();
|
||||
}
|
||||
// Sort by priority (higher first)
|
||||
|
||||
// Fast path: Aho-Corasick + glob engine for literal/glob patterns.
|
||||
trigger_match_t matches[TRIGGER_MAX];
|
||||
int n = trigger_set_search(ts_, text.c_str(), text.size(),
|
||||
matches, TRIGGER_MAX);
|
||||
|
||||
// Build a set of matched trigger IDs for fast lookup.
|
||||
uint8_t matched_ids[TRIGGER_MAX / 8] = {};
|
||||
for (int i = 0; i < n; i++) {
|
||||
int id = matches[i].id;
|
||||
matched_ids[id / 8] |= (uint8_t)(1 << (id % 8));
|
||||
}
|
||||
|
||||
for (auto& m : macros_) {
|
||||
if (m.trigger.empty() || m.shots == 0) continue;
|
||||
|
||||
bool hit = false;
|
||||
|
||||
if (m.regex_fallback) {
|
||||
// Slow path: std::regex for complex patterns.
|
||||
if (m.compiled) {
|
||||
try {
|
||||
hit = std::regex_search(text, m.trigger_re);
|
||||
} catch (...) {}
|
||||
}
|
||||
} else if (m.trigger_id >= 0) {
|
||||
// Check fast-path results.
|
||||
hit = (matched_ids[m.trigger_id / 8] &
|
||||
(1 << (m.trigger_id % 8))) != 0;
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
result.push_back(&m);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (higher first).
|
||||
std::sort(result.begin(), result.end(),
|
||||
[](const Macro* a, const Macro* b) {
|
||||
return a->priority > b->priority;
|
||||
|
|
@ -72,7 +149,7 @@ TriggerResult check_triggers(App& app, std::string& text) {
|
|||
result.matched = true;
|
||||
if (m->gag) result.gagged = true;
|
||||
|
||||
// Execute the macro body as a command
|
||||
// Execute the macro body as a command.
|
||||
if (!m->body.empty()) {
|
||||
if (m->body[0] == '/') {
|
||||
app.commands.dispatch(app, m->body);
|
||||
|
|
@ -81,7 +158,7 @@ TriggerResult check_triggers(App& app, std::string& text) {
|
|||
}
|
||||
}
|
||||
|
||||
// Decrement shots
|
||||
// Decrement shots.
|
||||
if (m->shots > 0) {
|
||||
m->shots--;
|
||||
}
|
||||
|
|
@ -92,7 +169,7 @@ TriggerResult check_triggers(App& app, std::string& text) {
|
|||
// Parse /def flags:
|
||||
// /def [name] -t'pattern' [-p priority] [-n shots] [-g] [-h] body
|
||||
bool parse_def(const std::string& args, Macro& out, std::string& error) {
|
||||
// Simple parser: tokenize by spaces, handle -flags
|
||||
// Simple parser: tokenize by spaces, handle -flags.
|
||||
std::istringstream ss(args);
|
||||
std::string token;
|
||||
std::vector<std::string> body_parts;
|
||||
|
|
@ -107,7 +184,7 @@ bool parse_def(const std::string& args, Macro& out, std::string& error) {
|
|||
std::string pattern;
|
||||
if (token.size() > 2) {
|
||||
pattern = token.substr(2);
|
||||
// Strip surrounding quotes
|
||||
// Strip surrounding quotes.
|
||||
if (!pattern.empty() && (pattern[0] == '\'' || pattern[0] == '"')) {
|
||||
char q = pattern[0];
|
||||
pattern = pattern.substr(1);
|
||||
|
|
@ -152,7 +229,7 @@ bool parse_def(const std::string& args, Macro& out, std::string& error) {
|
|||
out.name = token;
|
||||
have_name = true;
|
||||
} else {
|
||||
// Rest is body
|
||||
// Rest is body.
|
||||
body_parts.push_back(token);
|
||||
std::string rest;
|
||||
std::getline(ss, rest);
|
||||
|
|
@ -161,17 +238,17 @@ bool parse_def(const std::string& args, Macro& out, std::string& error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Join body parts
|
||||
// Join body parts.
|
||||
for (size_t i = 0; i < body_parts.size(); i++) {
|
||||
if (i > 0) out.body += " ";
|
||||
out.body += body_parts[i];
|
||||
}
|
||||
// Trim leading space
|
||||
// Trim leading space.
|
||||
if (!out.body.empty() && out.body[0] == ' ') {
|
||||
out.body = out.body.substr(1);
|
||||
}
|
||||
|
||||
// Auto-generate name if not provided
|
||||
// Auto-generate name if not provided.
|
||||
if (out.name.empty()) {
|
||||
static int auto_id = 0;
|
||||
out.name = "_trig_" + std::to_string(++auto_id);
|
||||
|
|
|
|||
|
|
@ -6,18 +6,25 @@
|
|||
#include <vector>
|
||||
#include <regex>
|
||||
|
||||
extern "C" {
|
||||
#include "trigger_match.h"
|
||||
}
|
||||
|
||||
struct App;
|
||||
|
||||
struct Macro {
|
||||
std::string name;
|
||||
std::string body; // command(s) to execute
|
||||
std::string trigger; // -t pattern (regex)
|
||||
std::string trigger; // -t pattern (glob/literal or regex)
|
||||
int priority = 0; // -p (higher fires first)
|
||||
int shots = -1; // -n (negative = unlimited, 0 = dead)
|
||||
bool gag = false; // suppress matched line from display
|
||||
bool hilite = false; // highlight matched text
|
||||
|
||||
// Compiled regex (from trigger)
|
||||
int trigger_id = -1; // Index in trigger_set (-1 if regex fallback)
|
||||
bool regex_fallback = false; // True if pattern needs std::regex
|
||||
|
||||
// std::regex fallback for complex patterns.
|
||||
std::regex trigger_re;
|
||||
bool compiled = false;
|
||||
|
||||
|
|
@ -26,6 +33,9 @@ struct Macro {
|
|||
|
||||
class MacroDB {
|
||||
public:
|
||||
MacroDB();
|
||||
~MacroDB();
|
||||
|
||||
void define(Macro m);
|
||||
bool undef(const std::string& name);
|
||||
Macro* find(const std::string& name);
|
||||
|
|
@ -36,6 +46,11 @@ public:
|
|||
|
||||
private:
|
||||
std::vector<Macro> macros_;
|
||||
trigger_set* ts_ = nullptr;
|
||||
int next_trigger_id_ = 0;
|
||||
bool ts_dirty_ = true;
|
||||
|
||||
void rebuild_trigger_set();
|
||||
};
|
||||
|
||||
// Result of checking triggers against a line.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# ragel/ — Ragel -G2 string mutation primitives for PUA-colored UTF-8
|
||||
#
|
||||
# Stage 0: Scaffold + color skip + visible length
|
||||
# ragel/ — Ragel -G2 string mutation primitives and trigger matching
|
||||
#
|
||||
|
||||
CC = gcc
|
||||
|
|
@ -8,20 +6,25 @@ CFLAGS = -O2 -Wall -Wextra -Wno-implicit-fallthrough -std=c11 -g
|
|||
RAGEL = ragel
|
||||
RFLAGS = -G2 -C
|
||||
|
||||
SRCS_RL = color_ops.rl
|
||||
SRCS_C = color_ops.c unicode_tables.c test_harness.c
|
||||
OBJS = color_ops.o unicode_tables.o test_harness.o tables_ascii.o
|
||||
COLOR_OBJS = color_ops.o unicode_tables.o test_harness.o tables_ascii.o
|
||||
TRIG_OBJS = trigger_match.o test_trigger.o
|
||||
|
||||
.PHONY: all clean test
|
||||
.PHONY: all clean test test-color test-trigger
|
||||
|
||||
all: test_harness
|
||||
all: test_harness test_trigger_bin
|
||||
|
||||
# Ragel .rl -> .c
|
||||
color_ops.c: color_ops.rl
|
||||
$(RAGEL) $(RFLAGS) -o $@ $<
|
||||
|
||||
test_harness: $(OBJS)
|
||||
$(CC) $(CFLAGS) -o $@ $(OBJS)
|
||||
trigger_match.c: trigger_match.rl
|
||||
$(RAGEL) $(RFLAGS) -o $@ $<
|
||||
|
||||
test_harness: $(COLOR_OBJS)
|
||||
$(CC) $(CFLAGS) -o $@ $(COLOR_OBJS)
|
||||
|
||||
test_trigger_bin: $(TRIG_OBJS)
|
||||
$(CC) $(CFLAGS) -o $@ $(TRIG_OBJS)
|
||||
|
||||
%.o: %.c
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
|
|
@ -32,9 +35,17 @@ tables_ascii.o: ../tests/color_ops/tables_ascii.c
|
|||
test_harness.o: test_harness.c color_ops.h
|
||||
color_ops.o: color_ops.c color_ops.h unicode_tables.h
|
||||
unicode_tables.o: unicode_tables.c unicode_tables.h
|
||||
trigger_match.o: trigger_match.c trigger_match.h
|
||||
test_trigger.o: test_trigger.c trigger_match.h
|
||||
|
||||
test: test_harness
|
||||
test: test-color test-trigger
|
||||
|
||||
test-color: test_harness
|
||||
./test_harness
|
||||
|
||||
test-trigger: test_trigger_bin
|
||||
./test_trigger_bin
|
||||
|
||||
clean:
|
||||
rm -f $(OBJS) color_ops.c test_harness
|
||||
rm -f $(COLOR_OBJS) $(TRIG_OBJS) color_ops.c trigger_match.c \
|
||||
test_harness test_trigger_bin
|
||||
|
|
|
|||
404
ragel/test_trigger.c
Normal file
404
ragel/test_trigger.c
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/*
|
||||
* test_trigger.c — Test harness for the trigger matching engine.
|
||||
*/
|
||||
|
||||
#include "trigger_match.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int g_pass = 0;
|
||||
static int g_fail = 0;
|
||||
|
||||
#define TEST(name) \
|
||||
do { printf(" %-55s ", name); } while (0)
|
||||
|
||||
#define PASS() \
|
||||
do { printf("PASS\n"); g_pass++; } while (0)
|
||||
|
||||
#define FAIL(fmt, ...) \
|
||||
do { printf("FAIL " fmt "\n", ##__VA_ARGS__); g_fail++; } while (0)
|
||||
|
||||
/* ---- Helpers ---- */
|
||||
|
||||
static int search_one(trigger_set *ts, const char *text)
|
||||
{
|
||||
trigger_match_t results[TRIGGER_MAX];
|
||||
return trigger_set_search(ts, text, strlen(text),
|
||||
results, TRIGGER_MAX);
|
||||
}
|
||||
|
||||
static int search_has_id(trigger_set *ts, const char *text, int id)
|
||||
{
|
||||
trigger_match_t results[TRIGGER_MAX];
|
||||
int n = trigger_set_search(ts, text, strlen(text),
|
||||
results, TRIGGER_MAX);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (results[i].id == id) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Tests ---- */
|
||||
|
||||
static void test_single_literal(void)
|
||||
{
|
||||
printf("\n--- Single literal trigger ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "tells you", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("matches substring");
|
||||
if (search_one(ts, "Bob tells you hello")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("no match");
|
||||
if (search_one(ts, "Bob greets you") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
TEST("exact match");
|
||||
if (search_one(ts, "tells you")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("case insensitive");
|
||||
if (search_one(ts, "Bob TELLS YOU hello")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_multiple_literals(void)
|
||||
{
|
||||
printf("\n--- Multiple literal triggers ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "tells you", TRIGGER_ICASE);
|
||||
trigger_set_add(ts, 1, "are hungry", TRIGGER_ICASE);
|
||||
trigger_set_add(ts, 2, "drops gold", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("match first only");
|
||||
if (search_has_id(ts, "Bob tells you hi", 0) &&
|
||||
!search_has_id(ts, "Bob tells you hi", 1)) { PASS(); }
|
||||
else { FAIL("wrong match set"); }
|
||||
|
||||
TEST("match second only");
|
||||
if (search_has_id(ts, "You are hungry", 1) &&
|
||||
!search_has_id(ts, "You are hungry", 0)) { PASS(); }
|
||||
else { FAIL("wrong match set"); }
|
||||
|
||||
TEST("match two in one line");
|
||||
if (search_has_id(ts, "tells you that you are hungry", 0) &&
|
||||
search_has_id(ts, "tells you that you are hungry", 1)) { PASS(); }
|
||||
else { FAIL("expected both triggers"); }
|
||||
|
||||
TEST("match none");
|
||||
if (search_one(ts, "nothing interesting") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_glob_star(void)
|
||||
{
|
||||
printf("\n--- Glob * wildcards ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "drops*gold", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("matches with gap");
|
||||
if (search_one(ts, "The orc drops 50 gold coins")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("matches adjacent");
|
||||
if (search_one(ts, "drops gold here")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("matches with star=empty");
|
||||
if (search_one(ts, "dropsgold")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("no match (missing gold)");
|
||||
if (search_one(ts, "drops silver coins") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
TEST("no match (wrong order)");
|
||||
if (search_one(ts, "gold drops here") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_glob_question(void)
|
||||
{
|
||||
printf("\n--- Glob ? wildcard ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "h?llo", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("matches hello");
|
||||
if (search_one(ts, "say hello")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("matches hallo");
|
||||
if (search_one(ts, "say hallo")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("no match (hllo, missing char)");
|
||||
if (search_one(ts, "say hllo") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_glob_dot(void)
|
||||
{
|
||||
printf("\n--- Glob . wildcard ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "c.t", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("matches cat");
|
||||
if (search_one(ts, "the cat sat")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("matches cot");
|
||||
if (search_one(ts, "a cot")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("no match (ct)");
|
||||
if (search_one(ts, "act quickly") == 0 ||
|
||||
search_one(ts, "ct")) { /* 'act' contains 'act' which has a.t? No — c.t needs exactly 3 chars c?t */ }
|
||||
/* Let me just test 'ct' alone */
|
||||
if (search_one(ts, "only ct here") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_anchors(void)
|
||||
{
|
||||
printf("\n--- Anchors ^ and $ ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "^Hello", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("^ matches at start");
|
||||
if (search_one(ts, "Hello world")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("^ no match in middle");
|
||||
if (search_one(ts, "Say Hello") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
|
||||
ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "world$", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("$ matches at end");
|
||||
if (search_one(ts, "Hello world")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("$ no match in middle");
|
||||
if (search_one(ts, "world Hello") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_escape(void)
|
||||
{
|
||||
printf("\n--- Escaped special characters ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "price\\*gold", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("escaped * is literal");
|
||||
if (search_one(ts, "the price*gold is high")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("escaped * does not glob");
|
||||
if (search_one(ts, "the price of gold") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_mixed_literal_glob(void)
|
||||
{
|
||||
printf("\n--- Mixed literal and glob triggers ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "tells you", TRIGGER_ICASE); /* literal → AC */
|
||||
trigger_set_add(ts, 1, "drops*gold", TRIGGER_ICASE); /* glob */
|
||||
trigger_set_add(ts, 2, "You are hungry", TRIGGER_ICASE); /* literal → AC */
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("literal match only");
|
||||
{
|
||||
int got = search_has_id(ts, "Bob tells you hi", 0);
|
||||
int no1 = !search_has_id(ts, "Bob tells you hi", 1);
|
||||
if (got && no1) { PASS(); } else { FAIL("wrong"); }
|
||||
}
|
||||
|
||||
TEST("glob match only");
|
||||
{
|
||||
int got = search_has_id(ts, "orc drops 5 gold", 1);
|
||||
int no0 = !search_has_id(ts, "orc drops 5 gold", 0);
|
||||
if (got && no0) { PASS(); } else { FAIL("wrong"); }
|
||||
}
|
||||
|
||||
TEST("both literal and glob");
|
||||
{
|
||||
int g0 = search_has_id(ts, "tells you orc drops gold", 0);
|
||||
int g1 = search_has_id(ts, "tells you orc drops gold", 1);
|
||||
if (g0 && g1) { PASS(); } else { FAIL("expected both"); }
|
||||
}
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_remove_and_recompile(void)
|
||||
{
|
||||
printf("\n--- Remove and recompile ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "hello", TRIGGER_ICASE);
|
||||
trigger_set_add(ts, 1, "world", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("both match before remove");
|
||||
if (search_has_id(ts, "hello world", 0) &&
|
||||
search_has_id(ts, "hello world", 1)) { PASS(); }
|
||||
else { FAIL("expected both"); }
|
||||
|
||||
trigger_set_remove(ts, 0);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("only world matches after remove");
|
||||
if (!search_has_id(ts, "hello world", 0) &&
|
||||
search_has_id(ts, "hello world", 1)) { PASS(); }
|
||||
else { FAIL("wrong match set"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_needs_regex(void)
|
||||
{
|
||||
printf("\n--- trigger_needs_regex ---\n");
|
||||
|
||||
TEST("literal: no regex needed");
|
||||
if (!trigger_needs_regex("tells you")) { PASS(); }
|
||||
else { FAIL("should not need regex"); }
|
||||
|
||||
TEST("glob *: no regex needed");
|
||||
if (!trigger_needs_regex("drops*gold")) { PASS(); }
|
||||
else { FAIL("should not need regex"); }
|
||||
|
||||
TEST("alternation |: needs regex");
|
||||
if (trigger_needs_regex("cat|dog")) { PASS(); }
|
||||
else { FAIL("should need regex"); }
|
||||
|
||||
TEST("group (): needs regex");
|
||||
if (trigger_needs_regex("(hello)")) { PASS(); }
|
||||
else { FAIL("should need regex"); }
|
||||
|
||||
TEST("quantifier +: needs regex");
|
||||
if (trigger_needs_regex("he+llo")) { PASS(); }
|
||||
else { FAIL("should need regex"); }
|
||||
|
||||
TEST("char class []: needs regex");
|
||||
if (trigger_needs_regex("[abc]")) { PASS(); }
|
||||
else { FAIL("should need regex"); }
|
||||
|
||||
TEST("escaped special: no regex needed");
|
||||
if (!trigger_needs_regex("price\\+tax")) { PASS(); }
|
||||
else { FAIL("escape should prevent regex flag"); }
|
||||
}
|
||||
|
||||
static void test_empty_and_edge_cases(void)
|
||||
{
|
||||
printf("\n--- Edge cases ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "x", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("empty text matches nothing");
|
||||
if (search_one(ts, "") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
TEST("single char pattern");
|
||||
if (search_one(ts, "x")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("single char in longer text");
|
||||
if (search_one(ts, "abcxdef")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
|
||||
/* Overlapping patterns. */
|
||||
ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "he", TRIGGER_ICASE);
|
||||
trigger_set_add(ts, 1, "hello", TRIGGER_ICASE);
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("overlapping: both match");
|
||||
if (search_has_id(ts, "hello world", 0) &&
|
||||
search_has_id(ts, "hello world", 1)) { PASS(); }
|
||||
else { FAIL("expected both"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
static void test_case_sensitive(void)
|
||||
{
|
||||
printf("\n--- Case-sensitive matching (no ICASE) ---\n");
|
||||
|
||||
trigger_set *ts = trigger_set_create();
|
||||
trigger_set_add(ts, 0, "Hello", 0); /* No TRIGGER_ICASE */
|
||||
trigger_set_compile(ts);
|
||||
|
||||
TEST("exact case matches");
|
||||
if (search_one(ts, "say Hello there")) { PASS(); }
|
||||
else { FAIL("expected match"); }
|
||||
|
||||
TEST("wrong case does not match");
|
||||
if (search_one(ts, "say hello there") == 0) { PASS(); }
|
||||
else { FAIL("expected no match"); }
|
||||
|
||||
trigger_set_free(ts);
|
||||
}
|
||||
|
||||
/* ---- Main ---- */
|
||||
|
||||
int main(void)
|
||||
{
|
||||
printf("=== Trigger Match Engine Tests ===\n");
|
||||
|
||||
test_single_literal();
|
||||
test_multiple_literals();
|
||||
test_glob_star();
|
||||
test_glob_question();
|
||||
test_glob_dot();
|
||||
test_anchors();
|
||||
test_escape();
|
||||
test_mixed_literal_glob();
|
||||
test_remove_and_recompile();
|
||||
test_needs_regex();
|
||||
test_empty_and_edge_cases();
|
||||
test_case_sensitive();
|
||||
|
||||
printf("\n=== Results: %d passed, %d failed ===\n",
|
||||
g_pass, g_fail);
|
||||
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
691
ragel/trigger_match.c
Normal file
691
ragel/trigger_match.c
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
|
||||
#line 1 "trigger_match.rl"
|
||||
/*
|
||||
* trigger_match.rl — Multi-pattern trigger matching engine.
|
||||
*
|
||||
* Ragel -G2 generates the pattern parser. The matching engine is
|
||||
* pure C: Aho-Corasick for simultaneous literal matching, plus a
|
||||
* backtracking glob matcher for wildcard patterns.
|
||||
*
|
||||
* Build: ragel -G2 -C -o trigger_match.c trigger_match.rl
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "trigger_match.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ASCII case folding */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static inline unsigned char ascii_lower(unsigned char c)
|
||||
{
|
||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pattern segment types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
enum {
|
||||
SEG_LITERAL, /* Single literal byte */
|
||||
SEG_STAR, /* * — match any sequence */
|
||||
SEG_QUESTION, /* ? or . — match any single byte */
|
||||
SEG_BOL, /* ^ — anchor to start of line */
|
||||
SEG_EOL /* $ — anchor to end of line */
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint8_t type;
|
||||
unsigned char ch; /* For SEG_LITERAL */
|
||||
} trig_seg_t;
|
||||
|
||||
#define MAX_SEGS 512
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ragel pattern parser */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
|
||||
#line 49 "trigger_match.c"
|
||||
static const int trigger_parse_start = 1;
|
||||
|
||||
static const int trigger_parse_en_main = 1;
|
||||
|
||||
|
||||
#line 137 "trigger_match.rl"
|
||||
|
||||
|
||||
/*
|
||||
* Parse a trigger pattern into segments.
|
||||
* Returns the number of segments, or -1 on error.
|
||||
* Sets *is_glob if the pattern has glob wildcards (* ? .).
|
||||
* Sets *is_regex if the pattern needs std::regex fallback.
|
||||
*/
|
||||
static int trigger_parse_pattern(const char *pattern, size_t plen,
|
||||
trig_seg_t *segs, int max_segs,
|
||||
int *is_glob, int *is_regex)
|
||||
{
|
||||
int cs;
|
||||
const unsigned char *p = (const unsigned char *)pattern;
|
||||
const unsigned char *pe = p + plen;
|
||||
int nsegs = 0;
|
||||
int eol_pending = 0;
|
||||
|
||||
*is_glob = 0;
|
||||
*is_regex = 0;
|
||||
|
||||
|
||||
#line 74 "trigger_match.c"
|
||||
{
|
||||
cs = trigger_parse_start;
|
||||
}
|
||||
|
||||
#line 159 "trigger_match.rl"
|
||||
(void)trigger_parse_en_main; /* Suppress unused-variable warning. */
|
||||
|
||||
#line 78 "trigger_match.c"
|
||||
{
|
||||
if ( p == pe )
|
||||
goto _test_eof;
|
||||
switch ( cs )
|
||||
{
|
||||
tr0:
|
||||
#line 103 "trigger_match.rl"
|
||||
{
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_LITERAL;
|
||||
segs[nsegs].ch = (*p);
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
goto st1;
|
||||
tr1:
|
||||
#line 52 "trigger_match.rl"
|
||||
{
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_LITERAL;
|
||||
segs[nsegs].ch = (*p);
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
goto st1;
|
||||
tr2:
|
||||
#line 97 "trigger_match.rl"
|
||||
{
|
||||
/* $ at end of pattern = EOL anchor; elsewhere = regex. */
|
||||
eol_pending = 1;
|
||||
*is_glob = 1;
|
||||
}
|
||||
goto st1;
|
||||
tr3:
|
||||
#line 111 "trigger_match.rl"
|
||||
{
|
||||
*is_regex = 1;
|
||||
}
|
||||
goto st1;
|
||||
tr4:
|
||||
#line 60 "trigger_match.rl"
|
||||
{
|
||||
if (nsegs < max_segs) {
|
||||
/* Collapse consecutive stars. */
|
||||
if (nsegs == 0 || segs[nsegs - 1].type != SEG_STAR) {
|
||||
segs[nsegs].type = SEG_STAR;
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
*is_glob = 1;
|
||||
}
|
||||
goto st1;
|
||||
tr5:
|
||||
#line 79 "trigger_match.rl"
|
||||
{
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_QUESTION;
|
||||
nsegs++;
|
||||
}
|
||||
*is_glob = 1;
|
||||
}
|
||||
goto st1;
|
||||
tr6:
|
||||
#line 71 "trigger_match.rl"
|
||||
{
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_QUESTION;
|
||||
nsegs++;
|
||||
}
|
||||
*is_glob = 1;
|
||||
}
|
||||
goto st1;
|
||||
tr8:
|
||||
#line 87 "trigger_match.rl"
|
||||
{
|
||||
if (nsegs == 0 && nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_BOL;
|
||||
nsegs++;
|
||||
*is_glob = 1;
|
||||
} else {
|
||||
*is_regex = 1;
|
||||
}
|
||||
}
|
||||
goto st1;
|
||||
st1:
|
||||
if ( ++p == pe )
|
||||
goto _test_eof1;
|
||||
case 1:
|
||||
#line 158 "trigger_match.c"
|
||||
switch( (*p) ) {
|
||||
case 36u: goto tr2;
|
||||
case 42u: goto tr4;
|
||||
case 46u: goto tr5;
|
||||
case 63u: goto tr6;
|
||||
case 92u: goto st0;
|
||||
case 94u: goto tr8;
|
||||
}
|
||||
if ( (*p) < 91u ) {
|
||||
if ( 40u <= (*p) && (*p) <= 43u )
|
||||
goto tr3;
|
||||
} else if ( (*p) > 93u ) {
|
||||
if ( 123u <= (*p) && (*p) <= 125u )
|
||||
goto tr3;
|
||||
} else
|
||||
goto tr3;
|
||||
goto tr1;
|
||||
st0:
|
||||
if ( ++p == pe )
|
||||
goto _test_eof0;
|
||||
case 0:
|
||||
goto tr0;
|
||||
}
|
||||
_test_eof1: cs = 1; goto _test_eof;
|
||||
_test_eof0: cs = 0; goto _test_eof;
|
||||
|
||||
_test_eof: {}
|
||||
}
|
||||
|
||||
#line 161 "trigger_match.rl"
|
||||
|
||||
/* If $ was the last character, it's a valid EOL anchor. */
|
||||
if (eol_pending) {
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_EOL;
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
|
||||
return nsegs;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Aho-Corasick multi-pattern automaton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define AC_ALPHA 256
|
||||
|
||||
typedef struct {
|
||||
int go[AC_ALPHA]; /* Goto: byte → next state (-1 = undefined) */
|
||||
int fail; /* Failure link */
|
||||
int match_id; /* Trigger ID ending here (-1 = none) */
|
||||
int dict_link; /* Nearest match state via failure chain */
|
||||
} ac_node_t;
|
||||
|
||||
typedef struct {
|
||||
ac_node_t *nodes;
|
||||
int num;
|
||||
int cap;
|
||||
} ac_machine_t;
|
||||
|
||||
static void ac_init(ac_machine_t *ac)
|
||||
{
|
||||
ac->cap = 64;
|
||||
ac->num = 0;
|
||||
ac->nodes = (ac_node_t *)calloc((size_t)ac->cap, sizeof(ac_node_t));
|
||||
}
|
||||
|
||||
static int ac_new_state(ac_machine_t *ac)
|
||||
{
|
||||
if (ac->num >= ac->cap) {
|
||||
ac->cap *= 2;
|
||||
ac->nodes = (ac_node_t *)realloc(ac->nodes,
|
||||
(size_t)ac->cap * sizeof(ac_node_t));
|
||||
}
|
||||
int s = ac->num++;
|
||||
memset(ac->nodes[s].go, -1, sizeof(ac->nodes[s].go));
|
||||
ac->nodes[s].fail = 0;
|
||||
ac->nodes[s].match_id = -1;
|
||||
ac->nodes[s].dict_link = -1;
|
||||
return s;
|
||||
}
|
||||
|
||||
static void ac_free(ac_machine_t *ac)
|
||||
{
|
||||
free(ac->nodes);
|
||||
ac->nodes = NULL;
|
||||
ac->num = ac->cap = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Insert a literal byte string into the trie.
|
||||
* Bytes are pre-folded to lowercase if icase.
|
||||
*/
|
||||
static void ac_insert(ac_machine_t *ac,
|
||||
const unsigned char *pat, int patlen,
|
||||
int trigger_id, int icase)
|
||||
{
|
||||
int state = 0; /* root */
|
||||
for (int i = 0; i < patlen; i++) {
|
||||
unsigned char c = icase ? ascii_lower(pat[i]) : pat[i];
|
||||
if (ac->nodes[state].go[c] < 0) {
|
||||
ac->nodes[state].go[c] = ac_new_state(ac);
|
||||
}
|
||||
state = ac->nodes[state].go[c];
|
||||
}
|
||||
ac->nodes[state].match_id = trigger_id;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build failure links and complete the goto function.
|
||||
* After this, go[state][byte] is always >= 0 for any state and byte.
|
||||
*/
|
||||
static void ac_build(ac_machine_t *ac)
|
||||
{
|
||||
int *queue = (int *)malloc((size_t)ac->num * sizeof(int));
|
||||
int qh = 0, qt = 0;
|
||||
|
||||
/* Root: undefined transitions loop back to root (state 0). */
|
||||
for (int c = 0; c < AC_ALPHA; c++) {
|
||||
int s = ac->nodes[0].go[c];
|
||||
if (s > 0) {
|
||||
ac->nodes[s].fail = 0;
|
||||
ac->nodes[s].dict_link = -1;
|
||||
queue[qt++] = s;
|
||||
} else {
|
||||
ac->nodes[0].go[c] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* BFS: compute failure links and complete goto. */
|
||||
while (qh < qt) {
|
||||
int u = queue[qh++];
|
||||
for (int c = 0; c < AC_ALPHA; c++) {
|
||||
int v = ac->nodes[u].go[c];
|
||||
if (v > 0) {
|
||||
int f = ac->nodes[ac->nodes[u].fail].go[c];
|
||||
ac->nodes[v].fail = f;
|
||||
ac->nodes[v].dict_link =
|
||||
(ac->nodes[f].match_id >= 0) ? f
|
||||
: ac->nodes[f].dict_link;
|
||||
queue[qt++] = v;
|
||||
} else {
|
||||
ac->nodes[u].go[c] = ac->nodes[ac->nodes[u].fail].go[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(queue);
|
||||
}
|
||||
|
||||
/*
|
||||
* Search text using the compiled AC automaton.
|
||||
* Returns number of distinct trigger IDs that matched.
|
||||
* Uses a bitmask to avoid duplicate reports.
|
||||
*/
|
||||
static int ac_search(const ac_machine_t *ac,
|
||||
const unsigned char *text, size_t tlen,
|
||||
int icase,
|
||||
trigger_match_t *results, int max_results)
|
||||
{
|
||||
int n = 0;
|
||||
/* Bitmask for dedup (TRIGGER_MAX <= 256, use 256 bits = 32 bytes). */
|
||||
uint8_t seen[TRIGGER_MAX / 8];
|
||||
memset(seen, 0, sizeof(seen));
|
||||
|
||||
int state = 0;
|
||||
for (size_t i = 0; i < tlen && n < max_results; i++) {
|
||||
unsigned char c = icase ? ascii_lower(text[i]) : text[i];
|
||||
state = ac->nodes[state].go[c];
|
||||
|
||||
/* Check for matches at this state and via dict_link chain. */
|
||||
int tmp = state;
|
||||
while (tmp > 0 && n < max_results) {
|
||||
if (ac->nodes[tmp].match_id >= 0) {
|
||||
int id = ac->nodes[tmp].match_id;
|
||||
int idx = id / 8;
|
||||
int bit = 1 << (id % 8);
|
||||
if (!(seen[idx] & bit)) {
|
||||
seen[idx] |= (uint8_t)bit;
|
||||
results[n].id = id;
|
||||
results[n].offset = i; /* End of match position. */
|
||||
n++;
|
||||
}
|
||||
}
|
||||
tmp = ac->nodes[tmp].dict_link;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Glob pattern matcher (backtracking) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
int id;
|
||||
int flags;
|
||||
trig_seg_t *segs;
|
||||
int nsegs;
|
||||
int anchored_start; /* Pattern began with ^ */
|
||||
} glob_pattern_t;
|
||||
|
||||
/*
|
||||
* Try matching a glob pattern anchored at text[0..tlen).
|
||||
* The match need not consume all of text (unanchored end, unless EOL).
|
||||
*/
|
||||
static int glob_match_at(const trig_seg_t *segs, int nsegs,
|
||||
const unsigned char *text, size_t tlen, int icase)
|
||||
{
|
||||
int si = 0;
|
||||
size_t ti = 0;
|
||||
int save_si = -1;
|
||||
size_t save_ti = 0;
|
||||
|
||||
for (;;) {
|
||||
if (si == nsegs) {
|
||||
return 1; /* All segments consumed — match. */
|
||||
}
|
||||
|
||||
switch (segs[si].type) {
|
||||
case SEG_STAR:
|
||||
save_si = si + 1;
|
||||
save_ti = ti;
|
||||
si++;
|
||||
continue;
|
||||
|
||||
case SEG_EOL:
|
||||
if (ti == tlen) { si++; continue; }
|
||||
break; /* Mismatch. */
|
||||
|
||||
case SEG_LITERAL:
|
||||
if (ti < tlen) {
|
||||
unsigned char pc = icase ? ascii_lower(segs[si].ch) : segs[si].ch;
|
||||
unsigned char tc = icase ? ascii_lower(text[ti]) : text[ti];
|
||||
if (pc == tc) { si++; ti++; continue; }
|
||||
}
|
||||
break; /* Mismatch. */
|
||||
|
||||
case SEG_QUESTION:
|
||||
if (ti < tlen) { si++; ti++; continue; }
|
||||
break; /* Mismatch. */
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
/* Backtrack to last STAR. */
|
||||
if (save_si >= 0 && save_ti < tlen) {
|
||||
save_ti++;
|
||||
si = save_si;
|
||||
ti = save_ti;
|
||||
continue;
|
||||
}
|
||||
|
||||
return 0; /* No match. */
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Search for a glob pattern anywhere in text.
|
||||
* If anchored_start, only try position 0.
|
||||
*/
|
||||
static int glob_search(const glob_pattern_t *gp,
|
||||
const unsigned char *text, size_t tlen)
|
||||
{
|
||||
int icase = (gp->flags & TRIGGER_ICASE) != 0;
|
||||
|
||||
if (gp->anchored_start) {
|
||||
return glob_match_at(gp->segs, gp->nsegs, text, tlen, icase);
|
||||
}
|
||||
|
||||
for (size_t start = 0; start <= tlen; start++) {
|
||||
if (glob_match_at(gp->segs, gp->nsegs,
|
||||
text + start, tlen - start, icase)) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Stored trigger entry (before compilation) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
int id;
|
||||
char *pattern;
|
||||
int flags;
|
||||
int active;
|
||||
} trigger_entry_t;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* trigger_set struct */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
struct trigger_set {
|
||||
trigger_entry_t entries[TRIGGER_MAX];
|
||||
int num_entries;
|
||||
|
||||
/* Compiled AC machine for literal patterns. */
|
||||
ac_machine_t ac;
|
||||
int ac_icase; /* Global icase for AC (all same). */
|
||||
int ac_count; /* Number of patterns in AC. */
|
||||
|
||||
/* Compiled glob patterns for wildcard patterns. */
|
||||
glob_pattern_t *globs;
|
||||
int num_globs;
|
||||
int cap_globs;
|
||||
|
||||
int compiled;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
trigger_set *trigger_set_create(void)
|
||||
{
|
||||
trigger_set *ts = (trigger_set *)calloc(1, sizeof(trigger_set));
|
||||
return ts;
|
||||
}
|
||||
|
||||
static void free_globs(trigger_set *ts)
|
||||
{
|
||||
for (int i = 0; i < ts->num_globs; i++) {
|
||||
free(ts->globs[i].segs);
|
||||
}
|
||||
free(ts->globs);
|
||||
ts->globs = NULL;
|
||||
ts->num_globs = 0;
|
||||
ts->cap_globs = 0;
|
||||
}
|
||||
|
||||
void trigger_set_free(trigger_set *ts)
|
||||
{
|
||||
if (!ts) return;
|
||||
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
free(ts->entries[i].pattern);
|
||||
}
|
||||
ac_free(&ts->ac);
|
||||
free_globs(ts);
|
||||
free(ts);
|
||||
}
|
||||
|
||||
int trigger_set_add(trigger_set *ts, int id,
|
||||
const char *pattern, int flags)
|
||||
{
|
||||
if (!ts || id < 0 || id >= TRIGGER_MAX || !pattern) return -1;
|
||||
|
||||
/* Check for existing entry with same ID and replace. */
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
if (ts->entries[i].id == id && ts->entries[i].active) {
|
||||
free(ts->entries[i].pattern);
|
||||
ts->entries[i].pattern = strdup(pattern);
|
||||
ts->entries[i].flags = flags;
|
||||
ts->compiled = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (ts->num_entries >= TRIGGER_MAX) return -1;
|
||||
|
||||
trigger_entry_t *e = &ts->entries[ts->num_entries++];
|
||||
e->id = id;
|
||||
e->pattern = strdup(pattern);
|
||||
e->flags = flags;
|
||||
e->active = 1;
|
||||
ts->compiled = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_set_remove(trigger_set *ts, int id)
|
||||
{
|
||||
if (!ts) return -1;
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
if (ts->entries[i].id == id && ts->entries[i].active) {
|
||||
ts->entries[i].active = 0;
|
||||
ts->compiled = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int trigger_set_compile(trigger_set *ts)
|
||||
{
|
||||
if (!ts) return -1;
|
||||
|
||||
/* Tear down previous compilation. */
|
||||
ac_free(&ts->ac);
|
||||
free_globs(ts);
|
||||
ts->ac_count = 0;
|
||||
|
||||
/* Initialize AC with root state. */
|
||||
ac_init(&ts->ac);
|
||||
ac_new_state(&ts->ac); /* State 0 = root. */
|
||||
|
||||
/* Classify each active entry and route to AC or glob. */
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
trigger_entry_t *e = &ts->entries[i];
|
||||
if (!e->active) continue;
|
||||
|
||||
trig_seg_t segs[MAX_SEGS];
|
||||
int is_glob = 0;
|
||||
int is_regex = 0;
|
||||
size_t plen = strlen(e->pattern);
|
||||
|
||||
int nsegs = trigger_parse_pattern(e->pattern, plen,
|
||||
segs, MAX_SEGS,
|
||||
&is_glob, &is_regex);
|
||||
if (nsegs < 0) continue;
|
||||
|
||||
/* Regex patterns can't be handled here — skip.
|
||||
* The caller should check trigger_needs_regex() and use
|
||||
* std::regex for those patterns. */
|
||||
if (is_regex) continue;
|
||||
|
||||
int icase = (e->flags & TRIGGER_ICASE) != 0;
|
||||
|
||||
if (!is_glob) {
|
||||
/* Pure literal: insert into AC automaton. */
|
||||
unsigned char lit[MAX_SEGS];
|
||||
int llen = 0;
|
||||
for (int j = 0; j < nsegs; j++) {
|
||||
if (segs[j].type == SEG_LITERAL && llen < MAX_SEGS) {
|
||||
lit[llen++] = segs[j].ch;
|
||||
}
|
||||
}
|
||||
ac_insert(&ts->ac, lit, llen, e->id, icase);
|
||||
ts->ac_icase = icase;
|
||||
ts->ac_count++;
|
||||
} else {
|
||||
/* Glob: compile to glob_pattern_t. */
|
||||
if (ts->num_globs >= ts->cap_globs) {
|
||||
ts->cap_globs = ts->cap_globs ? ts->cap_globs * 2 : 16;
|
||||
ts->globs = (glob_pattern_t *)realloc(
|
||||
ts->globs,
|
||||
(size_t)ts->cap_globs * sizeof(glob_pattern_t));
|
||||
}
|
||||
glob_pattern_t *gp = &ts->globs[ts->num_globs++];
|
||||
gp->id = e->id;
|
||||
gp->flags = e->flags;
|
||||
|
||||
/* Check for BOL anchor. */
|
||||
int seg_start = 0;
|
||||
gp->anchored_start = 0;
|
||||
if (nsegs > 0 && segs[0].type == SEG_BOL) {
|
||||
gp->anchored_start = 1;
|
||||
seg_start = 1;
|
||||
}
|
||||
|
||||
int seg_count = nsegs - seg_start;
|
||||
gp->segs = (trig_seg_t *)malloc((size_t)seg_count * sizeof(trig_seg_t));
|
||||
gp->nsegs = seg_count;
|
||||
memcpy(gp->segs, segs + seg_start,
|
||||
(size_t)seg_count * sizeof(trig_seg_t));
|
||||
}
|
||||
}
|
||||
|
||||
/* Build AC failure links. */
|
||||
if (ts->ac_count > 0) {
|
||||
ac_build(&ts->ac);
|
||||
}
|
||||
|
||||
ts->compiled = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_set_search(const trigger_set *ts,
|
||||
const char *text, size_t len,
|
||||
trigger_match_t *results, int max_results)
|
||||
{
|
||||
if (!ts || !ts->compiled || !text || max_results <= 0) return 0;
|
||||
|
||||
int n = 0;
|
||||
|
||||
/* AC search for literal patterns. */
|
||||
if (ts->ac_count > 0) {
|
||||
n = ac_search(&ts->ac,
|
||||
(const unsigned char *)text, len,
|
||||
ts->ac_icase,
|
||||
results, max_results);
|
||||
}
|
||||
|
||||
/* Glob search for wildcard patterns. */
|
||||
for (int i = 0; i < ts->num_globs && n < max_results; i++) {
|
||||
if (glob_search(&ts->globs[i],
|
||||
(const unsigned char *)text, len)) {
|
||||
results[n].id = ts->globs[i].id;
|
||||
results[n].offset = 0; /* Glob doesn't track offset. */
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
int trigger_needs_regex(const char *pattern)
|
||||
{
|
||||
if (!pattern) return 0;
|
||||
|
||||
trig_seg_t segs[MAX_SEGS];
|
||||
int is_glob = 0;
|
||||
int is_regex = 0;
|
||||
|
||||
trigger_parse_pattern(pattern, strlen(pattern),
|
||||
segs, MAX_SEGS,
|
||||
&is_glob, &is_regex);
|
||||
return is_regex;
|
||||
}
|
||||
107
ragel/trigger_match.h
Normal file
107
ragel/trigger_match.h
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* trigger_match.h — Multi-pattern trigger matching engine.
|
||||
*
|
||||
* Replaces per-trigger std::regex_search with a single-pass Aho-Corasick
|
||||
* automaton for literal patterns and a backtracking glob matcher for
|
||||
* wildcard patterns. Complex PCRE-style patterns (alternation, groups,
|
||||
* lookahead, backreferences) are flagged for std::regex fallback.
|
||||
*
|
||||
* Design: same committed-C-artifact pattern as color_ops.rl.
|
||||
* The .rl source lives in ragel/, Ragel -G2 generates trigger_match.c,
|
||||
* and that generated file is checked in so builds don't need Ragel.
|
||||
*/
|
||||
|
||||
#ifndef TRIGGER_MATCH_H
|
||||
#define TRIGGER_MATCH_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Maximum triggers in a single set. */
|
||||
#define TRIGGER_MAX 256
|
||||
|
||||
/* Pattern flags. */
|
||||
#define TRIGGER_ICASE 0x01 /* Case-insensitive (default for MUD triggers) */
|
||||
|
||||
/* Opaque trigger set. */
|
||||
typedef struct trigger_set trigger_set;
|
||||
|
||||
/* Match result for one trigger. */
|
||||
typedef struct {
|
||||
int id; /* Caller-assigned trigger ID */
|
||||
size_t offset; /* Byte offset in text where match starts */
|
||||
} trigger_match_t;
|
||||
|
||||
/*
|
||||
* trigger_set_create — Allocate an empty trigger set.
|
||||
*/
|
||||
trigger_set *trigger_set_create(void);
|
||||
|
||||
/*
|
||||
* trigger_set_free — Destroy a trigger set and all compiled state.
|
||||
*/
|
||||
void trigger_set_free(trigger_set *ts);
|
||||
|
||||
/*
|
||||
* trigger_set_add — Add a trigger pattern.
|
||||
*
|
||||
* id: Caller-assigned ID (0..TRIGGER_MAX-1).
|
||||
* pattern: Trigger pattern string (glob or literal).
|
||||
* flags: TRIGGER_ICASE etc.
|
||||
*
|
||||
* Returns 0 on success, -1 on error (bad id, too many, etc.).
|
||||
* Invalidates any prior compilation — call trigger_set_compile() again.
|
||||
*/
|
||||
int trigger_set_add(trigger_set *ts, int id,
|
||||
const char *pattern, int flags);
|
||||
|
||||
/*
|
||||
* trigger_set_remove — Remove a trigger by ID.
|
||||
*
|
||||
* Returns 0 on success, -1 if not found.
|
||||
* Invalidates any prior compilation.
|
||||
*/
|
||||
int trigger_set_remove(trigger_set *ts, int id);
|
||||
|
||||
/*
|
||||
* trigger_set_compile — Build the matching automaton.
|
||||
*
|
||||
* Must be called after add/remove and before search.
|
||||
* Returns 0 on success, -1 on error.
|
||||
*/
|
||||
int trigger_set_compile(trigger_set *ts);
|
||||
|
||||
/*
|
||||
* trigger_set_search — Match text against all compiled triggers.
|
||||
*
|
||||
* text: Input line (not necessarily NUL-terminated).
|
||||
* len: Length of text in bytes.
|
||||
* results: Output array for matches.
|
||||
* max_results: Capacity of results[].
|
||||
*
|
||||
* Returns number of matches written to results[].
|
||||
* Matching is unanchored (substring search) unless the pattern
|
||||
* starts with ^ or ends with $.
|
||||
*/
|
||||
int trigger_set_search(const trigger_set *ts,
|
||||
const char *text, size_t len,
|
||||
trigger_match_t *results, int max_results);
|
||||
|
||||
/*
|
||||
* trigger_needs_regex — Check if a pattern needs std::regex fallback.
|
||||
*
|
||||
* Returns nonzero if the pattern uses features beyond literal/glob
|
||||
* (alternation, groups, quantifiers other than *, lookahead, etc.).
|
||||
* The caller should route these patterns to std::regex instead.
|
||||
*/
|
||||
int trigger_needs_regex(const char *pattern);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* TRIGGER_MATCH_H */
|
||||
644
ragel/trigger_match.rl
Normal file
644
ragel/trigger_match.rl
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
/*
|
||||
* trigger_match.rl — Multi-pattern trigger matching engine.
|
||||
*
|
||||
* Ragel -G2 generates the pattern parser. The matching engine is
|
||||
* pure C: Aho-Corasick for simultaneous literal matching, plus a
|
||||
* backtracking glob matcher for wildcard patterns.
|
||||
*
|
||||
* Build: ragel -G2 -C -o trigger_match.c trigger_match.rl
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "trigger_match.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ASCII case folding */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static inline unsigned char ascii_lower(unsigned char c)
|
||||
{
|
||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pattern segment types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
enum {
|
||||
SEG_LITERAL, /* Single literal byte */
|
||||
SEG_STAR, /* * — match any sequence */
|
||||
SEG_QUESTION, /* ? or . — match any single byte */
|
||||
SEG_BOL, /* ^ — anchor to start of line */
|
||||
SEG_EOL /* $ — anchor to end of line */
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint8_t type;
|
||||
unsigned char ch; /* For SEG_LITERAL */
|
||||
} trig_seg_t;
|
||||
|
||||
#define MAX_SEGS 512
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ragel pattern parser */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
%%{
|
||||
machine trigger_parse;
|
||||
alphtype unsigned char;
|
||||
|
||||
action emit_literal {
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_LITERAL;
|
||||
segs[nsegs].ch = fc;
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
|
||||
action emit_star {
|
||||
if (nsegs < max_segs) {
|
||||
/* Collapse consecutive stars. */
|
||||
if (nsegs == 0 || segs[nsegs - 1].type != SEG_STAR) {
|
||||
segs[nsegs].type = SEG_STAR;
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
*is_glob = 1;
|
||||
}
|
||||
|
||||
action emit_question {
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_QUESTION;
|
||||
nsegs++;
|
||||
}
|
||||
*is_glob = 1;
|
||||
}
|
||||
|
||||
action emit_dot {
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_QUESTION;
|
||||
nsegs++;
|
||||
}
|
||||
*is_glob = 1;
|
||||
}
|
||||
|
||||
action emit_bol {
|
||||
if (nsegs == 0 && nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_BOL;
|
||||
nsegs++;
|
||||
*is_glob = 1;
|
||||
} else {
|
||||
*is_regex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
action emit_eol {
|
||||
/* $ at end of pattern = EOL anchor; elsewhere = regex. */
|
||||
eol_pending = 1;
|
||||
*is_glob = 1;
|
||||
}
|
||||
|
||||
action emit_escaped {
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_LITERAL;
|
||||
segs[nsegs].ch = fc;
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
|
||||
action mark_regex {
|
||||
*is_regex = 1;
|
||||
}
|
||||
|
||||
# Escaped character: backslash followed by any byte.
|
||||
escape = '\\' any @emit_escaped;
|
||||
|
||||
# Glob wildcards.
|
||||
star = '*' @emit_star;
|
||||
question = '?' @emit_question;
|
||||
dot = '.' @emit_dot;
|
||||
|
||||
# Anchors.
|
||||
caret = '^' @emit_bol;
|
||||
dollar = '$' @emit_eol;
|
||||
|
||||
# Characters that require std::regex fallback.
|
||||
regex_char = [+|(){}\[\]] @mark_regex;
|
||||
|
||||
# Plain literal: anything not special.
|
||||
literal = (any - [*?\\+|(){}\[\]^$.]) @emit_literal;
|
||||
|
||||
main := (escape | star | question | dot | caret | dollar
|
||||
| regex_char | literal)*;
|
||||
|
||||
write data noerror nofinal;
|
||||
}%%
|
||||
|
||||
/*
|
||||
* Parse a trigger pattern into segments.
|
||||
* Returns the number of segments, or -1 on error.
|
||||
* Sets *is_glob if the pattern has glob wildcards (* ? .).
|
||||
* Sets *is_regex if the pattern needs std::regex fallback.
|
||||
*/
|
||||
static int trigger_parse_pattern(const char *pattern, size_t plen,
|
||||
trig_seg_t *segs, int max_segs,
|
||||
int *is_glob, int *is_regex)
|
||||
{
|
||||
int cs;
|
||||
const unsigned char *p = (const unsigned char *)pattern;
|
||||
const unsigned char *pe = p + plen;
|
||||
int nsegs = 0;
|
||||
int eol_pending = 0;
|
||||
|
||||
*is_glob = 0;
|
||||
*is_regex = 0;
|
||||
|
||||
%% write init;
|
||||
(void)trigger_parse_en_main; /* Suppress unused-variable warning. */
|
||||
%% write exec;
|
||||
|
||||
/* If $ was the last character, it's a valid EOL anchor. */
|
||||
if (eol_pending) {
|
||||
if (nsegs < max_segs) {
|
||||
segs[nsegs].type = SEG_EOL;
|
||||
nsegs++;
|
||||
}
|
||||
}
|
||||
|
||||
return nsegs;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Aho-Corasick multi-pattern automaton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define AC_ALPHA 256
|
||||
|
||||
typedef struct {
|
||||
int go[AC_ALPHA]; /* Goto: byte → next state (-1 = undefined) */
|
||||
int fail; /* Failure link */
|
||||
int match_id; /* Trigger ID ending here (-1 = none) */
|
||||
int dict_link; /* Nearest match state via failure chain */
|
||||
} ac_node_t;
|
||||
|
||||
typedef struct {
|
||||
ac_node_t *nodes;
|
||||
int num;
|
||||
int cap;
|
||||
} ac_machine_t;
|
||||
|
||||
static void ac_init(ac_machine_t *ac)
|
||||
{
|
||||
ac->cap = 64;
|
||||
ac->num = 0;
|
||||
ac->nodes = (ac_node_t *)calloc((size_t)ac->cap, sizeof(ac_node_t));
|
||||
}
|
||||
|
||||
static int ac_new_state(ac_machine_t *ac)
|
||||
{
|
||||
if (ac->num >= ac->cap) {
|
||||
ac->cap *= 2;
|
||||
ac->nodes = (ac_node_t *)realloc(ac->nodes,
|
||||
(size_t)ac->cap * sizeof(ac_node_t));
|
||||
}
|
||||
int s = ac->num++;
|
||||
memset(ac->nodes[s].go, -1, sizeof(ac->nodes[s].go));
|
||||
ac->nodes[s].fail = 0;
|
||||
ac->nodes[s].match_id = -1;
|
||||
ac->nodes[s].dict_link = -1;
|
||||
return s;
|
||||
}
|
||||
|
||||
static void ac_free(ac_machine_t *ac)
|
||||
{
|
||||
free(ac->nodes);
|
||||
ac->nodes = NULL;
|
||||
ac->num = ac->cap = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Insert a literal byte string into the trie.
|
||||
* Bytes are pre-folded to lowercase if icase.
|
||||
*/
|
||||
static void ac_insert(ac_machine_t *ac,
|
||||
const unsigned char *pat, int patlen,
|
||||
int trigger_id, int icase)
|
||||
{
|
||||
int state = 0; /* root */
|
||||
for (int i = 0; i < patlen; i++) {
|
||||
unsigned char c = icase ? ascii_lower(pat[i]) : pat[i];
|
||||
if (ac->nodes[state].go[c] < 0) {
|
||||
ac->nodes[state].go[c] = ac_new_state(ac);
|
||||
}
|
||||
state = ac->nodes[state].go[c];
|
||||
}
|
||||
ac->nodes[state].match_id = trigger_id;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build failure links and complete the goto function.
|
||||
* After this, go[state][byte] is always >= 0 for any state and byte.
|
||||
*/
|
||||
static void ac_build(ac_machine_t *ac)
|
||||
{
|
||||
int *queue = (int *)malloc((size_t)ac->num * sizeof(int));
|
||||
int qh = 0, qt = 0;
|
||||
|
||||
/* Root: undefined transitions loop back to root (state 0). */
|
||||
for (int c = 0; c < AC_ALPHA; c++) {
|
||||
int s = ac->nodes[0].go[c];
|
||||
if (s > 0) {
|
||||
ac->nodes[s].fail = 0;
|
||||
ac->nodes[s].dict_link = -1;
|
||||
queue[qt++] = s;
|
||||
} else {
|
||||
ac->nodes[0].go[c] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* BFS: compute failure links and complete goto. */
|
||||
while (qh < qt) {
|
||||
int u = queue[qh++];
|
||||
for (int c = 0; c < AC_ALPHA; c++) {
|
||||
int v = ac->nodes[u].go[c];
|
||||
if (v > 0) {
|
||||
int f = ac->nodes[ac->nodes[u].fail].go[c];
|
||||
ac->nodes[v].fail = f;
|
||||
ac->nodes[v].dict_link =
|
||||
(ac->nodes[f].match_id >= 0) ? f
|
||||
: ac->nodes[f].dict_link;
|
||||
queue[qt++] = v;
|
||||
} else {
|
||||
ac->nodes[u].go[c] = ac->nodes[ac->nodes[u].fail].go[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(queue);
|
||||
}
|
||||
|
||||
/*
|
||||
* Search text using the compiled AC automaton.
|
||||
* Returns number of distinct trigger IDs that matched.
|
||||
* Uses a bitmask to avoid duplicate reports.
|
||||
*/
|
||||
static int ac_search(const ac_machine_t *ac,
|
||||
const unsigned char *text, size_t tlen,
|
||||
int icase,
|
||||
trigger_match_t *results, int max_results)
|
||||
{
|
||||
int n = 0;
|
||||
/* Bitmask for dedup (TRIGGER_MAX <= 256, use 256 bits = 32 bytes). */
|
||||
uint8_t seen[TRIGGER_MAX / 8];
|
||||
memset(seen, 0, sizeof(seen));
|
||||
|
||||
int state = 0;
|
||||
for (size_t i = 0; i < tlen && n < max_results; i++) {
|
||||
unsigned char c = icase ? ascii_lower(text[i]) : text[i];
|
||||
state = ac->nodes[state].go[c];
|
||||
|
||||
/* Check for matches at this state and via dict_link chain. */
|
||||
int tmp = state;
|
||||
while (tmp > 0 && n < max_results) {
|
||||
if (ac->nodes[tmp].match_id >= 0) {
|
||||
int id = ac->nodes[tmp].match_id;
|
||||
int idx = id / 8;
|
||||
int bit = 1 << (id % 8);
|
||||
if (!(seen[idx] & bit)) {
|
||||
seen[idx] |= (uint8_t)bit;
|
||||
results[n].id = id;
|
||||
results[n].offset = i; /* End of match position. */
|
||||
n++;
|
||||
}
|
||||
}
|
||||
tmp = ac->nodes[tmp].dict_link;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Glob pattern matcher (backtracking) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
int id;
|
||||
int flags;
|
||||
trig_seg_t *segs;
|
||||
int nsegs;
|
||||
int anchored_start; /* Pattern began with ^ */
|
||||
} glob_pattern_t;
|
||||
|
||||
/*
|
||||
* Try matching a glob pattern anchored at text[0..tlen).
|
||||
* The match need not consume all of text (unanchored end, unless EOL).
|
||||
*/
|
||||
static int glob_match_at(const trig_seg_t *segs, int nsegs,
|
||||
const unsigned char *text, size_t tlen, int icase)
|
||||
{
|
||||
int si = 0;
|
||||
size_t ti = 0;
|
||||
int save_si = -1;
|
||||
size_t save_ti = 0;
|
||||
|
||||
for (;;) {
|
||||
if (si == nsegs) {
|
||||
return 1; /* All segments consumed — match. */
|
||||
}
|
||||
|
||||
switch (segs[si].type) {
|
||||
case SEG_STAR:
|
||||
save_si = si + 1;
|
||||
save_ti = ti;
|
||||
si++;
|
||||
continue;
|
||||
|
||||
case SEG_EOL:
|
||||
if (ti == tlen) { si++; continue; }
|
||||
break; /* Mismatch. */
|
||||
|
||||
case SEG_LITERAL:
|
||||
if (ti < tlen) {
|
||||
unsigned char pc = icase ? ascii_lower(segs[si].ch) : segs[si].ch;
|
||||
unsigned char tc = icase ? ascii_lower(text[ti]) : text[ti];
|
||||
if (pc == tc) { si++; ti++; continue; }
|
||||
}
|
||||
break; /* Mismatch. */
|
||||
|
||||
case SEG_QUESTION:
|
||||
if (ti < tlen) { si++; ti++; continue; }
|
||||
break; /* Mismatch. */
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
/* Backtrack to last STAR. */
|
||||
if (save_si >= 0 && save_ti < tlen) {
|
||||
save_ti++;
|
||||
si = save_si;
|
||||
ti = save_ti;
|
||||
continue;
|
||||
}
|
||||
|
||||
return 0; /* No match. */
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Search for a glob pattern anywhere in text.
|
||||
* If anchored_start, only try position 0.
|
||||
*/
|
||||
static int glob_search(const glob_pattern_t *gp,
|
||||
const unsigned char *text, size_t tlen)
|
||||
{
|
||||
int icase = (gp->flags & TRIGGER_ICASE) != 0;
|
||||
|
||||
if (gp->anchored_start) {
|
||||
return glob_match_at(gp->segs, gp->nsegs, text, tlen, icase);
|
||||
}
|
||||
|
||||
for (size_t start = 0; start <= tlen; start++) {
|
||||
if (glob_match_at(gp->segs, gp->nsegs,
|
||||
text + start, tlen - start, icase)) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Stored trigger entry (before compilation) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
int id;
|
||||
char *pattern;
|
||||
int flags;
|
||||
int active;
|
||||
} trigger_entry_t;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* trigger_set struct */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
struct trigger_set {
|
||||
trigger_entry_t entries[TRIGGER_MAX];
|
||||
int num_entries;
|
||||
|
||||
/* Compiled AC machine for literal patterns. */
|
||||
ac_machine_t ac;
|
||||
int ac_icase; /* Global icase for AC (all same). */
|
||||
int ac_count; /* Number of patterns in AC. */
|
||||
|
||||
/* Compiled glob patterns for wildcard patterns. */
|
||||
glob_pattern_t *globs;
|
||||
int num_globs;
|
||||
int cap_globs;
|
||||
|
||||
int compiled;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
trigger_set *trigger_set_create(void)
|
||||
{
|
||||
trigger_set *ts = (trigger_set *)calloc(1, sizeof(trigger_set));
|
||||
return ts;
|
||||
}
|
||||
|
||||
static void free_globs(trigger_set *ts)
|
||||
{
|
||||
for (int i = 0; i < ts->num_globs; i++) {
|
||||
free(ts->globs[i].segs);
|
||||
}
|
||||
free(ts->globs);
|
||||
ts->globs = NULL;
|
||||
ts->num_globs = 0;
|
||||
ts->cap_globs = 0;
|
||||
}
|
||||
|
||||
void trigger_set_free(trigger_set *ts)
|
||||
{
|
||||
if (!ts) return;
|
||||
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
free(ts->entries[i].pattern);
|
||||
}
|
||||
ac_free(&ts->ac);
|
||||
free_globs(ts);
|
||||
free(ts);
|
||||
}
|
||||
|
||||
int trigger_set_add(trigger_set *ts, int id,
|
||||
const char *pattern, int flags)
|
||||
{
|
||||
if (!ts || id < 0 || id >= TRIGGER_MAX || !pattern) return -1;
|
||||
|
||||
/* Check for existing entry with same ID and replace. */
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
if (ts->entries[i].id == id && ts->entries[i].active) {
|
||||
free(ts->entries[i].pattern);
|
||||
ts->entries[i].pattern = strdup(pattern);
|
||||
ts->entries[i].flags = flags;
|
||||
ts->compiled = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (ts->num_entries >= TRIGGER_MAX) return -1;
|
||||
|
||||
trigger_entry_t *e = &ts->entries[ts->num_entries++];
|
||||
e->id = id;
|
||||
e->pattern = strdup(pattern);
|
||||
e->flags = flags;
|
||||
e->active = 1;
|
||||
ts->compiled = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_set_remove(trigger_set *ts, int id)
|
||||
{
|
||||
if (!ts) return -1;
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
if (ts->entries[i].id == id && ts->entries[i].active) {
|
||||
ts->entries[i].active = 0;
|
||||
ts->compiled = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int trigger_set_compile(trigger_set *ts)
|
||||
{
|
||||
if (!ts) return -1;
|
||||
|
||||
/* Tear down previous compilation. */
|
||||
ac_free(&ts->ac);
|
||||
free_globs(ts);
|
||||
ts->ac_count = 0;
|
||||
|
||||
/* Initialize AC with root state. */
|
||||
ac_init(&ts->ac);
|
||||
ac_new_state(&ts->ac); /* State 0 = root. */
|
||||
|
||||
/* Classify each active entry and route to AC or glob. */
|
||||
for (int i = 0; i < ts->num_entries; i++) {
|
||||
trigger_entry_t *e = &ts->entries[i];
|
||||
if (!e->active) continue;
|
||||
|
||||
trig_seg_t segs[MAX_SEGS];
|
||||
int is_glob = 0;
|
||||
int is_regex = 0;
|
||||
size_t plen = strlen(e->pattern);
|
||||
|
||||
int nsegs = trigger_parse_pattern(e->pattern, plen,
|
||||
segs, MAX_SEGS,
|
||||
&is_glob, &is_regex);
|
||||
if (nsegs < 0) continue;
|
||||
|
||||
/* Regex patterns can't be handled here — skip.
|
||||
* The caller should check trigger_needs_regex() and use
|
||||
* std::regex for those patterns. */
|
||||
if (is_regex) continue;
|
||||
|
||||
int icase = (e->flags & TRIGGER_ICASE) != 0;
|
||||
|
||||
if (!is_glob) {
|
||||
/* Pure literal: insert into AC automaton. */
|
||||
unsigned char lit[MAX_SEGS];
|
||||
int llen = 0;
|
||||
for (int j = 0; j < nsegs; j++) {
|
||||
if (segs[j].type == SEG_LITERAL && llen < MAX_SEGS) {
|
||||
lit[llen++] = segs[j].ch;
|
||||
}
|
||||
}
|
||||
ac_insert(&ts->ac, lit, llen, e->id, icase);
|
||||
ts->ac_icase = icase;
|
||||
ts->ac_count++;
|
||||
} else {
|
||||
/* Glob: compile to glob_pattern_t. */
|
||||
if (ts->num_globs >= ts->cap_globs) {
|
||||
ts->cap_globs = ts->cap_globs ? ts->cap_globs * 2 : 16;
|
||||
ts->globs = (glob_pattern_t *)realloc(
|
||||
ts->globs,
|
||||
(size_t)ts->cap_globs * sizeof(glob_pattern_t));
|
||||
}
|
||||
glob_pattern_t *gp = &ts->globs[ts->num_globs++];
|
||||
gp->id = e->id;
|
||||
gp->flags = e->flags;
|
||||
|
||||
/* Check for BOL anchor. */
|
||||
int seg_start = 0;
|
||||
gp->anchored_start = 0;
|
||||
if (nsegs > 0 && segs[0].type == SEG_BOL) {
|
||||
gp->anchored_start = 1;
|
||||
seg_start = 1;
|
||||
}
|
||||
|
||||
int seg_count = nsegs - seg_start;
|
||||
gp->segs = (trig_seg_t *)malloc((size_t)seg_count * sizeof(trig_seg_t));
|
||||
gp->nsegs = seg_count;
|
||||
memcpy(gp->segs, segs + seg_start,
|
||||
(size_t)seg_count * sizeof(trig_seg_t));
|
||||
}
|
||||
}
|
||||
|
||||
/* Build AC failure links. */
|
||||
if (ts->ac_count > 0) {
|
||||
ac_build(&ts->ac);
|
||||
}
|
||||
|
||||
ts->compiled = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_set_search(const trigger_set *ts,
|
||||
const char *text, size_t len,
|
||||
trigger_match_t *results, int max_results)
|
||||
{
|
||||
if (!ts || !ts->compiled || !text || max_results <= 0) return 0;
|
||||
|
||||
int n = 0;
|
||||
|
||||
/* AC search for literal patterns. */
|
||||
if (ts->ac_count > 0) {
|
||||
n = ac_search(&ts->ac,
|
||||
(const unsigned char *)text, len,
|
||||
ts->ac_icase,
|
||||
results, max_results);
|
||||
}
|
||||
|
||||
/* Glob search for wildcard patterns. */
|
||||
for (int i = 0; i < ts->num_globs && n < max_results; i++) {
|
||||
if (glob_search(&ts->globs[i],
|
||||
(const unsigned char *)text, len)) {
|
||||
results[n].id = ts->globs[i].id;
|
||||
results[n].offset = 0; /* Glob doesn't track offset. */
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
int trigger_needs_regex(const char *pattern)
|
||||
{
|
||||
if (!pattern) return 0;
|
||||
|
||||
trig_seg_t segs[MAX_SEGS];
|
||||
int is_glob = 0;
|
||||
int is_regex = 0;
|
||||
|
||||
trigger_parse_pattern(pattern, strlen(pattern),
|
||||
segs, MAX_SEGS,
|
||||
&is_glob, &is_regex);
|
||||
return is_regex;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue