Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add code completion for chained value factories #11

Merged
merged 7 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format

[versions]
gradle-tooling = "8.12-20241112084018+0000"
declarative-dsl = "8.12-20241112084018+0000"
gradle-tooling = "8.13-20250128002155+0000"
declarative-dsl = "8.13-20250128002155+0000"
detekt = "1.23.6"
lsp4j = "0.23.1"
logback = "1.5.6"
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions-snapshots/gradle-8.13-20250128002155+0000-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
1 change: 1 addition & 0 deletions lsp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
implementation(libs.gradle.declarative.dsl.tooling.models)

testImplementation(libs.mockk)
testImplementation(libs.gradle.tooling.api)
}

detekt {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class DeclarativeLanguageServer : LanguageServer, LanguageClientAware {

LOGGER.info("Fetching declarative model for workspace folder: $workspaceFolderFile")
TapiConnectionHandler(workspaceFolderFile).let {
val declarativeResources = it.getDomPrequisites()
val declarativeResources = it.getDeclarativeResources()

// Create services shared between the LSP services
val documentStore = VersionedDocumentStore()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

package org.gradle.declarative.lsp

import org.eclipse.lsp4j.ClientCapabilities
import org.eclipse.lsp4j.ClientInfo
import org.eclipse.lsp4j.CodeAction
import org.eclipse.lsp4j.CodeActionParams
import org.eclipse.lsp4j.Command
Expand All @@ -42,7 +40,17 @@ import org.eclipse.lsp4j.SignatureInformation
import org.eclipse.lsp4j.jsonrpc.messages.Either
import org.eclipse.lsp4j.services.LanguageClient
import org.eclipse.lsp4j.services.TextDocumentService
import org.gradle.declarative.dsl.schema.*
import org.gradle.declarative.dsl.schema.AnalysisSchema
import org.gradle.declarative.dsl.schema.DataClass
import org.gradle.declarative.dsl.schema.DataParameter
import org.gradle.declarative.dsl.schema.DataProperty
import org.gradle.declarative.dsl.schema.DataType
import org.gradle.declarative.dsl.schema.DataTypeRef
import org.gradle.declarative.dsl.schema.EnumClass
import org.gradle.declarative.dsl.schema.FqName
import org.gradle.declarative.dsl.schema.FunctionSemantics
import org.gradle.declarative.dsl.schema.SchemaFunction
import org.gradle.declarative.dsl.schema.SchemaMemberFunction
import org.gradle.declarative.lsp.build.model.DeclarativeResourcesModel
import org.gradle.declarative.lsp.extension.indexBasedOverlayResultFromDocuments
import org.gradle.declarative.lsp.extension.toLspRange
Expand Down Expand Up @@ -88,7 +96,7 @@ class DeclarativeTextDocumentService : TextDocumentService {
this.documentStore = documentStore
this.mutationRegistry = mutationRegistry
this.declarativeResources = declarativeResources

this.schemaAnalysisEvaluator = SimpleAnalysisEvaluator.withSchema(
declarativeResources.settingsInterpretationSequence,
declarativeResources.projectInterpretationSequence
Expand Down Expand Up @@ -168,17 +176,21 @@ class DeclarativeTextDocumentService : TextDocumentService {
val completions = params?.let { param ->
val uri = URI(param.textDocument.uri)
withDom(uri) { dom, schema, _ ->
dom.document.visit(
val bestFittingNode = dom.document.visit(
BestFittingNodeVisitor(
params.position,
DeclarativeDocument.DocumentNode.ElementNode::class
)
).bestFittingNode
bestFittingNode
?.getDataClass(dom.overlayResolutionContainer)
.let { it ?: schema.topLevelReceiverType }
.let { dataClass ->
computePropertyCompletions(dataClass, schema) +
computeFunctionCompletions(dataClass, schema)
computePropertyCompletions(dataClass, schema) + computePropertyByValueFactoryCompletions(
dataClass,
schema
) +
computeFunctionCompletions(dataClass, schema)
}
}
}.orEmpty().toMutableList()
Expand Down Expand Up @@ -303,7 +315,7 @@ class DeclarativeTextDocumentService : TextDocumentService {
)
)
}

data class ParsedDocument(
val documentOverlayResult: DocumentOverlayResult,
val analysisSchemas: List<AnalysisSchema>
Expand All @@ -312,7 +324,7 @@ class DeclarativeTextDocumentService : TextDocumentService {
private fun parse(uri: URI, text: String): ParsedDocument {
val fileName = uri.path.substringAfterLast('/')
val analysisResult = schemaAnalysisEvaluator.evaluate(fileName, text)

// Workaround: for now, the mutation utilities cannot handle mutations that touch the underlay document content.
// To avoid that, use the utility that produces an overlay result with no real underlay content.
// This utility also takes care of multi-step resolution results and merges them, presenting .
Expand Down Expand Up @@ -345,14 +357,70 @@ private fun computePropertyCompletions(
dataClass: DataClass,
analysisSchema: AnalysisSchema
): List<CompletionItem> {
return dataClass.properties.map { property ->
val propertyName = property.name
val targetType = property.valueType.toSimpleName()
return dataClass.properties.mapNotNull { property ->
when (val resolvedType = SchemaTypeRefContext(analysisSchema).resolveRef(property.valueType)) {
is EnumClass -> completionItem(property, resolvedType)
is DataType.BooleanDataType -> completionItem(property, resolvedType)
is DataType.IntDataType -> completionItem(property, resolvedType)
is DataType.LongDataType -> completionItem(property, resolvedType)
is DataType.StringDataType -> completionItem(property, resolvedType)
else -> null
}
}
}

CompletionItem("$propertyName = $targetType").apply {
kind = CompletionItemKind.Field
insertTextFormat = InsertTextFormat.Snippet
insertText = "${property.name} = ${computeTypedPlaceholder(1, property.valueType, analysisSchema)}"
private fun completionItem(property: DataProperty, resolvedType: DataType) =
CompletionItem("${property.name} = ${property.valueType.toSimpleName()}").apply {
kind = CompletionItemKind.Field
insertTextFormat = InsertTextFormat.Snippet
insertText = "${property.name} = ${computeTypedPlaceholder(1, resolvedType)}"
}

private typealias LabelAndInsertText = Pair<String, String>

private fun computePropertyByValueFactoryCompletions(
dataClass: DataClass,
analysisSchema: AnalysisSchema
): List<CompletionItem> {
fun indexValueFactories(analysisSchema: AnalysisSchema, type: DataClass, namePrefix: String): Map<FqName, List<LabelAndInsertText>> {
Copy link
Member

@h0tk3y h0tk3y Feb 10, 2025

Choose a reason for hiding this comment

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

💭 Given that with a big enough schema this might be an expensive operation, I would suggest finding a way to build the index once (or maybe precompute/memoize some parts of it) and invalidate it on schema changes.

For instance, some precomputed or memoized state could include:

  • For each value type, all value factories of that type grouped by the container type.
  • For each container type, the set of paths like layout.settingsDirectory starting from that type (along with the type of layout.settingsDirectory)

Copy link
Member Author

Choose a reason for hiding this comment

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

I've addressed this, please take a look.

val factoryIndex = mutableMapOf<FqName, List<LabelAndInsertText>>()
type.memberFunctions
.filter { it.semantics is FunctionSemantics.Pure && it.returnValueType is DataTypeRef.Name }
.forEach {
val indexKey = (it.returnValueType as DataTypeRef.Name).fqName
val labelAndInsertText = "$namePrefix${computeCompletionLabel(it)}" to "$namePrefix${computeCompletionInsertText(it, analysisSchema)}"
factoryIndex.merge(indexKey, listOf(labelAndInsertText)) { oldVal, newVal -> oldVal + newVal }
}
type.properties.filter { it.valueType is DataTypeRef.Name }.forEach {
when (val propType = analysisSchema.dataClassTypesByFqName[(it.valueType as DataTypeRef.Name).fqName]) {
is DataClass -> {
val propName = it.name
val propIndex = indexValueFactories(analysisSchema, propType, "$namePrefix${propName}.")
propIndex.forEach { (key, value) -> factoryIndex.merge(key, value) { oldVal, newVal -> oldVal + newVal } }
}

is EnumClass -> Unit
null -> Unit
}
}
return factoryIndex
}

val factories = indexValueFactories(analysisSchema, analysisSchema.topLevelReceiverType, "")
Copy link
Member

Choose a reason for hiding this comment

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

💭 Currently, in addition to the top-level type, you can use the members of the nested containers. For instance, in

foo {
    bar { 
        baz = qux.quux()
    }
}

qux could be a member of:

  • bar's receiver
  • foo's receiver
  • the top-level receiver

We should probably discuss if we want to keep it that way, but if we do, these nested receivers should also contribute the value factory groups to completion.

Copy link
Member Author

Choose a reason for hiding this comment

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

As it works now, qux will be found regardless which of those receivers it's on and will be proposed as a potential value for properties with the same type as the return type of the quux() function.

I think the real problem we should think about is that qux.quux() will be proposed as a potential value for baz even if qux comes from a totally unrelated block, which is part of the top-level receiver, but is unrelated to foo (thus by extension unrelated to bar and baz too).

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a TODO.

return dataClass.properties.flatMap { property ->
val resolvedType = SchemaTypeRefContext(analysisSchema).resolveRef(property.valueType)
if (resolvedType is DataType.ClassDataType) {
val factoriesForProperty = factories[resolvedType.name]
factoriesForProperty
?.map {
CompletionItem("${property.name} = ${it.first}").apply {
kind = CompletionItemKind.Field
insertTextFormat = InsertTextFormat.Snippet
insertText = "${property.name} = ${it.second}"
}
} ?: emptyList()
} else {
emptyList()
}
}
}
Expand All @@ -362,28 +430,56 @@ private fun computeFunctionCompletions(
analysisSchema: AnalysisSchema
): List<CompletionItem> =
dataClass.memberFunctions.map { function ->
val functionName = function.simpleName
val parameterSignature = when (function.parameters.isEmpty()) {
true -> ""
false -> function.parameters.joinToString(",", "(", ")") { it.toSignatureLabel() }
}
val configureBlockLabel = function.semantics.toBlockConfigurabilityLabel().orEmpty()

CompletionItem("$functionName$parameterSignature$configureBlockLabel").apply {
val label = computeCompletionLabel(function)
val text = computeCompletionInsertText(function, analysisSchema)
CompletionItem(label).apply {
kind = CompletionItemKind.Method
insertTextFormat = InsertTextFormat.Snippet
insertTextMode = InsertTextMode.AdjustIndentation
insertText = computeCompletionInsertText(function, analysisSchema)
insertText = text
}
}

/**
* Computes a [placeholder](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#placeholders)
* based on the given data type.
*
* If there is a specific placeholder for the given data type, it will be used.
* Otherwise, a simple indexed will be used
*/
private fun computeTypedPlaceholder(
index: Int,
resolvedType: DataType
): String {
return when (resolvedType) {
is DataType.BooleanDataType -> "\${$index|true,false|}"
is EnumClass -> "\${$index|${resolvedType.entryNames.joinToString(",")}|}"
is DataType.IntDataType -> "\${$index:0}"
is DataType.LongDataType -> "\${$index:0L}"
is DataType.StringDataType -> "\"\${$index}\""
else -> "\$$index"
}
}

private fun computeCompletionLabel(function: SchemaMemberFunction): String {
val functionName = function.simpleName
val parameterSignature = when (function.parameters.isEmpty()) {
true -> ""
false -> function.parameters.joinToString(",", "(", ")") { it.toSignatureLabel() }
}
val configureBlockLabel = function.semantics.toBlockConfigurabilityLabel().orEmpty()

return "$functionName$parameterSignature$configureBlockLabel"
}

private fun computeCompletionInsertText(
function: SchemaFunction,
analysisSchema: AnalysisSchema
analysisSchema: AnalysisSchema,
): String {
val parameterSnippet = function.parameters.mapIndexed { index, parameter ->
// Additional placeholders are indexed from 1
computeTypedPlaceholder(index + 1, parameter.type, analysisSchema)
val resolvedType = SchemaTypeRefContext(analysisSchema).resolveRef(parameter.type)
computeTypedPlaceholder(index + 1, resolvedType)
}.joinToString(", ", "(", ")")

return when (val semantics = function.semantics) {
Expand All @@ -410,27 +506,6 @@ private fun computeCompletionInsertText(
}
}

/**
* Computes a [placeholder](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#placeholders) based on the given data type.
*
* If there is a specific placeholder for the given data type, it will be used.
* Otherwise, a simple indexed will be used
*/
private fun computeTypedPlaceholder(
index: Int,
type: DataTypeRef,
analysisSchema: AnalysisSchema
): String {
return when (val resolvedType = SchemaTypeRefContext(analysisSchema).resolveRef(type)) {
is DataType.BooleanDataType -> "\${$index|true,false|}"
is EnumClass -> "\${$index|${resolvedType.entryNames.joinToString(",")}|}"
is DataType.IntDataType -> "\${$index:0}"
is DataType.LongDataType -> "\${$index:0L}"
is DataType.StringDataType -> "\"\${$index}\""
else -> "\$$index"
}
}

// Extension functions -------------------------------------------------------------------------------------------------

// TODO: this might not be the best way to resolve the type name, but it works for now
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ private val LOGGER = LoggerFactory.getLogger(TapiConnectionHandler::class.java)

class TapiConnectionHandler(val projectRoot: File): ProgressListener {

fun getDomPrequisites(): DeclarativeResourcesModel {
fun getDeclarativeResources(): DeclarativeResourcesModel {
var connection: ProjectConnection? = null
try {
connection = GradleConnector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ fun <T : DocumentVisitor> DeclarativeDocument.visit(visitor: T): T {
is DeclarativeDocument.ValueNode.ValueFactoryNode -> visitor.visitValueFactoryNode(node)
else -> {}
}
}
is DeclarativeDocument.DocumentNode -> {
} else -> {
visitor.visitDocumentNode(node)
when (node) {
is DeclarativeDocument.DocumentNode.ElementNode -> visitor.visitDocumentElementNode(node)
Expand Down
Loading