09|LSP、诊断与重构
补全可以减少输入量,LSP 则负责理解代码之间的关系。它知道 add_task 在哪里定义、哪些文件调用了 visible_titles、某个表达式为什么类型不对,也能安全地在整个项目中完成重命名。
这一章继续使用 pocket-tasks。所有故意制造的错误都会在章末清理,函数名也会改回第 07 章的基线,第 10 章可以直接开始测试循环。
本章结束时,你会掌握
- 判断 LSP 是否已经附着当前 Buffer;
- 查看悬浮信息,跳到定义,再回到原来的位置;
- 把所有引用加入 Quickfix 逐个检查;
- 在诊断之间前后跳转,并读懂当前位置的完整报错;
- 用语义重命名跨文件修改符号;
- 打开代码动作 Picker,选择服务器提供的修复或整理操作。
本章按工作流分批增加按键。先查看总览,练习时每次只记两三个:
| 按键 | 效果 |
|---|---|
Space l h | 显示光标下符号的悬浮信息 |
Space l g d | 跳到定义 |
Ctrl-o | 沿跳转历史后退一步 |
Space l g r | 列出所有引用并打开 Quickfix |
Space l e | 打开光标处诊断浮窗 |
Space l g n / Space l g p | 下一条 / 上一条诊断,并自动展示诊断浮窗 |
Space l n | 语义重命名 |
Space l a | 打开当前位置的代码动作 |
这些都是当前锁定版 nvf 最终生成的 Buffer 局部映射。只有 LSP 成功附着当前 Buffer 后,它们才会安装并可用。
1. 开始前检查:确认 BasedPyright 已附着
本项目的 pyproject.toml 应包含:
[project]
name = "pocket-tasks"
version = "0.1.0"
requires-python = ">=3.11"
[tool.basedpyright]
extraPaths = ["src"]
typeCheckingMode = "standard"pyproject.toml 同时给 BasedPyright 提供项目根目录和 src 导入路径。打开 Python 文件后,语言服务器会异步启动,通常只要一小会儿。
用 Which-key 看附着结果
- 按
Space ,,输入cli,选中cli.py后按Enter。 - 等一两秒。
- 普通模式按
Space,稍停一下。 - 继续按
l。
预期界面:Which-key 展示 h、g、n、a 等 LSP 后续键。

按 Esc 收起菜单。
若 Space l 下没有这些项目:
- 确认当前文件是已保存的
.py文件; - 确认 Neovim 从
pocket-tasks项目目录启动; - 确认项目根目录存在上面的
pyproject.toml; - 等两秒,再切到另一个 Buffer 后切回来;
- 仍无结果时,用
F1→wqa→Enter→Enter保存并退出,再从项目根目录重新执行nvim .。
LSP 尚未附着时,下面的按键可能没有映射,也可能没有任何结果。应先解决附着条件,再继续练习;重复按键不会解决连接问题。
2. 工作流一:悬浮查看、跳定义、原路返回
这一轮只记三个动作:
Space l h 查看
Space l g d 跳转
Ctrl-o 返回查看 add_task 的类型
- 在
cli.py普通模式输入/tasks = add_task,按Enter。

- 用
w或l把光标准确移到add_task的字母上。 - 依次按
Space、l、h。
预期界面:光标附近出现浮窗,内容大致包含函数参数与返回类型:

add_task(tasks: list[Task], title: str) -> list[Task]移动光标后,浮窗通常会自动收起。悬浮信息适合快速确认一个符号接收哪些参数、返回什么类型,以及是否带有文档说明。
跳到真实定义
- 再把光标放到
add_task上。 - 依次按
Space、l、g、d。
预期结果:当前 Window 切到 src/pocket_tasks/service.py,光标落在 def add_task(...) 附近。这个符号只有一个定义,所以 LSP 会直接跳过去。

- 按
Ctrl-o。
预期结果:回到 cli.py 的调用位置。Ctrl-o 用来沿跳转历史后退,查看完定义后按一下,就能继续阅读原来的代码。

若 Space l g d 提示 No locations found:
- 确认光标在名称字母上,没有停在括号或逗号;
- 检查名称拼写;
- 确认 LSP 已附着;
- 动态生成的对象可能没有可追踪的定义,这时可以用
Space /搜索相关文字。
定义有多个时会发生什么
若语言服务器返回一个位置,Neovim 直接跳转;返回多个位置时,它会打开底部列表供你选择。列表由 Quicker 美化,j / k 选行,Enter 跳到高亮位置。
3. 工作流二:列出一个符号的所有引用
跳定义回答“它从哪里来”,查引用回答“谁在使用它”。这一轮只新增:
Space l g r查 visible_titles 的调用点
按
Space ,,输入service,选中service.py后按Enter。输入
/def visible_titles,按Enter。把光标放在
visible_titles名称上。

