diff --git a/lib/main.dart b/lib/main.dart index 5657b7a1..b0a50bfc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; import 'package:solid_lints/src/lints/avoid_debug_print_in_release/avoid_debug_print_in_release_rule.dart'; import 'package:solid_lints/src/lints/avoid_global_state/avoid_global_state_rule.dart'; import 'package:solid_lints/src/lints/avoid_non_null_assertion/avoid_non_null_assertion_rule.dart'; @@ -21,8 +22,9 @@ class SolidLintsPlugin extends Plugin { @override void register(PluginRegistry registry) { + final analysisLoader = AnalysisOptionsLoader(); registry.registerLintRule( - AvoidGlobalStateRule(), + AvoidGlobalStateRule(analysisLoader), ); registry.registerLintRule( AvoidNonNullAssertionRule(), diff --git a/lib/src/common/parameter_parser/analysis_options_loader.dart b/lib/src/common/parameter_parser/analysis_options_loader.dart new file mode 100644 index 00000000..b397bb08 --- /dev/null +++ b/lib/src/common/parameter_parser/analysis_options_loader.dart @@ -0,0 +1,104 @@ +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/file_system/file_system.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:solid_lints/src/common/parameter_parser/cached_package_rules.dart'; +import 'package:yaml/yaml.dart'; + +/// Loads and parses analysis options from a Dart project's YAML file. +class AnalysisOptionsLoader { + final ResourceProvider _resourceProvider; + final Map _rulesCache = {}; + + /// Creates an instance of [AnalysisOptionsLoader] + AnalysisOptionsLoader({ResourceProvider? resourceProvider}) + : _resourceProvider = + resourceProvider ?? PhysicalResourceProvider.INSTANCE; + + /// Gets the options for a specific rule by its name. + Map? getRuleOptions(RuleContext context, String ruleName) { + final packageRootPath = context.package?.root.path; + if (packageRootPath == null) return null; + + final yamlPath = _findNearestAnalysisOptionsFilePath(packageRootPath); + if (yamlPath == null) return null; + + return _rulesCache[yamlPath]?.rules[ruleName]; + } + + /// Loads lint rules from the analysis options file for all rules + /// using the provided [RuleContext]. + void loadRulesOptionsFromContext(RuleContext context) { + final packageRootPath = context.package?.root.path; + if (packageRootPath == null) return; + + _loadRulesOptionsIfNewer(packageRootPath); + } + + void _loadRulesOptionsIfNewer(String rootPath) { + final yamlPath = _findNearestAnalysisOptionsFilePath(rootPath); + if (yamlPath == null) return; + + final analysisOptionsFile = _resourceProvider.getFile(yamlPath); + final modificationStamp = analysisOptionsFile.modificationStamp; + final cachedRules = _rulesCache[yamlPath]; + + if (cachedRules?.modificationStamp == modificationStamp) { + return; + } + + final rules = _getRules(analysisOptionsFile); + _rulesCache[yamlPath] = CachedPackageRules( + modificationStamp: modificationStamp, + rules: rules, + ); + } + + String? _findNearestAnalysisOptionsFilePath(String packageRootPath) { + final pathContext = _resourceProvider.pathContext; + String currentDirectoryPath = packageRootPath; + + while (pathContext.dirname(currentDirectoryPath) != currentDirectoryPath) { + final candidatePath = + pathContext.join(currentDirectoryPath, 'analysis_options.yaml'); + final candidateFile = _resourceProvider.getFile(candidatePath); + + if (candidateFile.exists) { + return candidatePath; + } + + final parentDir = pathContext.dirname(currentDirectoryPath); + currentDirectoryPath = parentDir; + } + + return null; + } + + Map> _getRules(File? analysisOptionsFile) { + if (analysisOptionsFile == null || !analysisOptionsFile.exists) { + return {}; + } + + final optionsString = analysisOptionsFile.readAsStringSync(); + Object? yaml; + try { + yaml = loadYaml(optionsString) as Object?; + } catch (err) { + return {}; + } + + if (yaml + case {'plugins': {'solid_lints': {'diagnostics': final diagnostics?}}} + when diagnostics is Map) { + return Map.fromEntries( + diagnostics.entries.where((e) => e.key is String && e.value is Map).map( + (e) => MapEntry( + e.key as String, + Map.from(e.value as Map), + ), + ), + ); + } + + return {}; + } +} diff --git a/lib/src/common/parameter_parser/cached_package_rules.dart b/lib/src/common/parameter_parser/cached_package_rules.dart new file mode 100644 index 00000000..a65201d9 --- /dev/null +++ b/lib/src/common/parameter_parser/cached_package_rules.dart @@ -0,0 +1,14 @@ +/// Cached rules for a dart package +class CachedPackageRules { + /// The last modification stamp of the analysis options file + final int modificationStamp; + + /// Cached rules options by rule name for the package + final Map> rules; + + /// Creates an instance of [CachedPackageRules] + const CachedPackageRules({ + required this.modificationStamp, + required this.rules, + }); +} diff --git a/lib/src/lints/avoid_global_state/avoid_global_state_rule.dart b/lib/src/lints/avoid_global_state/avoid_global_state_rule.dart index 6ee78241..a027f402 100644 --- a/lib/src/lints/avoid_global_state/avoid_global_state_rule.dart +++ b/lib/src/lints/avoid_global_state/avoid_global_state_rule.dart @@ -2,6 +2,7 @@ import 'package:analyzer/analysis_rule/analysis_rule.dart'; import 'package:analyzer/analysis_rule/rule_context.dart'; import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; import 'package:analyzer/error/error.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; import 'package:solid_lints/src/lints/avoid_global_state/visitors/avoid_global_state_visitor.dart'; /// Avoid top-level and static mutable variables. @@ -46,8 +47,10 @@ class AvoidGlobalStateRule extends AnalysisRule { 'Prefer using final/const or a state management solution.', ); + final AnalysisOptionsLoader _analysisLoader; + /// Creates an instance of [AvoidGlobalStateRule]. - AvoidGlobalStateRule() + AvoidGlobalStateRule(this._analysisLoader) : super( name: lintName, description: 'Avoid top-level or static mutable variables ', @@ -63,6 +66,10 @@ class AvoidGlobalStateRule extends AnalysisRule { ) { final visitor = AvoidGlobalStateVisitor(this); + _analysisLoader.loadRulesOptionsFromContext(context); + // To get the options of the rule: + // _analysisLoader.getRuleOptions(context, lintName); + registry.addTopLevelVariableDeclaration(this, visitor); registry.addFieldDeclaration(this, visitor); } diff --git a/lib/src/models/rule_config.dart b/lib/src/models/rule_config.dart deleted file mode 100644 index 35c35b96..00000000 --- a/lib/src/models/rule_config.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:analyzer/error/error.dart' as error; -import 'package:custom_lint_builder/custom_lint_builder.dart'; - -/// Type definition of a value factory which allows us to map data from -/// YAML configuration to an object of type [T]. -typedef RuleParameterParser = T Function(Map json); - -/// Type definition for a problem message factory after finding a problem -/// by a given lint. -typedef RuleProblemFactory = String Function(T value); - -/// [RuleConfig] allows us to quickly parse a lint rule and -/// declare basic configuration for it. -class RuleConfig { - /// Constructor for [RuleConfig] model. - RuleConfig({ - required this.name, - required CustomLintConfigs configs, - required RuleProblemFactory problemMessage, - RuleParameterParser? paramsParser, - }) : enabled = configs.rules[name]?.enabled ?? false, - parameters = paramsParser?.call(configs.rules[name]?.json ?? {}) as T, - _problemMessageFactory = problemMessage; - - /// This lint rule represents the error. - final String name; - - /// A flag which indicates whether this rule was enabled by the user. - final bool enabled; - - /// Value with a configuration for a particular rule. - final T parameters; - - /// Factory for generating error messages. - final RuleProblemFactory _problemMessageFactory; - - /// [LintCode] which is generated based on the provided data. - LintCode get lintCode => LintCode( - name: name, - problemMessage: _problemMessageFactory(parameters), - errorSeverity: error.DiagnosticSeverity.WARNING, - ); -} diff --git a/lib/src/models/solid_lint_rule.dart b/lib/src/models/solid_lint_rule.dart index a536eb4f..9f5bee56 100644 --- a/lib/src/models/solid_lint_rule.dart +++ b/lib/src/models/solid_lint_rule.dart @@ -1,16 +1,43 @@ -import 'package:custom_lint_builder/custom_lint_builder.dart'; -import 'package:solid_lints/src/models/rule_config.dart'; +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; + +/// A function that parses the rule parameters from analysis options json +typedef RuleParametersParser = T Function(Map); /// A base class for emitting information about /// issues with user's `.dart` files. -abstract class SolidLintRule extends DartLintRule { +abstract class SolidLintRule extends AnalysisRule { + final AnalysisOptionsLoader? _analysisOptionsLoader; + + final RuleParametersParser? _parametersParser; + /// Constructor for [SolidLintRule] model. - SolidLintRule(this.config) : super(code: config.lintCode); + SolidLintRule({ + required super.name, + required super.description, + super.state, + }) : _analysisOptionsLoader = null, + _parametersParser = null; + + /// Constructor for [SolidLintRule] model with parameters. + SolidLintRule.withParameters({ + required AnalysisOptionsLoader analysisOptionsLoader, + required RuleParametersParser parametersParser, + required super.name, + required super.description, + super.state, + }) : _analysisOptionsLoader = analysisOptionsLoader, + _parametersParser = parametersParser; + + /// Reads the rule parameters from analysis options and parses them to [T] + T? getParametersForContext(RuleContext context) { + _analysisOptionsLoader?.loadRulesOptionsFromContext(context); - /// Configuration for a particular rule with all the - /// defined custom parameters. - final RuleConfig config; + final unparsedParameters = + _analysisOptionsLoader?.getRuleOptions(context, name); + if (unparsedParameters == null) return null; - /// A flag which indicates whether this rule was enabled by the user. - bool get enabled => config.enabled; + return _parametersParser?.call(unparsedParameters); + } } diff --git a/test/src/common/parameter_parser/analysis_options_loader_test.dart b/test/src/common/parameter_parser/analysis_options_loader_test.dart new file mode 100644 index 00000000..a727136f --- /dev/null +++ b/test/src/common/parameter_parser/analysis_options_loader_test.dart @@ -0,0 +1,225 @@ +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/file_system/file_system.dart'; +import 'package:analyzer/workspace/workspace.dart'; +import 'package:analyzer_testing/src/analysis_rule/pub_package_resolution.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(AnalysisOptionsLoaderTest); + }); +} + +@reflectiveTest +class AnalysisOptionsLoaderTest extends PubPackageResolutionTest { + // TODO: use actual [Rule.lintName] after migrating to analyzer_server_plugin + // Can't be used right now because they have compile errors + static const _mockRuleThatNeedsConfigName = 'mock_rule_that_needs_config'; + static const _mockRule2Name = 'mock_rule_2'; + static const _cyclomaticComplexityName = 'cyclomatic_complexity'; + + static const _mockAnalysisOptionsContent = ''' +plugins: + solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + abc: def + $_mockRule2Name: + foo: bar + exclude: + - class_name: MockClass + method_name: mockMethod + $_cyclomaticComplexityName: + max_complexity: 10 + exclude: + - class_name: MockClass + method_name: mockMethod + - method_name: mockMethod2 + '''; + static const _mockDifferentAnalysisOptionsContent = ''' +plugins: + solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + abc: ghi + $_mockRule2Name: + foo: baz + exclude: + - class_name: MockOtherClass + method_name: mockOtherMethod + $_cyclomaticComplexityName: + max_complexity: 20 + exclude: + - class_name: MockOtherClass + method_name: mockOtherMethod + - method_name: mockOtherMethod2 + '''; + + late AnalysisOptionsLoader analysisOptionsLoader; + late RuleContext mockRuleContext; + + @override + void setUp() { + super.setUp(); + + analysisOptionsLoader = + AnalysisOptionsLoader(resourceProvider: resourceProvider); + mockRuleContext = _createMockContextForPackage(testPackageRootPath); + + _writeMockAnalysisOptionsYamlFile(); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + } + + void _writeMockAnalysisOptionsYamlFile() { + newAnalysisOptionsYamlFile( + testPackageRootPath, + _mockAnalysisOptionsContent, + ); + } + + void test_cached_response_is_scoped_to_package_and_rule() { + const otherPackageRootPath = '/home/other'; + + newFolder(otherPackageRootPath); + newPubspecYamlFile(otherPackageRootPath, 'name: other'); + newAnalysisOptionsYamlFile( + otherPackageRootPath, + _mockDifferentAnalysisOptionsContent, + ); + + for (final ruleName in [ + _mockRuleThatNeedsConfigName, + _mockRule2Name, + _cyclomaticComplexityName + ]) { + final currentPackageOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + ruleName, + ); + final otherPackageOptions = analysisOptionsLoader.getRuleOptions( + _createMockContextForPackage(otherPackageRootPath), + ruleName, + ); + + expect( + currentPackageOptions, + isNot(equals(otherPackageOptions)), + ); + } + } + + void test_each_rule_gets_its_options() { + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final mockRuleThatNeedsConfigOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + final mockRule2Options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRule2Name, + ); + final cyclomaticComplexityOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _cyclomaticComplexityName, + ); + + expect(mockRuleThatNeedsConfigOptions, isNotNull); + expect(mockRuleThatNeedsConfigOptions, {'abc': 'def'}); + + expect(mockRule2Options, isNotNull); + expect(mockRule2Options, { + 'foo': 'bar', + 'exclude': [ + {'class_name': 'MockClass', 'method_name': 'mockMethod'}, + ] + }); + + expect(cyclomaticComplexityOptions, isNotNull); + expect(cyclomaticComplexityOptions, { + 'max_complexity': 10, + 'exclude': [ + {'class_name': 'MockClass', 'method_name': 'mockMethod'}, + {'method_name': 'mockMethod2'}, + ] + }); + } + + void test_invalidates_cache_when_analysis_options_changed() { + final initialOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + _mockDifferentAnalysisOptionsContent, + ); + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final updatedOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(initialOptions, {'abc': 'def'}); + expect(updatedOptions, {'abc': 'ghi'}); + expect(updatedOptions, isNot(same(initialOptions))); + } + + void test_loads_and_parses_rule_options_from_yaml_file() { + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, isNotNull); + expect(options, {'abc': 'def'}); + } + + void test_returns_cached_response_for_same_rule_name() { + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final firstOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + final secondOptions = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(secondOptions, same(firstOptions)); + } + + RuleContext _createMockContextForPackage(String packageRootPath) { + return _TestRuleContext( + _TestWorkspacePackage(getFolder(packageRootPath)), + ); + } +} + +class _TestRuleContext implements RuleContext { + @override + final WorkspacePackage? package; + + _TestRuleContext(this.package); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _TestWorkspacePackage implements WorkspacePackage { + @override + final Folder root; + + _TestWorkspacePackage(this.root); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}