-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #61 from Workiva/toggle-stateful-assist
Add Assist for Toggling Component Statefulness
- Loading branch information
Showing
5 changed files
with
293 additions
and
0 deletions.
There are no files selected for viewing
108 changes: 108 additions & 0 deletions
108
over_react_analyzer_plugin/lib/src/assist/toggle_stateful.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import 'package:analyzer/dart/ast/ast.dart'; | ||
import 'package:analyzer_plugin/protocol/protocol_generated.dart'; | ||
import 'package:analyzer_plugin/utilities/assist/assist.dart'; | ||
import 'package:analyzer_plugin/utilities/range_factory.dart'; | ||
|
||
import 'package:over_react_analyzer_plugin/src/assist/contributor_base.dart'; | ||
import 'package:over_react_analyzer_plugin/src/indent_util.dart'; | ||
import 'package:over_react_analyzer_plugin/src/util/boilerplate_assist_apis.dart'; | ||
import 'package:over_react_analyzer_plugin/src/util/fix.dart'; | ||
|
||
// ignore_for_file: implementation_imports | ||
import 'package:over_react/src/builder/parsing/util.dart'; | ||
|
||
class ToggleComponentStatefulness extends AssistContributorBase with ComponentDeclarationAssistApi { | ||
static AssistKind makeStateful = AssistKind('makeStateful', 30, 'Make component stateful.'); | ||
static AssistKind makeStateless = AssistKind('makeStateless', 30, 'Make component stateless.'); | ||
|
||
/// The counterpart base component class that will replace the current one. | ||
/// | ||
/// e.g "UiComponent2" / "UiStatefulComponent2", "FluxUiComponent2" / "FluxUiStatefulComponent2" | ||
String newComponentBaseClass; | ||
|
||
@override | ||
Future<void> computeAssists(DartAssistRequest request, AssistCollector collector) async { | ||
await super.computeAssists(request, collector); | ||
if (!setupCompute() || !initializeAssistApi(request.result.content)) return; | ||
|
||
newComponentBaseClass = _getNewBase(componentSupertypeNode.name.name); | ||
|
||
// If there is no known corresponding base class, short circuit. | ||
if (newComponentBaseClass == null) return; | ||
|
||
if (state != null) { | ||
await _removeStatefulness(); | ||
} else { | ||
await _addStatefulness(); | ||
} | ||
} | ||
|
||
Future<void> _addStatefulness() async { | ||
final sourceChange = await buildFileEdit(request.result, (builder) { | ||
final defaultProps = componentDeclaration.component.nodeHelper.members.firstWhere((member) { | ||
return member is MethodDeclaration && member.declaredElement.name == 'defaultProps'; | ||
}, orElse: () => null); | ||
|
||
const indent = ' '; | ||
|
||
final insertionOffset = defaultProps != null | ||
? componentSourceFile.getOffsetForLineAfter(defaultProps.end) | ||
: componentSourceFile.getOffsetForLineAfter(componentDeclaration.component.nodeHelper.node.offset); | ||
|
||
builder.addInsertion(insertionOffset, (builder) { | ||
if (defaultProps != null) builder.write('\n'); | ||
builder.write('$indent@override'); | ||
builder.write('\n${indent}get initialState => (newState());\n'); | ||
builder.write(!componentSourceFile.hasEmptyLineAfter(insertionOffset) ? '\n' : ''); | ||
}); | ||
|
||
builder.addInsertion(componentSourceFile.getOffsetForLineAfter(props.either.nodeHelper.node.end), (builder) { | ||
builder.write('\nmixin ${normalizedComponentName}State on UiState {}\n'); | ||
}); | ||
|
||
builder.addReplacement(range.node(componentSupertypeNode), (builder) { | ||
builder.write('$newComponentBaseClass<${props.either.name.name}, ${normalizedComponentName}State>'); | ||
}); | ||
}); | ||
sourceChange | ||
..message = makeStateful.message | ||
..id = makeStateful.id; | ||
collector.addAssist(PrioritizedSourceChange(makeStateful.priority, sourceChange)); | ||
} | ||
|
||
Future<void> _removeStatefulness() async { | ||
final sourceChange = await buildFileEdit(request.result, (builder) { | ||
final initialState = componentDeclaration.component.nodeHelper.members.firstWhere((member) { | ||
return member is MethodDeclaration && member.declaredElement.name == 'initialState'; | ||
}, orElse: () => null); | ||
|
||
builder.addDeletion(componentSourceFile.getEncompassingRangeFor(state.either.nodeHelper.node)); | ||
|
||
builder.addReplacement(range.node(componentSupertypeNode), (builder) { | ||
builder.write('$newComponentBaseClass<${normalizedComponentName}Props>'); | ||
}); | ||
|
||
if (initialState != null) { | ||
if (componentSourceFile.hasEmptyLineBefore(initialState.offset)) { | ||
builder.addDeletion(componentSourceFile.getEncompassingRangeFor(initialState)); | ||
} else { | ||
builder.addDeletion(componentSourceFile.getPreciseRangeFor(initialState)); | ||
} | ||
} | ||
}); | ||
sourceChange | ||
..message = makeStateless.message | ||
..id = makeStateless.id; | ||
collector.addAssist(PrioritizedSourceChange(makeStateless.priority, sourceChange)); | ||
} | ||
|
||
String _getNewBase(String oldBase) { | ||
const baseMapping = { | ||
'UiComponent2': 'UiStatefulComponent2', | ||
'FluxUiComponent2': 'FluxUiStatefulComponent2', | ||
}; | ||
|
||
return baseMapping[oldBase] ?? | ||
baseMapping.keys.firstWhere((key) => baseMapping[key] == oldBase, orElse: () => null); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
124 changes: 124 additions & 0 deletions
124
over_react_analyzer_plugin/lib/src/util/boilerplate_assist_apis.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import 'package:analyzer/dart/ast/ast.dart'; | ||
import 'package:over_react_analyzer_plugin/src/assist/contributor_base.dart'; | ||
import 'package:source_span/source_span.dart'; | ||
|
||
// ignore_for_file: implementation_imports | ||
import 'package:over_react/src/builder/parsing/declarations.dart'; | ||
import 'package:over_react/src/builder/parsing/declarations_from_members.dart'; | ||
import 'package:over_react/src/builder/parsing/error_collection.dart'; | ||
import 'package:over_react/src/builder/parsing/member_association.dart'; | ||
import 'package:over_react/src/builder/parsing/members.dart'; | ||
import 'package:over_react/src/builder/parsing/members_from_ast.dart'; | ||
import 'package:over_react/src/builder/parsing/util.dart'; | ||
import 'package:over_react/src/builder/parsing/version.dart'; | ||
|
||
/// A mixin that allows easy access to common APIs needed when writing assists | ||
/// that manipulate component boilerplate. | ||
/// | ||
/// Important Usage Guidelines: | ||
/// 1. Only to be used to identify new boilerplate components. Future work may be | ||
/// able to change this to be more version agnostic, but currently its usage is | ||
/// tailored to be for the new boilerplate syntax. | ||
/// 2. This mixin is only intended to be used when the assist trigger node is the | ||
/// component name. | ||
/// | ||
/// Example: | ||
/// ``` | ||
/// import 'package:over_react/over_react.dart'; | ||
/// | ||
/// part 'test.over_react.g.dart'; | ||
/// | ||
/// // The assist should trigger off of the `TestComponent` name node and nothing | ||
/// // else. | ||
/// class TestComponent extends UiComponent2<TestProps> { | ||
/// ... | ||
/// } | ||
/// ``` | ||
/// 3. The assist declaration should call `initializeAssistApi` before any instance | ||
/// specific logic is implemented. See 'initializeAssistApi' below for an example. | ||
mixin ComponentDeclarationAssistApi on AssistContributorBase { | ||
SourceFile componentSourceFile; | ||
|
||
/// A context variable representing the component being targeted by the assist. | ||
/// | ||
/// After triggering the assist off of the component class name, the mixin initialization | ||
/// will detect the related boilerplate and set this as the entrypoint. This process | ||
/// will occur for the relevant component every time the assist is triggered. | ||
ClassComponentDeclaration componentDeclaration; | ||
|
||
ErrorCollector errorCollector; | ||
|
||
bool _isAValidComponentDeclaration; | ||
|
||
/// Checks the context of the assist node and returns if it is an appropriate | ||
/// context to suggest a component level assist. | ||
bool get isAValidComponentDeclaration { | ||
if (_isAValidComponentDeclaration == null) { | ||
throw StateError('API not initialized. Call `initializeAssistApi` before accessible API members.'); | ||
} | ||
|
||
return _isAValidComponentDeclaration; | ||
} | ||
|
||
String get normalizedComponentName => normalizeNameAndRemoveSuffix(componentDeclaration.component); | ||
|
||
NamedType get componentSupertypeNode => componentDeclaration.component.nodeHelper.superclass; | ||
|
||
Union<BoilerplateProps, BoilerplatePropsMixin> get props => componentDeclaration.props; | ||
|
||
Union<BoilerplateState, BoilerplateStateMixin> get state => componentDeclaration.state; | ||
|
||
bool _validateAndDetectBoilerplate() { | ||
if (node is! SimpleIdentifier || node.parent is! ClassDeclaration) return false; | ||
ClassDeclaration parent = node.parent; | ||
|
||
final members = detectBoilerplateMembers(node.thisOrAncestorOfType<CompilationUnit>()); | ||
final declarations = getBoilerplateDeclarations(members, errorCollector).toList(); | ||
|
||
componentDeclaration = declarations.whereType<ClassComponentDeclaration>().firstWhere((c) { | ||
return c.component.node == parent; | ||
}, orElse: () => null); | ||
|
||
_isAValidComponentDeclaration = | ||
componentDeclaration != null && componentDeclaration.version == Version.v4_mixinBased; | ||
return isAValidComponentDeclaration; | ||
} | ||
|
||
/// Returns whether the node under the cursor is the correct context to show a component | ||
/// assist after initializing important instance fields. | ||
/// | ||
/// Should be called right after finishing the assist setup. | ||
/// | ||
/// Example: | ||
/// ``` | ||
/// import 'package:analyzer_plugin/utilities/assist/assist.dart'; | ||
/// | ||
/// import 'package:over_react_analyzer_plugin/src/assist/contributor_base.dart'; | ||
/// import 'package:over_react_analyzer_plugin/src/util/boilerplate_assist_apis.dart'; | ||
/// | ||
/// class ExampleAssist extends AssistContributorBase with ComponentDeclarationAssistApi { | ||
/// @override | ||
/// Future<void> computeAssists(DartAssistRequest request, AssistCollector collector) async { | ||
/// await super.computeAssists(request, collector); | ||
/// if (!setupCompute()) return; | ||
/// | ||
/// initializeAssistApi(request.result.unit, request.result.content); | ||
/// // Then, `isAValidComponentDeclaration` getter can be used to check if this | ||
/// // assist abides by the API expectations. | ||
/// if (!isAValidComponentDeclaration) return; | ||
/// | ||
/// // Or, because `initializeAssistApi` mutates the instance fields but | ||
/// // returns the validity of the `unit` context, it can be wrapped in | ||
/// // the same if statement as `setupCompute`: | ||
/// // if (!setupCompute() || !initializeAssistApi(request.result.content)) return; | ||
/// | ||
/// ... // custom implementation details | ||
/// } | ||
/// } | ||
/// ``` | ||
bool initializeAssistApi(String sourceFileContent) { | ||
componentSourceFile = SourceFile.fromString(sourceFileContent); | ||
errorCollector = ErrorCollector.print(componentSourceFile); | ||
return _validateAndDetectBoilerplate(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters