Skip to content

Commit

Permalink
Merge pull request #61 from Workiva/toggle-stateful-assist
Browse files Browse the repository at this point in the history
Add Assist for Toggling Component Statefulness
  • Loading branch information
greglittlefield-wf authored Jun 24, 2020
2 parents 3d71d69 + bf19289 commit 70f57d9
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 0 deletions.
108 changes: 108 additions & 0 deletions over_react_analyzer_plugin/lib/src/assist/toggle_stateful.dart
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);
}
}
40 changes: 40 additions & 0 deletions over_react_analyzer_plugin/lib/src/indent_util.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source_range.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:source_span/source_span.dart';

/// Indents the content at [offset] with [indent].
///
Expand Down Expand Up @@ -29,3 +32,40 @@ String getIndent(String source, LineInfo info, int offset) {
bool isSameLine(LineInfo info, int offsetA, int offsetB) {
return info.getLocation(offsetA).lineNumber == info.getLocation(offsetB).lineNumber;
}

extension SourceFileTools on SourceFile {
/// Returns the starting offset for the line after the provided offset.
int getOffsetForLineAfter(int offset) {
return getOffset(getLine(offset) + 1);
}

/// Returns the starting offset for the line before the provided offset.
int getOffsetForLineBefore(int offset) {
return getOffset(getLine(offset) - 1);
}

/// Checks to see if the line before a given offset is just white space.
bool hasEmptyLineBefore(int offset) {
return getText(getOffsetForLineBefore(offset), offset).trim().isEmpty;
}

/// Checks to see if the line after a given offset is just white space.
bool hasEmptyLineAfter(int offset) {
return getText(offset, getOffsetForLineAfter(offset)).trim().isEmpty;
}

/// Grabs a range that is comprised of an entire AstNode plus the entire line
/// before and the start of the line after.
SourceRange getEncompassingRangeFor(AstNode node) {
final startingOffset = getOffsetForLineBefore(node.offset);
final endingOffset = getOffsetForLineAfter(node.end);

return SourceRange(startingOffset, endingOffset - startingOffset);
}

/// Creates a range for a node that includes the through the beginning of the next
/// line.
SourceRange getPreciseRangeFor(AstNode node) {
return SourceRange(node.offset, getOffsetForLineAfter(node.end) - node.offset);
}
}
2 changes: 2 additions & 0 deletions over_react_analyzer_plugin/lib/src/plugin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import 'package:analyzer_plugin/utilities/navigation/navigation.dart';
import 'package:over_react_analyzer_plugin/src/assist/add_props.dart';
import 'package:over_react_analyzer_plugin/src/assist/refs/add_create_ref_assist.dart';
import 'package:over_react_analyzer_plugin/src/assist/extract_component.dart';
import 'package:over_react_analyzer_plugin/src/assist/toggle_stateful.dart';
import 'package:over_react_analyzer_plugin/src/assist/wrap_unwrap.dart';
import 'package:over_react_analyzer_plugin/src/async_plugin_apis/assist.dart';
import 'package:over_react_analyzer_plugin/src/async_plugin_apis/diagnostic.dart';
Expand Down Expand Up @@ -136,6 +137,7 @@ class OverReactAnalyzerPlugin extends ServerPlugin
AddCreateRefAssistContributor(),
ExtractComponentAssistContributor(),
ExtractStatefulComponentAssistContributor(),
ToggleComponentStatefulness(),
WrapUnwrapAssistContributor(),
];
}
Expand Down
124 changes: 124 additions & 0 deletions over_react_analyzer_plugin/lib/src/util/boilerplate_assist_apis.dart
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();
}
}
19 changes: 19 additions & 0 deletions playground/web/foo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ UiFactory<FooProps> Foo = _$Foo; // ignore: undefined_identifier
mixin FooProps on UiProps {}

class FooComponent extends UiComponent2<FooProps> {
@override
get defaultProps => (newProps());

@override
render() {}
}

UiFactory<BarProps> Bar = _$Bar; // ignore: undefined_identifier

mixin BarPropsMixin on UiProps {}

class BarProps = UiProps with FluxUiPropsMixin, BarPropsMixin;

mixin BarState on UiState {}

class BarComponent extends FluxUiStatefulComponent2<BarProps, BarState> {
@override
get initialState => (newState());

@override
render() {}
}

0 comments on commit 70f57d9

Please sign in to comment.