mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
RIDER-139586 [rider-mcp]: resolve the target project by its base directories
`rootFolder` pointing at a repository root was rejected with "doesn't correspond to any open project": a project was matched by its own directory only, so a solution opened from a subdirectory of the repository could not be addressed by the root the agent runs in. Match a project by every directory `BaseProjectDirectories` reports for it as well — an attached repository root is one of them. Ranking of several matches was dead code: the comparison key was the looked-up path itself, equal for all candidates, so an arbitrary project won. Order matches by the depth of the matched directory instead, preferring the project's own directory over a base directory pointing at the same place. Report no project when the same directory belongs to two projects and it is the directory of neither. Two solutions opened from one repository both report the repository root, and the root alone tells one from the other. The caller then asks for an explicit project path. RIDER-139586 fix (cherry picked from commit f01045db6812303603aafa1f9bb9eceac95c296c) GitOrigin-RevId: 6614b671f44de60d057e77b2b3dcdbc4f67fccbf
This commit is contained in:
committed by
intellij-monorepo-bot
parent
72f9c34405
commit
4f25eece90
@@ -4,6 +4,7 @@ import com.intellij.mcpserver.mcpFail
|
||||
import com.intellij.openapi.components.serviceAsync
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.openapi.diagnostic.trace
|
||||
import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.util.io.FileUtilRt
|
||||
@@ -61,6 +62,30 @@ fun resolveInProject(pathInProject: String, projectDirectory: Path, throwWhenOut
|
||||
return filePath
|
||||
}
|
||||
|
||||
/**
|
||||
* The roots [BaseProjectDirectories] reports for this project, or an empty list when they cannot be obtained.
|
||||
*/
|
||||
private fun Project.baseProjectDirectories(): List<Path> =
|
||||
readSafely("the base directories", emptyList()) {
|
||||
getBaseDirectories().mapNotNull { it.toNioPathOrNull()?.normalize() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a part of the project state, and returns [fallback] when the project cannot supply it. A project that is
|
||||
* disposed in parallel throws from every service it owns, and one failed project must not break the whole lookup.
|
||||
*/
|
||||
private fun <T> Project.readSafely(what: String, fallback: T, action: () -> T): T =
|
||||
try {
|
||||
action()
|
||||
}
|
||||
catch (ce: CancellationException) {
|
||||
throw ce
|
||||
}
|
||||
catch (error: Throwable) {
|
||||
logger.warn("Failed to read $what of the project '$name'", error)
|
||||
fallback
|
||||
}
|
||||
|
||||
suspend fun findMostRelevantProjectForRoots(roots: Collection<String>): Project? {
|
||||
return roots.firstNotNullOfOrNull { findMostRelevantProject (it) }
|
||||
}
|
||||
@@ -99,18 +124,61 @@ private suspend fun findMostRelevantProject(path: Path): Project? {
|
||||
val targetNormalizedPath = path.normalize()
|
||||
val openProjects = serviceAsync<ProjectManager>().openProjects
|
||||
|
||||
// a project is matched not only by its own directory, but by every directory `BaseProjectDirectories` reports for it:
|
||||
// a solution opened from a repository subdirectory keeps the repository root attached, and that root is a valid
|
||||
// location of the project even though the project directory is below it
|
||||
//
|
||||
// prefer most inner directories
|
||||
// let's say we have
|
||||
// - frontend (a project)
|
||||
// - frontend/common (also a separate project but in the inner dir)
|
||||
// - frontend/common/src <-- this path passed as `path`
|
||||
// here we will have 2 project matches: `frontend/common` and `frontend` and better to prefer `frontend/common`
|
||||
val pairs = openProjects.mapNotNull { project ->
|
||||
val openProjectPath = if (project is ProjectStoreOwner) project.componentStore.projectBasePath.normalize() else return@mapNotNull null
|
||||
if (targetNormalizedPath.startsWith(openProjectPath)) project to path else null
|
||||
}.sortedByDescending { it.second.nameCount }
|
||||
logger.trace { "Found projects for path $path: ${pairs.joinToString { it.first.basePath ?: "null"}}" }
|
||||
return pairs.firstOrNull()?.first
|
||||
val matches = openProjects.flatMap { it.matchingDirectories(targetNormalizedPath) }.sortedWith(MOST_INNER_MATCH_FIRST)
|
||||
logger.trace { "Found projects for path $path: ${matches.joinToString { "${it.project.basePath} (matched by ${it.directory})" }}" }
|
||||
val bestMatch = matches.firstOrNull() ?: return null
|
||||
|
||||
// two projects opened from the same repository report the same repository root, and the root alone does not tell one
|
||||
// from the other: report no project, so that the caller asks for an explicit project path
|
||||
if (!bestMatch.isProjectDirectory &&
|
||||
matches.any { it.project !== bestMatch.project && it.directory == bestMatch.directory }) {
|
||||
logger.trace { "The path $path matches the directory ${bestMatch.directory} of more than one project" }
|
||||
return null
|
||||
}
|
||||
return bestMatch.project
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory of [project] that contains the looked-up path. [isProjectDirectory] tells the project's own directory from
|
||||
* a base directory of the project.
|
||||
*/
|
||||
private class ProjectDirectoryMatch(val project: Project, val directory: Path, val isProjectDirectory: Boolean)
|
||||
|
||||
/**
|
||||
* Orders matches from the most to the least specific: a deeper directory wins, and the project's own directory wins over
|
||||
* a base directory pointing at the very same place.
|
||||
*/
|
||||
private val MOST_INNER_MATCH_FIRST: Comparator<ProjectDirectoryMatch> =
|
||||
compareByDescending<ProjectDirectoryMatch> { it.directory.nameCount }.thenByDescending { it.isProjectDirectory }
|
||||
|
||||
/**
|
||||
* The directories of this project that contain [targetNormalizedPath].
|
||||
*/
|
||||
private fun Project.matchingDirectories(targetNormalizedPath: Path): List<ProjectDirectoryMatch> {
|
||||
if (isDisposed) return emptyList()
|
||||
|
||||
return readSafely("the directories", emptyList()) {
|
||||
val projectPath = if (this is ProjectStoreOwner) componentStore.projectBasePath.normalize() else null
|
||||
|
||||
val directories = LinkedHashSet<Path>()
|
||||
projectPath?.let(directories::add)
|
||||
directories.addAll(baseProjectDirectories())
|
||||
|
||||
directories.mapNotNull { directory ->
|
||||
if (!targetNormalizedPath.startsWith(directory)) return@mapNotNull null
|
||||
ProjectDirectoryMatch(project = this, directory = directory, isProjectDirectory = directory == projectPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+129
@@ -14,23 +14,35 @@ import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.StateStorageOperation
|
||||
import com.intellij.openapi.components.Storage
|
||||
import com.intellij.openapi.extensions.ExtensionPointName
|
||||
import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ex.ProjectManagerEx
|
||||
import com.intellij.openapi.vfs.toNioPathOrNull
|
||||
import com.intellij.project.ProjectStoreOwner
|
||||
import com.intellij.testFramework.ExtensionTestUtil
|
||||
import com.intellij.testFramework.PlatformTestUtil
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.intellij.testFramework.junit5.TestDisposable
|
||||
import com.intellij.testFramework.junit5.fixture.moduleFixture
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.sourceRootFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.testFramework.junit5.fixture.testFixture
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.invariantSeparatorsPathString
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@TestApplication
|
||||
class McpProjectLocationInputsTest {
|
||||
@@ -39,6 +51,35 @@ class McpProjectLocationInputsTest {
|
||||
val firstProject by firstProjectFixture
|
||||
val secondProjectFixture = projectFixture(openAfterCreation = true)
|
||||
val secondProject by secondProjectFixture
|
||||
|
||||
/**
|
||||
* A directory above the project directory, akin to a repository root attached to a solution opened from
|
||||
* a subdirectory of that repository: a content root of the project, but not the project directory itself.
|
||||
*/
|
||||
val outerRootPathFixture = tempPathFixture()
|
||||
val outerRootProjectFixture = projectFixture(openAfterCreation = true)
|
||||
val outerRootProject by outerRootProjectFixture
|
||||
val outerRoot by outerRootProjectFixture.moduleFixture().sourceRootFixture(pathFixture = outerRootPathFixture)
|
||||
|
||||
/** A project whose own directory is located under [outerRoot]. */
|
||||
val nestedProjectPathFixture = testFixture<Path>("nested project directory") {
|
||||
val path = outerRootPathFixture.init().resolve("nested-project")
|
||||
withContext(Dispatchers.IO) { path.createDirectories() }
|
||||
initialized(path) {}
|
||||
}
|
||||
val nestedProject by projectFixture(pathFixture = nestedProjectPathFixture, openAfterCreation = true)
|
||||
|
||||
/**
|
||||
* A directory that is a content root of two projects at once, akin to a repository that holds two solutions. It
|
||||
* tells neither project from the other.
|
||||
*/
|
||||
val sharedRootPathFixture = tempPathFixture()
|
||||
val firstSharedRootProjectFixture = projectFixture(openAfterCreation = true)
|
||||
val firstSharedRootProject by firstSharedRootProjectFixture
|
||||
val firstSharedRoot by firstSharedRootProjectFixture.moduleFixture().sourceRootFixture(pathFixture = sharedRootPathFixture)
|
||||
val secondSharedRootProjectFixture = projectFixture(openAfterCreation = true)
|
||||
val secondSharedRootProject by secondSharedRootProjectFixture
|
||||
val secondSharedRoot by secondSharedRootProjectFixture.moduleFixture().sourceRootFixture(pathFixture = sharedRootPathFixture)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -183,6 +224,94 @@ class McpProjectLocationInputsTest {
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projectPath argument pointing at a content root outside the project directory resolves that project`() {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
val outerRootPath = outerRoot.virtualFile.toNioPath()
|
||||
awaitBaseDirectory(outerRootProject, outerRootPath)
|
||||
|
||||
val project = McpProjectLocationInputs(
|
||||
projectPathFromArgument = outerRootPath.invariantSeparatorsPathString,
|
||||
projectPathFromCallHeader = null,
|
||||
projectPathFromSessionHeader = null,
|
||||
roots = emptySet(),
|
||||
).resolveProject()
|
||||
|
||||
assertThat(project).isSameAs(outerRootProject)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roots pointing at a content root outside the project directory resolve that project`() {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
val outerRootPath = outerRoot.virtualFile.toNioPath()
|
||||
awaitBaseDirectory(outerRootProject, outerRootPath)
|
||||
|
||||
val project = McpProjectLocationInputs(
|
||||
projectPathFromArgument = null,
|
||||
projectPathFromCallHeader = null,
|
||||
projectPathFromSessionHeader = null,
|
||||
roots = setOf(outerRootPath.toUri().toString()),
|
||||
).resolveProject()
|
||||
|
||||
assertThat(project).isSameAs(outerRootProject)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `innermost project wins over a project matched by its outer content root`() {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
val outerRootPath = outerRoot.virtualFile.toNioPath()
|
||||
awaitBaseDirectory(outerRootProject, outerRootPath)
|
||||
val nestedProjectPath = Path.of(nestedProject.basePath!!)
|
||||
assertThat(nestedProjectPath).startsWith(outerRootPath)
|
||||
|
||||
val project = McpProjectLocationInputs(
|
||||
projectPathFromArgument = nestedProjectPath.invariantSeparatorsPathString,
|
||||
projectPathFromCallHeader = null,
|
||||
projectPathFromSessionHeader = null,
|
||||
roots = emptySet(),
|
||||
).resolveProject()
|
||||
|
||||
assertThat(project).isSameAs(nestedProject)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a content root shared by two projects resolves no project`() {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
val sharedRootPath = firstSharedRoot.virtualFile.toNioPath()
|
||||
assertThat(secondSharedRoot.virtualFile.toNioPath()).isEqualTo(sharedRootPath)
|
||||
awaitBaseDirectory(firstSharedRootProject, sharedRootPath)
|
||||
awaitBaseDirectory(secondSharedRootProject, sharedRootPath)
|
||||
|
||||
assertThatThrownBy {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
McpProjectLocationInputs(
|
||||
projectPathFromArgument = sharedRootPath.invariantSeparatorsPathString,
|
||||
projectPathFromCallHeader = null,
|
||||
projectPathFromSessionHeader = null,
|
||||
roots = emptySet(),
|
||||
).resolveProject()
|
||||
}
|
||||
}
|
||||
.isInstanceOf(McpExpectedError::class.java)
|
||||
.hasMessageContaining("doesn't correspond to any open project.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [com.intellij.openapi.project.BaseProjectDirectories] picks workspace model changes up asynchronously.
|
||||
*/
|
||||
private suspend fun awaitBaseDirectory(project: Project, directory: Path) {
|
||||
val normalizedDirectory = directory.normalize()
|
||||
withTimeout(1.minutes) {
|
||||
while (project.getBaseDirectories().none { it.toNioPathOrNull()?.normalize() == normalizedDirectory }) {
|
||||
delay(50.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun projectRootUri(project: Project): String = Path.of(project.basePath!!).toUri().toString()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user