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:
Stephen Dennis 2026-03-18 06:25:18 -06:00
parent 9d79fea37c
commit c3f0ffbfc6
7 changed files with 1985 additions and 36 deletions

View file

@ -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);

View file

@ -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.

View file

@ -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
View 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
View 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
View 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
View 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;
}