diff --git a/.idea/modules.xml b/.idea/modules.xml
index 0c5456a6960e..38d7655e9578 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -942,6 +942,7 @@
+
diff --git a/intellij.idea.community.main.iml b/intellij.idea.community.main.iml
index bcdbb2d13d51..6abb38864e90 100644
--- a/intellij.idea.community.main.iml
+++ b/intellij.idea.community.main.iml
@@ -208,6 +208,7 @@
+
diff --git a/tools/devLauncher/src/ModuleResourceFileFinder.kt b/tools/devLauncher/src/ModuleResourceFileFinder.kt
new file mode 100644
index 000000000000..3c8cabee2eb2
--- /dev/null
+++ b/tools/devLauncher/src/ModuleResourceFileFinder.kt
@@ -0,0 +1,102 @@
+package com.intellij.tools.devLauncher
+
+import java.nio.file.Path
+import javax.xml.stream.XMLInputFactory
+import javax.xml.stream.XMLStreamConstants
+import kotlin.io.path.*
+
+/**
+ * Provides a way to locate a resource file under source roots by its relative path in an IntelliJ project.
+ */
+class ModuleResourceFileFinder(private val projectDir: Path) {
+ private val moduleFiles: Map
+
+ init {
+ val modulesXmlFile = projectDir.resolve(".idea/modules.xml")
+ require(modulesXmlFile.exists()) { ".idea/modules.xml not found in $projectDir"}
+ modulesXmlFile.inputStream().buffered().use { input ->
+ val moduleFilesMap = LinkedHashMap()
+ val reader = XMLInputFactory.newDefaultFactory().createXMLStreamReader(input)
+ while (reader.hasNext()) {
+ val event = reader.next()
+ if (event == XMLStreamConstants.START_ELEMENT && reader.localName == "module") {
+ val attributeName = reader.getAttributeLocalName(0)
+ require(attributeName == "fileurl") { "Unexpected first attribute in 'module' tag: $attributeName"}
+ val imlUrl = reader.getAttributeValue(0)
+ val prefix = "file://${'$'}PROJECT_DIR${'$'}/"
+ require(imlUrl.startsWith(prefix)) { "Unexpected format of URL: $imlUrl"}
+ val imlPath = projectDir.resolve(imlUrl.removePrefix(prefix))
+ val fileName = imlPath.name
+ val suffix = ".iml"
+ require(fileName.endsWith(suffix)) { "Unexpected file extension in file path $imlPath" }
+ val moduleName = fileName.removeSuffix(suffix)
+ moduleFilesMap[moduleName] = imlPath
+ }
+ }
+ moduleFiles = moduleFilesMap
+ }
+ }
+
+ fun findResourceFile(moduleName: String, relativePath: String): Path? {
+ for ((prefix, rootPath) in loadRootsWithPrefixes(moduleName)) {
+ val relativePathWithoutPrefix = when {
+ prefix == null -> relativePath
+ relativePath.startsWith("$prefix/") -> relativePath.removePrefix("$prefix/")
+ else -> continue
+ }
+ val file = rootPath.resolve(relativePathWithoutPrefix)
+ if (file.exists()) {
+ return file
+ }
+ }
+ return null
+ }
+
+ private fun loadRootsWithPrefixes(moduleName: String): List> {
+ val imlPath = moduleFiles[moduleName] ?: error("Cannot find module '$moduleName' in project '$projectDir'")
+ require(imlPath.exists()) { "Module file $imlPath doesn't exist" }
+ val moduleDir = imlPath.parent
+ val rootsWithPrefixes = ArrayList>()
+ imlPath.inputStream().buffered().use { input ->
+ val reader = XMLInputFactory.newDefaultFactory().createXMLStreamReader(input)
+ while (reader.hasNext()) {
+ val event = reader.next()
+ if (event == XMLStreamConstants.START_ELEMENT && reader.localName == "sourceFolder") {
+ require(reader.attributeCount > 1) { "At least two attributes expected in 'sourceFolder' tag in $imlPath, but ${reader.attributeCount} found" }
+ val attributeName = reader.getAttributeLocalName(0)
+ require(attributeName == "url") { "Unexpected first attribute in 'sourceFolder' tag in $imlPath: $attributeName" }
+ val rootUrl = reader.getAttributeValue(0)
+ val prefix = "file://"
+ require(rootUrl.startsWith(prefix)) { "Unexpected format of URL: $rootUrl" }
+ val rootPath = Path(rootUrl.removePrefix(prefix).replace("${'$'}MODULE_DIR${'$'}", moduleDir.pathString))
+
+ val typeAttributeName = reader.getAttributeLocalName(1)
+ val typeAttributeValue = reader.getAttributeValue(1)
+ val directoryPrefix = when {
+ typeAttributeName == "isTestSource" && typeAttributeValue == "false" -> {
+ if (reader.attributeCount > 2 && reader.getAttributeLocalName(2) == "packagePrefix") {
+ reader.getAttributeValue(2).replace('.', '/').takeIf { it.isNotEmpty() }
+ }
+ else {
+ null
+ }
+ }
+ typeAttributeName == "type" && typeAttributeValue == "java-resource" -> {
+ if (reader.attributeCount > 2 && reader.getAttributeLocalName(2) == "relativeOutputPath") {
+ reader.getAttributeValue(2).removeSuffix("/").takeIf { it.isNotEmpty() }
+ }
+ else {
+ null
+ }
+ }
+ else -> {
+ continue
+ }
+ }
+ rootsWithPrefixes.add(directoryPrefix to rootPath)
+ }
+ }
+ }
+ return rootsWithPrefixes
+ }
+}
\ No newline at end of file
diff --git a/tools/devLauncher/tests/intellij.tools.devLauncher.tests.iml b/tools/devLauncher/tests/intellij.tools.devLauncher.tests.iml
new file mode 100644
index 000000000000..9cc2e470b043
--- /dev/null
+++ b/tools/devLauncher/tests/intellij.tools.devLauncher.tests.iml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/.idea/modules.xml b/tools/devLauncher/tests/testData/moduleResourceFinderProject/.idea/modules.xml
new file mode 100644
index 000000000000..48cfda831550
--- /dev/null
+++ b/tools/devLauncher/tests/testData/moduleResourceFinderProject/.idea/modules.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/additional-resources/c.txt b/tools/devLauncher/tests/testData/moduleResourceFinderProject/additional-resources/c.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/root.iml b/tools/devLauncher/tests/testData/moduleResourceFinderProject/root.iml
new file mode 100644
index 000000000000..768559d7e3ef
--- /dev/null
+++ b/tools/devLauncher/tests/testData/moduleResourceFinderProject/root.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/resources/a.txt b/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/resources/a.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/simple.iml b/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/simple.iml
new file mode 100644
index 000000000000..b9de36531b8b
--- /dev/null
+++ b/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/simple.iml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/src/b/b.txt b/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/src/b/b.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/testSrc/c.txt b/tools/devLauncher/tests/testData/moduleResourceFinderProject/simple/testSrc/c.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/resources/a.txt b/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/resources/a.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/src/b/b.txt b/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/src/b/b.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/withPrefix.iml b/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/withPrefix.iml
new file mode 100644
index 000000000000..a574e901b2f2
--- /dev/null
+++ b/tools/devLauncher/tests/testData/moduleResourceFinderProject/withPrefix/withPrefix.iml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/devLauncher/tests/testSrc/ModuleResourceFileFinderTest.kt b/tools/devLauncher/tests/testSrc/ModuleResourceFileFinderTest.kt
new file mode 100644
index 000000000000..2bc342d0b7ca
--- /dev/null
+++ b/tools/devLauncher/tests/testSrc/ModuleResourceFileFinderTest.kt
@@ -0,0 +1,39 @@
+package com.intellij.tools.devLauncher
+
+import com.intellij.openapi.application.ex.PathManagerEx
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.nio.file.Path
+import kotlin.io.path.Path
+import kotlin.io.path.invariantSeparatorsPathString
+import kotlin.io.path.relativeTo
+
+class ModuleResourceFileFinderTest {
+ private lateinit var projectDir: Path
+ private lateinit var finder: ModuleResourceFileFinder
+
+ @BeforeEach
+ fun setUp() {
+ projectDir = Path(PathManagerEx.getCommunityHomePath()).resolve("tools/devLauncher/tests/testData/moduleResourceFinderProject")
+ finder = ModuleResourceFileFinder(projectDir)
+ }
+
+ @Test
+ fun `simple roots`() {
+ assertPath("simple/resources/a.txt", finder.findResourceFile("simple", "a.txt"))
+ assertPath("simple/src/b/b.txt", finder.findResourceFile("simple", "b/b.txt"))
+ assertPath(null, finder.findResourceFile("simple", "c.txt"))
+ }
+
+ @Test
+ fun `roots with prefixes`() {
+ assertPath("withPrefix/resources/a.txt", finder.findResourceFile("withPrefix", "prefix1/a.txt"))
+ assertPath("withPrefix/src/b/b.txt", finder.findResourceFile("withPrefix", "prefix2/b/b.txt"))
+ assertPath("additional-resources/c.txt", finder.findResourceFile("withPrefix", "prefix3/c.txt"))
+ }
+
+ private fun assertPath(expectedPath: String?, file: Path?) {
+ assertEquals(expectedPath, file?.relativeTo(projectDir)?.invariantSeparatorsPathString)
+ }
+}
\ No newline at end of file