IDE中的魔法 - 如何为存在语法错误的场景提供自动补全

473 阅读10分钟

本文收录于专栏文章「IDE中的魔法」,希望读者能够通过这系列文章,对 IDE 相关技术的实现有一定的认知,同时,通过对语言进行静态分析,能够从编译器的视角,审视语言特性,帮助大家在了解 IDE 的同时,也能更深入的了解语言本身。

IDE 相比于文本编辑器,一个最重要的差别就是 IDE 提供了智能的自动补全提示,这让我们平时编辑代码事半功倍,也让我们不必去背记相关的属性方法。而自动补全的智能与否,还需要解决一个重要的问题:语法不完整的场景。回忆平日里我们使用 IDE,当我们输入「obj.」时,IDE 便会提示我们「obj」的属性和方法,这里的问题就在于,「obj.」并不是符合语法的输入,很明显我们直接编译这一段代码是会报错的,那么这种情况,我们是如何做到正常给出提示的呢?

首先,我们需要在这种情况能够得到包含错误信息的 AST,如果语法分析直接就报错退出,后续的语义分析也无从谈起。语法分析对错误的处理可以回阅 IDE 中的魔法 - 如何实现一个 parser 中对于 Error Recovery 的介绍。在获得包含错误信息的 AST 之后,我们便可以开始这一段静态分析之旅。

自动补全 - 从通用场景到错误场景

IDE 中的魔法 - 如何实现一个 parser 中,我们已经介绍了自动补全的实现原理,虽然当时的实现是完全忽略错误的情况下的实现,但自动补全的总体思路始终不变,仍然如下图所示:

由于之前已经实现了正常的情况下的自动补全,现在我们的焦点将全部集中于错误的情况。在开始之前,有必要再次说明一下在前文已经强调过的观点:错误场景的自动补全是没有银弹的。因为任意输入(只要不符合我们的语法)都属于错误范围,但不管是程序还是实际的用户,都不能预期任意输入的情况下,我们需要提示什么。因此对于错误场景的处理,基本都需要 case by case 地处理。

虽然错误场景没有银弹,但处理起来,却又有一些通用思路,首先仍然是要确定用户正在关注的节点,这个节点是否是表示错误的节点。然后检索这个节点对应的上下文,最后根据上下文判断当前需要给出什么样的提示。在我们的公式语法中,我们希望在对于「exp +」、「func(」以及「func(1,」这样类型的输入,我们能够正常有效的给出提示。现在,我们就沿着自动补全的通用思路,去实现对语法错误的容忍,让我们的自动补全,能够更加智能。

用户在关注着一个包含错误的语法节点吗?

图灵奖获得者 Niklaus Wirth 曾总结过:程序 = 数据结构 + 算法。在开始处理错误之前,我们很有必要先看一下包含错误节点的 AST 的数据结构,下图是 「te / 」所生成的 AST 的结构。

相比于正常情况下的 AST,这里有一个重要的区别在于,单词法符号补全所生成的节点是没有 index 的(这也很好理解,因为这个节点本就不存在于用户输入流中),而这会带来一个严重的问题:我们原先按照光标位置进行搜索是不会搜索到这种没有 index 的节点的。由于光标位于「/」之后,实际整个 expression 「te/」节点的 index 都没有包含光标的位置,这种情况我们要如何检索到存在错误的节点?

我们先来看一下我们的输入 「te/」,虽然它语法不完整,但实际上,「te」和「/」都是正确完成规约的节点,单词法符号补全的节点实际上紧跟着一个正确的节点「/」,这给了我们一个启示,「/」节点实际上可以代替没有 index 的错误节点,作为我们的搜索目标

那么如何从上往下检索呢?我们可以观察「/」的全部上下文「start」和「expression」,发现虽然他们都不包含光标位置,但是光标位置实际上紧跟着他们也就是说光标位于当前语法节点结束位置到下一个语法节点开始位置的中间。 我们根据这些要点,可以简单总结能够搜索到错误节点的语义分析需要做的事情:

  1. 优先处理光标落在正常节点上的情况。
  1. 光标不在某个正常节点上时,我们搜索的目标节点是光标位置的前一个节点
  1. 在我们搜索到目标节点后,如果这个节点本身就可以给出提示(比如光标前就是一个变量了),我们直接给提示。
  1. 如果该节点是一些诸如「+」、「-」这样的操作符,我们根据上下文,特别是操作符的后文,视情况决定是否给出提示。

可以将上述总结为一个流程图如下:

然后依据流程图,我们改造一下之前搜索节点的实现如下:

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 isErrorNode(node: ParseTree) {
    return node instanceof ErrorNode ||  (node instanceof ParserRuleContext && node.exception)
  }

  /** 向下搜索光标位置所对应的节点 */
  private visitContext(context: ParserRuleContext): ParseTree[] {
    let lastNode: ParseTree | null = null
    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)]
      }
      // 光标不落在一个正常的节点上时,搜索光标位置前一个正常节点
      if (start > this.target) {
        return [context, ...this.visit(lastNode)]
      } else if (!this.isErrorNode(child)){
        lastNode = child
      }
    }
    if (!lastNode) return [context]
    if (lastNode instanceof TerminalNode) return [context, lastNode]
    return [context, ...this.visit(lastNode)]
  }

