Browse Source

Add basic utilities for globbing and fnmatching

default_compile_flags
vector-of-bool 4 years ago
parent
commit
6bd48d7395
6 changed files with 910 additions and 0 deletions
  1. +189
    -0
      src/dds/util/fnmatch.cpp
  2. +321
    -0
      src/dds/util/fnmatch.hpp
  3. +32
    -0
      src/dds/util/fnmatch.test.cpp
  4. +238
    -0
      src/dds/util/glob.cpp
  5. +61
    -0
      src/dds/util/glob.hpp
  6. +69
    -0
      src/dds/util/glob.test.cpp

+ 189
- 0
src/dds/util/fnmatch.cpp View File

@@ -0,0 +1,189 @@
#include "./fnmatch.hpp"

#include <cassert>
#include <stdexcept>
#include <string_view>
#include <vector>

using charptr = const char*;

namespace dds::detail::fnmatch {

namespace {

class base_pattern_elem {
public:
virtual bool match(charptr first, charptr last) const noexcept = 0;
virtual ~base_pattern_elem() = default;

std::unique_ptr<base_pattern_elem> next;
};

class rt_star : public base_pattern_elem {
bool match(charptr first, charptr last) const noexcept {
while (first != last) {
auto did_match = next->match(first, last);
if (did_match) {
return true;
}
++first;
}
// We're at the end. Try once more
return next->match(first, last);
}
};

class rt_any_char : public base_pattern_elem {
bool match(charptr first, charptr last) const noexcept {
if (first == last) {
return false;
}
return next->match(first + 1, last);
}
};

class rt_oneof : public base_pattern_elem {
std::string _chars;
bool match(charptr first, charptr last) const noexcept {
if (first == last) {
return false;
}
auto idx = _chars.find(*first);
if (idx == _chars.npos) {
return false;
}
return next->match(first + 1, last);
}

public:
explicit rt_oneof(std::string chars)
: _chars(chars) {}
};

class rt_lit : public base_pattern_elem {
std::string _lit;
bool match(charptr first, charptr last) const noexcept {
auto remaining = static_cast<std::size_t>(std::distance(first, last));
if (remaining < _lit.size()) {
return false;
}
auto eq = std::equal(first, first + _lit.size(), _lit.begin());
if (!eq) {
return false;
}
return next->match(first + _lit.size(), last);
}

public:
explicit rt_lit(std::string lit)
: _lit(lit) {}
};

class rt_end : public base_pattern_elem {
bool match(charptr first, charptr last) const noexcept { return first == last; }
};

} // namespace

class pattern_impl {
std::unique_ptr<base_pattern_elem> _head;
std::unique_ptr<base_pattern_elem>* _next_to_compile = &_head;

template <typename T, typename... Args>
void _add_elem(Args&&... args) {
*_next_to_compile = std::make_unique<T>(std::forward<Args>(args)...);
_next_to_compile = &(*_next_to_compile)->next;
}

charptr _compile_oneof(charptr cur, charptr last) {
std::string chars;
while (cur != last) {
auto c = *cur;
if (c == ']') {
// We've reached the end of the group
_add_elem<rt_oneof>(chars);
return cur + 1;
}
if (c == '\\') {
++cur;
if (cur == last) {
throw std::runtime_error("Untermated [group] in pattern");
}
chars.push_back(*cur);
} else {
chars.push_back(c);
}
++cur;
}
throw std::runtime_error("Unterminated [group] in pattern");
}

charptr _compile_lit(charptr cur, charptr last) {
std::string lit;
while (cur != last) {
auto c = *cur;
if (c == '*' || c == '[' || c == '?') {
break;
}
if (c == '\\') {
++cur;
if (cur == last) {
throw std::runtime_error("Invalid \\ at end of pattern");
}
// Push back whatever character follows
lit.push_back(*cur);
++cur;
continue;
} else {
lit.push_back(c);
}
++cur;
}
_add_elem<rt_lit>(lit);
return cur;
}

void _compile_next(charptr first, charptr last) {
if (first == last) {
return;
}
auto c = *first;
if (c == '*') {
_add_elem<rt_star>();
_compile_next(first + 1, last);
} else if (c == '[') {
first = _compile_oneof(first + 1, last);
_compile_next(first, last);
} else if (c == '?') {
_add_elem<rt_any_char>();
_compile_next(first + 1, last);
} else {
// Literal string
first = _compile_lit(first, last);
_compile_next(first, last);
}
}

public:
pattern_impl(std::string_view str) {
_compile_next(str.data(), str.data() + str.size());
// Set the tail of the list to be an rt_end to detect end-of-string
_add_elem<rt_end>();
}

bool match(charptr first, charptr last) const noexcept {
assert(_head);
return _head->match(first, last);
}
};

} // namespace dds::detail::fnmatch

