version_sync/
html_root_url.rs

1#![cfg(feature = "html_root_url_updated")]
2use semver::{Version, VersionReq};
3use syn::spanned::Spanned;
4use syn::token;
5use url::Url;
6
7use crate::helpers::{indent, read_file, version_matches_request, Result};
8
9fn url_matches(value: &str, pkg_name: &str, version: &Version) -> Result<()> {
10    let url = Url::parse(value).map_err(|err| format!("parse error: {}", err))?;
11
12    // We can only reason about docs.rs.
13    if url.domain().is_some() && url.domain() != Some("docs.rs") {
14        return Ok(());
15    }
16
17    // Since docs.rs redirects HTTP traffic to HTTPS, we will ensure
18    // that the scheme is "https" here.
19    if url.scheme() != "https" {
20        return Err(format!("expected \"https\", found {:?}", url.scheme()));
21    }
22
23    let mut path_segments = url
24        .path_segments()
25        .ok_or_else(|| String::from("no path in URL"))?;
26
27    // The package name should not be empty.
28    let name = path_segments
29        .next()
30        .and_then(|path| if path.is_empty() { None } else { Some(path) })
31        .ok_or_else(|| String::from("missing package name"))?;
32
33    // The version number should not be empty.
34    let request = path_segments
35        .next()
36        .and_then(|path| if path.is_empty() { None } else { Some(path) })
37        .ok_or_else(|| String::from("missing version number"))?;
38
39    // Finally, we check that the package name and version matches.
40    if name != pkg_name {
41        Err(format!("expected package \"{pkg_name}\", found \"{name}\""))
42    } else {
43        // The Rust API Guidelines[1] suggest using an exact version
44        // number, but we have relaxed this a little and allow the
45        // user to specify the version as just "1" or "1.2". We might
46        // make this more strict in the future.
47        //
48        // [1]: https://rust-lang-nursery.github.io/api-guidelines/documentation.html
49        // #crate-sets-html_root_url-attribute-c-html-root
50        VersionReq::parse(request)
51            .map_err(|err| format!("could not parse version in URL: {}", err))
52            .and_then(|request| version_matches_request(version, &request))
53    }
54}
55
56/// Check version numbers in `html_root_url` attributes.
57///
58/// This function parses the Rust source file in `path` and looks for
59/// `html_root_url` attributes. Such an attribute must specify a valid
60/// URL and if the URL points to docs.rs, it must be point to the
61/// documentation for `pkg_name` and `pkg_version`.
62///
63/// # Errors
64///
65/// If any attribute fails the check, an `Err` is returned with a
66/// succinct error message. Status information has then already been
67/// printed on `stdout`.
68pub fn check_html_root_url(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> {
69    let code = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?;
70    let version = Version::parse(pkg_version)
71        .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;
72    let krate: syn::File = syn::parse_file(&code)
73        .map_err(|_| format!("could not parse {}: please run \"cargo build\"", path))?;
74
75    println!("Checking doc attributes in {path}...");
76    for attr in krate.attrs {
77        if let syn::AttrStyle::Outer = attr.style {
78            continue;
79        }
80
81        if !attr.path().is_ident("doc") {
82            continue;
83        }
84
85        if let syn::Meta::List(ref list) = attr.meta {
86            list.parse_nested_meta(|meta| {
87                if meta.path.is_ident("html_root_url") {
88                    let check_result = match meta.value() {
89                        Ok(value) => match value.parse()? {
90                            syn::Lit::Str(ref s) => url_matches(&s.value(), pkg_name, &version),
91                            _ => return Ok(()),
92                        },
93                        Err(_err) => Err(String::from("html_root_url attribute without URL")),
94                    };
95
96                    // FIXME: the proc-macro2-0.4.27 crate hides accurate span
97                    // information behind a procmacro2_semver_exempt flag: the
98                    // start line is correct, but the end line is always equal
99                    // to the start. Luckily, most html_root_url attributes
100                    // are on a single line, so the code below works okay.
101                    let first_line = attr.span().start().line;
102                    let last_line = attr.span().end().line;
103                    // Getting the source code for a span is tracked upstream:
104                    // https://github.com/alexcrichton/proc-macro2/issues/110.
105                    let source_lines = code.lines().take(last_line).skip(first_line - 1);
106                    match check_result {
107                        Ok(()) => {
108                            println!("{} (line {}) ... ok", path, first_line);
109                            return Ok(());
110                        }
111                        Err(err) => {
112                            println!("{} (line {}) ... {} in", path, first_line, err);
113                            for line in source_lines {
114                                println!("{}", indent(line));
115                            }
116                            return Err(meta.error(format!("html_root_url errors in {}", path)));
117                        }
118                    }
119                }
120                // Need to advance the input stream by parsing it.
121                // Otherwise syn gets stuck parsing the wrong tokens.
122                else if meta.input.peek(token::Eq) {
123                    let value = meta.value()?;
124                    value.parse::<proc_macro2::TokenTree>()?;
125                } else if meta.input.peek(token::Paren) {
126                    let value;
127                    syn::parenthesized!(value in meta.input);
128                    // There can be multiple elements before end.
129                    while !value.is_empty() {
130                        value.parse::<proc_macro2::TokenTree>()?;
131                    }
132                } else {
133                    return Err(meta.error("unknown doc attribute"));
134                }
135
136                Ok(())
137            })
138            .map_err(|err| err.to_string())?;
139        }
140    }
141
142    Ok(())
143}
144
145#[cfg(test)]
146mod test_url_matches {
147    use super::*;
148
149    #[test]
150    fn good_url() {
151        let ver = Version::parse("1.2.3").unwrap();
152        assert_eq!(
153            url_matches("https://docs.rs/foo/1.2.3", "foo", &ver),
154            Ok(())
155        );
156    }
157
158    #[test]
159    fn trailing_slash() {
160        let ver = Version::parse("1.2.3").unwrap();
161        assert_eq!(
162            url_matches("https://docs.rs/foo/1.2.3/", "foo", &ver),
163            Ok(())
164        );
165    }
166
167    #[test]
168    fn without_patch() {
169        let ver = Version::parse("1.2.3").unwrap();
170        assert_eq!(url_matches("https://docs.rs/foo/1.2/", "foo", &ver), Ok(()));
171    }
172
173    #[test]
174    fn without_minor() {
175        let ver = Version::parse("1.2.3").unwrap();
176        assert_eq!(url_matches("https://docs.rs/foo/1/", "foo", &ver), Ok(()));
177    }
178
179    #[test]
180    fn different_domain() {
181        let ver = Version::parse("1.2.3").unwrap();
182        assert_eq!(url_matches("https://example.net/foo/", "bar", &ver), Ok(()));
183    }
184
185    #[test]
186    fn different_domain_http() {
187        let ver = Version::parse("1.2.3").unwrap();
188        assert_eq!(
189            url_matches("http://example.net/foo/1.2.3", "foo", &ver),
190            Ok(())
191        );
192    }
193
194    #[test]
195    fn http_url() {
196        let ver = Version::parse("1.2.3").unwrap();
197        assert_eq!(
198            url_matches("http://docs.rs/foo/1.2.3", "foo", &ver),
199            Err(String::from("expected \"https\", found \"http\""))
200        );
201    }
202
203    #[test]
204    fn bad_scheme() {
205        let ver = Version::parse("1.2.3").unwrap();
206        assert_eq!(
207            url_matches("mailto:foo@example.net", "foo", &ver),
208            Err(String::from("expected \"https\", found \"mailto\""))
209        );
210    }
211
212    #[test]
213    fn no_package() {
214        let ver = Version::parse("1.2.3").unwrap();
215        assert_eq!(
216            url_matches("https://docs.rs", "foo", &ver),
217            Err(String::from("missing package name"))
218        );
219    }
220
221    #[test]
222    fn no_package_trailing_slash() {
223        let ver = Version::parse("1.2.3").unwrap();
224        assert_eq!(
225            url_matches("https://docs.rs/", "foo", &ver),
226            Err(String::from("missing package name"))
227        );
228    }
229
230    #[test]
231    fn no_version() {
232        let ver = Version::parse("1.2.3").unwrap();
233        assert_eq!(
234            url_matches("https://docs.rs/foo", "foo", &ver),
235            Err(String::from("missing version number"))
236        );
237    }
238
239    #[test]
240    fn no_version_trailing_slash() {
241        let ver = Version::parse("1.2.3").unwrap();
242        assert_eq!(
243            url_matches("https://docs.rs/foo/", "foo", &ver),
244            Err(String::from("missing version number"))
245        );
246    }
247
248    #[test]
249    fn bad_url() {
250        let ver = Version::parse("1.2.3").unwrap();
251        assert_eq!(
252            url_matches("docs.rs/foo/bar", "foo", &ver),
253            Err(String::from("parse error: relative URL without a base"))
254        );
255    }
256
257    #[test]
258    fn bad_pkg_version() {
259        let ver = Version::parse("1.2.3").unwrap();
260        assert_eq!(
261            url_matches("https://docs.rs/foo/1.2.bad/", "foo", &ver),
262            Err(String::from(
263                "could not parse version in URL: \
264                 unexpected character 'b' while parsing patch version number"
265            ))
266        );
267    }
268
269    #[test]
270    fn wrong_pkg_name() {
271        let ver = Version::parse("1.2.3").unwrap();
272        assert_eq!(
273            url_matches("https://docs.rs/foo/1.2.3/", "bar", &ver),
274            Err(String::from("expected package \"bar\", found \"foo\""))
275        );
276    }
277}
278
279#[cfg(test)]
280mod test_check_html_root_url {
281    use super::*;
282
283    #[test]
284    fn bad_path() {
285        let no_such_file = if cfg!(unix) {
286            "No such file or directory (os error 2)"
287        } else {
288            "The system cannot find the file specified. (os error 2)"
289        };
290        let errmsg = format!("could not read no-such-file.md: {no_such_file}");
291        assert_eq!(
292            check_html_root_url("no-such-file.md", "foobar", "1.2.3"),
293            Err(errmsg)
294        );
295    }
296
297    #[test]
298    fn bad_pkg_version() {
299        // This uses the src/lib.rs file from this crate.
300        assert_eq!(
301            check_html_root_url("src/lib.rs", "foobar", "1.2"),
302            Err(String::from(
303                "bad package version \"1.2\": unexpected end of input while parsing minor version number"
304            ))
305        );
306    }
307}