构建IntelliJ Luau插件的技术实践
理解IDE处理代码的流程
IDE从纯文本开始处理代码。词法分析器(lexer)将文本切分成词法单元(tokens),包括关键字、数字和符号。解析器(parser)将这些词法单元排列成抽象语法树(AST),这是程序语法的蓝图。在此基础上,JetBrains构建了PSI(程序结构接口),这是AST加上编辑、导航和重构等额外功能。
与使用LSP的VS Code相比,关键区别在于:PSI是IDE的可编辑蓝图,而LSP更像外部顾问。这解释了为什么JetBrains开发者通常需要两者兼备。
LSP方式与原生支持方式
为IntelliJ添加语言支持有两条主要路径:编写原生支持或集成语言服务器(LSP)。
原生支持让你完全控制IDE处理语言的方式——从语法高亮到导航和重构——但需要从头构建所有内容并深入学习语言知识。
“替代方式"是使用LSP。LSP是一种更通用的协议,让编辑器能与独立语言服务器通信。由于语言团队或其他开发者已经实现了自动完成和诊断等基本功能,因此能更快让这些功能运行。
词法分析器构建要点
词法分析器接收原始源代码(文本)并将其分解为词法单元。这个过程为后续的解析器理解代码结构做准备。
JFlex词法分析器基于从语法生成的DFA状态机(某些正则表达式引擎也使用)。在这种语法中,你需要描述哪些符号在哪些状态下被允许、状态转换以及要返回的词法单元。
|
|
字符串插值处理
Luau引入了与其他语言类似的字符串插值。像{ "inter" ..
{“po” .. {"lation"}
}}
这样的嵌套字符串插值需要跟踪大括号数量和嵌套级别。最直接的方法是在词法分析器中添加手动编写的函数,随状态一起保存这些信息。
|
|
多行字符串和词法分析器错误处理
Luau有多种编写多行字符串的方式。首先是[[]]
字符串,为避免与内部的[[
冲突,Luau允许使用额外的等号:
|
|
词法分析器应跟踪等号数量以正确识别字符串结尾,我们可以通过添加状态类似地解决这个问题。
类似于Python,Luau也允许在字符串中显式转义时换行:
|
|
解析器构建策略
编写优秀的解析器是一个迭代过程。制作有用的东西需要多次迭代,偶尔仍会出现错误。
构建优秀解析器的目标很明确。我们需要:
- 正确的解析器
- 对错误有弹性的解析器。例如,如果出现拼写错误,不应使文件的其余部分无效
- 快速的解析器。虽然无法达到新前端框架的开发速度,但至少可以尝试
- 为所有未来PSI操作结构良好的解析器,因为几乎所有功能都依赖于PSI树
规则顺序的重要性
一个简单但重要的规则:当多个语法规则共享共同前缀时,较长的规则应放在前面。
例如,如果你有T
和T...
作为两种不同类型的节点,永远不要将T
放在第一位。虽然这是显而易见的事情,但我还是多次犯错。
手动解析的必要性
有时候,与解析器语法斗争太久后,编写自定义解析规则似乎更容易。你可能是对的。
例如,我放弃使用语法的一个案例是表达式语句。大多数语言都允许你编写仅包含表达式2 + 2
的行(例如Python),但在Lua(u)中,唯一允许作为语句的表达式是函数调用。
这个规则作为一种防止错误的方式是有意义的,比如你只是忘记调用函数或将结果分配给变量。但这并不会简化解析。
解析性能优化
Luau作为类型化语言,支持像value :: Type
这样的类型转换。当我重写表达式解析器时,我将类型转换拆分为一个单独的规则,看起来像as_expr ::= subset_of_expressions :: type
。根据我们之前讨论的"较长规则应优先"原则,我将此规则放在解析简单表达式的规则之前。
起初这似乎工作正常。但后来,在编写涉及深度嵌套表的测试时,我注意到添加新表会导致明显减速。
问题在于解析器总是首先尝试匹配类型转换(as_expr
)。当遇到嵌套表达式时,它会再次尝试将其解析为类型转换,这种重复会深入到嵌套的深度。在最深层,类型转换会失败,因此解析器会回退到value_expr
并重新解析该部分。
修复这种情况的方法取决于具体情况。这里很简单,我应该使用之前救过我一次的left
修饰符:
|
|
这样,不再需要重新解析,我们只需解析值。此外,如果最后有类型转换,解析器将用它包装结果节点。
错误恢复机制
错误恢复允许解析器在解析的代码包含语法错误时继续工作。因此,解析器不会在出现第一个问题迹象时就放弃,而是尝试通过跳过部分输入来恢复,以便可以从文件的其余部分继续构建可用结构。
错误恢复在IntelliJ解析器中依赖于两个主要机制:pins和recovery规则。两者在概念上都很简单,但需要时间习惯它们的工作方式。在特定元素处固定规则意味着一旦解析器到达该元素,它就不会返回尝试解析另一个规则,而是提交当前规则并显示错误。
recoverWhile
也很简单,如文档所述:“它在规则匹配以任何结果完成之后匹配任意数量的词法单元”。
任何结果部分是最重要的。这意味着即使规则已成功解析,错误恢复仍会启动。大多数情况下,恢复规则都是带有否定的谓词,如此处所示:
|
|
这将匹配除提到的词法单元之外的所有词法单元,并且不会消耗它们,它们仍然可以被其他规则使用。
LSP集成与插件设置管理
选择如何打包LSP
如何集成LSP?如果它是Node包,并且你正在为WebStorm构建插件,可以重用其团队创建的基础设施:com.intellij.lang.typescript.lsp.LspServerPackageDescriptor
。否则,你需要实现标准的com.intellij.platform.lsp.api.LspServerSupportProvider
。
你还需要一个LSP可执行文件。当你有一个跨平台运行的LSP时,可以将其打包到插件文件中。如果它是应该基于平台选择的二进制文件,你需要决定如何处理。最简单的方法是打包所有版本,但代价是增加插件的重量,并且需要频繁更新以与LSP保持同步。或者,你可以要求用户提供LSP二进制文件的路径,或实现自动下载系统。
构建下载和版本控制的UI
我最终实现了一个在点击下载部分时下载LSP的UI。为保持简单,我还向用户显示存储LSP文件的文件夹,以防他们想要手动删除它们。
处理缺失源代码访问
另一个有趣的障碍是弄清楚如何获取LSP相关代码的源代码。大多数文档都在那里,但通常的"下载源代码"按钮并不总是适用于这些特定类(存在一些问题)。
学习LSP工作原理(被迫)
在使用LSP时,你不可避免地会学习它们的工作原理。例如,你可能需要在启动时通过CLI参数或通过工作区配置来配置它。你甚至可能不得不自豪地为C++代码库做出第一个贡献,添加serverInfo
,这是LSP在初始化后共享其名称和版本的内置方式,让此信息出现在IDE小部件中。
其他功能和IDE集成陷阱+提示
理解引用解析
我们在词法分析器和解析器上花了这么多时间,不是因为它们很难,而是因为我能够形成对它们的理解,而许多其他功能都是临时的。
到目前为止,我已经为类型和变量实现了文件内的基本引用解析,以及基于它们的代码完成。
“最糟糕"的部分是实现开始严重依赖于你的语言和目标。你可以按照自己喜欢的方式实现功能,只要你实现了基本接口所需的方法。
实现PsiNameIdentifierOwner
每当将光标放在类似变量的元素上时,IDE会突出显示其用法。这种机制比VSCode提供的更先进——它不仅仅是突出显示匹配的单词。为此工作,必须实现引用解析。
第一步是声明引入新变量名的PSI元素。相关接口是PsiNameIdentifierOwner
。一旦你在文档中阅读了它,就可以继续实现它——这很简单。只需重写所需的方法并填写几行代码。
使用getTextOffset修复高亮错误
PsiNameIdentifierOwner
接口代码的文档说明:
实现者还应重写PsiElement.getTextOffset()
以返回标识符词法单元的相对偏移量。
接口方法的名称如此具有描述性,以至于我从未打开接口文档。除了高亮显示,此方法还负责在点击转到定义时将光标放置在正确位置。一旦你实现了getTextOffset
,一切就开始正常工作。
性能陷阱和文件监听器
在集成独立工具以帮助LSP解析引用时,我对使用BulkFileListener
还是较新的AsyncFileListener
感到困惑。性能提示:file.fileType
可能很慢,因为它使用多种机制来确定文件类型。在UI线程上运行的性能关键代码中,FileTypeRegistry.getInstance().getFileTypeByFileName(file.getPath())
通常更快。
总结
构建插件并不那么困难,你有地方可以寻求帮助。有复杂的功能,但也有像面包屑和粘性行这样增加真正价值且易于实现的功能。最困难的部分只是弄清楚它们叫什么,以便我可以找到文档。
实际实现?大约20行代码。
不要被构建自己的插件吓倒!