add lint and quick-fixes for Money.amount.toString()

Robert Malikowski committed Aug 15, 2026 at 14:34 UTC 8d6b3cd85d8b28de39c77cd2676067d0850bbed1
3 files changed +159
cw_custom_lints/lib/main.dart
+8
@@ -3,6 +3,8 @@ import "package:analysis_server_plugin/registry.dart";
3 import "package:cw_custom_lints/http_force_proxy/http_force_proxy_rule.dart";
4 import "package:cw_custom_lints/modal_top_bar_semantics/modal_top_bar_semantics_rule.dart";
5 import "package:cw_custom_lints/modern_button_semantics/modern_button_semantics_rule.dart";
6 +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_fix.dart";
7 +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_rule.dart";
8 import "package:cw_custom_lints/print_verbose/print_verbose_fix.dart";
9 import "package:cw_custom_lints/print_verbose/print_verbose_rule.dart";
10 import "package:cw_custom_lints/restricted_imports/restricted_imports_rule.dart";
@@ -20,7 +22,13 @@ class CwCustomLintsPlugin extends Plugin {
22 registry.registerWarningRule(HttpForceProxyRule());
23 registry.registerWarningRule(ModernButtonSemanticsRule());
24 registry.registerWarningRule(ModalTopBarSemanticsRule());
25 + registry.registerWarningRule(MoneyAmountToStringRule());
26
27 registry.registerFixForRule(PrintVerboseRule.code, ReplaceWithPrintV.new);
28 + registry.registerFixForRule(MoneyAmountToStringRule.code, ReplaceWithMoneyToString.new);
29 + registry.registerFixForRule(
30 + MoneyAmountToStringRule.code,
31 + ReplaceWithMoneyToStringInBaseUnit.new,
32 + );
33 }
34 }
cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart new
+66
@@ -0,0 +1,66 @@
1 +import "package:analysis_server_plugin/edit/dart/correction_producer.dart";
2 +import "package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart";
3 +import "package:analyzer/dart/ast/ast.dart";
4 +import "package:analyzer_plugin/utilities/change_builder/change_builder_core.dart";
5 +import "package:analyzer_plugin/utilities/fixes/fixes.dart";
6 +import "package:analyzer_plugin/utilities/range_factory.dart";
7 +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_rule.dart";
8 +
9 +abstract class _ReplaceMoneyAmountToString extends ResolvedCorrectionProducer {
10 + _ReplaceMoneyAmountToString({required super.context});
11 +
12 + String get replacement;
13 +
14 + @override
15 + CorrectionApplicability get applicability => CorrectionApplicability.singleLocation;
16 +
17 + @override
18 + Future<void> compute(ChangeBuilder builder) async {
19 + final invocation = node.thisOrAncestorOfType<MethodInvocation>();
20 + if (invocation == null) {
21 + return;
22 + }
23 +
24 + final amount = moneyAmountReference(invocation.realTarget);
25 + if (amount == null) {
26 + return;
27 + }
28 +
29 + await builder.addDartFileEdit(
30 + file,
31 + (builder) => builder.addSimpleReplacement(range.startEnd(amount, invocation), replacement),
32 + );
33 + }
34 +}
35 +
36 +class ReplaceWithMoneyToString extends _ReplaceMoneyAmountToString {
37 + ReplaceWithMoneyToString({required super.context});
38 +
39 + static const _fixKind = FixKind(
40 + "dart.fix.replaceWithMoneyToString",
41 + DartFixKindPriority.standard,
42 + "Replace with toString() for a decimal amount",
43 + );
44 +
45 + @override
46 + FixKind get fixKind => _fixKind;
47 +
48 + @override
49 + String get replacement => "toString()";
50 +}
51 +
52 +class ReplaceWithMoneyToStringInBaseUnit extends _ReplaceMoneyAmountToString {
53 + ReplaceWithMoneyToStringInBaseUnit({required super.context});
54 +
55 + static const _fixKind = FixKind(
56 + "dart.fix.replaceWithMoneyToStringInBaseUnit",
57 + DartFixKindPriority.standard,
58 + "Replace with toStringWithPrecision(useBaseUnit: true) for base units",
59 + );
60 +
61 + @override
62 + FixKind get fixKind => _fixKind;
63 +
64 + @override
65 + String get replacement => "toStringWithPrecision(useBaseUnit: true)";
66 +}
cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart new
+85
@@ -0,0 +1,85 @@
1 +import "package:analyzer/analysis_rule/analysis_rule.dart";
2 +import "package:analyzer/analysis_rule/rule_context.dart";
3 +import "package:analyzer/analysis_rule/rule_visitor_registry.dart";
4 +import "package:analyzer/dart/ast/ast.dart";
5 +import "package:analyzer/dart/ast/visitor.dart";
6 +import "package:analyzer/dart/element/element.dart";
7 +import "package:analyzer/error/error.dart";
8 +
9 +class MoneyAmountToStringRule extends AnalysisRule {
10 + MoneyAmountToStringRule()
11 + : super(
12 + name: "no_money_amount_to_string",
13 + description:
14 + "Money.amount is the internal BigInt, so stringifying it drops the decimal point.",
15 + );
16 +
17 + static const LintCode code = LintCode(
18 + "no_money_amount_to_string",
19 + "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.",
20 + correctionMessage:
21 + "Call toString() on the Money for a decimal amount, or toStringWithPrecision(useBaseUnit: true) for base units.",
22 + severity: DiagnosticSeverity.WARNING,
23 + );
24 +
25 + @override
26 + LintCode get diagnosticCode => code;
27 +
28 + @override
29 + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) =>
30 + registry.addMethodInvocation(this, _Visitor(this));
31 +}
32 +
33 +
34 +SimpleIdentifier? moneyAmountReference(Expression? expression) {
35 + final identifier = switch (expression) {
36 + PrefixedIdentifier() => expression.identifier,
37 + PropertyAccess() => expression.propertyName,
38 + SimpleIdentifier() => expression,
39 + _ => null,
40 + };
41 +
42 + if (identifier == null || identifier.name != "amount") {
43 + return null;
44 + }
45 +
46 + return _isDeclaredByMoney(identifier.element) ? identifier : null;
47 +}
48 +
49 +bool _isDeclaredByMoney(Element? element) {
50 + if (element == null) {
51 + return false;
52 + }
53 +
54 + final enclosing = element.enclosingElement;
55 + if (enclosing is! InterfaceElement || enclosing.name != "Money") {
56 + return false;
57 + }
58 +
59 + final libraryUri = element.library?.uri;
60 + if (libraryUri == null || libraryUri.scheme != "package") {
61 + return false;
62 + }
63 +
64 + final segments = libraryUri.pathSegments;
65 + return segments.isNotEmpty && segments.first == "cw_core";
66 +}
67 +
68 +class _Visitor extends SimpleAstVisitor<void> {
69 + _Visitor(this.rule);
70 +
71 + final AnalysisRule rule;
72 +
73 + @override
74 + void visitMethodInvocation(MethodInvocation node) {
75 + if (node.methodName.name != "toString" || node.argumentList.arguments.isNotEmpty) {
76 + return;
77 + }
78 +
79 + if (moneyAmountReference(node.realTarget) == null) {
80 + return;
81 + }
82 +
83 + rule.reportAtNode(node);
84 + }
85 +}