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 if url.domain().is_some() && url.domain() != Some("docs.rs") {
14 return Ok(());
15 }
16
17 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 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 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 if name != pkg_name {
41 Err(format!("expected package \"{pkg_name}\", found \"{name}\""))
42 } else {
43 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
56pub 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 let first_line = attr.span().start().line;
102 let last_line = attr.span().end().line;
103 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 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 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 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}