From e192e488c3a6a05db1a60cfedf770d8b7761b39c Mon Sep 17 00:00:00 2001 From: Felix Rieseberg Date: Sat, 28 Jun 2025 16:11:32 -0700 Subject: [PATCH] feat: Negation patterns (#24) --- src/node/files.ts | 113 ++++++++++++++++++++++++++--------------- test/dxtignore.test.ts | 56 ++++++++++++++++++++ 2 files changed, 127 insertions(+), 42 deletions(-) diff --git a/src/node/files.ts b/src/node/files.ts index cf17e1f..1491ede 100644 --- a/src/node/files.ts +++ b/src/node/files.ts @@ -60,60 +60,89 @@ export function readDxtIgnorePatterns(baseDir: string): string[] { } } +/** + * Tests if a file path matches a given pattern + */ +function matchesPattern( + pattern: string, + filePath: string, + fileName: string, +): boolean { + if (pattern.includes("*")) { + // Enhanced glob matching to handle directory paths like "temp/*" + let patternToMatch = pattern; + + // Handle patterns like "dir/" by converting to "dir/**" + if (pattern.endsWith("/")) { + patternToMatch = pattern + "**"; + } + + // Convert glob pattern to regex, with case sensitivity + const regexPattern = + "^" + + patternToMatch + .replace(/\./g, "\\.") // Escape dots + .replace(/\*\*/g, ".*") // ** matches anything including / + .replace(/\*/g, "[^/]*") + // * matches anything except / + "$"; + + const regex = new RegExp(regexPattern); + + // Test full path + if (regex.test(filePath)) return true; + + // Test filename + if (regex.test(fileName)) return true; + + // Check if any part of the path matches for patterns like "node_modules" + const pathParts = filePath.split(sep); + for (const part of pathParts) { + if (regex.test(part)) return true; + } + } else { + // Exact match + if (fileName === pattern) return true; + + // Check if any part of the path matches + if (filePath.includes(pattern)) return true; + + // Handle directory patterns like "tests/" + if (pattern.endsWith("/") && filePath.startsWith(pattern)) { + return true; + } + } + return false; +} + export function shouldExclude( filePath: string, additionalPatterns: string[] = [], ): boolean { const fileName = basename(filePath); const allPatterns = [...EXCLUDE_PATTERNS, ...additionalPatterns]; + const negationPatterns = allPatterns.filter((p) => p.startsWith("!")); + const exclusionPatterns = allPatterns.filter((p) => !p.startsWith("!")); - for (const pattern of allPatterns) { - if (pattern.includes("*")) { - // Enhanced glob matching to handle directory paths like "temp/*" - let patternToMatch = pattern; + let shouldExcludeFile = false; + for (const pattern of exclusionPatterns) { + if (matchesPattern(pattern, filePath, fileName)) { + shouldExcludeFile = true; + break; + } + } - // Handle patterns like "dir/" by converting to "dir/**" - if (pattern.endsWith("/")) { - patternToMatch = pattern + "**"; - } - - // Convert glob pattern to regex, with case sensitivity - const regexPattern = - "^" + - patternToMatch - .replace(/\./g, "\\.") // Escape dots - .replace(/\*\*/g, ".*") // ** matches anything including / - .replace(/\*/g, "[^/]*") + // * matches anything except / - "$"; - - const regex = new RegExp(regexPattern); - - // Test full path - if (regex.test(filePath)) return true; - - // Test filename - if (regex.test(fileName)) return true; - - // Check if any part of the path matches for patterns like "node_modules" - const pathParts = filePath.split(sep); - for (const part of pathParts) { - if (regex.test(part)) return true; - } - } else { - // Exact match - if (fileName === pattern) return true; - - // Check if any part of the path matches - if (filePath.includes(pattern)) return true; - - // Handle directory patterns like "tests/" - if (pattern.endsWith("/") && filePath.startsWith(pattern)) { - return true; + // If excluded, check if any negation pattern un-excludes it + if (shouldExcludeFile) { + for (const pattern of negationPatterns) { + const negatedPattern = pattern.slice(1); + if (matchesPattern(negatedPattern, filePath, fileName)) { + // File should not be excluded (negation pattern matched) + return false; } } } - return false; + return shouldExcludeFile; } export function getAllFiles( diff --git a/test/dxtignore.test.ts b/test/dxtignore.test.ts index f5ebe4c..762aaa3 100644 --- a/test/dxtignore.test.ts +++ b/test/dxtignore.test.ts @@ -258,4 +258,60 @@ coverage/`; ); }); }); + + describe("Negation patterns", () => { + it("should include files that match negation patterns", () => { + const additionalPatterns = ["*.log", "!important.log"]; + + // debug.log should be excluded (matches *.log, no negation) + expect(shouldExclude("debug.log", additionalPatterns)).toBe(true); + + // important.log should NOT be excluded (matches *.log, but negated by !important.log) + expect(shouldExclude("important.log", additionalPatterns)).toBe(false); + + // Other files should not be excluded + expect(shouldExclude("main.js", additionalPatterns)).toBe(false); + }); + + it("should handle multiple negation patterns", () => { + const additionalPatterns = ["*.log", "!important.log", "!critical.log"]; + + expect(shouldExclude("debug.log", additionalPatterns)).toBe(true); + expect(shouldExclude("important.log", additionalPatterns)).toBe(false); + expect(shouldExclude("critical.log", additionalPatterns)).toBe(false); + expect(shouldExclude("other.log", additionalPatterns)).toBe(true); + }); + + it("should handle negation with glob patterns", () => { + const additionalPatterns = ["temp/*", "!temp/important.txt"]; + + expect(shouldExclude("temp/debug.txt", additionalPatterns)).toBe(true); + expect(shouldExclude("temp/important.txt", additionalPatterns)).toBe( + false, + ); + expect(shouldExclude("other/file.txt", additionalPatterns)).toBe(false); + }); + + it("should handle negation with directory patterns", () => { + const additionalPatterns = ["tests/", "!tests/critical/"]; + + expect(shouldExclude("tests/unit/test.js", additionalPatterns)).toBe( + true, + ); + expect(shouldExclude("tests/critical/test.js", additionalPatterns)).toBe( + false, + ); + expect(shouldExclude("src/main.js", additionalPatterns)).toBe(false); + }); + + it("should not negate files that don't match exclusion patterns", () => { + const additionalPatterns = ["*.log", "!main.js"]; + + // main.js should not be excluded (doesn't match *.log, negation doesn't apply) + expect(shouldExclude("main.js", additionalPatterns)).toBe(false); + + // debug.log should be excluded (matches *.log, no applicable negation) + expect(shouldExclude("debug.log", additionalPatterns)).toBe(true); + }); + }); });