我们很高兴地宣布,文档内链(intra-doc links)即将稳定!
文档内链是 rustdoc
的一个特性,它允许你通过名称链接到“项”(函数、类型等),而不是使用硬编码的 URL。即使你的类型在不同的模块或 crate 中被重新导出,这也能确保链接的准确性。下面是一个简单的例子
/// Link to [`f()`]
;
文档内链已经存在一段时间了,可以追溯到2017年!它们已经在 nightly
版本中可用,无需特殊标志(因此在 docs.rs 上也可用),所以你可能会惊讶地听到它们尚未稳定。现在正在改变的是,它们将在稳定版 Rust 中可用,这也意味着我们对实现更有信心,并强烈鼓励大家使用它们。我们建议你的库切换到使用文档内链,这将修复重新导出类型的断开链接以及指向不同 crate 的链接。我们希望未来能够通过cargo fix
来自动化这个过程。
文档内链的历史
我(Manish)和 QuietMisdreavus 在2017年12月开始着手开发文档内链。Mozilla 在发布 Firefox Quantum 后给了全公司几周假期,当时我正在孟买探亲。这意味着我有很多空闲时间,而我们处于完全相反的时区。QuietMisdreavus 已经在这个特性上工作了一段时间,但对 rustc 的路径解析代码不太熟悉,所以我决定提供帮助。我们在那几周里进行了结对编程:白天我写一些代码,晚上与 QuietMisdreavus 讨论,然后交给她在夜间继续。这是一次很棒的经历,在开源项目里结对编程真的很有趣!最终,我们两人的提交合并成一个包含46个提交的拉取请求。
不幸的是,当时我们未能稳定这个特性。主要的障碍是跨 crate 的重新导出,比如以下情况
// Crate `inner`
/// Link to [`f()`]
;
// outer crate
pub use S;
`rustdoc` 处理重新导出的方式是,它会在原地渲染重新导出,解析并渲染所有的 markdown。这里的问题在于,当文档化 `outer` 时,`rustdoc` 无法访问 `inner::S` 的本地作用域信息,因此无法解析 `f()`。
这些链接是文档内链最初的动机,所以如果无法让它们工作,稳定化就没有多大意义了!它们还有一个缺点,就是可能静默地失效——当你构建文档时它能工作,但你的 API 的任何用户都可以重新导出你的类型,从而导致链接失效。
当时,为了持久化本地作用域信息,以便下游 crate 对 `rustdoc` 的调用可以访问,需要在编译器上做大量工作。这是编译器团队无论如何都希望完成的工作,但这工作量很大,我们俩都没有足够的精力来完成,所以我们提交了一个 bug,然后各自继续其他工作了。
发生了什么变化?
六月初,我(Jynn)厌倦了无法使用文档内链。我开始调查这个问题,看看是否有修复方法。它被标记为`E-hard`(困难),所以我没指望出现奇迹,但我觉得至少可以开始着手解决它。
事实证明,实现中有一个简单的问题——它假定所有项都在当前 crate 中!显然,情况并非总是如此。修复方案原来很简单,我作为对 rustdoc 的第一次贡献就实现了它。
Manish 的注:实际上,当我们编写这个特性时,DefId
和 LocalDefId
之间的区别并不存在,代码只根据解析器的当前内部作用域来解析路径(这只能在当前 crate 内,因为当时解析器只有这个作用域信息)。然而,随着时间的推移,编译器获得了存储和查询依赖项解析作用域的能力。我们从未注意到这一点,并一直认为有一项大量工作在阻碍稳定化。
然而,我的解决方案有一个小问题:在某些精心构造的输入上,它会崩溃
thread 'rustc' panicked at 'called `Option::unwrap()` on a `None` value', /home/jyn/src/rust/src/librustc_hir/definitions.rs:358:9
HirIds、DefIds 和树,天哪!
(如果你对 Rust 编译器的内部机制不感兴趣,可以跳过本节。)
上面的错误是由一个叫做everybody_loops
的编译器“pass”(处理阶段)引起的。编译器的“pass”是对源代码的转换,例如查找没有文档的项。everybody_loops
pass 将上面的代码转换成
作为我解决跨 crate 项的更改的一部分,我需要知道第一个父模块,以便判断哪些项在作用域内。然而请注意,在 everybody_loops
处理后,闭包消失了!崩溃是因为 rustdoc
试图访问一个 rustc
认为不存在的闭包(用编译器行话来说,它是将闭包的 DefId
,它在 crate 之间通用,转换成了 HirId
,后者特定于当前 crate 但包含更多信息)。
为什么这很难?
这原来是一个巨大的“兔子洞”(复杂问题)。everybody_loops
早在2017年就被引入,目的是解决另一个长期存在的问题:rustdoc
不知道如何处理条件编译。它允许 rustdoc(以及标准库)忽略函数体中的类型和名称错误。这使得在同一主机上可以同时文档化 Linux 和 Windows API,尽管它们的实现通常是无法编译的。如上所示,它的工作原理是将每个函数体转换为 loop {}
——这总是有效的,因为 loop {}
的类型是 !
,它可以强制转换为任何类型!
正如我们上面看到的,这种转换破坏了 rustdoc。此外,它还导致了许多其他问题(链接指向的都是各种问题或相关的 PR)。
所以我把它移除了!这就是“不要运行 everybody_loops”的拉取请求。这是我提交给 rustc 的最大 PR,希望也是我提交的最后一个这么大的 PR。问题是 libstd 中的错误并没有消失——如果说有什么变化,那就是自2017年以来错误反而增加了。我提出的一个“权宜之计”(hack)是,与其运行类型检查并尝试将代码重写成有效形式,不如根本不在函数体中运行类型检查!这既减少了工作量,也更符合 rustdoc 的期望语义。特别是,它不会导致那些导致 rustdoc
崩溃的无效状态。
后果:好心没好报
在 PR 合并大约一个月后,rustdoc 收到了一个 bug 报告:async-std
的文档在 nightly 通道上构建失败了。他们的代码看起来有点像以下示例
use *;
use *;
async
特别注意,在 cfg(doc)
下,两个 trait 都处于作用域内,并且有相同的方法,因此对于 .foo()
来说,使用哪个 trait 是模糊不清的。这正是通过不运行类型检查来解决的问题。不幸的是,由于它用于 async fn
,类型检查仍然在运行;bar
会被解糖(desugars)为以下形式的闭包
因为函数返回 impl Future
,这需要对函数体进行类型检查以推断出函数的返回类型。这正是 rustdoc
不想做的事情!
实现的临时性“修复”是根本不推断函数的类型——rustdoc 不关心确切的类型,只关心它实现的 trait。这只是一个临时方案(hack),因此有一个issue 专门用于修复它。
稳定化文档内链
既然跨 crate 的重新导出已经工作了,文档内链的稳定化就没有太多障碍了!还有一些清理的 PR,但总体而言,通往稳定化的道路似乎已经明朗。
与此同时,我一直在改进文档内链的各个方面
特别是,有很多人站出来帮助将标准库转换为文档内链。在此非常感谢 @camelid、@denisvasilik、@poliorcetics、@nixphix、@EllenNyan、@kolfs、@LeSeulArtichaut、@Amjad50 和 @GuillaumeGomez 的所有帮助!