Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 {

Check warning on line 38 in src/main/kotlin/com/github/aleksandrsl/intellijluau/lsp/CompanionPluginService.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Constructor parameter is never used as a property

Constructor parameter is never used as a property
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()

Check notice on line 149 in src/main/kotlin/com/github/aleksandrsl/intellijluau/lsp/CompanionPluginService.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
}
Comment on lines +136 to +150
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operation.Stop is never enqueued (dispose() stops the server directly and only closes the channel). Either enqueue Operation.Stop in dispose() (like SourcemapGeneratorService) or remove the unused operation/branch to avoid dead code and keep lifecycle handling consistent.

Copilot uses AI. Check for mistakes.

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

View workflow job for this annotation

GitHub Actions / Inspect code

Function or property has platform type

Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.
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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The companion HTTP server exposes /full, /clear, and /get-file-paths without any authentication/authorization. Even though it binds to 127.0.0.1, any local process can send requests (including retrieving absolute project file paths). Consider adding a per-project shared secret (random token) and require it via header/query param, or otherwise restricting accepted clients.

Copilot uses AI. Check for mistakes.
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

Check warning on line 47 in src/main/kotlin/com/github/aleksandrsl/intellijluau/lsp/CompanionServer.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unused symbol

Property "isRunning" is never used
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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleFull enforces MAX_BODY_SIZE only via the Content-Length header, which can be missing/incorrect (e.g., chunked transfer) and still allow an arbitrarily large body to be read into memory with readText(). Consider enforcing the limit while reading/parsing (e.g., bounded stream/reader) so the cap applies regardless of headers and compression.

Copilot uses AI. Check for mistakes.
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>> {

Check notice on line 118 in src/main/kotlin/com/github/aleksandrsl/intellijluau/lsp/CompanionServer.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Unnecessary type argument

Remove explicit type arguments
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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decompressIfNeeded allows gzip bodies but the size check is based on Content-Length (compressed size) and the code reads the full decompressed text into memory. This makes the endpoint vulnerable to decompression bombs; enforce a maximum on decompressed bytes (bounded GZIPInputStream / counting stream) before converting to a String.

Copilot uses AI. Check for mistakes.
}

/**
* 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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendLspNotification only catches NoSuchMethodException for the sendNotification path. If getMethod succeeds but invoke fails (e.g., IllegalAccessException, InvocationTargetException, signature mismatch at runtime), the exception will escape and turn the HTTP handler into a 500. Consider catching Throwable around the reflection invoke and returning false (similar to the fallback path).

Copilot uses AI. Check for mistakes.
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
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpExchange.use doesn't call exchange.close() in a finally block. Per HttpExchange contract, handlers should close the exchange to release the request/response streams; currently only responseBody is closed in sendResponse, and early returns/exception paths may leak the request body / connection resources.

Suggested change
}
}
} finally {
try {
close()
} catch (_: Throwable) {
// Ignore close failures while cleaning up the exchange
}

Copilot uses AI. Check for mistakes.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,19 @@ import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor
import com.intellij.platform.lsp.api.lsWidget.LspServerWidgetItem
import org.eclipse.lsp4j.ClientCapabilities
import org.eclipse.lsp4j.ConfigurationItem
import org.eclipse.lsp4j.jsonrpc.services.JsonNotification
import org.eclipse.lsp4j.services.LanguageServer

private val LOG = logger<LuauLspServerSupportProvider>()

interface LuauLanguageServer : LanguageServer {
@JsonNotification("\$/plugin/full")
fun pluginFull(params: Any)

@JsonNotification("\$/plugin/clear")
fun pluginClear()
}

class LuauLspServerSupportProvider : LspServerSupportProvider {
override fun fileOpened(
project: Project, file: VirtualFile, serverStarter: LspServerSupportProvider.LspServerStarter
Expand Down Expand Up @@ -57,6 +67,8 @@ private class LuauLspServerDescriptor(project: Project) : ProjectWideLspServerDe
) {
override fun isSupportedFile(file: VirtualFile) = file.fileType == LuauFileType

override val lsp4jServerClass = LuauLanguageServer::class.java

override val clientCapabilities: ClientCapabilities
get() = super.clientCapabilities.apply {
workspace.configuration = true
Expand Down
Loading
Loading