dds::fnmatch::pattern dds::fnmatch::compile(std::string_view str) {
return pattern{std::make_shared<detail::fnmatch::pattern_impl>(str)};
}

bool dds::fnmatch::pattern::_match(charptr first, charptr last) const noexcept {
assert(_impl);
return _impl->match(first, last);
}

+ 321
- 0
src/dds/util/fnmatch.hpp View File

@@ -0,0 +1,321 @@
#pragma once

#include <cstdlib>
#include <iterator>
#include <memory>
#include <optional>
#include <string>
#include <type_traits>

namespace dds {

namespace fnmatch {

template <typename... Elems>
struct ct_pattern;

class pattern;

} // namespace fnmatch

namespace detail::fnmatch {

template <typename... Sub>
struct seq {};

struct star {};

struct any_one {};

template <typename Char>
constexpr std::size_t length(const Char* str) {
std::size_t ret = 0;
while (*str != Char(0)) {
++str;
++ret;
}
return ret;
}

template <auto... Chars>
struct oneof {};

template <auto... Chars>
struct not_oneof {};

template <auto Char>
struct just {};

template <typename>
struct is_just : std::false_type {};
template <auto C>
struct is_just<just<C>> : std::true_type {};

template <typename Matcher, auto NewCur>
struct oneof_ret {
using type = Matcher;
constexpr static auto end_offset = NewCur;
};

template <auto... Chars, auto End>
constexpr auto negate(oneof_ret<oneof<Chars...>, End>) {
return oneof_ret<not_oneof<Chars...>, End>();
}

template <auto Cur, auto Len, auto... Chars, typename String>
constexpr auto compile_oneof_chars(String s) {
constexpr auto str = s();
constexpr auto cur_char = str[Cur];
static_assert(Cur != Len, "Unterminated '[' group in pattern");
static_assert(Cur + 1 != Len || cur_char != '\\', "Escape \\ at end of pattern");
if constexpr (cur_char == ']') {
return oneof_ret<oneof<Chars...>, Cur + 1>();
} else if constexpr (cur_char == '\\') {
constexpr auto next_char = str[Cur + 1];
return compile_oneof_chars<Cur + 2, Len, Chars..., next_char>(s);
} else {
return compile_oneof_chars<Cur + 1, Len, Chars..., cur_char>(s);
}
}

template <auto Cur, auto Len, typename String>
constexpr auto compile_oneof(String s) {
constexpr auto str = s();
constexpr bool negated = str[Cur] == '!';
constexpr auto oneof_start = Cur + (negated ? 1 : 0);
auto oneof = compile_oneof_chars<oneof_start, Len>(s);
if constexpr (negated) {
return negate(oneof);
} else {
return oneof;
}
}

template <auto Cur, auto Len, typename... Matchers, typename String>
constexpr auto compile_next(String s) {
constexpr auto str = s();
constexpr auto cur_char = str[Cur];
if constexpr (Cur == Len) {
return dds::fnmatch::ct_pattern<Matchers...>();
} else if constexpr (cur_char == '*') {
return compile_next<Cur + 1, Len, Matchers..., star>(s);
} else if constexpr (cur_char == '?') {
return compile_next<Cur + 1, Len, Matchers..., any_one>(s);
} else if constexpr (cur_char == '[') {
constexpr auto oneof_ret = compile_oneof<Cur + 1, Len>(s);
return compile_next<oneof_ret.end_offset,
Len,
Matchers...,
typename decltype(oneof_ret)::type>(s);
} else if constexpr (cur_char == '\\') {
// Escape sequence
static_assert(Cur + 1 != Len, "Escape \\ at end of pattern.");
constexpr auto next_char = str[Cur + 1];
return compile_next<Cur + 2, Len, Matchers..., just<next_char>>(s);
} else {
return compile_next<Cur + 1, Len, Matchers..., just<cur_char>>(s);
}
}

template <typename Iter1, typename Iter2>
constexpr bool equal(Iter1 a_first, Iter1 a_last, Iter2 b_first) {
while (a_first != a_last) {
if (*a_first != *b_first) {
return false;
}
++a_first;
++b_first;
}
return true;
}

} // namespace detail::fnmatch

namespace fnmatch {

template <typename... Elems>
struct ct_pattern {
private:
/// VVVVVVVVVVVVVVVVVVV Optimized Cases VVVVVVVVVVVVVVVVVVVVVVV

/**
* Common case of a star '*' followed by literals to the end of the pattern
*/
template <typename Iter, auto C, auto... Chars>
static constexpr bool match_1(Iter cur,
const Iter last,
detail::fnmatch::star,
detail::fnmatch::just<C> c1,
detail::fnmatch::just<Chars>... t) {
// We know the length of tail required, so we can just skip ahead without
// a loop
auto cur_len = std::distance(cur, last);
if (cur_len < sizeof...(Chars) + 1) {
// Not enough remaining to match
return false;
}
// Skip ahead and match the rest
auto to_skip = cur_len - (sizeof...(Chars) + 1);
return match_1(std::next(cur, to_skip), last, c1, t...);
}

/**
* Common case of a sequence of literals at the tail.
*/
template <typename Iter, auto... Chars>
static constexpr bool match_1(Iter cur, const Iter last, detail::fnmatch::just<Chars>...) {
constexpr auto LitLength = sizeof...(Chars);
auto remaining = std::distance(cur, last);
if (remaining != LitLength) {
return false;
}
// Put our characters into an array for a quick comparison
std::decay_t<decltype(*cur)> chars[LitLength] = {Chars...};
return detail::fnmatch::equal(chars, chars + LitLength, cur);
}

/// VVVVVVVVVVVVVVVVVVVV General cases VVVVVVVVVVVVVVVVVVVVVVVV
template <typename Iter, typename... Tail>
static constexpr bool match_1(Iter cur, const Iter last, detail::fnmatch::star, Tail... t) {
while (cur != last) {
auto did_match = match_1(cur, last, t...);
if (did_match) {
return true;
}
++cur;
}
// We've advanced to the end of the string, but we might still have a match...
return match_1(cur, last, t...);
}

template <typename Iter, auto... Chars, typename... Tail>
static constexpr bool
match_1(Iter cur, const Iter last, detail::fnmatch::not_oneof<Chars...>, Tail... t) {
if (cur == last) {
return false;
}
if (((*cur == Chars) || ...)) {
return false;
}
return match_1(std::next(cur), last, t...);
}

template <typename Iter, auto... Chars, typename... Tail>
static constexpr bool
match_1(Iter cur, const Iter last, detail::fnmatch::oneof<Chars...>, Tail... t) {
if (cur == last) {
return false;
}
if (((*cur == Chars) || ...)) {
return match_1(std::next(cur), last, t...);
} else {
// current char is not in pattern
return false;
}
}

template <typename Iter,
auto C,
typename... Tail,
// Only enable this overload if the tail is not entirely just<> items
// (we have an optimization for that case)
typename = std::enable_if_t<!(detail::fnmatch::is_just<Tail>() && ...)>>
static constexpr bool match_1(Iter cur, const Iter last, detail::fnmatch::just<C>, Tail... t) {
if (cur == last) {
// We've reached the end, but we have more things to match
return false;
}
if (*cur != C) {
// Wrong char
return false;
} else {
// Good char, keep going
return match_1(std::next(cur), last, t...);
}
}

template <typename Iter, typename... Tail>
static constexpr bool match_1(Iter cur, const Iter last, detail::fnmatch::any_one, Tail... t) {
if (cur == last) {
return false;
}
return match_1(std::next(cur), last, t...);
}

template <typename Iter>
static constexpr bool match_1(Iter cur, Iter last) {
return cur == last;
}

public:
static constexpr bool match(const char* fname) {
return match_1(fname, fname + detail::fnmatch::length(fname), Elems()...);
}
};

template <typename StringGenerator, typename = decltype(std::declval<StringGenerator&>()())>
constexpr auto compile(StringGenerator&& s) {
constexpr auto pattern = s();
constexpr auto len = detail::fnmatch::length(pattern);
return decltype(detail::fnmatch::compile_next<0, len>(s))();
}

pattern compile(std::string_view str);

} // namespace fnmatch

namespace detail::fnmatch {

class pattern_impl;

} // namespace detail::fnmatch

namespace fnmatch {

class pattern {
std::shared_ptr<const detail::fnmatch::pattern_impl> _impl;

bool _match(const char* begin, const char* end) const noexcept;

public:
constexpr static std::size_t noalloc_size = 256;

pattern(std::shared_ptr<const detail::fnmatch::pattern_impl> ptr)
: _impl(ptr) {}
~pattern() = default;
pattern(const pattern&) = default;
pattern(pattern&&) = default;
pattern& operator=(const pattern&) = default;
pattern& operator=(pattern&&) = default;

template <typename Iter>
bool match(Iter first, Iter last) const {
auto dist = static_cast<std::size_t>(std::distance(first, last));
if (dist < noalloc_size) {
char buffer[noalloc_size];
auto buf_end = std::copy(first, last, buffer);
return _match(buffer, buf_end);
} else {
// Allocates
std::string str(first, last);
return _match(str.data(), str.data() + str.size());
}
}

bool match(const char* str) const {
return match(str, str + dds::detail::fnmatch::length(str));
}

template <typename Seq>
bool match(const Seq& seq) const {
using std::begin;
using std::end;
return match(begin(seq), end(seq));
}

std::optional<std::string> literal_spelling() const noexcept;
};

} // namespace fnmatch

} // namespace dds

