Skip to content

Commit 79ffa86

Browse files
authored
Merge pull request #12 from amichne/amichne/issue-4
2 parents 4efacef + 0f99545 commit 79ffa86

6 files changed

Lines changed: 579 additions & 62 deletions

File tree

backend-intellij/build.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,29 @@ dependencies {
1717
implementation(project(":analysis-api"))
1818
implementation(project(":analysis-server"))
1919
testImplementation(project(":shared-testing"))
20+
testImplementation("junit:junit:4.13.2")
21+
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.3")
22+
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.3")
23+
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.3")
2024

2125
intellijPlatform {
2226
intellijIdea("2025.3")
2327
bundledPlugin("com.intellij.java")
2428
bundledPlugin("org.jetbrains.kotlin")
2529
testFramework(TestFrameworkType.Platform)
30+
testFramework(TestFrameworkType.Bundled)
31+
testFramework(TestFrameworkType.JUnit5)
32+
testFramework(TestFrameworkType.Plugin.Java)
2633
}
2734
}
2835

36+
configurations.named("testRuntimeClasspath") {
37+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
38+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
39+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test")
40+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test-jvm")
41+
}
42+
2943
tasks.named("buildSearchableOptions") {
3044
enabled = false
3145
}

backend-intellij/src/main/kotlin/io/github/amichne/kast/intellij/IntelliJAnalysisBackend.kt

Lines changed: 214 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package io.github.amichne.kast.intellij
22

3+
import com.intellij.openapi.application.ReadAction
34
import com.intellij.openapi.command.WriteCommandAction
45
import com.intellij.openapi.fileEditor.FileDocumentManager
5-
import com.intellij.openapi.project.DumbService
66
import com.intellij.openapi.project.Project
7-
import com.intellij.openapi.util.Computable
7+
import com.intellij.openapi.progress.ProcessCanceledException
88
import com.intellij.openapi.util.TextRange
99
import com.intellij.openapi.vfs.LocalFileSystem
1010
import com.intellij.psi.PsiClass
@@ -17,9 +17,12 @@ import com.intellij.psi.PsiManager
1717
import com.intellij.psi.PsiMethod
1818
import com.intellij.psi.PsiNameIdentifierOwner
1919
import com.intellij.psi.PsiNamedElement
20+
import com.intellij.psi.SmartPointerManager
2021
import com.intellij.psi.search.GlobalSearchScope
2122
import com.intellij.psi.search.searches.ReferencesSearch
2223
import com.intellij.psi.util.PsiTreeUtil
24+
import com.intellij.refactoring.rename.RenameProcessor
25+
import com.intellij.usageView.UsageInfo
2326
import io.github.amichne.kast.api.AnalysisBackend
2427
import io.github.amichne.kast.api.ApplyEditsQuery
2528
import io.github.amichne.kast.api.ApplyEditsResult
@@ -52,23 +55,37 @@ import io.github.amichne.kast.api.SymbolQuery
5255
import io.github.amichne.kast.api.SymbolResult
5356
import io.github.amichne.kast.api.TextEdit
5457
import kotlinx.coroutines.Dispatchers
58+
import kotlinx.coroutines.asExecutor
59+
import kotlinx.coroutines.suspendCancellableCoroutine
5560
import kotlinx.coroutines.sync.Mutex
5661
import kotlinx.coroutines.sync.withLock
5762
import kotlinx.coroutines.withContext
63+
import org.jetbrains.concurrency.CancellablePromise
64+
import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
65+
import org.jetbrains.kotlin.analysis.api.analyze
66+
import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter
67+
import org.jetbrains.kotlin.analysis.api.components.collectDiagnostics
68+
import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi
69+
import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
70+
import org.jetbrains.kotlin.psi.KtFile
5871
import org.jetbrains.kotlin.psi.KtClass
5972
import org.jetbrains.kotlin.psi.KtNamedDeclaration
6073
import org.jetbrains.kotlin.psi.KtNamedFunction
6174
import org.jetbrains.kotlin.psi.KtObjectDeclaration
6275
import org.jetbrains.kotlin.psi.KtParameter
6376
import org.jetbrains.kotlin.psi.KtProperty
77+
import java.util.concurrent.Callable
6478
import java.nio.file.Path
6579
import kotlin.io.path.readText
80+
import kotlin.coroutines.resume
81+
import kotlin.coroutines.resumeWithException
6682

