11package io.github.amichne.kast.intellij
22
3+ import com.intellij.openapi.application.ReadAction
34import com.intellij.openapi.command.WriteCommandAction
45import com.intellij.openapi.fileEditor.FileDocumentManager
5- import com.intellij.openapi.project.DumbService
66import com.intellij.openapi.project.Project
7- import com.intellij.openapi.util.Computable
7+ import com.intellij.openapi.progress.ProcessCanceledException
88import com.intellij.openapi.util.TextRange
99import com.intellij.openapi.vfs.LocalFileSystem
1010import com.intellij.psi.PsiClass
@@ -17,9 +17,12 @@ import com.intellij.psi.PsiManager
1717import com.intellij.psi.PsiMethod
1818import com.intellij.psi.PsiNameIdentifierOwner
1919import com.intellij.psi.PsiNamedElement
20+ import com.intellij.psi.SmartPointerManager
2021import com.intellij.psi.search.GlobalSearchScope
2122import com.intellij.psi.search.searches.ReferencesSearch
2223import com.intellij.psi.util.PsiTreeUtil
24+ import com.intellij.refactoring.rename.RenameProcessor
25+ import com.intellij.usageView.UsageInfo
2326import io.github.amichne.kast.api.AnalysisBackend
2427import io.github.amichne.kast.api.ApplyEditsQuery
2528import io.github.amichne.kast.api.ApplyEditsResult
@@ -52,23 +55,37 @@ import io.github.amichne.kast.api.SymbolQuery
5255import io.github.amichne.kast.api.SymbolResult
5356import io.github.amichne.kast.api.TextEdit
5457import kotlinx.coroutines.Dispatchers
58+ import kotlinx.coroutines.asExecutor
59+ import kotlinx.coroutines.suspendCancellableCoroutine
5560import kotlinx.coroutines.sync.Mutex
5661import kotlinx.coroutines.sync.withLock
5762import 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
5871import org.jetbrains.kotlin.psi.KtClass
5972import org.jetbrains.kotlin.psi.KtNamedDeclaration
6073import org.jetbrains.kotlin.psi.KtNamedFunction
6174import org.jetbrains.kotlin.psi.KtObjectDeclaration
6275import org.jetbrains.kotlin.psi.KtParameter
6376import org.jetbrains.kotlin.psi.KtProperty
77+ import java.util.concurrent.Callable
6478import java.nio.file.Path
6579import kotlin.io.path.readText
80+ import kotlin.coroutines.resume
81+ import kotlin.coroutines.resumeWithException
6682
83+ @OptIn(KaExperimentalApi ::class )
6784class 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