+ 32
- 0
src/dds/util/fnmatch.test.cpp View File

@@ -0,0 +1,32 @@
#include <dds/util/fnmatch.hpp>

#include <catch2/catch.hpp>

TEST_CASE("Basic fnmatch matching") {
auto pat = dds::fnmatch::compile("foo.bar");
CHECK_FALSE(pat.match("foo.baz"));
CHECK_FALSE(pat.match("foo."));
CHECK_FALSE(pat.match("foo.barz"));
CHECK_FALSE(pat.match("foo.bar "));
CHECK_FALSE(pat.match(" foo.bar"));
CHECK(pat.match("foo.bar"));

pat = dds::fnmatch::compile("foo.*");
CHECK(pat.match("foo."));
auto m = pat.match("foo.b");
CHECK(m);
CHECK(pat.match("foo. "));
CHECK_FALSE(pat.match("foo"));
CHECK_FALSE(pat.match(" foo.bar"));

pat = dds::fnmatch::compile("foo.*.cpp");
for (auto fname : {"foo.bar.cpp", "foo..cpp", "foo.cat.cpp"}) {
auto m = pat.match(fname);
CHECK(m);
}

for (auto fname : {"foo.cpp", "foo.cpp"}) {
auto m = pat.match(fname);
CHECK_FALSE(m);
}
}

