Skip to content

Commit

Permalink
Proper support for settings.gradle.dcl
Browse files Browse the repository at this point in the history
* Copy and reuse utilities from `gradle-client` for merging
  analysis results from multiple interpretation sequence
  steps.

* Produce an approximate union schema for type lookup in
  `settings.gradle.dcl` where there is more than one schema
  from the multiple interpretation sequence steps.

* Track the analysis schemas along with the documents in the
  language server state.

* Use the schema's top-level receiver type for completion
  at the top-level when outside any matching element.

* Simplify the `DeclarativeResourcesModel`, as the schemas
  and files are actually tracked by the DCL analysis
  utilities.
  • Loading branch information
h0tk3y committed Nov 14, 2024
1 parent 9e93e6e commit d304792
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 110 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import org.eclipse.lsp4j.services.LanguageClient
import org.eclipse.lsp4j.services.TextDocumentService
import org.gradle.declarative.dsl.schema.*
import org.gradle.declarative.lsp.build.model.DeclarativeResourcesModel
import org.gradle.declarative.lsp.extension.indexBasedOverlayResultFromDocuments
import org.gradle.declarative.lsp.extension.toLspRange
import org.gradle.declarative.lsp.service.MutationRegistry
import org.gradle.declarative.lsp.service.VersionedDocumentStore
Expand All @@ -53,11 +54,9 @@ import org.gradle.internal.declarativedsl.analysis.SchemaTypeRefContext
import org.gradle.internal.declarativedsl.dom.DeclarativeDocument
import org.gradle.internal.declarativedsl.dom.DocumentResolution
import org.gradle.internal.declarativedsl.dom.mutation.MutationParameterKind
import org.gradle.internal.declarativedsl.dom.operations.overlay.DocumentOverlay
import org.gradle.internal.declarativedsl.dom.operations.overlay.DocumentOverlayResult
import org.gradle.internal.declarativedsl.dom.resolution.DocumentResolutionContainer
import org.gradle.internal.declarativedsl.evaluator.main.AnalysisDocumentUtils.resolvedDocument
import org.gradle.internal.declarativedsl.evaluator.main.AnalysisSequenceResult
import org.gradle.internal.declarativedsl.evaluator.main.SimpleAnalysisEvaluator
import org.gradle.internal.declarativedsl.evaluator.runner.stepResultOrPartialResult
import org.slf4j.LoggerFactory
Expand All @@ -72,7 +71,6 @@ class DeclarativeTextDocumentService : TextDocumentService {
private lateinit var documentStore: VersionedDocumentStore
private lateinit var mutationRegistry: MutationRegistry
private lateinit var declarativeResources: DeclarativeResourcesModel
private lateinit var analysisSchema: AnalysisSchema
private lateinit var schemaAnalysisEvaluator: SimpleAnalysisEvaluator

fun initialize(
Expand All @@ -85,8 +83,7 @@ class DeclarativeTextDocumentService : TextDocumentService {
this.documentStore = documentStore
this.mutationRegistry = mutationRegistry
this.declarativeResources = declarativeResources

this.analysisSchema = declarativeResources.analysisSchema

this.schemaAnalysisEvaluator = SimpleAnalysisEvaluator.withSchema(
declarativeResources.settingsInterpretationSequence,
declarativeResources.projectInterpretationSequence
Expand All @@ -100,9 +97,9 @@ class DeclarativeTextDocumentService : TextDocumentService {
params?.let {
val uri = URI(it.textDocument.uri)
val text = it.textDocument.text
val dom = parse(uri, text)
val parsed = parse(uri, text)
run {
documentStore.storeInitial(uri, text, dom)
documentStore.storeInitial(uri, text, parsed.documentOverlayResult, parsed.analysisSchemas)
processDocument(uri)
}
}
Expand All @@ -115,8 +112,8 @@ class DeclarativeTextDocumentService : TextDocumentService {
it.contentChanges.forEach { change ->
val version = it.textDocument.version
val text = change.text
val dom = parse(uri, change.text)
documentStore.storeVersioned(uri, version, text, dom)
val parsed = parse(uri, change.text)
documentStore.storeVersioned(uri, version, text, parsed.documentOverlayResult, parsed.analysisSchemas)
processDocument(uri)
}
}
Expand All @@ -138,7 +135,7 @@ class DeclarativeTextDocumentService : TextDocumentService {
LOGGER.trace("Hover requested for position: {}", params)
val hover = params?.let {
val uri = URI(it.textDocument.uri)
withDom(uri) { dom, _ ->
withDom(uri) { dom, _, _ ->
// LSPs are supplying 0-based line and column numbers, while the DSL model is 1-based
val visitor = BestFittingNodeVisitor(
params.position,
Expand All @@ -163,19 +160,20 @@ class DeclarativeTextDocumentService : TextDocumentService {

override fun completion(params: CompletionParams?): CompletableFuture<Either<MutableList<CompletionItem>, CompletionList>> {
LOGGER.trace("Completion requested for position: {}", params)
val completions = params?.let {
val uri = URI(it.textDocument.uri)
withDom(uri) { dom, _ ->
val completions = params?.let { param ->
val uri = URI(param.textDocument.uri)
withDom(uri) { dom, schema, _ ->
dom.document.visit(
BestFittingNodeVisitor(
params.position,
DeclarativeDocument.DocumentNode.ElementNode::class
)
).bestFittingNode
?.getDataClass(dom.overlayResolutionContainer)
?.let { dataClass ->
computePropertyCompletions(dataClass, analysisSchema) +
computeFunctionCompletions(dataClass, analysisSchema)
.let { it ?: schema.topLevelReceiverType }
.let { dataClass ->
computePropertyCompletions(dataClass, schema) +
computeFunctionCompletions(dataClass, schema)
}
}
}.orEmpty().toMutableList()
Expand All @@ -187,7 +185,7 @@ class DeclarativeTextDocumentService : TextDocumentService {

val signatureInformationList = params?.let {
val uri = URI(it.textDocument.uri)
withDom(uri) { dom, _ ->
withDom(uri) { dom, _, _ ->
val position = it.position
val matchingNodes = dom.document.visit(
BestFittingNodeVisitor(
Expand Down Expand Up @@ -252,10 +250,10 @@ class DeclarativeTextDocumentService : TextDocumentService {

// Utility and other member functions ------------------------------------------------------------------------------

private fun processDocument(uri: URI) = withDom(uri) { dom, _ ->
private fun processDocument(uri: URI) = withDom(uri) { dom, schema, _ ->
reportSyntaxErrors(uri, dom)
reportSemanticErrors(uri, dom)
mutationRegistry.registerDocument(uri, dom.result)
mutationRegistry.registerDocument(uri, schema, dom.result)
}

/**
Expand Down Expand Up @@ -294,30 +292,37 @@ class DeclarativeTextDocumentService : TextDocumentService {
)
)
}

data class ParsedDocument(
val documentOverlayResult: DocumentOverlayResult,
val analysisSchemas: List<AnalysisSchema>
)

private fun parse(uri: URI, text: String): DocumentOverlayResult {
fun AnalysisSequenceResult.lastStepDocument() =
stepResults.values.last().stepResultOrPartialResult.resolvedDocument()

private fun parse(uri: URI, text: String): ParsedDocument {
val fileName = uri.path.substringAfterLast('/')
val document = schemaAnalysisEvaluator.evaluate(fileName, text).lastStepDocument()
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 an empty document as an underlay instead of the real document produced from the
// settings file.
// 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 .
// TODO: carry both the real overlay and the document produced from just the current file, run the mutations
// against the latter for now.
// TODO: once the mutation utilities start handling mutations across the overlay, pass them the right overlay.
val emptyUnderlay = schemaAnalysisEvaluator.evaluate("empty-underlay/build.gradle.dcl", "").lastStepDocument()

val overlay = indexBasedOverlayResultFromDocuments(
analysisResult.stepResults.map { it.value.stepResultOrPartialResult.resolvedDocument() }
)

LOGGER.trace("Parsed declarative model for document: {}", uri)

return DocumentOverlay.overlayResolvedDocuments(emptyUnderlay, document)
return ParsedDocument(
overlay,
analysisResult.stepResults.map { it.key.evaluationSchemaForStep.analysisSchema }
)
}

private fun <T> withDom(uri: URI, work: (DocumentOverlayResult, String) -> T): T? {
private fun <T> withDom(uri: URI, work: (DocumentOverlayResult, AnalysisSchema, String) -> T): T? {
return documentStore[uri]?.let { entry ->
work(entry.dom, entry.document)
work(entry.dom, entry.unionSchema, entry.document)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package org.gradle.declarative.lsp.extension

import org.gradle.declarative.dsl.schema.AnalysisSchema
import org.gradle.declarative.dsl.schema.*
import org.gradle.internal.declarativedsl.analysis.DefaultAnalysisSchema
import org.gradle.internal.declarativedsl.analysis.DefaultDataClass
import org.gradle.internal.declarativedsl.analysis.DefaultEnumClass
import org.gradle.internal.declarativedsl.analysis.DefaultFqName

/*
* Copyright 2024 the original author or authors.
Expand All @@ -24,4 +28,84 @@ inline fun <reified T> AnalysisSchema.findType(name: String): T? = dataClassType
dataClass.name.qualifiedName == name && dataClass is T
}?.let {
it as T
}
}


/**
* Produces an [AnalysisSchema] that approximates the [schemas] merged together.
* Namely, it has [AnalysisSchema.dataClassTypesByFqName] from all the schemas, and if a type appears in more than
* one of the schemas, its contents get merged, too.
*
* The top level receiver is either the merged type from the [AnalysisSchema.topLevelReceiverType]s from the schemas, if
* it has the same name in all of them, or a type with a synthetic name that has the content from the top level
* receiver types from [schemas].
*/
fun unionAnalysisSchema(schemas: List<AnalysisSchema>): AnalysisSchema = if (schemas.size == 1)
schemas.single()
else {
fun mergeDataClasses(newName: String?, dataClasses: List<DataClass>): DataClass {
// Can't have properties with the same name but different types anyway:
val properties = dataClasses.flatMap { it.properties }.distinctBy { it.name }

val functions = dataClasses.flatMap { it.memberFunctions }
.distinctBy { listOf(it.simpleName) + it.parameters.map { it.name to typeIdentityName(it.type) } }

val constructors =
dataClasses.flatMap { it.constructors }.distinctBy { it.parameters.map { typeIdentityName(it.type) } }

val supertypes = dataClasses.flatMap { it.supertypes }.toSet()

return DefaultDataClass(
newName?.let { DefaultFqName.parse(it) } ?: dataClasses.first().name,
dataClasses.first().javaTypeName,
dataClasses.first().javaTypeArgumentTypeNames,
supertypes,
properties,
functions,
constructors
)
}

val dataClassesByFqName = run {
fun mergeEnums(enumTypes: List<EnumClass>): EnumClass =
DefaultEnumClass(
enumTypes.first().name,
enumTypes.first().javaTypeName,
enumTypes.flatMap { it.entryNames }.distinct()
)

schemas.flatMap { it.dataClassTypesByFqName.values }.groupBy { it.name }
.mapValues { (_, dataClasses) ->
when {
dataClasses.all { it is DataClass } -> mergeDataClasses(null, dataClasses.map { it as DataClass })
dataClasses.all { it is EnumClass } -> mergeEnums(dataClasses.map { it as EnumClass })
else -> error("mixed enum and data classes")
}
}
}

val newTopLevelReceiver = run {
val topLevelReceivers = schemas.map { it.topLevelReceiverType }
if (topLevelReceivers.map { it.name.qualifiedName }.distinct().size == 1) {
dataClassesByFqName.getValue(topLevelReceivers.first().name) as DataClass
} else {
mergeDataClasses("\$top-level-receiver\$", topLevelReceivers)
}
}

DefaultAnalysisSchema(
newTopLevelReceiver,
dataClassesByFqName,
emptyMap(),
emptyMap(),
emptySet()
)
}

private fun typeIdentityName(typeRef: DataTypeRef) = when (typeRef) {
is DataTypeRef.Name -> typeRef.fqName.qualifiedName
is DataTypeRef.Type -> when (val type = typeRef.dataType) {
is DataType.ClassDataType -> type.name.qualifiedName
else -> type.toString()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.gradle.declarative.lsp.extension

import org.gradle.internal.declarativedsl.dom.DeclarativeDocument
import org.gradle.internal.declarativedsl.dom.DocumentResolution
import org.gradle.internal.declarativedsl.dom.UnresolvedBase
import org.gradle.internal.declarativedsl.dom.operations.overlay.DocumentOverlay.overlayResolvedDocuments
import org.gradle.internal.declarativedsl.dom.operations.overlay.DocumentOverlayResult
import org.gradle.internal.declarativedsl.dom.resolution.DocumentResolutionContainer
import org.gradle.internal.declarativedsl.dom.resolution.DocumentWithResolution
import org.gradle.internal.declarativedsl.language.SourceData
import org.gradle.internal.declarativedsl.language.SyntheticallyProduced

/**
* Utilities copied from `gradle-client`.
* TODO: expose some of them, or their replacements, in the Gradle DCL libs.
*/

/**
* Packs multiple instances of the same document with different resolution results into a [DocumentOverlayResult] in a
* way that they appear as a single document (all in the overlay, no underlay part). The resolution results get merged
* so that if any of the resolution results container has [UnresolvedBase] for a part of the document, it is checked
* against the other resolution result containers.
*/
internal fun indexBasedOverlayResultFromDocuments(docs: List<DocumentWithResolution>): DocumentOverlayResult {
val emptyDoc = DocumentWithResolution(
object : DeclarativeDocument {
override val content: List<DeclarativeDocument.DocumentNode> = emptyList()
override val sourceData: SourceData = SyntheticallyProduced
},
indexBasedMultiResolutionContainer(emptyList())
)

val lastDocWithAllResolutionResults = DocumentWithResolution(
docs.last().document,
indexBasedMultiResolutionContainer(docs)
)

/**
* NB: No real overlay origin data is going to be present, as we are overlaying the doc with all the resolution
* results collected over the empty document.
*/
return overlayResolvedDocuments(emptyDoc, lastDocWithAllResolutionResults)
}

/**
* A resolution results container collected from multiple resolved instances of the same document (or multiple
* different instances of the same document, no referential equality required).
*
* The document parts are matched based on indices.
*
* If any of the [docs] is different from the others, the result is undefined (likely to be a broken container).
*/
internal fun indexBasedMultiResolutionContainer(docs: List<DocumentWithResolution>): DocumentResolutionContainer {
val indicesMaps: Map<DocumentWithResolution, Map<IntRange, DeclarativeDocument.Node>> = docs.associateWith {
buildMap {
fun visitValue(valueNode: DeclarativeDocument.ValueNode) {
put(valueNode.sourceData.indexRange, valueNode)
when (valueNode) {
is DeclarativeDocument.ValueNode.ValueFactoryNode -> valueNode.values.forEach(::visitValue)
is DeclarativeDocument.ValueNode.LiteralValueNode,
is DeclarativeDocument.ValueNode.NamedReferenceNode -> Unit
}
}

fun visitDocumentNode(documentNode: DeclarativeDocument.DocumentNode) {
put(documentNode.sourceData.indexRange, documentNode)
when (documentNode) {
is DeclarativeDocument.DocumentNode.ElementNode -> {
documentNode.elementValues.forEach(::visitValue)
documentNode.content.forEach(::visitDocumentNode)
}

is DeclarativeDocument.DocumentNode.PropertyNode -> visitValue(documentNode.value)
is DeclarativeDocument.DocumentNode.ErrorNode -> Unit
}
}

it.document.content.forEach(::visitDocumentNode)
}
}

/**
* The resolution containers work with node identities.
* Querying resolution results using nodes from a different document is prohibited.
* Given that all documents are the same, we can map the node indices and use them to find matching nodes in
* the documents that we are merging.
*/
return object : DocumentResolutionContainer {
inline fun <reified N : DeclarativeDocument.Node, reified T> retryOverContainers(
node: N,
noinline get: DocumentResolutionContainer.(N) -> T
) = docs.map { doc ->
val matchingNode = indicesMaps.getValue(doc)[node.sourceData.indexRange]
?: error("index not found in index map")
get(doc.resolutionContainer, matchingNode as N)
}.let { results ->
results.firstOrNull {
it !is DocumentResolution.UnsuccessfulResolution || !it.reasons.contains(UnresolvedBase)
} ?: results.first()
}

override fun data(node: DeclarativeDocument.DocumentNode.ElementNode) = retryOverContainers(node) { data(it) }
override fun data(node: DeclarativeDocument.DocumentNode.ErrorNode) = retryOverContainers(node) { data(it) }
override fun data(node: DeclarativeDocument.DocumentNode.PropertyNode) = retryOverContainers(node) { data(it) }
override fun data(node: DeclarativeDocument.ValueNode.LiteralValueNode) = retryOverContainers(node) { data(it) }
override fun data(node: DeclarativeDocument.ValueNode.NamedReferenceNode) =
retryOverContainers(node) { data(it) }

override fun data(node: DeclarativeDocument.ValueNode.ValueFactoryNode) = retryOverContainers(node) { data(it) }
}
}
Loading

0 comments on commit d304792

Please sign in to comment.