Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FED-3248 Update non-defaulted state mixin fields to be optional #298

Merged
merged 6 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2024 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart';
import 'package:over_react_codemod/src/util.dart';
import 'package:analyzer/dart/ast/ast.dart';

import '../../util/class_suggestor.dart';

/// Suggestor to assist with preparations for null-safety by adding
/// nullability (`?`) hints to state field types.
///
/// This is intended to be run after [ClassComponentRequiredInitialStateMigrator]
/// to make the rest of the state fields nullable.
class StateMixinSuggestor extends RecursiveAstVisitor<void>
with ClassSuggestor {
@override
void visitVariableDeclaration(VariableDeclaration node) {
super.visitVariableDeclaration(node);

final isStateClass = (node.declaredElement?.enclosingElement
?.tryCast<InterfaceElement>()
?.allSupertypes
.any((s) => s.element.name == 'UiState') ??
false);
if (!isStateClass) return;

final fieldDeclaration = node.parentFieldDeclaration;
if (fieldDeclaration == null) return;
if (fieldDeclaration.isStatic) return;
if (fieldDeclaration.fields.isConst) return;

final type = fieldDeclaration.fields.type;
if (type != null &&
(requiredHintAlreadyExists(type) || nullableHintAlreadyExists(type))) {
return;
}

// Make state field optional.
if (type != null) {
yieldPatch(nullableHint, type.end, type.end);
}
}

@override
Future<void> generatePatches() async {
final r = await context.getResolvedUnit();
if (r == null) {
throw Exception(
'Could not get resolved result for "${context.relativePath}"');
}
r.unit.accept(this);
}
}
39 changes: 34 additions & 5 deletions lib/src/executables/null_safety_migrator_companion.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_c
import 'package:over_react_codemod/src/util.dart';

import '../dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart';
import '../dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart';
import '../util/package_util.dart';

const _changesRequiredOutput = """
To update your code, run the following commands in your repository:
pub global activate over_react_codemod
pub global run over_react_codemod:null_safety_prep
dart pub global activate over_react_codemod
dart pub global run over_react_codemod:null_safety_migrator_companion
""";