可以看到,在正常的搜索路径不匹配时,我们记录当前非错误节点为 lastNode,直到我们找到一个节点的开始位置超过光标位置,我们就继续搜索前一个非错误节点。同时,如果光标本身就在所有节点之后,我们也会继续搜索最后一个非错误节点。

至此,通过上述搜索模式,如果光标在某一节点上,我们正常搜索到该节点,如果不在,我们也能搜索到当前光标前一个节点,这样,即使用户输入存在错误,我们也能够先聚焦于用户当前关注的节点的位置。在这之后,便可以对所有我们考虑到的情况,给出合适的提示。

对错误情况给出合适的提示

IDE中的魔法 - 自动补全如何实现 中,我们已经对正确的情况给出了提示,由于公式语法比较简单,正确的情况涉及的场景也比较少,我们就针对几个场景直接分配了提示。在加入错误的情况下,场景开始变多起来,我们先简单罗列一下,需要补全的上下文的情况。

搜索到的节点后文上下文提示
变量-expression
变量-expressionexpression
变量-function函数参数
字符串-function函数参数
终结符光标位于终结符之后expressionexpression
终结符终结符是 (function函数的第一个参数
终结符光标位于终结符之后且后文是 Param (即终结符是 ,)function函数的下一个参数
function (特殊情况「fun(」)-function函数的第一个参数

也可以看到实际上我们做补全的场景并不是特别多,我们将上述需要补全的情景简单用代码实现出来:

suggestion(index: number) {
    const countFunctionsVisitor = new FormulaContextVisitor()
    const result = countFunctionsVisitor.findContext(index, this.tree).reverse()

    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 FunctionContext) {
      // 「fun(」的输入在 ANTLR 中会处理成两个连续的错误节点,此时我们只会搜索到一个没有下文的 function context
      return this.functionSuggestion(result[0])
    } else if (result[0] instanceof TerminalNode) {
      if (result[1] && result[1] instanceof FunctionContext) {
        // 函数上下文中,返回了一个终结符
        const nextNode = this.findSibling(result[1], result[0])
        if (result[0].text === '(') {
          // 光标位于「func()」的 ( 后,此时提示函数的第一个参数
          return this.functionSuggestion(result[1])
        }
        if (result[0].symbol.stopIndex < index && nextNode instanceof ParamContext) {
          // 光标位于终结符后,且后文补全了一个参数节点,提示函数的下一个参数
          return this.functionSuggestion(result[1], nextNode)
        }
        
      } else if (result[1] && result[1] instanceof ExpressionContext) {
        // 表达式上下文中,返回了一个终结符
        if(result[0].symbol.stopIndex < index && this.isErrorNode(this.findSibling(result[1], result[0]))) {
          // 终结符后紧跟一个错误节点,按表达式的模式提示
          return this.expressSuggestion()
        }
      }
    }
    return []
  }
  /** 是否是单词法符号删除或者补全出现的错误节点 */
  isErrorNode(node: ParseTree) {
    return (node instanceof ParserRuleContext && !!node.exception) || node instanceof ErrorNode
  }

  findSibling(parent: ParserRuleContext, node: ParseTree) {
    let i = 0
    for (; i < parent.childCount; i++) {
      if (node === parent.getChild(i)) {
        break
      }
    }
    return parent.getChild(Math.min(parent.childCount - 1, i + 1))
  }

现在,我们可以测试一下对于错误场景是否如我们的预期,可以参考下述测试用例,完整的测试用例可以在 Github 中查看:

it('incomplete expression should autocomplete if there is extra space', () => {
    const input = "te / "
    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'])
    const result = suggestion.suggestion(5)
    expect(result.sort()).toEqual(['ptest1', 'ptest2'].sort())
  })

写在最后

至关于公式语法的自动补全,我们至此已完全实现,完整的实现可以在 Github 查看。我们从词法分析和语法分析出发,构造了能够解析公式语法的 parser,然后又基于我们的 parser,实现了自动补全,同时为了自动补全能够更加智能,我们仔细处理了一些语法不完整的场景,这些场景我们认为是用户编写代码时,所必经的场景,这些场景,也需要给出合适的提示。

如果你想完整回顾公式语法相关的实现,可以通过下述链接访问:

IDE中的魔法 - 语法分析器生成工具 ANTLR

IDE 中的魔法 - 如何实现一个 parser

IDE中的魔法 - 自动补全如何实现

在完结公式语法之后,我们开始把目光投向更复杂的语言,他们可以定义变量,定义函数,所需要提示的内容不再如同公式语法一样,是提前写死的内容。他们的变量有严格的类型,函数的参数和返回值也是,当我们的光标放到一个标识符(Identify)上时,我们便能看到这个标识符对应的类型。一个具体的标识符是什么类型,怎么定义的,如何查找,这些统称为 Identify resolution。 Identify resolution 和类型系统是相辅相成的,类型系统依赖 Identify resolution 确定变量是如何定义的, Identify resolution 依赖类型系统确定 Identify 的类型(如果 obj.property 中,解析 property 就需要 obj 的类型)。

在 Identify resolution 和类型系统之前,他们的共同基础都是作用域,作用域决定了变量和类型的可访问性,当我们 hoving 任意变量时,我们需要按照作用域规则解析,才能知道这个变量是在哪定义的。后续我们将正式进入真实语言的环境,去了解作用域是如何解析的,以及类型系统是如何构逐步建起来的。

欢迎大家关注我的专栏 IDE中的魔法 ,欢迎分享建议、看法。