- 依次按
Space、l、g、r。
预期界面:底部打开 Quickfix,至少会看到这些位置:
service.py中的定义;tests/test_service.py中的导入和调用;cli.py中的导入和调用。

当前 Neovim 的引用请求会把声明位置也放进结果。Quicker 会按文件与行号显示列表,体验与第 06 章的 grep 结果很接近。
浏览引用列表
- 用
j/k移动高亮行。 - 在
tests/test_service.py的结果上按Enter。 - 当前代码 Window 跳到对应引用,Quickfix 仍可继续使用。
继续在 Quickfix 中移动到测试调用项并按 Enter,可以直接核对断言位置。

- 检查完后按
F1,输入cclose。

- 连按两次
Enter执行命令。

cclose 只关闭 Quickfix Window,里面列出的源码 Buffer 会继续留在内存中。
如果出现 No references found,先把光标移回完整的符号名称。字符串里的同名文字通常不会被算作代码引用,因此语义查询得到的结果比全文搜索更准确;如果还要搜索注释和文档,再使用 Space /。
4. 工作流三:制造两条诊断,再逐条修掉
诊断是语言服务器给出的错误、警告、信息与提示。当前配置会在编辑区显示高亮或符号,状态栏也可能显示数量。
这一轮只记:
Space l e 读当前诊断
Space l g n 下一条诊断
Space l g p 上一条诊断在 cli.py 加两个临时错误
按
Space ,,输入cli,选中cli.py后按Enter。输入
/def main,按Enter。按
O,在main()上方进入插入模式。输入下面两个临时函数:
def render_count(count: int) -> str:
return count
def first_title(tasks: list[Task]) -> str:
return tasks[0].missing_title 
- 按
Esc,等一两秒。

预期结果:
return count附近出现类型错误,因为函数承诺返回str,实际给出int;missing_title附近出现成员错误,因为Task只有title和done。
BasedPyright 的措辞可能随版本有细微变化,错误核心应与上面一致。
在诊断之间移动
- 光标放在文件上方,依次按
Space、l、g、n。 - 光标跳到第一条诊断,旁边自动出现该诊断的浮窗。

- 再按一次
Space l g n,去下一条。

- 按
Space l g p,回上一条。
这两个映射在跳转成功后会自动调用诊断浮窗,所以“去下一条”和“读下一条”一次完成。
单独重看光标处诊断
- 把光标放在
count或missing_title的高亮范围内。 - 按
Space l e。
预期界面:光标附近出现完整消息、严重级别与诊断来源。浮窗会显示行内无法完整呈现的诊断信息。
Space l e 显示 No diagnostics found
- 光标可能停在同一行的其他位置,移到波浪线范围再试;
- 服务器可能仍在分析,稍等片刻;
- 错误可能已经被修复,因此当前位置不再有诊断。
修复两条错误
先把错误的返回值改成 str(count):

再把不存在的 missing_title 改回真实字段 title。两处修复的差异如下:
- return count
+ return str(count)
- return tasks[0].missing_title
+ return tasks[0].title修复后的完整代码应为:
def render_count(count: int) -> str:
return str(count)
def first_title(tasks: list[Task]) -> str:
return tasks[0].title等待一小会儿,两个诊断应消失。再按 Space l g n 时,如果项目里没有其他问题,Neovim 会提示找不到下一条诊断。

清掉临时函数,恢复 CLI
输入
/def render_count,按Enter。按
V选中函数定义行,再按j把return行加入选区。

按
d删除选区。多余空行可以用
dd清理,顶层函数之间保留两行空白。输入
/def first_title,按Enter。同样按
V、j、d删除这个函数,再整理空行。
此时文件应重新从导入直接过渡到 def main()。

5. 工作流四:跨文件语义重命名,再改回来
全文替换会处理所有匹配文字。LSP 重命名按符号关系修改定义、导入与调用,无关字符串和普通说明文字通常会保留。
这一轮新增:
Space l n
Ctrl-u 清空重命名输入框里的旧名称把 visible_titles 改成 open_titles
按
Space ,,输入service,选中service.py后按Enter。输入
/def visible_titles,按Enter。把光标放在函数名的字母上。

- 依次按
Space、l、n。
预期界面:屏幕上方出现 Snacks 输入浮窗,标题类似 New Name,里面已有 visible_titles,光标位于旧名称末尾。