+ 238
- 0
src/dds/util/glob.cpp View File

@@ -0,0 +1,238 @@
#include "./glob.hpp"

#include "./fnmatch.hpp"

#include <neo/assert.hpp>

#include <optional>

namespace {

enum glob_coro_ret {
reenter_again,
yield_value,
done,
};

} // namespace

namespace dds::detail {

struct rglob_item {
std::optional<dds::fnmatch::pattern> pattern;
};

struct glob_impl {
std::vector<rglob_item> items;
};

struct glob_iter_state {
fs::path root;
const glob_impl& impl;
std::vector<rglob_item>::const_iterator pat_iter = impl.items.begin();

const bool is_leaf_pattern = std::next(pat_iter) == impl.items.end();

fs::directory_entry entry;
fs::directory_iterator dir_iter{root};
const bool is_rglob = !pat_iter->pattern.has_value();

std::unique_ptr<glob_iter_state> _next_state;
int _state_label = 0;

fs::directory_entry get_entry() const noexcept {
if (_next_state) {
return _next_state->get_entry();
}
return entry;
}

#define CORO_REENTER_POINT \
case __LINE__: \
static_assert(true)
#define CORO_SAVE_POINT _state_label = __LINE__

#define YIELD(E) \
do { \
CORO_SAVE_POINT; \
entry = E; \
return yield_value; \
} while (0); \
CORO_REENTER_POINT

#define EXIT_DIRECTORY() \
do { \
return done; \
} while (0); \
CORO_REENTER_POINT

#define ENTER_DIRECTORY(D, Pat) \
do { \
_next_state.reset(new glob_iter_state{fs::path(D), impl, Pat}); \
CORO_SAVE_POINT; \
return reenter_again; \
} while (0); \
CORO_REENTER_POINT

#define CONTINUE() \
do { \
_state_label = 0; \
return reenter_again; \
} while (0)

glob_coro_ret reenter() {
if (_next_state) {
auto st = _next_state->reenter();
if (st == done) {
_next_state.reset();
return reenter_again;
}
return st;
}

const bool dir_done = dir_iter == fs::directory_iterator();
const auto cur_pattern = pat_iter->pattern;
const bool cur_is_rglob = !cur_pattern.has_value();

switch (_state_label) {
case 0:
//
if (dir_done) {
EXIT_DIRECTORY();
}
entry = *dir_iter++;

if (cur_is_rglob) {
if (is_leaf_pattern) {
YIELD(entry);
} else if (std::next(pat_iter)->pattern.value().match(
fs::path(entry).filename().string())) {
// The next pattern in the glob will match this file directly.
if (entry.is_directory()) {
ENTER_DIRECTORY(entry, std::next(pat_iter));
} else {
YIELD(entry);
}
}
if (entry.is_directory()) {
ENTER_DIRECTORY(entry, pat_iter);
} else {
// A non-directory file matches an `**` pattern? Ignore it.
}
} else {
if (cur_pattern->match(fs::path(entry).filename().string())) {
// We match this entry
if (is_leaf_pattern) {
YIELD(entry);
} else if (entry.is_directory()) {
ENTER_DIRECTORY(entry, std::next(pat_iter));
}
}
}
}

CONTINUE();
}
}; // namespace dds::detail

} // namespace dds::detail

