Skip to content

Commit f2fe052

Browse files
solid-illiaaihistovIllia Aihistov
andauthored
feat: add prefer_mirror_test_structure rule (#379)
Co-authored-by: Illia Aihistov <illia.aihistov-us@solid.software>
1 parent 58f07a7 commit f2fe052

5 files changed

Lines changed: 395 additions & 0 deletions

File tree

‎lib/analysis_options.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ solid_lints:
110110
prefer_first: true
111111
prefer_last: true
112112
prefer_match_file_name: true
113+
prefer_mirror_test_structure: true
113114
proper_super_calls: true
114115
# Disabled for now due to conflict with
115116
# `always_put_required_named_parameters_first`.

‎lib/main.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import 'package:solid_lints/src/lints/prefer_early_return/prefer_early_return_ru
3333
import 'package:solid_lints/src/lints/prefer_first/prefer_first_rule.dart';
3434
import 'package:solid_lints/src/lints/prefer_last/prefer_last_rule.dart';
3535
import 'package:solid_lints/src/lints/prefer_match_file_name/prefer_match_file_name_rule.dart';
36+
import 'package:solid_lints/src/lints/prefer_mirror_test_structure/prefer_mirror_test_structure_rule.dart';
3637
import 'package:solid_lints/src/lints/proper_super_calls/proper_super_calls_rule.dart';
3738
import 'package:solid_lints/src/lints/use_descriptive_names_for_type_parameters/use_descriptive_names_for_type_parameters_rule.dart';
3839
import 'package:solid_lints/src/lints/use_nearest_context/use_nearest_context_rule.dart';
@@ -89,6 +90,7 @@ class SolidLintsPlugin extends Plugin {
8990
PreferFirstRule(),
9091
PreferLastRule(),
9192
PreferMatchFileNameRule(analysisOptionsLoader: analysisLoader),
93+
PreferMirrorTestStructureRule(),
9294
ProperSuperCallsRule(),
9395
UseDescriptiveNamesForTypeParametersRule(
9496
analysisOptionsLoader: analysisLoader,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import 'package:analyzer/analysis_rule/rule_context.dart';
2+
import 'package:analyzer/analysis_rule/rule_visitor_registry.dart';
3+
import 'package:analyzer/error/error.dart';
4+
import 'package:solid_lints/src/lints/prefer_mirror_test_structure/visitors/prefer_mirror_test_structure_visitor.dart';
5+
import 'package:solid_lints/src/models/solid_lint_rule.dart';
6+
7+
/// Warns when the test file folder structure does not mirror the 'lib/' folder
8+
/// structure of the imported implementation file.
9+
///
10+
/// Keeping the same folder structure for your tests makes it easier to locate
11+
/// test files and allows using IDE commands such as 'Go to Test/Implementation
12+
/// File'.
13+
///
14+
/// This rule highlights test files that import implementation files with the
15+
/// same name (without the `_test` suffix) when their relative directory path in
16+
/// `test/` does not match the relative directory path in `lib/`.
17+
///
18+
/// ### Example
19+
///
20+
/// #### BAD:
21+
///
22+
/// File: `test/auth_service_test.dart`
23+
///
24+
/// ```dart
25+
/// import 'package:my_app/src/services/auth_service.dart'; // LINT
26+
/// ```
27+
///
28+
/// #### GOOD:
29+
///
30+
/// File: `test/src/services/auth_service_test.dart`
31+
///
32+
/// ```dart
33+
/// import 'package:my_app/src/services/auth_service.dart'; // OK
34+
/// ```
35+
///
36+
class PreferMirrorTestStructureRule extends SolidLintRule<void> {
37+
/// Name of the lint.
38+
static const lintName = 'prefer_mirror_test_structure';
39+
40+
static const _code = LintCode(
41+
lintName,
42+
"The test file folder structure does not mirror the 'lib/' folder "
43+
'structure.',
44+
correctionMessage: "Move the test file to '{0}'.",
45+
);
46+
47+
/// Creates an instance of [PreferMirrorTestStructureRule].
48+
PreferMirrorTestStructureRule()
49+
: super(
50+
name: lintName,
51+
description:
52+
'Warns when the test file folder structure does not mirror the '
53+
"'lib/' folder structure.",
54+
);
55+
56+
@override
57+
DiagnosticCode get diagnosticCode => _code;
58+
59+
@override
60+
void registerNodeProcessors(
61+
RuleVisitorRegistry registry,
62+
RuleContext context,
63+
) {
64+
super.registerNodeProcessors(registry, context);
65+
66+
final visitor = PreferMirrorTestStructureVisitor(
67+
rule: this,
68+
context: context,
69+
);
70+
71+
registry.addCompilationUnit(this, visitor);
72+
}
73+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import 'package:analyzer/analysis_rule/rule_context.dart';
2+
import 'package:analyzer/dart/ast/ast.dart';
3+
import 'package:analyzer/dart/ast/visitor.dart';
4+
import 'package:analyzer/dart/element/element.dart';
5+
import 'package:path/path.dart' as p;
6+
import 'package:solid_lints/src/lints/prefer_mirror_test_structure/prefer_mirror_test_structure_rule.dart';
7+
8+
/// The AST visitor that checks if test files mirror the folder structure of
9+
/// their corresponding implementation files.
10+
class PreferMirrorTestStructureVisitor extends SimpleAstVisitor<void> {
11+
static const _testSuffix = '_test.dart';
12+
static const _dartSuffix = '.dart';
13+
static const _testDirName = 'test';
14+
static const _libDirName = 'lib';
15+
16+
/// The rule that instantiated this visitor.
17+
final PreferMirrorTestStructureRule rule;
18+
19+
/// The context of the current analysis rule.
20+
final RuleContext context;
21+
22+
/// Creates a new instance of [PreferMirrorTestStructureVisitor].
23+
PreferMirrorTestStructureVisitor({
24+
required this.rule,
25+
required this.context,
26+
});
27+
28+
@override
29+
void visitCompilationUnit(CompilationUnit node) {
30+
final currentUnit = context.currentUnit;
31+
final packageRoot = context.package?.root.path;
32+
if (currentUnit == null ||
33+
packageRoot == null ||
34+
!context.isInTestDirectory) {
35+
return;
36+
}
37+
38+
final file = currentUnit.file;
39+
final fileName = file.shortName;
40+
if (!fileName.endsWith(_testSuffix)) return;
41+
42+
final pathContext = file.provider.pathContext;
43+
final testDir = pathContext.relative(
44+
file.parent.path,
45+
from: pathContext.join(packageRoot, _testDirName),
46+
);
47+
48+
final libRoot = pathContext.join(packageRoot, _libDirName);
49+
final targetFileName = fileName.replaceFirst(_testSuffix, _dartSuffix);
50+
51+
for (final directive in node.directives.whereType<ImportDirective>()) {
52+
if (directive.libraryImport?.uri case DirectiveUriWithSource(
53+
:final source,
54+
)) {
55+
final importedPath = source.fullName;
56+
if (pathContext.basename(importedPath) != targetFileName ||
57+
!pathContext.isWithin(libRoot, importedPath)) {
58+
continue;
59+
}
60+
61+
final libDir = pathContext.relative(
62+
pathContext.dirname(importedPath),
63+
from: libRoot,
64+
);
65+
if (libDir == testDir) return;
66+
67+
final nativePath = pathContext.normalize(
68+
pathContext.join(_testDirName, libDir, fileName),
69+
);
70+
currentUnit.diagnosticReporter.atNode(
71+
directive.uri,
72+
rule.diagnosticCode,
73+
arguments: [p.posix.joinAll(pathContext.split(nativePath))],
74+
);
75+
76+
return;
77+
}
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)