本文收录于专栏文章「IDE中的魔法」,希望读者能够通过这系列文章,对 IDE 相关技术的实现有一定的认知,同时,通过对语言进行静态分析,能够从编译器的视角,审视语言特性,帮助大家在了解 IDE 的同时,也能更深入的了解语言本身。
在上一章我们已经掌握了完整的公式语法,再结合 ANTLR,我们便能获得能够解析公式语法的 parser。现在,我们把关注点集中在自动补全是如何实现的问题上。我们的目标是实现公式语法的自动补全,自动补全的流程大致如下图所示:
我们需要在 AST 中搜索光标的位置,确定光标落在哪个语法节点上,然后我们需要取得这个语法节点的上下文信息,上下文其实就是这个语法节点在 AST 上的祖先节点,然后我们再根据上下文,结合其他已知信息(例如函数定义、变量声明等),给出合适的提示。
静态分析 - 用户在关注语法树的什么位置?
自动补全的第一步,就是确定用户当前关注的位置,也就是光标的位置落在哪个 AST 的节点上,这是我们后续分析的基础,毕竟补全也是往用户输入的位置去补全。在获知到用户关注的节点后,我们才能根据该节点的上下文信息,去做出符合用户预期的推断。
AST vs Parse Tree
不过首先需要解释一下的就是,ANTLR 的 parser 生成的是一棵 parse tree,这和 AST 还是有一定的区别。我们可以随意写一个公式看一下 ANTLR 生成的树。下图是 test(var, 'str') - var / 3 生成的 parse tree。因为 ANTLR 是完全依据我们的语法规则生成,所以所有的语法节点都会出现在 parse tree 上,例如 atom,atom 规则就是为了方便聚合表达式可以支持哪些因子,让语法显得更加可读,但反映到 parse tree 上则是每一个 expression 到实际的因子,中间都会多一个 atom 节点,而实际上这个节点对于分析没有任何意义。另外,像 string 这样的节点在 parse tree 中并不是一个叶子节点,实际我们只关心这是一个 string 节点就够了,不用额外关心它到底用的是单引号还是双引号。
简而言之,parse tree 是 AST 的超集,AST 是你做分析所需要关心的 parse tree 的子集,ANTLR 可以不需要在语法中内嵌代码(这在一些其他的 parser 生成工具中是必要的),从而可以将语法和特定的实现解耦,不过带来的副作用就是 visitor 和 listener 是基于 parse tree 的,相比较直接控制语法的规约过程,这种情况会有一些额外的,所谓的仅仅和语法定义相关但是和语义基本无关的节点。不过好在 ANTLR 提供的接口足够通用,我们实现时只需要关注和我们相关的接口实现即可。我们将使用 ANTLR 的 visitor 接口来实现这个查找的过程,为此,初始化项目时需要加上 --visitor 的参数:
"scripts" : {
"build" : "antlr4ts -visitor Formula.g4",
"start" : "ts-node index.ts"
}
复制代码
搜索光标的位置
现在我们开始实现 visitor,我们的目标是实现查询当前光标位置所处节点的全部上下文,所谓全部上下文,就是我们的目标节点(光标位置的节点)和他全部的祖先节点,并且以有序列表的形式提供,例如下图中,str 的全部上下文就是 [exp, exp, var, str] 这样一个队列。值得注意的是,用户实际光标的位置不一定在某一个具体的节点上,他可能存在于某个空白字符的位置,这种情况下,上下文列表的最后一个节点一定不是一个 AST 上的终结符节点,而是包含了用户光标位置的最小一个的语法节点,这种情况就不需要我们给出任何提示。
Visitor 模式提供的搜索仍然是深度优先的搜索,不过我们可以通过实现 visitXX 方法,来规定 XX 节点应该怎么继续向下搜索。形象地说,我们的搜索过程,就是在搜索一个节点时,如果这个节点是终结符节点,我们直接返回。如果不是,我们去看他所有的子节点,如果光标位置位于某个子节点的范围内,我们就继续搜索这个子节点,否则,我们返回当前的节点。我们将向下搜索的逻辑抽象成一个公共的方法如下:
private getParseTreeIndex(node: ParseTree) {
let start, end
// the children of a ParserRuleContext are instances of either ParserRuleContext or TerminalNode
if (node instanceof ParserRuleContext) {
start = node.start.startIndex
end = node.stop.stopIndex
}
if (node instanceof TerminalNode ) {
start = node.symbol.startIndex
end = node.symbol.stopIndex
}
return [ start, end ]
}
private visitContext(context: ParserRuleContext): ParseTree[] {
for (let i = 0; i < context.childCount; i++) {
const child = context.getChild(i)
const [ start, end ] = this.getParseTreeIndex(child)
if (start <= this.target && this.target <= end) {
return [context, ...this.visit(child)]
}
}
return [context]
}
复制代码
在 visitor 模式中,ParserRuleContext 记录着当前节点的相关信息,包括这个节点的起始位置,TerminalNode 和 PrserRuleContext 一样,不过他表示的是终结符节点,这类节点没有子节点了。而上述代码中的 ParseTree 实际上就是这两者公共部分的抽象。在有了这个方法后,我们便可以为我们的 visitor 实现相关的接口了,我们核心要实现的 findContext 方法,该方法接受光标位置和 Parse Tree,返回光标所在位置的全部上下文信息。
大部分需要我们关注的节点都可以直接调用 visitContext,同时,对 AST 而言相当于是终结符的节点,我们直接返回该节点的信息,完整的搜索完整上下文的代码如下:
import { ConstantAtomContext, ConstantContext, ExpressionContext, FunctionAtomContext, FunctionContext, NumberAtomContext, ParamContext, StartContext, StringContext, VariableAtomContext } from './FormulaParser'
import { FormulaVisitor } from './FormulaVisitor'
import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor'
import { TerminalNode } from 'antlr4ts/tree/TerminalNode';
import { ParseTree } from 'antlr4ts/tree/ParseTree';
import { ParserRuleContext } from 'antlr4ts';
export class FormulaContextVisitor extends AbstractParseTreeVisitor<ParseTree[]> implements FormulaVisitor<ParseTree[]> {
private target: number findContext(index: number, tree: ParseTree) {
this.target = index return this.visit(tree)
}
defaultResult() {
return []
}
visitStart(context: StartContext) {
// 忽略 EOF,直接从 expression 开始分析
return this.visitContext(context.expression())
}
visitExpression(context: ExpressionContext) {
// expression <- atom <- terminal 这样的 parse tree 节点比较冗余,在语法树中这种情况只保留 terminal 节点即可
// 其中 atom 我们没有记录,expression 在只有一个词法符号时直接遍历其子节点,不额外创建 expression 节点
if (context.sourceInterval.length === 1) {
return this.visitChildren(context)
}
return this.visitContext(context)
}
visitConstant(context: ConstantContext) {
return this.visitContext(context)
}
visitFunction(context: FunctionContext) {
return this.visitContext(context)
}
visitString(context: StringContext) {
return [context]
}
visitTerminal(node: TerminalNode) {
return [node]
}
visitParam(context: ParamContext) {
return this.visitContext(context)
}
visitNumberAtom(context: NumberAtomContext) {
return [context]
}
visitVariableAtom(context: VariableAtomContext) {
return [context]
}
visitFunctionAtom(context: FunctionAtomContext) {
return this.visitContext(context)
}
visitConstantAtom(context: ConstantAtomContext) {
return [context]
}
private getParseTreeIndex(node: ParseTree) {
let start, end
// the children of a ParserRuleContext are instances of either ParserRuleContext or TerminalNode
if (node instanceof ParserRuleContext) {
start = node.start.startIndex
end = node.stop.stopIndex
}
if (node instanceof TerminalNode ) {
start = node.symbol.startIndex
end = node.symbol.stopIndex
}
return [ start, end ]
}
private visitContext(context: ParserRuleContext): ParseTree[] {
for (let i = 0; i < context.childCount; i++) {
const child = context.getChild(i)
const [ start, end ] = this.getParseTreeIndex(child)
if (start <= this.target && this.target <= end) {
return [context, ...this.visit(child)]
}
}
return [context]
}
}
复制代码
给出提示
现在我们已经获得了用户光标位置的全部上下文信息,剩下的就是我们需要针对上下文给出合适的提示。好在我们的公式语法很简单,并且和 Excel 一样,我们也预期所有可以使用的函数,变量都是预先定义好的。在真实的语言中,当前上下文可以访问哪些变量并不是一个纯静态的问题,而是由语言自身定义,受其「作用域」约束的动态的集合,这部分内容也并不是那么简单。并且,作用域也是类型系统基础,因为类型的可访问性和变量一样,都受作用域的约束。我们会在后续的文章中再详细介绍作用域解析的相关技术。而对于目前的公式语法,所有可选的函数的变量都是纯静态的。
给出提示的实现思路也很简单,我们假定公式可访问的函数和变量是定义好的(和 Excel 中一样),所以为了示例,我们直接把可访问的变量和函数定义作为配置写在代码中。在分析时,我们首先考虑当前光标位于什么节点上,然后沿着其上下文向上查找,根据不同的情况走出不同的提示。有如下场景需要我们给出提示:
为变量名给出提示
当我们在编辑一个表达式的因子时,因为这个因子可能是变量,也可能是某个返回 number 的函数,所以当用户在输入的是表达式的因子时,需要我们给出提示。这种情况下,可能是我们刚刚开始编写,例如我们只输入了一个「t」,或者是编写到中间,例如输入到「1 + t」,这些时候,我们就可以根据用户的已经输入的因子的内容(例如上述的「t」),然后去前缀匹配所有对于表达式因子,可供选择的输入(也就是预先定义的所有变量和返回值为 number 的函数),并返回成功匹配到的所有可选项。
为函数的参数给出提示
函数的参数核心需要知道当前光标是在函数的第几个参数上,然后根据函数定义获知参数类型,再根据类型做出提示。在我们的定义中,如果是字符串,我们没有给出提示,如果是枚举类型,我们前缀匹配用户输入,如果是一个表达式,我们复用为表达式给出提示的逻辑。
完整的给出补全建议的代码如下:
import { ParseTree } from "antlr4ts/tree/ParseTree"
import { FormulaLexer } from './FormulaLexer'
import { ConstantAtomContext, ExpressionContext, FunctionContext, NumberAtomContext, ParamContext, StringContext, VariableAtomContext } from './FormulaParser'
import { TerminalNode } from 'antlr4ts/tree/TerminalNode'
import { FormulaContextVisitor } from "./FormulaContextVisitor"
import { ParserRuleContext } from "antlr4ts"
/** 公式支持的类型 */
export enum VariableType {
NUMBER,
STRING,
VOID
}
/** 函数参数,string[] 代表枚举类型的参数 */
export type FunctionParamType = VariableType.NUMBER | VariableType.STRING | string[]
export interface IFunctionType {
name: string params: FunctionParamType[]
return: VariableType
}
export class Suggestion {
private tree: ParseTree
private variables: string[] = []
private funcs: IFunctionType[] = []
constructor(tree: ParseTree) {
this.tree = tree }
suggestion(index: number) {
const countFunctionsVisitor = new FormulaContextVisitor()
const result = countFunctionsVisitor.findContext(index, this.tree).reverse()
if (result.length <= 0 || !(this.isTernimal(result[0]) || this.isErrorNode(result[0]))) {
// 没有位于终结符或者某错误节点上,或者没有输入,不提示
return []
}
// 由于只有变量以及函数需要补全,涉及的决策树不大,决策我们直接手写
if (result[0] instanceof VariableAtomContext) {
if (!result[1] || result[1] instanceof ExpressionContext) {
// 仍然是 expression 上下文或者就没有上下文了,直接按 expression 给出提示
return this.expressSuggestion(result[0].text)
}
if (result[1] instanceof ParamContext && result[2] instanceof FunctionContext) {
// 确认是函数上下文
return this.functionSuggestion(result[2], result[0])
}
}else if (result[0] instanceof StringContext) {
// 字符串只有作为函数的参数时才尝试做提示
if (result[1] instanceof ParamContext && result[2] instanceof FunctionContext) {
// 确认是函数上下文
return this.functionSuggestion(result[2], result[0])
}
}else if (result[0] instanceof TerminalNode) {
// 在某一个终结符上时,有如下情况需要提示
// 光标位于「func()」的 ( 后,此时提示函数的第一个参数
if (result[1] instanceof FunctionContext) {
return this.functionSuggestion(result[1])
}
}
return []
}
functionSuggestion(func: FunctionContext, target?: ParseTree) {
// 函数上下文,需要根据函数名以及参数的位置确定需要补全的内容
// 函数的语法规则为: function: VARIABLE (param, params... ),通过下述方法获取函数名
const funcName = func.getToken(FormulaLexer.VARIABLE, 0).text
const funcType = this.funcs.find(item => item.name === funcName)
const funcParms = func.getRuleContexts(ParamContext)
if (target) {
let i = 0
for (; i < funcParms.length; i++) {
if (
funcParms[i].sourceInterval.a === target.sourceInterval.a
&& funcParms[i].sourceInterval.b === target.sourceInterval.b
) {
break
}
}
return this.paramsSuggestion(funcType.params[i], target.text)
}
// 没有 target 时,当前处于「func(」或者「func()」的情况,此时实际需要补全的是函数的第一个参数
return this.paramsSuggestion(funcType.params[0])
}
paramsSuggestion(param: FunctionParamType, input?: string,) {
if (param instanceof Array) {
// 去掉 string token 收尾的引号然后再做匹配
return param.filter(item => input ? item.startsWith(input.replace(/^['"]/, '').replace(/['"]$/, '')) : true)
}
if (param === VariableType.NUMBER) {
return this.expressSuggestion().filter(item => input ? item.startsWith(input.replace(/^['"]/, '').replace(/['"]$/, '')) : true)
}
return []
}
/** 表达式的提示,包含所有变量以及返回值是 number 的函数 */
expressSuggestion(input?: string) {
return [
// 所有变量的提示
...this.variables,
// 所有返回值为 number 的函数名提示
...this.funcs.filter(item => item.return === VariableType.NUMBER).map(item => item.name)
// 按照当前已经输入的值进行过滤
].filter(item => input ? item.startsWith(input.replace(/^['"]/, '').replace(/['"]$/, '')) : true)
}
/** 设置可选用变量 */
setvariable(vars: string[]) {
this.variables = vars }
/** 设置可选用函数 */
setFuncs(funcs: IFunctionType[]) {
this.funcs = funcs }
/** 是否是终结符节点,我们的语法树不完全和 parse tree 相同,在遍历语法树时直接返回节点的都是我们的终结符 */
isTernimal(node: ParseTree) {
return [
TerminalNode,
StringContext,
NumberAtomContext,
ConstantAtomContext,
VariableAtomContext
].some(Fn => node instanceof Fn)
}
/** 是否是但词法符号删除或者补全出现的错误节点 */
isErrorNode(node: ParseTree) {
return node instanceof ParserRuleContext && !!node.exception
}
}
复制代码
最后,我们可以尝试一下我们的自动补全的程序,可以通过下述代码进行测试:
import { ANTLRInputStream, CommonTokenStream } from 'antlr4ts';
import { Suggestion } from './Suggestion';
import { FormulaLexer } from './FormulaLexer'
import { FormulaParser} from './FormulaParser'
// 当前输入的内容
const input = "te / 3"
// Create the lexer and parser
let inputStream = new ANTLRInputStream(input);
let lexer = new FormulaLexer(inputStream);
let tokenStream = new CommonTokenStream(lexer);
let parser = new FormulaParser(tokenStream);
let tree = parser.start();
const suggestion = new Suggestion(tree)
// 设置环境中存在的变量和函数
suggestion.setvariable(['ptest1', 'ptest2'])
suggestion.setFuncs([{
name: 'testFun1',
params: [VariableType.NUMBER, VariableType.STRING],
return: VariableType.NUMBER
}, {
name: 'testFun2',
params: [VariableType.NUMBER, ['p1', 'p2', 'q2']],
return: VariableType.NUMBER
}, {
name: 'testFun3',
params: [],
return: VariableType.STRING
}])
// 光标位置的提示,预期输出所有 te 前缀的变量以及返回值是 number 的 te 前缀的函数
// 输出:[ 'testFun1', 'testFun2' ]
console.log(suggestion.suggestion(1))
复制代码
至此,基础的自动补全就已经完成实现了,我们能够基于用户的输入,以及光标的位置,按我们预先配置好的函数和变量名,给出合理的提示。由于 Excel 的公式语法在语法上简单,并且需要提示的函数、变量都是静态的,这给了我们一个可以把全部精力放在自动补全这一件事之上的场景,现实语言中的场景需要作用域,需要类型系统,光标位置的节点也需要做区分,在想要完成自动补全之前,还需要很多的前置知识。但是,自动补全的思路始终是一致的,都是根据光标位置的上下文信息,再根据上下文中可访问且符合光标位置节点要求的所有元素,提供自动补全建议。
写在最后
在本文实现自动补全中,对于正常的输入,我们都能够给到合理的提示,但是在用户编辑的过程中,不是每时每刻,用户的输入都是语法完整的,在一些时刻,就是存在不是语法完整的输入,却需要我们正常给出提示的。最常见的是当我们在输入 「obj.」的时候,我们就预期 IDE 能给我们提示 obj 的所有属性和方法。在公式语法中也是一样,在我们输入 「1 + 」时,我们预期后面应该可以是一个变量或者数字,这意味着这时候我们应该需要提示所有变量以及所有返回值为 number 的函数。那么这种语法不完整的情况,又需要我们如何处理呢?另外,Excel 中在公式输入完时,如果存在语法错误,Excel 会尝试帮你修复错误,它会提供一个正确的输入,并询问你是否接受更正。他是如何做到自动帮我们修复语法错误呢?
错误容忍也是自动补全的一部分,否则自动补全用起来会非常不智能,关于错误容忍这部分内容,我们将会在下一篇文章中继续讨论。在错误处理介绍完后,自动补全本身所需要的就已经完备了,可以在 Github 上访问到 Excel 公式语法自动补全的全部最终实现。另外现实中某一项具体语言的自动补全还会涉及很多前置的知识(主要就是作用域和类型系统),这些内容相对较多,也比较独立,后续我们会通过一系列的文章对这些内容进行介绍。