real-regex/tests/automata/test_dfa_which_matched.cpp
Line | Count | Source |
1 | | // Stage-2 S2a: multi-accept unanchored which_matched vs N×search oracle. |
2 | | #include <chrono> |
3 | | #include <cstdint> |
4 | | #include <string> |
5 | | #include <string_view> |
6 | | #include <vector> |
7 | | |
8 | | #include <sciforge/test/framework.hpp> |
9 | | #include "real/dfa.hpp" |
10 | | #include "real/real.hpp" |
11 | | |
12 | | namespace { |
13 | | |
14 | | std::vector<bool> nwalk_which(const std::vector<real::regex>& pats, |
15 | | std::string_view text) |
16 | 7 | { |
17 | 7 | std::vector<bool> hit; |
18 | 7 | hit.reserve(pats.size()); |
19 | 56 | for (const auto& re : pats) { |
20 | 56 | hit.push_back(static_cast<bool>(re.search(text))); |
21 | 56 | } |
22 | 7 | return hit; |
23 | 7 | } |
24 | | |
25 | | std::vector<std::string> present_log() |
26 | 3 | { |
27 | 3 | return { |
28 | 3 | R"([0-9]{4}-[0-9]{2}-[0-9]{2})", |
29 | 3 | R"([0-9]{2}:[0-9]{2}:[0-9]{2})", |
30 | 3 | R"(error|warn|info|debug|fatal)", |
31 | 3 | R"([a-f0-9]{8})", |
32 | 3 | R"([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", |
33 | 3 | R"(GET|POST|PUT|DELETE)", |
34 | 3 | R"(user=[a-z]+)", |
35 | 3 | R"(q=[0-9]+)", |
36 | 3 | }; |
37 | 3 | } |
38 | | } // namespace |
39 | | |
40 | | TEST(which_matched_equals_nwalk_search) |
41 | 1 | { |
42 | 1 | const auto raw {present_log()}; |
43 | 1 | std::vector<real::regex> pats; |
44 | 1 | pats.reserve(raw.size()); |
45 | 8 | for (const auto& p : raw) { |
46 | 8 | pats.emplace_back(p); |
47 | 8 | } |
48 | 1 | const real::dfa d {std::span<const real::regex>(pats), real::dfa_mode::which_matched}; |
49 | 1 | EXPECT(d.is_unanchored()); |
50 | 1 | EXPECT_EQ(d.rule_count(), pats.size()); |
51 | | |
52 | 1 | const std::string text = |
53 | 1 | "2026-06-13 12:04:55 error id=a3f9c1d8 GET /api/x from 10.0.2.15 user=bob q=42\n" |
54 | 1 | "plain line\n"; |
55 | 1 | const auto got = d.which_matched(text); |
56 | 1 | const auto ora = nwalk_which(pats, text); |
57 | 1 | EXPECT_EQ(got.size(), ora.size()); |
58 | 9 | for (std::size_t i = 0; i < got.size(); ++i8 ) { |
59 | 8 | EXPECT_EQ(static_cast<int>(got[i]), static_cast<int>(ora[i])); |
60 | 8 | } |
61 | 1 | } |
62 | | |
63 | | TEST(which_matched_both_on_overlap_prefix) |
64 | 1 | { |
65 | | // "ab" and "a" both match "ab" (which-matched); munch would pick one. |
66 | 1 | std::vector<real::regex> pats; |
67 | 1 | pats.emplace_back("ab"); |
68 | 1 | pats.emplace_back("a"); |
69 | 1 | const real::dfa d {std::span<const real::regex>(pats), real::dfa_mode::which_matched}; |
70 | 1 | const auto hit {d.which_matched("xx ab yy")}; |
71 | 1 | EXPECT(hit[0] && hit[1]); |
72 | 1 | } |
73 | | |
74 | | TEST(which_matched_absent_false) |
75 | 1 | { |
76 | 1 | std::vector<real::regex> pats; |
77 | 1 | pats.emplace_back("needle"); |
78 | 1 | pats.emplace_back("zzz_absent"); |
79 | 1 | const real::dfa d {std::span<const real::regex>(pats), real::dfa_mode::which_matched}; |
80 | 1 | const auto hit {d.which_matched("a long haystack with a needle")}; |
81 | 1 | EXPECT(hit[0]); |
82 | 1 | EXPECT(!hit[1]); |
83 | 1 | } |
84 | | |
85 | | TEST(munch_mode_unchanged_default) |
86 | 1 | { |
87 | 1 | std::vector<real::regex> pats; |
88 | 1 | pats.emplace_back("ab"); |
89 | 1 | pats.emplace_back("a"); |
90 | 1 | const real::dfa d {std::span<const real::regex>(pats)}; // default munch |
91 | 1 | EXPECT(!d.is_unanchored()); |
92 | 1 | const auto m {d.match("ab")}; |
93 | 1 | EXPECT(m.has_value()); |
94 | 1 | EXPECT_EQ(m->rule_index, 0U); // longest "ab" |
95 | 1 | EXPECT_EQ(m->length, 2U); |
96 | 1 | } |
97 | | |
98 | | TEST(which_matched_state_count_bounded_log_patterns) |
99 | 1 | { |
100 | 1 | auto raw = present_log(); |
101 | 57 | for (int i = 0; i < 56; ++i56 ) { |
102 | 56 | raw.push_back("SEV" + std::to_string(i) + "|trace" + std::to_string(i)); |
103 | 56 | } |
104 | 1 | std::vector<real::regex> pats; |
105 | 1 | pats.reserve(raw.size()); |
106 | 64 | for (const auto& p : raw) { |
107 | 64 | pats.emplace_back(p); |
108 | 64 | } |
109 | 1 | const real::dfa d {std::span<const real::regex>(pats), real::dfa_mode::which_matched}; |
110 | | // Unanchored can be larger than munch proxy; must stay under production cap. |
111 | 1 | EXPECT(d.state_count() < 65536U); |
112 | 1 | EXPECT(d.state_count() > 0U); |
113 | 1 | } |
114 | | |
115 | | // First-byte skip is a pure opt — avec == sans == N×search on sparse/dense/edge. |
116 | | TEST(which_matched_first_byte_skip_equals_oracle) |
117 | 1 | { |
118 | 1 | const auto raw {present_log()}; |
119 | 1 | std::vector<real::regex> pats; |
120 | 1 | pats.reserve(raw.size()); |
121 | 8 | for (const auto& p : raw) { |
122 | 8 | pats.emplace_back(p); |
123 | 8 | } |
124 | 1 | const real::dfa d {std::span<const real::regex>(pats), real::dfa_mode::which_matched}; |
125 | 1 | EXPECT(d.has_first_byte_skip()); // all present_log rules have sound first_bytes |
126 | | |
127 | 1 | const std::string dense = |
128 | 1 | "2026-06-13 12:04:55 error id=a3f9c1d8 GET /api/x from 10.0.2.15 user=bob q=42\n" |
129 | 1 | "plain line\n"; |
130 | | // Sparse: long generic text, rare hits at the end (skip's happy path). |
131 | 1 | std::string sparse; |
132 | 1 | sparse.reserve(8000); |
133 | 101 | for (int i = 0; i < 100; ++i100 ) { |
134 | 100 | sparse += "the quick brown fox jumps over the lazy dog once more today\n"; |
135 | 100 | } |
136 | 1 | sparse += "error id=deadbeef GET user=alice q=7\n"; |
137 | | |
138 | | // Named buffer: dense+sparse must outlive the string_view (temporary would dangle → ASan UAF). |
139 | 1 | const std::string dense_plus_sparse {dense + sparse}; |
140 | 1 | const std::vector<std::string_view> corpora { |
141 | 1 | dense, sparse, "", "zzz", "error", dense_plus_sparse, |
142 | 1 | }; |
143 | 6 | for (const auto text : corpora) { |
144 | 6 | const auto with_skip {d.which_matched(text, true)}; |
145 | 6 | const auto no_skip {d.which_matched(text, false)}; |
146 | 6 | const auto ora {nwalk_which(pats, text)}; |
147 | 6 | EXPECT_EQ(with_skip.size(), no_skip.size()); |
148 | 6 | EXPECT_EQ(with_skip.size(), ora.size()); |
149 | 54 | for (std::size_t i = 0; i < with_skip.size(); ++i48 ) { |
150 | 48 | EXPECT_EQ(static_cast<int>(with_skip[i]), static_cast<int>(no_skip[i])); |
151 | 48 | EXPECT_EQ(static_cast<int>(with_skip[i]), static_cast<int>(ora[i])); |
152 | 48 | } |
153 | 6 | } |
154 | 1 | } |
155 | | |
156 | | TEST(which_matched_skip_disabled_when_nullable_rule) |
157 | 1 | { |
158 | | // Empty-match-possible rule ⇒ first_bytes_valid false on that rule ⇒ set skip off. |
159 | 1 | std::vector<real::regex> pats; |
160 | 1 | pats.emplace_back("needle"); |
161 | 1 | pats.emplace_back("a*"); // nullable |
162 | 1 | const real::dfa d {std::span<const real::regex>(pats), real::dfa_mode::which_matched}; |
163 | 1 | EXPECT(!d.has_first_byte_skip()); |
164 | 1 | const auto hit {d.which_matched("xx needle yy")}; |
165 | 1 | EXPECT(hit[0]); |
166 | 1 | } |