跳转至

导航、引用、查找用法与重构

IDE 级语言体验的核心是“名称和引用关系”。当 PSI 元素能稳定命名、引用能正确 resolve,跳转、补全、查找用法、重命名和安全删除就能沿同一套机制自然展开。

关键接口关系

能力 关键 API
可命名声明 PsiNamedElementPsiNameIdentifierOwner
引用 PsiReferencePsiPolyVariantReference
外部语言中贡献引用 PsiReferenceContributorPsiReferenceProvider
补全候选 PsiReference.getVariants()CompletionContributor
查找用法 FindUsagesProviderWordsScanner
重命名 setName()handleElementRename()NamesValidator
安全删除 RefactoringSupportProviderPsiElement.delete()
行号槽导航图标 LineMarkerProviderRelatedItemLineMarkerProvider

这套能力最好从声明、引用、索引、作用域四个维度一起设计,不要只为了一个功能临时拼实现。

可命名元素

所有可被引用或重命名的声明元素都应实现 PsiNamedElement。如果声明名只是 PSI 元素的一部分,更常见的是实现 PsiNameIdentifierOwner

interface ExampleNamedElement : PsiNameIdentifierOwner

实现要点:

  • getName() 返回用户看到的逻辑名称。
  • getNameIdentifier() 返回名称所在的 PSI 子元素。
  • getTextOffset() 应指向名称开始位置,而不是整个声明开始位置。
  • setName() 应通过 Element Factory 创建合法 PSI 节点,再替换旧 AST 节点。

不要手写字符串替换来重命名 PSI。官方建议创建一个 dummy file,让 parser 生成正确节点,再取出要替换的节点。

Element Factory

Element Factory 用来构造语言内合法 PSI:

object ExampleElementFactory {
    fun createProperty(project: Project, name: String): ExampleProperty {
        val file = PsiFileFactory.getInstance(project)
            .createFileFromText("dummy.example", ExampleFileType.INSTANCE, "$name = value")
        return PsiTreeUtil.findChildOfType(file, ExampleProperty::class.java)!!
    }
}

用途:

  • setName()
  • Quick Fix 插入新节点。
  • Rename 后替换引用文本。
  • 生成 formatter 或 inspection 需要的合法片段。

Reference 与 Resolve

引用连接“使用位置”和“声明位置”。单目标引用用 PsiReferenceBase,多目标引用用 PsiPolyVariantReferenceBase

class ExampleReference(
    element: PsiElement,
    rangeInElement: TextRange
) : PsiPolyVariantReferenceBase<PsiElement>(element, rangeInElement) {
    override fun multiResolve(incompleteCode: Boolean): Array<ResolveResult> {
        return ExampleIndex.findDeclarations(element.project, value)
            .map { PsiElementResolveResult(it) }
            .toTypedArray()
    }

    override fun getVariants(): Array<Any> {
        return ExampleIndex.findAllDeclarations(element.project)
            .map {
                LookupElementBuilder.create(it)
                    .withTypeText(it.containingFile.name)
            }
            .toTypedArray()
    }
}

设计原则:

  • rangeInElement 必须精确到引用文本,不要包含引号、前缀或额外语法。
  • resolve() / multiResolve() 要快,必要时通过 Stub Index。
  • getVariants() 可以提供基础补全,但复杂补全仍应放到 CompletionContributor
  • 跨语言引用可用 PsiReferenceContributor 注入到目标语言元素上。

注册引用贡献:

<extensions defaultExtensionNs="com.intellij">
  <psi.referenceContributor
      language="JAVA"
      implementation="com.example.ExampleReferenceContributor"/>
</extensions>

Find Usages

Find Usages 依赖三件事:

  1. 声明元素是 PsiNamedElement 或能从引用 resolve 到 PsiNamedElement
  2. 文件内容被 WordsScanner 建立词索引。
  3. 使用位置的 PsiReference 能正确 resolve 到声明。

注册:

<extensions defaultExtensionNs="com.intellij">
  <lang.findUsagesProvider
      language="Example"
      implementationClass="com.example.ExampleFindUsagesProvider"/>
