响应式集成开发环境的三种体系结构

2020-07-21 11:39:19

防锈分析器是一个新的IDE后端,用于防锈编程语言。支持Open Collective上的防锈分析器。

在这篇文章中,我们将通过三种不同的方式学习如何制作一个快速的集成开发环境:-)它的灵感来自于这篇关于使用数据日志进行语义分析的优秀文章:https://petevilter.me/post/datalog-typechecking/The发布只描述了最高级别的架构。

具体地说,我们将查看IDE的主干基础结构,它服务于两个目标:

第一种架构让人想起Map-Reduce范例,其思想是将分析分成相对简单的索引阶段和单独的完整分析阶段。

索引的核心约束是基于每个文件运行。索引器获取单个文件的文本,对其进行解析,然后输出有关该文件的一些数据。索引器不能接触其他文件。

完全分析可以读取其他文件,并且它利用索引中的信息来保存工作。

这听起来太抽象了,所以让我们来看一个具体的示例 - Java。在Java语言中,每个文件都以包声明开始。索引器将包的名称与类名连接起来,以获得完全限定名(FQN)。它还收集类中声明的方法集、超类和接口的列表等。

将每个文件的数据合并到将FQN映射到类的索引中。请注意,构建此映射是一项令人尴尬的并行任务,因为所有文件都是独立解析的。此外,此映射的更新成本很低。当文件更改到达时,此文件的贡献将从索引中移除,文件文本也会更改,索引器将在新文本上运行并添加新贡献。要做的工作量与更改的文件数量成正比,并且与文件总数无关。

//File./mypackage/Foo.java包mypackage;import java.util.*;public class foo{public static Bar f(){return new Bar();}}//File./mypackage/Bar.java包mypackage;public class Bar{public void g(){}}//File./Main.java import mypackage.Foo;public class main{public static void main(string[]args){foo.。F()。}}。

用户刚刚输入了Foo.f().,我们需要计算出接收器表达式的类型是Bar,并建议使用g作为完成符。

首先,当文件Main.java被修改时,我们在这个文件上运行索引器,没有任何改变(该文件仍然包含带有静态Main方法的Main类),所以我们不需要更新FQN索引。

接下来,我们需要解析名称Foo。我们解析文件,注意导入并在FQN索引中查找mypackage.foo。在索引中,我们还发现foo有一个静态方法f,因此我们也解析了调用。索引还存储了f的返回类型,但是,这一点很重要,它将其存储为字符串,而不是直接引用类Bar。

原因是在Foo.java中导入java.util.*。Bar可以引用java.util.Bar或mypackage.Bar.索引器不知道是哪一个,因为它只能查看Foo.java的文本。换句话说,虽然索引确实存储了方法的返回类型,但它以未解析的形式存储它们。

下一步是解析Foo.java.Context中的IDENTIFIER Bar。这将使用FQN索引,并位于类mypackage.Bar中。在那里可以找到所需的方法g。

总共只有三个文件在完成期间被触及。FQN索引允许我们完全忽略项目中的所有其他文件。

到目前为止所描述的方法的一个问题是,从索引解析类型需要大量的工作。例如,如果多次调用foo.f,这项工作可能会重复。修复方法是添加一个缓存。名称解析结果会被记录下来,因此只需支付一次成本。使用索引的任何更改 - 都会完全清除缓存,重新构建缓存的成本并不是很高。

每个文件都独立且并行地进行索引,生成存根( - )一组可见的顶级声明,其中包含未解析的类型。

名称解析是惰性的(我们只在需要的时候解析存根中的类型),并且是有记忆的(每个类型只解析一次)。

如果编辑没有更改文件的存根,则不需要更改索引。

注意";哑索引和";智能缓存之间有趣的相互作用,前者可以递增更新,后者则从头开始重新计算。

这种方法结合了简单性和卓越的性能。大部分工作是索引阶段,您可以将其并行,甚至将其分布在多台机器上。此体系结构的两个示例是IntelliJ和Sorbet。

这种方法的主要缺点是,它只有在工作时才起作用, - 并不是每种语言都有定义良好的FQN概念。我认为总的来说,设计名称解析和模块系统(语言中的大多数令人厌烦的部分)以便它们能够很好地使用Map-Reduce范例是个好主意。

禁止添加新的顶级声明的元编程工具,或者限制它们的方式使它们可以被索引器使用。例如,一次访问单个文件的类似预处理器的编译器插件可能会很好。

确保每个源元素对应于单个语义元素。例如,如果语言支持条件编译,请确保它在名称解析期间(如Kotlin的Expect/Actual)工作,而不是在解析期间(如大多数其他语言中的条件编译)。否则,您必须用不同的条件编译设置来索引同一文件,这会很混乱。

最后一点值得详细说明。让我们看看下面的铁锈示例:

