Skip to content

Commit 191fdbb

Browse files
committed
feat: implement basic diagnostics
Signed-off-by: Akash Yadav <akashyadav@appdevforall.org>
1 parent 77e5942 commit 191fdbb

File tree

2 files changed

+180
-3
lines changed

2 files changed

+180
-3
lines changed

lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.itsaky.androidide.lsp.api.ILanguageClient
2727
import com.itsaky.androidide.lsp.api.ILanguageServer
2828
import com.itsaky.androidide.lsp.api.IServerSettings
2929
import com.itsaky.androidide.lsp.kotlin.compiler.Compiler
30+
import com.itsaky.androidide.lsp.kotlin.diagnostic.KotlinDiagnosticProvider
3031
import com.itsaky.androidide.lsp.models.CompletionParams
3132
import com.itsaky.androidide.lsp.models.CompletionResult
3233
import com.itsaky.androidide.lsp.models.DefinitionParams
@@ -38,10 +39,20 @@ import com.itsaky.androidide.lsp.models.ReferenceResult
3839
import com.itsaky.androidide.lsp.models.SignatureHelp
3940
import com.itsaky.androidide.lsp.models.SignatureHelpParams
4041
import com.itsaky.androidide.models.Range
42+
import com.itsaky.androidide.projects.FileManager
4143
import com.itsaky.androidide.projects.api.ModuleProject
4244
import com.itsaky.androidide.projects.api.Workspace
4345
import com.itsaky.androidide.utils.DocumentUtils
4446
import com.itsaky.androidide.utils.Environment
47+
import kotlinx.coroutines.CoroutineName
48+
import kotlinx.coroutines.CoroutineScope
49+
import kotlinx.coroutines.Dispatchers
50+
import kotlinx.coroutines.Job
51+
import kotlinx.coroutines.SupervisorJob
52+
import kotlinx.coroutines.cancel
53+
import kotlinx.coroutines.delay
54+
import kotlinx.coroutines.launch
55+
import kotlinx.coroutines.withContext
4556
import org.greenrobot.eventbus.EventBus
4657
import org.greenrobot.eventbus.Subscribe
4758
import org.greenrobot.eventbus.ThreadMode
@@ -50,12 +61,12 @@ import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryMod
5061
import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule
5162
import org.jetbrains.kotlin.config.JvmTarget
5263
import org.jetbrains.kotlin.config.LanguageVersion
53-
import org.jetbrains.kotlin.platform.jvm.JdkPlatform
5464
import org.jetbrains.kotlin.platform.jvm.JvmPlatforms
5565
import org.slf4j.LoggerFactory
66+
import java.nio.file.Files
5667
import java.nio.file.Path
5768
import java.nio.file.Paths
58-
import kotlin.io.path.pathString
69+
import kotlin.time.Duration.Companion.milliseconds
5970

6071
class KotlinLanguageServer : ILanguageServer {
6172

@@ -64,7 +75,11 @@ class KotlinLanguageServer : ILanguageServer {
6475
private var selectedFile: Path? = null
6576
private var initialized = false
6677

78+
private val scope =
79+
CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!))
6780
private var compiler: Compiler? = null
81+
private var diagnosticProvider: KotlinDiagnosticProvider? = null
82+
private var analyzeJob: Job? = null
6883

6984
override val serverId: String = SERVER_ID
7085

@@ -75,6 +90,9 @@ class KotlinLanguageServer : ILanguageServer {
7590
get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it }
7691

