diff --git a/client/console/src/macro.cpp b/client/console/src/macro.cpp index 9a3297ddb..08aa4d8bb 100644 --- a/client/console/src/macro.cpp +++ b/client/console/src/macro.cpp @@ -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 MacroDB::match_triggers(const std::string& text) { std::vector 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 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); diff --git a/client/console/src/macro.h b/client/console/src/macro.h index e219e473a..0839a2866 100644 --- a/client/console/src/macro.h +++ b/client/console/src/macro.h @@ -6,18 +6,25 @@ #include #include +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 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. diff --git a/ragel/Makefile b/ragel/Makefile index 35ccbc2e1..dea88d2e4 100644 --- a/ragel/Makefile +++ b/ragel/Makefile @@ -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 diff --git a/ragel/test_trigger.c b/ragel/test_trigger.c new file mode 100644 index 000000000..d78f179af --- /dev/null +++ b/ragel/test_trigger.c @@ -0,0 +1,404 @@ +/* + * test_trigger.c — Test harness for the trigger matching engine. + */ + +#include "trigger_match.h" +#include +#include + +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; +} diff --git a/ragel/trigger_match.c b/ragel/trigger_match.c new file mode 100644 index 000000000..034f10311 --- /dev/null +++ b/ragel/trigger_match.c @@ -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 +#include + +/* ------------------------------------------------------------------ */ +/* 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; +} diff --git a/ragel/trigger_match.h b/ragel/trigger_match.h new file mode 100644 index 000000000..af944b6bf --- /dev/null +++ b/ragel/trigger_match.h @@ -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 +#include + +#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 */ diff --git a/ragel/trigger_match.rl b/ragel/trigger_match.rl new file mode 100644 index 000000000..7467ad097 --- /dev/null +++ b/ragel/trigger_match.rl @@ -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 +#include + +/* ------------------------------------------------------------------ */ +/* 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; +}