diff --git a/plugins/devkit/devkit-core/resources/inspectionDescriptions/PathAnnotationInspection.html b/plugins/devkit/devkit-core/resources/inspectionDescriptions/PathAnnotationInspection.html new file mode 100644 index 000000000000..4122a4e47084 --- /dev/null +++ b/plugins/devkit/devkit-core/resources/inspectionDescriptions/PathAnnotationInspection.html @@ -0,0 +1,28 @@ + + +Reports incorrect usage of path annotations. + +

+ This inspection detects cases where a string annotated with one path annotation is used in a context that expects a string with a different path annotation. + It helps ensure proper usage of @MultiRoutingFileSystemPath and @NativePath annotations. +

+ +

The inspection highlights the following issues:

+ + +

+ Quick fixes are provided to add the appropriate annotation where needed. +

+ +

+ This inspection helps prevent runtime errors that can occur when paths with different formats are used incorrectly. + The @MultiRoutingFileSystemPath annotation is used for paths that should work across different file systems, + while the @NativePath annotation is used for paths that are specific to the native file system. +

+ + diff --git a/plugins/devkit/devkit-core/resources/intellij.devkit.core.xml b/plugins/devkit/devkit-core/resources/intellij.devkit.core.xml index a6eaee6f34a1..061e79b1ec9d 100644 --- a/plugins/devkit/devkit-core/resources/intellij.devkit.core.xml +++ b/plugins/devkit/devkit-core/resources/intellij.devkit.core.xml @@ -499,6 +499,13 @@ level="ERROR" implementationClass="org.jetbrains.idea.devkit.inspections.PotentialDeadlockInServiceInitializationInspection"/> + + () + val expectedInfo = getExpectedPathAnnotationInfo(expression, nonAnnotatedTargets) + + if (expectedInfo is PathAnnotationInfo.MultiRouting) { + // Check if the string literal is used in a context that expects @MultiRoutingFileSystemPath + val fixes = mutableListOf() + for (target in nonAnnotatedTargets) { + if (target is PsiModifierListOwner) { + fixes.add(AddMultiRoutingAnnotationFix(target)) + } + } + if (fixes.isNotEmpty()) { + holder.registerProblem( + sourcePsi, + DevKitBundle.message("inspections.message.multiroutingfilesystempath.expected"), + *fixes.toTypedArray() + ) + } + } + else if (expectedInfo is PathAnnotationInfo.Native) { + // Check if the string literal is used in a context that expects @NativePath + val fixes = mutableListOf() + for (target in nonAnnotatedTargets) { + if (target is PsiModifierListOwner) { + fixes.add(AddNativePathAnnotationFix(target)) + } + } + if (fixes.isNotEmpty()) { + holder.registerProblem( + sourcePsi, + DevKitBundle.message("inspections.message.nativepath.expected"), + *fixes.toTypedArray() + ) + } + } + } + + private fun getExpectedPathAnnotationInfo( + expression: UExpression, + nonAnnotatedTargets: MutableSet, + ): PathAnnotationInfo { + // Check if the expression is passed to a method that expects a specific path annotation + val parent = expression.uastParent + if (parent is UCallExpression) { + val method = parent.resolve() ?: return PathAnnotationInfo.Unspecified(null) + val index = parent.valueArguments.indexOf(expression) + if (index >= 0) { + val parameter = getParameterForArgument(method, index) ?: return PathAnnotationInfo.Unspecified(null) + val info = PathAnnotationInfo.forModifierListOwner(parameter) + if (info !is PathAnnotationInfo.Unspecified) { + return info + } + nonAnnotatedTargets.add(parameter) + } + } + + // Check if the expression is assigned to a variable with a specific path annotation + if (parent is UVariable) { + val javaPsi = parent.javaPsi + if (javaPsi is PsiModifierListOwner) { + val info = PathAnnotationInfo.forModifierListOwner(javaPsi) + if (info !is PathAnnotationInfo.Unspecified) { + return info + } + nonAnnotatedTargets.add(javaPsi) + } + } + + // Check if the expression is passed to a Path constructor or factory method + if (isPassedToMultiRoutingMethod(expression, nonAnnotatedTargets)) { + return PathAnnotationInfo.MultiRouting() + } + + // Check if the expression is passed to a method that expects a native path + if (isPassedToNativePathMethod(expression, nonAnnotatedTargets)) { + return PathAnnotationInfo.Native() + } + + return PathAnnotationInfo.Unspecified(null) + } + + private fun isPathConstructorOrFactory(method: PsiElement): Boolean { + // Check if the method is a Path constructor or factory method like Path.of() + if (method is PsiModifierListOwner) { + val containingClass = (method as? com.intellij.psi.PsiMember)?.containingClass + if (containingClass != null) { + val qualifiedName = containingClass.qualifiedName + return qualifiedName == "java.nio.file.Path" || qualifiedName == "java.nio.file.Paths" + } + } + return false + } + + private fun isPassedToMultiRoutingMethod( + expression: UExpression, + nonAnnotatedTargets: MutableSet, + ): Boolean { + // Check if the expression is passed to a Path constructor or factory method + val parent = expression.uastParent + if (parent is UCallExpression) { + val method = parent.resolve() ?: return false + if (isPathConstructorOrFactory(method)) { + return true + } + } + return false + } + + private fun isPassedToNativePathMethod( + expression: UExpression, + nonAnnotatedTargets: MutableSet, + ): Boolean { + // Check if the expression is passed to a method that expects a native path + // This would be specific to your codebase, but could include methods like: + // - Docker container path methods + // - WSL path methods + // For now, we'll just return false as a placeholder + return false + } + + private fun getParameterForArgument(method: PsiElement, index: Int): PsiModifierListOwner? { + if (method is com.intellij.psi.PsiMethod) { + val parameters = method.parameterList.parameters + if (index < parameters.size) { + return parameters[index] + } + } + return null + } + } + + /** + * Contains information about path annotation status. + */ + sealed class PathAnnotationInfo { + abstract fun getPathAnnotationStatus(): ThreeState + + class MultiRouting(private val annotationCandidate: PsiModifierListOwner? = null) : PathAnnotationInfo() { + override fun getPathAnnotationStatus(): ThreeState = ThreeState.YES + + fun getAnnotationCandidate(): PsiModifierListOwner? = annotationCandidate + } + + class Native(private val annotationCandidate: PsiModifierListOwner? = null) : PathAnnotationInfo() { + override fun getPathAnnotationStatus(): ThreeState = ThreeState.YES + + fun getAnnotationCandidate(): PsiModifierListOwner? = annotationCandidate + } + + class Unspecified(private val annotationCandidate: PsiModifierListOwner?) : PathAnnotationInfo() { + override fun getPathAnnotationStatus(): ThreeState = ThreeState.UNSURE + fun getAnnotationCandidate(): PsiModifierListOwner? = annotationCandidate + } + + companion object { + fun forExpression(expression: UExpression): PathAnnotationInfo { + // Check if the expression has a path annotation + val sourcePsi = expression.sourcePsi + if (sourcePsi != null && sourcePsi is PsiModifierListOwner) { + return forModifierListOwner(sourcePsi) + } + + // Check if the expression is a reference to a variable with a path annotation + if (expression is UReferenceExpression) { + val resolved = expression.resolve() + if (resolved is PsiModifierListOwner) { + return forModifierListOwner(resolved) + } + } + + return Unspecified(null) + } + + fun forModifierListOwner(owner: PsiModifierListOwner): PathAnnotationInfo { + // Check if the owner has a path annotation + if (AnnotationUtil.isAnnotated(owner, MultiRoutingFileSystemPath::class.java.name, AnnotationUtil.CHECK_TYPE)) { + return MultiRouting(owner) + } + if (AnnotationUtil.isAnnotated(owner, NativePath::class.java.name, AnnotationUtil.CHECK_TYPE)) { + return Native(owner) + } + return Unspecified(owner) + } + } + } + + /** + * Quick fix to add @MultiRoutingFileSystemPath annotation. + */ + private class AddMultiRoutingAnnotationFix(private val target: PsiModifierListOwner?) : LocalQuickFix { + override fun getFamilyName(): String = DevKitBundle.message("inspections.intention.family.name.add.multiroutingfilesystempath.annotation") + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + if (target != null) { + val annotationOwner = target.modifierList + if (annotationOwner != null) { + AddAnnotationPsiFix.addPhysicalAnnotationIfAbsent( + MultiRoutingFileSystemPath::class.java.name, + emptyArray(), + annotationOwner + ) + } + } + } + } + + /** + * Quick fix to add @NativePath annotation. + */ + private class AddNativePathAnnotationFix(private val target: PsiModifierListOwner?) : LocalQuickFix { + override fun getFamilyName(): String = DevKitBundle.message("inspections.intention.family.name.add.nativepath.annotation") + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + if (target != null) { + val annotationOwner = target.modifierList + if (annotationOwner != null) { + AddAnnotationPsiFix.addPhysicalAnnotationIfAbsent( + NativePath::class.java.name, + emptyArray(), + annotationOwner + ) + } + } + } + } +} diff --git a/plugins/devkit/devkit-tests/testSrc/org/jetbrains/idea/devkit/inspections/PathAnnotationInspectionTestBase.kt b/plugins/devkit/devkit-tests/testSrc/org/jetbrains/idea/devkit/inspections/PathAnnotationInspectionTestBase.kt new file mode 100644 index 000000000000..ae5c4e2f9d2d --- /dev/null +++ b/plugins/devkit/devkit-tests/testSrc/org/jetbrains/idea/devkit/inspections/PathAnnotationInspectionTestBase.kt @@ -0,0 +1,94 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.inspections + +import com.intellij.pom.java.LanguageLevel +import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.IndexingTestUtil +import com.intellij.testFramework.LightProjectDescriptor +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import org.intellij.lang.annotations.Language +import org.jetbrains.idea.devkit.inspections.path.PathAnnotationInspection + +abstract class PathAnnotationInspectionTestBase : LightJavaCodeInsightFixtureTestCase() { + + override fun getProjectDescriptor(): LightProjectDescriptor = JAVA_17 + + protected abstract fun getFileExtension(): String + + override fun setUp() { + super.setUp() + addPlatformClasses() + IdeaTestUtil.setProjectLanguageLevel(project, LanguageLevel.JDK_17) + IndexingTestUtil.waitUntilIndexesAreReady(project) + myFixture.enableInspections(PathAnnotationInspection()) + } + + private fun addPlatformClasses() { + // Add the path annotations + myFixture.addClass( + """ + package com.intellij.platform.eel.annotations; + + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + import static java.lang.annotation.ElementType.*; + + /** + * This annotation should be applied to strings that could be directly used to construct java.nio.file.Path instances. + * These strings are either local to the IDE process or have prefix pointing to the specific environment. + */ + @Retention(RetentionPolicy.SOURCE) + @Target({FIELD, LOCAL_VARIABLE, PARAMETER, METHOD, TYPE_USE}) + public @interface MultiRoutingFileSystemPath {} + """.trimIndent() + ) + + myFixture.addClass( + """ + package com.intellij.platform.eel.annotations; + + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + import static java.lang.annotation.ElementType.*; + + /** + * This is the path within the specific environment. + * For example, for a path in WSL it would be a Unix path within the WSL machine, + * and for a path in a Docker container it would be a path within this Docker container. + *

+ * It should not be directly used in the java.nio.file.Path ctor and auxiliary methods like java.nio.file.Path.of. + */ + @Retention(RetentionPolicy.SOURCE) + @Target({FIELD, LOCAL_VARIABLE, PARAMETER, METHOD, TYPE_USE}) + public @interface NativePath {} + """.trimIndent() + ) + + // Add the NativeContext annotation + myFixture.addClass( + """ + package com.intellij.platform.eel.annotations; + + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + import java.lang.annotation.Target; + import static java.lang.annotation.ElementType.*; + + /** + * @see NativePath + */ + @Retention(RetentionPolicy.SOURCE) + @Target({FIELD, LOCAL_VARIABLE, PARAMETER, METHOD, TYPE_USE}) + public @interface NativeContext {} + """.trimIndent() + ) + } + + protected open fun doTest(@Language("JAVA") code: String) { + val filePath = getTestName(false) + '.' + getFileExtension() + myFixture.configureByText(filePath, code.trimIndent()) + myFixture.testHighlighting(filePath) + } +} diff --git a/plugins/devkit/devkit-tests/testSrc/org/jetbrains/idea/devkit/inspections/path/PathAnnotationInspectionJavaTest.kt b/plugins/devkit/devkit-tests/testSrc/org/jetbrains/idea/devkit/inspections/path/PathAnnotationInspectionJavaTest.kt new file mode 100644 index 000000000000..7faa5666f3db --- /dev/null +++ b/plugins/devkit/devkit-tests/testSrc/org/jetbrains/idea/devkit/inspections/path/PathAnnotationInspectionJavaTest.kt @@ -0,0 +1,52 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.inspections.path + +import org.jetbrains.idea.devkit.inspections.PathAnnotationInspectionTestBase + +class PathAnnotationInspectionJavaTest : PathAnnotationInspectionTestBase() { + override fun getFileExtension(): String = "java" + + fun testNativePathInPathOf() { + doTest(""" + import com.intellij.platform.eel.annotations.NativePath; + import java.nio.file.Path; + + public class NativePathInPathOf { + public void testMethod() { + @NativePath String nativePath = "/usr/local/bin"; + // This should be highlighted as an error because @NativePath strings should not be used directly in Path.of() + Path path = Path.of(nativePath); + } + } + """.trimIndent()) + } + + fun testMultiRoutingPathInNativeContext() { + doTest(""" + import com.intellij.platform.eel.annotations.MultiRoutingFileSystemPath; + import com.intellij.platform.eel.annotations.NativePath; + import com.intellij.platform.eel.annotations.NativeContext; + + public class MultiRoutingPathInNativeContext { + public void testMethod() { + @MultiRoutingFileSystemPath String multiRoutingPath = "/home/user/documents"; + + // This method expects a @NativePath string + processNativePath(multiRoutingPath); + } + + public void processNativePath(@NativePath String path) { + // Process the native path + System.out.println("Processing native path: " + path); + } + + @NativeContext + public void nativeContextMethod() { + @MultiRoutingFileSystemPath String multiRoutingPath = "/home/user/documents"; + // Using a @MultiRoutingFileSystemPath string in a @NativeContext method + String processedPath = multiRoutingPath + "/file.txt"; + } + } + """.trimIndent()) + } +} \ No newline at end of file