diff --git a/server/src/main/kotlin/org/javacs/kt/Configuration.kt b/server/src/main/kotlin/org/javacs/kt/Configuration.kt index 4747b0a23..5fb9fb467 100644 --- a/server/src/main/kotlin/org/javacs/kt/Configuration.kt +++ b/server/src/main/kotlin/org/javacs/kt/Configuration.kt @@ -46,6 +46,12 @@ public data class ExternalSourcesConfiguration( var autoConvertToKotlin: Boolean = false ) +data class InlayHintsConfiguration( + var typeHints: Boolean = false, + var parameterHints: Boolean = false, + var chainedHints: Boolean = false +) + fun getStoragePath(params: InitializeParams): Path? { params.initializationOptions?.let { initializationOptions -> @@ -81,5 +87,6 @@ public data class Configuration( val completion: CompletionConfiguration = CompletionConfiguration(), val linting: LintingConfiguration = LintingConfiguration(), var indexing: IndexingConfiguration = IndexingConfiguration(), - val externalSources: ExternalSourcesConfiguration = ExternalSourcesConfiguration() + val externalSources: ExternalSourcesConfiguration = ExternalSourcesConfiguration(), + val hints: InlayHintsConfiguration = InlayHintsConfiguration() ) diff --git a/server/src/main/kotlin/org/javacs/kt/KotlinLanguageServer.kt b/server/src/main/kotlin/org/javacs/kt/KotlinLanguageServer.kt index 1c94d2a4d..f243f7977 100644 --- a/server/src/main/kotlin/org/javacs/kt/KotlinLanguageServer.kt +++ b/server/src/main/kotlin/org/javacs/kt/KotlinLanguageServer.kt @@ -77,6 +77,7 @@ class KotlinLanguageServer : LanguageServer, LanguageClientAware, Closeable { serverCapabilities.workspace.workspaceFolders = WorkspaceFoldersOptions() serverCapabilities.workspace.workspaceFolders.supported = true serverCapabilities.workspace.workspaceFolders.changeNotifications = Either.forRight(true) + serverCapabilities.inlayHintProvider = Either.forLeft(true) serverCapabilities.hoverProvider = Either.forLeft(true) serverCapabilities.renameProvider = Either.forLeft(true) serverCapabilities.completionProvider = CompletionOptions(false, listOf(".")) diff --git a/server/src/main/kotlin/org/javacs/kt/KotlinTextDocumentService.kt b/server/src/main/kotlin/org/javacs/kt/KotlinTextDocumentService.kt index d5180a9f8..59e07814e 100644 --- a/server/src/main/kotlin/org/javacs/kt/KotlinTextDocumentService.kt +++ b/server/src/main/kotlin/org/javacs/kt/KotlinTextDocumentService.kt @@ -5,7 +5,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either import org.eclipse.lsp4j.services.LanguageClient import org.eclipse.lsp4j.services.TextDocumentService import org.javacs.kt.codeaction.codeActions -import org.javacs.kt.completion.* +import org.javacs.kt.completion.completions import org.javacs.kt.definition.goToDefinition import org.javacs.kt.diagnostic.convertDiagnostic import org.javacs.kt.formatting.formatKotlinCode @@ -16,17 +16,18 @@ import org.javacs.kt.position.position import org.javacs.kt.references.findReferences import org.javacs.kt.semantictokens.encodedSemanticTokens import org.javacs.kt.signaturehelp.fetchSignatureHelpAt +import org.javacs.kt.rename.renameSymbol +import org.javacs.kt.highlight.documentHighlightsAt +import org.javacs.kt.inlayhints.provideHints import org.javacs.kt.symbols.documentSymbols -import org.javacs.kt.util.noResult import org.javacs.kt.util.AsyncExecutor import org.javacs.kt.util.Debouncer -import org.javacs.kt.util.filePath import org.javacs.kt.util.TemporaryDirectory -import org.javacs.kt.util.parseURI import org.javacs.kt.util.describeURI import org.javacs.kt.util.describeURIs -import org.javacs.kt.rename.renameSymbol -import org.javacs.kt.highlight.documentHighlightsAt +import org.javacs.kt.util.filePath +import org.javacs.kt.util.noResult +import org.javacs.kt.util.parseURI import org.jetbrains.kotlin.resolve.diagnostics.Diagnostics import java.net.URI import java.io.Closeable @@ -95,6 +96,11 @@ class KotlinTextDocumentService( codeActions(file, sp.index, params.range, params.context) } + override fun inlayHint(params: InlayHintParams): CompletableFuture> = async.compute { + val (file, _) = recover(params.textDocument.uri, params.range.start, Recompile.ALWAYS) + provideHints(file, config.hints) + } + override fun hover(position: HoverParams): CompletableFuture = async.compute { reportTime { LOG.info("Hovering at {}", describePosition(position)) diff --git a/server/src/main/kotlin/org/javacs/kt/KotlinWorkspaceService.kt b/server/src/main/kotlin/org/javacs/kt/KotlinWorkspaceService.kt index 446a9a0dd..5aa1e7381 100644 --- a/server/src/main/kotlin/org/javacs/kt/KotlinWorkspaceService.kt +++ b/server/src/main/kotlin/org/javacs/kt/KotlinWorkspaceService.kt @@ -102,6 +102,14 @@ class KotlinWorkspaceService( } } + // Update options for inlay hints + get("inlayHints")?.asJsonObject?.apply { + val hints = config.hints + get("typeHints")?.asBoolean?.let { hints.typeHints = it } + get("parameterHints")?.asBoolean?.let { hints.parameterHints = it } + get("chainedHints")?.asBoolean?.let { hints.chainedHints = it } + } + // Update linter options get("linting")?.asJsonObject?.apply { val linting = config.linting diff --git a/server/src/main/kotlin/org/javacs/kt/inlayhints/InlayHint.kt b/server/src/main/kotlin/org/javacs/kt/inlayhints/InlayHint.kt new file mode 100644 index 000000000..4644a8987 --- /dev/null +++ b/server/src/main/kotlin/org/javacs/kt/inlayhints/InlayHint.kt @@ -0,0 +1,236 @@ +package org.javacs.kt.inlayhints + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNameIdentifierOwner +import com.intellij.psi.PsiWhiteSpace +import org.eclipse.lsp4j.InlayHint +import org.eclipse.lsp4j.InlayHintKind +import org.eclipse.lsp4j.jsonrpc.messages.Either +import org.javacs.kt.CompiledFile +import org.javacs.kt.InlayHintsConfiguration +import org.javacs.kt.completion.DECL_RENDERER +import org.javacs.kt.position.range +import org.javacs.kt.util.preOrderTraversal +import org.jetbrains.kotlin.descriptors.CallableDescriptor +import org.jetbrains.kotlin.lexer.KtTokens.DOT +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtFunction +import org.jetbrains.kotlin.psi.KtLambdaArgument +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.psiUtil.getChildOfType +import org.jetbrains.kotlin.psi.psiUtil.getChildrenOfType +import org.jetbrains.kotlin.resolve.BindingContext +import org.jetbrains.kotlin.resolve.calls.model.ResolvedValueArgument +import org.jetbrains.kotlin.resolve.calls.model.VarargValueArgument +import org.jetbrains.kotlin.resolve.calls.smartcasts.getKotlinTypeForComparison +import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall +import org.jetbrains.kotlin.resolve.calls.util.isSingleUnderscore +import org.jetbrains.kotlin.types.KotlinType +import org.jetbrains.kotlin.types.error.ErrorType + + +private fun PsiElement.determineType(ctx: BindingContext): KotlinType? = + when (this) { + is KtNamedFunction -> { + val descriptor = ctx[BindingContext.FUNCTION, this] + descriptor?.returnType + } + is KtCallExpression -> { + this.getKotlinTypeForComparison(ctx) + } + is KtParameter -> { + if (this.isLambdaParameter and (this.typeReference == null)) { + val descriptor = ctx[BindingContext.DECLARATION_TO_DESCRIPTOR, this] as CallableDescriptor + descriptor.returnType + } else null + } + is KtDestructuringDeclarationEntry -> { + //skip unused variable denoted by underscore + //https://kotlinlang.org/docs/destructuring-declarations.html#underscore-for-unused-variables + if (this.isSingleUnderscore) { + null + } else { + val resolvedCall = ctx[BindingContext.COMPONENT_RESOLVED_CALL, this] + resolvedCall?.resultingDescriptor?.returnType + } + } + is KtProperty -> { + val type = this.getKotlinTypeForComparison(ctx) + if (type is ErrorType) null else type + } + else -> null + } + +@Suppress("ReturnCount") +private fun PsiElement.hintBuilder(kind: InlayKind, file: CompiledFile, label: String? = null): InlayHint? { + val element = when(this) { + is KtFunction -> this.valueParameterList!!.originalElement + is PsiNameIdentifierOwner -> this.nameIdentifier + else -> this + } ?: return null + + val range = range(file.parse.text, element.textRange) + + val hint = when(kind) { + InlayKind.ParameterHint -> InlayHint(range.start, Either.forLeft("$label:")) + else -> + this.determineType(file.compile) ?.let { + InlayHint(range.end, Either.forLeft(DECL_RENDERER.renderType(it))) + } ?: return null + } + hint.kind = kind.base + hint.paddingRight = true + hint.paddingLeft = true + return hint +} + +@Suppress("ReturnCount") +private fun callableArgNameHints( + acc: MutableList, + callExpression: KtCallExpression, + file: CompiledFile, + config: InlayHintsConfiguration +) { + if (!config.parameterHints) return + + //hints are not rendered for argument of type lambda expression i.e. list.map { it } + if (callExpression.getChildOfType() != null) { + return + } + + val resolvedCall = callExpression.getResolvedCall(file.compile) + val entries = resolvedCall?.valueArguments?.entries ?: return + + val hints = entries.mapNotNull { (t, u) -> + val valueArg = u.arguments.firstOrNull() + if (valueArg != null && !valueArg.isNamed()) { + val label = getArgLabel(t.name, u) + valueArg.asElement().hintBuilder(InlayKind.ParameterHint, file, label) + } else null + } + acc.addAll(hints) +} + +private fun getArgLabel(name: Name, arg: ResolvedValueArgument) = + (name).let { + when (arg) { + is VarargValueArgument -> "...$it" + else -> it.asString() + } + } + +private fun lambdaValueParamHints( + acc: MutableList, + node: KtLambdaArgument, + file: CompiledFile, + config: InlayHintsConfiguration +) { + if (!config.typeHints) return + + val params = node.getLambdaExpression()!!.valueParameters + + //hint should not be rendered when parameter is of type DestructuringDeclaration + //example: Map.forEach { (k,v) -> _ } + //lambda parameter (k,v) becomes (k :hint, v :hint) :hint <- outer hint isnt needed + params.singleOrNull()?.let { + if (it.destructuringDeclaration != null) return + } + + val hints = params.mapNotNull { + it.hintBuilder(InlayKind.TypeHint, file) + } + acc.addAll(hints) +} + +private fun chainedExpressionHints( + acc: MutableList, + node: KtDotQualifiedExpression, + file: CompiledFile, + config: InlayHintsConfiguration +) { + if (!config.chainedHints) return + + ///chaining is defined as an expression whose next sibling tokens are newline and dot + val next = (node.nextSibling as? PsiWhiteSpace) + val nextSiblingElement = next?.nextSibling?.node?.elementType + + if (nextSiblingElement != null && nextSiblingElement == DOT) { + val hints = node.getChildrenOfType().mapNotNull { + it.hintBuilder(InlayKind.ChainingHint, file) + } + acc.addAll(hints) + } +} + +private fun destructuringVarHints( + acc: MutableList, + node: KtDestructuringDeclaration, + file: CompiledFile, + config: InlayHintsConfiguration +) { + if (!config.typeHints) return + + val hints = node.entries.mapNotNull { + it.hintBuilder(InlayKind.TypeHint, file) + } + acc.addAll(hints) +} + +@Suppress("ReturnCount") +private fun declarationHint( + acc: MutableList, + node: KtProperty, + file: CompiledFile, + config: InlayHintsConfiguration +) { + if (!config.typeHints) return + + //check decleration does not include type i.e. var t1: String + if (node.typeReference != null) return + + val hint = node.hintBuilder(InlayKind.TypeHint, file) ?: return + acc.add(hint) +} + +private fun functionHint( + acc: MutableList, + node: KtNamedFunction, + file: CompiledFile, + config: InlayHintsConfiguration +) { + if (!config.typeHints) return + + //only render hints for functions without block body + //functions WITH block body will always specify return types explicitly + if (!node.hasDeclaredReturnType() && !node.hasBlockBody()) { + val hint = node.hintBuilder(InlayKind.TypeHint, file) ?: return + acc.add(hint) + } +} + +fun provideHints(file: CompiledFile, config: InlayHintsConfiguration): List { + val res = mutableListOf() + for (node in file.parse.preOrderTraversal().asIterable()) { + when (node) { + is KtNamedFunction -> functionHint(res, node, file, config) + is KtLambdaArgument -> lambdaValueParamHints(res, node, file, config) + is KtDotQualifiedExpression -> chainedExpressionHints(res, node, file, config) + is KtCallExpression -> callableArgNameHints(res, node, file, config) + is KtDestructuringDeclaration -> destructuringVarHints(res, node, file, config) + is KtProperty -> declarationHint(res, node, file, config) + } + } + return res +} + +enum class InlayKind(val base: InlayHintKind) { + TypeHint(InlayHintKind.Type), + ParameterHint(InlayHintKind.Parameter), + ChainingHint(InlayHintKind.Type), +} diff --git a/server/src/test/kotlin/org/javacs/kt/InlayHintTest.kt b/server/src/test/kotlin/org/javacs/kt/InlayHintTest.kt new file mode 100644 index 000000000..3fa24f8fc --- /dev/null +++ b/server/src/test/kotlin/org/javacs/kt/InlayHintTest.kt @@ -0,0 +1,163 @@ +package org.javacs.kt + +import org.eclipse.lsp4j.InlayHint +import org.eclipse.lsp4j.Position +import org.hamcrest.Matchers.isIn +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.everyItem +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.equalTo +import org.junit.Assert.assertThat +import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals +import org.junit.Test + + +private fun predicate(pos: Position, label: String) = { + hint: InlayHint -> hint.position == pos && hint.label.left.contains(label) +} + +private fun nPredicateFilter( + hints: List, + predicates: List<(InlayHint) -> Boolean> +): List = + hints.filter { + predicates.any { p -> p(it) } + } + + +class InlayHintDeclarationTest : SingleFileTestFixture("inlayhints", "Declarations.kt") { + + private val hints = languageServer.textDocumentService.inlayHint(inlayHintParams(file, range(0, 0, 0, 0))).get() + + @Test + fun `lambda declaration hints`() { + val result = hints.filter { + it.position == Position(2, 10) + } + assertThat(result, hasSize(1)) + + val label = result.single().label.left.replaceBefore("(", "") + val regex = Regex("\\(([^)]+)\\) -> .*") + assertTrue(label.matches(regex)) + } + + @Test + fun `destrucuted declaration hints`() { + val predicates = listOf( + predicate(Position(17, 10), "Float"), + predicate(Position(17, 13), "Double"), + ) + val result = nPredicateFilter(hints, predicates) + assertThat(result, hasSize(2)) + assertThat(result, everyItem(isIn(hints))) + } + + @Test + fun `should not render hint with explicit type`() { + val result = hints.filter { + it.label.left.contains("Type") + } + assertTrue(result.isEmpty()) + } + + @Test + fun `generic type hints`() { + val expected = listOf(Position(5, 13), Position(20, 7)) + + val result = hints.filter { + it.label.left.matches(Regex("Box<([^)]+)>")) + }.map { it.position } + + assertEquals(result.size, expected.size) + assertEquals(result.sortedBy { it.line }, expected.sortedBy { it.line }) + } + + @Test + fun `inferred hint for single-expression function`() { + val hint = hints.filter { + it.position == Position(22, 24) + } + assertThat(hint, hasSize(1)) + assertThat(hint.single().label.left, containsString("String")) + } + +} + +class InlayHintCallableParameterTest : SingleFileTestFixture("inlayhints", "Parameters.kt") { + + private val hints = languageServer.textDocumentService.inlayHint(inlayHintParams(file, range(0, 0, 0, 0))).get() + + @Test + fun `class parameter hints`() { + val predicates = listOf( + predicate(Position(13, 4), "x"), + predicate(Position(14, 4), "y"), + predicate(Position(15, 4), "z"), + ) + val result = nPredicateFilter(hints, predicates) + assertThat(result, hasSize(3)) + assertThat(result, everyItem(isIn(hints))) + } + + @Test + fun `has one vararg parameter hint`() { + val varargHintCount = hints.filter { + it.label.left.contains("ints") + }.size + assertThat(varargHintCount, equalTo(1)) + } + + @Test + fun `mixed parameter types`(){ + val predicates = listOf( + predicate(Position(17, 14), "d"), + predicate(Position(17, 19), "p1"), + predicate(Position(17, 25), "ints"), + ) + val result = nPredicateFilter(hints, predicates) + assertThat(result, hasSize(3)) + assertThat(result, everyItem(isIn(hints))) + } + + @Test + fun `inferred lambda parameter type`() { + val hint = hints.filter { + it.label.left.contains("Int") + } + assertThat(hint, hasSize(1)) + assertThat(hint.single().label.left, containsString("Int")) + } + +} + +class InlayHintChainedTest : SingleFileTestFixture("inlayhints", "ChainedMethods.kt") { + + private val hints = languageServer.textDocumentService.inlayHint(inlayHintParams(file, range(0, 0, 0, 0))).get() + + @Test + fun `chained hints`() { + val predicates = listOf( + predicate(Position(17, 34), "List"), + predicate(Position(18, 26), "List"), + predicate(Position(19, 19), "Array"), + ) + val result = nPredicateFilter(hints, predicates) + + assertThat(result, hasSize(3)) + assertThat(result, everyItem(isIn(hints))) + } + + @Test + fun `generic chained hints`() { + val predicates = listOf( + predicate(Position(22, 16), "A"), + predicate(Position(23, 8), "B"), + ) + val result = nPredicateFilter(hints, predicates) + + assertThat(result, hasSize(2)) + assertThat(result, everyItem(isIn(hints))) + } + +} diff --git a/server/src/test/kotlin/org/javacs/kt/LanguageServerTestFixture.kt b/server/src/test/kotlin/org/javacs/kt/LanguageServerTestFixture.kt index 2f88a64f6..ae65bbe97 100644 --- a/server/src/test/kotlin/org/javacs/kt/LanguageServerTestFixture.kt +++ b/server/src/test/kotlin/org/javacs/kt/LanguageServerTestFixture.kt @@ -36,6 +36,12 @@ abstract class LanguageServerTestFixture(relativeWorkspaceRoot: String) : Langua name = workspaceRoot.fileName.toString() uri = workspaceRoot.toUri().toString() }) + + languageServer.config.hints.apply { + this.typeHints = true + this.parameterHints = true + this.chainedHints = true + } languageServer.sourcePath.indexEnabled = false languageServer.connect(this) languageServer.initialize(init).join() @@ -80,6 +86,9 @@ abstract class LanguageServerTestFixture(relativeWorkspaceRoot: String) : Langua fun hoverParams(relativePath: String, line: Int, column: Int): HoverParams = textDocumentPosition(relativePath, line, column).run { HoverParams(textDocument, position) } + fun inlayHintParams(relativePath: String, range: Range): InlayHintParams = + textDocumentPosition(relativePath, 0, 0).run { InlayHintParams(textDocument, range) } + fun semanticTokensParams(relativePath: String): SemanticTokensParams = textDocumentPosition(relativePath, 0, 0).run { SemanticTokensParams(textDocument) } diff --git a/server/src/test/resources/inlayhints/ChainedMethods.kt b/server/src/test/resources/inlayhints/ChainedMethods.kt new file mode 100644 index 000000000..8644e2841 --- /dev/null +++ b/server/src/test/resources/inlayhints/ChainedMethods.kt @@ -0,0 +1,25 @@ +package inlayhints + + +class A(private val self: List) { + fun a(): B { + return B(self) + } +} + +class B(private val self: List) { + fun b(): List { + return self + } +} + +val foo = listOf(1, 2, 3, 4) + +val bar = listOf("hello", "world") + .map { it.length * 2 } + .toTypedArray() + .contains(2) + +val baz = A(foo) + .a() + .b() diff --git a/server/src/test/resources/inlayhints/Declarations.kt b/server/src/test/resources/inlayhints/Declarations.kt new file mode 100644 index 000000000..051381165 --- /dev/null +++ b/server/src/test/resources/inlayhints/Declarations.kt @@ -0,0 +1,23 @@ +package inlayhints + +val lambda = { n: Int, m: Double -> "$n -> $m" } + +class Box(t: T) { + var value = this +} + +data class Type( + val f: Float, + val d: Double, +) + +fun destructure() { + val type: Type + type = Type(1.0f, 2.0) + + val (x, y) = type +} + +val box = Box(0) + +fun toStr(b: Box) = b.value.toString() diff --git a/server/src/test/resources/inlayhints/Parameters.kt b/server/src/test/resources/inlayhints/Parameters.kt new file mode 100644 index 000000000..69c08cbd6 --- /dev/null +++ b/server/src/test/resources/inlayhints/Parameters.kt @@ -0,0 +1,19 @@ +package inlayhints + +data class Vec( + val x: Double, + val y: Double, + val z: Int +) + +fun print(d: Double, vararg ints: Int, cond: Boolean) {} + +val calc = { v: Vec -> v.x + v.y * v.z } + +val vec = Vec( + 2.0, + 2.2, + 1, +) +val t = print(calc(vec), 1,2,3, cond = true) +val m = listOf(0,0).map { num -> num.toDouble() } \ No newline at end of file