From ed461196e127a57a67be961a19e7742d63d3b6d8 Mon Sep 17 00:00:00 2001 From: Illia Aihistov Date: Fri, 10 Jul 2026 19:49:35 +0300 Subject: [PATCH 1/4] fix: support package-level analysis options and rule suppression --- lib/analysis_options.yaml | 2 - lib/main.dart | 14 +- lib/src/common/constants.dart | 2 + .../analysis_options_loader.dart | 116 +++++----- .../analysis_options_parser.dart | 196 +++++++++++++++++ .../cached_package_rules.dart | 4 + .../package_config_resolver.dart | 131 +++++++++++ .../common/parameter_parser/rules_data.dart | 16 ++ lib/src/models/proxy_analysis_rule.dart | 54 +++++ lib/src/models/proxy_multi_analysis_rule.dart | 54 +++++ pubspec.yaml | 5 + .../analysis_options_loader_test.dart | 206 ++++++++++++++++++ test/utils/fake_analysis_options_loader.dart | 3 + 13 files changed, 738 insertions(+), 65 deletions(-) create mode 100644 lib/src/common/constants.dart create mode 100644 lib/src/common/parameter_parser/analysis_options_parser.dart create mode 100644 lib/src/common/parameter_parser/package_config_resolver.dart create mode 100644 lib/src/common/parameter_parser/rules_data.dart create mode 100644 lib/src/models/proxy_analysis_rule.dart create mode 100644 lib/src/models/proxy_multi_analysis_rule.dart diff --git a/lib/analysis_options.yaml b/lib/analysis_options.yaml index dd2a9c95..f74ed9ed 100644 --- a/lib/analysis_options.yaml +++ b/lib/analysis_options.yaml @@ -1,6 +1,4 @@ analyzer: - plugins: - - custom_lint exclude: # General generated files - "**/*.g.dart" diff --git a/lib/main.dart b/lib/main.dart index a6f55fad..6ee2d035 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:solid_lints/src/common/constants.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_final_with_getter/avoid_final_with_getter_rule.dart'; @@ -30,6 +32,8 @@ import 'package:solid_lints/src/lints/prefer_last/prefer_last_rule.dart'; import 'package:solid_lints/src/lints/prefer_match_file_name/prefer_match_file_name_rule.dart'; import 'package:solid_lints/src/lints/proper_super_calls/proper_super_calls_rule.dart'; import 'package:solid_lints/src/lints/use_nearest_context/use_nearest_context_rule.dart'; +import 'package:solid_lints/src/models/proxy_analysis_rule.dart'; +import 'package:solid_lints/src/models/proxy_multi_analysis_rule.dart'; import 'package:solid_lints/src/models/rule_with_fixes.dart'; /// The entry point for the Solid Lints analyser server plugin. @@ -44,7 +48,7 @@ final plugin = SolidLintsPlugin(); /// by the Dart analyzer during code analysis. class SolidLintsPlugin extends Plugin { @override - String get name => 'solid_lints'; + String get name => kPluginName; @override void register(PluginRegistry registry) { @@ -83,7 +87,13 @@ class SolidLintsPlugin extends Plugin { ]; for (final lintRule in lintRules) { - registry.registerLintRule(lintRule); + final ruleToRegister = switch (lintRule) { + MultiAnalysisRule() => ProxyMultiAnalysisRule(lintRule, analysisLoader), + AnalysisRule() => ProxyAnalysisRule(lintRule, analysisLoader), + }; + + registry.registerWarningRule(ruleToRegister); + if (lintRule is RuleWithFixes) { for (final entry in (lintRule as RuleWithFixes).fixesForCodes) { registry.registerFixForRule(entry.key, entry.value); diff --git a/lib/src/common/constants.dart b/lib/src/common/constants.dart new file mode 100644 index 00000000..6f0149b7 --- /dev/null +++ b/lib/src/common/constants.dart @@ -0,0 +1,2 @@ +/// Shared constants for the solid_lints package. +const kPluginName = 'solid_lints'; diff --git a/lib/src/common/parameter_parser/analysis_options_loader.dart b/lib/src/common/parameter_parser/analysis_options_loader.dart index 5b0a016e..31565c1c 100644 --- a/lib/src/common/parameter_parser/analysis_options_loader.dart +++ b/lib/src/common/parameter_parser/analysis_options_loader.dart @@ -1,17 +1,32 @@ 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/analysis_options_parser.dart'; import 'package:solid_lints/src/common/parameter_parser/cached_package_rules.dart'; -import 'package:yaml/yaml.dart'; +import 'package:solid_lints/src/common/parameter_parser/package_config_resolver.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; + // Caches directory path -> nearest analysis_options.yaml file path + final Map _nearestYamlCache = {}; + + late final AnalysisOptionsParser _parser; + + /// Creates an instance of [AnalysisOptionsLoader]. + AnalysisOptionsLoader({ + ResourceProvider? resourceProvider, + AnalysisOptionsParser? parser, + }) : _resourceProvider = + resourceProvider ?? PhysicalResourceProvider.INSTANCE { + _parser = parser ?? + AnalysisOptionsParser( + _resourceProvider, + PackageConfigResolver(_resourceProvider), + ); + } /// Gets the options for a specific rule by its name. Map? getRuleOptions(RuleContext context, String ruleName) => @@ -36,6 +51,17 @@ class AnalysisOptionsLoader { return _rulesCache[yamlPath]?.rules[ruleName]; } + /// Checks if a rule is explicitly disabled. + bool isRuleDisabled(RuleContext context, String ruleName) => + _withNearestAnalysisOptionsFilePathForContext( + context, + (path) { + _loadRulesOptionsIfNewer(path); + return _rulesCache[path]?.disabledRules.contains(ruleName) ?? false; + }, + ) ?? + false; + /// Loads lint rules from the analysis options file for all rules /// using the provided [RuleContext]. void loadRulesOptionsFromContext(RuleContext context) => @@ -66,70 +92,38 @@ class AnalysisOptionsLoader { return; } - final rules = _getRules(analysisOptionsFile); + final rulesData = _parser.parse(analysisOptionsFile); _rulesCache[yamlPath] = CachedPackageRules( modificationStamp: modificationStamp, - rules: rules, + rules: rulesData.rules, + disabledRules: rulesData.disabledRules, ); } String? _findNearestAnalysisOptionsFilePath(String startDirectoryPath) { - final pathContext = _resourceProvider.pathContext; - String currentDirectoryPath = startDirectoryPath; - - while (true) { - final candidatePath = pathContext.join( - currentDirectoryPath, - 'analysis_options.yaml', - ); - final candidateFile = _resourceProvider.getFile(candidatePath); - - if (candidateFile.exists) { - return candidatePath; - } - - final parentDir = pathContext.dirname(currentDirectoryPath); - if (parentDir == currentDirectoryPath) { - break; + return _nearestYamlCache.putIfAbsent(startDirectoryPath, () { + final pathContext = _resourceProvider.pathContext; + var currentDirectoryPath = startDirectoryPath; + + while (currentDirectoryPath.isNotEmpty) { + final candidatePath = pathContext.join( + currentDirectoryPath, + 'analysis_options.yaml', + ); + final candidateFile = _resourceProvider.getFile(candidatePath); + + if (candidateFile.exists) { + return candidatePath; + } + + final parentDir = pathContext.dirname(currentDirectoryPath); + if (parentDir == currentDirectoryPath) { + break; + } + currentDirectoryPath = parentDir; } - currentDirectoryPath = parentDir; - } - - return null; - } - - Map> _getRules(File? analysisOptionsFile) { - if (analysisOptionsFile == null || !analysisOptionsFile.exists) { - return {}; - } - - Object? yaml; - try { - final optionsString = analysisOptionsFile.readAsStringSync(); - yaml = loadYaml(optionsString); - } catch (_) { - return {}; - } - - Object? rawDiagnostics; - if (yaml case {'solid_lints': {'diagnostics': final diagnostics}}) { - rawDiagnostics = diagnostics; - } else if (yaml case { - 'plugins': {'solid_lints': {'diagnostics': final diagnostics}}, - }) { - rawDiagnostics = diagnostics; - } - if (rawDiagnostics is! Map) return {}; - - return { - for (final entry in rawDiagnostics.entries) - if (entry.key is String && entry.value is Map) - entry.key as String: { - for (final optionEntry in (entry.value as Map).entries) - if (optionEntry.key is String) - optionEntry.key as String: optionEntry.value, - }, - }; + return null; + }); } } diff --git a/lib/src/common/parameter_parser/analysis_options_parser.dart b/lib/src/common/parameter_parser/analysis_options_parser.dart new file mode 100644 index 00000000..90214dcf --- /dev/null +++ b/lib/src/common/parameter_parser/analysis_options_parser.dart @@ -0,0 +1,196 @@ +import 'package:analyzer/file_system/file_system.dart'; +import 'package:solid_lints/src/common/constants.dart'; +import 'package:solid_lints/src/common/parameter_parser/package_config_resolver.dart'; +import 'package:solid_lints/src/common/parameter_parser/rules_data.dart'; +import 'package:yaml/yaml.dart'; + +/// Parser for analysis_options.yaml files to extract RulesData. +class AnalysisOptionsParser { + final ResourceProvider _resourceProvider; + final PackageConfigResolver _packageConfigResolver; + + /// Creates a new instance of [AnalysisOptionsParser]. + AnalysisOptionsParser(this._resourceProvider, this._packageConfigResolver); + + /// Parses the given [analysisOptionsFile] and resolves its imports + /// to return [RulesData]. + RulesData parse(File? analysisOptionsFile) { + return _parseWithSeen(analysisOptionsFile, {}); + } + + RulesData _parseWithSeen(File? analysisOptionsFile, Set seenPaths) { + if (analysisOptionsFile == null || !analysisOptionsFile.exists) { + return const RulesData.empty(); + } + + final path = analysisOptionsFile.path; + if (seenPaths.contains(path)) { + return const RulesData.empty(); + } + seenPaths.add(path); + + final yaml = _parseYaml(analysisOptionsFile); + if (yaml == null) { + return const RulesData.empty(); + } + + final mergedRules = >{}; + final disabledRules = {}; + + _resolveAndMergeIncludes( + analysisOptionsFile, + yaml, + seenPaths, + mergedRules, + disabledRules, + ); + _parseRuleOptions(yaml, mergedRules, disabledRules); + _parseSuppressedErrors(yaml, mergedRules, disabledRules); + + return RulesData(rules: mergedRules, disabledRules: disabledRules); + } + + Map? _parseYaml(File file) { + try { + final optionsString = file.readAsStringSync(); + final parsed = loadYaml(optionsString); + return parsed is Map ? parsed : null; + } catch (_) { + return null; + } + } + + void _resolveAndMergeIncludes( + File baseFile, + Map yaml, + Set seenPaths, + Map> mergedRules, + Set disabledRules, + ) { + final includeOption = yaml['include']; + if (includeOption is! String) return; + + final includedFile = _resolveIncludedFile(baseFile, includeOption); + if (includedFile == null) return; + + final includedData = _parseWithSeen(includedFile, seenPaths); + mergedRules.addAll(includedData.rules); + disabledRules.addAll(includedData.disabledRules); + } + + File? _resolveIncludedFile(File baseFile, String includePath) { + final pathContext = _resourceProvider.pathContext; + if (includePath.startsWith('package:')) { + final resolvedPath = _packageConfigResolver.resolvePackageUri( + baseFile.path, + includePath, + ); + if (resolvedPath != null) { + return _resourceProvider.getFile(resolvedPath); + } + return null; + } + + final baseDir = pathContext.dirname(baseFile.path); + final resolvedPath = pathContext.join(baseDir, includePath); + return _resourceProvider.getFile(resolvedPath); + } + + void _parseRuleOptions( + Map yaml, + Map> mergedRules, + Set disabledRules, + ) { + final rawDiagnostics = _extractDiagnostics(yaml); + if (rawDiagnostics is! Map) return; + + for (final entry in rawDiagnostics.entries) { + final key = entry.key; + if (key is! String) continue; + + final ruleName = key; + final value = entry.value; + + if (value is Map) { + final existingOptions = mergedRules[ruleName] ?? {}; + mergedRules[ruleName] = { + ...existingOptions, + for (final optionEntry in value.entries) + if (optionEntry.key is String) + optionEntry.key as String: optionEntry.value, + }; + disabledRules.remove(ruleName); + } else if (value is bool) { + if (value) { + disabledRules.remove(ruleName); + } else { + mergedRules.remove(ruleName); + disabledRules.add(ruleName); + } + } + } + } + + Object? _extractDiagnostics(Map yaml) { + final pluginConfig = yaml[kPluginName]; + if (pluginConfig is Map) { + return pluginConfig['diagnostics']; + } + + final pluginsConfig = yaml['plugins']; + if (pluginsConfig is Map) { + final pluginSubConfig = pluginsConfig[kPluginName]; + if (pluginSubConfig is Map) { + return pluginSubConfig['diagnostics']; + } + } + + return null; + } + + /// Parses rule suppression configured under `analyzer: errors:`. + /// + /// By default, when a user excludes/ignores a rule using the IDE + /// quick fix or standard settings, the Dart analysis server appends + /// the following to `analysis_options.yaml`: + /// + /// ```yaml + /// analyzer: + /// errors: + /// solid_lints/rule_name: ignore + /// ``` + /// + /// To respect this standard mechanism and prevent the plugin rules + /// from running, we parse this section and add suppressed rules + /// to [disabledRules]. + void _parseSuppressedErrors( + Map yaml, + Map> mergedRules, + Set disabledRules, + ) { + final analyzer = yaml['analyzer']; + if (analyzer is! Map) return; + + final errors = analyzer['errors']; + if (errors is! Map) return; + + const pluginPrefix = '$kPluginName/'; + + for (final entry in errors.entries) { + final key = entry.key; + if (key is! String) continue; + + final errorValue = entry.value; + final ruleName = key.startsWith(pluginPrefix) + ? key.substring(pluginPrefix.length) + : key; + + if (errorValue == 'ignore') { + mergedRules.remove(ruleName); + disabledRules.add(ruleName); + } else { + disabledRules.remove(ruleName); + } + } + } +} diff --git a/lib/src/common/parameter_parser/cached_package_rules.dart b/lib/src/common/parameter_parser/cached_package_rules.dart index a65201d9..d9bee3bb 100644 --- a/lib/src/common/parameter_parser/cached_package_rules.dart +++ b/lib/src/common/parameter_parser/cached_package_rules.dart @@ -6,9 +6,13 @@ class CachedPackageRules { /// Cached rules options by rule name for the package final Map> rules; + /// Rules that are explicitly disabled + final Set disabledRules; + /// Creates an instance of [CachedPackageRules] const CachedPackageRules({ required this.modificationStamp, required this.rules, + required this.disabledRules, }); } diff --git a/lib/src/common/parameter_parser/package_config_resolver.dart b/lib/src/common/parameter_parser/package_config_resolver.dart new file mode 100644 index 00000000..4935c51d --- /dev/null +++ b/lib/src/common/parameter_parser/package_config_resolver.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'package:analyzer/file_system/file_system.dart'; + +/// Helper class to resolve package: URIs to physical file system paths. +class PackageConfigResolver { + final ResourceProvider _resourceProvider; + + // Caches directory path -> nearest package_config.json path + final Map _configPathCache = {}; + + // Caches package_config.json path -> Map + final Map> _packagesCache = {}; + + /// Creates a new instance of [PackageConfigResolver]. + PackageConfigResolver(this._resourceProvider); + + /// Resolves the absolute directory path for a package URI + /// (e.g., `package:solid_lints/lib/`). + String? resolvePackageUri(String baseFilePath, String packageUri) { + if (!packageUri.startsWith('package:')) return null; + + final uri = Uri.parse(packageUri); + if (uri.pathSegments.isEmpty) return null; + + final packageName = uri.pathSegments.first; + final relativePath = uri.pathSegments.skip(1).join('/'); + + final baseDir = _resourceProvider.pathContext.dirname(baseFilePath); + final packageConfigPath = _findPackageConfig(baseDir); + if (packageConfigPath == null) return null; + + final packageRoot = _resolvePackageRoot(packageConfigPath, packageName); + if (packageRoot == null) return null; + + return _resourceProvider.pathContext.join(packageRoot, relativePath); + } + + /// Finds the nearest `.dart_tool/package_config.json` file by walking up + /// the directory tree starting from [startDirectoryPath]. + /// + /// Results (including `null`) are cached in [_configPathCache] to avoid + /// redundant filesystem walks. + String? _findPackageConfig(String startDirectoryPath) { + final cached = _configPathCache[startDirectoryPath]; + if (cached != null || _configPathCache.containsKey(startDirectoryPath)) { + return cached; + } + + final pathContext = _resourceProvider.pathContext; + var currentDirectoryPath = startDirectoryPath; + + while (currentDirectoryPath.isNotEmpty) { + final candidatePath = pathContext.join( + currentDirectoryPath, + '.dart_tool', + 'package_config.json', + ); + final candidateFile = _resourceProvider.getFile(candidatePath); + + if (candidateFile.exists) { + return _configPathCache[startDirectoryPath] = candidatePath; + } + + final parentDir = pathContext.dirname(currentDirectoryPath); + if (parentDir == currentDirectoryPath) { + break; + } + currentDirectoryPath = parentDir; + } + + return _configPathCache[startDirectoryPath] = null; + } + + String? _resolvePackageRoot(String packageConfigPath, String packageName) { + var packageMap = _packagesCache[packageConfigPath]; + if (packageMap == null) { + packageMap = _parsePackageConfig(packageConfigPath); + _packagesCache[packageConfigPath] = packageMap; + } + return packageMap[packageName]; + } + + /// Reads and parses the `.dart_tool/package_config.json` configuration file. + /// + /// Returns a map associating each package name (e.g., 'lints') with its + /// absolute physical path to the `lib/` directory on disk. + Map _parsePackageConfig(String packageConfigPath) { + final file = _resourceProvider.getFile(packageConfigPath); + if (!file.exists) return const {}; + + try { + final content = file.readAsStringSync(); + final json = jsonDecode(content); + if (json is! Map) return const {}; + + final packagesList = json['packages']; + if (packagesList is! List) return const {}; + + final result = {}; + + for (final package in packagesList) { + if (package is! Map) continue; + + final name = package['name']; + final rootUriString = package['rootUri']; + final packageUriString = package['packageUri']; + + if (name is! String || + rootUriString is! String || + packageUriString is! String) { + continue; + } + + final rootUri = Uri.parse(rootUriString); + final resolvedRootUri = rootUri.isAbsolute + ? rootUri + : Uri.file(packageConfigPath).resolveUri(rootUri); + + if (resolvedRootUri.isScheme('file')) { + result[name] = _resourceProvider.pathContext.join( + resolvedRootUri.toFilePath(), + packageUriString, + ); + } + } + return result; + } catch (_) { + return const {}; + } + } +} diff --git a/lib/src/common/parameter_parser/rules_data.dart b/lib/src/common/parameter_parser/rules_data.dart new file mode 100644 index 00000000..a589b3fe --- /dev/null +++ b/lib/src/common/parameter_parser/rules_data.dart @@ -0,0 +1,16 @@ +/// Data class representing parsed rules configuration and disabled rules. +class RulesData { + /// The active rules options mapped by rule name. + final Map> rules; + + /// The set of explicitly disabled rules. + final Set disabledRules; + + /// Creates a new instance of [RulesData]. + const RulesData({required this.rules, required this.disabledRules}); + + /// Creates a new empty instance of [RulesData]. + const RulesData.empty() + : rules = const {}, + disabledRules = const {}; +} diff --git a/lib/src/models/proxy_analysis_rule.dart b/lib/src/models/proxy_analysis_rule.dart new file mode 100644 index 00000000..2bdd2d4e --- /dev/null +++ b/lib/src/models/proxy_analysis_rule.dart @@ -0,0 +1,54 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/pubspec.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:analyzer/error/listener.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; + +/// A proxy wrapper for [AnalysisRule] that checks if the rule is disabled +/// before registering its node processors. +class ProxyAnalysisRule extends AnalysisRule { + /// The delegated rule. + final AnalysisRule delegate; + + /// The loader used to check if the rule is disabled. + final AnalysisOptionsLoader loader; + + /// Creates a new instance of [ProxyAnalysisRule]. + ProxyAnalysisRule(this.delegate, this.loader) + : super( + name: delegate.name, + description: delegate.description, + state: delegate.state, + ); + + @override + DiagnosticCode get diagnosticCode => delegate.diagnosticCode; + + @override + bool get canUseParsedResult => delegate.canUseParsedResult; + + @override + List get incompatibleRules => delegate.incompatibleRules; + + @override + PubspecVisitor? get pubspecVisitor => delegate.pubspecVisitor; + + @override + set reporter(DiagnosticReporter value) { + super.reporter = value; + delegate.reporter = value; + } + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (loader.isRuleDisabled(context, name)) { + return; + } + delegate.registerNodeProcessors(registry, context); + } +} diff --git a/lib/src/models/proxy_multi_analysis_rule.dart b/lib/src/models/proxy_multi_analysis_rule.dart new file mode 100644 index 00000000..e497590b --- /dev/null +++ b/lib/src/models/proxy_multi_analysis_rule.dart @@ -0,0 +1,54 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/pubspec.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:analyzer/error/listener.dart'; +import 'package:solid_lints/src/common/parameter_parser/analysis_options_loader.dart'; + +/// A proxy wrapper for [MultiAnalysisRule] that checks if the rule is disabled +/// before registering its node processors. +class ProxyMultiAnalysisRule extends MultiAnalysisRule { + /// The delegated rule. + final MultiAnalysisRule delegate; + + /// The loader used to check if the rule is disabled. + final AnalysisOptionsLoader loader; + + /// Creates a new instance of [ProxyMultiAnalysisRule]. + ProxyMultiAnalysisRule(this.delegate, this.loader) + : super( + name: delegate.name, + description: delegate.description, + state: delegate.state, + ); + + @override + List get diagnosticCodes => delegate.diagnosticCodes; + + @override + bool get canUseParsedResult => delegate.canUseParsedResult; + + @override + List get incompatibleRules => delegate.incompatibleRules; + + @override + PubspecVisitor? get pubspecVisitor => delegate.pubspecVisitor; + + @override + set reporter(DiagnosticReporter value) { + super.reporter = value; + delegate.reporter = value; + } + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + if (loader.isRuleDisabled(context, name)) { + return; + } + delegate.registerNodeProcessors(registry, context); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 56c55842..31a9af9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,3 +27,8 @@ dev_dependencies: args: ^2.6.0 analyzer_testing: ^0.1.9 test_reflective_loader: ^0.3.0 + +plugin: + platforms: + dart: + pluginClass: SolidLintsPlugin diff --git a/test/src/common/parameter_parser/analysis_options_loader_test.dart b/test/src/common/parameter_parser/analysis_options_loader_test.dart index 67fffb6a..ebb93ee0 100644 --- a/test/src/common/parameter_parser/analysis_options_loader_test.dart +++ b/test/src/common/parameter_parser/analysis_options_loader_test.dart @@ -277,6 +277,212 @@ plugins: expect(options, {'some_parameter': 'nested_value'}); } + void test_resolve_include_relative_path() { + final includedOptionsPath = '$testPackageRootPath/included_options.yaml'; + newFile( + includedOptionsPath, + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + some_parameter: included_value +''', + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: included_options.yaml +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, isNotNull); + expect(options, {'some_parameter': 'included_value'}); + } + + void test_resolve_include_package_path() { + final sharedPackageRoot = '/home/shared'; + newFolder(sharedPackageRoot); + newFile( + '$sharedPackageRoot/lib/analysis_options.yaml', + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + some_parameter: package_value +''', + ); + + newPackageConfigJsonFile( + testPackageRootPath, + ''' +{ + "configVersion": 2, + "packages": [ + { + "name": "shared", + "rootUri": "file://$sharedPackageRoot", + "packageUri": "lib/" + } + ] +} +''', + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: package:shared/analysis_options.yaml +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, isNotNull); + expect(options, {'some_parameter': 'package_value'}); + } + + void test_resolve_include_cyclic() { + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: analysis_options.yaml +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + some_parameter: cyclic_value +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, isNotNull); + expect(options, {'some_parameter': 'cyclic_value'}); + } + + void test_isRuleDisabled_when_set_to_false() { + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: false +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + expect( + analysisOptionsLoader.isRuleDisabled(mockRuleContext, _mockRuleThatNeedsConfigName), + isTrue, + ); + } + + void test_isRuleDisabled_when_suppressed_in_analyzer_errors() { + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +analyzer: + errors: + solid_lints/$_mockRuleThatNeedsConfigName: ignore + $_mockRule2Name: ignore +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + expect( + analysisOptionsLoader.isRuleDisabled(mockRuleContext, _mockRuleThatNeedsConfigName), + isTrue, + ); + expect( + analysisOptionsLoader.isRuleDisabled(mockRuleContext, _mockRule2Name), + isTrue, + ); + } + + void test_isRuleDisabled_when_suppressed_in_included_analyzer_errors() { + final includedOptionsPath = '$testPackageRootPath/included_options.yaml'; + newFile( + includedOptionsPath, + ''' +analyzer: + errors: + solid_lints/$_mockRuleThatNeedsConfigName: ignore +''', + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: included_options.yaml +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + expect( + analysisOptionsLoader.isRuleDisabled(mockRuleContext, _mockRuleThatNeedsConfigName), + isTrue, + ); + } + + void test_options_merging_with_include() { + final includedOptionsPath = '$testPackageRootPath/included_options.yaml'; + newFile( + includedOptionsPath, + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + param_a: val_a + param_b: val_b +''', + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: included_options.yaml +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + param_b: local_val_b + param_c: val_c +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, { + 'param_a': 'val_a', + 'param_b': 'local_val_b', + 'param_c': 'val_c', + }); + } + RuleContext _createMockContextForPackage( String packageRootPath, { RuleContextUnit? definingUnit, diff --git a/test/utils/fake_analysis_options_loader.dart b/test/utils/fake_analysis_options_loader.dart index 777abb87..3672abca 100644 --- a/test/utils/fake_analysis_options_loader.dart +++ b/test/utils/fake_analysis_options_loader.dart @@ -19,4 +19,7 @@ class FakeAnalysisOptionsLoader implements AnalysisOptionsLoader { @override void loadRulesOptionsFromContext(RuleContext context) {} + + @override + bool isRuleDisabled(RuleContext context, String ruleName) => false; } From dbdc1d421d07f687ff976cacc90f4afa2d010dd9 Mon Sep 17 00:00:00 2001 From: Illia Aihistov Date: Fri, 10 Jul 2026 20:08:57 +0300 Subject: [PATCH 2/4] refactor: improve analysis options parsing robustness with type standardization and fix path resolution logic --- .../analysis_options_loader.dart | 41 +++++++++---------- .../analysis_options_parser.dart | 31 ++++++++++---- .../package_config_resolver.dart | 6 ++- .../models/avoid_using_api_parameters.dart | 11 +++-- 4 files changed, 54 insertions(+), 35 deletions(-) diff --git a/lib/src/common/parameter_parser/analysis_options_loader.dart b/lib/src/common/parameter_parser/analysis_options_loader.dart index 31565c1c..fb7c0d6e 100644 --- a/lib/src/common/parameter_parser/analysis_options_loader.dart +++ b/lib/src/common/parameter_parser/analysis_options_loader.dart @@ -10,8 +10,7 @@ class AnalysisOptionsLoader { final ResourceProvider _resourceProvider; final Map _rulesCache = {}; - // Caches directory path -> nearest analysis_options.yaml file path - final Map _nearestYamlCache = {}; + late final AnalysisOptionsParser _parser; @@ -101,29 +100,27 @@ class AnalysisOptionsLoader { } String? _findNearestAnalysisOptionsFilePath(String startDirectoryPath) { - return _nearestYamlCache.putIfAbsent(startDirectoryPath, () { - final pathContext = _resourceProvider.pathContext; - var currentDirectoryPath = startDirectoryPath; - - while (currentDirectoryPath.isNotEmpty) { - final candidatePath = pathContext.join( - currentDirectoryPath, - 'analysis_options.yaml', - ); - final candidateFile = _resourceProvider.getFile(candidatePath); + final pathContext = _resourceProvider.pathContext; + var currentDirectoryPath = startDirectoryPath; + + while (currentDirectoryPath.isNotEmpty) { + final candidatePath = pathContext.join( + currentDirectoryPath, + 'analysis_options.yaml', + ); + final candidateFile = _resourceProvider.getFile(candidatePath); - if (candidateFile.exists) { - return candidatePath; - } + if (candidateFile.exists) { + return candidatePath; + } - final parentDir = pathContext.dirname(currentDirectoryPath); - if (parentDir == currentDirectoryPath) { - break; - } - currentDirectoryPath = parentDir; + final parentDir = pathContext.dirname(currentDirectoryPath); + if (parentDir == currentDirectoryPath) { + break; } + currentDirectoryPath = parentDir; + } - return null; - }); + return null; } } diff --git a/lib/src/common/parameter_parser/analysis_options_parser.dart b/lib/src/common/parameter_parser/analysis_options_parser.dart index 90214dcf..2885cd3d 100644 --- a/lib/src/common/parameter_parser/analysis_options_parser.dart +++ b/lib/src/common/parameter_parser/analysis_options_parser.dart @@ -27,7 +27,6 @@ class AnalysisOptionsParser { if (seenPaths.contains(path)) { return const RulesData.empty(); } - seenPaths.add(path); final yaml = _parseYaml(analysisOptionsFile); if (yaml == null) { @@ -37,10 +36,11 @@ class AnalysisOptionsParser { final mergedRules = >{}; final disabledRules = {}; + final nextSeenPaths = {...seenPaths, path}; _resolveAndMergeIncludes( analysisOptionsFile, yaml, - seenPaths, + nextSeenPaths, mergedRules, disabledRules, ); @@ -50,19 +50,34 @@ class AnalysisOptionsParser { return RulesData(rules: mergedRules, disabledRules: disabledRules); } - Map? _parseYaml(File file) { + Map? _parseYaml(File file) { try { final optionsString = file.readAsStringSync(); final parsed = loadYaml(optionsString); - return parsed is Map ? parsed : null; + if (parsed is! Map) return null; + return _toStandardMap(parsed); } catch (_) { return null; } } + Map _toStandardMap(Map map) { + return { + for (final entry in map.entries) + if (entry.key is String) + entry.key as String: _toStandardType(entry.value), + }; + } + + Object? _toStandardType(Object? value) => switch (value) { + Map() => _toStandardMap(value), + Iterable() => value.map(_toStandardType).toList(), + _ => value, + }; + void _resolveAndMergeIncludes( File baseFile, - Map yaml, + Map yaml, Set seenPaths, Map> mergedRules, Set disabledRules, @@ -97,7 +112,7 @@ class AnalysisOptionsParser { } void _parseRuleOptions( - Map yaml, + Map yaml, Map> mergedRules, Set disabledRules, ) { @@ -131,7 +146,7 @@ class AnalysisOptionsParser { } } - Object? _extractDiagnostics(Map yaml) { + Object? _extractDiagnostics(Map yaml) { final pluginConfig = yaml[kPluginName]; if (pluginConfig is Map) { return pluginConfig['diagnostics']; @@ -164,7 +179,7 @@ class AnalysisOptionsParser { /// from running, we parse this section and add suppressed rules /// to [disabledRules]. void _parseSuppressedErrors( - Map yaml, + Map yaml, Map> mergedRules, Set disabledRules, ) { diff --git a/lib/src/common/parameter_parser/package_config_resolver.dart b/lib/src/common/parameter_parser/package_config_resolver.dart index 4935c51d..7b59168e 100644 --- a/lib/src/common/parameter_parser/package_config_resolver.dart +++ b/lib/src/common/parameter_parser/package_config_resolver.dart @@ -114,11 +114,13 @@ class PackageConfigResolver { final rootUri = Uri.parse(rootUriString); final resolvedRootUri = rootUri.isAbsolute ? rootUri - : Uri.file(packageConfigPath).resolveUri(rootUri); + : _resourceProvider.pathContext + .toUri(packageConfigPath) + .resolveUri(rootUri); if (resolvedRootUri.isScheme('file')) { result[name] = _resourceProvider.pathContext.join( - resolvedRootUri.toFilePath(), + _resourceProvider.pathContext.fromUri(resolvedRootUri), packageUriString, ); } diff --git a/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart b/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart index 6494fcaf..bb18d04e 100644 --- a/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart +++ b/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart @@ -48,9 +48,14 @@ class AvoidUsingApiParameters { return AvoidUsingApiParameters( entries: List.from( (json['entries'] as Iterable?)?.map( - (e) => AvoidUsingApiEntryParameters.fromJson( - (e as YamlMap).toMap(), - ), + (e) { + if (e is YamlMap) { + return AvoidUsingApiEntryParameters.fromJson(e.toMap()); + } + return AvoidUsingApiEntryParameters.fromJson( + Map.from(e as Map), + ); + }, ) ?? [], ), From 930b5bc52b3a478984944ed84b59b0b694ba4091 Mon Sep 17 00:00:00 2001 From: Illia Aihistov Date: Fri, 10 Jul 2026 20:24:08 +0300 Subject: [PATCH 3/4] refactor: implement file-change detection for package configs and support multiple analysis option includes --- .../analysis_options_parser.dart | 61 +++++++++++++++---- .../cached_package_config.dart | 14 +++++ .../package_config_resolver.dart | 42 ++++++------- .../analysis_options_loader_test.dart | 55 ++++++++++++++++- 4 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 lib/src/common/parameter_parser/cached_package_config.dart diff --git a/lib/src/common/parameter_parser/analysis_options_parser.dart b/lib/src/common/parameter_parser/analysis_options_parser.dart index 2885cd3d..fc03a923 100644 --- a/lib/src/common/parameter_parser/analysis_options_parser.dart +++ b/lib/src/common/parameter_parser/analysis_options_parser.dart @@ -70,10 +70,10 @@ class AnalysisOptionsParser { } Object? _toStandardType(Object? value) => switch (value) { - Map() => _toStandardMap(value), - Iterable() => value.map(_toStandardType).toList(), - _ => value, - }; + Map() => _toStandardMap(value), + Iterable() => value.map(_toStandardType).toList(), + _ => value, + }; void _resolveAndMergeIncludes( File baseFile, @@ -83,13 +83,54 @@ class AnalysisOptionsParser { Set disabledRules, ) { final includeOption = yaml['include']; - if (includeOption is! String) return; + switch (includeOption) { + case String(): + _resolveAndMergeInclude( + baseFile, + includeOption, + seenPaths, + mergedRules, + disabledRules, + ); + case List(): + for (final include in includeOption) { + if (include is String) { + _resolveAndMergeInclude( + baseFile, + include, + seenPaths, + mergedRules, + disabledRules, + ); + } + } + } + } - final includedFile = _resolveIncludedFile(baseFile, includeOption); + void _resolveAndMergeInclude( + File baseFile, + String includePath, + Set seenPaths, + Map> mergedRules, + Set disabledRules, + ) { + final includedFile = _resolveIncludedFile(baseFile, includePath); if (includedFile == null) return; final includedData = _parseWithSeen(includedFile, seenPaths); - mergedRules.addAll(includedData.rules); + for (final entry in includedData.rules.entries) { + final ruleName = entry.key; + final includedOptions = entry.value; + final existingOptions = mergedRules[ruleName]; + if (existingOptions == null) { + mergedRules[ruleName] = Map.from(includedOptions); + } else { + mergedRules[ruleName] = { + ...existingOptions, + ...includedOptions, + }; + } + } disabledRules.addAll(includedData.disabledRules); } @@ -193,12 +234,10 @@ class AnalysisOptionsParser { for (final entry in errors.entries) { final key = entry.key; - if (key is! String) continue; + if (key is! String || !key.startsWith(pluginPrefix)) continue; final errorValue = entry.value; - final ruleName = key.startsWith(pluginPrefix) - ? key.substring(pluginPrefix.length) - : key; + final ruleName = key.substring(pluginPrefix.length); if (errorValue == 'ignore') { mergedRules.remove(ruleName); diff --git a/lib/src/common/parameter_parser/cached_package_config.dart b/lib/src/common/parameter_parser/cached_package_config.dart new file mode 100644 index 00000000..48fda09a --- /dev/null +++ b/lib/src/common/parameter_parser/cached_package_config.dart @@ -0,0 +1,14 @@ +/// Cached package configuration with its modification stamp. +class CachedPackageConfig { + /// The modification stamp of the package_config.json file when cached. + final int modificationStamp; + + /// The parsed package mappings (packageName -> absolute path to lib). + final Map packages; + + /// Creates a new instance of [CachedPackageConfig]. + const CachedPackageConfig({ + required this.modificationStamp, + required this.packages, + }); +} diff --git a/lib/src/common/parameter_parser/package_config_resolver.dart b/lib/src/common/parameter_parser/package_config_resolver.dart index 7b59168e..3f428e04 100644 --- a/lib/src/common/parameter_parser/package_config_resolver.dart +++ b/lib/src/common/parameter_parser/package_config_resolver.dart @@ -1,15 +1,13 @@ import 'dart:convert'; import 'package:analyzer/file_system/file_system.dart'; +import 'package:solid_lints/src/common/parameter_parser/cached_package_config.dart'; /// Helper class to resolve package: URIs to physical file system paths. class PackageConfigResolver { final ResourceProvider _resourceProvider; - // Caches directory path -> nearest package_config.json path - final Map _configPathCache = {}; - - // Caches package_config.json path -> Map - final Map> _packagesCache = {}; + // Caches package_config.json path -> CachedPackageConfig + final Map _packagesCache = {}; /// Creates a new instance of [PackageConfigResolver]. PackageConfigResolver(this._resourceProvider); @@ -37,15 +35,7 @@ class PackageConfigResolver { /// Finds the nearest `.dart_tool/package_config.json` file by walking up /// the directory tree starting from [startDirectoryPath]. - /// - /// Results (including `null`) are cached in [_configPathCache] to avoid - /// redundant filesystem walks. String? _findPackageConfig(String startDirectoryPath) { - final cached = _configPathCache[startDirectoryPath]; - if (cached != null || _configPathCache.containsKey(startDirectoryPath)) { - return cached; - } - final pathContext = _resourceProvider.pathContext; var currentDirectoryPath = startDirectoryPath; @@ -58,7 +48,7 @@ class PackageConfigResolver { final candidateFile = _resourceProvider.getFile(candidatePath); if (candidateFile.exists) { - return _configPathCache[startDirectoryPath] = candidatePath; + return candidatePath; } final parentDir = pathContext.dirname(currentDirectoryPath); @@ -68,24 +58,30 @@ class PackageConfigResolver { currentDirectoryPath = parentDir; } - return _configPathCache[startDirectoryPath] = null; + return null; } String? _resolvePackageRoot(String packageConfigPath, String packageName) { - var packageMap = _packagesCache[packageConfigPath]; - if (packageMap == null) { - packageMap = _parsePackageConfig(packageConfigPath); - _packagesCache[packageConfigPath] = packageMap; + final file = _resourceProvider.getFile(packageConfigPath); + final modificationStamp = file.exists ? file.modificationStamp : -1; + + var cached = _packagesCache[packageConfigPath]; + if (cached == null || cached.modificationStamp != modificationStamp) { + final packageMap = _parsePackageConfig(file); + cached = CachedPackageConfig( + modificationStamp: modificationStamp, + packages: packageMap, + ); + _packagesCache[packageConfigPath] = cached; } - return packageMap[packageName]; + return cached.packages[packageName]; } /// Reads and parses the `.dart_tool/package_config.json` configuration file. /// /// Returns a map associating each package name (e.g., 'lints') with its /// absolute physical path to the `lib/` directory on disk. - Map _parsePackageConfig(String packageConfigPath) { - final file = _resourceProvider.getFile(packageConfigPath); + Map _parsePackageConfig(File file) { if (!file.exists) return const {}; try { @@ -115,7 +111,7 @@ class PackageConfigResolver { final resolvedRootUri = rootUri.isAbsolute ? rootUri : _resourceProvider.pathContext - .toUri(packageConfigPath) + .toUri(file.path) .resolveUri(rootUri); if (resolvedRootUri.isScheme('file')) { diff --git a/test/src/common/parameter_parser/analysis_options_loader_test.dart b/test/src/common/parameter_parser/analysis_options_loader_test.dart index ebb93ee0..38b5279c 100644 --- a/test/src/common/parameter_parser/analysis_options_loader_test.dart +++ b/test/src/common/parameter_parser/analysis_options_loader_test.dart @@ -402,7 +402,7 @@ solid_lints: analyzer: errors: solid_lints/$_mockRuleThatNeedsConfigName: ignore - $_mockRule2Name: ignore + solid_lints/$_mockRule2Name: ignore ''', ); @@ -483,6 +483,59 @@ solid_lints: }); } + void test_options_merging_with_multiple_includes() { + final includedOptionsPath1 = '$testPackageRootPath/included_options_1.yaml'; + final includedOptionsPath2 = '$testPackageRootPath/included_options_2.yaml'; + newFile( + includedOptionsPath1, + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + param_a: val_a + param_b: val_b +''', + ); + newFile( + includedOptionsPath2, + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + param_b: val_b_2 + param_c: val_c +''', + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: + - included_options_1.yaml + - included_options_2.yaml +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: + param_c: local_val_c + param_d: val_d +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, { + 'param_a': 'val_a', + 'param_b': 'val_b_2', + 'param_c': 'local_val_c', + 'param_d': 'val_d', + }); + } + RuleContext _createMockContextForPackage( String packageRootPath, { RuleContextUnit? definingUnit, From bc38e859eb7baf42b5b05c0491d534690f4ff6d0 Mon Sep 17 00:00:00 2001 From: Illia Aihistov Date: Fri, 10 Jul 2026 20:38:12 +0300 Subject: [PATCH 4/4] fix: improve robustness of configuration parsing and URI resolution to prevent crashes on malformed input --- .../analysis_options_parser.dart | 35 +++++----- .../package_config_resolver.dart | 4 +- .../models/avoid_using_api_parameters.dart | 6 +- .../analysis_options_loader_test.dart | 67 +++++++++++++++++++ .../avoid_using_api_rule_test.dart | 17 +++++ 5 files changed, 109 insertions(+), 20 deletions(-) diff --git a/lib/src/common/parameter_parser/analysis_options_parser.dart b/lib/src/common/parameter_parser/analysis_options_parser.dart index fc03a923..d116d2be 100644 --- a/lib/src/common/parameter_parser/analysis_options_parser.dart +++ b/lib/src/common/parameter_parser/analysis_options_parser.dart @@ -167,22 +167,25 @@ class AnalysisOptionsParser { final ruleName = key; final value = entry.value; - if (value is Map) { - final existingOptions = mergedRules[ruleName] ?? {}; - mergedRules[ruleName] = { - ...existingOptions, - for (final optionEntry in value.entries) - if (optionEntry.key is String) - optionEntry.key as String: optionEntry.value, - }; - disabledRules.remove(ruleName); - } else if (value is bool) { - if (value) { + switch (value) { + case Map(): + final existingOptions = mergedRules[ruleName] ?? {}; + mergedRules[ruleName] = { + ...existingOptions, + for (final optionEntry in value.entries) + if (optionEntry.key is String) + optionEntry.key as String: optionEntry.value, + }; + disabledRules.remove(ruleName); + case bool(): + if (value) { + disabledRules.remove(ruleName); + } else { + mergedRules.remove(ruleName); + disabledRules.add(ruleName); + } + case null: disabledRules.remove(ruleName); - } else { - mergedRules.remove(ruleName); - disabledRules.add(ruleName); - } } } } @@ -239,7 +242,7 @@ class AnalysisOptionsParser { final errorValue = entry.value; final ruleName = key.substring(pluginPrefix.length); - if (errorValue == 'ignore') { + if (errorValue == 'ignore' || errorValue == false) { mergedRules.remove(ruleName); disabledRules.add(ruleName); } else { diff --git a/lib/src/common/parameter_parser/package_config_resolver.dart b/lib/src/common/parameter_parser/package_config_resolver.dart index 3f428e04..0c990733 100644 --- a/lib/src/common/parameter_parser/package_config_resolver.dart +++ b/lib/src/common/parameter_parser/package_config_resolver.dart @@ -17,8 +17,8 @@ class PackageConfigResolver { String? resolvePackageUri(String baseFilePath, String packageUri) { if (!packageUri.startsWith('package:')) return null; - final uri = Uri.parse(packageUri); - if (uri.pathSegments.isEmpty) return null; + final uri = Uri.tryParse(packageUri); + if (uri == null || uri.pathSegments.isEmpty) return null; final packageName = uri.pathSegments.first; final relativePath = uri.pathSegments.skip(1).join('/'); diff --git a/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart b/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart index bb18d04e..f132b23c 100644 --- a/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart +++ b/lib/src/lints/avoid_using_api/models/avoid_using_api_parameters.dart @@ -47,13 +47,15 @@ class AvoidUsingApiParameters { final avoidUsingApi = json['avoid_using_api']; return AvoidUsingApiParameters( entries: List.from( - (json['entries'] as Iterable?)?.map( + (json['entries'] as Iterable?) + ?.whereType>() + .map( (e) { if (e is YamlMap) { return AvoidUsingApiEntryParameters.fromJson(e.toMap()); } return AvoidUsingApiEntryParameters.fromJson( - Map.from(e as Map), + Map.from(e), ); }, ) ?? diff --git a/test/src/common/parameter_parser/analysis_options_loader_test.dart b/test/src/common/parameter_parser/analysis_options_loader_test.dart index 38b5279c..a47a26e0 100644 --- a/test/src/common/parameter_parser/analysis_options_loader_test.dart +++ b/test/src/common/parameter_parser/analysis_options_loader_test.dart @@ -354,6 +354,25 @@ include: package:shared/analysis_options.yaml expect(options, {'some_parameter': 'package_value'}); } + void test_resolve_include_malformed_package_uri() { + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: package:foo:bar/baz.yaml +''', + ); + + // Should not throw FormatException + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + final options = analysisOptionsLoader.getRuleOptions( + mockRuleContext, + _mockRuleThatNeedsConfigName, + ); + + expect(options, isNull); + } + void test_resolve_include_cyclic() { newAnalysisOptionsYamlFile( testPackageRootPath, @@ -418,6 +437,25 @@ analyzer: ); } + void test_isRuleDisabled_when_suppressed_in_analyzer_errors_as_false() { + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +analyzer: + errors: + solid_lints/$_mockRuleThatNeedsConfigName: false +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + expect( + analysisOptionsLoader.isRuleDisabled(mockRuleContext, _mockRuleThatNeedsConfigName), + isTrue, + ); + } + + void test_isRuleDisabled_when_suppressed_in_included_analyzer_errors() { final includedOptionsPath = '$testPackageRootPath/included_options.yaml'; newFile( @@ -444,6 +482,35 @@ include: included_options.yaml ); } + void test_isRuleDisabled_when_disabled_in_include_but_re_enabled_with_null() { + final includedOptionsPath = '$testPackageRootPath/included_options.yaml'; + newFile( + includedOptionsPath, + ''' +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: false +''', + ); + + newAnalysisOptionsYamlFile( + testPackageRootPath, + ''' +include: included_options.yaml +solid_lints: + diagnostics: + $_mockRuleThatNeedsConfigName: +''', + ); + + analysisOptionsLoader.loadRulesOptionsFromContext(mockRuleContext); + + expect( + analysisOptionsLoader.isRuleDisabled(mockRuleContext, _mockRuleThatNeedsConfigName), + isFalse, + ); + } + void test_options_merging_with_include() { final includedOptionsPath = '$testPackageRootPath/included_options.yaml'; newFile( diff --git a/test/src/lints/avoid_using_api/avoid_using_api_rule_test.dart b/test/src/lints/avoid_using_api/avoid_using_api_rule_test.dart index 09b070b3..780ddc8d 100644 --- a/test/src/lints/avoid_using_api/avoid_using_api_rule_test.dart +++ b/test/src/lints/avoid_using_api/avoid_using_api_rule_test.dart @@ -396,4 +396,21 @@ void fn() { expect(diagnostics[2].severity.name, 'info'); expect(diagnostics[3].severity.name, 'warning'); } + + Future test_does_not_crash_on_invalid_entry_types() async { + _configureRule(''' + - "not_a_map" + - 123 + - class_name: BadClass + source: package:my_dep/my_dep.dart + '''); + + return assertAutoDiagnostics(''' +import 'package:my_dep/my_dep.dart'; + +void fn() { + final ${expectLint('a')} = ${expectLint('BadClass')}(); +} +'''); + } }