-
Notifications
You must be signed in to change notification settings - Fork 4
Roblox Studio Companion Plugin support #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| package com.github.aleksandrsl.intellijluau.lsp | ||
|
|
||
| import com.github.aleksandrsl.intellijluau.LuauBundle | ||
| import com.github.aleksandrsl.intellijluau.showProjectNotification | ||
| import com.github.aleksandrsl.intellijluau.settings.PlatformType | ||
| import com.github.aleksandrsl.intellijluau.settings.ProjectSettingsConfigurable | ||
| import com.github.aleksandrsl.intellijluau.settings.ProjectSettingsState | ||
| import com.intellij.notification.NotificationGroupManager | ||
| import com.intellij.notification.NotificationType | ||
| import com.intellij.openapi.Disposable | ||
| import com.intellij.openapi.components.Service | ||
| import com.intellij.openapi.components.service | ||
| import com.intellij.openapi.diagnostic.logger | ||
| import com.intellij.openapi.project.Project | ||
| import com.intellij.util.messages.MessageBusConnection | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.channels.Channel | ||
| import kotlinx.coroutines.launch | ||
| import java.net.BindException | ||
|
|
||
| private val LOG = logger<CompanionPluginService>() | ||
|
|
||
| private data class CompanionRelevantSettings( | ||
| val enabled: Boolean, | ||
| val port: Int, | ||
| val platformType: PlatformType, | ||
| val isLspEnabledAndMinimallyConfigured: Boolean, | ||
| ) | ||
|
|
||
| private fun ProjectSettingsState.State.toCompanionRelevantSettings() = CompanionRelevantSettings( | ||
| enabled = this.companionPluginEnabled, | ||
| port = this.companionPluginPort, | ||
| platformType = this.platformType, | ||
| isLspEnabledAndMinimallyConfigured = this.isLspEnabledAndMinimallyConfigured, | ||
| ) | ||
|
|
||
| @Service(Service.Level.PROJECT) | ||
| class CompanionPluginService(private val project: Project, private val coroutineScope: CoroutineScope) : Disposable { | ||
| private var messageBusConnection: MessageBusConnection? = null | ||
| private var server: CompanionServer? = null | ||
|
|
||
| private val operationQueue = Channel<Operation>(Channel.UNLIMITED) | ||
|
|
||
| init { | ||
| messageBusConnection = project.messageBus.connect(this) | ||
| messageBusConnection?.subscribe( | ||
| ProjectSettingsConfigurable.TOPIC, object : ProjectSettingsConfigurable.SettingsChangeListener { | ||
| override fun settingsChanged(event: ProjectSettingsConfigurable.SettingsChangedEvent) { | ||
| operationQueue.trySend(Operation.Update(event)) | ||
| } | ||
| }) | ||
|
|
||
| coroutineScope.launch { | ||
| for (op in operationQueue) { | ||
| try { | ||
| when (op) { | ||
| is Operation.Update -> updateServer(op.change) | ||
| is Operation.Stop -> stop() | ||
| } | ||
| } catch (e: Exception) { | ||
| LOG.error("Error processing companion plugin operation: $op", e) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| operationQueue.trySend(Operation.Update()) | ||
| } | ||
|
|
||
| private fun stop() { | ||
| server?.stop() | ||
| server = null | ||
| } | ||
|
|
||
| private fun shouldServerBeRunning(settings: CompanionRelevantSettings): Boolean { | ||
| return settings.enabled | ||
| && settings.platformType == PlatformType.Roblox | ||
| && settings.isLspEnabledAndMinimallyConfigured | ||
| } | ||
|
|
||
| private fun updateServer(change: ProjectSettingsConfigurable.SettingsChangedEvent? = null) { | ||
| val newSettingsState = change?.newState ?: ProjectSettingsState.getInstance(project).state | ||
| val companionSettings = newSettingsState.toCompanionRelevantSettings() | ||
|
|
||
| if (change != null) { | ||
| val oldCompanionSettings = change.oldState.toCompanionRelevantSettings() | ||
| if (oldCompanionSettings == companionSettings) { | ||
| return | ||
| } | ||
| } | ||
|
|
||
| val oldServer = server | ||
| oldServer?.stop() | ||
| server = null | ||
|
|
||
| if (!shouldServerBeRunning(companionSettings)) { | ||
| if (oldServer != null) { | ||
| notifications() | ||
| .showProjectNotification( | ||
| LuauBundle.message("luau.companion.plugin.stopped"), | ||
| NotificationType.INFORMATION, | ||
| project | ||
| ) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| try { | ||
| val newServer = CompanionServer(project, companionSettings.port) | ||
| newServer.start() | ||
| server = newServer | ||
| notifications() | ||
| .showProjectNotification( | ||
| LuauBundle.message("luau.companion.plugin.started", companionSettings.port), | ||
| NotificationType.INFORMATION, | ||
| project | ||
| ) | ||
| } catch (e: BindException) { | ||
| LOG.warn("Companion plugin port ${companionSettings.port} is already in use", e) | ||
| notifications() | ||
| .showProjectNotification( | ||
| LuauBundle.message("luau.companion.plugin.port.in.use", companionSettings.port), | ||
| NotificationType.ERROR, | ||
| project | ||
| ) | ||
| } catch (e: Exception) { | ||
| LOG.error("Failed to start companion plugin server", e) | ||
| notifications() | ||
| .showProjectNotification( | ||
| LuauBundle.message("luau.companion.plugin.error", e.message ?: "Unknown error"), | ||
| NotificationType.ERROR, | ||
| project | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| override fun dispose() { | ||
| messageBusConnection?.disconnect() | ||
| messageBusConnection = null | ||
| server?.stop() | ||
| server = null | ||
| operationQueue.close() | ||
| } | ||
|
|
||
| private sealed class Operation { | ||
| data class Update( | ||
| val change: ProjectSettingsConfigurable.SettingsChangedEvent? = null, | ||
| ) : Operation() | ||
|
|
||
| object Stop : Operation() | ||
| } | ||
|
|
||
| companion object { | ||
| @JvmStatic | ||
| fun getInstance(project: Project): CompanionPluginService = project.service() | ||
|
|
||
| fun notifications() = | ||
|
Check notice on line 156 in src/main/kotlin/com/github/aleksandrsl/intellijluau/lsp/CompanionPluginService.kt
|
||
| NotificationGroupManager.getInstance().getNotificationGroup("Luau companion plugin") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.github.aleksandrsl.intellijluau.lsp | ||
|
|
||
| import com.intellij.openapi.project.Project | ||
| import com.intellij.openapi.startup.ProjectActivity | ||
|
|
||
| class CompanionPluginStartupActivity : ProjectActivity { | ||
| override suspend fun execute(project: Project) { | ||
| CompanionPluginService.getInstance(project) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,196 @@ | ||||||||||||||||||
| @file:Suppress("UnstableApiUsage") | ||||||||||||||||||
|
|
||||||||||||||||||
| package com.github.aleksandrsl.intellijluau.lsp | ||||||||||||||||||
|
|
||||||||||||||||||
| import com.github.aleksandrsl.intellijluau.LuauFileType | ||||||||||||||||||
| import com.google.gson.JsonParser | ||||||||||||||||||
| import com.google.gson.Strictness | ||||||||||||||||||
| import com.google.gson.stream.JsonReader | ||||||||||||||||||
| import com.intellij.openapi.application.runReadAction | ||||||||||||||||||
| import com.intellij.openapi.diagnostic.logger | ||||||||||||||||||
| import com.intellij.openapi.project.Project | ||||||||||||||||||
| import com.intellij.platform.lsp.api.LspServerManager | ||||||||||||||||||
| import com.intellij.psi.search.FileTypeIndex | ||||||||||||||||||
| import com.intellij.psi.search.GlobalSearchScope | ||||||||||||||||||
| import com.sun.net.httpserver.HttpExchange | ||||||||||||||||||
| import com.sun.net.httpserver.HttpServer | ||||||||||||||||||
| import java.io.InputStream | ||||||||||||||||||
| import java.net.InetSocketAddress | ||||||||||||||||||
| import java.util.zip.GZIPInputStream | ||||||||||||||||||
|
|
||||||||||||||||||
| private val LOG = logger<CompanionServer>() | ||||||||||||||||||
|
|
||||||||||||||||||
| private const val MAX_BODY_SIZE = 3 * 1024 * 1024 // 3MB | ||||||||||||||||||
|
|
||||||||||||||||||
| class CompanionServer( | ||||||||||||||||||
| private val project: Project, | ||||||||||||||||||
| private val port: Int, | ||||||||||||||||||
| ) { | ||||||||||||||||||
| private var server: HttpServer? = null | ||||||||||||||||||
|
|
||||||||||||||||||
| fun start() { | ||||||||||||||||||
| val httpServer = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) | ||||||||||||||||||
| httpServer.createContext("/full") { exchange -> handleFull(exchange) } | ||||||||||||||||||
| httpServer.createContext("/clear") { exchange -> handleClear(exchange) } | ||||||||||||||||||
| httpServer.createContext("/get-file-paths") { exchange -> handleGetFilePaths(exchange) } | ||||||||||||||||||
| httpServer.start() | ||||||||||||||||||
|
Comment on lines
+31
to
+36
|
||||||||||||||||||
| server = httpServer | ||||||||||||||||||
| LOG.info("Companion plugin server started on port $port") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| fun stop() { | ||||||||||||||||||
| server?.stop(0) | ||||||||||||||||||
| server = null | ||||||||||||||||||
| LOG.info("Companion plugin server stopped") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| val isRunning: Boolean | ||||||||||||||||||
| get() = server != null | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun handleFull(exchange: HttpExchange) { | ||||||||||||||||||
| exchange.use { | ||||||||||||||||||
| if (exchange.requestMethod != "POST") { | ||||||||||||||||||
| exchange.sendResponse(405, "Method Not Allowed") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| val contentLength = exchange.requestHeaders.getFirst("Content-Length")?.toLongOrNull() ?: 0 | ||||||||||||||||||
| if (contentLength > MAX_BODY_SIZE) { | ||||||||||||||||||
| exchange.sendResponse(413, "Request body too large. Limit: ${MAX_BODY_SIZE / 1024 / 1024}MB") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| val body = try { | ||||||||||||||||||
| val inputStream = decompressIfNeeded(exchange) | ||||||||||||||||||
| inputStream.bufferedReader().readText() | ||||||||||||||||||
| } catch (e: Exception) { | ||||||||||||||||||
|
Comment on lines
+57
to
+66
|
||||||||||||||||||
| LOG.debug("Failed to read request body", e) | ||||||||||||||||||
| exchange.sendResponse(400, "Failed to read request body") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| val jsonObject = try { | ||||||||||||||||||
| val reader = JsonReader(body.reader()) | ||||||||||||||||||
| reader.strictness = Strictness.LENIENT | ||||||||||||||||||
| JsonParser.parseReader(reader).asJsonObject | ||||||||||||||||||
| } catch (e: Exception) { | ||||||||||||||||||
| val preview = if (body.length > 500) body.substring(0, 500) + "..." else body | ||||||||||||||||||
| LOG.warn("Failed to parse JSON body (length=${body.length}): $preview", e) | ||||||||||||||||||
| exchange.sendResponse(400, "Invalid JSON") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| val tree = jsonObject.get("tree") | ||||||||||||||||||
| if (tree == null) { | ||||||||||||||||||
| exchange.sendResponse(400, "Missing 'tree' property") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!sendLspNotification { it.pluginFull(tree) }) { | ||||||||||||||||||
| LOG.debug("LSP server not available, ignoring /full request") | ||||||||||||||||||
| } | ||||||||||||||||||
| exchange.sendResponse(200, "OK") | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun handleClear(exchange: HttpExchange) { | ||||||||||||||||||
| exchange.use { | ||||||||||||||||||
| if (exchange.requestMethod != "POST") { | ||||||||||||||||||
| exchange.sendResponse(405, "Method Not Allowed") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!sendLspNotification { it.pluginClear() }) { | ||||||||||||||||||
| LOG.debug("LSP server not available, ignoring /clear request") | ||||||||||||||||||
| } | ||||||||||||||||||
| exchange.sendResponse(200, "OK") | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun handleGetFilePaths(exchange: HttpExchange) { | ||||||||||||||||||
| exchange.use { | ||||||||||||||||||
| if (exchange.requestMethod != "GET") { | ||||||||||||||||||
| exchange.sendResponse(405, "Method Not Allowed") | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| val files = runReadAction<List<String>> { | ||||||||||||||||||
| FileTypeIndex.getFiles(LuauFileType, GlobalSearchScope.projectScope(project)) | ||||||||||||||||||
| .map { it.path } | ||||||||||||||||||
| } | ||||||||||||||||||
| val json = com.google.gson.JsonObject().apply { | ||||||||||||||||||
| val array = com.google.gson.JsonArray() | ||||||||||||||||||
| files.forEach { array.add(it) } | ||||||||||||||||||
| add("files", array) | ||||||||||||||||||
| } | ||||||||||||||||||
| exchange.responseHeaders.add("Content-Type", "application/json") | ||||||||||||||||||
| exchange.sendResponse(200, json.toString()) | ||||||||||||||||||
| } catch (e: Exception) { | ||||||||||||||||||
| LOG.warn("Failed to get file paths", e) | ||||||||||||||||||
| exchange.sendResponse(500, "Failed to get file paths") | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun decompressIfNeeded(exchange: HttpExchange): InputStream { | ||||||||||||||||||
| val encoding = exchange.requestHeaders.getFirst("Content-Encoding") | ||||||||||||||||||
| return if (encoding != null && encoding.equals("gzip", ignoreCase = true)) { | ||||||||||||||||||
| GZIPInputStream(exchange.requestBody) | ||||||||||||||||||
| } else { | ||||||||||||||||||
| exchange.requestBody | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+136
to
+142
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Send a notification to the Luau LSP server. | ||||||||||||||||||
| * Uses reflection to support both old (lsp4jServer, 2024.x) and new (sendNotification, 2025.x+) IntelliJ APIs. | ||||||||||||||||||
| */ | ||||||||||||||||||
| private fun sendLspNotification(action: (LuauLanguageServer) -> Unit): Boolean { | ||||||||||||||||||
| val lspServer = LspServerManager.getInstance(project) | ||||||||||||||||||
| .getServersForProvider(LuauLspServerSupportProvider::class.java) | ||||||||||||||||||
| .firstOrNull() ?: return false | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| val sendNotification = lspServer.javaClass.getMethod( | ||||||||||||||||||
| "sendNotification", | ||||||||||||||||||
| kotlin.jvm.functions.Function1::class.java | ||||||||||||||||||
| ) | ||||||||||||||||||
| val callback: (org.eclipse.lsp4j.services.LanguageServer) -> Unit = { languageServer -> | ||||||||||||||||||
| (languageServer as? LuauLanguageServer)?.let(action) | ||||||||||||||||||
| } | ||||||||||||||||||
| sendNotification.invoke(lspServer, callback) | ||||||||||||||||||
| } catch (_: NoSuchMethodException) { | ||||||||||||||||||
| // Fallback for older IntelliJ versions with lsp4jServer | ||||||||||||||||||
| try { | ||||||||||||||||||
| val getLsp4jServer = lspServer.javaClass.getMethod("getLsp4jServer") | ||||||||||||||||||
| val server = getLsp4jServer.invoke(lspServer) as? LuauLanguageServer ?: return false | ||||||||||||||||||
| action(server) | ||||||||||||||||||
| } catch (e: Throwable) { | ||||||||||||||||||
| LOG.warn("Failed to access LSP server API", e) | ||||||||||||||||||
| return false | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+154
to
+173
|
||||||||||||||||||
| return true | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun HttpExchange.sendResponse(code: Int, body: String) { | ||||||||||||||||||
| val bytes = body.toByteArray() | ||||||||||||||||||
| sendResponseHeaders(code, bytes.size.toLong()) | ||||||||||||||||||
| responseBody.write(bytes) | ||||||||||||||||||
| responseBody.close() | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private inline fun HttpExchange.use(block: () -> Unit) { | ||||||||||||||||||
| try { | ||||||||||||||||||
| block() | ||||||||||||||||||
| } catch (e: Throwable) { | ||||||||||||||||||
| LOG.warn("Unhandled error in companion server", e) | ||||||||||||||||||
| try { | ||||||||||||||||||
| sendResponse(500, "Internal Server Error") | ||||||||||||||||||
| } catch (_: Throwable) { | ||||||||||||||||||
| // Response may have already been sent | ||||||||||||||||||
| } | ||||||||||||||||||
|
||||||||||||||||||
| } | |
| } | |
| } finally { | |
| try { | |
| close() | |
| } catch (_: Throwable) { | |
| // Ignore close failures while cleaning up the exchange | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Operation.Stopis never enqueued (dispose() stops the server directly and only closes the channel). Either enqueueOperation.Stopindispose()(likeSourcemapGeneratorService) or remove the unused operation/branch to avoid dead code and keep lifecycle handling consistent.