Entropic 2.11.1
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
91// ── Pattern compilation ──────────────────────────────────
92
93namespace {
94
106void emit_star(const std::string& pattern, size_t& i, std::string& out) {
107 bool double_star = (i + 1 < pattern.size())
108 && pattern[i + 1] == '*';
109 if (!double_star) { out += "[^/]*"; return; }
110 out += ".*";
111 ++i;
112 if (i + 1 < pattern.size() && pattern[i + 1] == '/') {
113 ++i;
114 }
115}
116
123void emit_bracket(const std::string& pattern, size_t& i,
124 std::string& out) {
125 out += '[';
126 ++i;
127 while (i < pattern.size() && pattern[i] != ']') {
128 out += pattern[i];
129 ++i;
130 }
131 out += ']';
132}
133
139void emit_escape(const std::string& pattern, size_t& i,
140 std::string& out) {
141 char next = pattern[i + 1];
142 if (is_regex_meta(next)) { out += '\\'; }
143 out += next;
144 ++i;
145}
146
147} // namespace
148
159namespace {
160
170void emit_one(const std::string& pattern, size_t& i,
171 std::string& out) {
172 char c = pattern[i];
173 bool handled = true;
174 switch (c) {
175 case '*': emit_star(pattern, i, out); break;
176 case '?': out += "[^/]"; break;
177 case '[': emit_bracket(pattern, i, out); break;
178 case '\\':
179 if (i + 1 < pattern.size()) {
180 emit_escape(pattern, i, out);
181 } else {
182 handled = false;
183 }
184 break;
185 default: handled = false; break;
186 }
187 if (!handled) {
188 if (is_regex_meta(c)) { out += '\\'; }
189 out += c;
190 }
191}
192
193} // namespace
194
209std::string IgnoreMatcher::pattern_to_regex(const std::string& pattern) {
210 std::string out;
211 out.reserve(pattern.size() * 2);
212 for (size_t i = 0; i < pattern.size(); ++i) {
213 emit_one(pattern, i, out);
214 }
215 return out;
216}
217
229namespace {
230
237void strip_flags(std::string& body, IgnoreMatcher::Rule& rule) {
238 if (!body.empty() && body[0] == '!') {
239 rule.negate = true;
240 body.erase(0, 1);
241 }
242 if (!body.empty() && body.back() == '/') {
243 rule.dir_only = true;
244 body.pop_back();
245 }
246}
247
257std::string make_base_prefix(const std::string& base) {
258 if (base.empty()) { return {}; }
259 std::string raw = base + "/";
260 std::string out;
261 for (char c : raw) {
262 if (is_regex_meta(c) || c == '*' || c == '?' || c == '[') {
263 out += '\\';
264 }
265 out += c;
266 }
267 return out;
268}
269
285std::regex compile_or_never(const std::string& src,
286 const std::string& original_pattern) {
287 try {
288 return std::regex(src);
289 } catch (const std::regex_error& e) {
290 logger->warn("Skipping malformed ignore pattern '{}': {}",
291 original_pattern, e.what());
292 return std::regex("(?!)");
293 }
294}
295
296} // namespace
297
319IgnoreMatcher::Rule IgnoreMatcher::compile_pattern(
320 const std::string& pattern, const std::string& base) {
321 Rule rule;
322 rule.original = pattern;
323 rule.base = base;
324
325 std::string body = pattern;
326 strip_flags(body, rule);
327 bool root_anchored = !body.empty() && body[0] == '/';
328 if (root_anchored) { body.erase(0, 1); }
329 bool anchored = root_anchored
330 || body.find('/') != std::string::npos;
331
332 std::string regex_body = pattern_to_regex(body);
333 std::string base_prefix = make_base_prefix(base);
334 std::string anchor_left = anchored
335 ? ("^" + base_prefix)
336 : ("^" + base_prefix + "(?:.*/)?");
337
338 rule.re_exact = compile_or_never(
339 anchor_left + regex_body + "$", pattern);
340 rule.re_under = compile_or_never(
341 anchor_left + regex_body + "/.*$", pattern);
342 return rule;
343}
344
345// ── Public API ───────────────────────────────────────────
346
358void IgnoreMatcher::add_pattern(const std::string& pattern,
359 const fs::path& base) {
360 std::string trimmed = trim(pattern);
361 if (trimmed.empty() || trimmed[0] == '#') { return; }
362 rules_.push_back(compile_pattern(trimmed, to_slash(base)));
363}
364
377void IgnoreMatcher::load_file(const fs::path& path,
378 const std::string& base) {
379 std::ifstream in(path);
380 if (!in.is_open()) { return; }
381 std::string line;
382 int loaded = 0;
383 while (std::getline(in, line)) {
384 std::string trimmed = trim(line);
385 if (trimmed.empty() || trimmed[0] == '#') { continue; }
386 rules_.push_back(compile_pattern(trimmed, base));
387 ++loaded;
388 }
389 std::string base_label = base.empty() ? std::string("<root>") : base;
390 logger->info("Loaded {} ignore rules from {} (base='{}')",
391 loaded, path.string(), base_label);
392}
393
407void IgnoreMatcher::load(const fs::path& root) {
408 rules_.clear();
409 if (!fs::exists(root) || !fs::is_directory(root)) {
410 logger->warn("IgnoreMatcher::load: root does not exist: {}",
411 root.string());
412 return;
413 }
414
415 auto canonical_root = fs::weakly_canonical(root);
416 fs::path root_gi = canonical_root / ".gitignore";
417 if (fs::exists(root_gi)) { load_file(root_gi, ""); }
418
419 load_nested_gitignores(canonical_root, root_gi);
420
421 fs::path explorer = canonical_root / ".explorerignore";
422 if (fs::exists(explorer)) { load_file(explorer, ""); }
423}
424
432void IgnoreMatcher::load_nested_gitignores(
433 const fs::path& canonical_root, const fs::path& root_gi) {
434 // Recursively discover .gitignore files in subdirectories. We skip
435 // the root one (already loaded) and directories the accumulated
436 // rule set already excludes (avoids descending into node_modules
437 // just to find an irrelevant .gitignore).
438 try {
439 auto it = fs::recursive_directory_iterator(
440 canonical_root,
441 fs::directory_options::skip_permission_denied);
442 for (auto& entry : it) {
443 if (!entry.is_regular_file()) { continue; }
444 if (entry.path().filename() != ".gitignore") { continue; }
445 if (entry.path() == root_gi) { continue; }
446 auto rel_dir = fs::relative(entry.path().parent_path(),
447 canonical_root);
448 load_file(entry.path(), to_slash(rel_dir));
449 }
450 } catch (const std::exception& e) {
451 logger->warn("Recursive gitignore scan aborted: {}", e.what());
452 }
453}
454
471bool IgnoreMatcher::is_ignored(const std::string& rel_path,
472 bool is_dir) const {
473 bool ignored = false;
474 for (const auto& rule : rules_) {
475 bool match_under = std::regex_match(rel_path, rule.re_under);
476 bool match_exact = std::regex_match(rel_path, rule.re_exact);
477 // For dir_only rules, an exact match only counts when the path
478 // is itself a directory (a regular file with the same name as
479 // a `dir/` pattern is NOT excluded). re_under always counts —
480 // any descendant inherits the parent's exclusion.
481 bool exact_counts = match_exact
482 && (!rule.dir_only || is_dir);
483 if (match_under || exact_counts) {
484 ignored = !rule.negate;
485 }
486 }
487 return ignored;
488}
489
490} // namespace entropic
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.
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).