diff --git a/internal/config/config.go b/internal/config/config.go index c43fab908..4967c5001 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,10 +5,7 @@ import ( "fmt" "os" "path/filepath" - "strings" - "github.com/bmatcuk/doublestar/v4" - "github.com/microsoft/typescript-go/shim/tspath" importPlugin "github.com/web-infra-dev/rslint/internal/plugins/import" "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/adjacent_overload_signatures" "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/array_type" @@ -65,6 +62,7 @@ import ( "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/only_throw_error" "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/prefer_as_const" "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/prefer_promise_reject_errors" + // "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/prefer_readonly_parameter_types" // Temporarily disabled - incomplete implementation "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/prefer_reduce_type_parameter" "github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/prefer_return_this_type" @@ -108,6 +106,15 @@ type ConfigEntry struct { LanguageOptions *LanguageOptions `json:"languageOptions,omitempty"` Rules Rules `json:"rules"` Plugins []string `json:"plugins,omitempty"` // List of plugin names + matcher *fileMatcher // Cache for the file matcher +} + +// GetFileMatcher returns the cached file matcher or creates a new one +func (c *ConfigEntry) GetFileMatcher(cwd string) *fileMatcher { + if c.matcher == nil { + c.matcher = newFileMatcher(c, cwd) + } + return c.matcher } // LanguageOptions contains language-specific configuration options @@ -304,49 +311,47 @@ func parseArrayRuleConfig(ruleArray []interface{}) *RuleConfig { func (config RslintConfig) GetRulesForFile(filePath string) map[string]*RuleConfig { enabledRules := make(map[string]*RuleConfig) - for _, entry := range config { - // First check if the file should be ignored - if isFileIgnored(filePath, entry.Ignores) { - continue // Skip this config entry for ignored files - } + cwd, _ := os.Getwd() - // Check if the file matches the files pattern - matches := true + for i := range config { + entry := &config[i] - if matches { + fileMatcher := entry.GetFileMatcher(cwd) + if !fileMatcher.isFileMatched(normalizeAbsolutePath(filePath, cwd)) { + continue + } - /// Merge rules from plugin - for _, plugin := range entry.Plugins { + /// Merge rules from plugin + for _, plugin := range entry.Plugins { - for _, rule := range GetAllRulesForPlugin(plugin) { - enabledRules[rule.Name] = &RuleConfig{Level: "error"} // Default level for plugin rules - } + for _, rule := range GetAllRulesForPlugin(plugin) { + enabledRules[rule.Name] = &RuleConfig{Level: "error"} // Default level for plugin rules } - // Merge rules from this entry - for ruleName, ruleValue := range entry.Rules { - - switch v := ruleValue.(type) { - case string: - // Handle simple string values like "error", "warn", "off" - enabledRules[ruleName] = &RuleConfig{Level: v} - case map[string]interface{}: - // Handle object configuration - ruleConfig := &RuleConfig{} - if level, ok := v["level"].(string); ok { - ruleConfig.Level = level - } - if options, ok := v["options"].(map[string]interface{}); ok { - ruleConfig.Options = options - } - if ruleConfig.IsEnabled() { - enabledRules[ruleName] = ruleConfig - } - case []interface{}: - // Handle array format like ["error", {...options}] or ["warn"] or ["off"] - ruleConfig := parseArrayRuleConfig(v) - if ruleConfig != nil && ruleConfig.IsEnabled() { - enabledRules[ruleName] = ruleConfig - } + } + // Merge rules from this entry + for ruleName, ruleValue := range entry.Rules { + + switch v := ruleValue.(type) { + case string: + // Handle simple string values like "error", "warn", "off" + enabledRules[ruleName] = &RuleConfig{Level: v} + case map[string]interface{}: + // Handle object configuration + ruleConfig := &RuleConfig{} + if level, ok := v["level"].(string); ok { + ruleConfig.Level = level + } + if options, ok := v["options"].(map[string]interface{}); ok { + ruleConfig.Options = options + } + if ruleConfig.IsEnabled() { + enabledRules[ruleName] = ruleConfig + } + case []interface{}: + // Handle array format like ["error", {...options}] or ["warn"] or ["off"] + ruleConfig := parseArrayRuleConfig(v) + if ruleConfig != nil && ruleConfig.IsEnabled() { + enabledRules[ruleName] = ruleConfig } } } @@ -471,60 +476,6 @@ func getAllTypeScriptEslintPluginRules() []rule.Rule { return rules } -// isFileIgnored checks if a file should be ignored based on ignore patterns -func isFileIgnored(filePath string, ignorePatterns []string) bool { - // Get current working directory for relative path resolution - cwd, err := os.Getwd() - if err != nil { - // If we can't get cwd, fall back to simple matching - return isFileIgnoredSimple(filePath, ignorePatterns) - } - - // Normalize the file path relative to cwd - normalizedPath := normalizePath(filePath, cwd) - - for _, pattern := range ignorePatterns { - // Try matching against normalized path - if matched, err := doublestar.Match(pattern, normalizedPath); err == nil && matched { - return true - } - - // Also try matching against original path for absolute patterns - if normalizedPath != filePath { - if matched, err := doublestar.Match(pattern, filePath); err == nil && matched { - return true - } - } - - // Try Unix-style path for cross-platform compatibility - unixPath := strings.ReplaceAll(normalizedPath, "\\", "/") - if unixPath != normalizedPath { - if matched, err := doublestar.Match(pattern, unixPath); err == nil && matched { - return true - } - } - } - return false -} - -// normalizePath converts file path to be relative to cwd for consistent matching -func normalizePath(filePath, cwd string) string { - return tspath.NormalizePath(tspath.ConvertToRelativePath(filePath, tspath.ComparePathsOptions{ - UseCaseSensitiveFileNames: true, - CurrentDirectory: cwd, - })) -} - -// isFileIgnoredSimple provides fallback matching when cwd is unavailable -func isFileIgnoredSimple(filePath string, ignorePatterns []string) bool { - for _, pattern := range ignorePatterns { - if matched, err := doublestar.Match(pattern, filePath); err == nil && matched { - return true - } - } - return false -} - // initialize a default config in the directory func InitDefaultConfig(directory string) error { configPath := filepath.Join(directory, "rslint.jsonc") diff --git a/internal/config/cwd_test.go b/internal/config/cwd_test.go deleted file mode 100644 index f6f9c3d41..000000000 --- a/internal/config/cwd_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" - - "github.com/bmatcuk/doublestar/v4" -) - -func TestCwdHandling(t *testing.T) { - // Save the original working directory - originalCwd, err := os.Getwd() - if err != nil { - t.Fatalf("Unable to get current working directory: %v", err) - } - defer t.Chdir(originalCwd) // Restore after test completes - - tests := []struct { - name string - filePath string - patterns []string - shouldIgnore bool - description string - }{ - { - name: "Relative path matching", - filePath: "src/component.ts", - patterns: []string{"src/**"}, - shouldIgnore: true, - description: "Relative paths should match relative patterns", - }, - { - name: "Absolute path to relative path matching", - filePath: filepath.Join(originalCwd, "src/component.ts"), - patterns: []string{"src/**"}, - shouldIgnore: true, - description: "Absolute paths should be converted to relative paths before matching", - }, - { - name: "node_modules recursive matching", - filePath: "node_modules/package/deep/file.js", - patterns: []string{"node_modules/**"}, - shouldIgnore: true, - description: "Recursive patterns should match deeply nested files", - }, - { - name: "Test file pattern matching", - filePath: "src/utils/helper.test.ts", - patterns: []string{"**/*.test.ts"}, - shouldIgnore: true, - description: "Global recursive patterns should match test files in any location", - }, - { - name: "Non-matching file", - filePath: "src/component.ts", - patterns: []string{"dist/**", "*.log"}, - shouldIgnore: false, - description: "Files not matching any pattern should not be ignored", - }, - { - name: "Cross-platform path handling", - filePath: "src\\windows\\style\\path.ts", - patterns: []string{"src/**"}, - shouldIgnore: true, - description: "Windows style paths should be handled correctly", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isFileIgnored(tt.filePath, tt.patterns) - if result != tt.shouldIgnore { - t.Errorf("%s: isFileIgnored(%q, %v) = %v, expected %v", - tt.description, tt.filePath, tt.patterns, result, tt.shouldIgnore) - } - }) - } -} - -func TestNormalizePath(t *testing.T) { - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("Unable to get working directory: %v", err) - } - - tests := []struct { - name string - filePath string - expected string - }{ - { - name: "Relative path remains unchanged", - filePath: "src/component.ts", - expected: "src/component.ts", - }, - { - name: "Absolute path converts to relative path", - filePath: filepath.Join(cwd, "src/component.ts"), - expected: "src/component.ts", - }, - { - name: "Current directory marker", - filePath: "./src/component.ts", - expected: "src/component.ts", - }, - { - name: "Complex relative path", - filePath: "src/../src/component.ts", - expected: "src/component.ts", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizePath(tt.filePath, cwd) - if result != tt.expected { - t.Errorf("normalizePath(%q, %q) = %q, expected %q", - tt.filePath, cwd, result, tt.expected) - } - }) - } -} - -func TestDoublestarBehavior(t *testing.T) { - // Test specific behavior of the doublestar library - tests := []struct { - pattern string - path string - expected bool - name string - }{ - {"**/*.ts", "src/file.ts", true, "Recursive matching of TypeScript files"}, - {"**/*.ts", "src/deep/nested/file.ts", true, "Deep recursive matching"}, - {"src/**", "src/file.ts", true, "Directory recursive matching"}, - {"src/**", "src/deep/nested/file.ts", true, "Deep directory recursive matching"}, - {"*.ts", "file.ts", true, "Single-level wildcard"}, - {"*.ts", "src/file.ts", false, "Single-level wildcard doesn't match nested files"}, - {"node_modules/**", "node_modules/package/file.js", true, "node_modules recursive matching"}, - {"**/test/**", "src/test/file.js", true, "Middle recursive matching"}, - {"**/test/**", "test/file.js", true, "Beginning recursive matching"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - matched, err := doublestar.Match(tt.pattern, tt.path) - if err != nil { - t.Errorf("doublestar.PathMatch error: %v", err) - return - } - if matched != tt.expected { - t.Errorf("doublestar.PathMatch(%q, %q) = %v, expected %v", - tt.pattern, tt.path, matched, tt.expected) - } - }) - } -} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 000000000..ba3f000a3 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,143 @@ +package config + +import ( + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/typescript-go/shim/tspath" +) + +// negPatternPrefix is the prefix character used to indicate a negation pattern. +const negPatternPrefix = "!" + +// normalizeAbsolutePath converts file path to be a absolute path to cwd +func normalizeAbsolutePath(filePath, cwd string) string { + return tspath.GetNormalizedAbsolutePath(filePath, cwd) +} + +// parseNegationPattern parses a glob pattern and distinguishes whether it's a negation pattern. +// Returns (isNeg, actualPattern) where isNeg indicates if the pattern starts with "!". +func parseNegationPattern(pattern string) (isNeg bool, actualPattern string) { + if strings.HasPrefix(pattern, negPatternPrefix) { + return true, pattern[1:] + } + return false, pattern +} + +// classifyPatterns splits a list of glob patterns into its positive and negative patterns. +func classifyPatterns(patterns []string) (positivePatterns []string, negativePatterns []string) { + for _, pattern := range patterns { + isNeg, actualPattern := parseNegationPattern(pattern) + if isNeg { + negativePatterns = append(negativePatterns, actualPattern) + } else { + positivePatterns = append(positivePatterns, actualPattern) + } + } + return positivePatterns, negativePatterns +} + +// normalizePatterns normalizes a list of glob patterns to be absolute paths relative to cwd. +func normalizePatterns(patterns []string, cwd string) []string { + normalizedPatterns := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + normalizedPatterns = append(normalizedPatterns, normalizeAbsolutePath(pattern, cwd)) + } + return normalizedPatterns +} + +// normalizedFilePatterns is a struct that contains the positive glob patterns and negative glob patterns normalized to be absolute paths relative to cwd. +type normalizedFilePatterns struct { + positivePatterns []string + negativePatterns []string +} + +func newNormalizedFilePatterns(patterns []string, cwd string) *normalizedFilePatterns { + positivePatterns, negativePatterns := classifyPatterns(patterns) + return &normalizedFilePatterns{ + positivePatterns: normalizePatterns(positivePatterns, cwd), + negativePatterns: normalizePatterns(negativePatterns, cwd), + } +} + +// isFileMatched checks if a file is matched by the normalized file patterns. +// A file is matched if: +// - It matches at least one positive pattern (or there are no positive patterns), and +// - It is not excluded by any negative pattern. +func (n *normalizedFilePatterns) isFileMatched(absoluteFilePath string) bool { + isMatched := len(n.positivePatterns) == 0 + for _, pattern := range n.positivePatterns { + if matched, err := doublestar.Match(pattern, absoluteFilePath); err == nil && matched { + isMatched = true + break + } + } + if !isMatched { + return false + } + for _, pattern := range n.negativePatterns { + if matched, err := doublestar.Match(pattern, absoluteFilePath); err == nil && matched { + isMatched = false + break + } + } + return isMatched +} + +type normalizedIgnorePattern struct { + isNeg bool + pattern string +} + +// normalizedIgnorePatterns is a collection of normalized ignore patterns. +type normalizedIgnorePatterns []*normalizedIgnorePattern + +func newNormalizedIgnorePatterns(patterns []string, cwd string) normalizedIgnorePatterns { + parsedPatterns := make([]*normalizedIgnorePattern, 0, len(patterns)) + for _, pattern := range patterns { + isNeg, actualPattern := parseNegationPattern(pattern) + parsedPatterns = append(parsedPatterns, &normalizedIgnorePattern{ + isNeg: isNeg, + pattern: normalizeAbsolutePath(actualPattern, cwd), + }) + } + return parsedPatterns +} + +// isFileIgnored checks if a file should be ignored based on the normalized ignore patterns. +// It follows ESLint's "last match wins" rule: patterns are processed in order, +// and the last matching pattern determines whether the file is ignored. +func (n normalizedIgnorePatterns) isFileIgnored(absoluteFilePath string) bool { + isIgnored := false + for _, pattern := range n { + if matched, err := doublestar.Match(pattern.pattern, absoluteFilePath); err == nil { + if matched { + if pattern.isNeg { + isIgnored = false + } else { + isIgnored = true + } + } + } + } + return isIgnored +} + +type fileMatcher struct { + normalizedFilePatterns *normalizedFilePatterns + normalizedIgnorePatterns normalizedIgnorePatterns +} + +func newFileMatcher(config *ConfigEntry, cwd string) *fileMatcher { + return &fileMatcher{ + normalizedFilePatterns: newNormalizedFilePatterns(config.Files, cwd), + normalizedIgnorePatterns: newNormalizedIgnorePatterns(config.Ignores, cwd), + } +} + +func (f *fileMatcher) isFileMatched(absoluteFilePath string) bool { + if f.normalizedIgnorePatterns.isFileIgnored(absoluteFilePath) { + return false + } + return f.normalizedFilePatterns.isFileMatched(absoluteFilePath) +} diff --git a/internal/config/file_test.go b/internal/config/file_test.go new file mode 100644 index 000000000..711de67c7 --- /dev/null +++ b/internal/config/file_test.go @@ -0,0 +1,548 @@ +package config + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/bmatcuk/doublestar/v4" +) + +func TestDoublestarBehavior(t *testing.T) { + // Test specific behavior of the doublestar library + tests := []struct { + pattern string + path string + expected bool + name string + }{ + {"**/*.ts", "src/file.ts", true, "Recursive matching of TypeScript files"}, + {"**/*.ts", "src/deep/nested/file.ts", true, "Deep recursive matching"}, + {"src/**", "src/file.ts", true, "Directory recursive matching"}, + {"src/**", "src/deep/nested/file.ts", true, "Deep directory recursive matching"}, + {"*.ts", "file.ts", true, "Single-level wildcard"}, + {"*.ts", "src/file.ts", false, "Single-level wildcard doesn't match nested files"}, + {"node_modules/**", "node_modules/package/file.js", true, "node_modules recursive matching"}, + {"**/test/**", "src/test/file.js", true, "Middle recursive matching"}, + {"**/test/**", "test/file.js", true, "Beginning recursive matching"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched, err := doublestar.Match(tt.pattern, tt.path) + if err != nil { + t.Errorf("doublestar.PathMatch error: %v", err) + return + } + if matched != tt.expected { + t.Errorf("doublestar.PathMatch(%q, %q) = %v, expected %v", + tt.pattern, tt.path, matched, tt.expected) + } + }) + } +} + +func TestNormalizePath(t *testing.T) { + tests := []struct { + name string + filePath string + cwd string + expected string + }{ + { + name: "Relative path converts to absolute path", + filePath: "src/main.ts", + cwd: "/Users/labmda47/Code/test", + expected: "/Users/labmda47/Code/test/src/main.ts", + }, + { + name: "Absolute path remains unchanged", + filePath: "/Users/labmda47/Code/test/src/main.ts", + cwd: "/Users/labmda47/Code/test", + expected: "/Users/labmda47/Code/test/src/main.ts", + }, + { + name: "Complex relative path", + filePath: "./src/../src/main.ts", + cwd: "/Users/labmda47/Code/test", + expected: "/Users/labmda47/Code/test/src/main.ts", + }, + { + name: "Windows path normalized", + filePath: "src\\main.ts", + cwd: "D:\\Code", + expected: "D:/Code/src/main.ts", + }, + { + name: "Windows relative path converts to absolute path", + filePath: ".\\src\\..\\src\\main.ts", + cwd: "D:\\Code", + expected: "D:/Code/src/main.ts", + }, + { + name: "Empty cwd returns normalized input path", + filePath: "src/main.ts", + cwd: "", + expected: "src/main.ts", + }, + { + name: "Empty cwd returns normalized input path", + filePath: "src\\main.ts", + cwd: "", + expected: "src\\main.ts", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeAbsolutePath(tt.filePath, tt.cwd) + if result != tt.expected { + t.Errorf("normalizePath(%q, %q) = %q, expected %q", + tt.filePath, tt.cwd, result, tt.expected) + } + }) + } +} + +func TestParseNegationPattern(t *testing.T) { + tests := []struct { + name string + pattern string + isNeg bool + actualPattern string + }{ + { + name: "Negation pattern", + pattern: "!src/components/**/*.ts", + isNeg: true, + actualPattern: "src/components/**/*.ts"}, + { + name: "Positive pattern", + pattern: "src/components/**/*.ts", + isNeg: false, + actualPattern: "src/components/**/*.ts"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isNeg, actualPattern := parseNegationPattern(tt.pattern) + if isNeg != tt.isNeg || actualPattern != tt.actualPattern { + t.Errorf("parseNegationPattern(%q) = (%v, %q), expected (%v, %q)", + tt.pattern, isNeg, actualPattern, tt.isNeg, tt.actualPattern) + } + }) + } +} + +func TestClassifyPatterns(t *testing.T) { + tests := []struct { + name string + patterns []string + positivePatterns []string + negativePatterns []string + }{ + { + name: "Positive and negative patterns", + patterns: []string{ + "src/components/**/*.ts", + "!src/components/**/*.test.ts", + }, + positivePatterns: []string{ + "src/components/**/*.ts", + }, + negativePatterns: []string{ + "src/components/**/*.test.ts", + }, + }, + { + name: "Only positive patterns", + patterns: []string{ + "src/components/**/*.ts", + "src/components/**/*.test.ts", + }, + positivePatterns: []string{ + "src/components/**/*.ts", + "src/components/**/*.test.ts", + }, + negativePatterns: []string{}, + }, + { + name: "Only negative patterns", + patterns: []string{ + "!src/components/**/*.ts", + "!src/components/**/*.test.ts", + }, + positivePatterns: []string{}, + negativePatterns: []string{ + "src/components/**/*.ts", + "src/components/**/*.test.ts", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + positivePatterns, negativePatterns := classifyPatterns(tt.patterns) + if !slices.Equal(positivePatterns, tt.positivePatterns) || !slices.Equal(negativePatterns, tt.negativePatterns) { + t.Errorf("classifyPatterns(%#v) = (%#v, %#v), expected (%#v, %#v)", + tt.patterns, positivePatterns, negativePatterns, tt.positivePatterns, tt.negativePatterns) + } + }) + } +} + +func TestNormalizePatterns(t *testing.T) { + tests := []struct { + name string + patterns []string + cwd string + expected []string + }{ + { + name: "Normalize patterns with relative path", + patterns: []string{ + "src/components/**/*.ts", + "src/components/**/*.test.ts", + }, + cwd: "/Users/labmda47/Code/test", + expected: []string{ + "/Users/labmda47/Code/test/src/components/**/*.ts", + "/Users/labmda47/Code/test/src/components/**/*.test.ts", + }, + }, + { + name: "Normalize patterns with relative path and absolute path", + patterns: []string{ + "src/components/**/*.ts", + "/Users/labmda47/Code/test/src/components/**/*.test.ts", + }, + cwd: "/Users/labmda47/Code/test", + expected: []string{ + "/Users/labmda47/Code/test/src/components/**/*.ts", + "/Users/labmda47/Code/test/src/components/**/*.test.ts", + }, + }, + { + name: "Normalize patterns with windows cwd path", + patterns: []string{ + "src/components/**/*.ts", + "src/components/**/*.test.ts", + }, + cwd: "D:\\Code\\test", + expected: []string{ + "D:/Code/test/src/components/**/*.ts", + "D:/Code/test/src/components/**/*.test.ts", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizedPatterns := normalizePatterns(tt.patterns, tt.cwd) + if !slices.Equal(normalizedPatterns, tt.expected) { + t.Errorf("normalizePatterns(%#v, %q) = %#v, expected %#v", + tt.patterns, tt.cwd, normalizedPatterns, tt.expected) + } + }) + } +} + +func TestNormalizedFilePatterns_isFileMatched(t *testing.T) { + tests := []struct { + name string + patterns []string + cwd string + filePath string + expected bool + }{ + { + name: "File matched by positive pattern", + patterns: []string{ + "src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.ts", + expected: true, + }, + { + name: "File excluded by negative pattern", + patterns: []string{ + "src/components/**/*.ts", + "!src/components/**/*.test.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.test.ts", + expected: false, + }, + { + name: "File matched by positive pattern, then excluded by negative pattern", + patterns: []string{ + "!src/components/**/*.test.ts", + "src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.test.ts", + expected: false, + }, + { + name: "File not matched by any pattern", + patterns: []string{ + "src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/utils/helper.ts", + expected: false, + }, + { + name: "No patterns means all files match", + patterns: []string{}, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.ts", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizedFilePatterns := newNormalizedFilePatterns(tt.patterns, tt.cwd) + result := normalizedFilePatterns.isFileMatched(tt.filePath) + if result != tt.expected { + t.Errorf("patterns: %#v, isFileMatched(%q) = %v, expected %v", + tt.patterns, tt.filePath, result, tt.expected) + } + }) + } +} + +func TestNormalizedIgnorePatterns_isFileIgnored(t *testing.T) { + tests := []struct { + name string + patterns []string + cwd string + filePath string + expected bool + }{ + { + name: "File ignored by positive pattern", + patterns: []string{ + "src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.ts", + expected: true, + }, + { + name: "File not ignored by positive pattern", + patterns: []string{ + "src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/utils/helper.ts", + expected: false, + }, + { + name: "File not ignored by negation pattern", + patterns: []string{ + "!src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.ts", + expected: false, + }, + { + name: "File not matching negation pattern remains not ignored", + patterns: []string{ + "!src/components/**/*.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/utils/helper.ts", + expected: false, + }, + { + name: "File ignored by positive pattern, then not ignored by negative pattern", + patterns: []string{ + "src/components/**/*.ts", + "!src/components/**/*.test.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.test.ts", + expected: false, + }, + { + name: "File not ignored by positive pattern, then ignored by negative pattern", + patterns: []string{ + "!src/components/**/*.test.ts", + "src/components/**/button.test.ts", + }, + cwd: "/Users/labmda47/Code/test", + filePath: "/Users/labmda47/Code/test/src/components/button.test.ts", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizedIgnorePatterns := newNormalizedIgnorePatterns(tt.patterns, tt.cwd) + result := normalizedIgnorePatterns.isFileIgnored(tt.filePath) + if result != tt.expected { + t.Errorf("patterns: %#v, isFileIgnored(%q) = %v, expected %v", + tt.patterns, tt.filePath, result, tt.expected) + } + }) + } +} + +func TestFileMatcher_isFileMatched(t *testing.T) { + // Save the original working directory + originalCwd, err := os.Getwd() + if err != nil { + t.Fatalf("Unable to get current working directory: %v", err) + } + defer t.Chdir(originalCwd) // Restore after test completes + + tests := []struct { + name string + description string + config ConfigEntry + filePath string + expected bool + }{ + { + name: "No patterns means all files match", + description: "No patterns means all files match", + config: ConfigEntry{ + Files: []string{}, + Ignores: []string{}, + }, + filePath: "src/component.ts", + expected: true, + }, + { + name: "Relative path matching", + description: "Relative paths should match relative patterns", + config: ConfigEntry{ + Files: []string{ + "src/**", + }, + }, + filePath: "src/component.ts", + expected: true, + }, + { + name: "Absolute path matches relative pattern", + description: "File with absolute path should be normalized and match relative glob pattern", + filePath: filepath.Join(originalCwd, "src/component.ts"), + config: ConfigEntry{ + Files: []string{ + "src/**", + }, + }, + expected: true, + }, + { + name: "Deep nested file matches recursive pattern", + description: "File in deeply nested directory should match recursive glob pattern", + filePath: "node_modules/package/deep/file.js", + config: ConfigEntry{ + Files: []string{ + "node_modules/**", + }, + }, + expected: true, + }, + { + name: "Test file matches global recursive pattern", + description: "Test file in any location should match global recursive pattern", + filePath: "src/utils/helper.test.ts", + config: ConfigEntry{ + Files: []string{ + "**/*.test.ts", + }, + }, + expected: true, + }, + { + name: "File not matching any pattern", + description: "File that doesn't match any pattern should be ignored", + filePath: "src/component.ts", + config: ConfigEntry{ + Files: []string{ + "dist/**", + "*.log", + }, + }, + expected: false, + }, + { + name: "Windows path separator handled correctly", + description: "File path with Windows backslash separators should be normalized and matched", + filePath: "src\\windows\\style\\path.ts", + config: ConfigEntry{ + Files: []string{ + "src/**", + }, + }, + expected: true, + }, + { + name: "File excluded by single negation pattern", + description: "File matching a negation pattern should be excluded even if it matches the base pattern", + filePath: "src/component.ts", + config: ConfigEntry{ + Files: []string{ + "!src/component.ts", + }, + }, + expected: false, + }, + { + name: "No patterns means all files match", + description: "No patterns means all files match", + filePath: "src/component.ts", + config: ConfigEntry{ + Files: []string{}, + }, + expected: true, + }, + { + name: "File ignored by ignore pattern", + description: "File matching ignore pattern should be ignored", + filePath: "src/component.ts", + config: ConfigEntry{ + Files: []string{ + "src/**", + }, + Ignores: []string{ + "src/component.ts", + }, + }, + expected: false, + }, + { + name: "File ignored by ignore pattern, then not ignored by negation pattern", + description: "File matching ignore pattern should be ignored, then not ignored by negation pattern", + filePath: "src/utils/helper.test.ts", + config: ConfigEntry{ + Files: []string{ + "src/**", + }, + Ignores: []string{ + "src/**/*.test.ts", + "!src/**/helper.test.ts", + }, + }, + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fileMatcher := newFileMatcher(&tt.config, originalCwd) + result := fileMatcher.isFileMatched(normalizeAbsolutePath(tt.filePath, originalCwd)) + if result != tt.expected { + t.Errorf("%s, config: %#v, isFileMatched(%q) = %v, expected %v", + tt.description, tt.config, tt.filePath, result, tt.expected) + } + }) + } +} diff --git a/website/docs/en/config/index.md b/website/docs/en/config/index.md index d6704c493..d972116d4 100644 --- a/website/docs/en/config/index.md +++ b/website/docs/en/config/index.md @@ -49,6 +49,25 @@ The configuration file contains an array of configuration entries. Each entry de ## Configuration Options +### files + +- **Type:** `string[]` +- **Default:** `[]` + +An array of glob patterns for files and directories to include during linting. If omitted, all files are included. + +```jsonc +{ + "files": ["./src/**", "./tests/**"], +} +``` + +Patterns support: + +- **Glob patterns**: `*.js`, `**/*.ts` +- **Directory patterns**: `src/**`, `tests/**` +- **Negation**: `!important.ts` (when used with other patterns) + ### ignores - **Type:** `string[]`