Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section states that ** matches “one or more path segments”, but the implementation/tests also rely on ** matching empty in some contexts (e.g., foo** matching foo). Please adjust the wording here to match the actual semantics (standalone ** can be empty; /** and **/ require at least one segment).

Suggested change
- `**` matches one or more path segments including `/` (recursive)
- `**` matches path segments recursively: when used on its own or between path
components it may match zero or more segments, but in `/**` and `**/` it must
match at least one segment

Copilot uses AI. Check for mistakes.

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:
Expand Down
20 changes: 15 additions & 5 deletions RULES_FILE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Comment on lines +81 to +84
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec says ** “matches one or more path segments”, but the matcher/test suite also relies on ** being able to match an empty string in some contexts (e.g., foo** matching foo). Update this description to reflect the actual semantics (standalone ** can match empty; when adjacent to / it requires at least one segment).

Suggested change
- `**` - 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`
- `**` - Recursive wildcard. When used on its own, it matches zero or more path
segments (including the empty string). When adjacent to `/`, it matches one or
more path segments.
- Example: `lib/**` matches `lib/app.dart` and `lib/src/app.dart`
- Example: `**/src/**` matches `lib/src/app.dart` and
`features/auth/src/user.dart`
- Example: `target: "**"` matches every file in the project, and `foo**`
matches `foo`, `foo/bar.dart`, and so on.

Copilot uses AI. Check for mistakes.

### Examples

```yaml
# Match a specific Dart file.
Expand Down
94 changes: 94 additions & 0 deletions lib/src/glob_matcher.dart
Original file line number Diff line number Diff line change
@@ -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 /)
///
Comment on lines +4 to +12
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment states that ** “matches zero or more characters including /”, but the documented key behaviors below require at least one segment when ** is adjacent to a slash (e.g., lib/** not matching lib/, **/lib not matching lib). Please clarify the ** semantics in the class documentation so it’s not internally contradictory (standalone ** can be empty, but /** and **/ require at least one character/segment).

Copilot uses AI. Check for mistakes.
/// 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)';
}
7 changes: 4 additions & 3 deletions lib/src/import_rule.dart
Original file line number Diff line number Diff line change
@@ -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 }

Expand All @@ -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);
}
Comment on lines 29 to 33
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TargetPattern.matches() creates a new GlobMatcher on every call, which recompiles the regex each time. Since pattern is immutable per TargetPattern, consider compiling once (e.g., store a final GlobMatcher field) and reuse it to avoid repeated allocations/compilation during analysis.

Copilot uses AI. Check for mistakes.

Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading