| // Copyright (c) 2018, 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/lsp_protocol/protocol_generated.dart'; |
| import 'package:analysis_server/lsp_protocol/protocol_special.dart'; |
| import 'package:analysis_server/src/lsp/constants.dart'; |
| import 'package:analysis_server/src/lsp/handlers/handlers.dart'; |
| import 'package:analysis_server/src/lsp/lsp_analysis_server.dart'; |
| import 'package:analysis_server/src/lsp/mapping.dart'; |
| import 'package:analysis_server/src/plugin/plugin_manager.dart'; |
| import 'package:analysis_server/src/protocol_server.dart' hide Position; |
| import 'package:analysis_server/src/services/correction/assist.dart'; |
| import 'package:analysis_server/src/services/correction/assist_internal.dart'; |
| import 'package:analysis_server/src/services/correction/change_workspace.dart'; |
| import 'package:analysis_server/src/services/correction/fix.dart'; |
| import 'package:analysis_server/src/services/correction/fix/dart/top_level_declarations.dart'; |
| import 'package:analysis_server/src/services/correction/fix_internal.dart'; |
| import 'package:analysis_server/src/services/refactoring/refactoring.dart'; |
| import 'package:analyzer/dart/analysis/results.dart'; |
| import 'package:analyzer/dart/analysis/session.dart' |
| show InconsistentAnalysisException; |
| import 'package:analyzer/dart/element/element.dart'; |
| import 'package:analyzer/src/dart/ast/utilities.dart'; |
| import 'package:analyzer/src/util/file_paths.dart' as file_paths; |
| import 'package:analyzer_plugin/protocol/protocol.dart' as plugin; |
| import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin; |
| import 'package:collection/collection.dart' show groupBy; |
| |
| class CodeActionHandler extends MessageHandler<CodeActionParams, |
| List<Either2<Command, CodeAction>>> { |
| // Because server+plugin results are different types and we lose |
| // priorites when converting them to CodeActions, store the priorities |
| // against each action in an expando. This avoids wrapping CodeActions in |
| // another wrapper class (since we can't modify the LSP-spec-generated |
| // CodeAction class). |
| final codeActionPriorities = Expando<int>(); |
| |
| /// A comparator that can be used to sort [CodeActions]s using priorties |
| /// in [codeActionPriorities]. The highest number priority will be sorted |
| /// before lower number priorityies. Items with the same relevance are sorted |
| /// alphabetically by their title. |
| late final Comparator<CodeAction> _codeActionComparator = |
| (CodeAction a, CodeAction b) { |
| // We should never be sorting actions without priorities. |
| final aPriority = codeActionPriorities[a] ?? 0; |
| final bPriority = codeActionPriorities[b] ?? 0; |
| if (aPriority != bPriority) { |
| return bPriority - aPriority; |
| } |
| return a.title.compareTo(b.title); |
| }; |
| |
| CodeActionHandler(LspAnalysisServer server) : super(server); |
| |
| @override |
| Method get handlesMessage => Method.textDocument_codeAction; |
| |
| @override |
| LspJsonHandler<CodeActionParams> get jsonHandler => |
| CodeActionParams.jsonHandler; |
| |
| @override |
| Future<ErrorOr<List<Either2<Command, CodeAction>>>> handle( |
| CodeActionParams params, CancellationToken token) async { |
| if (!isDartDocument(params.textDocument)) { |
| return success(const []); |
| } |
| |
| final path = pathOfDoc(params.textDocument); |
| if (!path.isError && !server.isAnalyzed(path.result)) { |
| return success(const []); |
| } |
| |
| final clientCapabilities = server.clientCapabilities; |
| if (clientCapabilities == null) { |
| // This should not happen unless a client misbehaves. |
| return error(ErrorCodes.ServerNotInitialized, |
| 'Requests not before server is initilized'); |
| } |
| |
| final supportsApplyEdit = clientCapabilities.applyEdit; |
| final supportsLiteralCodeActions = clientCapabilities.literalCodeActions; |
| final supportedKinds = clientCapabilities.codeActionKinds; |
| final supportedDiagnosticTags = clientCapabilities.diagnosticTags; |
| |
| final unit = await path.mapResult(requireResolvedUnit); |
| |
| bool shouldIncludeKind(CodeActionKind? kind) { |
| /// Checks whether the kind matches the [wanted] kind. |
| /// |
| /// If `wanted` is `refactor.foo` then: |
| /// - refactor.foo - included |
| /// - refactor.foobar - not included |
| /// - refactor.foo.bar - included |
| bool isMatch(CodeActionKind wanted) => |
| kind == wanted || kind.toString().startsWith('${wanted.toString()}.'); |
| |
| // If the client wants only a specific set, use only that filter. |
| final only = params.context.only; |
| if (only != null) { |
| return only.any(isMatch); |
| } |
| |
| // Otherwise, filter out anything not supported by the client (if they |
| // advertised that they provided the kinds). |
| if (supportsLiteralCodeActions && !supportedKinds.any(isMatch)) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| return unit.mapResult((unit) { |
| final startOffset = toOffset(unit.lineInfo, params.range.start); |
| final endOffset = toOffset(unit.lineInfo, params.range.end); |
| return startOffset.mapResult((startOffset) { |
| return endOffset.mapResult((endOffset) { |
| final offset = startOffset; |
| final length = endOffset - startOffset; |
| return _getCodeActions( |
| shouldIncludeKind, |
| supportsLiteralCodeActions, |
| supportsApplyEdit, |
| supportedDiagnosticTags, |
| path.result, |
| params.range, |
| offset, |
| length, |
| unit); |
| }); |
| }); |
| }); |
| } |
| |
| /// Creates a comparer for [CodeActions] that compares the column distance from [pos]. |
| int Function(CodeAction a, CodeAction b) _codeActionColumnDistanceComparer( |
| Position pos) { |
| Position posOf(CodeAction action) { |
| final diagnostics = action.diagnostics; |
| return diagnostics != null && diagnostics.isNotEmpty |
| ? diagnostics.first.range.start |
| : pos; |
| } |
| |
| return (a, b) => _columnDistance(posOf(a), pos) |
| .compareTo(_columnDistance(posOf(b), pos)); |
| } |
| |
| /// Returns the distance (in columns, ignoring lines) between two positions. |
| int _columnDistance(Position a, Position b) => |
| (a.character - b.character).abs(); |
| |
| /// Wraps a command in a CodeAction if the client supports it so that a |
| /// CodeActionKind can be supplied. |
| Either2<Command, CodeAction> _commandOrCodeAction( |
| bool supportsLiteralCodeActions, |
| CodeActionKind kind, |
| Command command, |
| ) { |
| return supportsLiteralCodeActions |
| ? Either2<Command, CodeAction>.t2( |
| CodeAction(title: command.title, kind: kind, command: command), |
| ) |
| : Either2<Command, CodeAction>.t1(command); |
| } |
| |
| /// Creates a CodeAction to apply this assist. Note: This code will fetch the |
| /// version of each document being modified so it's important to call this |
| /// immediately after computing edits to ensure the document is not modified |
| /// before the version number is read. |
| CodeAction _createAssistAction(SourceChange change) { |
| return CodeAction( |
| title: change.message, |
| kind: toCodeActionKind(change.id, CodeActionKind.Refactor), |
| diagnostics: const [], |
| edit: createWorkspaceEdit(server, change), |
| ); |
| } |
| |
| /// Creates a CodeAction to apply this fix. Note: This code will fetch the |
| /// version of each document being modified so it's important to call this |
| /// immediately after computing edits to ensure the document is not modified |
| /// before the version number is read. |
| CodeAction _createFixAction(SourceChange change, Diagnostic diagnostic) { |
| return CodeAction( |
| title: change.message, |
| kind: toCodeActionKind(change.id, CodeActionKind.QuickFix), |
| diagnostics: [diagnostic], |
| edit: createWorkspaceEdit(server, change), |
| ); |
| } |
| |
| /// Dedupes/merges actions that have the same title, selecting the one nearest [pos]. |
| /// |
| /// If actions perform the same edit/command, their diagnostics will be merged |
| /// together. Otherwise, the additional accounts are just dropped. |
| /// |
| /// The first diagnostic for an action is used to determine the position (using |
| /// its `start`). If there is no diagnostic, it will be treated as being at [pos]. |
| /// |
| /// If multiple actions have the same position, one will arbitrarily be chosen. |
| List<CodeAction> _dedupeActions(Iterable<CodeAction> actions, Position pos) { |
| final groups = groupBy(actions, (CodeAction action) => action.title); |
| return groups.entries.map((entry) { |
| final actions = entry.value; |
| |
| // If there's only one in the group, just return it. |
| if (actions.length == 1) { |
| return actions.single; |
| } |
| |
| // Otherwise, find the action nearest to the caret. |
| actions.sort(_codeActionColumnDistanceComparer(pos)); |
| final first = actions.first; |
| |
| // Get any actions with the same fix (edit/command) for merging diagnostics. |
| final others = actions.skip(1).where( |
| (other) => |
| // Compare either edits or commands based on which the selected action has. |
| first.edit != null |
| ? first.edit == other.edit |
| : first.command != null |
| ? first.command == other.command |
| : false, |
| ); |
| |
| // Build a new CodeAction that merges the diagnostics from each same |
| // code action onto a single one. |
| return CodeAction( |
| title: first.title, |
| kind: first.kind, |
| // Merge diagnostics from all of the matching CodeActions. |
| diagnostics: [ |
| ...?first.diagnostics, |
| for (final other in others) ...?other.diagnostics, |
| ], |
| edit: first.edit, |
| command: first.command, |
| ); |
| }).toList(); |
| } |
| |
| Future<List<Either2<Command, CodeAction>>> _getAssistActions( |
| bool Function(CodeActionKind?) shouldIncludeKind, |
| bool supportsLiteralCodeActions, |
| String path, |
| Range range, |
| int offset, |
| int length, |
| ResolvedUnitResult unit, |
| ) async { |
| try { |
| var context = DartAssistContextImpl( |
| server.instrumentationService, |
| DartChangeWorkspace(server.currentSessions), |
| unit, |
| offset, |
| length, |
| ); |
| final processor = AssistProcessor(context); |
| final serverFuture = processor.compute(); |
| final pluginFuture = _getPluginAssistChanges(path, offset, length); |
| |
| final assists = await serverFuture; |
| final pluginChanges = await pluginFuture; |
| |
| final codeActions = <CodeAction>[]; |
| codeActions.addAll(assists.map((assist) { |
| final action = _createAssistAction(assist.change); |
| codeActionPriorities[action] = assist.kind.priority; |
| return action; |
| })); |
| codeActions.addAll(pluginChanges.map((change) { |
| final action = _createAssistAction(change.change); |
| codeActionPriorities[action] = change.priority; |
| return action; |
| })); |
| |
| final dedupedCodeActions = _dedupeActions(codeActions, range.start); |
| dedupedCodeActions.sort(_codeActionComparator); |
| |
| return dedupedCodeActions |
| .where((action) => shouldIncludeKind(action.kind)) |
| .map((action) => Either2<Command, CodeAction>.t2(action)) |
| .toList(); |
| } on InconsistentAnalysisException { |
| // If an InconsistentAnalysisException occurs, it's likely the user modified |
| // the source and therefore is no longer interested in the results, so |
| // just return an empty set. |
| return []; |
| } |
| } |
| |
| Future<ErrorOr<List<Either2<Command, CodeAction>>>> _getCodeActions( |
| bool Function(CodeActionKind?) shouldIncludeKind, |
| bool supportsLiterals, |
| bool supportsWorkspaceApplyEdit, |
| Set<DiagnosticTag> supportedDiagnosticTags, |
| String path, |
| Range range, |
| int offset, |
| int length, |
| ResolvedUnitResult unit, |
| ) async { |
| final results = await Future.wait([ |
| _getSourceActions(shouldIncludeKind, supportsLiterals, |
| supportsWorkspaceApplyEdit, path), |
| _getAssistActions(shouldIncludeKind, supportsLiterals, path, range, |
| offset, length, unit), |
| _getRefactorActions( |
| shouldIncludeKind, supportsLiterals, path, offset, length, unit), |
| _getFixActions(shouldIncludeKind, supportsLiterals, path, offset, |
| supportedDiagnosticTags, range, unit), |
| ]); |
| final flatResults = results.expand((x) => x).toList(); |
| |
| return success(flatResults); |
| } |
| |
| Future<List<Either2<Command, CodeAction>>> _getFixActions( |
| bool Function(CodeActionKind?) shouldIncludeKind, |
| bool supportsLiteralCodeActions, |
| String path, |
| int offset, |
| Set<DiagnosticTag> supportedDiagnosticTags, |
| Range range, |
| ResolvedUnitResult unit, |
| ) async { |
| final clientSupportsCodeDescription = |
| server.clientCapabilities?.diagnosticCodeDescription ?? false; |
| // TODO(dantup): We may be missing fixes for pubspec, analysis_options, |
| // android manifests (see _computeServerErrorFixes in EditDomainHandler). |
| final lineInfo = unit.lineInfo; |
| final codeActions = <CodeAction>[]; |
| final fixContributor = DartFixContributor(); |
| |
| final pluginFuture = _getPluginFixActions(unit, offset); |
| |
| try { |
| for (final error in unit.errors) { |
| // Server lineNumber is one-based so subtract one. |
| var errorLine = lineInfo.getLocation(error.offset).lineNumber - 1; |
| if (errorLine < range.start.line || errorLine > range.end.line) { |
| continue; |
| } |
| var workspace = DartChangeWorkspace(server.currentSessions); |
| var context = DartFixContextImpl( |
| server.instrumentationService, workspace, unit, error, (name) { |
| var tracker = server.declarationsTracker!; |
| return TopLevelDeclarationsProvider(tracker).get( |
| unit.session.analysisContext, |
| unit.path, |
| name, |
| ); |
| }, extensionCache: server.getExtensionCacheFor(unit)); |
| final fixes = await fixContributor.computeFixes(context); |
| if (fixes.isNotEmpty) { |
| final diagnostic = toDiagnostic( |
| unit, |
| error, |
| supportedTags: supportedDiagnosticTags, |
| clientSupportsCodeDescription: clientSupportsCodeDescription, |
| ); |
| codeActions.addAll( |
| fixes.map((fix) { |
| final action = _createFixAction(fix.change, diagnostic); |
| codeActionPriorities[action] = fix.kind.priority; |
| return action; |
| }), |
| ); |
| } |
| } |
| |
| Diagnostic pluginErrorToDiagnostic(AnalysisError error) { |
| return pluginToDiagnostic( |
| (_) => lineInfo, |
| error, |
| supportedTags: supportedDiagnosticTags, |
| clientSupportsCodeDescription: clientSupportsCodeDescription, |
| ); |
| } |
| |
| final pluginFixes = await pluginFuture; |
| final pluginFixActions = pluginFixes.expand( |
| (fix) => fix.fixes.map((fixChange) { |
| final action = _createFixAction( |
| fixChange.change, pluginErrorToDiagnostic(fix.error)); |
| codeActionPriorities[action] = fixChange.priority; |
| return action; |
| }), |
| ); |
| codeActions.addAll(pluginFixActions); |
| |
| final dedupedActions = _dedupeActions(codeActions, range.start); |
| dedupedActions.sort(_codeActionComparator); |
| |
| return dedupedActions |
| .where((action) => shouldIncludeKind(action.kind)) |
| .map((action) => Either2<Command, CodeAction>.t2(action)) |
| .toList(); |
| } on InconsistentAnalysisException { |
| // If an InconsistentAnalysisException occurs, it's likely the user modified |
| // the source and therefore is no longer interested in the results, so |
| // just return an empty set. |
| return []; |
| } |
| } |
| |
| Future<Iterable<plugin.PrioritizedSourceChange>> _getPluginAssistChanges( |
| String path, int offset, int length) async { |
| final requestParams = plugin.EditGetAssistsParams(path, offset, length); |
| final driver = server.getAnalysisDriver(path); |
| |
| Map<PluginInfo, Future<plugin.Response>> pluginFutures; |
| if (driver == null) { |
| pluginFutures = <PluginInfo, Future<plugin.Response>>{}; |
| } else { |
| pluginFutures = server.pluginManager.broadcastRequest( |
| requestParams, |
| contextRoot: driver.analysisContext!.contextRoot, |
| ); |
| } |
| |
| final pluginChanges = <plugin.PrioritizedSourceChange>[]; |
| final responses = |
| await waitForResponses(pluginFutures, requestParameters: requestParams); |
| |
| for (final response in responses) { |
| final result = plugin.EditGetAssistsResult.fromResponse(response); |
| pluginChanges.addAll(result.assists); |
| } |
| |
| return pluginChanges; |
| } |
| |
| Future<Iterable<plugin.AnalysisErrorFixes>> _getPluginFixActions( |
| ResolvedUnitResult unit, int offset) async { |
| final file = unit.path; |
| final requestParams = plugin.EditGetFixesParams(file, offset); |
| final driver = server.getAnalysisDriver(file); |
| |
| Map<PluginInfo, Future<plugin.Response>> pluginFutures; |
| if (driver == null) { |
| pluginFutures = <PluginInfo, Future<plugin.Response>>{}; |
| } else { |
| pluginFutures = server.pluginManager.broadcastRequest( |
| requestParams, |
| contextRoot: driver.analysisContext!.contextRoot, |
| ); |
| } |
| |
| final pluginFixes = <plugin.AnalysisErrorFixes>[]; |
| final responses = |
| await waitForResponses(pluginFutures, requestParameters: requestParams); |
| |
| for (final response in responses) { |
| final result = plugin.EditGetFixesResult.fromResponse(response); |
| pluginFixes.addAll(result.fixes); |
| } |
| |
| return pluginFixes; |
| } |
| |
| Future<List<Either2<Command, CodeAction>>> _getRefactorActions( |
| bool Function(CodeActionKind) shouldIncludeKind, |
| bool supportsLiteralCodeActions, |
| String path, |
| int offset, |
| int length, |
| ResolvedUnitResult unit, |
| ) async { |
| // The refactor actions supported are only valid for Dart files. |
| var pathContext = server.resourceProvider.pathContext; |
| if (!file_paths.isDart(pathContext, path)) { |
| return const []; |
| } |
| |
| /// Helper to create refactors that execute commands provided with |
| /// the current file, location and document version. |
| Either2<Command, CodeAction> createRefactor( |
| CodeActionKind actionKind, |
| String name, |
| RefactoringKind refactorKind, [ |
| Map<String, dynamic>? options, |
| ]) { |
| return _commandOrCodeAction( |
| supportsLiteralCodeActions, |
| actionKind, |
| Command( |
| title: name, |
| command: Commands.performRefactor, |
| arguments: [ |
| refactorKind.toJson(), |
| path, |
| server.getVersionedDocumentIdentifier(path).version, |
| offset, |
| length, |
| options, |
| ], |
| )); |
| } |
| |
| try { |
| final refactorActions = <Either2<Command, CodeAction>>[]; |
| |
| // Extracts |
| if (shouldIncludeKind(CodeActionKind.RefactorExtract)) { |
| // Extract Method |
| if (ExtractMethodRefactoring(server.searchEngine, unit, offset, length) |
| .isAvailable()) { |
| refactorActions.add(createRefactor(CodeActionKind.RefactorExtract, |
| 'Extract Method', RefactoringKind.EXTRACT_METHOD)); |
| } |
| |
| // Extract Local Variable |
| if (ExtractLocalRefactoring(unit, offset, length).isAvailable()) { |
| refactorActions.add(createRefactor( |
| CodeActionKind.RefactorExtract, |
| 'Extract Local Variable', |
| RefactoringKind.EXTRACT_LOCAL_VARIABLE)); |
| } |
| |
| // Extract Widget |
| if (ExtractWidgetRefactoring(server.searchEngine, unit, offset, length) |
| .isAvailable()) { |
| refactorActions.add(createRefactor(CodeActionKind.RefactorExtract, |
| 'Extract Widget', RefactoringKind.EXTRACT_WIDGET)); |
| } |
| } |
| |
| // Inlines |
| if (shouldIncludeKind(CodeActionKind.RefactorInline)) { |
| // Inline Local Variable |
| if (InlineLocalRefactoring(server.searchEngine, unit, offset) |
| .isAvailable()) { |
| refactorActions.add(createRefactor(CodeActionKind.RefactorInline, |
| 'Inline Local Variable', RefactoringKind.INLINE_LOCAL_VARIABLE)); |
| } |
| |
| // Inline Method |
| if (InlineMethodRefactoring(server.searchEngine, unit, offset) |
| .isAvailable()) { |
| refactorActions.add(createRefactor(CodeActionKind.RefactorInline, |
| 'Inline Method', RefactoringKind.INLINE_METHOD)); |
| } |
| } |
| |
| // Converts/Rewrites |
| if (shouldIncludeKind(CodeActionKind.RefactorRewrite)) { |
| final node = NodeLocator(offset).searchWithin(unit.unit); |
| final element = server.getElementOfNode(node); |
| // Getter to Method |
| if (element is PropertyAccessorElement) { |
| refactorActions.add(createRefactor( |
| CodeActionKind.RefactorRewrite, |
| 'Convert Getter to Method', |
| RefactoringKind.CONVERT_GETTER_TO_METHOD)); |
| } |
| |
| // Method to Getter |
| if (element is ExecutableElement && |
| element is! PropertyAccessorElement) { |
| refactorActions.add(createRefactor( |
| CodeActionKind.RefactorRewrite, |
| 'Convert Method to Getter', |
| RefactoringKind.CONVERT_METHOD_TO_GETTER)); |
| } |
| } |
| |
| return refactorActions; |
| } on InconsistentAnalysisException { |
| // If an InconsistentAnalysisException occurs, it's likely the user modified |
| // the source and therefore is no longer interested in the results, so |
| // just return an empty set. |
| return []; |
| } |
| } |
| |
| /// Gets "Source" CodeActions, which are actions that apply to whole files of |
| /// source such as Sort Members and Organise Imports. |
| Future<List<Either2<Command, CodeAction>>> _getSourceActions( |
| bool Function(CodeActionKind) shouldIncludeKind, |
| bool supportsLiteralCodeActions, |
| bool supportsApplyEdit, |
| String path, |
| ) async { |
| // The source actions supported are only valid for Dart files. |
| var pathContext = server.resourceProvider.pathContext; |
| if (!file_paths.isDart(pathContext, path)) { |
| return const []; |
| } |
| |
| // If the client does not support workspace/applyEdit, we won't be able to |
| // run any of these. |
| if (!supportsApplyEdit) { |
| return const []; |
| } |
| |
| return [ |
| if (shouldIncludeKind(DartCodeActionKind.SortMembers)) |
| _commandOrCodeAction( |
| supportsLiteralCodeActions, |
| DartCodeActionKind.SortMembers, |
| Command( |
| title: 'Sort Members', |
| command: Commands.sortMembers, |
| arguments: [path]), |
| ), |
| if (shouldIncludeKind(CodeActionKind.SourceOrganizeImports)) |
| _commandOrCodeAction( |
| supportsLiteralCodeActions, |
| CodeActionKind.SourceOrganizeImports, |
| Command( |
| title: 'Organize Imports', |
| command: Commands.organizeImports, |
| arguments: [path]), |
| ), |
| if (shouldIncludeKind(DartCodeActionKind.FixAll)) |
| _commandOrCodeAction( |
| supportsLiteralCodeActions, |
| DartCodeActionKind.FixAll, |
| Command( |
| title: 'Fix All', command: Commands.fixAll, arguments: [path]), |
| ), |
| ]; |
| } |
| } |