Skip to content

Commit 5234021

Browse files
authored
Merge pull request #45 from ehuss/std-links
Support automatic links to the standard library.
2 parents 59b9647 + e34bf46 commit 5234021

File tree

4 files changed

+168
-22
lines changed

4 files changed

+168
-22
lines changed

docs/authoring.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ Rules can be linked to by their ID using markdown such as `[foo.bar]`. There are
2121

2222
In the HTML, the rules are clickable just like headers.
2323

24+
### Standard library links
25+
26+
You should link to the standard library without specifying a URL in a fashion similar to [rustdoc intra-doc links][intra]. Some examples:
27+
28+
```
29+
Link to Option is [`std::option::Option`]
30+
31+
You can include generics, they are ignored, like [`std::option::Option<T>`]
32+
33+
You can shorthand things if you don't want the full path in the text,
34+
like [`Option`](std::option::Option).
35+
36+
Macros can use `!`, which also works for disambiguation,
37+
like [`alloc::vec!`] is the macro, not the module.
38+
39+
Explicit namespace disambiguation is also supported, such as [`std::vec`](mod@std::vec).
40+
```
41+
42+
[intra]: https://doc.rust-lang.org/rustdoc/write-documentation/linking-to-items-by-name.html
43+
2444
### Admonitions
2545

2646
Admonitions use a style similar to GitHub-flavored markdown, where the style name is placed at the beginning of a blockquote, such as:

mdbook-spec/Cargo.lock

Lines changed: 5 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mdbook-spec/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ pathdiff = "0.2.1"
1212
regex = "1.10.3"
1313
semver = "1.0.21"
1414
serde_json = "1.0.113"
15+
tempfile = "3.10.1"

mdbook-spec/src/main.rs

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ use mdbook::BookItem;
55
use regex::{Captures, Regex};
66
use semver::{Version, VersionReq};
77
use std::collections::BTreeMap;
8-
use std::io;
8+
use std::fmt::Write as _;
9+
use std::fs;
10+
use std::io::{self, Write as _};
911
use std::path::PathBuf;
10-
use std::process;
12+
use std::process::{self, Command};
1113

