[analysis_server] Wait for analysis context rebuilds when obtaining parsed/resolved results in LSP handlers

Fixes https://github.com/Dart-Code/Dart-Code/issues/3929.

Change-Id: I3176a603785b5e9546a4c1d7c3f818396eaec4bd
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/241606
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_select_range.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_select_range.dart
index 3c4a7a4..c567939 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_select_range.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_select_range.dart
@@ -10,7 +10,6 @@
 import 'package:analysis_server/src/lsp/mapping.dart';
 import 'package:analyzer/dart/analysis/results.dart';
 import 'package:analyzer/dart/ast/ast.dart';
-import 'package:analyzer/source/line_info.dart';
 
 class SelectionRangeHandler
     extends MessageHandler<SelectionRangeParams, List<SelectionRange>?> {
@@ -31,26 +30,20 @@
 
     final path = pathOfDoc(params.textDocument);
     return path.mapResult((path) async {
-      final lineInfo = server.getLineInfo(path);
-      // If there is no lineInfo, the request cannot be translated from LSP
-      // line/col to server offset/length.
-      if (lineInfo == null) {
-        return success(null);
-      }
-
       final unit = await requireUnresolvedUnit(path);
       final positions = params.positions;
       final offsets = await unit.mapResult((unit) =>
           ErrorOr.all(positions.map((pos) => toOffset(unit.lineInfo, pos))));
-      final allRanges = await offsets.mapResult((offsets) =>
-          success(_getSelectionRangesForOffsets(offsets, unit, lineInfo)));
+      final allRanges = await offsets.mapResult(
+          (offsets) => success(_getSelectionRangesForOffsets(offsets, unit)));
 
       return allRanges;
     });
   }
 
   SelectionRange _getSelectionRangesForOffset(
-      CompilationUnit unit, LineInfo lineInfo, int offset) {
+      CompilationUnit unit, int offset) {
+    final lineInfo = unit.lineInfo;
     final computer = DartSelectionRangeComputer(unit, offset);
     final ranges = computer.compute();
     // Loop through the items starting at the end (the outermost range), using
@@ -64,20 +57,19 @@
       );
     }
 
-    // It's not clear how to respond if a subset of the results
-    // do not have results, so for now if the list is empty just return a single
-    // range that is exactly the same as the position.
-    // TODO(dantup): Update this based on the response to
-    // https://github.com/microsoft/language-server-protocol/issues/1270
-
+    // When there is no range for a given position, return an empty range at the
+    // requested position. From the LSP spec:
+    //
+    // "To allow for results where some positions have selection ranges and
+    //  others do not, result[i].range is allowed to be the empty range at
+    //  positions[i]."
     return last ?? SelectionRange(range: toRange(lineInfo, offset, 0));
   }
 
   List<SelectionRange> _getSelectionRangesForOffsets(
-      List<int> offsets, ErrorOr<ParsedUnitResult> unit, LineInfo lineInfo) {
+      List<int> offsets, ErrorOr<ParsedUnitResult> unit) {
     return offsets
-        .map((offset) =>
-            _getSelectionRangesForOffset(unit.result.unit, lineInfo, offset))
+        .map((offset) => _getSelectionRangesForOffset(unit.result.unit, offset))
         .toList();
   }
 }
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart b/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart
index 1880af3..6a8fd79 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart
@@ -49,12 +49,18 @@
 
   LspAnalysisServer get server;
 
+  ErrorOr<T> analysisFailedError<T>(String path) => error<T>(
+      ServerErrorCodes.FileAnalysisFailed, 'Analysis failed for file', path);
+
   bool fileHasBeenModified(String path, num? clientVersion) {
     final serverDocIdentifier = server.getVersionedDocumentIdentifier(path);
     return clientVersion != null &&
         clientVersion != serverDocIdentifier.version;
   }
 
+  ErrorOr<T> fileNotAnalyzedError<T>(String path) => error<T>(
+      ServerErrorCodes.FileNotAnalyzed, 'File is not being analyzed', path);
+
   ErrorOr<LineInfo> getLineInfo(String path) {
     final lineInfo = server.getLineInfo(path);
 
@@ -66,19 +72,30 @@
     }
   }
 
