scheme-langserver

Scheme-langserver 开发指南

本手册旨在向开发者说明如何为 Scheme-langserver 添加新语言支持,通过 top-environment 支持特定的语言环境(如 R6/7RSS7)。其中 R6/7RS 是两种标准,S7则是一种R7RS实现。这些语言环境决定了词法、句法、语法层面的若干解析规则,也决定了什么样的标准库将被加载。特别的,Scheme 语言环境往往通过宏确定用户声明变量、引用库的方式,因此开发者扩展对 top-environment 的支持,将有相当大的自由度添加新的标准、方言、实现。这对熟悉 REPL(Read-Evaluate-Print Loop)的用户来说,可以类比为一个配置当前 REPL 环境的开关,它的作用是告诉 Scheme-langserver 当前项目应该使用哪种 Scheme 语言环境来解析代码、加载库和匹配规则。在 REPL 中,用户输入的表达式会根据当前环境加载对应的库和规则进行解析和执行。同样,top-environment 就是 Scheme-langserver 的“环境配置”,它确保语言服务器在处理代码时,能够正确地模拟出一个符合指定标准的上下文。

阅读本手册,您可以对这些新语言支持实现 goto-defninitionauto-complete 等功能,并掌握对整个生命周期的测试方法。这些内容涉及的代码结构如下:

util/protocol/ 文件夹下的代码与本文核心内容关系较远,仅在有必要时略加引用及介绍。

top-environment 初始化

本阶段将从workspace.sls文件的init-workspace函数开始,它整个的目的是解析代码(analysis/tokenizer.sls)并建立一个索引,然后为所有的代码文件设定一个类似初始化 REPL 的环境。这其中有一些关窍,即LSP协议的核心在于客户端将随时更新这些代码,而索引不得不随之发生变动,并且关联工作区的变动。因此,开发者将看到相关代码冗长的分成了若干个分支函数而并不能立刻在本手册中找到对应的章节。作者在这里请求诸位保持一些耐心,并且把注意力集中在一些重点的段落。

建立索引:init-virtual-file-systemtokenizer.sls

开发者第一个需要对workspace.sls搜索的函数是init-virtual-file-system,它是init-workspace对文件系统的抽象函数。论文 对抽象的过程进行了详细的介绍,不过本手册提醒开发者注意其参数top-environment,这个参数传递将在init-document函数中结束:在此之前,virtual-file-system/file-node.sls相关的代码将文件系统的细节隔离在外,本手册后文基本不会再见到它;而top-environment最终成为了virtual-file-system/document.sls中的数据结构的一个参数,并且决定一些事情。不过在此之前,请读者先注意到函数init-index-node:它并没有top-environment,它调用了tokenizer.sls中的source-file->annotations获得了Chez Scheme内置的代码索引结构annotation,并调用virtual-file-system/index-node.sls在外面包裹了一层index-node数据结构。可以说,index-node就是代码索引的本体,但它相当程度上屈从于客观的 Chez Scheme 的一些限制,即 Chez Scheme 服从R6RS标准,这导致R7RS标准的词法、句法定义存在相当程度的冲突。Scheme-langserver 不得不服从这些限制的动因是性能,一些可能的替代品可能导致2-3倍的性能开销。

为了处理这些冲突,tokenizer.sls采用“降级”的方法进行了处理,Chez Scheme抛出的内置异常将导致一些字符在document.sls层面被替换为空格。因为替换的核心原则是不改变代码中其他结构的位置,这样LSP层面的交互将不受到影响。绕是如此,Scheme-langserver维护者们目前仅对对Chez Scheme经常抛出的异常进行了处理,用户仍然在各种奇怪的情况下引发奇怪的异常,继而导致Scheme-langserver崩溃。如果出现了这样的问题,请向开发者提交issue。

top-environment 初始化: meta.sls

