[path-annotations] Add PathAnnotationInspection for verifying path annotation usage

Introduced `PathAnnotationInspection` to detect incorrect usage of `@NativePath` and `@MultiRoutingFileSystemPath` annotations. Added corresponding tests and test resources to validate the inspection functionality. Updated DevKit messages and inspection metadata for integration.

GitOrigin-RevId: 759c0fda9da795e0ec2f9ceab1cbd70f3ce835b7
This commit is contained in:
Alexander Koshevoy
2025-04-08 22:23:05 +02:00
committed by intellij-monorepo-bot
parent 8d6a52d3f0
commit 58fbc8f1a4
6 changed files with 527 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
<html>
<body>
Reports incorrect usage of path annotations.
<p>
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 <code>@MultiRoutingFileSystemPath</code> and <code>@NativePath</code> annotations.
</p>
<p>The inspection highlights the following issues:</p>
<ul>
<li>When a string annotated with <code>@NativePath</code> is used in a Path constructor or factory method</li>
<li>When a string annotated with <code>@NativePath</code> is passed to a method parameter annotated with <code>@MultiRoutingFileSystemPath</code></li>
<li>When a string annotated with <code>@MultiRoutingFileSystemPath</code> is passed to a method parameter annotated with <code>@NativePath</code></li>
<li>When a string literal is used in a context that expects either annotation</li>
</ul>
<p>
Quick fixes are provided to add the appropriate annotation where needed.
</p>
<!-- tooltip end -->
<p>
This inspection helps prevent runtime errors that can occur when paths with different formats are used incorrectly.
The <code>@MultiRoutingFileSystemPath</code> annotation is used for paths that should work across different file systems,
while the <code>@NativePath</code> annotation is used for paths that are specific to the native file system.
</p>
</body>
</html>

View File

@@ -499,6 +499,13 @@
level="ERROR"
implementationClass="org.jetbrains.idea.devkit.inspections.PotentialDeadlockInServiceInitializationInspection"/>
<localInspection language="UAST" shortName="PathAnnotationInspection"
projectType="INTELLIJ_PLUGIN"
groupPathKey="inspections.group.path" groupKey="inspections.group.code"
enabledByDefault="true" level="WARNING"
implementationClass="org.jetbrains.idea.devkit.inspections.path.PathAnnotationInspection"
key="inspection.path.annotation.display.name"/>
<localInspection language="JVM"
projectType="INTELLIJ_PLUGIN"
groupPathKey="inspections.group.path" groupKey="inspections.group.code"

View File

@@ -725,6 +725,8 @@ inspection.can.be.dumb.aware.settings.ignore.classes.dialog.title=Specify Class
inspection.can.be.dumb.aware.message=Can be made DumbAware if it does not access indexes
inspection.can.be.dumb.aware.quickfix.add.to.ignore=Ignore ''{0}''
inspection.path.annotation.display.name=Path annotation usage problems
inspections.check.return.value=Return value must be checked
# {0} is the identifier of the inspection. It's a relatively short ASCII string.
inspection.message.return.value.must.be.checked=[{0}] Return value must be checked
@@ -751,3 +753,12 @@ list.item.an.action=Action
list.item.toggle.action=Toggle action
command.create.bundle.properties=Create Bundle .properties File
action.create.message.bundle.title=New Message Bundle
inspections.path.annotation.usage.problems=Path annotation usage problems
inspections.intention.family.name.add.multiroutingfilesystempath.annotation=Add @MultiRoutingFileSystemPath annotation
inspections.intention.family.name.add.nativepath.annotation=Add @NativePath annotation
inspections.message.nativepath.passed.to.multiroutingfilesystempath.method.parameter=A string annotated with @NativePath is passed to a method parameter annotated with @MultiRoutingFileSystemPath
inspections.message.multiroutingfilesystempath.passed.to.nativepath.method.parameter=A string annotated with @MultiRoutingFileSystemPath is passed to a method parameter annotated with @NativePath
inspections.message.nativepath.should.not.be.used.directly.constructing.path=A string annotated with @NativePath should not be used directly in Path constructor or factory method
inspections.message.multiroutingfilesystempath.expected=String literal is used in a context that expects @MultiRoutingFileSystemPath
inspections.message.nativepath.expected=String literal is used in a context that expects @NativePath

View File