7792
companion object {
93+
94+
private val ANALYZE_DEBOUNCE_DELAY = 400.milliseconds
95+
7896
const val SERVER_ID = "ide.lsp.kotlin"
7997
private val log = LoggerFactory.getLogger(KotlinLanguageServer::class.java)
8098
}
@@ -89,6 +107,7 @@ class KotlinLanguageServer : ILanguageServer {
89107

90108
override fun shutdown() {
91109
EventBus.getDefault().unregister(this)
110+
scope.cancel("LSP is being shut down")
92111
compiler?.close()
93112
initialized = false
94113
}
@@ -110,6 +129,7 @@ class KotlinLanguageServer : ILanguageServer {
110129
}
111130

112131
private fun recreateSession(workspace: Workspace) {
132+
diagnosticProvider?.close()
113133
compiler?.close()
114134

115135
val jdkHome = Environment.JAVA_HOME.toPath()
@@ -188,6 +208,11 @@ class KotlinLanguageServer : ILanguageServer {
188208
}
189209
}
190210
}
211+
212+
diagnosticProvider = KotlinDiagnosticProvider(
213+
compiler = compiler!!,
214+
scope = scope,
215+
)
191216
}
192217

193218
override fun complete(params: CompletionParams?): CompletionResult {
@@ -250,7 +275,8 @@ class KotlinLanguageServer : ILanguageServer {
250275
return DiagnosticResult.NO_UPDATE
251276
}
252277

253-
return DiagnosticResult.NO_UPDATE
278+
return diagnosticProvider?.analyze(file)
279+
?: DiagnosticResult.NO_UPDATE
254280
}
255281

256282
@Subscribe(threadMode = ThreadMode.ASYNC)
@@ -261,6 +287,27 @@ class KotlinLanguageServer : ILanguageServer {
261287
}
262288

263289
selectedFile = event.openedFile
290+
debouncingAnalyze()
291+
}
292+
293+
private fun debouncingAnalyze() {
294+
analyzeJob?.cancel()
295+
analyzeJob = scope.launch(Dispatchers.Default) {
296+
delay(ANALYZE_DEBOUNCE_DELAY)
297+
analyzeSelected()
298+
}
299+
}
300+
301+
private suspend fun analyzeSelected() {
302+
val file = selectedFile ?: return
303+
val client = _client ?: return
304+
305+
if (!Files.exists(file)) return
306+
307+
val result = analyze(file)
308+
withContext(Dispatchers.Main) {
309+
client.publishDiagnostics(result)
310+
}
264311
}
265312

266313
@Subscribe(threadMode = ThreadMode.ASYNC)
@@ -269,6 +316,7 @@ class KotlinLanguageServer : ILanguageServer {
269316
if (!DocumentUtils.isKotlinFile(event.changedFile)) {
270317
return
271318
}
319+
debouncingAnalyze()
272320
}
273321

274322
@Subscribe(threadMode = ThreadMode.ASYNC)
@@ -277,6 +325,12 @@ class KotlinLanguageServer : ILanguageServer {
277325
if (!DocumentUtils.isKotlinFile(event.closedFile)) {
278326
return
279327
}
328+
329+
diagnosticProvider?.clearTimestamp(event.closedFile)
330+
if (FileManager.getActiveDocumentCount() == 0) {
331+
selectedFile = null
332+
analyzeJob?.cancel("No active files")
333+
}
280334
}
281335

