Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
ignore_matcher.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
29
30#include <fstream>
31#include <sstream>
32
33namespace fs = std::filesystem;
34static auto logger = entropic::log::get("mcp.filesystem.ignore");
35
36namespace entropic {
37
38namespace {
39
47std::string trim(const std::string& s) {
48 auto begin = s.find_first_not_of(" \t\r\n");
49 auto end = s.find_last_not_of(" \t\r\n");
50 return (begin == std::string::npos)
51 ? std::string{}
52 : s.substr(begin, end - begin + 1);
53}
54
62std::string to_slash(const fs::path& p) {
63 auto s = p.generic_string();
64 return s;
65}
66
78bool is_regex_meta(char c) {
79 switch (c) {
80 case '.': case '+': case '(': case ')':
81 case '|': case '^': case '$': case '{':
82 case '}': case '\\':
83 return true;
84 default:
85 return false;
86 }
87}
88
89} // namespace
90
107bool IgnoreMatcher::is_skipped_dir_name(const std::string& name) {
108 static const std::vector<std::string> kSkipDirs = {
109 ".git", "node_modules", "__pycache__", ".venv"
110 };
111 bool skipped = false;
112 for (const auto& skip : kSkipDirs) {
113 if (name == skip) { skipped = true; }
114 }
115 return skipped;
116}
117
118// ── Pattern compilation ──────────────────────────────────
119
120namespace {
121
133void emit_star(const std::string& pattern, size_t& i, std::string& out) {
134 bool double_star = (i + 1 < pattern.size())
135 && pattern[i + 1] == '*';
136 if (!double_star) { out += "[^/]*"; return; }
137 out += ".*";
138 ++i;
139 if (i + 1 < pattern.size() && pattern[i + 1] == '/') {
140 ++i;
141 }
142}
143
150void emit_bracket(const std::string& pattern, size_t& i,
151 std::string& out) {
152 out += '[';
153 ++i;
154 while (i < pattern.size() && pattern[i] != ']') {
155 out += pattern[i];
156 ++i;
157 }
158 out += ']';
159}
160
166void emit_escape(const std::string& pattern, size_t& i,
167 std::string& out) {
168 char next = pattern[i + 1];
169 if (is_regex_meta(next)) { out += '\\'; }
170 out += next;
171 ++i;
172}
173
174} // namespace
175
186namespace {
187
197void emit_one(const std::string& pattern, size_t& i,
198 std::string& out) {
199 char c = pattern[i];
200 bool handled = true;
201 switch (c) {
202 case '*': emit_star(pattern, i, out); break;
203 case '?': out += "[^/]"; break;
204 case '[': emit_bracket(pattern, i, out); break;
205 case '\\':
206 if (i + 1 < pattern.size()) {
207 emit_escape(pattern, i, out);
208 } else {
209 handled = false;
210 }
211 break;
212 default: handled = false; break;
213 }
214 if (!handled) {
215 if (is_regex_meta(c)) { out += '\\'; }
216 out += c;
217 }
218}
219
220} // namespace
221
236std::string IgnoreMatcher::pattern_to_regex(const std::string& pattern) {
237 std::string out;
238 out.reserve(pattern.size() * 2);
239 for (size_t i = 0; i < pattern.size(); ++i) {
240 emit_one(pattern, i, out);
241 }
242 return out;
243}
244
256namespace {
257
264void strip_flags(std::string& body, IgnoreMatcher::Rule& rule) {
265 if (!body.empty() && body[0] == '!') {
266 rule.negate = true;
267 body.erase(0, 1);
268 }
269 if (!body.empty() && body.back() == '/') {
270 rule.dir_only = true;
271 body.pop_back();
272 }
273}
274
288std::string normalize_base(const std::string& base) {
289 std::string out = base;
290 if (out.rfind("./", 0) == 0) { out.erase(0, 2); }
291 if (out == ".") { out.clear(); }
292 while (!out.empty() && out.back() == '/') { out.pop_back(); }
293 return out;
294}
295
305std::string make_base_prefix(const std::string& base) {
306 if (base.empty()) { return {}; }
307 std::string raw = base + "/";
308 std::string out;
309 for (char c : raw) {
310 if (is_regex_meta(c) || c == '*' || c == '?' || c == '[') {
311 out += '\\';
312 }
313 out += c;
314 }
315 return out;
316}
317
333std::regex compile_or_never(const std::string& src,
334 const std::string& original_pattern) {
335 try {
336 return std::regex(src);
337 } catch (const std::regex_error& e) {
338 logger->warn("Skipping malformed ignore pattern '{}': {}",
339 original_pattern, e.what());
340 return std::regex("(?!)");
341 }
342}
343
344} // namespace
345
367IgnoreMatcher::Rule IgnoreMatcher::compile_pattern(
368 const std::string& pattern, const std::string& base) {
369 Rule rule;
370 rule.original = pattern;
371 rule.base = normalize_base(base);
372
373 std::string body = pattern;
374 strip_flags(body, rule);
375 bool root_anchored = !body.empty() && body[0] == '/';
376 if (root_anchored) { body.erase(0, 1); }
378 || body.find('/') != std::string::npos;
379
380 std::string regex_body = pattern_to_regex(body);
381 std::string base_prefix = make_base_prefix(rule.base);
382 std::string anchor_left = anchored
383 ? ("^" + base_prefix)
384 : ("^" + base_prefix + "(?:.*/)?");
385
386 rule.re_exact = compile_or_never(
388 rule.re_under = compile_or_never(
389 anchor_left + regex_body + "/.*$", pattern);
390 return rule;
391}
392
393// ── Public API ───────────────────────────────────────────
394
406void IgnoreMatcher::push_rule(Rule&& rule) {
407 std::string base = rule.base;
408 rules_.push_back(std::move(rule));
409 by_base_[base].push_back(rules_.size() - 1);
410}
411
423void IgnoreMatcher::add_pattern(const std::string& pattern,
424 const fs::path& base) {
425 std::string trimmed = trim(pattern);
426 if (trimmed.empty() || trimmed[0] == '#') { return; }
427 push_rule(compile_pattern(trimmed, to_slash(base)));
428}
429
442void IgnoreMatcher::load_file(const fs::path& path,
443 const std::string& base) {
444 std::ifstream in(path);
445 if (!in.is_open()) { return; }
446 std::string line;
447 int loaded = 0;
448 while (std::getline(in, line)) {
449 std::string trimmed = trim(line);
450 if (trimmed.empty() || trimmed[0] == '#') { continue; }
451 push_rule(compile_pattern(trimmed, base));
452 ++loaded;
453 }
454 std::string base_label = base.empty() ? std::string("<root>") : base;
455 logger->info("Loaded {} ignore rules from {} (base='{}')",
456 loaded, path.string(), base_label);
457}
458
472void IgnoreMatcher::load(const fs::path& root) {
473 rules_.clear();
474 by_base_.clear();
476 if (!fs::exists(root) || !fs::is_directory(root)) {
477 logger->warn("IgnoreMatcher::load: root does not exist: {}",
478 root.string());
479 return;
480 }
481
482 auto canonical_root = fs::weakly_canonical(root);
483 fs::path root_gi = canonical_root / ".gitignore";
484 if (fs::exists(root_gi)) { load_file(root_gi, ""); }
485
486 load_nested_gitignores(canonical_root, root_gi);
487
488 fs::path explorer = canonical_root / ".explorerignore";
489 if (fs::exists(explorer)) { load_file(explorer, ""); }
490}
491
499void IgnoreMatcher::load_nested_gitignores(
500 const fs::path& canonical_root, const fs::path& root_gi) {
501 try {
502 fs::recursive_directory_iterator it(
504 fs::directory_options::skip_permission_denied);
505 const fs::recursive_directory_iterator end;
506 for (; it != end; ++it) {
507 visit_discovery_entry(it, canonical_root, root_gi);
508 }
509 } catch (const std::exception& e) {
510 logger->warn("Recursive gitignore scan aborted: {}", e.what());
511 }
512}
513
535void IgnoreMatcher::visit_discovery_entry(
536 fs::recursive_directory_iterator& it,
537 const fs::path& canonical_root, const fs::path& root_gi) {
538 const auto& entry = *it;
539 if (entry.is_directory()) {
540 if (prunes_discovery(entry.path(), canonical_root)) {
541 it.disable_recursion_pending();
542 }
543 return;
544 }
545 if (entry.path().filename() != ".gitignore"
546 || entry.path() == root_gi) {
547 return;
548 }
549 auto rel_dir = fs::relative(entry.path().parent_path(),
551 load_file(entry.path(), to_slash(rel_dir));
552}
553
568bool IgnoreMatcher::prunes_discovery(
569 const fs::path& dir, const fs::path& canonical_root) const {
570 if (is_skipped_dir_name(dir.filename().string())) { return true; }
571 auto rel = to_slash(fs::relative(dir, canonical_root));
572 return !rel.empty() && is_ignored(rel, true);
573}
574
575// ── Bucketed matching (gh#161) ───────────────────────────
576
577namespace {
578
580using BaseIndex =
581 std::unordered_map<std::string, std::vector<std::size_t>>;
582
584constexpr std::size_t kNoRule = static_cast<std::size_t>(-1);
585
591struct BucketCursor {
592 const std::vector<std::size_t>* indices;
593 std::size_t remaining;
594};
595
611void collect_buckets(const BaseIndex& index, const std::string& rel_path,
612 std::vector<BucketCursor>& out) {
613 auto take = [&](const std::string& base) {
614 auto found = index.find(base);
615 if (found != index.end() && !found->second.empty()) {
616 out.push_back({&found->second, found->second.size()});
617 }
618 };
619 take("");
620 for (std::size_t i = 0; i < rel_path.size(); ++i) {
621 if (rel_path[i] == '/') { take(rel_path.substr(0, i)); }
622 }
623}
624
638std::size_t pop_highest(std::vector<BucketCursor>& cursors) {
639 std::size_t best = kNoRule;
640 BucketCursor* from = nullptr;
641 for (auto& cursor : cursors) {
642 if (cursor.remaining == 0) { continue; }
643 std::size_t candidate = (*cursor.indices)[cursor.remaining - 1];
644 if (from == nullptr || candidate > best) {
645 best = candidate;
646 from = &cursor;
647 }
648 }
649 if (from != nullptr) { --from->remaining; }
650 return best;
651}
652
669bool rule_matches(const IgnoreMatcher::Rule& rule,
670 const std::string& rel_path, bool is_dir,
671 std::atomic<std::uint64_t>& evals) {
672 evals.fetch_add(1, std::memory_order_relaxed);
673 bool matched = std::regex_match(rel_path, rule.re_under);
674 // For dir_only rules an exact match only counts when the path is
675 // itself a directory — a regular file named like a `dir/` pattern
676 // is NOT excluded.
677 if (!matched && (!rule.dir_only || is_dir)) {
678 evals.fetch_add(1, std::memory_order_relaxed);
679 matched = std::regex_match(rel_path, rule.re_exact);
680 }
681 return matched;
682}
683
684} // namespace
685
707bool IgnoreMatcher::is_ignored(const std::string& rel_path,
708 bool is_dir) const {
709 std::vector<BucketCursor> cursors;
710 collect_buckets(by_base_, rel_path, cursors);
711
712 bool ignored = false;
713 for (std::size_t idx = pop_highest(cursors); idx != kNoRule;
715 if (rule_matches(rules_[idx], rel_path, is_dir, regex_evals_)) {
716 ignored = !rules_[idx].negate;
717 break;
718 }
719 }
720 return ignored;
721}
722
723} // namespace entropic
static bool is_skipped_dir_name(const std::string &name)
Directory names never walked, by name alone (gh#161).
void load(const std::filesystem::path &root)
Load gitignore + explorerignore from a workspace root.
void add_pattern(const std::string &pattern, const std::filesystem::path &base={})
Add a single pattern programmatically (test surface).
bool is_ignored(const std::string &rel_path, bool is_dir) const
Test whether a path is ignored.
void reset_regex_evals() const
Reset the regex-evaluation counter (gh#161, test surface).
std::size_t remaining
Unexamined count.
const std::vector< std::size_t > * indices
Ascending indices.
Path-relative ignore matching honoring .gitignore + .explorerignore.
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
Activate model on GPU (WARM → ACTIVE).
@ skipped
Validation did not run (skip_tiers / pure-tool-call / empty)
@ optional
optional: every escape asks the host's path approver