| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'dart:math' as math; | |
| import 'package:process/process.dart'; | |
| import '../base/file_system.dart'; | |
| import '../base/io.dart'; | |
| import '../base/logger.dart'; | |
| import '../base/platform.dart'; | |
| import '../base/terminal.dart'; | |
| import '../base/utils.dart'; | |
| import '../convert.dart'; | |
| import '../globals.dart' as globals; | |
| /// An interface to the Dart analysis server. | |
| class AnalysisServer { | |
| AnalysisServer( | |
| this.sdkPath, | |
| this.directories, { | |
| required this._fileSystem, | |
| required this._processManager, | |
| required this._logger, | |
| required this._platform, | |
| required this._terminal, | |
| required this.suppressAnalytics, | |
| this.withFineDependencies = true, | |
| this.usePlugins = true, | |
| this._protocolTrafficLog, | |
| }); | |
| final bool withFineDependencies; | |
| final bool usePlugins; | |
| final String sdkPath; | |
| final List<String> directories; | |
| final FileSystem _fileSystem; | |
| final ProcessManager _processManager; | |
| final Logger _logger; | |
| final Platform _platform; | |
| final Terminal _terminal; | |
| final String? _protocolTrafficLog; | |
| final bool suppressAnalytics; | |
| Process? _process; | |
| final _analyzingController = StreamController<bool>.broadcast(); | |
| final _errorsController = StreamController<FileAnalysisErrors>.broadcast(); | |
| var _didServerErrorOccur = false; | |
| /// Returns a [Future] that completes when the server has completed any | |
| /// in-progress initialization or analysis. | |
| /// | |
| /// This method will wait for [delay] before calling the server. If not | |
| /// provided, defaults to 100ms. | |
| /// | |
| /// This is useful to avoid the race condition where analysis hasn't started | |
| /// yet after a file change. | |
| Future<void> waitForAnalysis({Duration delay = const Duration(milliseconds: 100)}) async { | |
| await Future<void>.delayed(delay); | |
| await sendRequest('dart/workspace/analysis/complete', {}); | |
| } | |
| var _id = 0; | |
| final _outstandingRequests = <int, Completer<Map<String, Object?>?>>{}; | |
| Future<void> start() async { | |
| final command = <String>[ | |
| _fileSystem.path.join(sdkPath, 'bin', 'dart'), | |
| 'language-server', | |
| '--dart-sdk', | |
| sdkPath, | |
| '--disable-server-feature-completion', | |
| '--disable-server-feature-search', | |
| if (!withFineDependencies) '--no-with-fine-dependencies', | |
| if (!usePlugins) '--no-plugins', | |
| if (suppressAnalytics) '--suppress-analytics', | |
| if (_protocolTrafficLog != null) '--protocol-traffic-log=$_protocolTrafficLog', | |
| ]; | |
| _logger.printTrace('dart ${command.skip(1).join(' ')}'); | |
| final Process process = _process = await _processManager.start(command); | |
| _onExit = process.exitCode; | |
| // This callback hookup can't throw. | |
| unawaited(process.exitCode.whenComplete(() => _process = null)); | |
| final Stream<String> errorStream = process.stderr.transform(utf8LineDecoder); | |
| errorStream.listen(_handleError); | |
| process.stdout.listen(_handleServerResponseRaw); | |
| await Future.any<void>([ | |
| sendRequest('initialize', <String, Object?>{ | |
| 'processId': pid, | |
| 'rootUri': _fileSystem.directory(directories.first).uri.toString(), | |
| 'workspaceFolders': [ | |
| for (final dir in directories) | |
| {'name': dir, 'uri': _fileSystem.directory(dir).uri.toString()}, | |
| ], | |
| 'capabilities': <String, Object?>{ | |
| 'window': <String, Object?>{'workDoneProgress': true}, | |
| }, | |
| }).then((_) => _sendNotification('initialized', <String, Object?>{})), | |
| _onExit!, | |
| ]); | |
| } | |
| final _logs = <String>[]; | |
| /// Aggregated STDOUT and STDERR logs from the server. | |
| /// | |
| /// This can be surfaced to the user if the server crashes. If [tail] is null, | |
| /// returns all logs, else only the last [tail] lines. | |
| String getLogs([int? tail]) { | |
| if (tail == null) { | |
| return _logs.join('\n'); | |
| } | |
| // Since List doesn't implement a .tail() method, we reverse it then use | |
| // .take() | |
| final Iterable<String> reversedLogs = _logs.reversed; | |
| final List<String> firstTailLogs = reversedLogs.take(tail).toList(); | |
| return firstTailLogs.reversed.join('\n'); | |
| } | |
| void _handleError(String message) { | |
| _logs.add('[stderr] $message'); | |
| _logger.printError(message); | |
| } | |
| bool get didServerErrorOccur => _didServerErrorOccur; | |
| /// A stream of booleans indicating that the server is starting or stopping | |
| /// analysis. | |
| /// | |
| /// These statuses are intended to show progress to a user and not intended | |
| /// as a reliable indicator that all analysis has completed. To know when | |
| /// analysis has definitely completed, use [waitForAnalysis]. | |
| Stream<bool> get onAnalyzing => _analyzingController.stream; | |
| Stream<FileAnalysisErrors> get onErrors => _errorsController.stream; | |
| Future<int?> get onExit async => _onExit; | |
| Future<int?>? _onExit; | |
| void _writeMessage({required String message}) { | |
| final List<int> encodedMessage = utf8.encode(message); | |
| _process?.stdin.write('Content-Length: ${encodedMessage.length}\r\n\r\n$message'); | |
| } | |
| Future<Map<String, Object?>?> sendRequest(String method, Map<String, Object?> params) async { | |
| final int id = ++_id; | |
| final Completer<Map<String, Object?>?> completer = _outstandingRequests[id] = | |
| Completer<Map<String, Object?>?>(); | |
| final String message = json.encode(<String, Object?>{ | |
| 'jsonrpc': '2.0', | |
| 'id': id, | |
| 'method': method, | |
| 'params': params, | |
| }); | |
| _writeMessage(message: message); | |
| _logger.printTrace('==> $message'); | |
| return completer.future; | |
| } | |
| Future<void> connectToDtd({required Uri dtdUri}) async { | |
| await sendRequest('dart/connectToDtd', <String, Object?>{'uri': dtdUri.toString()}); | |
| } | |
| void _sendNotification(String method, Map<String, Object?> params) { | |
| final String message = json.encode(<String, Object?>{ | |
| 'jsonrpc': '2.0', | |
| 'method': method, | |
| 'params': params, | |
| }); | |
| _writeMessage(message: message); | |
| _logger.printTrace('==> $message'); | |
| } | |
| void _sendResponse(Object? id, Object? result) { | |
| final String message = json.encode(<String, Object?>{ | |
| 'jsonrpc': '2.0', | |
| 'id': id, | |
| 'result': result, | |
| }); | |
| _writeMessage(message: message); | |
| _logger.printTrace('==> $message'); | |
| } | |
| final List<int> _byteBuffer = <int>[]; | |
| void _handleServerResponseRaw(List<int> data) { | |
| _byteBuffer.addAll(data); | |
| while (_byteBuffer.isNotEmpty) { | |
| // Find \r\n\r\n header separator | |
| var byteHeaderEnd = -1; | |
| for (var i = 0; i < _byteBuffer.length - 3; i++) { | |
| if (_byteBuffer[i] == 13 && | |
| _byteBuffer[i + 1] == 10 && | |
| _byteBuffer[i + 2] == 13 && | |
| _byteBuffer[i + 3] == 10) { | |
| byteHeaderEnd = i; | |
| break; | |
| } | |
| } | |
| if (byteHeaderEnd == -1) { | |
| break; | |
| } | |
| final String headers = utf8.decode(_byteBuffer.sublist(0, byteHeaderEnd)); | |
| final int contentLength = _parseContentLength(headers); | |
| if (contentLength == -1) { | |
| _logger.printTrace('No Content-Length found in headers:\n$headers'); | |
| _byteBuffer.removeRange(0, byteHeaderEnd + 4); | |
| continue; | |
| } | |
| if (_byteBuffer.length < byteHeaderEnd + 4 + contentLength) { | |
| break; | |
| } | |
| final List<int> messageBytes = _byteBuffer.sublist( | |
| byteHeaderEnd + 4, | |
| byteHeaderEnd + 4 + contentLength, | |
| ); | |
| _byteBuffer.removeRange(0, byteHeaderEnd + 4 + contentLength); | |
| final String message = utf8.decode(messageBytes); | |
| _handleServerResponse(message); | |
| } | |
| } | |
| static final RegExp _contentLengthRegExp = RegExp( | |
| r'content-length:\s*(\d+)', | |
| caseSensitive: false, | |
| ); | |
| int _parseContentLength(String headers) { | |
| final Match? match = _contentLengthRegExp.firstMatch(headers); | |
| if (match != null) { | |
| return int.tryParse(match.group(1)!) ?? -1; | |
| } | |
| return -1; | |
| } | |
| void _handleServerResponse(String line) { | |
| _logs.add('[stdout] $line'); | |
| _logger.printTrace('<== $line'); | |
| if (line.startsWith(globals.kVMServiceMessageRegExp)) { | |
| return; | |
| } | |
| final Object? response = json.decode(line); | |
| if (response is Map<String, Object?>) { | |
| final method = response['method'] as String?; | |
| if (method != null) { | |
| final Object? id = response['id']; | |
| final Object? params = response['params']; | |
| Map<String, Object?>? paramsMap; | |
| if (params is Map<String, Object?>) { | |
| paramsMap = castStringKeyedMap(params); | |
| } | |
| if (id != null) { | |
| // Handle requests from the server | |
| switch (method) { | |
| case 'window/workDoneProgress/create': | |
| _sendResponse(id, null); | |
| } | |
| } else if (paramsMap != null) { | |
| // Handle notifications from the server | |
| switch (method) { | |
| case r'$/progress': | |
| _handleProgress(paramsMap); | |
| case 'textDocument/publishDiagnostics': | |
| _handleAnalysisIssues(paramsMap); | |
| case 'window/showMessage': | |
| _handleShowMessage(paramsMap); | |
| } | |
| } | |
| } else { | |
| final Object? id = response['id']; | |
| final Completer<Map<String, Object?>?>? completer = _outstandingRequests.remove(id); | |
| if (completer != null) { | |
| if (response case {'result': final Map<String, Object?>? result}) { | |
| completer.complete(result); | |
| } else if (response case {'error': final Map<String, Object?> error}) { | |
| completer.completeError(error['message'] ?? error); | |
| } else { | |
| completer.completeError('Response for unknown request received: $response'); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| void _handleProgress(Map<String, Object?> params) { | |
| // LSP progress for analysis is typically reported via tokens. | |
| // The server sends begin/report/end for a token. | |
| final Object? value = params['value']; | |
| if (value is Map<String, Object?>) { | |
| final kind = value['kind'] as String?; | |
| if (kind == 'begin') { | |
| _analyzingController.add(true); | |
| } else if (kind == 'end') { | |
| _analyzingController.add(false); | |
| } | |
| } | |
| } | |
| void _handleShowMessage(Map<String, Object?> params) { | |
| final typeId = params['type'] as int?; | |
| final _ShowMessageType? type = _ShowMessageType.fromId(typeId); | |
| final message = params['message']! as String; | |
| switch (type) { | |
| case _ShowMessageType.error: | |
| _didServerErrorOccur = true; | |
| _logger.printError('Error from the analysis server: $message'); | |
| case _ShowMessageType.warning: | |
| _logger.printWarning('Warning from the analysis server: $message'); | |
| case _ShowMessageType.info: | |
| _logger.printStatus('Info from the analysis server: $message'); | |
| case _ShowMessageType.log: | |
| _logger.printTrace('Log from the analysis server: $message'); | |
| case null: | |
| _logger.printStatus('Message from the analysis server: $message'); | |
| } | |
| } | |
| void _handleAnalysisIssues(Map<String, Object?> params) { | |
| // {"method":"textDocument/publishDiagnostics","params":{"uri":"file:///.../lib/main.dart","diagnostics":[]}} | |
| final Uri uri = Uri.parse(params['uri']! as String); | |
| final String file; | |
| try { | |
| file = uri.toFilePath(); | |
| } on UnsupportedError { | |
| _logger.printTrace( | |
| 'URI in analysis issues message is not a valid file URI: ${params['uri']}. Ignoring.', | |
| ); | |
| return; | |
| } | |
| final diagnosticsList = params['diagnostics']! as List<Object?>; | |
| final List<AnalysisError> errors = diagnosticsList | |
| .map<Map<String, Object?>>((Object? e) => castStringKeyedMap(e) ?? <String, Object?>{}) | |
| .map<AnalysisError>((Map<String, Object?> json) { | |
| return AnalysisError( | |
| WrittenError.fromLsp(json, file), | |
| fileSystem: _fileSystem, | |
| platform: _platform, | |
| terminal: _terminal, | |
| ); | |
| }) | |
| .toList(); | |
| if (!_errorsController.isClosed) { | |
| _errorsController.add(FileAnalysisErrors(file, errors)); | |
| } | |
| } | |
| Future<bool?> dispose() async { | |
| await _analyzingController.close(); | |
| await _errorsController.close(); | |
| return _process?.kill(); | |
| } | |
| } | |
| enum AnalysisSeverity { error, warning, info, none } | |
| /// [AnalysisError] with command line style. | |
| class AnalysisError implements Comparable<AnalysisError> { | |
| AnalysisError( | |
| this.writtenError, { | |
| required this._platform, | |
| required this._terminal, | |
| required this._fileSystem, | |
| }); | |
| final WrittenError writtenError; | |
| final Platform _platform; | |
| final Terminal _terminal; | |
| final FileSystem _fileSystem; | |
| String get _separator => _platform.isWindows ? '-' : '•'; | |
| String get colorSeverity => switch (writtenError.severityLevel) { | |
| AnalysisSeverity.error => _terminal.color(writtenError.severity, TerminalColor.red), | |
| AnalysisSeverity.warning => _terminal.color(writtenError.severity, TerminalColor.yellow), | |
| AnalysisSeverity.info || AnalysisSeverity.none => writtenError.severity, | |
| }; | |
| String get code => writtenError.code; | |
| @override | |
| int compareTo(AnalysisError other) { | |
| // Sort in order of file path, error location, severity, and message. | |
| if (writtenError.file != other.writtenError.file) { | |
| return writtenError.file.compareTo(other.writtenError.file); | |
| } | |
| if (writtenError.startLine != other.writtenError.startLine) { | |
| return writtenError.startLine - other.writtenError.startLine; | |
| } | |
| if (writtenError.startColumn != other.writtenError.startColumn) { | |
| return writtenError.startColumn - other.writtenError.startColumn; | |
| } | |
| final int diff = other.writtenError.severityLevel.index - writtenError.severityLevel.index; | |
| if (diff != 0) { | |
| return diff; | |
| } | |
| return writtenError.message.compareTo(other.writtenError.message); | |
| } | |
| @override | |
| String toString() { | |
| // Can't use "padLeft" because of ANSI color sequences in the colorized | |
| // severity. | |
| final String padding = ' ' * math.max(0, 7 - writtenError.severity.length); | |
| return '$padding${colorSeverity.toLowerCase()} $_separator ' | |
| '${writtenError.messageSentenceFragment} $_separator ' | |
| '${_fileSystem.path.relative(writtenError.file)}:${writtenError.startLine}:${writtenError.startColumn} $_separator ' | |
| '$code'; | |
| } | |
| String toLegacyString() { | |
| return writtenError.toString(); | |
| } | |
| } | |
| /// [AnalysisError] in plain text content. | |
| class WrittenError { | |
| WrittenError._({ | |
| required this.severity, | |
| required this.message, | |
| required this.code, | |
| required this.file, | |
| required this.startLine, | |
| required this.startColumn, | |
| }); | |
| static WrittenError fromLsp(Map<String, Object?> json, String file) { | |
| final range = json['range']! as Map<String, Object?>; | |
| final start = range['start']! as Map<String, Object?>; | |
| final severity = json['severity'] as int?; | |
| return WrittenError._( | |
| severity: _lspSeverityMap[severity] ?? 'INFO', | |
| message: json['message']! as String, | |
| code: (json['code'] ?? '').toString(), | |
| file: file, | |
| // LSP is 0-indexed. | |
| startLine: (start['line']! as int) + 1, | |
| startColumn: (start['character']! as int) + 1, | |
| ); | |
| } | |
| final String severity; | |
| final String message; | |
| final String code; | |
| final String file; | |
| final int startLine; | |
| final int startColumn; | |
| static final _severityMap = <String, AnalysisSeverity>{ | |
| 'INFO': AnalysisSeverity.info, | |
| 'WARNING': AnalysisSeverity.warning, | |
| 'ERROR': AnalysisSeverity.error, | |
| }; | |
| static final _lspSeverityMap = <int, String>{1: 'ERROR', 2: 'WARNING', 3: 'INFO', 4: 'INFO'}; | |
| AnalysisSeverity get severityLevel => _severityMap[severity] ?? AnalysisSeverity.none; | |
| String get messageSentenceFragment { | |
| final String cleanMessage = message.replaceAll('\n', ' '); | |
| if (cleanMessage.endsWith('.')) { | |
| return cleanMessage.substring(0, cleanMessage.length - 1); | |
| } | |
| return cleanMessage; | |
| } | |
| @override | |
| String toString() { | |
| return '[${severity.toLowerCase()}] $messageSentenceFragment ($file:$startLine:$startColumn)'; | |
| } | |
| } | |
| class FileAnalysisErrors { | |
| FileAnalysisErrors(this.file, this.errors); | |
| final String file; | |
| final List<AnalysisError> errors; | |
| } | |
| enum _ShowMessageType { | |
| error(1), | |
| warning(2), | |
| info(3), | |
| log(4); | |
| const _ShowMessageType(this.id); | |
| final int id; | |
| static _ShowMessageType? fromId(int? id) { | |
| if (id == null) { | |
| return null; | |
| } | |
| for (final _ShowMessageType type in values) { | |
| if (type.id == id) { | |
| return type; | |
| } | |
| } | |
| return null; | |
| } | |
| } |