| // Copyright (c) 2020, 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. | |
| /// @docImport 'package:dartdev/src/lsp_analysis_server.dart'; | |
| library; | |
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:analysis_server/src/server/driver.dart' show Driver; | |
| import 'package:analysis_server_client/protocol.dart' | |
| show | |
| AddContentOverlay, | |
| AnalysisUpdateContentParams, | |
| EditBulkFixesResult, | |
| ResponseDecoder; | |
| import 'package:args/args.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'core.dart'; | |
| import 'experiments.dart'; | |
| import 'sdk.dart'; | |
| import 'utils.dart'; | |
| /// When set, this function is executed just before the Analysis Server starts. | |
| void Function( | |
| String cmdName, | |
| List<FileSystemEntity> analysisRoots, | |
| ArgResults? argResults, | |
| )? | |
| preAnalysisServerStart; | |
| /// A class to provide an API wrapper around a Legacy analysis server process. | |
| /// | |
| /// [LspAnalysisServer] is a replacement for this class and this class will be | |
| /// removed once both 'dart analyze' and 'dart fix' have been moved over to it. | |
| class AnalysisServer { | |
| AnalysisServer( | |
| this._packagesFile, | |
| this._sdkPath, | |
| this._analysisRoots, { | |
| this.cacheDirectoryPath, | |
| required this.commandName, | |
| required this.argResults, | |
| required this._usePlugins, | |
| this.enabledExperiments = const [], | |
| this.disableStatusNotificationDebouncing = false, | |
| this.suppressAnalytics = false, | |
| this._useAotSnapshot = false, | |
| }); | |
| final String? cacheDirectoryPath; | |
| final File? _packagesFile; | |
| final Directory _sdkPath; | |
| final List<FileSystemEntity> _analysisRoots; | |
| final String commandName; | |
| final ArgResults? argResults; | |
| final List<String> enabledExperiments; | |
| final bool disableStatusNotificationDebouncing; | |
| final bool suppressAnalytics; | |
| final bool _useAotSnapshot; | |
| final bool _usePlugins; | |
| Process? _process; | |
| /// When not null, this is a [Completer] which completes when analysis has | |
| /// finished, otherwise `null`. | |
| Completer<bool>? _analysisFinished; | |
| int _id = 0; | |
| bool _shutdownResponseReceived = false; | |
| bool _serverErrorReceived = false; | |
| /// Whether any server error occurred that could mean analysis was not | |
| /// performed correctly. | |
| bool get serverErrorReceived => _serverErrorReceived; | |
| Stream<bool> get onAnalyzing { | |
| // {"event":"server.status","params":{"analysis":{"isAnalyzing":true}}} | |
| return _streamController('server.status').stream | |
| .where((event) => event['analysis'] != null) | |
| .map((event) => (event['analysis']['isAnalyzing']!) as bool); | |
| } | |
| /// This future completes when we next receive an analysis finished event | |
| /// (unless there's no current analysis and we've already received a complete | |
| /// event, in which case this future completes immediately). | |
| Future<bool>? get analysisFinished => _analysisFinished?.future; | |
| Stream<FileAnalysisErrors> get onErrors { | |
| // {"event":"analysis.errors", | |
| // "params":{"file":"/Users/.../lib/main.dart","errors":[]}} | |
| return _streamController('analysis.errors').stream.map((event) { | |
| final file = event['file'] as String; | |
| final errorsList = event['errors'] as List<dynamic>; | |
| final errors = [ | |
| for (final error in errorsList) | |
| AnalysisError((error as Map).cast<String, dynamic>()), | |
| ]; | |
| return FileAnalysisErrors(file, errors); | |
| }); | |
| } | |
| Future<int> get onExit => _process!.exitCode; | |
| final Map<String, StreamController<Map<String, dynamic>>> _streamControllers = | |
| {}; | |
| /// Completes when an analysis server crash has been detected. | |
| Future<void> get onCrash => _onCrash.future; | |
| final _onCrash = Completer<void>(); | |
| final Map<String, Completer<Map<String, dynamic>>> _requestCompleters = {}; | |
| /// Starts the process and returns the pid for it. | |
| Future<int> start({bool setAnalysisRoots = true}) async { | |
| preAnalysisServerStart?.call(commandName, _analysisRoots, argResults); | |
| final process = await _startProcess(); | |
| _process = process; | |
| _shutdownResponseReceived = false; | |
| // This callback hookup can't throw. | |
| process.exitCode.whenComplete(() { | |
| _process = null; | |
| if (!_shutdownResponseReceived) { | |
| // The process exited unexpectedly. Report the crash. | |
| // If `server.error` reported an error, that has been logged by | |
| // `_handleServerError`. | |
| final error = StateError('The analysis server crashed unexpectedly'); | |
| final analysisFinished = _analysisFinished; | |
| if (analysisFinished != null && !analysisFinished.isCompleted) { | |
| // Complete this completer in order to unstick the process. | |
| analysisFinished.completeError(error); | |
| } | |
| // Complete these completers in order to unstick the process. | |
| for (final completer in _requestCompleters.values) { | |
| completer.completeError(error); | |
| } | |
| _onCrash.complete(); | |
| } | |
| }); | |
| final errorStream = process.stderr | |
| .transform<String>(utf8.decoder) | |
| .transform<String>(const LineSplitter()); | |
| errorStream.listen(log.stderr); | |
| final inStream = process.stdout | |
| .transform<String>(utf8.decoder) | |
| .transform<String>(const LineSplitter()); | |
| inStream.listen(_handleServerResponse); | |
| _streamController('server.error').stream.listen(_handleServerError); | |
| _streamController('server.pluginError').stream.listen(_handlePluginError); | |
| _sendCommand( | |
| 'server.setSubscriptions', | |
| params: { | |
| 'subscriptions': <String>['STATUS'], | |
| }, | |
| ); | |
| // Reference and trim off any trailing slash, the Dart Analysis Server | |
| // protocol throws an error (INVALID_FILE_PATH_FORMAT) if there is a | |
| // trailing slash. | |
| // | |
| // The call to `absolute.resolveSymbolicLinksSync()` canonicalizes the path | |
| // to be passed to the analysis server. | |
| final analysisRootPaths = [ | |
| for (final root in _analysisRoots) | |
| trimEnd( | |
| root.absolute.resolveSymbolicLinksSync(), | |
| path.context.separator, | |
| ), | |
| ]; | |
| onAnalyzing.listen((isAnalyzing) { | |
| final analysisFinished = _analysisFinished; | |
| if (isAnalyzing && (analysisFinished?.isCompleted ?? true)) { | |
| // Start a new completer, to be completed when we receive the | |
| // corresponding analysis complete event. | |
| _analysisFinished = Completer(); | |
| } else if (!isAnalyzing && | |
| analysisFinished != null && | |
| !analysisFinished.isCompleted) { | |
| analysisFinished.complete(true); | |
| } | |
| }); | |
| if (setAnalysisRoots) { | |
| await _sendCommand( | |
| 'analysis.setAnalysisRoots', | |
| params: {'included': analysisRootPaths, 'excluded': []}, | |
| ); | |
| } | |
| return process.pid; | |
| } | |
| Future<Process> _startProcess() { | |
| final executable = _useAotSnapshot ? sdk.dartAotRuntime : sdk.dart; | |
| final arguments = [ | |
| if (_useAotSnapshot) | |
| sdk.analysisServerAotSnapshot | |
| else | |
| sdk.analysisServerSnapshot, | |
| if (suppressAnalytics) '--${Driver.suppressAnalyticsFlag}', | |
| '--${Driver.clientIdOption}=dart-$commandName', | |
| '--disable-server-feature-completion', | |
| '--disable-server-feature-search', | |
| if (disableStatusNotificationDebouncing) | |
| '--disable-status-notification-debouncing', | |
| '--disable-silent-analysis-exceptions', | |
| '--sdk', | |
| _sdkPath.path, | |
| if (cacheDirectoryPath != null) '--cache=$cacheDirectoryPath', | |
| if (_packagesFile != null) '--packages=${_packagesFile.path}', | |
| if (enabledExperiments.isNotEmpty) | |
| '--$experimentFlagName=${enabledExperiments.join(',')}', | |
| if (!_usePlugins) '--no-plugins', | |
| ]; | |
| log.trace('$executable ${arguments.join(' ')}'); | |
| return Process.start(executable, arguments); | |
| } | |
| Future<String> getVersion() { | |
| return _sendCommand( | |
| 'server.getVersion', | |
| ).then((response) => response['version']); | |
| } | |
| Future<EditBulkFixesResult> requestBulkFixes( | |
| String filePath, | |
| bool inTestMode, | |
| List<String> codes, { | |
| bool updatePubspec = false, | |
| }) { | |
| return _sendCommand( | |
| 'edit.bulkFixes', | |
| params: { | |
| 'included': [path.canonicalize(filePath)], | |
| 'inTestMode': inTestMode, | |
| 'updatePubspec': updatePubspec, | |
| if (codes.isNotEmpty) 'codes': codes, | |
| }, | |
| ).then((result) { | |
| return EditBulkFixesResult.fromJson( | |
| ResponseDecoder(null), | |
| 'result', | |
| result, | |
| ); | |
| }); | |
| } | |
| Future<void> shutdown({Duration? timeout}) async { | |
| // Request shutdown. | |
| var future = _sendCommand('server.shutdown').then((_) { | |
| _shutdownResponseReceived = true; | |
| }); | |
| if (timeout != null) { | |
| future = future.timeout( | |
| timeout, | |
| onTimeout: () { | |
| log.stderr('The analysis server timed out while shutting down.'); | |
| }, | |
| ); | |
| } | |
| await future.whenComplete(dispose); | |
| } | |
| /// Send an `analysis.updateContent` request with the given [files]. | |
| Future<void> updateContent(Map<String, AddContentOverlay> files) async { | |
| await _sendCommand( | |
| 'analysis.updateContent', | |
| params: AnalysisUpdateContentParams(files).toJson(), | |
| ); | |
| } | |
| Future<Map<String, dynamic>> _sendCommand( | |
| String method, { | |
| Map<String, dynamic>? params, | |
| }) { | |
| final String id = (++_id).toString(); | |
| final String message = json.encode({ | |
| 'id': id, | |
| 'method': method, | |
| 'params': params, | |
| }); | |
| final Completer<Map<String, dynamic>> completer = Completer(); | |
| _requestCompleters[id] = completer; | |
| _process!.stdin.writeln(message); | |
| log.trace('==> $message'); | |
| return completer.future; | |
| } | |
| void _handlePluginError(Map<String, dynamic> error) { | |
| _serverErrorReceived = true; | |
| // No need for a preamble (like in `_handleServerError`); the message should | |
| // have all of the context necessary. | |
| log.stderr(error['message']); | |
| final stackTrace = error['stackTrace']; | |
| if (stackTrace is String && stackTrace.isNotEmpty) { | |
| log.stderr(stackTrace); | |
| } | |
| } | |
| void _handleServerResponse(String line) { | |
| log.trace('<== $line'); | |
| final response = json.decode(line) as Object?; | |
| if (response is Map<String, dynamic>) { | |
| if (response case { | |
| 'event': final String event, | |
| 'params': final Object? params, | |
| }) { | |
| if (params is Map<String, dynamic>) { | |
| _streamController(event).add(params.cast<String, dynamic>()); | |
| } | |
| } else if (response case {'id': final String id}) { | |
| if (response case {'error': final Map<String, Object?> error}) { | |
| _requestCompleters | |
| .remove(id) | |
| ?.completeError( | |
| _RequestError.parse(error.cast<String, dynamic>()), | |
| ); | |
| } else { | |
| _requestCompleters.remove(id)?.complete(response['result'] ?? {}); | |
| } | |
| } | |
| } | |
| } | |
| void _handleServerError(Map<String, dynamic> error) { | |
| _serverErrorReceived = true; | |
| log.stderr('An unexpected error was encountered by the Analysis Server.'); | |
| log.stderr( | |
| 'Please file an issue at ' | |
| 'https://github.com/dart-lang/sdk/issues/new/choose with the following ' | |
| 'details:\n', | |
| ); | |
| // Fields are 'isFatal', 'message', and 'stackTrace'. | |
| log.stderr(error['message']); | |
| final stackTrace = error['stackTrace']; | |
| if (stackTrace is String && stackTrace.isNotEmpty) { | |
| log.stderr(stackTrace); | |
| } | |
| } | |
| StreamController<Map<String, dynamic>> _streamController(String streamId) { | |
| return _streamControllers.putIfAbsent( | |
| streamId, | |
| () => StreamController<Map<String, dynamic>>.broadcast(), | |
| ); | |
| } | |
| Future<bool> dispose() async { | |
| return _process?.kill() ?? true; | |
| } | |
| } | |
| enum _AnalysisSeverity { error, warning, info, none } | |
| class AnalysisError implements Comparable<AnalysisError> { | |
| AnalysisError(this.json); | |
| static final Map<String, _AnalysisSeverity> _severityMap = { | |
| 'INFO': _AnalysisSeverity.info, | |
| 'WARNING': _AnalysisSeverity.warning, | |
| 'ERROR': _AnalysisSeverity.error, | |
| }; | |
| // "severity":"INFO","type":"TODO","location":{ | |
| // "file":"/Users/.../lib/test.dart","offset":362,"length":72,"startLine":15,"startColumn":4 | |
| // },"message":"...","hasFix":false} | |
| final Map<String, dynamic> json; | |
| String get severity => json['severity'] as String; | |
| _AnalysisSeverity get _severityLevel => | |
| _severityMap[severity] ?? _AnalysisSeverity.none; | |
| bool get isInfo => _severityLevel == _AnalysisSeverity.info; | |
| bool get isWarning => _severityLevel == _AnalysisSeverity.warning; | |
| bool get isError => _severityLevel == _AnalysisSeverity.error; | |
| String get type => json['type'] as String; | |
| String get message => json['message'] as String; | |
| String get code => json['code'] as String; | |
| String? get correction => json['correction'] as String?; | |
| int? get endColumn => json['location']['endColumn'] as int?; | |
| int? get endLine => json['location']['endLine'] as int?; | |
| String get file => json['location']['file'] as String; | |
| int? get startLine => json['location']['startLine'] as int?; | |
| int? get startColumn => json['location']['startColumn'] as int?; | |
| int get offset => json['location']['offset'] as int; | |
| int get length => json['location']['length'] as int; | |
| String? get url => json['url'] as String?; | |
| List<DiagnosticMessage> get contextMessages { | |
| if (json['contextMessages'] case List<dynamic> messages) { | |
| return messages.map((message) => DiagnosticMessage(message)).toList(); | |
| } else { | |
| return const []; | |
| } | |
| } | |
| @override | |
| int compareTo(AnalysisError other) { | |
| // Sort in order of severity, file path, error location, and message. | |
| final diff = _severityLevel.index - other._severityLevel.index; | |
| if (diff != 0) { | |
| return diff; | |
| } | |
| if (file != other.file) { | |
| return file.compareTo(other.file); | |
| } | |
| if (offset != other.offset) { | |
| return offset - other.offset; | |
| } | |
| return message.compareTo(other.message); | |
| } | |
| @override | |
| String toString() => | |
| '${severity.toLowerCase()} • ' | |
| '$message • $file:$startLine:$startColumn • ' | |
| '($code)'; | |
| } | |
| extension type DiagnosticMessage(Map<String, dynamic> json) { | |
| int? get column => json['location']['startColumn'] as int?; | |
| int? get endColumn => json['location']['endColumn'] as int?; | |
| int? get endLine => json['location']['endLine'] as int?; | |
| String get filePath => json['location']['file'] as String; | |
| int get length => json['location']['length'] as int; | |
| int get line => json['location']['startLine'] as int; | |
| String get message => json['message'] as String; | |
| int get offset => json['location']['offset'] as int; | |
| } | |
| class FileAnalysisErrors { | |
| final String file; | |
| final List<AnalysisError> errors; | |
| FileAnalysisErrors(this.file, this.errors); | |
| } | |
| class _RequestError { | |
| factory _RequestError.parse(Map<String, dynamic> error) => _RequestError( | |
| error['code'], | |
| error['message'], | |
| ); | |
| final String code; | |
| final String message; | |
| _RequestError(this.code, this.message); | |
| @override | |
| String toString() => '[RequestError code: $code, message: $message]'; | |
| } |