]>
Commit | Line | Data |
---|---|---|
54a0048b SL |
1 | // Copyright 2016 The Rust Project Developers. See the COPYRIGHT |
2 | // file at the top-level directory of this distribution and at | |
3 | // http://rust-lang.org/COPYRIGHT. | |
4 | // | |
5 | // Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or | |
6 | // http://www.apache.org/licenses/LICENSE-2.0> or the MIT license | |
7 | // <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your | |
8 | // option. This file may not be copied, modified, or distributed | |
9 | // except according to those terms. | |
10 | ||
11 | //! Script to check the validity of `href` links in our HTML documentation. | |
12 | //! | |
13 | //! In the past we've been quite error prone to writing in broken links as most | |
14 | //! of them are manually rather than automatically added. As files move over | |
15 | //! time or apis change old links become stale or broken. The purpose of this | |
16 | //! script is to check all relative links in our documentation to make sure they | |
17 | //! actually point to a valid place. | |
18 | //! | |
19 | //! Currently this doesn't actually do any HTML parsing or anything fancy like | |
20 | //! that, it just has a simple "regex" to search for `href` and `id` tags. | |
21 | //! These values are then translated to file URLs if possible and then the | |
22 | //! destination is asserted to exist. | |
23 | //! | |
24 | //! A few whitelisted exceptions are allowed as there's known bugs in rustdoc, | |
25 | //! but this should catch the majority of "broken link" cases. | |
26 | ||
27 | extern crate url; | |
28 | ||
29 | use std::env; | |
30 | use std::fs::File; | |
31 | use std::io::prelude::*; | |
32 | use std::path::{Path, PathBuf}; | |
33 | use std::collections::{HashMap, HashSet}; | |
34 | use std::collections::hash_map::Entry; | |
35 | ||
5bcae85e | 36 | use url::Url; |
54a0048b SL |
37 | |
38 | use Redirect::*; | |
39 | ||
40 | macro_rules! t { | |
41 | ($e:expr) => (match $e { | |
42 | Ok(e) => e, | |
43 | Err(e) => panic!("{} failed with {:?}", stringify!($e), e), | |
44 | }) | |
45 | } | |
46 | ||
47 | fn main() { | |
48 | let docs = env::args().nth(1).unwrap(); | |
49 | let docs = env::current_dir().unwrap().join(docs); | |
50 | let mut url = Url::from_file_path(&docs).unwrap(); | |
51 | let mut errors = false; | |
52 | walk(&mut HashMap::new(), &docs, &docs, &mut url, &mut errors); | |
53 | if errors { | |
54 | panic!("found some broken links"); | |
55 | } | |
56 | } | |
57 | ||
58 | #[derive(Debug)] | |
59 | pub enum LoadError { | |
60 | IOError(std::io::Error), | |
61 | BrokenRedirect(PathBuf, std::io::Error), | |
62 | IsRedirect, | |
63 | } | |
64 | ||
65 | enum Redirect { | |
66 | SkipRedirect, | |
67 | FromRedirect(bool), | |
68 | } | |
69 | ||
70 | struct FileEntry { | |
71 | source: String, | |
72 | ids: HashSet<String>, | |
73 | } | |
74 | ||
75 | type Cache = HashMap<PathBuf, FileEntry>; | |
76 | ||
77 | impl FileEntry { | |
3157f602 | 78 | fn parse_ids(&mut self, file: &Path, contents: &str, errors: &mut bool) { |
54a0048b SL |
79 | if self.ids.is_empty() { |
80 | with_attrs_in_source(contents, " id", |fragment, i| { | |
81 | let frag = fragment.trim_left_matches("#").to_owned(); | |
82 | if !self.ids.insert(frag) { | |
83 | *errors = true; | |
3157f602 | 84 | println!("{}:{}: id is not unique: `{}`", file.display(), i, fragment); |
54a0048b SL |
85 | } |
86 | }); | |
87 | } | |
88 | } | |
89 | } | |
90 | ||
3157f602 | 91 | fn walk(cache: &mut Cache, root: &Path, dir: &Path, url: &mut Url, errors: &mut bool) { |
54a0048b SL |
92 | for entry in t!(dir.read_dir()).map(|e| t!(e)) { |
93 | let path = entry.path(); | |
94 | let kind = t!(entry.file_type()); | |
5bcae85e | 95 | url.path_segments_mut().unwrap().push(entry.file_name().to_str().unwrap()); |
54a0048b SL |
96 | if kind.is_dir() { |
97 | walk(cache, root, &path, url, errors); | |
98 | } else { | |
99 | let pretty_path = check(cache, root, &path, url, errors); | |
100 | if let Some(pretty_path) = pretty_path { | |
101 | let entry = cache.get_mut(&pretty_path).unwrap(); | |
102 | // we don't need the source anymore, | |
a7813a04 | 103 | // so drop to reduce memory-usage |
54a0048b SL |
104 | entry.source = String::new(); |
105 | } | |
106 | } | |
5bcae85e | 107 | url.path_segments_mut().unwrap().pop(); |
54a0048b SL |
108 | } |
109 | } | |
110 | ||
111 | fn check(cache: &mut Cache, | |
112 | root: &Path, | |
113 | file: &Path, | |
114 | base: &Url, | |
3157f602 XL |
115 | errors: &mut bool) |
116 | -> Option<PathBuf> { | |
54a0048b SL |
117 | // ignore js files as they are not prone to errors as the rest of the |
118 | // documentation is and they otherwise bring up false positives. | |
119 | if file.extension().and_then(|s| s.to_str()) == Some("js") { | |
120 | return None; | |
121 | } | |
122 | ||
123 | // Unfortunately we're not 100% full of valid links today to we need a few | |
124 | // whitelists to get this past `make check` today. | |
125 | // FIXME(#32129) | |
126 | if file.ends_with("std/string/struct.String.html") { | |
127 | return None; | |
128 | } | |
129 | // FIXME(#32553) | |
130 | if file.ends_with("collections/string/struct.String.html") { | |
131 | return None; | |
132 | } | |
133 | // FIXME(#32130) | |
134 | if file.ends_with("btree_set/struct.BTreeSet.html") || | |
135 | file.ends_with("collections/struct.BTreeSet.html") || | |
136 | file.ends_with("collections/btree_map/struct.BTreeMap.html") || | |
137 | file.ends_with("collections/hash_map/struct.HashMap.html") { | |
138 | return None; | |
139 | } | |
140 | ||
54a0048b SL |
141 | let res = load_file(cache, root, PathBuf::from(file), SkipRedirect); |
142 | let (pretty_file, contents) = match res { | |
143 | Ok(res) => res, | |
144 | Err(_) => return None, | |
145 | }; | |
146 | { | |
3157f602 XL |
147 | cache.get_mut(&pretty_file) |
148 | .unwrap() | |
149 | .parse_ids(&pretty_file, &contents, errors); | |
54a0048b SL |
150 | } |
151 | ||
152 | // Search for anything that's the regex 'href[ ]*=[ ]*".*?"' | |
153 | with_attrs_in_source(&contents, " href", |url, i| { | |
3157f602 XL |
154 | // Ignore external URLs |
155 | if url.starts_with("http:") || url.starts_with("https:") || | |
156 | url.starts_with("javascript:") || url.starts_with("ftp:") || | |
157 | url.starts_with("irc:") || url.starts_with("data:") { | |
158 | return; | |
159 | } | |
54a0048b | 160 | // Once we've plucked out the URL, parse it using our base url and |
3157f602 | 161 | // then try to extract a file path. |
5bcae85e | 162 | let (parsed_url, path) = match url_to_file_path(&base, url) { |
54a0048b | 163 | Some((url, path)) => (url, PathBuf::from(path)), |
3157f602 XL |
164 | None => { |
165 | *errors = true; | |
166 | println!("{}:{}: invalid link - {}", | |
167 | pretty_file.display(), | |
168 | i + 1, | |
169 | url); | |
170 | return; | |
171 | } | |
54a0048b SL |
172 | }; |
173 | ||
174 | // Alright, if we've found a file name then this file had better | |
175 | // exist! If it doesn't then we register and print an error. | |
176 | if path.exists() { | |
177 | if path.is_dir() { | |
3157f602 XL |
178 | // Links to directories show as directory listings when viewing |
179 | // the docs offline so it's best to avoid them. | |
180 | *errors = true; | |
181 | let pretty_path = path.strip_prefix(root).unwrap_or(&path); | |
182 | println!("{}:{}: directory link - {}", | |
183 | pretty_file.display(), | |
184 | i + 1, | |
185 | pretty_path.display()); | |
54a0048b SL |
186 | return; |
187 | } | |
188 | let res = load_file(cache, root, path.clone(), FromRedirect(false)); | |
189 | let (pretty_path, contents) = match res { | |
190 | Ok(res) => res, | |
191 | Err(LoadError::IOError(err)) => panic!(format!("{}", err)), | |
192 | Err(LoadError::BrokenRedirect(target, _)) => { | |
3157f602 XL |
193 | *errors = true; |
194 | println!("{}:{}: broken redirect to {}", | |
195 | pretty_file.display(), | |
196 | i + 1, | |
197 | target.display()); | |
54a0048b SL |
198 | return; |
199 | } | |
200 | Err(LoadError::IsRedirect) => unreachable!(), | |
201 | }; | |
202 | ||
5bcae85e | 203 | if let Some(ref fragment) = parsed_url.fragment() { |
54a0048b SL |
204 | // Fragments like `#1-6` are most likely line numbers to be |
205 | // interpreted by javascript, so we're ignoring these | |
206 | if fragment.splitn(2, '-') | |
207 | .all(|f| f.chars().all(|c| c.is_numeric())) { | |
208 | return; | |
209 | } | |
210 | ||
211 | let entry = &mut cache.get_mut(&pretty_path).unwrap(); | |
212 | entry.parse_ids(&pretty_path, &contents, errors); | |
213 | ||
5bcae85e | 214 | if !entry.ids.contains(*fragment) { |
54a0048b SL |
215 | *errors = true; |
216 | print!("{}:{}: broken link fragment ", | |
3157f602 XL |
217 | pretty_file.display(), |
218 | i + 1); | |
219 | println!("`#{}` pointing to `{}`", fragment, pretty_path.display()); | |
54a0048b SL |
220 | }; |
221 | } | |
222 | } else { | |
223 | *errors = true; | |
224 | print!("{}:{}: broken link - ", pretty_file.display(), i + 1); | |
225 | let pretty_path = path.strip_prefix(root).unwrap_or(&path); | |
226 | println!("{}", pretty_path.display()); | |
227 | } | |
228 | }); | |
229 | Some(pretty_file) | |
230 | } | |
231 | ||
232 | fn load_file(cache: &mut Cache, | |
233 | root: &Path, | |
234 | file: PathBuf, | |
3157f602 XL |
235 | redirect: Redirect) |
236 | -> Result<(PathBuf, String), LoadError> { | |
54a0048b SL |
237 | let mut contents = String::new(); |
238 | let pretty_file = PathBuf::from(file.strip_prefix(root).unwrap_or(&file)); | |
239 | ||
240 | let maybe_redirect = match cache.entry(pretty_file.clone()) { | |
241 | Entry::Occupied(entry) => { | |
242 | contents = entry.get().source.clone(); | |
243 | None | |
3157f602 | 244 | } |
54a0048b | 245 | Entry::Vacant(entry) => { |
9e0c209e | 246 | let mut fp = File::open(file.clone()).map_err(|err| { |
54a0048b SL |
247 | if let FromRedirect(true) = redirect { |
248 | LoadError::BrokenRedirect(file.clone(), err) | |
249 | } else { | |
250 | LoadError::IOError(err) | |
251 | } | |
9e0c209e SL |
252 | })?; |
253 | fp.read_to_string(&mut contents).map_err(|err| LoadError::IOError(err))?; | |
54a0048b SL |
254 | |
255 | let maybe = maybe_redirect(&contents); | |
256 | if maybe.is_some() { | |
257 | if let SkipRedirect = redirect { | |
258 | return Err(LoadError::IsRedirect); | |
259 | } | |
260 | } else { | |
261 | entry.insert(FileEntry { | |
262 | source: contents.clone(), | |
263 | ids: HashSet::new(), | |
264 | }); | |
265 | } | |
266 | maybe | |
3157f602 | 267 | } |
54a0048b SL |
268 | }; |
269 | let base = Url::from_file_path(&file).unwrap(); | |
54a0048b | 270 | |
5bcae85e | 271 | match maybe_redirect.and_then(|url| url_to_file_path(&base, &url)) { |
54a0048b SL |
272 | Some((_, redirect_file)) => { |
273 | let path = PathBuf::from(redirect_file); | |
274 | load_file(cache, root, path, FromRedirect(true)) | |
275 | } | |
3157f602 | 276 | None => Ok((pretty_file, contents)), |
54a0048b SL |
277 | } |
278 | } | |
279 | ||
280 | fn maybe_redirect(source: &str) -> Option<String> { | |
281 | const REDIRECT: &'static str = "<p>Redirecting to <a href="; | |
282 | ||
283 | let mut lines = source.lines(); | |
284 | let redirect_line = match lines.nth(6) { | |
285 | Some(l) => l, | |
286 | None => return None, | |
287 | }; | |
288 | ||
289 | redirect_line.find(REDIRECT).map(|i| { | |
290 | let rest = &redirect_line[(i + REDIRECT.len() + 1)..]; | |
291 | let pos_quote = rest.find('"').unwrap(); | |
292 | rest[..pos_quote].to_owned() | |
293 | }) | |
294 | } | |
295 | ||
5bcae85e SL |
296 | fn url_to_file_path(parser: &Url, url: &str) -> Option<(Url, PathBuf)> { |
297 | parser.join(url) | |
3157f602 XL |
298 | .ok() |
299 | .and_then(|parsed_url| parsed_url.to_file_path().ok().map(|f| (parsed_url, f))) | |
54a0048b SL |
300 | } |
301 | ||
3157f602 | 302 | fn with_attrs_in_source<F: FnMut(&str, usize)>(contents: &str, attr: &str, mut f: F) { |
54a0048b SL |
303 | for (i, mut line) in contents.lines().enumerate() { |
304 | while let Some(j) = line.find(attr) { | |
3157f602 | 305 | let rest = &line[j + attr.len()..]; |
54a0048b SL |
306 | line = rest; |
307 | let pos_equals = match rest.find("=") { | |
308 | Some(i) => i, | |
309 | None => continue, | |
310 | }; | |
311 | if rest[..pos_equals].trim_left_matches(" ") != "" { | |
3157f602 | 312 | continue; |
54a0048b SL |
313 | } |
314 | ||
315 | let rest = &rest[pos_equals + 1..]; | |
316 | ||
317 | let pos_quote = match rest.find(&['"', '\''][..]) { | |
318 | Some(i) => i, | |
319 | None => continue, | |
320 | }; | |
321 | let quote_delim = rest.as_bytes()[pos_quote] as char; | |
322 | ||
323 | if rest[..pos_quote].trim_left_matches(" ") != "" { | |
3157f602 | 324 | continue; |
54a0048b SL |
325 | } |
326 | let rest = &rest[pos_quote + 1..]; | |
327 | let url = match rest.find(quote_delim) { | |
328 | Some(i) => &rest[..i], | |
329 | None => continue, | |
330 | }; | |
331 | f(url, i) | |
332 | } | |
333 | } | |
334 | } |