/// Codemods in this executable add nullability "hints" to assist with a
Expand All @@ -36,14 +38,41 @@ void main(List<String> args) async {
final parser = ArgParser.allowAnything();

final parsedArgs = parser.parse(args);
final packageRoot = findPackageRootFor('.');
await runPubGetIfNeeded(packageRoot);
final dartPaths = allDartPathsExceptHiddenAndGenerated();

exitCode = await runInteractiveCodemod(
exitCode = await runInteractiveCodemodSequence(
greglittlefield-wf marked this conversation as resolved.
Show resolved Hide resolved
dartPaths,
aggregate([
[
CallbackRefHintSuggestor(),
],
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);

if (exitCode != 0) return;

exitCode = await runInteractiveCodemodSequence(
dartPaths,
[
ClassComponentRequiredInitialStateMigrator(),
]),
],
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);

if (exitCode != 0) return;

exitCode = await runInteractiveCodemodSequence(
dartPaths,
[
StateMixinSuggestor(),
],
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
Expand Down
151 changes: 151 additions & 0 deletions test/dart3_suggestors/null_safety_prep/state_mixin_suggestor_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright 2024 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart';
import 'package:test/test.dart';

import '../../resolved_file_context.dart';
import '../../util.dart';
import '../../util/component_usage_migrator_test.dart' show withOverReactImport;

void main() {
final resolvedContext = SharedAnalysisContext.overReact;

// Warm up analysis in a setUpAll so that if getting the resolved AST times out
// (which is more common for the WSD context), it fails here instead of failing the first test.
setUpAll(resolvedContext.warmUpAnalysis);

group('StateMixinSuggestor', () {
late SuggestorTester testSuggestor;

setUp(() {
testSuggestor = getSuggestorTester(
StateMixinSuggestor(),
resolvedContext: resolvedContext,
);
});

test('patches state fields in mixins', () async {
await testSuggestor(
expectedPatchCount: 3,
input: withOverReactImport(/*language=dart*/ r'''
// ignore: undefined_identifier
UiFactory<FooProps> Foo = castUiFactory(_$Foo);
mixin FooProps on UiProps {
String prop1;
}
mixin FooStateMixin on UiState {
String state1;
num state2;
/// This is a doc comment
/*late*/ String/*!*/ alreadyPatched;
/*late*/ String/*?*/ alreadyPatchedButNoDocComment;
String/*?*/ alreadyPatchedOptional;
}
mixin SomeOtherStateMixin on UiState {
String state3;
String/*?*/ alreadyPatchedOptional2;
}
class FooState = UiState with FooStateMixin, SomeOtherStateMixin;
class FooComponent extends UiStatefulComponent2<FooProps, FooState> {
@override
render() => null;
}
'''),
expectedOutput: withOverReactImport(/*language=dart*/ r'''
// ignore: undefined_identifier
UiFactory<FooProps> Foo = castUiFactory(_$Foo);
mixin FooProps on UiProps {
String prop1;
}
mixin FooStateMixin on UiState {
String/*?*/ state1;
num/*?*/ state2;
/// This is a doc comment
/*late*/ String/*!*/ alreadyPatched;
/*late*/ String/*?*/ alreadyPatchedButNoDocComment;
String/*?*/ alreadyPatchedOptional;
}
mixin SomeOtherStateMixin on UiState {
String/*?*/ state3;
String/*?*/ alreadyPatchedOptional2;
}
class FooState = UiState with FooStateMixin, SomeOtherStateMixin;
class FooComponent extends UiStatefulComponent2<FooProps, FooState> {
@override
render() => null;
}
'''),
);
});

test('patches state fields in legacy classes', () async {
await testSuggestor(
expectedPatchCount: 3,
input: withOverReactImport(/*language=dart*/ r'''
@Factory()
UiFactory<FooProps> Foo = _$Foo; // ignore: undefined_identifier
@Props()
class FooProps extends UiProps {
String prop1;
}
@StateMixin()
mixin SomeOtherStateMixin on UiState {
num state1;
}
@State()
class FooState extends UiState with SomeOtherStateMixin {
String state2;
num state3;
/// This is a doc comment
/*late*/ String/*!*/ alreadyPatched;
/*late*/ String/*?*/ alreadyPatchedButNoDocComment;
String/*?*/ alreadyPatchedOptional;
}
@Component()
class FooComponent extends UiStatefulComponent<FooProps, FooState> {
@override
render() => null;
}
'''),
expectedOutput: withOverReactImport(/*language=dart*/ r'''
@Factory()
UiFactory<FooProps> Foo = _$Foo; // ignore: undefined_identifier
@Props()
class FooProps extends UiProps {
String prop1;
}
@StateMixin()
mixin SomeOtherStateMixin on UiState {
num/*?*/ state1;
}
@State()
class FooState extends UiState with SomeOtherStateMixin {
String/*?*/ state2;
num/*?*/ state3;
/// This is a doc comment
/*late*/ String/*!*/ alreadyPatched;
/*late*/ String/*?*/ alreadyPatchedButNoDocComment;
String/*?*/ alreadyPatchedOptional;
}
@Component()
class FooComponent extends UiStatefulComponent<FooProps, FooState> {
@override
render() => null;
}
'''),
);
});
});
}
75 changes: 75 additions & 0 deletions test/executables/null_safety_migrator_companion_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2024 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:over_react_codemod/src/util/package_util.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;

import 'required_props_collect_and_codemod_test.dart';

void main() {
group('null_safety_migrator_companion codemod, end-to-end behavior:', () {
final companionScript = p.join(findPackageRootFor(p.current),
'bin/null_safety_migrator_companion.dart');

const name = 'test_package';
late d.DirectoryDescriptor projectDir;

setUp(() async {
projectDir = d.DirectoryDescriptor.fromFilesystem(
name,
p.join(findPackageRootFor(p.current),
'test/test_fixtures/required_props/test_package'));
await projectDir.create();
});

test('adds hints as expected in different cases', () async {
await testCodemod(
script: companionScript,
args: [
'--yes-to-all',
],
input: projectDir,
expectedOutput: d.dir(projectDir.name, [
d.dir('lib', [
d.dir('src', [
d.file('test_state.dart', contains('''
mixin FooProps on UiProps {
int prop1;
}

mixin FooState on UiState {
String/*?*/ state1;
/*late*/ int/*!*/ initializedState;
void Function()/*?*/ state2;
}

class FooComponent extends UiStatefulComponent2<FooProps, FooState> {
@override
get initialState => (newState()..initializedState = 1);

@override
render() {
ButtonElement/*?*/ _ref;
return (Dom.div()..ref = (ButtonElement/*?*/ r) => _ref = r)();
}
}''')),
]),
]),
]),
);
});
}, timeout: Timeout(Duration(minutes: 2)));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'dart:html';

import 'package:over_react/over_react.dart';

// ignore: uri_has_not_been_generated
part 'test_state.over_react.g.dart';

UiFactory<FooProps> Foo =
castUiFactory(_$Foo); // ignore: undefined_identifier

mixin FooProps on UiProps {
int prop1;
}

mixin FooState on UiState {
String state1;
int initializedState;
void Function() state2;
}

class FooComponent extends UiStatefulComponent2<FooProps, FooState> {
@override
get initialState => (newState()..initializedState = 1);

@override
render() {
ButtonElement _ref;
return (Dom.div()..ref = (ButtonElement r) => _ref = r)();
}
}
Loading