Add "Add 'late' hint" feature

Tested with:

* top-level variables
* local variables
* instance and static fields declared in classes and mixins (enhanced enums are not supported pre-null safety)

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

Change-Id: I8a89668fffe64fff508d0aeb36aaebef5c84f223
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/263460
Reviewed-by: Paul Berry <paulberry@google.com>
Commit-Queue: Samuel Rawlins <srawlins@google.com>
diff --git a/pkg/nnbd_migration/lib/src/front_end/info_builder.dart b/pkg/nnbd_migration/lib/src/front_end/info_builder.dart
index beb0e96..9e43729 100644
--- a/pkg/nnbd_migration/lib/src/front_end/info_builder.dart
+++ b/pkg/nnbd_migration/lib/src/front_end/info_builder.dart
@@ -8,6 +8,7 @@
 import 'package:analyzer/dart/analysis/results.dart';
 import 'package:analyzer/dart/ast/ast.dart';
 import 'package:analyzer/file_system/file_system.dart';
+import 'package:analyzer/src/dart/ast/utilities.dart';
 import 'package:analyzer_plugin/protocol/protocol_common.dart'
     show SourceFileEdit;
 import 'package:analyzer_plugin/protocol/protocol_common.dart' as protocol;
@@ -225,6 +226,13 @@
       case NullabilityFixKind.typeNotMadeNullable:
         edits.add(EditDetail('Add /*!*/ hint', offset, 0, '/*!*/'));
         edits.add(EditDetail('Add /*?*/ hint', offset, 0, '/*?*/'));
+        var declarationList = _findVariableDeclaration(result.unit, offset);
+        if (declarationList != null) {
+          var lateOffset = _offsetForPossibleLateModifier(declarationList);
+          if (lateOffset != null) {
+            edits.add(EditDetail('Add late hint', lateOffset, 0, '/*late*/'));
+          }
+        }
         break;
       case NullabilityFixKind.makeTypeNullableDueToHint:
         edits.add(changeHint('Change to /*!*/ hint', '/*!*/'));
@@ -504,6 +512,18 @@
     return null;
   }
 
+  /// Returns the variable declaration which covers [offset], or `null` if none
+  /// does.
+  VariableDeclarationList? _findVariableDeclaration(
+      CompilationUnit unit, int offset) {
+    var nodeLocator = NodeLocator2(offset);
+    var node = nodeLocator.searchWithin(unit);
+    if (node == null) {
+      return null;
+    }
+    return node.thisOrAncestorOfType<VariableDeclarationList>();
+  }
+
   TraceEntryInfo _makeTraceEntry(
       String description, CodeReference? codeReference,
       {List<HintAction> hintActions = const []}) {
@@ -527,6 +547,34 @@
             .toList());
   }
 
+  /// Returns the offset for a possible `late` modifier which could be inserted
+  /// into [declarationList], or `null` if none is possible.
+  int? _offsetForPossibleLateModifier(VariableDeclarationList declarationList) {
+    if (declarationList.isLate || declarationList.isConst) {
+      // Don't offer an ofset.
+      return null;
+    }
+    var keyword = declarationList.keyword;
+    if (keyword != null) {
+      // Offset for possible `late` is before `var`, `const`, or `final`.
+      return keyword.offset;
+    }
+
+    var typeAnnotation = declarationList.type;
+    if (typeAnnotation != null) {
+      // Without a `keyword`, offset for possible `late` is before the type
+      // annotation.
+      return typeAnnotation.offset;
+    }
+
+    assert(
+        false,
+        'In this VariableDeclarationList, there is no `var`, '
+        '`const`, or `final` keyword, nor any type annotation. This variable '
+        'declaration list is not valid: $declarationList');
+    return null;
+  }
+
   TraceEntryInfo _stepToTraceEntry(PropagationStepInfo step) {
     var description = step.edge?.description;
     description ??= step.toString(); // TODO(paulberry): improve this message.
diff --git a/pkg/nnbd_migration/test/front_end/info_builder_test.dart b/pkg/nnbd_migration/test/front_end/info_builder_test.dart
index 9bfd701..7e92ff9 100644
--- a/pkg/nnbd_migration/test/front_end/info_builder_test.dart
+++ b/pkg/nnbd_migration/test/front_end/info_builder_test.dart
@@ -384,6 +384,192 @@
             replacement: ''));
   }
 
+  Future<void> test_addLateHint_classMultipleTypedInstanceVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+class C {
+  int f, g;
+  C() {
+    f = 1;
+  }
+}
+''', migratedContent: '''
+class C {
+  int? f, g;
+  C() {
+    f = 1;
+  }
+}
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 15,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 12, length: 0, replacement: '/*late*/');
+  }
+
+  Future<void> test_addLateHint_classTypedFinalInstanceVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+class C {
+  final int f;
+  C({this.f});
+}
+''', migratedContent: '''
+class C {
+  final int? f;
+  C({this.f});
+}
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 21,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 12, length: 0, replacement: '/*late*/');
+  }
+
+  Future<void> test_addLateHint_classTypedInstanceVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+class C {
+  int f;
+  C() {
+    f = 1;
+  }
+}
+''', migratedContent: '''
+class C {
+  int? f;
+  C() {
+    f = 1;
+  }
+}
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 15,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 12, length: 0, replacement: '/*late*/');
+  }
+
+  Future<void> test_addLateHint_classTypedStaticVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+class C {
+  static int x;
+  void f() => x = null;
+}
+''', migratedContent: '''
+class C {
+  static int? x;
+  void f() => x = null;
+}
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 22,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 19, length: 0, replacement: '/*late*/');
+  }
+
+  Future<void> test_addLateHint_mixinVarInstanceVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+mixin M {
+  int f;
+  void m() => f = null;
+}
+''', migratedContent: '''
+mixin M {
+  int? f;
+  void m() => f = null;
+}
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 15,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 12, length: 0, replacement: '/*late*/');
+  }
+
+  Future<void> test_addLateHint_typedLocalVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+void f() {
+  int x;
+  void g() => x = null;
+}
+''', migratedContent: '''
+void f() {
+  int? x;
+  void g() => x = null;
+}
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 16,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 13, length: 0, replacement: '/*late*/');
+  }
+
+  Future<void> test_addLateHint_typedTopLevelVariable() async {
+    addMetaPackage();
+    var unit = await buildInfoForSingleTestFile('''
+int x;
+void f() => x = null;
+''', migratedContent: '''
+int? x;
+void f() => x = null;
+''');
+    var regions = unit.fixRegions;
+    expect(regions, hasLength(1));
+    var region = regions[0];
+    var edits = region.edits;
+    assertRegion(
+        region: region,
+        offset: 3,
+        length: 1,
+        explanation: "Changed type 'int' to be nullable",
+        kind: NullabilityFixKind.makeTypeNullable);
+    assertEdit(edit: edits[2], offset: 0, length: 0, replacement: '/*late*/');
+  }
+
   Future<void> test_compound_assignment_nullable_result() async {
     var unit = await buildInfoForSingleTestFile('''
 abstract class C {
@@ -1623,7 +1809,7 @@
     expect(region.lineNumber, 1);
     expect(region.explanation, "Type 'int' was not made nullable");
     expect(region.edits.map((edit) => edit.description).toSet(),
-        {'Add /*?*/ hint', 'Add /*!*/ hint'});
+        {'Add /*?*/ hint', 'Add /*!*/ hint', 'Add late hint'});
     var trace = region.traces.first;
     expect(trace.description, 'Non-nullability reason');
     var entries = trace.entries;