Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cb852eb
WIP: Langoustine adapter
kubukoz Mar 22, 2025
d72fb1e
Split up into files
kubukoz Mar 22, 2025
24226ce
Pretty much a complete langoustine implementation
kubukoz Mar 22, 2025
04bc090
WIP
kubukoz Mar 23, 2025
a751f01
Merge branch 'main' into langoustine
kubukoz May 13, 2025
76b83f3
Update to langoustine 0.0.23
kubukoz May 13, 2025
f6a7af1
Move to langoustine in e2e tests, use snapshot with deadlock fix
kubukoz May 14, 2025
10b0817
fixess
kubukoz May 14, 2025
a58745d
0.0.24
kubukoz May 14, 2025
7feee29
Merge branch 'main' into langoustine
kubukoz May 15, 2025
91b5fa4
skip log
kubukoz May 16, 2025
c578d81
bump
kubukoz Jun 9, 2025
f99de2a
Merge branch 'main' into langoustine
kubukoz Jun 9, 2025
7661ccd
Add .name
kubukoz Jun 9, 2025
0856b3d
Merge branch 'main' into langoustine
kubukoz Jun 14, 2025
5382b3c
add some logz
kubukoz Jun 14, 2025
488ce9a
Merge branch 'main' into langoustine
kubukoz Jun 14, 2025
570e46e
Merge branch 'main' into langoustine
kubukoz Jun 14, 2025
b2207d7
more verbose
kubukoz Jun 14, 2025
a98f905
restore stream
kubukoz Jun 14, 2025
ba2413e
i'm going insane, anyone want anything?
kubukoz Jun 14, 2025
5cf1ff9
cleanup, we cool now
kubukoz Jun 14, 2025
7052536
add onFinalizes
kubukoz Jun 14, 2025
bff313b
simplify for test
kubukoz Jun 14, 2025
125c5b7
small change
kubukoz Jun 14, 2025
5edf6aa
println
kubukoz Jun 14, 2025
66d322f
eh
kubukoz Jun 14, 2025
3dd2f16
Merge branch 'main' into langoustine
kubukoz Aug 10, 2025
0e7bfcd
improve server-side logging
kubukoz Aug 10, 2025
6ce835d
Dump traces during e2e test
kubukoz Aug 10, 2025
a5efe91
Revert "Dump traces during e2e test"
kubukoz Aug 10, 2025
f48261c
do a full fiber dump
kubukoz Aug 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -231,19 +231,27 @@ lazy val lsp = module("lsp")
)
.dependsOn(lspKernel)

lazy val lsp2 = module("lsp2")
.settings(
libraryDependencies ++= Seq(
"tech.neander" %% "langoustine-app" % "0.0.25"
).pipe(jsoniterFix)
)
.dependsOn(lspKernel)