init-document执行的末段,find-meta函数将处理top-environment的未竟事宜,也就是为每个document初始化 REPL 类似的环境。开发者可以看到 meta.slsfind-meta 函数是如何工作的:

  1. find-meta 函数内部通过 initialized? 标志检查是否已初始化类型表达式。如果未初始化,调用 init-type-expressions 函数完成初始化,并将 initialized? 设置为 #t
  2. find-meta 函数根据当前的 top-environment 和对应的 meta-lib 标识符选择不同的元库集合
  3. 最后,meta.sls中的信息将通过make-document进入document-ordered-reference-list结构。

要为新的 Scheme 标准添加支持,开发者需要进行如下修改:

通过以上步骤,可以在 meta.sls 中添加对新标准的支持,确保语言服务器能够正确解析新标准的库和语法规则。

document-ordered-reference-listreference.sls

请开发者在reference.sls中搜索find-available-references-for代码,其中有这么一段:

(if (null? (index-node-parent current-index-node))
  (private-binary-search (document-ordered-reference-list document) identifier current-exclude)
  (find-available-references-for document (index-node-parent current-index-node) identifier current-exclude))

这段的意思是,当 index-node 中的信息不够充分,函数将从 document-ordered-reference-list 获得信息,这也是“初始化”的真正含义。而find-available-references-for将处理这些初始化以后的信息的可见性,也就是该函数的其他部分。这些内容本手册将在后文一一介绍。

txt-filter.sls: 目标文件筛选器

在开发新标准支持时,往往需要针对特定标准筛选和处理相关代码文件,以便测试和功能实现更具针对性。例如,在 r7rs 标准兼容性开发过程中,txt-filter.sls 文件充当了专用的文件过滤器。该模块通过检查文件路径的后缀名和目录属性,灵活决定哪些文件或目录应被加载和解析。为便于管理和测试,所有与 r7rs 相关的测试代码文件统一采用 .scm.txt 后缀。这样,在测试(如 tests/analysis/test-workspace.sps)中指定该文件过滤器后,Scheme-langserver 只会处理目标标准的相关文件,避免了无关文件的加载,提高了测试效率和开发灵活性。

通过灵活定义文件筛选规则,目标文件筛选器能够高效地识别和加载与当前标准相关的代码文件,避免无关文件的干扰,从而提升语言服务器的性能和解析效率。在开发新标准时,开发者可以根据实际需求自定义筛选器规则,例如指定特定的文件后缀、目录结构或命名约定,使测试和功能扩展更加便捷和有针对性。这种机制不仅优化了工作区的资源管理,也为后续标准的集成和维护提供了便利。

依赖解析

现在回到workspace.slsinit-virtual-file-system的部分已经做完了,要做的是init-library-nodeinit-file-linkage。它的作用是调用virtual-file-system/library-node.slsanalysis/dependency/file-linkage.sls初步建立代码文件之间的依赖关系,并保存在file-linkage.sls中的file-linkage-matrix中。

init-library-linkage识别代码文件的库声明

