// 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 FileSystem fileSystem, required ProcessManager processManager, required Logger logger, required Platform platform, required Terminal terminal, required this.suppressAnalytics, String? protocolTrafficLog, }) : _fileSystem = fileSystem, _processManager = processManager, _logger = logger, _platform = platform, _terminal = terminal, _protocolTrafficLog = protocolTrafficLog; final String sdkPath; final List 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.broadcast(); final _errorsController = StreamController.broadcast(); var _didServerErrorOccur = false; /// Whether the server is currently analyzing. bool get isAnalyzing => _isAnalyzing; bool _isAnalyzing = false; /// Returns a [Future] that completes when the server is no longer analyzing. /// /// If [delay] is provided, this method will wait for that duration before /// checking if the server is analyzing. if the server starts analyzing during /// that duration, it will wait for analysis to complete. /// /// This is useful to avoid the race condition where analysis hasn't started /// yet after a file change. Future waitForAnalysis({Duration delay = const Duration(milliseconds: 100)}) async { if (_isAnalyzing) { await onAnalyzing.firstWhere((bool analyzing) => !analyzing); } if (delay != Duration.zero) { // Wait for analysis to potentially start. try { await onAnalyzing.firstWhere((bool analyzing) => analyzing).timeout(delay); // If analysis started, wait for it to finish. if (_isAnalyzing) { await onAnalyzing.firstWhere((bool analyzing) => !analyzing); } } on TimeoutException { // Analysis didn't start within the delay, so we assume it's not going to. } } } var _id = 0; final _outstandingRequests = ?>>{}; Future start() async { final command = [ _fileSystem.path.join(sdkPath, 'bin', 'dart'), 'language-server', '--dart-sdk', sdkPath, '--disable-server-feature-completion', '--disable-server-feature-search', 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 errorStream = process.stderr.transform(utf8LineDecoder); errorStream.listen(_handleError); process.stdout.listen(_handleServerResponseRaw); await Future.any([ sendRequest('initialize', { 'processId': pid, 'rootUri': _fileSystem.directory(directories.first).uri.toString(), 'workspaceFolders': [ for (final dir in directories) {'name': dir, 'uri': _fileSystem.directory(dir).uri.toString()}, ], 'capabilities': { 'window': {'workDoneProgress': true}, }, }).then((_) => _sendNotification('initialized', {})), _onExit!, ]); } final _logs = []; /// 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 reversedLogs = _logs.reversed; final List 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; Stream get onAnalyzing => _analyzingController.stream; Stream get onErrors => _errorsController.stream; Future get onExit async => _onExit; Future? _onExit; void _writeMessage({required String message}) { _process?.stdin.write('Content-Length: ${message.length}\r\n\r\n$message'); } Future?> sendRequest(String method, Map params) async { final int id = ++_id; final Completer?> completer = _outstandingRequests[id] = Completer?>(); final String message = json.encode({ 'jsonrpc': '2.0', 'id': id, 'method': method, 'params': params, }); _writeMessage(message: message); _logger.printTrace('==> $message'); return completer.future; } Future connectToDtd({required Uri dtdUri}) async { await sendRequest('dart/connectToDtd', {'uri': dtdUri.toString()}); } void _sendNotification(String method, Map params) { final String message = json.encode({ 'jsonrpc': '2.0', 'method': method, 'params': params, }); _writeMessage(message: message); _logger.printTrace('==> $message'); } void _sendResponse(Object? id, Object? result) { final String message = json.encode({ 'jsonrpc': '2.0', 'id': id, 'result': result, }); _writeMessage(message: message); _logger.printTrace('==> $message'); } final List _byteBuffer = []; void _handleServerResponseRaw(List 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 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) { final Object? id = response['id']; final Completer?>? completer = _outstandingRequests.remove(id); if (completer != null) { if (response case {'result': final Map? result}) { completer.complete(result); } else if (response case {'error': final Map error}) { completer.completeError(error['message'] ?? error); } else { completer.completeError('Response for unknown request received: $response'); } } final method = response['method'] as String?; if (method != null) { final Object? id = response['id']; final Object? params = response['params']; Map? paramsMap; if (params is Map) { 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); } } } } } void _handleProgress(Map 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) { final kind = value['kind'] as String?; if (kind == 'begin') { _isAnalyzing = true; _analyzingController.add(true); } else if (kind == 'end') { _isAnalyzing = false; _analyzingController.add(false); } } } void _handleShowMessage(Map 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 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; final List errors = diagnosticsList .map>((Object? e) => castStringKeyedMap(e) ?? {}) .map((Map json) { return AnalysisError( WrittenError.fromLsp(json, file), fileSystem: _fileSystem, platform: _platform, terminal: _terminal, ); }) .toList(); if (!_errorsController.isClosed) { _errorsController.add(FileAnalysisErrors(file, errors)); } } Future 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( this.writtenError, { required Platform platform, required Terminal terminal, required FileSystem fileSystem, }) : _platform = platform, _terminal = terminal, _fileSystem = 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 json, String file) { final range = json['range']! as Map; final start = range['start']! as Map; 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 = { 'INFO': AnalysisSeverity.info, 'WARNING': AnalysisSeverity.warning, 'ERROR': AnalysisSeverity.error, }; static final _lspSeverityMap = {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 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; } }