namespace {

dds::detail::glob_impl compile_glob_expr(std::string_view pattern) {
using namespace dds::detail;

glob_impl acc{};

while (!pattern.empty()) {
const auto next_slash = pattern.find('/');
const auto next_part = pattern.substr(0, next_slash);
if (next_slash != pattern.npos) {
pattern.remove_prefix(next_slash + 1);
} else {
pattern = "";
}

if (next_part == "**") {
acc.items.emplace_back();
} else {
acc.items.push_back({dds::fnmatch::compile(next_part)});
}
}

if (acc.items.empty()) {
throw std::runtime_error("Invalid path glob expression (Must not be empty!)");
}

return acc;
}

} // namespace

dds::glob_iterator::glob_iterator(dds::glob gl, dds::path_ref root)
: _impl(gl._impl)
, _done(false) {

_state = std::make_shared<detail::glob_iter_state>(detail::glob_iter_state{root, *_impl});
increment();
}

void dds::glob_iterator::increment() {
auto st = reenter_again;
while (st == reenter_again) {
st = _state->reenter();
}
_done = st == done;
}

dds::fs::directory_entry dds::glob_iterator::dereference() const noexcept {
return _state->get_entry();
}

dds::glob dds::glob::compile(std::string_view pattern) {
glob ret;
ret._impl = std::make_shared<dds::detail::glob_impl>(compile_glob_expr(pattern));
return ret;
}

namespace {

using path_iter = dds::fs::path::const_iterator;
using pat_iter = std::vector<dds::detail::rglob_item>::const_iterator;

bool check_matches(path_iter elem_it,
const path_iter elem_stop,
pat_iter pat_it,
const pat_iter pat_stop) noexcept {
if (elem_it == elem_stop && pat_it == pat_stop) {
return true;
}
if (elem_it == elem_stop || pat_it == pat_stop) {
return false;
}
if (pat_it->pattern.has_value()) {
// A regular pattern
if (!pat_it->pattern->match(elem_it->string())) {
return false;
}
return check_matches(++elem_it, elem_stop, ++pat_it, pat_stop);
} else {
// An rglob pattern "**". Check by peeling of individual path elements
const auto next_pat = std::next(pat_it);
for (; elem_it != elem_stop; ++elem_it) {
if (check_matches(elem_it, elem_stop, next_pat, pat_stop)) {
return true;
}
}
return false;
}
}

} // namespace

bool dds::glob::match(dds::path_ref filepath) const noexcept {
return check_matches(filepath.begin(),
filepath.end(),
_impl->items.cbegin(),
_impl->items.cend());
}

