| // Copyright (c) 2026, 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 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:analysis_server/src/lsp/constants.dart' show CustomMethods; | |
| import 'package:analysis_server/src/lsp/lsp_packet_transformer.dart'; | |
| import 'package:analysis_server/src/server/driver.dart' show Driver; | |
| import 'package:args/args.dart'; | |
| import 'package:language_server_protocol/protocol_custom_generated.dart'; | |
| import 'package:language_server_protocol/protocol_generated.dart'; | |
| import 'package:language_server_protocol/protocol_special.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'core.dart'; | |
| import 'experiments.dart'; | |
| import 'sdk.dart'; | |
| typedef ProcessFactory = | |
| Future<Process> Function( | |
| String executable, | |
| List<String> arguments, | |
| ); | |
| typedef _Id = Either2<int, String>; | |
| /// A class to provide an API wrapper around an LSP analysis server process. | |
| class LspAnalysisServer { | |
| LspAnalysisServer( | |
| this._packagesFile, | |
| this._sdkPath, | |
| this._analysisRoots, { | |
| this.cacheDirectoryPath, | |
| required this.commandName, | |
| required this.argResults, | |
| required this._usePlugins, | |
| this.enabledExperiments = const [], | |
| this.suppressAnalytics = false, | |
| this._useAotSnapshot = false, | |
| ProcessFactory? processFactory, | |
| }) : _processFactory = | |
| processFactory ?? | |
| ((executable, arguments) => Process.start(executable, arguments)); | |
| final ProcessFactory _processFactory; | |
| 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 suppressAnalytics; | |
| final bool _useAotSnapshot; | |
| final bool _usePlugins; | |
| Process? _process; | |
| final StreamController<String> _outgoingMessages = StreamController<String>( | |
| sync: true, | |
| ); | |
| 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; | |
| /// Waits for any in-progress initialization or analysis to complete. | |
| /// | |
| /// If the server shuts down unexpectedly, the returned future will complete | |
| /// with an error. | |
| Future<void> workspaceAnalysisComplete() { | |
| return _sendRequest(CustomMethods.workspaceAnalysisComplete); | |
| } | |
| Stream<PublishDiagnosticsParams> get onErrors { | |
| return _stream( | |
| Method.textDocument_publishDiagnostics, | |
| PublishDiagnosticsParams.fromJson, | |
| ); | |
| } | |
| /// A stream of work done progress notifications from the analysis server. | |
| Stream<ProgressParams> get onProgress { | |
| return _stream(Method.progress, ProgressParams.fromJson); | |
| } | |
| Future<int> _onExit = Future.value(0); | |
| /// A future that completes when the last spawned process exits with its exit | |
| /// code. | |
| /// | |
| /// If no process has been spawned, will return a completed Future with the | |
| /// value 0. | |
| Future<int> get onExit => _onExit; | |
| final Map<Method, StreamController<NotificationMessage>> _streamControllers = | |
| {}; | |
| /// Whether the process has crashed. | |
| bool get hasCrashed => _onCrash.isCompleted; | |
| /// Completes when an analysis server crash has been detected. | |
| Future<void> get onCrash => _onCrash.future; | |
| final _onCrash = Completer<void>(); | |
| /// Completers that will resolve with the responses for outbound | |
| /// client-to-server requests. | |
| final Map<_Id, Completer<ResponseMessage>> _requestCompleters = {}; | |
| /// Starts the process, performs LSP initialization and returns the pid for it. | |
| Future<int> start({bool setAnalysisRoots = true}) async { | |
| if (_process != null) { | |
| throw StateError('Server process is already running'); | |
| } | |
| final process = await _startProcess(); | |
| _process = process; | |
| _onExit = process.exitCode; | |
| _outgoingMessages.stream | |
| .transform(LspPacketEncoder()) | |
| .listen(process.stdin.add); | |
| _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'); | |
| // 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(LspPacketTransformer()); | |
| inStream.listen(_handleServerMessage); | |
| _stream( | |
| Method.window_logMessage, | |
| LogMessageParams.fromJson, | |
| ).listen(_handleLogMessage); | |
| _stream( | |
| Method.window_showMessage, | |
| ShowMessageParams.fromJson, | |
| ).listen(_handleShowMessage); | |
| // The call to `absolute.resolveSymbolicLinksSync()` canonicalizes the path | |
| // to be passed to the analysis server. | |
| List<WorkspaceFolder> workspaceFolders = setAnalysisRoots | |
| ? _analysisRoots | |
| .map((root) => root.absolute.resolveSymbolicLinksSync()) | |
| .map( | |
| (rootPath) => WorkspaceFolder( | |
| name: path.basename(rootPath), | |
| uri: Uri.file(rootPath), | |
| ), | |
| ) | |
| .toList() | |
| : []; | |
| var initializeResult = await _expectSuccessfulResponse( | |
| Method.initialize, | |
| InitializeParams( | |
| capabilities: ClientCapabilities( | |
| textDocument: TextDocumentClientCapabilities( | |
| publishDiagnostics: PublishDiagnosticsClientCapabilities( | |
| // Allows us to get URLs in diagnostic codes. | |
| codeDescriptionSupport: true, | |
| ), | |
| ), | |
| experimental: { | |
| // Request the server includes fields like offset, length, type, | |
| // correctionMessage in Diagnostic.data. These fields are used in | |
| // the machine/JSON outputs of 'dart analyze' and we want to avoid | |
| // breaking potential consumers. | |
| 'includeAdditionalDiagnosticData': true, | |
| }, | |
| ), | |
| workspaceFolders: workspaceFolders, | |
| ), | |
| InitializeResult.fromJson, | |
| ); | |
| var serverCapabilities = initializeResult.capabilities; | |
| var experimentalCapabilities = serverCapabilities.experimental; | |
| assert(experimentalCapabilities is Map<String, Object?>); | |
| assert( | |
| (experimentalCapabilities as Map<String, Object?>).containsKey( | |
| 'workspaceAnalysisComplete', | |
| ), | |
| ); | |
| _sendNotification(Method.initialized, InitializedParams()); | |
| 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', | |
| '--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', | |
| '--protocol=lsp', | |
| ]; | |
| log.trace('$executable ${arguments.join(' ')}'); | |
| return _processFactory(executable, arguments); | |
| } | |
| /// Sends a hover request. | |
| Future<Hover?> getHover(Uri uri, Position pos) { | |
| return _expectSuccessfulResponse( | |
| Method.textDocument_hover, | |
| TextDocumentPositionParams( | |
| textDocument: TextDocumentIdentifier(uri: uri), | |
| position: pos, | |
| ), | |
| Hover.fromJson, | |
| ); | |
| } | |
| /// Sends a migrate request. | |
| Future<DartMigrateResult?> migrate( | |
| List<Uri> uris, { | |
| bool? apply, | |
| List<MigrationStep>? steps, | |
| String? targetSdk, | |
| ProgressToken? workDoneToken, | |
| }) { | |
| return _expectSuccessfulResponse( | |
| CustomMethods.migrate, | |
| DartMigrateParams( | |
| uris: uris, | |
| apply: apply, | |
| steps: steps, | |
| targetSdk: targetSdk, | |
| workDoneToken: workDoneToken, | |
| ), | |
| DartMigrateResult.fromJson, | |
| ); | |
| } | |
| Future<void> shutdown({Duration? timeout}) async { | |
| // Request shutdown. | |
| var future = _gracefulShutdown(); | |
| if (timeout != null) { | |
| future = future.timeout( | |
| timeout, | |
| onTimeout: () { | |
| log.stderr('The analysis server timed out while shutting down.'); | |
| }, | |
| ); | |
| } | |
| await future.whenComplete(dispose); | |
| } | |
| /// Attempt a graceful shutdown and wait for the process to exit. | |
| Future<void> _gracefulShutdown() async { | |
| try { | |
| await _sendRequest(Method.shutdown); | |
| _shutdownResponseReceived = true; | |
| } catch (_) {} | |
| try { | |
| _sendNotification(Method.exit); | |
| } catch (_) {} | |
| await _process?.exitCode; | |
| } | |
| void _sendLsp(Message message) { | |
| final json = jsonEncode(message.toJson()); | |
| log.trace('==> $json'); | |
| _outgoingMessages.add(json); | |
| } | |
| void _sendNotification(Method method, [Object? params]) { | |
| final message = NotificationMessage( | |
| method: method, | |
| params: params is ToJsonable ? params.toJson() : params, | |
| jsonrpc: jsonRpcVersion, | |
| ); | |
| _sendLsp(message); | |
| } | |
| Future<ResponseMessage> _sendRequest(Method method, [Object? params]) { | |
| final id = _Id.t1(++_id); | |
| final message = RequestMessage( | |
| id: id, | |
| method: method, | |
| params: params is ToJsonable ? params.toJson() : params, | |
| jsonrpc: jsonRpcVersion, | |
| ); | |
| final completer = _requestCompleters[id] = Completer<ResponseMessage>(); | |
| _sendLsp(message); | |
| return completer.future; | |
| } | |
| void _handleLogMessage(LogMessageParams message) { | |
| if (message.type == MessageType.Error) { | |
| _handleServerError(message.message); | |
| } | |
| } | |
| void _handleServerMessage(String line) { | |
| log.trace('<== $line'); | |
| final response = json.decode(line) as Object?; | |
| if (response is Map<String, Object?>) { | |
| var message = Message.fromJson(response); | |
| switch (message) { | |
| case NotificationMessage(): | |
| _streamControllers[message.method]?.add(message); | |
| break; | |
| case RequestMessage(): | |
| // We don't expect reverse-requests from the server in this mode. | |
| break; | |
| case ResponseMessage(): | |
| _requestCompleters.remove(message.id)?.complete(message); | |
| break; | |
| } | |
| } | |
| } | |
| void _handleServerError(String message, [String? stackTrace]) { | |
| _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', | |
| ); | |
| log.stderr(message); | |
| if (stackTrace case String(isNotEmpty: true)) { | |
| log.stderr(stackTrace); | |
| } | |
| } | |
| void _handleShowMessage(ShowMessageParams message) { | |
| if (message.type == MessageType.Error) { | |
| _handleServerError(message.message); | |
| } | |
| } | |
| Stream<T> _stream<T, R>(Method method, T Function(R) converter) { | |
| final controller = _streamControllers.putIfAbsent( | |
| method, | |
| () => StreamController<NotificationMessage>.broadcast(), | |
| ); | |
| return controller.stream | |
| .map((message) => message.params) | |
| .cast<R>() | |
| .map(converter); | |
| } | |
| bool dispose() { | |
| return _process?.kill() ?? true; | |
| } | |
| Future<T> _expectSuccessfulResponse<T, R>( | |
| Method method, | |
| Object? params, | |
| T Function(R) converter, | |
| ) async { | |
| var response = await _sendRequest(method, params); | |
| if (response.error case var error?) { | |
| throw LspRequestError(error); | |
| } | |
| return converter(response.result as R); | |
| } | |
| Future<DartGetWorkspaceFixesResult> getFixes(List<String> codes) async { | |
| return _expectSuccessfulResponse( | |
| CustomMethods.getWorkspaceFixes, | |
| DartGetWorkspaceFixesParams( | |
| diagnosticCodes: codes.isEmpty ? null : codes, | |
| ), | |
| DartGetWorkspaceFixesResult.fromJson, | |
| ); | |
| } | |
| } | |
| class LspRequestError implements Exception { | |
| final ResponseError error; | |
| LspRequestError(this.error); | |
| @override | |
| String toString() => 'LspException: ${error.code}: ${error.message}'; | |
| } |