[DAS] Convert related to cascade fix

R=scheglov@google.com

Fixes https://github.com/dart-lang/sdk/issues/52476

Change-Id: I64d81aaeb5286508e9b03869fd34fc592b0870e6
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/390421
Commit-Queue: Konstantin Shcheglov <scheglov@google.com>
Reviewed-by: Samuel Rawlins <srawlins@google.com>
Auto-Submit: Felipe Morschel <fmorschel.dev@gmail.com>
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
diff --git a/pkg/analysis_server/lib/src/services/correction/dart/convert_related_to_cascade.dart b/pkg/analysis_server/lib/src/services/correction/dart/convert_related_to_cascade.dart
new file mode 100644
index 0000000..818c0f7
--- /dev/null
+++ b/pkg/analysis_server/lib/src/services/correction/dart/convert_related_to_cascade.dart
@@ -0,0 +1,150 @@
+// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:analysis_server/src/services/correction/fix.dart';
+import 'package:analysis_server_plugin/edit/dart/correction_producer.dart';
+import 'package:analyzer/dart/ast/ast.dart';
+import 'package:analyzer/dart/ast/token.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:collection/collection.dart';
+import 'package:linter/src/lint_names.dart';
+
+class ConvertRelatedToCascade extends ResolvedCorrectionProducer {
+  final CorrectionProducerContext _context;
+
+  ConvertRelatedToCascade({required super.context}) : _context = context;
+
+  @override
+  CorrectionApplicability get applicability =>
+      // TODO(applicability): comment on why.
+      CorrectionApplicability.singleLocation;
+
+  @override
+  FixKind get fixKind => DartFixKind.CONVERT_RELATED_TO_CASCADE;
+
+  @override
+  Future<void> compute(ChangeBuilder builder) async {
+    var node = this.node;
+    if (node is! ExpressionStatement) return;
+
+    var block = node.parent;
+    if (block is! Block) return;
+
+    var errors = _context.dartFixContext?.resolvedResult.errors
+        .where((error) => error.errorCode.name == LintNames.cascade_invocations)
+        .whereNot((error) =>
+            error.offset == node.offset && error.length == node.length);
+
+    if (errors == null || errors.isEmpty) return;
+
+    var previous = _getPrevious(block, node);
+    var next = _getNext(block, node);
+
+    // Skip if no error has the offset and length of previous or next.
+    if (errors.none((error) =>
+            error.offset == previous?.offset &&
+            error.length == previous?.length) &&
+        errors.none((error) =>
+            error.offset == next?.offset && error.length == next?.length)) {
+      return;
+    }
+
+    // Get the full list of statements with errors that are related to this.
+    List<ExpressionStatement> relatedStatements = [node];
+    while (previous != null && previous is ExpressionStatement) {
+      if (errors.any((error) =>
+          error.offset == previous!.offset &&
+          error.length == previous.length)) {
+        relatedStatements.insert(0, previous);
+      }
+      previous = _getPrevious(block, previous);
+    }
+    while (next != null && next is ExpressionStatement) {
+      if (errors.any((error) =>
+          error.offset == next!.offset && error.length == next.length)) {
+        relatedStatements.add(next);
+      }
+      next = _getNext(block, next);
+    }
+
+    for (var (index, statement) in relatedStatements.indexed) {
+      Token? previousOperator;
+      Token? semicolon;
+      var previous = index > 0
+          ? relatedStatements[index - 1]
+          : _getPrevious(block, statement);
+      if (previous is ExpressionStatement) {
+        semicolon = previous.semicolon;
+        previousOperator = (index == 0)
+            ? _getTargetAndOperator(previous.expression)?.operator
+            : null;
+      } else if (previous is VariableDeclarationStatement) {
+        // Single variable declaration.
+        if (previous.variables.variables.length != 1) {
+          return;
+        }
+        semicolon = previous.endToken;
+      } else {
+        // TODO(fmorschel): Refactor this to collect all changes and apply them
+        // at once.
+        // One unfortunate consequence of this approach is that we might have
+        // already used [builder.addDartFileEdit], and so we could stop with
+        // incomplete changes.
+        // In the future there could be other cases for triggering this fix
+        // other than `ExpressionStatement` and `VariableDeclarationStatement`.
+        return;
+      }
+
+      var expression = statement.expression;
+      var target = _getTargetAndOperator(expression)?.target;
+      if (target == null) return;
+
+      var targetReplacement = expression is CascadeExpression ? '' : '.';
+
+      await builder.addDartFileEdit(file, (builder) {
+        if (previousOperator != null) {
+          builder.addSimpleInsertion(previousOperator.offset, '.');
+        }
+        if (semicolon != null) {
+          builder.addDeletion(range.token(semicolon));
+        }
+        builder.addSimpleReplacement(range.node(target), targetReplacement);
+      });
+    }
+  }
+
+  Statement? _getNext(Block block, Statement statement) {
+    var statements = block.statements;
+    var index = statements.indexOf(statement);
+    return index < (statements.length - 1) ? statements[index + 1] : null;
+  }
+
+  Statement? _getPrevious(Block block, Statement statement) {
+    var statements = block.statements;
+    var index = statements.indexOf(statement);
+    return index > 0 ? statements[index - 1] : null;
+  }
+
+  _TargetAndOperator? _getTargetAndOperator(Expression expression) {
+    if (expression is AssignmentExpression) {
+      var lhs = expression.leftHandSide;
+      if (lhs is PrefixedIdentifier) {
+        return _TargetAndOperator(lhs.prefix, lhs.period);
+      }
+    } else if (expression is MethodInvocation) {
+      return _TargetAndOperator(expression.target, expression.operator);
+    } else if (expression is CascadeExpression) {
+      return _TargetAndOperator(expression.target, null);
+    }
+    return null;
+  }
+}
+
+class _TargetAndOperator {
+  final AstNode? target;
+  final Token? operator;
+  _TargetAndOperator(this.target, this.operator);
+}
diff --git a/pkg/analysis_server/lib/src/services/correction/dart/convert_to_cascade.dart b/pkg/analysis_server/lib/src/services/correction/dart/convert_to_cascade.dart
index df02ecc..bc255e1 100644
--- a/pkg/analysis_server/lib/src/services/correction/dart/convert_to_cascade.dart
+++ b/pkg/analysis_server/lib/src/services/correction/dart/convert_to_cascade.dart
@@ -84,7 +84,7 @@
 }
 
 class _TargetAndOperator {
-  AstNode? target;
-  Token? operator;
+  final AstNode? target;
+  final Token? operator;
   _TargetAndOperator(this.target, this.operator);
 }