282336
@Subscribe(threadMode = ThreadMode.ASYNC)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.itsaky.androidide.lsp.kotlin.diagnostic
2+
3+
import com.itsaky.androidide.lsp.kotlin.compiler.CompilationKind
4+
import com.itsaky.androidide.lsp.kotlin.compiler.Compiler
5+
import com.itsaky.androidide.lsp.models.DiagnosticItem
6+
import com.itsaky.androidide.lsp.models.DiagnosticResult
7+
import com.itsaky.androidide.lsp.models.DiagnosticSeverity
8+
import com.itsaky.androidide.models.Position
9+
import com.itsaky.androidide.models.Range
10+
import com.itsaky.androidide.projects.FileManager
11+
import com.itsaky.androidide.tasks.cancelIfActive
12+
import kotlinx.coroutines.CancellationException
13+
import kotlinx.coroutines.CoroutineScope
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.Job
16+
import kotlinx.coroutines.delay
17+
import kotlinx.coroutines.launch
18+
import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter
19+
import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi
20+
import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
21+
import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange
22+
import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager
23+
import org.jetbrains.kotlin.com.intellij.psi.PsiFile
24+
import org.slf4j.LoggerFactory
25+
import java.nio.file.Path
26+
import java.time.Instant
27+
import java.util.concurrent.ConcurrentHashMap
28+
import kotlin.math.log
29+
import org.jetbrains.kotlin.analysis.api.analyze as ktAnalyze
30+
31+
class KotlinDiagnosticProvider(
32+
private val compiler: Compiler,
33+
private val scope: CoroutineScope
34+
) : AutoCloseable {
35+
36+
companion object {
37+
private val logger = LoggerFactory.getLogger(KotlinDiagnosticProvider::class.java)
38+
}
39+
40+
private val analyzeTimestamps = ConcurrentHashMap<Path, Instant>()
41+
42+
fun analyze(file: Path): DiagnosticResult =
43+
try {
44+
logger.info("Analyzing file: {}", file)
45+
return doAnalyze(file)
46+
} catch (err: Throwable) {
47+
if (err is CancellationException) {
48+
throw err
49+
}
50+
logger.error("An error occurred analyzing file: {}", file, err)
51+
return DiagnosticResult.NO_UPDATE
52+
}
53+
54+
private fun doAnalyze(file: Path): DiagnosticResult {
55+
val modifiedAt = FileManager.getLastModified(file)
56+
val analyzedAt = analyzeTimestamps[file]
57+
if (analyzedAt?.isAfter(modifiedAt) == true) {
58+
return DiagnosticResult.NO_UPDATE
59+
}
60+
61+
val fileContents = FileManager.getDocumentContents(file)
62+
.replace("\r", "")
63+
val ktFile = compiler.createKtFile(fileContents, file, CompilationKind.Default)
64+
val rawDiagnostics = ktAnalyze(ktFile) {
65+
ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS)
66+
}
67+
68+
return DiagnosticResult(
69+
file = file,
70+
diagnostics = rawDiagnostics.map { rawDiagnostic ->
71+
rawDiagnostic.toDiagnosticItem()
72+
}
73+
)
74+
75+
}
76+
77+
internal fun clearTimestamp(file: Path) {
78+
analyzeTimestamps.remove(file)
79+
}
80+
81+
override fun close() {
82+
scope.cancelIfActive("diagnostic provider is being destroyed")
83+
}
84+
}
85+
86+
private fun KaDiagnosticWithPsi<*>.toDiagnosticItem(): DiagnosticItem {
87+
val range = psi.textRange.toRange(psi.containingFile)
88+
val severity = severity.toDiagnosticSeverity()
89+
return DiagnosticItem(
90+
message = defaultMessage,
91+
code = "",
92+
range = range,
93+
source = "Kotlin",
94+
severity = severity,
95+
)
96+
}
97+
98+
private fun KaSeverity.toDiagnosticSeverity(): DiagnosticSeverity {
99+
return when (this) {
100+
KaSeverity.ERROR -> DiagnosticSeverity.ERROR
101+
KaSeverity.WARNING -> DiagnosticSeverity.WARNING
102+
KaSeverity.INFO -> DiagnosticSeverity.INFO
103+
}
104+
}
105+
106+
private fun TextRange.toRange(containingFile: PsiFile): Range {
107+
val doc = PsiDocumentManager.getInstance(containingFile.project)
108+
.getDocument(containingFile) ?: return Range.NONE
109+
val startLine = doc.getLineNumber(startOffset)
110+
val startCol = startOffset - doc.getLineStartOffset(startLine)
111+
val endLine = doc.getLineNumber(endOffset)
112+
val endCol = endOffset - doc.getLineStartOffset(endLine)
113+
return Range(
114+
start = Position(
115+
line = startLine,
116+
column = startCol,
117+
),
118+
end = Position(
119+
line = endLine,
120+
column = endCol,
121+
)
122+
)
123+
}

0 commit comments

Comments
 (0)