83+
@OptIn(KaExperimentalApi::class)
6784
class IntelliJAnalysisBackend(
6885
private val project: Project,
6986
private val limits: ServerLimits,
7087
) : AnalysisBackend {
71-
private val readDispatcher = Dispatchers.IO.limitedParallelism(limits.maxConcurrentRequests)
88+
private val readExecutor = Dispatchers.IO.limitedParallelism(limits.maxConcurrentRequests).asExecutor()
7289
private val writeMutex = Mutex()
7390

7491
override suspend fun capabilities(): BackendCapabilities = BackendCapabilities(
@@ -126,45 +143,77 @@ class IntelliJAnalysisBackend(
126143
override suspend fun diagnostics(query: DiagnosticsQuery): DiagnosticsResult = readInSmartMode {
127144
val diagnostics = query.filePaths.sorted().flatMap { filePath ->
128145
val file = requirePsiFile(filePath)
129-
PsiTreeUtil.collectElementsOfType(file, PsiErrorElement::class.java)
130-
.map { error ->
131-
Diagnostic(
132-
location = error.toLocation(error.textRange),
133-
severity = DiagnosticSeverity.ERROR,
134-
message = error.errorDescription,
135-
code = "PSI_PARSE_ERROR",
136-
)
137-
}
138-
}
146+
when (file) {
147+
is KtFile -> analyze(file) {
148+
file.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS)
149+
.flatMap { diagnostic -> diagnostic.toApiDiagnostics() }
150+
}.ifEmpty { file.psiParseErrors() }
151+
else -> file.psiParseErrors()
152+
}
153+
}.sortedWith(compareBy({ it.location.filePath }, { it.location.startOffset }, { it.code ?: "" }))
139154

140155
DiagnosticsResult(diagnostics = diagnostics)
141156
}
142157

143-
override suspend fun rename(query: RenameQuery): RenameResult = readInSmartMode {
144-
val file = requirePsiFile(query.position.filePath)
145-
val target = resolveTarget(file, query.position.offset)
146-
val declarationEdit = target.declarationEdit(query.newName)
147-
val referenceEdits = ReferencesSearch.search(target, GlobalSearchScope.projectScope(project))
148-
.findAll()
149-
.map { reference ->
150-
val elementRange = reference.element.textRange
151-
TextEdit(
152-
filePath = reference.element.containingFile.virtualFile.path,
153-
startOffset = elementRange.startOffset + reference.rangeInElement.startOffset,
154-
endOffset = elementRange.startOffset + reference.rangeInElement.endOffset,
155-
newText = query.newName,
156-
)
158+
override suspend fun rename(query: RenameQuery): RenameResult {
159+
val target = readInSmartMode {
160+
val file = requirePsiFile(query.position.filePath)
161+
SmartPointerManager.getInstance(project)
162+
.createSmartPsiElementPointer(resolveTarget(file, query.position.offset))
163+
.element
164+
?: throw NotFoundException("No resolvable symbol was found at the requested offset")
165+
}
166+
val processor = readInSmartMode {
167+
RenameProcessor(project, target, query.newName, false, false).apply {
168+
setSearchInComments(false)
169+
setSearchTextOccurrences(false)
157170
}
171+
}
158172

159-
val edits = (listOf(declarationEdit) + referenceEdits)
160-
.distinctBy { Triple(it.filePath, it.startOffset, it.endOffset) }
161-
.sortedWith(compareBy({ it.filePath }, { it.startOffset }))
162-
val fileHashes = currentFileHashes(edits.map(TextEdit::filePath))
163-
RenameResult(
164-
edits = edits,
165-
fileHashes = fileHashes,
166-
affectedFiles = fileHashes.map(FileHash::filePath),
167-
)
173+
return withContext(Dispatchers.IO.limitedParallelism(1)) {
174+
val preparedRenames = LinkedHashMap<PsiElement, String>()
175+
invokeOnEdt {
176+
ReadAction.run<RuntimeException> {
177+
processor.prepareRenaming(target, query.newName, preparedRenames)
178+
preparedRenames.forEach { (element, newName) ->
179+
if (element != target) {
180+
processor.addElement(element, newName)
181+
}
182+
}
183+
}
184+
}
185+
val usages = readInSmartMode {
186+
processor.findUsages().filterNot { it.isNonCodeUsage }
187+
}
188+
189+
readInSmartMode {
190+
val renamedElements = linkedSetOf(target).apply {
191+
addAll(preparedRenames.keys)
192+
}
193+
val classifiedUsages = RenameProcessor.classifyUsages(
194+
renamedElements,
195+
usages,
196+
)
197+
val declarationEdits = renamedElements.map { element ->
198+
element.declarationEdit(processor.getNewName(element))
199+
}
200+
val usageEdits = renamedElements.flatMap { element ->
201+
classifiedUsages[element].orEmpty().mapNotNull { usage ->
202+
usage.toTextEdit(processor.getNewName(element))
203+
}
204+
}
205+
206+
val edits = (declarationEdits + usageEdits)
207+
.distinctBy { Triple(it.filePath, it.startOffset, it.endOffset) }
208+
.sortedWith(compareBy({ it.filePath }, { it.startOffset }))
209+
val fileHashes = currentFileHashes(edits.map(TextEdit::filePath))
210+
RenameResult(
211+
edits = edits,
212+
fileHashes = fileHashes,
213+
affectedFiles = fileHashes.map(FileHash::filePath),
214+
)
215+
}
216+
}
168217
}
169218

170219
override suspend fun applyEdits(query: ApplyEditsQuery): ApplyEditsResult = writeMutex.withLock {
@@ -214,11 +263,13 @@ class IntelliJAnalysisBackend(
214263
)
215264
}
216265

217-
private suspend fun <T> readInSmartMode(action: () -> T): T = withContext(readDispatcher) {
218-
DumbService.getInstance(project).runReadActionInSmartMode(
219-
Computable { action() },
220-
)
221-
}
266+
private suspend fun <T> readInSmartMode(action: () -> T): T = awaitPromise(
267+
ReadAction.nonBlocking(Callable { action() })
268+
.inSmartMode(project)
269+
.withDocumentsCommitted(project)
270+
.expireWith(project)
271+
.submit(readExecutor),
272+
)
222273

223274
private fun requirePsiFile(filePath: String): PsiFile {
224275
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
@@ -307,6 +358,40 @@ class IntelliJAnalysisBackend(
307358
)
308359
}
309360

361+
private fun UsageInfo.toTextEdit(newName: String): TextEdit? {
362+
if (isNonCodeUsage) {
363+
return null
364+
}
365+
366+
val usageElement = element ?: reference?.element ?: return null
367+
val absoluteRange = when {
368+
reference != null -> {
369+
val usageReference = reference ?: return null
370+
usageElement.textRange.shiftRight(usageReference.rangeInElement.startOffset).let { range ->
371+
TextRange(range.startOffset, range.startOffset + usageReference.rangeInElement.length)
372+
}
373+
}
374+
rangeInElement != null -> {
375+
val usageRange = rangeInElement ?: return null
376+
usageElement.textRange.shiftRight(usageRange.startOffset).let { range ->
377+
TextRange(range.startOffset, range.startOffset + usageRange.length)
378+
}
379+
}
380+
segment != null -> {
381+
val usageSegment = segment ?: return null
382+
TextRange(usageSegment.startOffset, usageSegment.endOffset)
383+
}
384+
else -> usageElement.textRange
385+
}
386+
387+
return TextEdit(
388+
filePath = usageElement.containingFile.virtualFile.path,
389+
startOffset = absoluteRange.startOffset,
390+
endOffset = absoluteRange.endOffset,
391+
newText = newName,
392+
)
393+
}
394+
310395
private fun PsiElement.toSymbol(file: PsiFile): Symbol {
311396
return Symbol(
312397
fqName = fqName(),
@@ -326,16 +411,19 @@ class IntelliJAnalysisBackend(
326411
private fun PsiElement.toLocation(range: TextRange): Location {
327412
val document = PsiDocumentManager.getInstance(project).getDocument(containingFile)
328413
?: throw NotFoundException("Unable to create a document for the PSI file")
329-
val lineIndex = document.getLineNumber(range.startOffset)
414+
val safeStartOffset = range.startOffset.coerceIn(0, document.textLength)
415+
val safeEndOffset = range.endOffset.coerceIn(safeStartOffset, document.textLength)
416+
val safeRange = TextRange(safeStartOffset, safeEndOffset)
417+
val lineIndex = document.getLineNumber(safeRange.startOffset)
330418
val previewStart = document.getLineStartOffset(lineIndex)
331419
val previewEnd = document.getLineEndOffset(lineIndex)
332420

333421
return Location(
334422
filePath = containingFile.virtualFile.path,
335-
startOffset = range.startOffset,
336-
endOffset = range.endOffset,
423+
startOffset = safeRange.startOffset,
424+
endOffset = safeRange.endOffset,
337425
startLine = lineIndex + 1,
338-
startColumn = range.startOffset - previewStart + 1,
426+
startColumn = safeRange.startOffset - previewStart + 1,
339427
preview = document.getText(TextRange(previewStart, previewEnd)).trimEnd(),
340428
)
341429
}
@@ -373,4 +461,85 @@ class IntelliJAnalysisBackend(
373461
is org.jetbrains.kotlin.psi.KtFile -> packageFqName.asString().ifBlank { null }
374462
else -> null
375463
}
464+
465+
private fun PsiFile.psiParseErrors(): List<Diagnostic> = PsiTreeUtil.collectElementsOfType(this, PsiErrorElement::class.java)
466+
.map { error ->
467+
Diagnostic(
468+
location = error.toLocation(error.textRange),
469+
severity = DiagnosticSeverity.ERROR,
470+
message = error.errorDescription,
471+
code = "PSI_PARSE_ERROR",
472+
)
473+
}
474+
475+
private fun KaDiagnosticWithPsi<*>.toApiDiagnostics(): List<Diagnostic> {
476+
val ranges = textRanges.ifEmpty { listOf(TextRange(0, psi.textLength)) }
477+
return ranges.map { range ->
478+
Diagnostic(
479+
location = psi.toLocation(absoluteRange(range)),
480+
severity = severity.toApiSeverity(),
481+
message = defaultMessage,
482+
code = factoryName,
483+
)
484+
}
485+
}
486+
487+
private fun KaDiagnosticWithPsi<*>.absoluteRange(relativeRange: TextRange): TextRange {
488+
val fileLength = psi.containingFile.textLength
489+
return when {
490+
relativeRange.endOffset <= psi.textLength -> {
491+
val elementStartOffset = psi.textRange.startOffset
492+
TextRange(
493+
elementStartOffset + relativeRange.startOffset,
494+
elementStartOffset + relativeRange.endOffset,
495+
)
496+
}
497+
relativeRange.endOffset <= fileLength -> relativeRange
498+
else -> TextRange(
499+
relativeRange.startOffset.coerceIn(0, fileLength),
500+
relativeRange.endOffset.coerceIn(relativeRange.startOffset.coerceIn(0, fileLength), fileLength),
501+
)
502+
}
503+
}
504+
505+
private fun KaSeverity.toApiSeverity(): DiagnosticSeverity = when (this) {
506+
KaSeverity.ERROR -> DiagnosticSeverity.ERROR
507+
KaSeverity.WARNING -> DiagnosticSeverity.WARNING
508+
KaSeverity.INFO -> DiagnosticSeverity.INFO
509+
}
510+
511+
private suspend fun <T> awaitPromise(promise: CancellablePromise<T>): T = suspendCancellableCoroutine { continuation ->
512+
promise.onSuccess { result ->
513+
if (continuation.isActive) {
514+
continuation.resume(result)
515+
}
516+
}
517+
promise.onError { throwable ->
518+
if (!continuation.isActive) {
519+
return@onError
520+
}
521+
if (throwable is ProcessCanceledException) {
522+
continuation.cancel(throwable)
523+
} else {
524+
continuation.resumeWithException(throwable)
525+
}
526+
}
527+
continuation.invokeOnCancellation {
528+
promise.cancel()
529+
}
530+
}
531+
532+
private suspend fun invokeOnEdt(action: () -> Unit) = suspendCancellableCoroutine { continuation ->
533+
com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater {
534+
if (!continuation.isActive) {
535+
return@invokeLater
536+
}
537+
538+
runCatching(action).onSuccess {
539+
continuation.resume(Unit)
540+
}.onFailure { throwable ->
541+
continuation.resumeWithException(throwable)
542+
}
543+
}
544+
}
376545
}

0 commit comments

Comments
 (0)