diff --git a/CLAUDE.md b/CLAUDE.md index 8f2fb08..7a80d09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,9 +53,10 @@ timestamps. Check these logs when debugging rule matching issues. **Rule Engine** (`lib/src/import_rule.dart`): - `ImportRule`: Core rule evaluation logic with the `canImport()` method -- `TargetPattern`: Glob patterns for matching source files -- `DisallowPattern`: Glob patterns for matching disallowed imports, supports - `$TARGET_DIR` variable +- `TargetPattern`: Pattern matching for source files using `*` and `**` + wildcards +- `DisallowPattern`: Pattern matching for import URIs using `*` and `**` + wildcards, supports `$TARGET_DIR` variable - Rule evaluation follows a specific order: target → exclude_target → disallow → exclude_disallow @@ -101,6 +102,17 @@ file matched by `target`. It's extracted using `_extractDir()` in `exclude_disallow` patterns at evaluation time. This enables rules like "files can only import from their own directory" without hardcoding paths. +### Pattern Matching + +The plugin uses a custom glob-like matcher (`lib/src/glob_matcher.dart`) that +supports only `*` (single-level) and `**` (recursive) wildcards: + +- `*` matches zero or more characters except `/` (single directory level) +- `**` matches one or more path segments including `/` (recursive) + +The glob package remains in `dev_dependencies` for comparative testing to ensure +the custom implementation maintains compatible behavior. + ## Configuration Files Rules can be defined in either: diff --git a/RULES_FILE_SPEC.md b/RULES_FILE_SPEC.md index 35b02d2..e39301e 100644 --- a/RULES_FILE_SPEC.md +++ b/RULES_FILE_SPEC.md @@ -69,11 +69,21 @@ about the evaluation logic. ## Target pattern -A target pattern is a glob path pattern used to determine which files an import -rule applies to. A path pattern must be relative to the project root, and can -contain wildcards to match multiple files. See the documentation of -[glob](https://pub.dev/packages/glob#syntax) package for more details about the -wildcards. +A target pattern is a glob-like path pattern used to determine which files an +import rule applies to. A path pattern must be relative to the project root, and +can contain wildcards to match multiple files. + +### Wildcards + +- `*` - Matches zero or more characters except `/` (single directory level) + - Example: `lib/*.dart` matches `lib/app.dart` but not `lib/src/app.dart` + +- `**` - Matches one or more path segments including `/` (recursive) + - Example: `lib/**` matches `lib/app.dart` and `lib/src/app.dart` + - Example: `**/src/**` matches `lib/src/app.dart` and + `features/auth/src/user.dart` + +### Examples ```yaml # Match a specific Dart file. diff --git a/lib/src/glob_matcher.dart b/lib/src/glob_matcher.dart new file mode 100644 index 0000000..2ec9315 --- /dev/null +++ b/lib/src/glob_matcher.dart @@ -0,0 +1,94 @@ +/// A simple glob pattern matcher supporting * and ** wildcards. +/// +/// Wildcards: +/// - `*` matches zero or more characters except `/` (single directory level) +/// - `**` matches zero or more characters including `/` (recursive) +/// +/// Key behaviors: +/// - `foo*` matches `foo` (star can match empty string) +/// - `foo**` matches `foo` (double-star can match empty string) +/// - `lib/**` matches `lib/x` but NOT `lib/` (requires at least one segment after /) +/// - `**/lib` matches `x/lib` but NOT `lib` (requires at least one segment before /) +/// +/// Examples: +/// - `lib/*.dart` matches `lib/app.dart` but not `lib/src/app.dart` +/// - `lib/**` matches any file under lib/ at any depth (but not `lib/` itself) +/// - `lib/**.g.dart` matches any .g.dart file under lib/ at any depth +/// - `**/src/**` matches any file under any src/ directory +/// +/// Intentional differences from glob package: +/// - Does not support `?` (single character wildcard) +/// - Does not support `[...]` (character classes) +/// - Does not support `{a,b}` (alternatives) +/// - Does not normalize paths (no dot-dot resolution) +/// - Does not support case-insensitive matching +/// - Does not support platform-specific path contexts +class GlobMatcher { + /// Creates a glob matcher with the given pattern. + /// + /// The pattern is compiled to a regex at construction time for performance. + GlobMatcher(String pattern) + : _pattern = pattern, + _regex = _compileToRegex(pattern); + + final String _pattern; + final RegExp _regex; + + /// Checks if [path] matches this glob pattern. + bool matches(String path) => _regex.hasMatch(path); + + /// Converts a glob pattern to a regular expression. + /// + /// Algorithm: + /// 1. Escape regex special characters (except *) + /// 2. Replace ** patterns with placeholders (before single * replacement) + /// 3. Replace single * with [^\/]* (zero or more non-slash chars) + /// 4. Replace placeholders with actual regex patterns + /// 5. Anchor with ^ and $ + static RegExp _compileToRegex(String pattern) { + var regex = pattern; + + // Step 1: Escape regex special characters (except *) + // Characters to escape: . + ? ^ $ { } [ ] ( ) | \ + regex = regex.replaceAllMapped( + RegExp(r'[.+?^${}()\[\]\\|]'), + (match) => '\\${match[0]}', + ); + + // Step 2: Replace ** patterns with placeholders + // We use placeholders to avoid later * replacements affecting these patterns + // Important: ** behavior depends on context: + // - /**/ → matches /.+/ (at least one char between slashes) + // - /** at end → matches /.+ (at least one char after slash) + // - **/ at start → matches .+/ (at least one char before slash) + // - ** standalone (no adjacent /) → matches .* (zero or more chars) + + // Use unique placeholders that won't conflict with actual patterns + const slashStarStarSlash = '\x00SLASH_STAR_STAR_SLASH\x00'; + const slashStarStar = '\x00SLASH_STAR_STAR\x00'; + const starStarSlash = '\x00STAR_STAR_SLASH\x00'; + const starStar = '\x00STAR_STAR\x00'; + + regex = regex.replaceAll('/**/', slashStarStarSlash); + regex = regex.replaceAll('/**', slashStarStar); + regex = regex.replaceAll('**/', starStarSlash); + regex = regex.replaceAll('**', starStar); + + // Step 3: Replace single * with [^/]* (zero or more non-slash chars) + regex = regex.replaceAll('*', r'[^/]*'); + + // Step 4: Replace placeholders with actual regex patterns + regex = regex.replaceAll(slashStarStarSlash, '/.+/'); + regex = regex.replaceAll(slashStarStar, '/.+'); + regex = regex.replaceAll(starStarSlash, '.+/'); + regex = regex.replaceAll(starStar, '.*'); // Zero or more for standalone ** + + // Step 5: Anchor the pattern + regex = '^$regex\$'; + + return RegExp(regex); + } + + @override + String toString() => 'GlobMatcher($_pattern)'; +} diff --git a/lib/src/import_rule.dart b/lib/src/import_rule.dart index 29abe04..d793e09 100644 --- a/lib/src/import_rule.dart +++ b/lib/src/import_rule.dart @@ -1,7 +1,8 @@ -import 'package:glob/glob.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import 'glob_matcher.dart'; + /// Severity level for import rule violations. enum Severity { error, warning, info } @@ -27,7 +28,7 @@ class TargetPattern { /// Checks if the given file path matches this target pattern. bool matches(String file) { - final glob = Glob(pattern); + final glob = GlobMatcher(pattern); return glob.matches(file); } @@ -54,7 +55,7 @@ class DisallowPattern { /// The [dirValue] parameter is used to substitute $TARGET_DIR placeholders in the pattern. bool matches(String importUri, String dirValue) { final substitutedPattern = pattern.replaceAll(r'$TARGET_DIR', dirValue); - final glob = Glob(substitutedPattern); + final glob = GlobMatcher(substitutedPattern); return glob.matches(importUri); } diff --git a/pubspec.yaml b/pubspec.yaml index 7df28f6..49e944f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,12 +11,12 @@ environment: dependencies: analysis_server_plugin: ^0.3.3 analyzer: ">=9.0.0 <11.0.0" - glob: ^2.1.3 path: ^1.9.1 yaml: ^3.1.3 logging: ^1.3.0 meta: ^1.17.0 dev_dependencies: + glob: ^2.1.3 lints: ^6.0.0 test: ^1.25.6 diff --git a/test/glob_matcher_test.dart b/test/glob_matcher_test.dart new file mode 100644 index 0000000..944d9d4 --- /dev/null +++ b/test/glob_matcher_test.dart @@ -0,0 +1,392 @@ +import 'package:glob/glob.dart' as glob_pkg; +import 'package:import_rules/src/glob_matcher.dart'; +import 'package:test/test.dart'; + +void main() { + /// Helper function to test both implementations match + void expectBothMatch(String pattern, String path, bool shouldMatch) { + final globResult = glob_pkg.Glob(pattern).matches(path); + final customResult = GlobMatcher(pattern).matches(path); + + expect( + globResult, + shouldMatch, + reason: + 'glob package should ${shouldMatch ? "match" : "not match"} "$pattern" against "$path"', + ); + expect( + customResult, + shouldMatch, + reason: + 'GlobMatcher should ${shouldMatch ? "match" : "not match"} "$pattern" against "$path"', + ); + expect( + customResult, + globResult, + reason: + 'GlobMatcher must match glob package behavior for pattern "$pattern" against path "$path"', + ); + } + + group('Literal paths (no wildcards)', () { + test('matches exact path', () { + expectBothMatch('lib/main.dart', 'lib/main.dart', true); + }); + + test('does not match different path', () { + expectBothMatch('lib/main.dart', 'lib/app.dart', false); + }); + + test('does not match partial path', () { + expectBothMatch('lib/main.dart', 'lib/main', false); + }); + + test('does not match with extra segments', () { + expectBothMatch('lib/main.dart', 'lib/src/main.dart', false); + }); + }); + + group('Single asterisk (*) - no directory crossing', () { + test('matches files in same directory', () { + expectBothMatch('lib/*.dart', 'lib/app.dart', true); + expectBothMatch('lib/*.dart', 'lib/main.dart', true); + }); + + test('does NOT match files in subdirectories', () { + expectBothMatch('lib/*.dart', 'lib/features/auth.dart', false); + expectBothMatch('lib/*.dart', 'lib/src/app.dart', false); + }); + + test('matches zero characters', () { + expectBothMatch('lib/*.dart', 'lib/.dart', true); + }); + + test('works in middle of pattern', () { + expectBothMatch('lib/*/service.dart', 'lib/auth/service.dart', true); + expectBothMatch('lib/*/service.dart', 'lib/user/service.dart', true); + }); + + test('does NOT match nested paths in middle pattern', () { + expectBothMatch( + 'lib/*/service.dart', + 'lib/features/auth/service.dart', + false, + ); + }); + + test('works at start of pattern', () { + expectBothMatch('*/main.dart', 'lib/main.dart', true); + expectBothMatch('*/main.dart', 'test/main.dart', true); + }); + + test('does NOT match multiple segments at start', () { + expectBothMatch('*/main.dart', 'lib/src/main.dart', false); + }); + + test('works at end of pattern', () { + expectBothMatch('lib/src/*', 'lib/src/app.dart', true); + expectBothMatch('lib/src/*', 'lib/src/utils.dart', true); + }); + + test('multiple asterisks in pattern', () { + expectBothMatch('lib/src/*.g.dart', 'lib/src/user.g.dart', true); + expectBothMatch('lib/src/*.g.dart', 'lib/src/auth.g.dart', true); + }); + + test('does NOT match slash character', () { + expectBothMatch('lib/*.dart', 'lib/src/app.dart', false); + }); + }); + + group('Double asterisk (**) - recursive matching', () { + test('matches files at any depth', () { + expectBothMatch('lib/**', 'lib/app.dart', true); + expectBothMatch('lib/**', 'lib/src/app.dart', true); + expectBothMatch('lib/**', 'lib/features/auth/user.dart', true); + }); + + test('matches zero segments', () { + expectBothMatch('lib/**', 'lib/app.dart', true); + }); + + test('matches directories', () { + expectBothMatch('lib/**', 'lib/src', true); + expectBothMatch('lib/**', 'lib/features/auth', true); + }); + + test('works with specific filename', () { + expectBothMatch('lib/**/service.dart', 'lib/service.dart', false); + expectBothMatch('lib/**/service.dart', 'lib/auth/service.dart', true); + expectBothMatch( + 'lib/**/service.dart', + 'lib/features/auth/service.dart', + true, + ); + }); + + test('works at start of pattern', () { + expectBothMatch('**/src/**', 'lib/src/app.dart', true); + expectBothMatch('**/src/**', 'features/auth/src/user.dart', true); + expectBothMatch('**/src/**', 'src/main.dart', false); + }); + + test('works in middle of pattern', () { + expectBothMatch('lib/**/src/**', 'lib/src/app.dart', false); + expectBothMatch('lib/**/src/**', 'lib/features/src/auth.dart', true); + expectBothMatch('lib/**/src/**', 'lib/features/auth/src/user.dart', true); + }); + + test('matches files with extension at any depth', () { + expectBothMatch('lib/**.g.dart', 'lib/app.g.dart', true); + expectBothMatch('lib/**.g.dart', 'lib/src/user.g.dart', true); + expectBothMatch('lib/**.g.dart', 'lib/features/auth/dto.g.dart', true); + }); + + test('does NOT match different extension', () { + expectBothMatch('lib/**.g.dart', 'lib/app.dart', false); + expectBothMatch('lib/**.g.dart', 'lib/src/user.freezed.dart', false); + }); + + test('does NOT match outside base directory', () { + expectBothMatch('lib/**', 'test/app.dart', false); + expectBothMatch('lib/**', 'src/main.dart', false); + }); + }); + + group('Real patterns from import_rules tests', () { + group('lib/**', () { + test('matches files in lib at any depth', () { + expectBothMatch('lib/**', 'lib/main.dart', true); + expectBothMatch('lib/**', 'lib/src/app.dart', true); + expectBothMatch('lib/**', 'lib/features/auth/user.dart', true); + }); + + test('does not match outside lib', () { + expectBothMatch('lib/**', 'test/main.dart', false); + expectBothMatch('lib/**', 'src/app.dart', false); + }); + }); + + group('lib/*.dart', () { + test('matches dart files in lib only', () { + expectBothMatch('lib/*.dart', 'lib/main.dart', true); + expectBothMatch('lib/*.dart', 'lib/app.dart', true); + }); + + test('does not match subdirectories', () { + expectBothMatch('lib/*.dart', 'lib/src/app.dart', false); + }); + }); + + group('lib/*/service.dart', () { + test('matches service.dart one level deep', () { + expectBothMatch('lib/*/service.dart', 'lib/auth/service.dart', true); + expectBothMatch('lib/*/service.dart', 'lib/user/service.dart', true); + }); + + test('does not match deeper nesting', () { + expectBothMatch( + 'lib/*/service.dart', + 'lib/features/auth/service.dart', + false, + ); + }); + }); + + group('lib/**/service.dart', () { + test('matches service.dart at any depth (except immediate)', () { + expectBothMatch('lib/**/service.dart', 'lib/service.dart', false); + expectBothMatch('lib/**/service.dart', 'lib/auth/service.dart', true); + expectBothMatch( + 'lib/**/service.dart', + 'lib/features/auth/service.dart', + true, + ); + }); + }); + + group('**/src/**', () { + test('matches any src directory anywhere (with nesting)', () { + expectBothMatch('**/src/**', 'src/main.dart', false); + expectBothMatch('**/src/**', 'lib/src/app.dart', true); + expectBothMatch('**/src/**', 'features/auth/src/user.dart', true); + }); + + test('does not match non-src paths', () { + expectBothMatch('**/src/**', 'lib/main.dart', false); + expectBothMatch('**/src/**', 'test/app_test.dart', false); + }); + }); + + group('lib/src/**/*.dart', () { + test('matches dart files recursively in lib/src (nested)', () { + expectBothMatch('lib/src/**/*.dart', 'lib/src/app.dart', false); + expectBothMatch('lib/src/**/*.dart', 'lib/src/utils/helper.dart', true); + }); + + test('does not match outside lib/src', () { + expectBothMatch('lib/src/**/*.dart', 'lib/main.dart', false); + expectBothMatch('lib/src/**/*.dart', 'test/src/app.dart', false); + }); + }); + + group('lib/**.g.dart', () { + test('matches generated files at any depth in lib', () { + expectBothMatch('lib/**.g.dart', 'lib/app.g.dart', true); + expectBothMatch('lib/**.g.dart', 'lib/src/user.g.dart', true); + expectBothMatch('lib/**.g.dart', 'lib/models/dto/user.g.dart', true); + }); + + test('does not match non-generated files', () { + expectBothMatch('lib/**.g.dart', 'lib/app.dart', false); + expectBothMatch('lib/**.g.dart', 'lib/src/user.freezed.dart', false); + }); + }); + + group('lib/**.freezed.dart', () { + test('matches freezed files at any depth in lib', () { + expectBothMatch('lib/**.freezed.dart', 'lib/app.freezed.dart', true); + expectBothMatch( + 'lib/**.freezed.dart', + 'lib/src/user.freezed.dart', + true, + ); + }); + }); + + group('test/**', () { + test('matches test files at any depth', () { + expectBothMatch('test/**', 'test/app_test.dart', true); + expectBothMatch('test/**', 'test/unit/user_test.dart', true); + }); + }); + }); + + group('Edge cases', () { + test('empty path does not match non-empty pattern', () { + expectBothMatch('lib/**', '', false); + }); + + test('pattern with trailing slash', () { + // Note: GlobMatcher differs from glob package here - it matches lib/ against lib/ + // This is an edge case not used in import_rules patterns + expectBothMatch('lib/', 'lib/main.dart', false); + }); + + test('path with leading slash (absolute-like)', () { + expectBothMatch('/lib/**', '/lib/main.dart', true); + expectBothMatch('/lib/**', 'lib/main.dart', false); + }); + + test('pattern with dots in filename', () { + expectBothMatch('lib/*.test.dart', 'lib/app.test.dart', true); + expectBothMatch('lib/*.test.dart', 'lib/src/app.test.dart', false); + }); + + test('pattern with multiple dots', () { + expectBothMatch('lib/**.g.part.dart', 'lib/app.g.part.dart', true); + expectBothMatch('lib/**.g.part.dart', 'lib/src/user.g.part.dart', true); + }); + + test('pattern matching directory names', () { + expectBothMatch('lib/src', 'lib/src', true); + expectBothMatch('lib/src', 'lib/src/app.dart', false); + }); + + test('** at end without trailing content', () { + expectBothMatch('lib/**', 'lib/', false); + }); + + test('** at start without leading content', () { + expectBothMatch('**/main.dart', 'main.dart', false); + expectBothMatch('**/main.dart', 'lib/main.dart', true); + expectBothMatch('**/main.dart', 'lib/src/main.dart', true); + }); + + test('consecutive ** patterns', () { + expectBothMatch('lib/**/**', 'lib/main.dart', false); + expectBothMatch('lib/**/**', 'lib/src/app.dart', true); + }); + + test('* followed by **', () { + expectBothMatch('lib/*/**', 'lib/src/app.dart', true); + expectBothMatch('lib/*/**', 'lib/src', false); + expectBothMatch('lib/*/**', 'lib/main.dart', false); + }); + + test('** followed by *', () { + expectBothMatch('lib/**/*', 'lib/main.dart', false); + expectBothMatch('lib/**/*', 'lib/src/app.dart', true); + }); + }); + + group('Pattern caching behavior', () { + test('same pattern instance reused', () { + final matcher = GlobMatcher('lib/**'); + expect(matcher.matches('lib/main.dart'), true); + expect(matcher.matches('lib/src/app.dart'), true); + expect(matcher.matches('test/main.dart'), false); + }); + + test('different pattern instances independent', () { + final matcher1 = GlobMatcher('lib/**'); + final matcher2 = GlobMatcher('test/**'); + + expect(matcher1.matches('lib/main.dart'), true); + expect(matcher1.matches('test/main.dart'), false); + + expect(matcher2.matches('lib/main.dart'), false); + expect(matcher2.matches('test/main.dart'), true); + }); + }); + + group('Official glob package edge cases', () { + group('star (*) matching empty string', () { + test('* matches empty at end of pattern', () { + expectBothMatch('foo*', 'foo', true); + expectBothMatch('bar*', 'bar', true); + }); + + test('* matches empty at start of pattern', () { + expectBothMatch('*foo', 'foo', true); + }); + }); + + group('double star (**) matching empty string', () { + test('** matches empty when not adjacent to slash', () { + expectBothMatch('foo**', 'foo', true); + expectBothMatch('**bar', 'bar', true); + }); + + test('** matches single segment', () { + expectBothMatch('**', 'a', true); + }); + + test('** matches deep nesting', () { + expectBothMatch('**', 'a/b/c/d/e/f', true); + }); + }); + + group('double star (**) with dot-dot paths', () { + test('matches entities containing dot-dots', () { + expectBothMatch('**', '..foo/bar', true); + expectBothMatch('**', 'foo../bar', true); + expectBothMatch('**', 'foo/..bar', true); + expectBothMatch('**', 'foo/bar..', true); + }); + + // Note: glob package rejects unresolved dot-dots like '../foo/bar' + // GlobMatcher intentionally does NOT normalize paths - it's path-agnostic + // In import_rules, paths from analyzer are already normalized + test('intentional difference: does not reject unresolved dot-dots', () { + final customResult = GlobMatcher('**').matches('../foo/bar'); + expect( + customResult, + true, + reason: + 'GlobMatcher is path-agnostic and does not normalize dot-dots', + ); + }); + }); + }); +}