程序结构接口(Program Structure Interface)简称PSI,PSI是IDEA插件开发最复杂的一块内容,后续会有大量实战来强化理解此处的知识。PSI是IntelliJ 平台中的一个层,负责解析文件并创建语法和语义代码模型,为平台的众多功能提供支持。它主要包含三方面内容:
- PSI File
- File View Provider
- PSI Element
一、idea platform主要的文件类型
首先先回顾一下idea插件开发涉及的几个主要的文件类型,如下图:
这里需要注意的是,idea平台对于可解析的文件有两层抽象,第一层是通用抽象即所谓的UAST语法树,而PSI是针对专门语言的抽象。另外一点就是对于XML文件是有专让的语言解析框架,因为PSI主要针对的是可编程的语言,像xml或json这样的不是可编程的语言是不需用到PSI或UAST的,接下来回到正题,看下PSI的主要内容。
二、什么是PSI?
1、PSI File
PSI文件表现为带root的层级视图树,是为特定编程语言中元素的层次结构,有点类似JAVA中的AST树的结构。PsiFile.java是所有PSI的基类,特定语言根据需要会扩展出自己的Psi类,比如PsiJavaFile表示一个java文件,XmlFile表示一个xml文件。
PSI 的Scope是项目范围,与之前提到过另两类文件VF和Document不同(即使打开了多个项目,每个文件都由同一个实例表示),所以基于这一点而言一个VF或Document可能存在多个PSI实例。
获取PSI文件
Context | API |
Action | AnActionEvent.getData(CommonDataKeys.PSI_FILE) |
Document | PsiDocumentManager.getPsiFile() |
PSI Element | PsiElement.getContainingFile() (may return null if the PSI element is not contained in a file) |
Virtual File | PsiManager.findFile(), PsiUtilCore.toPsiFiles() |
File Name | FilenameIndex.getVirtualFilesByName() and locate via PsiManager.findFile() or PsiUtilCore.toPsiFiles() |
操作PSI文件
大多数修改操作是在单个 PSI 元素的级别上执行的,而不是作为一个整体的文件,比如要遍历文件中的元素:
psiFile.accept(new PsiRecursiveElementWalkingVisitor() {
// visitor implementation ...
});
创建PSI文件
PSI依赖具体的语言,所以想创建一个PSI用到的API是Language.java,比如:
LanguageParserDefinitions.INSTANCE
.forLanguage(MyLanguage.INSTANCE)
.createFile(fileViewProvider);
PSI和VF一样都是存在于内存中的,可以被GC。
PsiFileFactory.createFileFromText() //创建具有指定内容的内存中 PSI 文件。
PsiDirectory.add() //将 PSI 文件保存到磁盘
监听PSI文件的改动
使用PsiManager.addPsiTreeChangeListener()可以接收有关项目PSI树的所有更改的通知。或者注册com.intellij.psi.treeChangeListener扩展点,然后实现PsiTreeChangeListener接口,处理PsiTreeChangeEvent事件。对PSI文件进行的任何修改都会反应到Document上。
2、File View Provider
现实情况是编程语言是可以混合多种语言混写的,这时就需要用到了文件视图提供程序 ( FileViewProvider) ,它可以对单个文件中多个 PSI 树的访问。例如,JSPX 页面中的 Java 代码有一个单独的 PSI 树 ( PsiJavaFile),XML 代码有一个单独的树 ( XmlFile),整个 JSP 有一个单独的树 ( JspFile)。
每种语言被包装成单独的PSI树,然后在源文件的入口处饮食了一个"outer language elements"的点位符。每个 PSI 树都覆盖了文件的全部内容,并在可以找到不同语言内容的地方包含特殊的“外部语言元素”。
总结一下:一个FileViewProviderinstance对应一个VirtualFile,一个single Document,包含一或多个PsiFileinstance。
获取FileViewProvider
Context | API |
PSI File | PsiFile.getViewProvider() |
Virtual File | PsiManager.getInstance(project).findViewProvider() |
操作FileViewProvider
- .getLanguages():获取文件中存在 PSI 树的所有语言的集合
- .getPsi(language):获取特定语言的 PSI 树,例如,要获取 XML 的 PSI 树,使用fileViewProvider.getPsi(XMLLanguage.INSTANCE)
- .findElementAt(offset, language):在文件中的指定偏移量处查找特定语言的元素
扩展FileViewProvider
注册com.intellij.fileType.fileViewProviderFactory扩展点,实现FileViewProviderFactory接口的createFileViewProvider()方法获取FileViewProvider。
<extensions defaultExtensionNs="com.intellij">
<fileType.fileViewProviderFactory
filetype="$FILE_TYPE$"
implementationClass="com.example.MyFileViewProviderFactory"/>
</extensions>
<!--$FILE_TYPE$是指正在创建的文件的类型(例如,“JSF”)-->
3、PSI Element
在不同层级上操作PSI元素可以获取源代码的内部结构,例如,您可以使用 PSI 元素执行代码分析、代码检查等功做。所有PSI元素的基类实现都是PsiElement。这里需要注意的是单个的PSI文件也是一个PSI Element。这样最后其实形成的是一棵deep树。
获取PSI Element
Context | API |
Action | AnActionEvent.getData(CommonDataKeys.PSI_ELEMENT)注意:如果当前打开了一个编辑器并且元素是caret的一个引用,将返回解析引用的结果。 |
PSI File | PsiFile.findElementAt(offset):返回offset处的叶子节点,通常是一个解析器对象。PsiTreeUtil.getParentOfType():查找元素的类型 |
Reference | PsiReference.resolve() |
二、PSI导航
导航 PSI 的方式主要有三种:自上而下、自下而上和References。在第一个场景中,您有一个 PSI 文件或另一个更高级别的元素(例如,一个方法)。您需要找到所有符合指定条件的元素(例如,所有变量声明)。在第二种情况下,您在 PSI 树中有一个特定的点(例如,插入符号处的元素),需要找出有关其上下文的信息(例如,声明它的元素)。最后,引用允许您从元素的用法(例如,方法调用)导航到声明(被调用的方法)并返回。
1、自上而下的导航
执行自上而下导航的最常见方法是使用Visitor。要使用Visitor,需要创建一个类(通常是匿名内部类)来扩展基本Visitor类,覆盖处理您感兴趣的元素的方法,并将Visitor实例传递给PsiElement.accept().
Visitor的基类是特定于语言的。例如,处理 Java 文件中的元素,可以扩展JavaRecursiveElementVisitor和覆盖感兴趣的 Java 元素类型对应的方法。以下代码片段显示了使用Visitor查找所有 Java 局部变量声明,也可以使用PsiClass.getMethods()这样的快捷方法:
file.accept(new JavaRecursiveElementVisitor() {
@Override
public void visitLocalVariable(PsiLocalVariable variable) {
super.visitLocalVariable(variable);
System.out.println("Found a variable at offset " +
variable.getTextRange().getStartOffset());
}
});
PsiTreeUtil包含许多用于 PSI 树导航的通用、独立于语言的函数,其中一些(例如,findChildrenOfType())执行自上而下的导航。
2、自底向上导航
自下而上导航的起点是 PSI 树中的特定元素(例如,解析引用的结果)或偏移量。如果你有一个偏移量,可以通过调用PsiFile.findElementAt()找到相应的 PSI 元素,此方法返回树最低级别的元素(例如,标识符),如果要确定更广泛的上下文,则需要向上导航树。
在大多数情况下,自底向上导航是通过调用PsiTreeUtil.getParentOfType(). 此方法在树中向上移动,直到找到指定类型的元素。例如,要查找包含方法,可以调用PsiTreeUtil.getParentOfType(element, PsiMethod.class).
在某些情况下,您还可以使用特定的导航方法。例如,要查找包含某个方法的类,可以使用PsiMethod.getContainingClass().以下代码片段显示了如何一起使用这些调用:
PsiFile psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE);
PsiElement element = psiFile.findElementAt(offset);
PsiMethod containingMethod = PsiTreeUtil.getParentOfType(element, PsiMethod.class);
PsiClass containingClass = containingMethod.getContainingClass();
PsiNavigationDemoAction.java示例代码
public class PsiNavigationDemoAction extends AnAction {
@Override
public void actionPerformed(AnActionEvent anActionEvent) {
Editor editor = anActionEvent.getData(CommonDataKeys.EDITOR);
PsiFile psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE);
if (editor == null || psiFile == null) {
return;
}
int offset = editor.getCaretModel().getOffset();
final StringBuilder infoBuilder = new StringBuilder();
PsiElement element = psiFile.findElementAt(offset);
infoBuilder.append("Element at caret: ").append(element).append("\n");
if (element != null) {
PsiMethod containingMethod = PsiTreeUtil.getParentOfType(element, PsiMethod.class);
infoBuilder
.append("Containing method: ")
.append(containingMethod != null ? containingMethod.getName() : "none")
.append("\n");
if (containingMethod != null) {
PsiClass containingClass = containingMethod.getContainingClass();
infoBuilder
.append("Containing class: ")
.append(containingClass != null ? containingClass.getName() : "none")
.append("\n");
infoBuilder.append("Local variables:\n");
containingMethod.accept(new JavaRecursiveElementVisitor() {
@Override
public void visitLocalVariable(PsiLocalVariable variable) {
super.visitLocalVariable(variable);
infoBuilder.append(variable.getName()).append("\n");
}
});
}
}
Messages.showMessageDialog(anActionEvent.getProject(), infoBuilder.toString(), "PSI Info", null);
}
@Override
public void update(AnActionEvent e) {
Editor editor = e.getData(CommonDataKeys.EDITOR);
PsiFile psiFile = e.getData(CommonDataKeys.PSI_FILE);
e.getPresentation().setEnabled(editor != null && psiFile != null);
}
}
3、References
PSI 树中的References是一个对象,表示代码中对特定元素的引用的链接。解析引用意味着找到特定用法所引用的声明。比如:
public void hello(String message) {
System.out.println(message);
}
jdk会解析为5个引用:class:String、System;field:out;method: println;parameter:println(message)中的message。引用是PsiReference接口的实例。注意,References和 PSI Element不同。引用是由PsiElement.getReferences()创建的,引用的基础 PSI 元素可以从获得PsiReference.getElement()。
定位
要解析定位References的声明 ,需要调用PsiReference.resolve()方法。了解PsiReference.getElement()和PsiReference.resolve()之间的区别非常重要。前一种方法返回引用的来源,而后者返回其目标。比如在上面的示例中message References,getElement()返回标识符,resolve()返回标识符。
解析引用的过程与解析不同,并不总是成功。如果当前在 IDE 中打开的代码没有编译,或者其他情况,PsiReference.resolve()返回null是正常的——所有使用引用的代码都要注意处理。
查询
执行相反方向的导航——从声明到它的用法,可以使用ReferencesSearch,指定要搜索的元素,以及可选的其他参数,例如需要搜索引用的范围。Query允许一次获取所有结果或一个一个地迭代结果。也可以在找到第一个(匹配)结果后立即停止处理。
处理多个解析引用结果
最简单的情况下,引用解析为单个元素,如果解析失败,则代码不正确,IDE 需要将其高亮显示为错误。但是,也有情况不同的情况。
第一种情况是软引用。考虑new File("foo.txt")。如果 IDE 找不到文件“foo.txt”,这并不意味着代码错误 - 也许该文件仅在运行时可用。此类引用需要PsiReference.isSoft()方法返回true,然后可以在检查/注释器中使用以跳过完全突出显示它们或使用较低的严重性。
第二种情况是多变引用。考虑 JavaScript 程序的情况。JavaScript 是一种动态类型的语言,因此 IDE 不能总是准确地确定在特定位置调用了哪个方法。为了处理这个问题,它提供了一个可以解析为多个可能元素的引用。这些引用实现了PsiPolyVariantReference接口。
要解析PsiPolyVariantReference引用,可以调用multiResolve()方法,这个方法返回一个ResolveResult对象数组。例如,假设您有多个 Java 方法重载和一个调用,其参数与任何重载都不匹配。在这种情况下,您将取回ResolveResult所有重载的对象,并为所有重载isValidResult()返回。
4、例子
注册plugin.xml
<actions>
<action class="org.intellij.sdk.psi.PsiNavigationDemoAction" id="PsiNavigationDemo"
text="PSI Navigation Demo...">
<add-to-group group-id="ToolsMenu" anchor="last"/>
</action>
</actions>
实现java
public class PsiNavigationDemoAction extends AnAction {
@Override
public void actionPerformed(AnActionEvent anActionEvent) {
Editor editor = anActionEvent.getData(CommonDataKeys.EDITOR);
PsiFile psiFile = anActionEvent.getData(CommonDataKeys.PSI_FILE);
if (editor == null || psiFile == null) {
return;
}
int offset = editor.getCaretModel().getOffset();
final StringBuilder infoBuilder = new StringBuilder();
PsiElement element = psiFile.findElementAt(offset);
infoBuilder.append("Element at caret: ").append(element).append("\n");
if (element != null) {
PsiMethod containingMethod = PsiTreeUtil.getParentOfType(element, PsiMethod.class);
infoBuilder
.append("Containing method: ")
.append(containingMethod != null ? containingMethod.getName() : "none")
.append("\n");
if (containingMethod != null) {
PsiClass containingClass = containingMethod.getContainingClass();
infoBuilder
.append("Containing class: ")
.append(containingClass != null ? containingClass.getName() : "none")
.append("\n");
infoBuilder.append("Local variables:\n");
containingMethod.accept(new JavaRecursiveElementVisitor() {
@Override
public void visitLocalVariable(PsiLocalVariable variable) {
super.visitLocalVariable(variable);
infoBuilder.append(variable.getName()).append("\n");
}
});
}
}
Messages.showMessageDialog(anActionEvent.getProject(), infoBuilder.toString(), "PSI Info", null);
}
@Override
public void update(AnActionEvent e) {
Editor editor = e.getData(CommonDataKeys.EDITOR);
PsiFile psiFile = e.getData(CommonDataKeys.PSI_FILE);
e.getPresentation().setEnabled(editor != null && psiFile != null);
}
}
运行测试
三、PSI修改
PSI(程序结构接口)的最常见操作的方法。是关于使用现有语言(例如 Java)的 PSI。
1、基本操作API
通用操作
- 如果我知道文件名但不知道路径,如何找到文件?FilenameIndex.getFilesByName()
- 如何找到使用特定 PSI 元素的位置?ReferencesSearch.search()
- 如何重命名 PSI 元素?RefactoringFactory.createRename()
- 如何使虚拟文件的 PSI 重建?FileContentUtil.reparseFiles()
针对java的操作
- 我如何找到一个类的所有继承者?ClassInheritorsSearch.search()
- 如何通过限定名称查找课程?JavaPsiFacade.findClass()
- 如何通过短名称查找班级?PsiShortNamesCache.getClassesByName()
- 如何找到 Java 类的超类?PsiClass.getSuperClass()
- 如何获取对 Java 类的包含包的引用?
PsiJavaFile javaFile = (PsiJavaFile)psiClass.getContainingFile(); PsiPackage psiPackage = JavaPsiFacade.getInstance(project) .findPackage(javaFile.getPackageName());
//或者PsiUtil.getPackageName()
- 如何找到覆盖特定方法的方法?OverridingMethodsSearch.search()
- 如何检查 JVM 库是否存在?可以使用来JavaLibraryUtil类的方法:hasLibraryClass()通过已知的库类 FQN 检查存在hasLibraryJar()使用 Maven 坐标(例如,io.micronaut:micronaut-core)。
2、操作注意事项
PSI 是源代码的读/写表示,作为与源文件结构相对应的元素树。您可以通过添加、替换和删除PSI 元素来修改 PSI 。可以使用PsiElement类的PsiElement.add()、PsiElement.delete()和PsiElement.replace()等方法在单个操作中处理多个元素,或者指定树中需要添加元素的确切位置,与文档操作一样,PSI 修改需要包装在一个Action中。
创建新的PSI
添加到树中或替换现有 PSI 元素的 PSI 元素通常是从文本创建的。在最一般的情况下,PsiFileFactory.createFileFromText()方法一般用于创建一个新文件,其中包含您需要添加到树中的代码构造或用作现有元素的替换、遍历生成的树以找到特定的部分,然后将该元素传递给add()or replace()。大多数语言都提供工厂方法,可以更轻松地创建特定的代码结构。例子:
PsiJavaParserFacade类包含诸如createMethodFromText()之类的方法,它根据给定的文本创建 Java 方法。SimpleElementFactory.createProperty()创建简单语言属性对于小的代码版本,可以以文本形式编写或从现有文件中获取的代码片段截取,然后传递给createFromText(),对于较大的代码片断,最好要分几步进行:
- 从文本创建替换树片段,为用户代码片段留下占位符;
- 用用户代码片段替换占位符;
- 用替换树替换原始源文件中的元素。
这可确保保留用户代码的格式,并且修改不会引入任何不需要的空白更改。正如 IntelliJ 平台 API 中的其他地方一样,传递给的文本createFileFromText()和其他createFromText()方法必须仅用作\n行分隔符。可参考ComparingStringReferencesInspection示例:
// binaryExpression holds a PSI expression of the form "x == y", which needs to be replaced with "x.equals(y)"
PsiBinaryExpression binaryExpression = (PsiBinaryExpression) descriptor.getPsiElement();
IElementType opSign = binaryExpression.getOperationTokenType();
PsiExpression lExpr = binaryExpression.getLOperand();
PsiExpression rExpr = binaryExpression.getROperand();
// Step 1: Create a replacement fragment from text, with "a" and "b" as placeholders
PsiElementFactory factory = JavaPsiFacade.getInstance(project).getElementFactory();
PsiMethodCallExpression equalsCall =
(PsiMethodCallExpression) factory.createExpressionFromText("a.equals(b)", null);
// Step 2: replace "a" and "b" with elements from the original file
equalsCall.getMethodExpression().getQualifierExpression().replace(lExpr);
equalsCall.getArgumentList().getExpressions()[0].replace(rExpr);
// Step 3: replace a larger element in the original file with the replacement tree
PsiExpression result = (PsiExpression) binaryExpression.replace(equalsCall);
保持树结构的一致性
PSI 修改方法不会限制您构建结果树结构的方式。例如,在使用 Java 类时,您可以将语句添加for为元素的直接子元素,即使 Java 解析器永远不会生成表示方法主体的PsiMethod这种结构(语句for将始终是 的子元素)。PsiCodeBlock产生不正确树结构的修改可能看起来有效,但它们稍后会导致问题和异常。因此,您始终需要确保使用 PSI 修改操作构建的结构与解析器在解析您创建的代码时生成的结构相同。为确保您不会引入不一致,可以使用PsiTestUtil.checkFileStructure()为修改 PSI 的操作调用测试。此方法可确保您构建的结构与解析器生成的结构相同。
空格和导入
使用 PSI 修改函数时,您永远不应从文本中创建单独的空白节点(空格或换行符)。相反,所有空白修改都由格式化程序执行,它遵循用户选择的代码样式设置。reformat(PsiElement)格式化会在每个命令结束时自动执行,如果需要,您也可以使用类中的方法手动执行CodeStyleManager。
此外,在使用 Java 代码(或使用具有类似导入机制的其他语言的代码,例如 Groovy 或 Python)时,您永远不应该手动创建导入。相反,您应该将完全限定的名称插入到您正在生成的代码中,然后调用(或您正在使用的语言的等效 API)shortenClassReferences()中的方法。JavaCodeStyleManager这确保导入是根据用户的代码样式设置创建的,并插入到文件的正确位置。
结合PSI 和Document
在某些情况下,您需要执行 PSI 修改,然后通过 PSI 对刚刚修改的文档执行操作(例如,启动实时模板)。要完成基于 PSI 的后处理(例如格式化)并将更改提交到文档,请调用doPostponedOperationsAndUnblockDocument()实例PsiDocumentManager。
四、Element Patterns
元素模式提供了一种通用的方式来给指定的对象设置格式条件,一般用来检查 PSI 元素是否匹配特定结构。类似使用正则表达式测试字符串的与正则表达式是否匹配一样,元素模式用于对 PSI 元素的嵌套结构设置条件。在 IntelliJ 平台有两个application使用了这种技术:
- 指定在为自定义语言实现完成贡献者时应在何处进行自动完成。
- 指定通过PSI 参考贡献者提供进一步参考的 PSI 元素。
在使用时建议使用 IntelliJ 平台提供的高级模式类,而不要直接扩展ElementPattern:
Class | Main Contents | Notable Examples |
StandardPatterns | 字符串和字符模式的工厂;与、或、非等逻辑运算 | LogbackReferenceContributor, RegExpCompletionContributor |
PlatformPatterns | PSI、IElement 和 VirtualFile 模式的工厂 | FxmlReferencesContributor, PyDataclassCompletionContributor |
PsiElementPattern | PSI 模式;检查孩子、父母或邻近的叶子 | XmlCompletionContributor |
CollectionPattern | 过滤和检查模式集合;主要用于为其他高级模式类提供功能 | PsiElementPattern |
TreeElementPattern | 专门用于检查 (PSI) 树结构的模式 | PyMetaClassCompletionContributor |
StringPattern | 检查字符串是否匹配、是否具有特定长度、是否具有特定的开头或结尾,或者是否是字符串集合中的一个 | AbstractGradleCompletionContributor |
CharPattern | 检查字符是否为空格、数字或 Java 标识符部分 | CompletionUtil |
IntelliJ 平台中的一些内置语言实现了它们自己的模式类,并且可以提供额外的示例:
- XmlPatterns为 XML 属性、值、实体和文本提供模式。
- PythonPatterns为 Python 提供文字、字符串、参数和函数/方法参数的模式。
- DomPatterns构建XmlPatterns并充当包装器,为DOM-API提供更多模式。
1、例子
PsiElementPattern.Capture<PsiElement> AFTER_COMMA_OR_BRACKET_IN_ARRAY =
psiElement().
afterLeaf("[", ",").
withSuperParent(2, JsonArray.class).
andNot(
psiElement().
withParent(JsonStringLiteral.class)
);
上述代码主要完成以下功能:
- 出现在左括号或逗号之后,通过对相邻叶元素施加限制来表示。
- 作为JsonArray二级父级,指示 PSI 元素必须位于 JSON 数组内。
- 没有 aJsonStringLiteral作为父级,这可以防止数组中带有方括号或逗号的字符串给出误报匹配的情况。