1214
fn main() {
1315
let mut args = std::env::args().skip(1);
@@ -57,17 +59,38 @@ struct Spec {
5759
deny_warnings: bool,
5860
rule_re: Regex,
5961
admonition_re: Regex,
62+
std_link_re: Regex,
63+
std_link_extract_re: Regex,
6064
}
6165

6266
impl Spec {
6367
pub fn new() -> Spec {
68+
// This is roughly a rustdoc intra-doc link definition.
69+
let std_link = r"(?: [a-z]+@ )?
70+
(?: std|core|alloc|proc_macro|test )
71+
(?: ::[A-Za-z_!:<>{}()\[\]]+ )?";
6472
Spec {
6573
deny_warnings: std::env::var("SPEC_DENY_WARNINGS").as_deref() == Ok("1"),
6674
rule_re: Regex::new(r"(?m)^r\[([^]]+)]$").unwrap(),
6775
admonition_re: Regex::new(
6876
r"(?m)^ *> \[!(?<admon>[^]]+)\]\n(?<blockquote>(?: *> .*\n)+)",
6977
)
7078
.unwrap(),
79+
std_link_re: Regex::new(&format!(
80+
r"(?x)
81+
(?:
82+
( \[`[^`]+`\] ) \( ({std_link}) \)
83+
)
84+
| (?:
85+
( \[`{std_link}`\] )
86+
)
87+
"
88+
))
89+
.unwrap(),
90+
std_link_extract_re: Regex::new(
91+
r#"<li><a [^>]*href="(https://doc.rust-lang.org/[^"]+)""#,
92+
)
93+
.unwrap(),
7194
}
7295
}
7396

@@ -152,6 +175,122 @@ impl Spec {
152175
})
153176
.to_string()
154177
}
178+
179+
/// Converts links to the standard library to the online documentation in
180+
/// a fashion similar to rustdoc intra-doc links.
181+
fn std_links(&self, chapter: &Chapter) -> String {
182+
// This is very hacky, but should work well enough.
183+
//
184+
// Collect all standard library links.
185+
//
186+
// links are tuples of ("[`std::foo`]", None) for links without dest,
187+
// or ("[`foo`]", "std::foo") with a dest.
188+
let mut links: Vec<_> = self
189+
.std_link_re
190+
.captures_iter(&chapter.content)
191+
.map(|cap| {
192+
if let Some(no_dest) = cap.get(3) {
193+
(no_dest.as_str(), None)
194+
} else {
195+
(
196+
cap.get(1).unwrap().as_str(),
197+
Some(cap.get(2).unwrap().as_str()),
198+
)
199+
}
200+
})
201+
.collect();
202+
if links.is_empty() {
203+
return chapter.content.clone();
204+
}
205+
links.sort();
206+
links.dedup();
207+
208+
// Write a Rust source file to use with rustdoc to generate intra-doc links.
209+
let tmp = tempfile::TempDir::with_prefix("mdbook-spec-").unwrap();
210+
let src_path = tmp.path().join("a.rs");
211+
// Allow redundant since there could some in-scope things that are
212+
// technically not necessary, but we don't care about (like
213+
// [`Option`](std::option::Option)).
214+
let mut src = format!(
215+
"#![deny(rustdoc::broken_intra_doc_links)]\n\
216+
#![allow(rustdoc::redundant_explicit_links)]\n"
217+
);
218+
for (link, dest) in &links {
219+
write!(src, "//! - {link}").unwrap();
220+
if let Some(dest) = dest {
221+
write!(src, "({})", dest).unwrap();
222+
}
223+
src.push('\n');
224+
}
225+
writeln!(
226+
src,
227+
"extern crate alloc;\n\
228+
extern crate proc_macro;\n\
229+
extern crate test;\n"
230+
)
231+
.unwrap();
232+
fs::write(&src_path, &src).unwrap();
233+
let output = Command::new("rustdoc")
234+
.arg("--edition=2021")
235+
.arg(&src_path)
236+
.current_dir(tmp.path())
237+
.output()
238+
.expect("rustdoc installed");
239+
if !output.status.success() {
240+
eprintln!(
241+
"error: failed to extract std links ({:?}) in chapter {} ({:?})\n",
242+
output.status,
243+
chapter.name,
244+
chapter.source_path.as_ref().unwrap()
245+
);
246+
io::stderr().write_all(&output.stderr).unwrap();
247+
process::exit(1);
248+
}
249+
250+
// Extract the links from the generated html.
251+
let generated =
252+
fs::read_to_string(tmp.path().join("doc/a/index.html")).expect("index.html generated");
253+
let urls: Vec<_> = self
254+
.std_link_extract_re
255+
.captures_iter(&generated)
256+
.map(|cap| cap.get(1).unwrap().as_str())
257+
.collect();
258+
if urls.len() != links.len() {
259+
eprintln!(
260+
"error: expected rustdoc to generate {} links, but found {} in chapter {} ({:?})",
261+
links.len(),
262+
urls.len(),
263+
chapter.name,
264+
chapter.source_path.as_ref().unwrap()
265+
);
266+
process::exit(1);
267+
}
268+
269+
// Replace any disambiguated links with just the disambiguation.
270+
let mut output = self
271+
.std_link_re
272+
.replace_all(&chapter.content, |caps: &Captures| {
273+
if let Some(dest) = caps.get(2) {
274+
// Replace destination parenthesis with a link definition (square brackets).
275+
format!("{}[{}]", &caps[1], dest.as_str())
276+
} else {
277+
caps[0].to_string()
278+
}
279+
})
280+
.to_string();
281+
282+
// Append the link definitions to the bottom of the chapter.
283+
write!(output, "\n").unwrap();
284+
for ((link, dest), url) in links.iter().zip(urls) {
285+
if let Some(dest) = dest {
286+
write!(output, "[{dest}]: {url}\n").unwrap();
287+
} else {
288+
write!(output, "{link}: {url}\n").unwrap();
289+
}
290+
}
291+
292+
output
293+
}
155294
}
156295

157296
impl Preprocessor for Spec {
@@ -170,6 +309,7 @@ impl Preprocessor for Spec {
170309
}
171310
ch.content = self.rule_definitions(&ch, &mut found_rules);
172311
ch.content = self.admonitions(&ch);
312+
ch.content = self.std_links(&ch);
173313
}
174314
for section in &mut book.sections {
175315
let BookItem::Chapter(ch) = section else {

0 commit comments

Comments
 (0)