diff --git a/cw_custom_lints/lib/main.dart b/cw_custom_lints/lib/main.dart index e57e22525..1bf70f6aa 100644 --- a/cw_custom_lints/lib/main.dart +++ b/cw_custom_lints/lib/main.dart @@ -3,6 +3,8 @@ import "package:analysis_server_plugin/registry.dart"; import "package:cw_custom_lints/http_force_proxy/http_force_proxy_rule.dart"; import "package:cw_custom_lints/modal_top_bar_semantics/modal_top_bar_semantics_rule.dart"; import "package:cw_custom_lints/modern_button_semantics/modern_button_semantics_rule.dart"; +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_fix.dart"; +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_rule.dart"; import "package:cw_custom_lints/print_verbose/print_verbose_fix.dart"; import "package:cw_custom_lints/print_verbose/print_verbose_rule.dart"; import "package:cw_custom_lints/restricted_imports/restricted_imports_rule.dart"; @@ -20,7 +22,13 @@ class CwCustomLintsPlugin extends Plugin { registry.registerWarningRule(HttpForceProxyRule()); registry.registerWarningRule(ModernButtonSemanticsRule()); registry.registerWarningRule(ModalTopBarSemanticsRule()); + registry.registerWarningRule(MoneyAmountToStringRule()); registry.registerFixForRule(PrintVerboseRule.code, ReplaceWithPrintV.new); + registry.registerFixForRule(MoneyAmountToStringRule.code, ReplaceWithMoneyToString.new); + registry.registerFixForRule( + MoneyAmountToStringRule.code, + ReplaceWithMoneyToStringInBaseUnit.new, + ); } } diff --git a/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart new file mode 100644 index 000000000..3aad8152a --- /dev/null +++ b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart @@ -0,0 +1,66 @@ +import "package:analysis_server_plugin/edit/dart/correction_producer.dart"; +import "package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer_plugin/utilities/change_builder/change_builder_core.dart"; +import "package:analyzer_plugin/utilities/fixes/fixes.dart"; +import "package:analyzer_plugin/utilities/range_factory.dart"; +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_rule.dart"; + +abstract class _ReplaceMoneyAmountToString extends ResolvedCorrectionProducer { + _ReplaceMoneyAmountToString({required super.context}); + + String get replacement; + + @override + CorrectionApplicability get applicability => CorrectionApplicability.singleLocation; + + @override + Future compute(ChangeBuilder builder) async { + final invocation = node.thisOrAncestorOfType(); + if (invocation == null) { + return; + } + + final amount = moneyAmountReference(invocation.realTarget); + if (amount == null) { + return; + } + + await builder.addDartFileEdit( + file, + (builder) => builder.addSimpleReplacement(range.startEnd(amount, invocation), replacement), + ); + } +} + +class ReplaceWithMoneyToString extends _ReplaceMoneyAmountToString { + ReplaceWithMoneyToString({required super.context}); + + static const _fixKind = FixKind( + "dart.fix.replaceWithMoneyToString", + DartFixKindPriority.standard, + "Replace with toString() for a decimal amount", + ); + + @override + FixKind get fixKind => _fixKind; + + @override + String get replacement => "toString()"; +} + +class ReplaceWithMoneyToStringInBaseUnit extends _ReplaceMoneyAmountToString { + ReplaceWithMoneyToStringInBaseUnit({required super.context}); + + static const _fixKind = FixKind( + "dart.fix.replaceWithMoneyToStringInBaseUnit", + DartFixKindPriority.standard, + "Replace with toStringWithPrecision(useBaseUnit: true) for base units", + ); + + @override + FixKind get fixKind => _fixKind; + + @override + String get replacement => "toStringWithPrecision(useBaseUnit: true)"; +} diff --git a/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart new file mode 100644 index 000000000..3a7e58917 --- /dev/null +++ b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart @@ -0,0 +1,85 @@ +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/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/dart/element/element.dart"; +import "package:analyzer/error/error.dart"; + +class MoneyAmountToStringRule extends AnalysisRule { + MoneyAmountToStringRule() + : super( + name: "no_money_amount_to_string", + description: + "Money.amount is the internal BigInt, so stringifying it drops the decimal point.", + ); + + static const LintCode code = LintCode( + "no_money_amount_to_string", + "This stringifies the internal BigInt of Money instead of the Money itself, so the decimal point is dropped and the amount reads far larger than it is.", + correctionMessage: + "Call toString() on the Money for a decimal amount, or toStringWithPrecision(useBaseUnit: true) for base units.", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) => + registry.addMethodInvocation(this, _Visitor(this)); +} + + +SimpleIdentifier? moneyAmountReference(Expression? expression) { + final identifier = switch (expression) { + PrefixedIdentifier() => expression.identifier, + PropertyAccess() => expression.propertyName, + SimpleIdentifier() => expression, + _ => null, + }; + + if (identifier == null || identifier.name != "amount") { + return null; + } + + return _isDeclaredByMoney(identifier.element) ? identifier : null; +} + +bool _isDeclaredByMoney(Element? element) { + if (element == null) { + return false; + } + + final enclosing = element.enclosingElement; + if (enclosing is! InterfaceElement || enclosing.name != "Money") { + return false; + } + + final libraryUri = element.library?.uri; + if (libraryUri == null || libraryUri.scheme != "package") { + return false; + } + + final segments = libraryUri.pathSegments; + return segments.isNotEmpty && segments.first == "cw_core"; +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name != "toString" || node.argumentList.arguments.isNotEmpty) { + return; + } + + if (moneyAmountReference(node.realTarget) == null) { + return; + } + + rule.reportAtNode(node); + } +}