379 lines
13 KiB
C++
379 lines
13 KiB
C++
/// @file grep_regex_test.cpp
|
|
/// @brief Catch2 unit tests for the GrepRegex wrapper (RE2 + std::regex fallback).
|
|
|
|
#include <cstddef>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <yams/app/services/grep_regex.hpp>
|
|
|
|
#if defined(__has_feature)
|
|
#if __has_feature(address_sanitizer)
|
|
#define YAMS_GREP_REGEX_TEST_ASAN 1
|
|
#endif
|
|
#endif
|
|
|
|
#if !defined(YAMS_GREP_REGEX_TEST_ASAN) && defined(__SANITIZE_ADDRESS__)
|
|
#define YAMS_GREP_REGEX_TEST_ASAN 1
|
|
#endif
|
|
|
|
using namespace yams::app::services;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// compile — success cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex::compile: simple literal pattern", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("hello", false);
|
|
REQUIRE(re.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::compile: character class", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("[a-z]+", false);
|
|
REQUIRE(re.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::compile: quantifiers", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("a{2,4}", false);
|
|
REQUIRE(re.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::compile: alternation", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("foo|bar|baz", false);
|
|
REQUIRE(re.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::compile: anchors", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("^start.*end$", false);
|
|
REQUIRE(re.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::compile: case insensitive", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("Hello", true);
|
|
REQUIRE(re.has_value());
|
|
// Should match lowercase text.
|
|
auto m = re->findFirst("say hello world");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 4);
|
|
REQUIRE(m->length == 5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// compile — failure cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex::compile: invalid pattern returns nullopt", "[grep_regex]") {
|
|
// Unmatched parenthesis — invalid for both RE2 and ECMAScript.
|
|
auto re = GrepRegex::compile("(abc", false);
|
|
REQUIRE_FALSE(re.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::compile: empty pattern compiles", "[grep_regex]") {
|
|
// Empty pattern is valid regex (matches everything).
|
|
auto re = GrepRegex::compile("", false);
|
|
REQUIRE(re.has_value());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// findFirst
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex::findFirst: match at start", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("the", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("the quick brown fox");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
REQUIRE(m->length == 3);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findFirst: match in middle", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("brown", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("the quick brown fox");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 10);
|
|
REQUIRE(m->length == 5);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findFirst: match at end", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("fox$", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("the quick brown fox");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 16);
|
|
REQUIRE(m->length == 3);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findFirst: no match returns nullopt", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("cat", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("the quick brown fox");
|
|
REQUIRE_FALSE(m.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findFirst: regex metacharacters", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("\\d+", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("abc 123 def");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 4);
|
|
REQUIRE(m->length == 3);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findFirst: alternation picks first match", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("cat|fox", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("the fox ate the cat");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 4);
|
|
REQUIRE(m->length == 3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// findNext — offset tracking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex::findNext: successive matches", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("the", false);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "the cat and the dog and the fox";
|
|
// 0 1 2
|
|
// 0123456789012345678901234567890
|
|
|
|
auto m1 = re->findNext(text.data(), text.size(), 0);
|
|
REQUIRE(m1.has_value());
|
|
REQUIRE(m1->position == 0);
|
|
|
|
auto m2 = re->findNext(text.data(), text.size(), m1->position + m1->length);
|
|
REQUIRE(m2.has_value());
|
|
REQUIRE(m2->position == 12);
|
|
|
|
auto m3 = re->findNext(text.data(), text.size(), m2->position + m2->length);
|
|
REQUIRE(m3.has_value());
|
|
REQUIRE(m3->position == 24);
|
|
|
|
auto m4 = re->findNext(text.data(), text.size(), m3->position + m3->length);
|
|
REQUIRE_FALSE(m4.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findNext: offset beyond length returns nullopt", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("x", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findNext("abc", 3, 10);
|
|
REQUIRE_FALSE(m.has_value());
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::findNext: offset at length returns nullopt", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("x", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findNext("abc", 3, 3);
|
|
REQUIRE_FALSE(m.has_value());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// countMatches
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex::countMatches: zero matches", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("xyz", false);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "no match here";
|
|
REQUIRE(re->countMatches(text.data(), text.size()) == 0);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::countMatches: single match", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("world", false);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "hello world";
|
|
REQUIRE(re->countMatches(text.data(), text.size()) == 1);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::countMatches: multiple matches", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("ab", false);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "ab cd ab ef ab";
|
|
REQUIRE(re->countMatches(text.data(), text.size()) == 3);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::countMatches: overlapping candidates (non-overlapping result)",
|
|
"[grep_regex]") {
|
|
// "aa" in "aaaa" — non-overlapping should find 2, not 3.
|
|
auto re = GrepRegex::compile("aa", false);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "aaaa";
|
|
REQUIRE(re->countMatches(text.data(), text.size()) == 2);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex::countMatches: zero-width match advances", "[grep_regex]") {
|
|
// Regex that can match zero-width (empty pattern).
|
|
// countMatches should not infinite-loop; it advances by at least 1 byte.
|
|
auto re = GrepRegex::compile("", false);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "ab";
|
|
// Empty pattern matches at every position: 0, 1, 2 (after last char) → 3 matches.
|
|
// But our implementation advances by max(length, 1), so positions 0, 1, 2 → 3.
|
|
// The exact count depends on the regex engine behavior; just verify it terminates
|
|
// and returns a finite count.
|
|
size_t count = re->countMatches(text.data(), text.size());
|
|
REQUIRE(count >= 1);
|
|
REQUIRE(count <= text.size() + 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// usesRe2
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex::usesRe2: reflects backend", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("simple", false);
|
|
REQUIRE(re.has_value());
|
|
// When built with YAMS_HAS_RE2=1, a simple pattern should compile via RE2.
|
|
// When built without RE2, it should use std::regex.
|
|
// We can't know at test-compile time, but the flag should be consistent.
|
|
#if YAMS_HAS_RE2
|
|
#if defined(YAMS_GREP_REGEX_TEST_ASAN)
|
|
REQUIRE(re->usesRe2() == false);
|
|
REQUIRE(re->fallbackReason() == "RE2 disabled under AddressSanitizer");
|
|
#else
|
|
REQUIRE(re->usesRe2() == true);
|
|
REQUIRE(re->fallbackReason().empty());
|
|
#endif
|
|
#else
|
|
REQUIRE(re->usesRe2() == false);
|
|
#endif
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RE2 fallback to std::regex for unsupported features
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#if YAMS_HAS_RE2
|
|
TEST_CASE("GrepRegex: RE2 fallback for backreferences", "[grep_regex][re2_fallback]") {
|
|
// Backreferences are not supported by RE2 but are valid ECMAScript.
|
|
auto re = GrepRegex::compile("(abc)\\1", false);
|
|
REQUIRE(re.has_value());
|
|
// Should have fallen back to std::regex.
|
|
REQUIRE(re->usesRe2() == false);
|
|
REQUIRE_FALSE(re->fallbackReason().empty());
|
|
|
|
// Verify it actually works via std::regex.
|
|
auto m = re->findFirst("abcabc");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
REQUIRE(m->length == 6);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex: RE2 fallback for lookahead", "[grep_regex][re2_fallback]") {
|
|
auto re = GrepRegex::compile("foo(?=bar)", false);
|
|
REQUIRE(re.has_value());
|
|
REQUIRE(re->usesRe2() == false);
|
|
|
|
auto m = re->findFirst("foobar");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
REQUIRE(m->length == 3); // Lookahead is zero-width.
|
|
}
|
|
#endif
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Case-insensitive matching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex: case-insensitive findFirst", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("hello", true);
|
|
REQUIRE(re.has_value());
|
|
|
|
SECTION("exact case") {
|
|
auto m = re->findFirst("hello");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
}
|
|
SECTION("upper case") {
|
|
auto m = re->findFirst("say HELLO world");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 4);
|
|
REQUIRE(m->length == 5);
|
|
}
|
|
SECTION("mixed case") {
|
|
auto m = re->findFirst("HeLLo");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("GrepRegex: case-insensitive countMatches", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("the", true);
|
|
REQUIRE(re.has_value());
|
|
const std::string text = "The cat and THE dog and the fox";
|
|
REQUIRE(re->countMatches(text.data(), text.size()) == 3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Move semantics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex: move construction", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("hello", false);
|
|
REQUIRE(re.has_value());
|
|
|
|
GrepRegex moved(std::move(*re));
|
|
// Moved-to object should work.
|
|
auto m = moved.findFirst("say hello");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 4);
|
|
}
|
|
|
|
TEST_CASE("GrepRegex: move assignment", "[grep_regex]") {
|
|
auto re1 = GrepRegex::compile("hello", false);
|
|
auto re2 = GrepRegex::compile("world", false);
|
|
REQUIRE(re1.has_value());
|
|
REQUIRE(re2.has_value());
|
|
|
|
*re1 = std::move(*re2);
|
|
// re1 should now match "world", not "hello".
|
|
auto m = re1->findFirst("hello world");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 6);
|
|
REQUIRE(m->length == 5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Complex patterns (exercise the regex engine thoroughly)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("GrepRegex: word boundary pattern", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("\\bfoo\\b", false);
|
|
REQUIRE(re.has_value());
|
|
|
|
SECTION("whole word match") {
|
|
auto m = re->findFirst("foo bar");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
}
|
|
SECTION("no match inside word") {
|
|
auto m = re->findFirst("foobar");
|
|
REQUIRE_FALSE(m.has_value());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("GrepRegex: greedy quantifier captures longest", "[grep_regex]") {
|
|
auto re = GrepRegex::compile("a.*b", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("a1b2b");
|
|
REQUIRE(m.has_value());
|
|
REQUIRE(m->position == 0);
|
|
REQUIRE(m->length == 5); // Greedy: matches "a1b2b".
|
|
}
|
|
|
|
TEST_CASE("GrepRegex: multiline-like text (no dot-nl)", "[grep_regex]") {
|
|
// By default, '.' does not match newline (grep convention).
|
|
auto re = GrepRegex::compile("a.b", false);
|
|
REQUIRE(re.has_value());
|
|
auto m = re->findFirst("a\nb");
|
|
// '.' should NOT match '\n' → no match.
|
|
// Note: std::regex with ECMAScript also doesn't match \n with '.' by default.
|
|
REQUIRE_FALSE(m.has_value());
|
|
}
|