</extensions>

典型实现:

class ExampleFindUsagesProvider : FindUsagesProvider {
    override fun getWordsScanner(): WordsScanner {
        return DefaultWordsScanner(
            ExampleLexerAdapter(),
            ExampleTokenSets.IDENTIFIERS,
            ExampleTokenSets.COMMENTS,
            ExampleTokenSets.STRINGS
        )
    }

    override fun canFindUsagesFor(psiElement: PsiElement): Boolean {
        return psiElement is ExampleNamedElement
    }

    override fun getType(element: PsiElement): String = "example symbol"
    override fun getDescriptiveName(element: PsiElement): String = (element as PsiNamedElement).name ?: ""
    override fun getNodeText(element: PsiElement, useFullName: Boolean): String = getDescriptiveName(element)
}

性能关键:对局部变量、函数参数、局部标签等有限作用域元素,覆写 getUseScope() 返回更窄范围。这样重命名和查找用法不必扫描全项目。

Rename

Rename 使用 Find Usages 的同一套查找规则:

  • 声明元素调用 PsiNamedElement.setName()
  • 引用位置调用 PsiReference.handleElementRename()
  • 如果引用继承 PsiReferenceBase,可通过 ElementManipulator.handleContentChange() 处理内部文本替换。

名称校验:

<extensions defaultExtensionNs="com.intellij">
  <lang.namesValidator
      language="Example"
      implementationClass="com.example.ExampleNamesValidator"/>
</extensions>

更细粒度校验:

<extensions defaultExtensionNs="com.intellij">
  <renameInputValidator implementation="com.example.ExampleRenameInputValidator"/>
</extensions>

扩展场景:

需求 API
禁止某类元素被重命名 com.intellij.vetoRenameCondition
标准 UI 但扩展逻辑 RenamePsiElementProcessor
完全自定义 Rename UI 和流程 RenameHandler
支持原地重命名 RefactoringSupportProvider.isMemberInplaceRenameAvailable()

Safe Delete

Safe Delete 也建立在 Find Usages 之上。需要:

  • RefactoringSupportProvider 中返回 isSafeDeleteAvailable()
  • 对可删除 PSI 元素实现 delete()
  • 复杂逻辑用 SafeDeleteProcessorDelegate 定制引用搜索和删除流程。

注册:

<extensions defaultExtensionNs="com.intellij">
  <lang.refactoringSupport
      language="Example"
      implementationClass="com.example.ExampleRefactoringSupportProvider"/>
</extensions>

安全删除真正执行前会先找用法。删除时应该删除 AST 中对应节点,而不是直接改 Document 文本。

Line Marker 与导航图标

Line Marker 用于在 gutter 放图标,点击跳转到相关元素:

<extensions defaultExtensionNs="com.intellij">
  <codeInsight.lineMarkerProvider
      language="Example"
      implementationClass="com.example.ExampleLineMarkerProvider"/>
</extensions>

最佳实践:

  • 返回 marker 的元素必须是最小叶子元素,例如标识符 token。
  • 不要在 PsiMethod、函数体、文件等大节点上返回 marker,否则可见区域两阶段扫描会导致图标闪烁。
  • 需要用户控制开关时,继承 GutterIconDescriptor
  • RelatedItemLineMarkerProviderNavigationGutterIconBuilder 简化“跳到相关目标”场景。

实战验收清单

  • 声明元素 getNameIdentifier()getTextOffset() 正确。
  • 引用 rangeInElement 只覆盖引用名。
  • resolve() 对不存在目标返回空,不抛异常。
  • getVariants() 不做全项目 PSI 遍历。
  • Find Usages 能区分代码、注释、字符串。
  • Rename 后声明和所有引用都更新。
  • 名称校验符合语言规则,不退回 Java 标识符规则。
  • 局部符号覆写 getUseScope()
  • Safe Delete 会先报告用法,再删除正确 AST 节点。
  • Line Marker 不闪烁。

参考来源