lazy val e2e = module("e2e")
.enablePlugins(BuildInfoPlugin)
.settings(
buildInfoKeys ++=
Seq[BuildInfoKey.Entry[_]]( // do you know how to simplify this? let me know please!
Def
.task {
s"""${(lsp / organization).value}::${(lsp / moduleName).value}:${(lsp / version).value}"""
s"""${(lsp2 / organization).value}::${(lsp2 / moduleName).value}:${(lsp2 / version).value}"""
}
// todo: replace with a full publishLocal before e2e in particular gets run (but not before tests run normally)
.dependsOn(
lspKernel / publishLocal,
lsp / publishLocal,
lsp2 / publishLocal,
languageSupport / publishLocal,
core / publishLocal,
parser / publishLocal,
Expand All @@ -258,8 +266,12 @@ lazy val e2e = module("e2e")
),
publish / skip := true,
Test / fork := true,
libraryDependencies ++= Seq(
"tech.neander" %% "langoustine-lsp" % "0.0.25",
"tech.neander" %% "jsonrpclib-fs2" % "0.0.7",
).pipe(jsoniterFix),
)
.dependsOn(lsp)
.dependsOn(lspKernel)

val writeVersion = taskKey[Unit]("Writes the current version to the `.version` file")

Expand All @@ -269,7 +281,8 @@ lazy val root = project
publish / skip := true,
mimaFailOnNoPrevious := false,
mimaPreviousArtifacts := Set.empty,
addCommandAlias("ci", "+test;+mimaReportBinaryIssues;+publishLocal;writeVersion"),
addCommandAlias("ci", "e2e/test"),
// addCommandAlias("ci", "+test;+mimaReportBinaryIssues;+publishLocal;writeVersion"),
writeVersion := {
IO.write(file(".version"), version.value)
},
Expand All @@ -284,6 +297,7 @@ lazy val root = project
languageSupport,
lspKernel,
lsp,
lsp2,
protocol4s,
pluginCore,
pluginSample,
Expand Down
220 changes: 117 additions & 103 deletions modules/e2e/src/test/scala/playground/e2e/E2ETests.scala
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package playground.e2e

import buildinfo.BuildInfo
import cats.effect.Concurrent
import cats.effect.IO
import cats.effect.IOHack
import cats.effect.kernel.Resource
import cats.effect.unsafe.IORuntime
import cats.effect.unsafe.implicits.*
import cats.syntax.all.*
import fs2.io.file
import org.eclipse.lsp4j.InitializeParams
import org.eclipse.lsp4j.InitializeResult
import org.eclipse.lsp4j.MessageActionItem
import org.eclipse.lsp4j.MessageParams
import org.eclipse.lsp4j.PublishDiagnosticsParams
import org.eclipse.lsp4j.ShowMessageRequestParams
import org.eclipse.lsp4j.WorkspaceFolder
import org.eclipse.lsp4j.launch.LSPLauncher
import org.eclipse.lsp4j.services.LanguageServer
import playground.lsp.PlaygroundLanguageClient
import fs2.io.process.Processes
import jsonrpclib.fs2.FS2Channel
import jsonrpclib.fs2.given
import langoustine.lsp.Communicate
import langoustine.lsp.LSPBuilder
import langoustine.lsp.requests.initialize
import langoustine.lsp.requests.window
import langoustine.lsp.runtime.Opt
import langoustine.lsp.runtime.Uri
import weaver.*

import java.io.PrintWriter
Expand All @@ -28,107 +30,119 @@ import scala.util.chaining.*

object E2ETests extends SimpleIOSuite {

class LanguageServerAdapter(
ls: LanguageServer
) {

def initialize(
params: InitializeParams
): IO[InitializeResult] = IO.fromCompletableFuture(IO(ls.initialize(params)))

}

private def runServer: Resource[IO, LanguageServerAdapter] = {

val client: PlaygroundLanguageClient =
new PlaygroundLanguageClient {

override def telemetryEvent(
`object`: Object
): Unit = ()

override def publishDiagnostics(
diagnostics: PublishDiagnosticsParams
): Unit = ()

override def showMessage(
messageParams: MessageParams
): Unit = println(
s"${Console.MAGENTA}Message from server: ${messageParams
.getMessage()} (type: ${messageParams.getType()})${Console.RESET}"
)

override def showMessageRequest(
requestParams: ShowMessageRequestParams
): CompletableFuture[MessageActionItem] = (IO.stub: IO[MessageActionItem])
.unsafeToCompletableFuture()

override def logMessage(
message: MessageParams
): Unit = ()

override def showOutputPanel(
): Unit = ()

}

val builder =
new ProcessBuilder(
"cs",
"launch",
BuildInfo.lspArtifact,
)
// Watch process stderr in test runner
.redirectError(Redirect.INHERIT)

Resource
.make(IO.interruptibleMany(builder.start()))(p => IO(p.destroy()).void)
.flatMap { process =>
val launcher = new LSPLauncher.Builder[LanguageServer]()
.setLocalService(client)
.setRemoteInterface(classOf[LanguageServer])
.setInput(process.getInputStream())
.setOutput(process.getOutputStream())
.traceMessages(new PrintWriter(System.err))
.create()
private def runServer: Resource[IO, Communicate[IO]] = Processes[IO]
.spawn(fs2.io.process.ProcessBuilder("cs", "launch", BuildInfo.lspArtifact))
.onFinalize(IO.println("Server process finalized"))
.flatMap { process =>
val clientEndpoints: LSPBuilder[IO] => LSPBuilder[IO] =
_.handleNotification(window.showMessage) { in =>
val messageParams = in.params
IO.println {
s"${Console.MAGENTA}Message from server: ${messageParams.message} (type: ${messageParams.`type`.name})${Console.RESET}"
}
}

Resource
.make(IO(launcher.startListening()).timeout(5.seconds))(f =>
IO(f.cancel(true): @nowarn("msg=discarded non-Unit"))
)
.as(new LanguageServerAdapter(launcher.getRemoteProxy()))
}
}
FS2Channel[IO]()
.compile
.resource
.onlyOrError
.onFinalize(IO.println("Channel finalized"))
.flatMap { chan =>
val comms = Communicate.channel(chan)
chan
.withEndpoints(clientEndpoints(LSPBuilder.create[IO]).build(comms))
.flatMap { channel =>
process
.stdout
// fs2.io.stdout seems to be printed repeatedly for some reason, could be a bug with sbt
.observe(_.through(fs2.text.utf8.decode[IO]).debug("stdout: " + _).drain)
.through(jsonrpclib.fs2.lsp.decodeMessages[IO])
.through(channel.inputOrBounce)
.onFinalize(IO.println("stdout stream finalized"))
.concurrently(
channel
.output
.through(jsonrpclib.fs2.lsp.encodeMessages[IO])
.observe(_.through(fs2.text.utf8.decode[IO]).debug("stdin: " + _).drain)
.through(process.stdin)
.onFinalize(IO.println("stdin stream finalized"))
)
.concurrently(
process
.stderr
.through(fs2.io.stderr[IO])
.onFinalize(
IO.println("stderr stream finalized")
)
)
.onFinalize(
IO.println("stdio streams finalized")
)
.compile
.drain
.guarantee(
IO.println("Finalizing server process fiber")
)
.background
.as(comms)
.onFinalize(
IO.println("Server process and channel finalized")
)
}
}
}

private def initializeParams(
workspaceFolders: List[file.Path]
): InitializeParams = new InitializeParams()
.tap(
_.setWorkspaceFolders(
workspaceFolders
.zipWithIndex
.map { case (path, i) =>
new WorkspaceFolder(path.toNioPath.toUri().toString(), s"test-workspace-$i")
}
.asJava
)
): langoustine.lsp.structures.InitializeParams = langoustine
.lsp
.structures
.InitializeParams(
processId = Opt.empty,
rootUri = Opt.empty,
capabilities = langoustine.lsp.structures.ClientCapabilities(),
workspaceFolders = Opt(
Opt(
workspaceFolders
.zipWithIndex
.map { case (path, i) =>
langoustine
.lsp
.structures
.WorkspaceFolder(
uri = Uri(path.toNioPath.toUri().toString),
name = s"test-workspace-$i",
)
}
.toVector
)
),
)

test("server startup and initialize") {
runServer
.use { ls =>
file.Files[IO].tempDirectory.use { tempDirectory =>
val initParams = initializeParams(workspaceFolders = List(tempDirectory))

ls.initialize(initParams).map { result =>
expect.eql(
result.getServerInfo().getName(),
"Smithy Playground",
)
}
val run =
runServer
.use { ls =>
file.Files[IO].tempDirectory.use { tempDirectory =>
val initParams = initializeParams(workspaceFolders = List(tempDirectory))

ls.request(initialize(initParams)).map { result =>
expect.eql(
result.serverInfo.toOption.get.name,
"Smithy Playground",
)
}
} <* IO.println("Finished inner test")
}
.timeout(20.seconds) <* IO.println("Finished test and closed server")

}
.timeout(20.seconds)
run
.race(
IO(triggerFiberSnapshot()).andWait(5.seconds).foreverM
)
.map(_.merge)
}

def triggerFiberSnapshot(): Unit = IOHack.fiberSnapshot()

}
10 changes: 10 additions & 0 deletions modules/e2e/src/test/scala/playground/e2e/IOHack.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package cats.effect

object IOHack {

def fiberSnapshot() = {
val runtime = cats.effect.unsafe.implicits.global
runtime.fiberMonitor.liveFiberSnapshot(System.err.print(_))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import fs2.io.file.Path
import java.net.URI
import java.nio.file.Paths

// not making constructor private because
// "access modifiers from `copy` method are copied from the case class constructor"
// cannot nicely be silenced in scala 2
final case class Uri /* private */ (
final case class Uri private (
value: String
) extends AnyVal {
def toPath: Path = Path.fromNioPath(Paths.get(new URI(value)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,9 @@ import playground.smithyql.SourceRange
import playground.types.*
import smithy4s.dynamic.DynamicSchemaIndex

// todo: move to kernel
trait LanguageServer[F[_]] {

def initialize[A](workspaceFolders: List[Uri]): F[InitializeResult]
def initialize(workspaceFolders: List[Uri]): F[InitializeResult]

def didChange(
documentUri: Uri,
Expand Down Expand Up @@ -146,7 +145,7 @@ object LanguageServer {
getFormatterWidth
)

def initialize[A](workspaceFolders: List[Uri]): F[InitializeResult] = {
def initialize(workspaceFolders: List[Uri]): F[InitializeResult] = {
def capabilities(compiler: ServerCapabilitiesCompiler): compiler.Result = {
given Semigroup[compiler.Result] = compiler.semigroup
compiler.textDocumentSync(TextDocumentSyncKind.Full) |+|
Expand Down Expand Up @@ -345,6 +344,21 @@ trait ServerCapabilitiesCompiler {
def documentSymbolProvider: Result
}

object ServerCapabilitiesCompiler {

abstract class Default extends ServerCapabilitiesCompiler {
def default: Result

def textDocumentSync(kind: TextDocumentSyncKind): Result = default
def documentFormattingProvider: Result = default
def completionProvider: Result = default
def diagnosticProvider: Result = default
def codeLensProvider: Result = default
def documentSymbolProvider: Result = default
}

}

case class ServerInfo(name: String, version: String)

case class InitializeResult(
Expand Down
7 changes: 5 additions & 2 deletions modules/lsp/src/main/scala/playground/lsp/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ object Main extends IOApp.Simple {
launch(stdin_raw, stdout_raw)
)
.use { launcher =>
IO.interruptibleMany(launcher.startListening().get())
} *> IO.println("Server terminated without errors")
IO.interruptibleMany(launcher.startListening().get()).void
}
.guaranteeCase { oc =>
IO.println(s"Server terminated with result: $oc")
}

def launch(
in: InputStream,
Expand Down
Loading
Loading