+ 61
- 0
src/dds/util/glob.hpp View File

@@ -0,0 +1,61 @@
#pragma once

#include <dds/util/fs.hpp>

#include <neo/iterator_facade.hpp>

#include <string_view>
#include <vector>

namespace dds {

namespace detail {

struct glob_impl;

struct glob_iter_state;

} // namespace detail

class glob;

class glob_iterator : public neo::iterator_facade<glob_iterator> {
std::shared_ptr<const detail::glob_impl> _impl;

std::shared_ptr<detail::glob_iter_state> _state;

bool _done = true;

public:
glob_iterator() = default;
glob_iterator(glob impl, path_ref root);

fs::directory_entry dereference() const noexcept;
void increment();

struct sentinel_type {};

bool at_end() const noexcept { return _done; }

glob_iterator begin() const noexcept { return *this; }
auto end() const noexcept { return sentinel_type{}; }
};

class glob {
friend class glob_iterator;
std::shared_ptr<const detail::glob_impl> _impl;

glob() = default;

public:
static glob compile(std::string_view str);

auto scan_from(path_ref root) const noexcept { return glob_iterator(*this, root); }

auto begin() const noexcept { return scan_from(fs::current_path()); }
auto end() const noexcept { return glob_iterator::sentinel_type{}; }

bool match(path_ref) const noexcept;
};

} // namespace dds

+ 69
- 0
src/dds/util/glob.test.cpp View File

@@ -0,0 +1,69 @@
#include <dds/util/glob.hpp>

#include <catch2/catch.hpp>

TEST_CASE("Simple glob") {
auto this_dir = dds::fs::path(__FILE__).parent_path();
auto glob = dds::glob::compile("*.test.cpp");

auto it = glob.scan_from(this_dir);
for (; it != glob.end(); ++it) {
auto&& el = *it;
}

int n_found = 0;
for (auto found : glob.scan_from(this_dir)) {
++n_found;
}
CHECK(n_found > 0);

n_found = 0;
for (auto found : dds::glob::compile("glob.test.cpp").scan_from(this_dir)) {
n_found++;
}
CHECK(n_found == 1);

auto me_it = dds::glob::compile("src/**/glob.test.cpp").begin();
REQUIRE(!me_it.at_end());
++me_it;
CHECK(me_it.at_end());

auto all_tests = dds::glob::compile("src/**/*.test.cpp");
n_found = 0;
for (auto f : all_tests) {
n_found += 1;
}
CHECK(n_found > 10);
CHECK(n_found < 1000); // If we have more than 1000 .test files, that's crazy
}

TEST_CASE("Check globs") {
auto glob = dds::glob::compile("foo/bar*/baz");
CHECK(glob.match("foo/bar/baz"));
CHECK(glob.match("foo/barffff/baz"));
CHECK_FALSE(glob.match("foo/bar"));
CHECK_FALSE(glob.match("foo/ffbar/baz"));
CHECK_FALSE(glob.match("foo/bar/bazf"));
CHECK_FALSE(glob.match("foo/bar/"));

glob = dds::glob::compile("foo/**/bar.txt");
CHECK(glob.match("foo/bar.txt"));
CHECK(glob.match("foo/thing/bar.txt"));
CHECK(glob.match("foo/thing/another/bar.txt"));
CHECK_FALSE(glob.match("foo/fail"));
CHECK_FALSE(glob.match("foo/bar.txtf"));
CHECK_FALSE(glob.match("foo/bar.txt/f"));
CHECK_FALSE(glob.match("foo/fbar.txt"));
CHECK_FALSE(glob.match("foo/thing/fail"));
CHECK_FALSE(glob.match("foo/thing/another/fail"));
CHECK_FALSE(glob.match("foo/thing/bar.txt/fail"));
CHECK_FALSE(glob.match("foo/bar.txt/fail"));

glob = dds::glob::compile("foo/**/bar/**/baz.txt");
CHECK(glob.match("foo/bar/baz.txt"));
CHECK(glob.match("foo/thing/bar/baz.txt"));
CHECK(glob.match("foo/thing/bar/baz.txt"));
CHECK(glob.match("foo/thing/bar/thing/baz.txt"));
CHECK(glob.match("foo/bar/thing/baz.txt"));
CHECK(glob.match("foo/bar/baz/baz.txt"));
}

Loading…
Cancel
Save