//File:./foo.rs特征T{fn f(&;self){}}//File:./bar.rs struct S;//File:./Where/ell.rs Impl T for S{}//File:./main.s use foo::t;use bar::s fn main(){let s=S;s.f();}。

在这里,我们可以很容易地找到S结构和T特征(因为它们是直接导入的)。但是,为了确保S.f确实引用T中的f,我们还需要找到相应的impl,它可以大致在任何地方!

该方法的思想很简单, - 只需使用传统编译器,并在导入每个编译单元后立即对其状态进行快照。例如:

在这里,编译器完全处理iostream(以及任何更多的头),对其状态进行快照,并自行解析程序。当用户键入更多字符时,编译器会从紧接在包含之后的位置重新启动。由于每个编译单元本身的大小通常是合理的,因此分析速度很快。

如果用户在头文件中键入内容,则需要使缓存无效。但是,对头文件的更改相对较少,大多数代码都位于.cpp文件中。

在某种意义上,标头对应于第一种方法的存根,有两个显著的区别:

是用户负责生成存根,而不是工具。

与存根不同,标头不能相互递归。存根存储未解析的类型,但可以在完成分析后创建Include快照。

这种方法的巨大好处是它允许重用现有的批处理编译器。本文描述的另外两种方法通常会导致编译器重写。缺点是几乎没有人喜欢头文件和转发声明。

请注意,这两种方法都不是以任何有趣的方式递增的。如果有什么变化,让我们完全清除缓存。在第一种方法中,索引更新中有一点点递增,但是 - 删除旧键,添加新键几乎是微不足道的。

这是因为让和集成开发变得快速的不是增量,而是懒惰(lastness - ),即完全跳过大量代码的能力。

使用map-duce,索引准确地告诉我们使用了当前文件中的哪一小部分文件,并且值得查看。Headers使我们免受大部分实现代码的影响。

#[MACRO_USE]外部箱架位标志;位标志!{struct标志:u32{const A=0b00000001;const B=0b00000010;const C=0b00000100;const ABC=self::a.bits|self::b.bits|self::c.bits;}}。

位标志是来自另一个板条箱并定义顶级声明的宏。我们不能将宏展开的结果放入索引中,因为它依赖于另一个文件中的宏定义。我们可以将宏调用自身放入索引中,但这大多是无用的,因为由宏声明的项将丢失索引。

模块foo和bar引用相同的文件foo.rs,这实际上意味着foo.rs中的项是重复的。如果foo.rs包含声明struct S;,那么foo::s和bar::s是不同类型的。您也不能将其放入索引中,因为那些mod声明位于不同的文件中。

第二种方法也不起作用。在C++中,编译单元是单个文件。在Rust中,编译单元是一个完整的机箱,它由许多文件组成,通常要大得多。而且Rust有过程性宏,这意味着即使是代码的表面分析也可能需要无限的时间。而且没有头文件,所以IDE必须处理整个机箱。此外,机箱内的名称解析要复杂得多(使用前声明和定点迭代与宏交织在一起)。此外,在Rust中,编译单元是一个单独的文件。此外,Rust中的编译单元是一个完整的机箱,它由许多文件组成,通常要大得多。此外,RUST有过程性宏,这意味着即使是代码的表面分析也会花费无限的时间,而且没有头文件,所以IDE必须处理整个机箱。

似乎纯粹基于懒惰的模型不适用于Rust,懒惰的最小可行单位,一个板条箱,仍然太大了。

为此,在锈蚀分析器中,我们采用了一种智能的解决方案,我们用增量来弥补懒惰的不足,具体地说,我们使用了一个通用的框架来增量计算 - SALSA。

SALSA背后的思想相当简单, - 编译器内的所有函数调用都会被检测,以记录在执行过程中调用了哪些其他函数。记录的跟踪用于实现细粒度增量。如果修改后所有依赖项的结果都相同,则重用旧结果。

如果函数由于依赖关系的改变而重新执行,还会有一个额外的、关键的扭曲 - ,新结果将与旧结果进行比较,如果尽管输入不同,但它们是相同的,则无效的传播将停止。

使用这个引擎,我们能够实现一种相当奇特的更新策略。与Map Reduce方法不同,我们的索引可以存储解析的类型,这些类型只有在顶级更改发生时才会失效。即使在顶级更改之后,我们也能够重用大多数宏展开的结果。在顶级宏中键入也不会使缓存失效,除非宏的展开引入了一组不同的项。

这种方法的主要好处是通用性和正确性。如果您可以使用增量计算引擎,那么尝试构建计算的方式就会变得相对容易。代码看起来大多像一个乏味的命令式编译器,并且您不会出现缓存无效错误(我们有一个,因为过程性宏是不确定的)。

主要缺点是额外的复杂性、较慢的性能(细粒度跟踪依赖项需要时间和内存),并且给人的感觉是这在某种程度上还是一个未知的领域:-)