Scheme-langserver假设Scheme代码文件有两种,即一种是有库声明的,而另一种是没有的。它们都需要在init-library-node调用的get-library-identifier-list中处理。可以看到top-environment经传递到达了analysis/util.sls

  1. get-library-identifier-list 通过不同的 top-environment 选择不同的 match 语句。这一分支同时也对应不同标准定义库的语法不同。
  2. match 宏根据不同标准对库声明方法进行匹配并提取。如 r6rslibrary 作为库声明的关键字,因此使用 [('library (name **1) _ ... ) name]library 关键字后的标示符进行捕捉;

也就是说,开发者遵循本手册支持新的语言环境,需要首先修改这一部分。

init-file-linkage识别依赖关系

library-node经过识别完毕,init-file-linkage.sls将其之间的依赖关系识别为一个图并存储在file-linkage-matrix中。识别这种依赖关系的核心函数是get-imported-libraries-from-index-node,开发者可以在此看到top-environment的作用:

  1. get-imported-libraries-from-index-node 根据 top-environment 选择对应的 import 处理函数。
  2. import 处理函数在匹配到库声明关键字后,将关键字后续的节点传递给 match-import 函数进行处理。
  3. match-import 函数使用 ('import dummy **1 ) 形式的 match 语句,过滤所有 import 节点传递给 match-clause 函数处理。
  4. match-clause 函数最终使用一系列 match 宏来匹配诸如 only, renameimport 关键字,并最终返回 import 所对应的库声明。

当然这里要额外注意一点,就是这些代码之处理了各个代码文件中与最外层库声明嵌套一层的的import等结构,对于更深层次的嵌套需要等到abstract-interpreter.sls来处理。这再次实现了本手册关于“初始化”的解释,因为对于嵌套层次更深的代码,即便具有与importload等相同的宏名称或者函数名称,其语义可能受到上下文影响发生了变化,与top-environment的定义有所区别——这些区别恰恰是程序员开发的意义。find-available-references-for恰恰就是用来处理这些区别的。

file-linkage的真正作用

再次回到workspace.sls,在init-file-linkageget-init-reference-batches揭示了file-linkage的真正作用,即文件之间的依赖关系将决定后文抽象解释abstract-interpreter.sls的处理顺序:为了性能的因素,没有依赖关系的文件也采用并行化方法进行了处理。如果开发者和用户发现 Scheme-langserver 在自己的项目上表现不佳且没有很好的利用全部CPU性能,问题可能在于你没有良好的划分代码之间的依赖关系。

抽象解释

workspace.sls中真正的重头戏在于init-references函数,这里的references特指find-available-references-for中的含义,即references.sls中的identifier-reference结构,它支撑了 LSP 协议的全部功能:当本手册在说 goto-definition 功能的时候,本手册意思是通过find-available-references-for找到index-node对应的identifier-reference,并且通过其identifier-reference-initialization-index-node找到跳转目标;当本手册再说 auto-complete 功能的时候,本手册意思是通过find-available-references-for找到index-node可见的identifier-reference。而这些工作的基础是init-references

开发者同时可以注意到,top-environment到这里似乎已经结束了,它不再出现在init-references的参数列表里面。但是,top-environment的影响将无处不在。

step:开始

请开发者在 init-references 函数执行及它调用的函数的执行过程中搜索 step 函数,这个函数来自 abstract-interpreter.sls。它的全部逻辑是遍历index-node形成的树,并判断何种规则适用于处理当前的index-node。而“规则”直接由establish-available-rules-from生成:

  1. 它的输入参数有:root-file-node,表示当前正在分析的文件的根节点;root-library-node,表示当前文件所属的库节点;file-linkage,表示文件之间的依赖关系;current-document,表示当前正在处理的文档对象;current-index-node,表示当前正在处理的 AST 节点。。
  2. 通过一系列条件,如果它判断一个宏确实来自 top-environment,表达 top-environment确定的语义,比如define,的确是 R6/7RS 定义的 define,不是用户自己定义的,那么它将调用 analysis/identifier/rules/define.sls 进行处理,处理过程见下文;
  3. 它的规则的执行过程为:首先该函数检查当前节点的类型,并根据类型设置排除引用或调用子节点的递归分析;对于具有子节点的表达式,step 会通过 establish-available-rules-from 函数动态匹配规则,为语法解析、标识符解析和规则应用提供支持。

例:analysis\identifier\rules\define.sls 的处理过程

  1. 函数通过 match 宏对节点表达式进行模式匹配,例如 (_ ((? symbol? identifier) dummy0 ... ) dummy1 ... ),可用于识别如 (define (f a b c) ... ) 这样的函数定义形式。
  2. 匹配到目标元素后,函数会为 identifier 新建一个 identifier-reference,并标记其类型为 procedure。这正是 Scheme-langserver 实现 goto-definition 跳转目标的依据。
  3. 随后,函数利用 map 对参数名逐一新建 identifier-reference,类型标记为 parameter。在函数体内跳转时,参数的 goto-definition 操作将定位到对应的形参位置。

力有不逮:用户自定义宏

为了满足 Scheme-langserver 对用户自定义语法规则和宏扩展的支持需求,Scheme-langserver 引入了 self-defined-rules 模块。self-defined-rules 模块用于支持用户自定义的语法规则和宏扩展。它的主要作用是通过动态规则匹配和处理机制,为用户定义的 Scheme 语法提供解析、索引和语义分析支持,这使得开发者可以根据项目需求定义新的语法形式或扩展现有的语言特性。

另一个例子:self-defined-rules/goldfish/typed-lambda.sls

typed-lambda 是 Goldfish Scheme 独有的语法扩展。它允许在使用 lambda 创建过程时,将形参指定为一个列表,其中第二个元素为断言函数。这样,定义的过程在调用时会自动对传入参数进行断言检查,确保参数符合预期条件,从而提升代码的类型安全性和健壮性。该自定义宏的处理过程为:

  1. 函数通过 match 宏对节点表达式进行模式匹配,匹配模式为 (_ (identifier **1) fuzzy ... ),与 define 的处理方式类似。
  2. 对于每一个形参,使用 match 宏进行匹配:若形参为单个符号,则按常规处理;若为列表,则提取列表中的第一个元素作为实际的 identifier-reference。处理规则与 define 参数一致:为对应节点新建 identifier-reference,并将类型标记为 parameter

如何加入新标准

为了在 Scheme-langserver 中支持新的 Scheme 标准,需要对多个模块进行扩展和修改。首先,需要在 rules 目录中添加新的规则模块,以支持新标准的特定语法构造。现有的规则模块为不同的 Scheme 标准(如 R6RSR7RSS7)提供了独立的规则集,新标准也需要类似的模块化设计,以确保规则的清晰性和可维护性。

其次,需要扩展 analysis/abstract-interpreter.sls 中的 establish-available-rules-from 函数,为新标准的特定语法形式添加规则映射。这通常通过在函数中添加新的条件分支来实现,这些分支会检查当前的 top-environment 是否匹配新标准,并根据匹配结果加载对应的规则处理器。此外,还需要更新 private:top-env=? 函数及相关工具,使其能够识别新标准的标识符,从而在顶层环境检测系统中正确区分不同的语言标准。

类型推断

analysis/type 是 Scheme-langserver 中负责类型推断的模块,主要用于为 Scheme 代码提供静态类型分析支持。由于 Scheme 是动态类型语言,类型信息通常不会显式地出现在代码中,因此类型推断模块的作用是通过静态分析推导出代码中的类型信息,从而为代码补全、跳转定义和错误检查等功能提供支持。

类型推断模块的实现基于类型表达式和规则匹配,支持基本类型(如 number?boolean?)、复合类型(如 list?vector?)以及用户自定义类型。它通过遍历抽象语法树(AST),结合上下文信息和类型规则,推导出变量、函数参数和返回值的类型。

类型推断是 Scheme-langserver 的高级功能,本文档对其不作深入解释。开发者如需深入了解类型推断的实现细节,可参考 type 目录下的代码文件,或结合实际需求扩展类型推断规则,以支持新的 Scheme 标准或项目特定的类型需求。

测试

Scheme-langserver 使用 SRFI :64 testing 作为标准化测试框架,所有测试文件遵循统一的结构,并与主代码库的层级组织保持一致。通过 test.sh 脚本,测试框架能够自动发现和执行测试,同时部分测试因调试或性能原因被排除自动化运行。

在 Scheme-langserver 的测试框架中,所有测试文件都遵循严格的代码规范,以确保测试代码的可读性、一致性和可维护性。这些规范涵盖了文件头部标准、导入模式、命名约定以及测试组织方式。

每个测试文件都包含一个标准化的文件头部,所有测试文件统一导入 SRFI :64 测试库,这是 Scheme 的标准化测试框架。此外,测试文件还会根据需要导入项目的相关模块,例如 analysis/protocol/virtual-file-system/ 等模块。同时,测试文件和测试函数的命名遵循以下约定:测试文件的名称与被测试模块的名称保持一致。这种命名约定使得测试文件和测试函数的作用一目了然,便于开发者快速定位问题。

测试运行方法

Scheme-langserver 项目已集成自动化测试脚本 test.sh。开发者只需将自定义测试文件放入 tests 目录,test.sh 会自动遍历该目录,发现所有测试文件并依次执行。测试过程中,脚本会收集并汇总测试结果,报告每个测试文件的执行情况,包括通过、失败及错误信息。这样,开发者能够快速定位问题,确保新增功能或标准的兼容性和稳定性,无需手动管理测试流程。但注意运行测试脚本之前需要首先激活 akku 环境。

akku install
bash .akku/env
bash test.sh

日志调试工具

在 langserver 实际运行过程中,难免会遇到一些意外错误。为此,Scheme-langserver 提供了日志调试工具,帮助开发者读取日志文件并重现操作场景,便于调试和分析服务器行为。该工具能够解析日志内容,生成符合 LSP 协议的输入流,模拟客户端与服务器的交互过程,从而有效定位和解决问题。本小节简单介绍如何使用日志调试工具对日志进行调试,详细的内容请参考 doc/how-to-debug.md

开发者可以通过运行测试脚本(log-debug.sps)来使用日志调试工具。脚本会自动读取日志文件,生成输入流并初始化服务器实例。测试完成后,工具会输出测试结果,包括服务器的响应状态和日志重现的详细信息。

针对错误日志进行调试时,开发者首先需要确定自己的日志输出路径(可参考 VSCode 插件的相关选项配置),确保日志文件能够正确生成并保存于指定位置。当遇到错误或异常行为时,应将对应时间段的日志内容复制到 ready-for-analyse.log 文件中。随后,开发者可利用 Scheme-langserver 提供的日志调试工具,对该日志文件进行分析和重放。该工具会自动解析日志内容,模拟客户端与服务器的交互过程,帮助开发者反复重现错误场景。通过多次重现和分析,开发者能够精准定位问题根源,验证修复效果,并确保相关功能的稳定性和兼容性。这一流程极大提升了调试效率,降低了排查复杂问题的难度,是 Scheme-langserver 项目中推荐的标准调试方法。

通过以上测试框架和方法,Scheme-langserver 能够全面验证代码库的功能和稳定性,为开发者提供可靠的测试支持。开发者在开发新功能或扩展新标准时,应参考现有测试文件,及时补充针对新增模块、语法规则和边界情况的测试用例。完善的测试体系不仅有助于发现潜在问题,还能保障项目的长期可维护性和兼容性。

针对R7RSS7Goldfish的测试

在开发 R7RSS7Goldfish 标准兼容的过程中,我补充了大量测试文件,覆盖了新标准的初始化、库加载、依赖管理、宏扩展等核心功能。测试文件主要分布在 tests 目录下,针对各标准的特性分别编写,重点验证了新增模块和规则的正确性及稳定性。这些测试为新标准的集成和后续维护提供了可靠保障。

通过大量的测试补充,我验证了新增功能的正确性,确保了功能的稳定性和兼容性。这不仅验证了功能的实现效果,也为 Scheme-langserver 的持续开发和扩展打下了坚实的基础,为后续的维护和新标准支持提供了可靠保障。

总结

本手册详细介绍了 Scheme-langserver 的架构和扩展方法,帮助开发者理解如何添加新标准、优化现有功能,并确保代码库的稳定性。我们欢迎所有对 Scheme-langserver 感兴趣的开发者加入,共同完善这一项目,为 Scheme 社区贡献力量!

参考链接