-  Future<ErrorOr<ResolvedUnitResult>> requireResolvedUnit(String path) async {
+  /// Attempts to get a [ResolvedUnitResult] for [path] or an error.
+  ///
+  /// When [waitForInProgressContextRebuilds] is `true` and the file appears to not be
+  /// analyzed but analysis roots are currently being discovered, will wait for
+  /// discovery to complete and then try again (once) to get a result.
+  Future<ErrorOr<ResolvedUnitResult>> requireResolvedUnit(
+    String path, {
+    bool waitForInProgressContextRebuilds = true,
+  }) async {
     final result = await server.getResolvedUnit(path);
     if (result == null) {
-      if (server.isAnalyzed(path)) {
-        // If the file was being analyzed and we got a null result, that usually
-        // indicators a parser or analysis failure, so provide a more specific
-        // message.
-        return error(ServerErrorCodes.FileAnalysisFailed,
-            'Analysis failed for file', path);
-      } else {
-        return error(ServerErrorCodes.FileNotAnalyzed,
-            'File is not being analyzed', path);
+      // Handle retry if allowed.
+      if (waitForInProgressContextRebuilds) {
+        await server.analysisContextsRebuilt;
+        return requireResolvedUnit(path,
+            waitForInProgressContextRebuilds: false);
       }
+
+      // If the file was being analyzed and we got a null result, that usually
+      // indicates a parser or analysis failure, so provide a more specific
+      // message.
+      return server.isAnalyzed(path)
+          ? analysisFailedError(path)
+          : fileNotAnalyzedError(path);
     } else if (!result.exists) {
       return error(
           ServerErrorCodes.InvalidFilePath, 'File does not exist', path);
@@ -86,19 +103,25 @@
     return success(result);
   }
 
-  Future<ErrorOr<ParsedUnitResult>> requireUnresolvedUnit(String path) async {
+  Future<ErrorOr<ParsedUnitResult>> requireUnresolvedUnit(
+    String path, {
+    bool waitForInProgressContextRebuilds = true,
+  }) async {
     final result = await server.getParsedUnit(path);
     if (result == null) {
-      if (server.isAnalyzed(path)) {
-        // If the file was being analyzed and we got a null result, that usually
-        // indicators a parser or analysis failure, so provide a more specific
-        // message.
-        return error(ServerErrorCodes.FileAnalysisFailed,
-            'Analysis failed for file', path);
-      } else {
-        return error(ServerErrorCodes.FileNotAnalyzed,
-            'File is not being analyzed', path);
+      // Handle retry if allowed.
+      if (waitForInProgressContextRebuilds) {
+        await server.analysisContextsRebuilt;
+        return requireUnresolvedUnit(path,
+            waitForInProgressContextRebuilds: false);
       }
+
+      // If the file was being analyzed and we got a null result, that usually
+      // indicates a parser or analysis failure, so provide a more specific
+      // message.
+      return server.isAnalyzed(path)
+          ? analysisFailedError(path)
+          : fileNotAnalyzedError(path);
     }
     return success(result);
   }
diff --git a/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart b/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
index 4667875..dba5fc2 100644
--- a/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
+++ b/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
@@ -120,6 +120,11 @@
   /// The subscription to the stream of incoming messages from the client.
   late StreamSubscription<void> _channelSubscription;
 
+  /// A completer that tracks in-progress analysis context rebuilds.
+  ///
+  /// Starts completed and will be replaced each time a context rebuild starts.
+  Completer<void> _analysisContextRebuildCompleter = Completer()..complete();
+
   /// Initialize a newly created server to send and receive messages to the
   /// given [channel].
   LspAnalysisServer(
@@ -163,6 +168,14 @@
         pluginManager.pluginsChanged.listen((_) => _onPluginsChanged());
   }
 
+  /// A [Future] that completes when any in-progress analysis context rebuild
+  /// completes.
+  ///
+  /// If no context rebuild is in progress, will return an already complete
+  /// [Future].
+  Future<void> get analysisContextsRebuilt =>
+      _analysisContextRebuildCompleter.future;
+
   /// The capabilities of the LSP client. Will be null prior to initialization.
   LspClientCapabilities? get clientCapabilities => _clientCapabilities;
 
@@ -788,10 +801,15 @@
                 (root) => resourceProvider.pathContext.join(root, excludePath)))
         .toSet();
 
-    notificationManager.setAnalysisRoots(
-        includedPaths.toList(), excludedPaths.toList());
-    await contextManager.setRoots(
-        includedPaths.toList(), excludedPaths.toList());
+    final completer = _analysisContextRebuildCompleter = Completer();
+    try {
+      notificationManager.setAnalysisRoots(
+          includedPaths.toList(), excludedPaths.toList());
+      await contextManager.setRoots(
+          includedPaths.toList(), excludedPaths.toList());
+    } finally {
+      completer.complete();
+    }
   }
 
   void _updateDriversAndPluginsPriorityFiles() {
diff --git a/pkg/analysis_server/test/lsp/document_symbols_test.dart b/pkg/analysis_server/test/lsp/document_symbols_test.dart
index 2fb6196..43b129b 100644
--- a/pkg/analysis_server/test/lsp/document_symbols_test.dart
+++ b/pkg/analysis_server/test/lsp/document_symbols_test.dart
@@ -27,7 +27,7 @@
     newFile(mainFilePath, content);
     await initialize();
 
-    final result = await getDocumentSymbols(mainFileUri.toString());
+    final result = await getDocumentSymbols(mainFileUri);
     final symbols = result.map(
       (docsymbols) => throw 'Expected SymbolInformations, got DocumentSymbols',
       (symbolInfos) => symbolInfos,
@@ -61,7 +61,7 @@
       ),
     );
 
-    final result = await getDocumentSymbols(mainFileUri.toString());
+    final result = await getDocumentSymbols(mainFileUri);
     final symbols = result.map(
       (docsymbols) => throw 'Expected SymbolInformations, got DocumentSymbols',
       (symbolInfos) => symbolInfos,
@@ -93,7 +93,7 @@
     newFile(mainFilePath, content);
     await initialize();
 
-    final result = await getDocumentSymbols(mainFileUri.toString());
+    final result = await getDocumentSymbols(mainFileUri);
     final symbols = result.map(
       (docsymbols) => throw 'Expected SymbolInformations, got DocumentSymbols',
       (symbolInfos) => symbolInfos,
@@ -148,7 +148,7 @@
         textDocumentCapabilities: withHierarchicalDocumentSymbolSupport(
             emptyTextDocumentClientCapabilities));
 
-    final result = await getDocumentSymbols(mainFileUri.toString());
+    final result = await getDocumentSymbols(mainFileUri);
     final symbols = result.map(
       (docsymbols) => docsymbols,
       (symbolInfos) => throw 'Expected DocumentSymbols, got SymbolInformations',
@@ -186,7 +186,7 @@
     await initialize(allowEmptyRootUri: true);
     await openFile(mainFileUri, content);
 
-    final result = await getDocumentSymbols(mainFileUri.toString());
+    final result = await getDocumentSymbols(mainFileUri);
     final symbols = result.map(
       (docsymbols) => throw 'Expected SymbolInformations, got DocumentSymbols',
       (symbolInfos) => symbolInfos,
@@ -207,7 +207,7 @@
     await initialize(allowEmptyRootUri: true);
 
     await expectLater(
-        getDocumentSymbols(mainFileUri.toString()),
+        getDocumentSymbols(mainFileUri),
         throwsA(isResponseError(ServerErrorCodes.FileNotAnalyzed,
             message: 'File is not being analyzed')));
   }
@@ -216,7 +216,7 @@
     newFile(pubspecFilePath, simplePubspecContent);
     await initialize();
 
-    final result = await getDocumentSymbols(pubspecFileUri.toString());
+    final result = await getDocumentSymbols(pubspecFileUri);
     // Since the list is empty, it will deserialise into whatever the first
     // type is, so just accept both types.
     final symbols = result.map(
diff --git a/pkg/analysis_server/test/lsp/initialization_test.dart b/pkg/analysis_server/test/lsp/initialization_test.dart
index a05138c..68fc852 100644
--- a/pkg/analysis_server/test/lsp/initialization_test.dart
+++ b/pkg/analysis_server/test/lsp/initialization_test.dart
@@ -2,12 +2,15 @@
 // 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 '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/json_parsing.dart';
 import 'package:analysis_server/src/lsp/server_capabilities_computer.dart';
 import 'package:analysis_server/src/plugin/plugin_manager.dart';
+import 'package:analyzer/file_system/memory_file_system.dart';
 import 'package:test/test.dart';
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
@@ -16,6 +19,7 @@
 void main() {
   defineReflectiveSuite(() {
     defineReflectiveTests(InitializationTest);
+    defineReflectiveTests(SlowInitializationTest);
   });
 }
 
@@ -495,6 +499,54 @@
     );
   }
 
+  /// Tests that when there are no explicit analysis roots (instead they are
+  /// implied by open files), requests for open files are successful even if
+  /// sent _immediately_ after opening the file.
+  ///
+  /// https://github.com/Dart-Code/Dart-Code/issues/3929
+  ///
+  /// This test uses getDocumentSymbols which requires a resolved result.
+  Future<void>
+      test_emptyAnalysisRoots_handlesFileRequestsImmediately_resolved() async {
+    const content = 'void f() {}';
+    final file1 = join(projectFolderPath, 'file1.dart');
+    final file1Uri = Uri.file(file1);
+    newFile(file1, content);
+    newPubspecYamlFile(projectFolderPath, '');
+
+    await initialize(allowEmptyRootUri: true);
+
+    unawaited(openFile(file1Uri, content)); // Don't wait
+    final result = await getDocumentSymbols(file1Uri);
+    final symbols = result.map(
+      (docSymbols) => docSymbols,
+      (symbolInfos) => symbolInfos,
+    );
+    expect(symbols, hasLength(1));
+  }
+
+  /// Tests that when there are no explicit analysis roots (instead they are
+  /// implied by open files), requests for open files are successful even if
+  /// sent _immediately_ after opening the file.
+  ///
+  /// https://github.com/Dart-Code/Dart-Code/issues/3929
+  ///
+  /// This test uses getSelectionRanges which requires only a parsed result.
+  Future<void>
+      test_emptyAnalysisRoots_handlesFileRequestsImmediately_unresolved() async {
+    const content = 'void f() {}';
+    final file1 = join(projectFolderPath, 'file1.dart');
+    final file1Uri = Uri.file(file1);
+    newFile(file1, content);
+    newPubspecYamlFile(projectFolderPath, '');
+
+    await initialize(allowEmptyRootUri: true);
+
+    unawaited(openFile(file1Uri, content)); // Don't wait
+    final result = await getSelectionRanges(file1Uri, [startOfDocPos]);
+    expect(result, hasLength(1));
+  }
+
   Future<void> test_emptyAnalysisRoots_multipleOpenFiles() async {
     final file1 = join(projectFolderPath, 'file1.dart');
     final file1Uri = Uri.file(file1);
@@ -509,12 +561,14 @@
     // Opening both files should only add the project folder once.
     await openFile(file1Uri, '');
     await openFile(file2Uri, '');
+    await pumpEventQueue(times: 5000);
     expect(server.contextManager.includedPaths, equals([projectFolderPath]));
 
     // Closing only one of the files should not remove the project folder
     // since there are still open files.
     resetContextBuildCounter();
     await closeFile(file1Uri);
+    await pumpEventQueue(times: 5000);
     expect(server.contextManager.includedPaths, equals([projectFolderPath]));
     expectNoContextBuilds();
 
@@ -522,6 +576,7 @@
     // the context.
     resetContextBuildCounter();
     await closeFile(file2Uri);
+    await pumpEventQueue(times: 5000);
     expect(server.contextManager.includedPaths, equals([]));
     expect(server.contextManager.driverMap, hasLength(0));
     expectContextBuilds();
@@ -556,6 +611,7 @@
 
     // Opening a file nested within the project should add the project folder.
     await openFile(nestedFileUri, '');
+    await pumpEventQueue(times: 500);
     expect(server.contextManager.includedPaths, equals([projectFolderPath]));
 
     // Ensure the file was cached in each driver. This happens as a result of
@@ -795,12 +851,14 @@
     // Opening both files should only add the project folder once.
     await openFile(file1Uri, '');
     await openFile(file2Uri, '');
+    await pumpEventQueue(times: 5000);
     expect(server.contextManager.includedPaths, equals([projectFolderPath]));
     expect(server.contextManager.driverMap, hasLength(1));
 
     // Closing only one of the files should not remove the root or rebuild the context.
     resetContextBuildCounter();
     await closeFile(file1Uri);
+    await pumpEventQueue(times: 5000);
     expect(server.contextManager.includedPaths, equals([projectFolderPath]));
     expect(server.contextManager.driverMap, hasLength(1));
     expectNoContextBuilds();
@@ -809,6 +867,7 @@
     // the context.
     resetContextBuildCounter();
     await closeFile(file2Uri);
+    await pumpEventQueue(times: 5000);
     expect(server.contextManager.includedPaths, equals([]));
     expect(server.contextManager.driverMap, hasLength(0));
     expectContextBuilds();
@@ -907,3 +966,15 @@
     return uri.replace(path: '${uri.path}/');
   }
 }
+
+/// Runs all initialization tests with a resource provider that slowly
+/// initializes watchers to simulate delays in real fs watchers.
+@reflectiveTest
+class SlowInitializationTest extends InitializationTest {
+  @override
+  MemoryResourceProvider resourceProvider = MemoryResourceProvider(
+    // Force the in-memory file watchers to be slowly initialized to emulate
+    // the physical watchers (for test_concurrentContextRebuilds).
+    delayWatcherInitialization: Duration(milliseconds: 1),
+  );
+}
diff --git a/pkg/analysis_server/test/lsp/server_abstract.dart b/pkg/analysis_server/test/lsp/server_abstract.dart
index 4277d90..a5e88ea 100644
--- a/pkg/analysis_server/test/lsp/server_abstract.dart
+++ b/pkg/analysis_server/test/lsp/server_abstract.dart
@@ -1156,11 +1156,11 @@
   }
 
   Future<Either2<List<DocumentSymbol>, List<SymbolInformation>>>
-      getDocumentSymbols(String fileUri) {
+      getDocumentSymbols(Uri uri) {
     final request = makeRequest(
       Method.textDocument_documentSymbol,
       DocumentSymbolParams(
-        textDocument: TextDocumentIdentifier(uri: fileUri),
+        textDocument: TextDocumentIdentifier(uri: uri.toString()),
       ),
     );
     return expectSuccessfulResponseTo(