diff --git a/pkg/analysis_server/lib/src/services/correction/fix.dart b/pkg/analysis_server/lib/src/services/correction/fix.dart
index 797ddeb..b78d905 100644
--- a/pkg/analysis_server/lib/src/services/correction/fix.dart
+++ b/pkg/analysis_server/lib/src/services/correction/fix.dart
@@ -417,6 +417,11 @@
     DartFixKindPriority.inFile,
     'Convert the quotes and remove escapes everywhere in file',
   );
+  static const CONVERT_RELATED_TO_CASCADE = FixKind(
+    'dart.fix.convert.relatedToCascade',
+    DartFixKindPriority.standard + 1,
+    'Convert this and related to cascade notation',
+  );
   static const CONVERT_TO_BOOL_EXPRESSION = FixKind(
     'dart.fix.convert.toBoolExpression',
     DartFixKindPriority.standard,
diff --git a/pkg/analysis_server/lib/src/services/correction/fix_internal.dart b/pkg/analysis_server/lib/src/services/correction/fix_internal.dart
index ba8c3ac..b0f0419 100644
--- a/pkg/analysis_server/lib/src/services/correction/fix_internal.dart
+++ b/pkg/analysis_server/lib/src/services/correction/fix_internal.dart
@@ -54,6 +54,7 @@
 import 'package:analysis_server/src/services/correction/dart/convert_into_is_not.dart';
 import 'package:analysis_server/src/services/correction/dart/convert_map_from_iterable_to_for_literal.dart';
 import 'package:analysis_server/src/services/correction/dart/convert_quotes.dart';
+import 'package:analysis_server/src/services/correction/dart/convert_related_to_cascade.dart';
 import 'package:analysis_server/src/services/correction/dart/convert_to_boolean_expression.dart';
 import 'package:analysis_server/src/services/correction/dart/convert_to_cascade.dart';
 import 'package:analysis_server/src/services/correction/dart/convert_to_constant_pattern.dart';
@@ -367,6 +368,7 @@
   ],
   LinterLintCode.cascade_invocations: [
     ConvertToCascade.new,
+    ConvertRelatedToCascade.new,
   ],
   LinterLintCode.cast_nullable_to_non_nullable: [
     AddNullCheck.withoutAssignabilityCheck,
diff --git a/pkg/analysis_server/test/src/services/correction/fix/convert_related_to_cascade_test.dart b/pkg/analysis_server/test/src/services/correction/fix/convert_related_to_cascade_test.dart
new file mode 100644
index 0000000..38253ff
--- /dev/null
+++ b/pkg/analysis_server/test/src/services/correction/fix/convert_related_to_cascade_test.dart
@@ -0,0 +1,209 @@
+// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:analysis_server/src/services/correction/fix.dart';
+import 'package:analyzer_plugin/utilities/fixes/fixes.dart';
+import 'package:linter/src/lint_names.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'fix_processor.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(ConvertRelatedToCascadeTest);
+  });
+}
+
+@reflectiveTest
+class ConvertRelatedToCascadeTest extends FixProcessorLintTest {
+  @override
+  FixKind get kind => DartFixKind.CONVERT_RELATED_TO_CASCADE;
+
+  @override
+  String get lintCode => LintNames.cascade_invocations;
+
+  Future<void> test_declaration_method_method() async {
+    await resolveTestCode('''
+class A {
+  void m() {}
+}
+void f() {
+  final a = A();
+  a.m();
+  a.m();
+}
+''');
+    await assertHasFix('''
+class A {
+  void m() {}
+}
+void f() {
+  final a = A()
+  ..m()
+  ..m();
+}
+''', errorFilter: (error) => error.offset == testCode.indexOf('a.m();'));
+  }
+
+  Future<void> test_method_property_method() async {
+    await resolveTestCode('''
+class A {
+  void m() {}
+  int? x;
+}
+void f(A a) {
+  a.m();
+  a.x = 1;
+  a.m();
+}
+''');
+    await assertHasFix('''
+class A {
+  void m() {}
+  int? x;
+}
+void f(A a) {
+  a..m()
+  ..x = 1
+  ..m();
+}
+''', errorFilter: (error) => error.offset == testCode.indexOf('a.x = 1'));
+  }
+
+  Future<void> test_multipleDeclaration_first_method() async {
+    await resolveTestCode('''
+class A {
+  void m() {}
+}
+void f() {
+  final a = A(), a2 = A();
+  a.m();
+}
+''');
+    await assertNoFix();
+  }
+
+  Future<void> test_multipleDeclaration_last_method() async {
+    await resolveTestCode('''
+class A {
+  void m() {}
+}
+void f() {
+  final a = A(), a2 = A();
+  a2.m();
+}
+''');
+    await assertNoFix();
+  }
+
+  Future<void> test_property_cascadeMethod_cascadeMethod() async {
+    await resolveTestCode('''
+class A {
+  void m() {}
+  int? x;
+}
+
+void f(A a) {
+  a.x = 1;
+  a..m();
+  a..m();
+}
+''');
+    await assertHasFix('''
+class A {
+  void m() {}
+  int? x;
+}
+
+void f(A a) {
+  a..x = 1
+  ..m()
+  ..m();
+}
+''', errorFilter: (error) => error.offset == testCode.indexOf('a..m();'));
+  }
+
+  Future<void> test_property_property_method_method_fisrt() async {
+    await resolveTestCode('''
+class A {
+  void m(int _) {}
+  int? x;
+}
+
+void f(A a) {
+  a..x = 1
+  ..x = 2;
+  a.m(1);
+  a.m(2);
+}
+''');
+    await assertHasFix('''
+class A {
+  void m(int _) {}
+  int? x;
+}
+
+void f(A a) {
+  a..x = 1
+  ..x = 2
+  ..m(1)
+  ..m(2);
+}
+''', errorFilter: (error) => error.offset == testCode.indexOf('a.m(1)'));
+  }
+
+  Future<void> test_property_property_method_method_last() async {
+    await resolveTestCode('''
+class A {
+  void m(int _) {}
+  int? x;
+}
+
+void f(A a) {
+  a..x = 1
+  ..x = 2;
+  a.m(1);
+  a.m(2);
+}
+''');
+    await assertHasFix('''
+class A {
+  void m(int _) {}
+  int? x;
+}
+
+void f(A a) {
+  a..x = 1
+  ..x = 2
+  ..m(1)
+  ..m(2);
+}
+''', errorFilter: (error) => error.offset == testCode.indexOf('a.m(2)'));
+  }
+
+  Future<void> test_property_property_property() async {
+    await resolveTestCode('''
+class A {
+  void m() {}
+  int? x;
+}
+void f(A a) {
+  a.x = 1;
+  a.x = 2;
+  a.x = 3;
+}
+''');
+    await assertHasFix('''
+class A {
+  void m() {}
+  int? x;
+}
+void f(A a) {
+  a..x = 1
+  ..x = 2
+  ..x = 3;
+}
+''', errorFilter: (error) => error.offset == testCode.indexOf('a.x = 2;'));
+  }
+}
diff --git a/pkg/analysis_server/test/src/services/correction/fix/test_all.dart b/pkg/analysis_server/test/src/services/correction/fix/test_all.dart
index 0244b36..c716d6e 100644
--- a/pkg/analysis_server/test/src/services/correction/fix/test_all.dart
+++ b/pkg/analysis_server/test/src/services/correction/fix/test_all.dart
@@ -68,6 +68,7 @@
 import 'convert_into_expression_body_test.dart' as convert_into_expression_body;
 import 'convert_into_is_not_test.dart' as convert_into_is_not;
 import 'convert_quotes_test.dart' as convert_quotes;
+import 'convert_related_to_cascade_test.dart' as convert_related_to_cascade;
 import 'convert_to_block_function_body_test.dart'
     as convert_to_block_function_body;
 import 'convert_to_boolean_expression_test.dart'
@@ -359,6 +360,7 @@
     convert_into_expression_body.main();
     convert_into_is_not.main();
     convert_quotes.main();
+    convert_related_to_cascade.main();
     convert_to_block_function_body.main();
     convert_to_boolean_expression.main();
     convert_to_cascade.main();