- 按住
Ctrl点一下u,清空旧名称。 - 输入
open_titles。

- 按
Enter确认。
预期结果:LSP 一次修改多个 Buffer:
service.py的函数定义变成open_titles;tests/test_service.py的导入和调用同步变化;cli.py的导入和调用同步变化。

这些变更此刻可能还在内存中,状态栏会提示文件已修改。先别保存,我们马上改回基线。
用引用查询验收改名
- 光标仍放在
open_titles上,按Space l g r。 - 在 Quickfix 中确认定义、测试和 CLI 都使用新名称。

- 按
F1,输入cclose,按两次Enter关闭列表。
若旧名称仍出现在注释或字符串里,这很正常;语义重命名关注代码符号。想检查所有文字,保存后再用 Space / 搜索。
把名称改回 visible_titles
回到
service.py的open_titles定义。按
Space l n。输入框出现旧名称后按
Ctrl-u清空。输入
visible_titles,按Enter。

- 用
Space l g r再检查一次引用,然后关闭 Quickfix。

这次往返练习用于确认语义重命名会影响哪些文件。以后修改公共 API 时,可以据此预估修改范围。
重命名只改到一个文件时
- 确认相关文件都属于同一个
pocket-tasksLSP 工作区; - 确认导入可以被 BasedPyright 解析;
- 确认
pyproject.toml中仍有extraPaths = ["src"]; - 先保存语法错误附近的文件,严重解析错误可能截断符号关系;
- 最后再用
Space /搜索旧名称,确认没有遗漏。
6. 工作流五:打开代码动作 Picker
代码动作由语言服务器根据当前光标位置和诊断动态提供,常见内容包括快速修复、整理导入、代码生成和重构。菜单内容会随文件状态变化;如果没有任何选项,说明服务器没有为当前位置提供可执行的动作。
用导入顺序练习
打开
cli.py。临时把前两行交换成:
from pocket_tasks.service import add_task, visible_titles
from pocket_tasks.model import Task 
- 按
Esc,把光标放在任意一条导入上。 - 依次按
Space、l、a。
若 BasedPyright 在当前位置提供 Organize Imports,Snacks 会打开选择器。它默认聚焦输入框:
- 直接输入
organize过滤; - 用
Ctrl-n/Ctrl-p或方向键选择; - 按一次
Enter确认代码动作。
这里是普通 Picker,一次回车就会确认。只有 F1 命令 Picker 需要第二次回车执行底部命令。
代码动作执行后,导入通常会恢复合理顺序。若屏幕提示 No code actions available,说明 BasedPyright 在这个光标位置没有返回动作;手动把导入恢复成下面的基线即可:

from pocket_tasks.model import Task
from pocket_tasks.service import add_task, visible_titles 
面对诊断时的代码动作习惯
以后看到诊断,可以按这个小循环:
Space l e读完整原因;Space l a看服务器有没有建议;- 阅读动作标题;
- 选择动作后检查实际改动;
- 没有合适动作就手动修改。
代码动作只是候选方案。执行后仍需检查实际修改;设计和行为是否正确仍由开发者判断。
7. 最终恢复与保存
先核对这四件事:
cli.py中已经没有render_count和first_title;- 服务函数最终名为
visible_titles; - 测试和 CLI 的导入、调用也使用
visible_titles; cli.py的导入顺序与第 07 章基线一致。
然后按 Esc 回普通模式,按 F1,输入 wall。

连按两次 Enter 保存全部 Buffer。

第一次回车把 :wall 放到底部命令行,第二次回车执行。保存后可以按 Space / 搜索 open_titles、render_count 和 missing_title;三次都应没有结果。
第 10 章将从下面这个状态继续:
Task(title, done),数据类使用frozen=True;service.py提供add_task、complete_task、visible_titles;tests/test_service.py有三条unittest测试;cli.py可以调用服务并打印可见任务。
本章肌肉记忆
| 目标 | 按键 |
|---|---|
| 查看悬浮信息 | Space l h |
| 跳到定义 / 返回 | Space l g d / Ctrl-o |
| 列出引用 | Space l g r |
| 当前诊断 | Space l e |
| 下一条 / 上一条诊断 | Space l g n / Space l g p |
| 跨文件重命名 | Space l n,输入框中 Ctrl-u 清旧名 |
| 代码动作 | Space l a |
| 保存所有修改 | F1 → wall → Enter → Enter |
上一章:补全与 Copilot · 下一章:右侧终端、测试循环与手动格式化