From e696a68485364c1ab95273881c51a1f49798bd2c Mon Sep 17 00:00:00 2001 From: imbant <13634731015@sina.cn> Date: Tue, 14 Jan 2025 16:38:05 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E7=AB=A0=E5=88=9D=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_drafts/lsp3\350\215\211\347\250\277.md" | 43 +++++ source/_posts/LSP1.md | 8 +- source/_posts/LSP2.md | 158 +++++++++++++++--- 3 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 "source/_drafts/lsp3\350\215\211\347\250\277.md" diff --git "a/source/_drafts/lsp3\350\215\211\347\250\277.md" "b/source/_drafts/lsp3\350\215\211\347\250\277.md" new file mode 100644 index 00000000..1bfd1f81 --- /dev/null +++ "b/source/_drafts/lsp3\350\215\211\347\250\277.md" @@ -0,0 +1,43 @@ +如果想入门 LSP,查官方文档是必不可少的:microsoft.github.io + +但是语言服务非常重交互,只靠文字描述是很单薄的,初学者只看干巴巴的接口定义肯定会头晕,不知道这些接口都能干什么。不过也不怪 LSP 官方,毕竟只负责设计协议,具体的实现还得靠客户端(各大代码编辑器)。 + +这时候就推荐 VS Code 的这篇文档了,列出了插件 API 和 LSP 的对应关系,毕竟和 LSP 一样都是微软家的,很多概念和术语都相通:code.visualstudio.com + +文档里详细列出了 VS Code 支持的语言特性,并且都配上了动图和聊胜于无的文字说明。要说为什么是聊胜于无,还是因为交互太重了,各个功能需要多写 demo 去体验,意会。 + +从学习和方便调试的角度,可以尝试先用 VS Code API 实现一些功能,翻翻 API 的文档,然后再换成 LSP 实现,两者的文档交叉比对,能更好的体会“设计”和“实现”的差异,更好的理解 LSP。 + +--- + +在 VS Code 中的语言服务插件,指的是能给一种语言提供高亮、补全、诊断,悬浮提示,查看定义等等功能的插件。 + +实现这些功能有两种方式。 +一种是在插件进程内,调用 vscode 这个 npm 包的 API。插件是一个 node 进程,也是 Electron 进程(具体是主进程,还是渲染进程,还不确定)的子进程。 +另一种是再新开一个进程,作为语言服务器,和插件(客户端)通过 LSP(Language Server Protocol) 通信。 + +是的,这些功能都是插件作者一行一行的代码写出来的。在开发插件之前,我完全没有料到在编辑器里 console 后输一个点,要经历这么复杂的代码后,才会弹出 log,warn 和 error。 + +两种方案有什么区别? +vscode API。文档全面,教程详细。可以快速实现小功能。适合快速实现轻量化语言服务,例如 json,markdown 甚至 css 这种非编程语言。或者快速验证想法。 +缺点是只能使用 js(ts) 实现,而且业务功能和插件底层在一个进程会导致性能问题,不适合计算密集型的功能。提供语言服务和编译器的区别之一就是需要处理用户输入,要求写完代码后能快速更新语法树,然后基于语法树提供语言服务。例如给一个 1000 行的 js 代码生成 AST,然后提供语义高亮。这时候插件更新慢了,插件和 vs code 的通信就会慢,提供语言服务的速度也会慢。 + +语言服务器。实现语言没有限制。与 vs code 解耦,只要是实现了 LSP 的客户端都可以通信,服务更多的编辑器。并且可以起多个进程(多个实例),一起给客户端服务,解放客户端进程的算力,客户端只需要转发其他进程算出来的数据给 vs code 即可。 +缺点是 LSP 有一定上手门槛,需要额外处理进程通信,架构复杂后,调试和开发都没那么轻便了。如果你还是用 node,微软官方提供了 vscode-languageserver 这个 npm 包,但是我都用它写完语义高亮、悬浮提示这些功能了,还是不知道这个包应该怎么用。比起 vs code API,它的文档太少了,示例代码也很少,相关讨论也很少,最要命的是 ChatGPT 也不太懂这个包怎么用,看来是能训练的资料确实太少了… + +文档缺失倒逼我去翻 GitHub,推荐一下 Volar.js 的源码,用 node 实现,实现的功能都很丰富,我就是抄它的配置,才做出语义高亮的… + +--- + +插件进程主要的功能应该是和 VS Code 主体通信。就像 Electron 主进程一样,应该避免执行很重的任务。VS Code 通知插件,现在需要一个自动补全的列表,虽然插件可以返回一个 promise 异步处理,但如果等太久,VS Code 就不要这个结果了(不提供补全)。这也是 VS Code 的用户交互指南之一,插件不能影响 UI 响应速度 + +--- + +插件本体、语言服务器、代码编辑器这三者分别是什么关系? +插件本体就是个转发层,代码编辑器是真正发请求的,语言服务器是真正处理请求的。 +他们在三个进程,分别是 VS Code 的 extension host 进程、VS Code 的渲染进程、语言服务器进程。后两者通过 LSP 通信,前两者通过 Electron IPC 通信(TODO: 存疑)。 + +--- + +拿自动补全举例,在 console 后输入一个点,这时候语法结构是不完整的,有错的,怎么根据不完整的语法结构和当前光标的位置,推断出当前上下文适用哪些补全,是很难的。 +现在理解尤雨溪为什么资助 Volar 作者全职开发这个插件了,它就是 DX 的基础之一,没有这种插件,开发者写 .vue 文件就和白纸写代码一样,是完全写不了的 diff --git a/source/_posts/LSP1.md b/source/_posts/LSP1.md index 4addf503..d3dbfb9f 100644 --- a/source/_posts/LSP1.md +++ b/source/_posts/LSP1.md @@ -1,5 +1,5 @@ --- -title: LSP 与 VS Code 插件开发 第一章 +title: LSP 与 VS Code 插件开发(一)语言服务器架构 date: 2024-8-24 tags: [LSP, VS Code, 语言服务器] --- @@ -84,9 +84,9 @@ Vue 插件做了哪些事情,才让你的编程体验变得如此美好呢? - 查看启动性能:VS Code 指令 `Show Running Extensions` 可以列出插件的启动时间,也有 Profile 能力,用来查看运行缓慢的插件。 - 二分法检查问题:当 VS Code 用起来总是有问题,还有个[二分查找法](https://code.visualstudio.com/blogs/2021/02/16/extension-bisect)功能,用来关闭一半的插件,看看问题是否解决。持续二分,直到找到问题插件。 -#### 相关文档 + ## 语言服务器 —— 服务任何代码编辑器 @@ -133,4 +133,4 @@ Vue 插件做了哪些事情,才让你的编程体验变得如此美好呢? 我在播客 [`Web Worker`](https://www.xiaoyuzhoufm.com/episode/66a1197533ddcbb53cd7a063) 上和几位 Vue 生态的大佬、团队成员们聊过 Vue 插件,欢迎收听。 -我也会在[即刻](https://okjk.co/OUqto1)分享语言服务器相关的开发心得,计划将它们整理成系列文章,欢迎关注。 \ No newline at end of file +我也会在[即刻](https://okjk.co/OUqto1)分享语言服务器相关的开发心得,计划将它们整理成系列文章,欢迎关注。 diff --git a/source/_posts/LSP2.md b/source/_posts/LSP2.md index 872df6c4..24759b35 100644 --- a/source/_posts/LSP2.md +++ b/source/_posts/LSP2.md @@ -1,14 +1,14 @@ --- -title: LSP 与 VS Code 插件开发 第二章 +title: LSP 与 VS Code 插件开发(二)语义构建 date: 2024-12-31 tags: [LSP, VS Code, 语言服务器] --- 上一章我们讲到,语言服务器的输入是源码,而输出是结构化的数据。代码编辑器(客户端)某个位置显示什么颜色,鼠标悬浮到某个位置提示什么信息,都由客户端向语言服务器请求,获取数据后,渲染到用户界面。 -因此语言服务器需要编译源码,构建语义模型,以为客户端提供*智能编程服务*。 +因此语言服务器需要编译源码,构建语义模型,为客户端提供*智能编程服务*。 -所谓的 `编译` 是怎么回事?它和更常见的编译器是什么关系?本章会和大学里的编译原理知识有些关系,但保证比课本上的更有趣、更好玩! +所谓的 `编译` 是怎么回事?它和编译器是什么关系?本章会和大学里的编译原理知识有些关系,但保证比课本上的更有趣、更好玩! ## 语言服务器与编译器的关系 @@ -18,9 +18,9 @@ tags: [LSP, VS Code, 语言服务器] ### 前端相似 具体各流程的作用就不赘述了,大学课本里那些长篇大论介绍 LL(1) 文法过于乏味。 -我们基本可以把编译器的工作分为`前端`、(`中端`)、`后端`几个流程,先说结论,编译器和语言服务器在编译的过程中,`前端`的过程是非常相似的。也就是 `词法分析`、`语法分析`、`语义分析` 几个阶段。 +我们基本可以把编译器的工作分为`前端`、(`中端`)、`后端`几个流程,事实上,编译器和语言服务器在编译的过程中,`前端`的过程是非常相似的。也就是 `词法分析`、`语法分析`、`语义分析` 几个阶段。 -通过这三个阶段,从源码中获取语义信息后,语言服务器等待客户端请求,为编程体验服务,而编译器则是继续中端、后端,为生成目标代码服务。在这之后,两种做的事情就大相径庭了。 +通过这三个阶段,从源码中获取语义信息后,语言服务器等待客户端请求,为编程体验服务,而编译器则是继续中端、后端,为生成目标产物服务。在这之后,两种做的事情就大相径庭了。 换句话说,对于 `console.log` 这一行代码,两者都经历了这样的阶段: @@ -36,11 +36,11 @@ tags: [LSP, VS Code, 语言服务器] 接下来,有了语义信息后,语言服务器就可以: 1. 前 7 个字母涂成红色;后三个字母涂成蓝色 `console.log` -> console.log -2. 鼠标悬停到后三个字母的位置,提示 `Prints to stdout with newline. ` +2. 鼠标悬停到后三个字母的位置,提示 `Prints to stdout with newline.` 3. 调用此函数时,校验实际参数的数量、类型是否与函数签名相符、返回值类型是否正确 4. ... -而这些都是编译器无法做到的事情。 +而这些都是和编译器完全无关的事情。 ![](https://imbant-blog.oss-cn-shanghai.aliyuncs.com/blog-img/lsp-vscode/consolelog.png) @@ -58,6 +58,8 @@ tags: [LSP, VS Code, 语言服务器] 总的来说,编译器面向运行时一次性执行,要求最终正确性和可执行性,而语言服务器在用户编码时持续服务,要求及时性和友好性。 +这里可以看到,语言服务器的工作和运行时是无关的。换句话说,使用记事本写的 Hello World,经过编译器编译后,同样可以执行,而语言服务器提供的只是更好的编码体验。 + ### 使用同一种语言构建 上一章提到,语言服务器与编译器往往是同一种编程语言构建的程序。现在你应该更理解了,在编译原理前端,两者的逻辑高度相似,往后才开始异化。使用同一种语言,甚至在同一个工程中,能最大的复用代码,减少维护成本。这也是促成语言服务器架构的原因之一。 @@ -67,9 +69,9 @@ tags: [LSP, VS Code, 语言服务器] ![](https://imbant-blog.oss-cn-shanghai.aliyuncs.com/blog-img/lsp-vscode/vscode-php.png) -## 语法错误与语义错误 +## 深入理解语法分析与语义分析 -刚才提到了语法分析和语义分析,这里从诊断的角度再详细说明下。 +刚才提到了语法分析和语义分析,这里再举几个例子详细说明下。 ### 十六进制颜色字面量 @@ -93,11 +95,43 @@ tags: [LSP, VS Code, 语言服务器] #ffffffffffffffffff ``` +### 类型推导和显式类型声明 + +再举一个变量定义的例子 + +```go +var a = 5 +``` + +`a` 的值是 `5`,类型是`整数`。这里没有显式的声明 `a` 的类型,因此是通过等号右边的表达式的类型推导出来的。 +右值是一个字面量,从词法分析可以通过正则匹配之类的方法得知它是一个 token,比如叫 `INT_DIGIT`。接着语法分析时,得知 `INT_DIGIT` 就是一个普通的字面量表达式。然后语义分析时,就知道一个整数字面量表达式的类型是`整数`,这是编程语言运行时就定义好的,无需额外的语法声明的(`typeof 5 == "int"`)。 +与之对应的,如果给 a 加了显式的类型声明: + +```go +var a int = 5 +``` + +那么语法上就是通过等号左边的 `int` token,在语义上查找符号表得知是`整数`类型。接着和右值类型比较,发现是一致的,这样就通过了类型检查。 + +不论是类型推导或者显示声明类型,`a` 的类型都是基于词法分析后根据语法结构决定的。前者是分析了字面量值 `5` 的语义,为`整数`;后者是分析了类型符号 `int` 的语义,同样是`整数`。这两个过程都是基于语法分析,判断语义。 + ### 语法错误更为严重 -语法错误的影响更严重,例如少了半个括号,会影响后续也许是整个文件的代码的作用域。相比来说,语义错误更易于容错,蝴蝶效应较小。 +语法错误的影响更严重,例如少了半个括号,会影响后续也许是整个文件的代码的作用域。相比来说,语义错误更可控,能尽量避免蝴蝶效应。 + +例如这样的 Go 代码,`bar` 是一个键为 `int`,值为 `string` 的字典。 + +![](https://imbant-blog.oss-cn-shanghai.aliyuncs.com/blog-img/lsp-vscode/gostringint.png) + +现在有两个典型的语义错误,分别在第 11 行,尝试用整数字面量赋值给 `string` 类型;和 13 行,尝试将 `string` 值赋值给 `int` 类型。 + +现在我们制造一个语法错误,删掉第 9 行的一个右括号 `]`,这显然是一个简单的编码失误,但语言服务器不干了,没有正确的语法支持,很难继续构建语义,第 11 行、第 13 行的红色波浪线也消失了。 + +![](https://imbant-blog.oss-cn-shanghai.aliyuncs.com/blog-img/lsp-vscode/gointstring2.png) + +一个简单的语法错误,导致了整个文件的语义分析失败,这是语法错误的严重性。而且报出的错误信息也只能基于语法,和代码改动毫无关系,这简直是新手的噩梦。 - +> 顺便提一句,其实有经验的开发者一眼就能看出问题所在,但而基于编译原理的解析程序则难以做到这一点。我认为这也许会是大语言模型的一个应用场景,结合代码编辑器,帮助代码初学者快速定位语法错误。 ### 语法错误更快出现 @@ -107,24 +141,100 @@ tags: [LSP, VS Code, 语言服务器] 例如,有些编程语言里支持“严格模式”,用来增强类型检查、避免隐式错误等。它在语法上可能就是一行字符串字面量或者魔法注释,例如 `"use strict"` 或者 `// @ts-check`。它们自身没有语法错误,但是会影响后续成千上万行代码的语义分析,带来数个语义错误。 ---- +这里有一个提高语言服务器性能的方法: +将语言服务器拆分成两个,一个面向语法,一个面向语义。 +语法服务器很轻量,只负责词法分析、语法分析,有语法错误时立即反馈诊断。另外也可以提供一些完全基于语法的,和语义无关的小功能,例如 +[显示颜色](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_documentColor)。 +语义服务器更重,负责全部的语义构建和服务。 +将语法诊断和其他小功能从语义服务器中剥离,可以降低语义服务器的开销,也能让轻量的功能更快的反馈给用户。 + +Vue 的[语言服务器](https://marketplace.visualstudio.com/items?itemName=Vue.volar)就是这么做的。 + +## 语义模型 + +这里展示一下我做的基于 Node 的语言服务器,编译这段代码后得到的语义模型 + +```go +graph test { + func A() { + for i = 0, 10, 1 { + if true { + LogInfo(i) + } + } + var a = 1 + while true { + a++ + } + } + + func B() { + + } +} +``` + +![](https://imbant-blog.oss-cn-shanghai.aliyuncs.com/blog-img/lsp-vscode/lastmodel.png) + +这是一门自研的 DSL 语言,其具体的语义就不做讨论了,但可以看到,在 Node 内存中,我的语义模型是一个大的对象,`graph`、`func`、`for`、`if`、`while` 关键字分别开辟了新的作用域,它们层层嵌套,形成一颗树结构。 +既然是树结构,深度优先遍历算法就是可行的。第 4 行的 `if` 语句,要从根节点开始经过 `root` -> `func A` -> `for` -> `if` 几个语法节点,才能访问的到。 +鼠标移动到第 5 行的 `LogInfo` 上,就要经过上述的深度优先搜索,根据鼠标位置的行列号,一层一层查找语义模型,得知此位置的语义是一个函数名,悬浮提示出函数签名,按 F12 还能跳转到函数声明的地方。 +这个在树结构上层层查找的过程,有点类似语法分析中由上至下的递归下降分析法,从非终结符开始,递归展开,直到终结符;或者反过来,从终结符开始,递归折叠,直到非终结符。但两者还是有本质区别的,语法分析是为了构建语法树,而语义分析是基于语法树,构建语义模型。只不过从实现上,我的语义模型也是一个树结构的,毕竟是基于语法,两者的树结构也是相似的。 -草稿 +### shadow 机制 + +有了这个形象的树形结构,我们就可以从新的角度理解编程语言中的 `shadow` 机制。 +`shadow` 机制通常指在当前作用域和外层作用域中,同名变量的优先级问题。 + +```go +func A() { + var foo = 0 + while (true) { + var foo = 1 + if true { + var foo = 2 + + foo += 10 + } + } +} +``` + +第 8 行的 `foo` 变量最终的值会是 12,也就是改变了第 6 行的 `foo`,即使外层还有两个同名变量。 +在语义分析阶段,第 8 行是一个加法赋值语句,右值是 10,左值是一个符号 x。为了找到这个符号的语义,语言服务器会就近当前位置的语义模型的作用域开始寻找,也就是 `if` 开辟的作用域中,发现符号表中有一个 `foo`,它的语义是 `int` 类型的变量。这样,第 8 行的 `foo` 符号就是一个变量的语义了,也许会变成 variable 的蓝色;而它的定义位置,根据符号表,得知在第 6 行,按 F12 跳转到定义,光标就会跳转到第 6 行。 +另外,得知第 8 行 `foo` 的语义后,还会做一系列的语义检查,常见的编程语言可能会检查: + +1. `foo` 是可写的局部变量,可以被赋值 +2. `foo` 是个整数,可以做加法赋值 +3. 右值是个整数字面量,和左值的类型相同,可以赋值 +4. ... + +`shadow` 机制对于自动补全也有积极意义:在 if 代码块中,输入 `fo`,语言服务器应该仅列出一个 `foo` 变量,忽略外层的两个。让我们给最外层的 `foo` 改名叫 `foo001` + +```go +func A() { + var foo001 = 0 + while (true) { + var foo = 1 + if true { + var foo = 2 + + foo += 10 + } + } +} +``` -## Volar、Vue 和 Astro +这时候就会补全 if 中的 `foo` 和外层的 `foo001`,而不会再出现 while 中的 `foo`。 -## 如何调试 ts 语言服务器 +![](https://imbant-blog.oss-cn-shanghai.aliyuncs.com/blog-img/lsp-vscode/fooshadowcompletion.png) -- 打开 log -- TODO: 见语雀 +## 总结 -## 编译:从文本到结构化数据 +现在,我们明白了语言服务器想要提供智能编程服务,首先需要编译,构建语义模型,这个过程和编译器前端很相似。有了语义模型基础,语言服务器要做的就是接收客户端请求,返回数据。两者通信的过程中,使用的协议就是 LSP(语言服务器协议)。它定义了哪些智能编程能力,通信的具体内容是什么?我们下一章继续。 -- 词法分析、语法分析工具 -- 语义分析 -- 从语义转为智能编程 +## 更多资料 -## LSP 实践 +我在播客 [`Web Worker`](https://www.xiaoyuzhoufm.com/episode/66a1197533ddcbb53cd7a063) 上和几位 Vue 生态的大佬、团队成员们聊过 Vue 插件,欢迎收听。 -- 如何读 LSP 文档 -- 具体的智能编程功能的实现和踩坑 +我也会在[即刻](https://okjk.co/OUqto1)分享语言服务器相关的开发心得,计划将它们整理成系列文章,欢迎关注。