@@ -0,0 +1,335 @@
// 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 com.intellij.codeInsight.AnnotationUtil
import com.intellij.codeInsight.intention.AddAnnotationPsiFix
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.project.Project
import com.intellij.platform.eel.annotations.MultiRoutingFileSystemPath
import com.intellij.platform.eel.annotations.NativePath
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.PsiModifierListOwner
import com.intellij.util.ThreeState
import org.jetbrains.annotations.NotNull
import org.jetbrains.idea.devkit.DevKitBundle
import org.jetbrains.idea.devkit.inspections.DevKitUastInspectionBase
import org.jetbrains.uast.*
import org.jetbrains.uast.expressions.UInjectionHost
import org.jetbrains.uast.visitor.AbstractUastNonRecursiveVisitor
/**
* Inspection that checks for proper usage of path annotations ([MultiRoutingFileSystemPath] and [NativePath]).
* It highlights cases where a string annotated with one path annotation is used in a context that expects a string
* with a different path annotation.
*/
class PathAnnotationInspection : DevKitUastInspectionBase() {
override fun getDisplayName(): String = DevKitBundle.message("inspections.path.annotation.usage.problems")
override fun getGroupDisplayName(): String = "DevKit"
override fun getShortName(): String = "PathAnnotationInspection"
override fun buildInternalVisitor(@NotNull holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
return com.intellij.uast.UastHintedVisitorAdapter.create(
holder.file.language,
PathAnnotationVisitor(holder),
arrayOf(UCallExpression::class.java, UInjectionHost::class.java)
)
}
/**
* Visitor that checks for path annotation issues.
*/
private inner class PathAnnotationVisitor(
private val holder: ProblemsHolder,
) : AbstractUastNonRecursiveVisitor() {
override fun visitCallExpression(node: UCallExpression): Boolean {
val sourcePsi = node.sourcePsi ?: return true
val target = node.resolve() ?: return true
// Check if the method is a Path constructor or factory method
if (isPathConstructorOrFactory(target)) {
// Check if the argument is annotated with @NativePath
val arguments = node.valueArguments
if (arguments.isNotEmpty()) {
val arg = arguments[0]
val argInfo = PathAnnotationInfo.forExpression(arg)
if (argInfo is PathAnnotationInfo.Native) {
// Report error: @NativePath string used in Path constructor or factory method
holder.registerProblem(
sourcePsi,
DevKitBundle.message("inspections.message.nativepath.should.not.be.used.directly.constructing.path"),
AddMultiRoutingAnnotationFix(argInfo.getAnnotationCandidate())
)
}
}
}
// Check if the method expects a specific path annotation
for ((index, arg) in node.valueArguments.withIndex()) {
val parameter = getParameterForArgument(target, index) ?: continue
val expectedInfo = PathAnnotationInfo.forModifierListOwner(parameter)
val actualInfo = PathAnnotationInfo.forExpression(arg)
if (expectedInfo is PathAnnotationInfo.MultiRouting && actualInfo is PathAnnotationInfo.Native) {
// Report error: @NativePath string passed to method expecting @MultiRoutingFileSystemPath
holder.registerProblem(
arg.sourcePsi ?: sourcePsi,
DevKitBundle.message("inspections.message.nativepath.passed.to.multiroutingfilesystempath.method.parameter"),
AddMultiRoutingAnnotationFix(actualInfo.getAnnotationCandidate())
)
}
else if (expectedInfo is PathAnnotationInfo.Native && actualInfo is PathAnnotationInfo.MultiRouting) {
// Report error: @MultiRoutingFileSystemPath string passed to method expecting @NativePath
holder.registerProblem(
arg.sourcePsi ?: sourcePsi,
DevKitBundle.message("inspections.message.multiroutingfilesystempath.passed.to.nativepath.method.parameter"),
AddNativePathAnnotationFix(actualInfo.getAnnotationCandidate())
)
}
}
return true
}
override fun visitElement(node: UElement): Boolean {
if (node is UInjectionHost) {
val psi = node.sourcePsi
if (psi != null) {
visitLiteralExpression(psi, node)
}
}
return super.visitElement(node)
}
private fun visitLiteralExpression(sourcePsi: PsiElement, expression: UInjectionHost) {
val stringValue = expression.evaluateToString() ?: return
if (stringValue.isBlank()) return
val nonAnnotatedTargets = mutableSetOf<PsiModifierListOwner>()
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<LocalQuickFix>()
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<LocalQuickFix>()
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<PsiModifierListOwner>,
): 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<PsiModifierListOwner>,
): 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<PsiModifierListOwner>,
): 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
)
}
}
}
}
}

View File

@@ -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.
* <p>
* 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)
}
}

View File

@@ -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 = <warning descr="A string annotated with @NativePath should not be used directly in Path constructor or factory method">Path.of(nativePath)</warning>;
}
}
""".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(<warning descr="A string annotated with @MultiRoutingFileSystemPath is passed to a method parameter annotated with @NativePath">multiRoutingPath</warning>);
}
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())
}
}