diff --git a/modules/lsp-kernel/src/main/scala/playground/lsp/BuildLoader.scala b/modules/lsp-kernel/src/main/scala/playground/lsp/BuildLoader.scala index d3b9934d..ffecc0c4 100644 --- a/modules/lsp-kernel/src/main/scala/playground/lsp/BuildLoader.scala +++ b/modules/lsp-kernel/src/main/scala/playground/lsp/BuildLoader.scala @@ -30,7 +30,9 @@ object BuildLoader { case class Loaded( config: PlaygroundConfig, configFilePath: Path, - ) + ) { + def isDummy: Boolean = this == Loaded.default + } object Loaded { // Path is irrelevant when no imports are provided. diff --git a/modules/lsp-kernel/src/main/scala/playground/lsp/LanguageServer.scala b/modules/lsp-kernel/src/main/scala/playground/lsp/LanguageServer.scala index b9cf6826..7153cd73 100644 --- a/modules/lsp-kernel/src/main/scala/playground/lsp/LanguageServer.scala +++ b/modules/lsp-kernel/src/main/scala/playground/lsp/LanguageServer.scala @@ -37,6 +37,7 @@ import playground.language.FormattingProvider import playground.language.Progress import playground.language.TextDocumentProvider import playground.language.TextEdit +import playground.lsp.ServerLoader.PrepareResult import playground.lsp.buildinfo.BuildInfo import playground.smithyql.Position import playground.smithyql.SourceRange @@ -116,12 +117,14 @@ object LanguageServer { ) def instance[ - F[_]: {Async, TextDocumentManager, LanguageClient, ServerLoader, CommandResultReporter, - Progress.Make} + F[_]: {Async, TextDocumentManager, LanguageClient, CommandResultReporter, Progress.Make, + ServerLoader.Queue} ]( dsi: DynamicSchemaIndex, serviceIndex: ServiceIndex, runner: FileRunner.Resolver[F], + )( + using serverLoader: ServerLoader[F] ): LanguageServer[F] = new LanguageServer[F] { @@ -194,24 +197,35 @@ object LanguageServer { ) *> ServerLoader[F] .prepare(workspaceFolders.some) - .flatMap { prepped => - mkProgress.use { progress => - progress.report(message = "Loaded build definition from workspace...".some) *> - ServerLoader[F] - .perform(prepped.params, progress) - .flatTap { stats => - Feedback[F] - .showInfoMessage( - s"Loaded Smithy Playground server with ${stats.render}" - ) - } - } + .flatMap { prepared => + ServerLoader + .Queue[F] + .schedule { + mkProgress.use { progress => + loadInitialServer(prepared, progress) + } + } } - .onError { case e => LanguageClient[F].showErrorMessage("Failed to reload project") } - .attempt .as(InitializeResult(capabilities, serverInfo)) } + private def loadInitialServer( + prepped: PrepareResult[serverLoader.Params], + progress: Progress[F], + ) = + progress.report(message = "Loaded build definition from workspace...".some) *> + ServerLoader[F] + .perform(prepped.params, progress) + .flatTap { stats => + LanguageClient[F].refreshDiagnostics *> + LanguageClient[F].refreshCodeLenses *> + Feedback[F].showInfoMessage( + s"Loaded Smithy Playground server with ${stats.render}" + ) + } + .onError { case e => LanguageClient[F].showErrorMessage("Failed to reload project") } + .void + def didChange( documentUri: Uri, newText: String, @@ -262,19 +276,28 @@ object LanguageServer { def diagnostic( documentUri: Uri - ): F[List[LSPDiagnostic]] = TextDocumentManager[F] - .get(documentUri) - .map { documentText => - val diags = diagnosticProvider.getDiagnostics( - documentText + ): F[List[LSPDiagnostic]] = + // Due to this happening in the background, we just don't return diagnostics until the server is initialized the first time. + // They'll be refreshed by a server->client request later. + ServerLoader[F] + .isInitialized + .get + .ifM( + ifFalse = Nil.pure[F], + ifTrue = TextDocumentManager[F] + .get(documentUri) + .map { documentText => + val diags = diagnosticProvider.getDiagnostics( + documentText + ) + + val map = LocationMap(documentText) + + diags + .map(LSPDiagnostic(_, map)) + }, ) - val map = LocationMap(documentText) - - diags - .map(LSPDiagnostic(_, map)) - } - def definition(documentUri: Uri, position: LSPPosition): F[List[LSPLocation]] = TextDocumentManager[F] .get(documentUri) @@ -318,34 +341,41 @@ object LanguageServer { DocumentSymbolProvider.make(text).map(LSPDocumentSymbol(_, map)) } - def didChangeWatchedFiles: F[Unit] = ServerLoader[F] - .prepare(workspaceFolders = None) - .flatMap { - case prepared if !prepared.isChanged => - Feedback[F].showInfoMessage( - "No change detected, not rebuilding server" - ) - case prepared => - Feedback[F].showInfoMessage("Detected changes, will try to rebuild server...") *> - Progress - .create(title = "Rebuilding server", message = None) - .use { - ServerLoader[F] - .perform(prepared.params, _) - } - .onError { case e => - LanguageClient[F].showErrorMessage( - "Couldn't reload server: " + e.getMessage - ) - } - .flatMap { stats => - LanguageClient[F].refreshDiagnostics *> - LanguageClient[F].refreshCodeLenses *> - Feedback[F].showInfoMessage( - s"Reloaded Smithy Playground server with ${stats.render}" - ) - } - } + def didChangeWatchedFiles: F[Unit] = + // Q: is this safe? or should we do this in the background / with a mutex of sorts? + ServerLoader[F].isInitialized.waitUntil(identity) *> + ServerLoader[F] + .prepare(workspaceFolders = None) + .flatMap { + case prepared if !prepared.isChanged => + Feedback[F].showInfoMessage( + "No change detected, not rebuilding server" + ) + + case prepared => reloadServer(prepared) + } + + private def reloadServer(prepared: PrepareResult[serverLoader.Params]) = { + Feedback[F].showInfoMessage("Detected changes, will try to rebuild server...") *> + Progress + .create(title = "Rebuilding server", message = None) + .use { + ServerLoader[F] + .perform(prepared.params, _) + } + .onError { case e => + LanguageClient[F].showErrorMessage( + "Couldn't reload server: " + e.getMessage + ) + } + .flatMap { stats => + LanguageClient[F].refreshDiagnostics *> + LanguageClient[F].refreshCodeLenses *> + Feedback[F].showInfoMessage( + s"Reloaded Smithy Playground server with ${stats.render}" + ) + } + } .onError { case e => LanguageClient[F].showErrorMessage( s"Couldn't rebuild server. Check your config file and the output panel.\nError: ${e.getMessage()}" diff --git a/modules/lsp-kernel/src/main/scala/playground/lsp/ServerBuilder.scala b/modules/lsp-kernel/src/main/scala/playground/lsp/ServerBuilder.scala index 47105dee..d816d9ad 100644 --- a/modules/lsp-kernel/src/main/scala/playground/lsp/ServerBuilder.scala +++ b/modules/lsp-kernel/src/main/scala/playground/lsp/ServerBuilder.scala @@ -83,10 +83,11 @@ object ServerBuilder { .memoize given TextDocumentManager[F] <- TextDocumentManager.instance[F].toResource rep <- CommandResultReporter.instance[F].toResource + given ServerLoader.Queue[F] <- ServerLoader.Queue.createCancelable[F] } yield new ServerBuilder[F] { - given Environment[F] = LSPEnvironment.instance[F](using httpClient = - httpClient + given Environment[F] = LSPEnvironment.instance[F]( + using httpClient = httpClient ) override def build( diff --git a/modules/lsp-kernel/src/main/scala/playground/lsp/ServerLoader.scala b/modules/lsp-kernel/src/main/scala/playground/lsp/ServerLoader.scala index 6e9e881e..5498bfbc 100644 --- a/modules/lsp-kernel/src/main/scala/playground/lsp/ServerLoader.scala +++ b/modules/lsp-kernel/src/main/scala/playground/lsp/ServerLoader.scala @@ -1,11 +1,15 @@ package playground.lsp import cats.MonadThrow +import cats.effect.kernel.Concurrent import cats.effect.kernel.Ref +import cats.effect.kernel.Resource +import cats.effect.syntax.all.* import cats.syntax.all.* +import fs2.concurrent.Signal +import fs2.concurrent.SignallingRef import playground.PlaygroundConfig import playground.Uri -import playground.language.Feedback import playground.language.Progress trait ServerLoader[F[_]] { @@ -21,10 +25,36 @@ trait ServerLoader[F[_]] { ): F[ServerLoader.WorkspaceStats] def server: LanguageServer[F] + def isInitialized: Signal[F, Boolean] } object ServerLoader { + trait Queue[F[_]] { + def schedule(task: F[Unit]): F[Unit] + } + + object Queue { + + def apply[F[_]]( + implicit F: Queue[F] + ): Queue[F] = F + + def createCancelable[F[_]: Concurrent] + : Resource[F, Queue[F]] = cats.effect.std.Queue.synchronous[F, F[Unit]].toResource.flatMap { + q => + fs2 + .Stream + .fromQueueUnterminated(q) + .switchMap(fs2.Stream.exec) + .compile + .drain + .background + .as(q.offer(_)) + } + + } + def apply[F[_]]( implicit F: ServerLoader[F] ): F.type = F @@ -61,7 +91,7 @@ object ServerLoader { } - def instance[F[_]: ServerBuilder: BuildLoader: Feedback: Ref.Make: MonadThrow] + def instance[F[_]: ServerBuilder: BuildLoader: Concurrent] : F[ServerLoader.Aux[F, BuildLoader.Loaded]] = { case class State( currentServer: LanguageServer[F], @@ -72,7 +102,7 @@ object ServerLoader { } ( - Ref[F] + SignallingRef[F] .of(State.initial), Ref[F].of(Option.empty[List[Uri]]), ) @@ -116,10 +146,15 @@ object ServerLoader { .as(WorkspaceStats.fromPlaygroundConfig(params.config)) val server: LanguageServer[F] = LanguageServer.defer(stateRef.get.map(_.currentServer)) + + val isInitialized: Signal[F, Boolean] = stateRef.map( + _.lastUsedConfig.exists(!_.isDummy) + ) } } .flatTap { serverLoader => // loading with dummy config to initialize server without dependencies + // we don't report progress either, as this is a very quick / uninteresting operation serverLoader.perform(BuildLoader.Loaded.default, Progress.ignored[F]) } } diff --git a/modules/lsp-kernel/src/test/scala/playground/lsp/harness/LanguageServerIntegrationTests.scala b/modules/lsp-kernel/src/test/scala/playground/lsp/harness/LanguageServerIntegrationTests.scala index ec5f8978..9c14734f 100644 --- a/modules/lsp-kernel/src/test/scala/playground/lsp/harness/LanguageServerIntegrationTests.scala +++ b/modules/lsp-kernel/src/test/scala/playground/lsp/harness/LanguageServerIntegrationTests.scala @@ -46,6 +46,8 @@ trait LanguageServerIntegrationTests { progressToken = Some("init-progress"), clientCapabilities = ClientCapabilities(windowProgress = true), ) *> + // problem: initialize is now async, so we need something to wait for. + // this would require some changes to the LanguageServer API which smells like a bit of a leak. assertStartupEvents(client) .as(result) }