]> git.lizzy.rs Git - rust.git/blob - src/librustdoc/passes/html_tags.rs
f8869a41eb601b9d279fc24279e55dffe57329a9
[rust.git] / src / librustdoc / passes / html_tags.rs
1 use super::{span_of_attrs, Pass};
2 use crate::clean::*;
3 use crate::core::DocContext;
4 use crate::fold::DocFolder;
5 use crate::html::markdown::opts;
6 use core::ops::Range;
7 use pulldown_cmark::{Event, Parser};
8 use rustc_feature::UnstableFeatures;
9 use rustc_session::lint;
10
11 pub const CHECK_INVALID_HTML_TAGS: Pass = Pass {
12     name: "check-invalid-html-tags",
13     run: check_invalid_html_tags,
14     description: "detects invalid HTML tags in doc comments",
15 };
16
17 struct InvalidHtmlTagsLinter<'a, 'tcx> {
18     cx: &'a DocContext<'tcx>,
19 }
20
21 impl<'a, 'tcx> InvalidHtmlTagsLinter<'a, 'tcx> {
22     fn new(cx: &'a DocContext<'tcx>) -> Self {
23         InvalidHtmlTagsLinter { cx }
24     }
25 }
26
27 pub fn check_invalid_html_tags(krate: Crate, cx: &DocContext<'_>) -> Crate {
28     if !UnstableFeatures::from_environment().is_nightly_build() {
29         krate
30     } else {
31         let mut coll = InvalidHtmlTagsLinter::new(cx);
32
33         coll.fold_crate(krate)
34     }
35 }
36
37 const ALLOWED_UNCLOSED: &[&str] = &[
38     "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
39     "source", "track", "wbr",
40 ];
41
42 fn drop_tag(
43     tags: &mut Vec<(String, Range<usize>)>,
44     tag_name: String,
45     range: Range<usize>,
46     f: &impl Fn(&str, &Range<usize>),
47 ) {
48     let tag_name_low = tag_name.to_lowercase();
49     if let Some(pos) = tags.iter().rev().position(|(t, _)| t.to_lowercase() == tag_name_low) {
50         // Because this is from a `rev` iterator, the position is reversed as well!
51         let pos = tags.len() - 1 - pos;
52         // If the tag is nested inside a "<script>" or a "<style>" tag, no warning should
53         // be emitted.
54         let should_not_warn = tags.iter().take(pos + 1).any(|(at, _)| {
55             let at = at.to_lowercase();
56             at == "script" || at == "style"
57         });
58         for (last_tag_name, last_tag_span) in tags.drain(pos + 1..) {
59             if should_not_warn {
60                 continue;
61             }
62             let last_tag_name_low = last_tag_name.to_lowercase();
63             if ALLOWED_UNCLOSED.iter().any(|&at| at == &last_tag_name_low) {
64                 continue;
65             }
66             // `tags` is used as a queue, meaning that everything after `pos` is included inside it.
67             // So `<h2><h3></h2>` will look like `["h2", "h3"]`. So when closing `h2`, we will still
68             // have `h3`, meaning the tag wasn't closed as it should have.
69             f(&format!("unclosed HTML tag `{}`", last_tag_name), &last_tag_span);
70         }
71         // Remove the `tag_name` that was originally closed
72         tags.pop();
73     } else {
74         // It can happen for example in this case: `<h2></script></h2>` (the `h2` tag isn't required
75         // but it helps for the visualization).
76         f(&format!("unopened HTML tag `{}`", tag_name), &range);
77     }
78 }
79
80 fn extract_tag(
81     tags: &mut Vec<(String, Range<usize>)>,
82     text: &str,
83     range: Range<usize>,
84     f: &impl Fn(&str, &Range<usize>),
85 ) {
86     let mut iter = text.chars().enumerate().peekable();
87
88     'top: while let Some((start_pos, c)) = iter.next() {
89         if c == '<' {
90             let mut tag_name = String::new();
91             let mut is_closing = false;
92             let mut prev_pos = start_pos;
93             loop {
94                 let (pos, c) = match iter.peek() {
95                     Some((pos, c)) => (*pos, *c),
96                     // In case we reached the of the doc comment, we want to check that it's an
97                     // unclosed HTML tag. For example "/// <h3".
98                     None => (prev_pos, '\0'),
99                 };
100                 prev_pos = pos;
101                 // Checking if this is a closing tag (like `</a>` for `<a>`).
102                 if c == '/' && tag_name.is_empty() {
103                     is_closing = true;
104                 } else if c.is_ascii_alphanumeric() {
105                     tag_name.push(c);
106                 } else {
107                     if !tag_name.is_empty() {
108                         let mut r =
109                             Range { start: range.start + start_pos, end: range.start + pos };
110                         if c == '>' {
111                             // In case we have a tag without attribute, we can consider the span to
112                             // refer to it fully.
113                             r.end += 1;
114                         }
115                         if is_closing {
116                             drop_tag(tags, tag_name, r, f);
117                         } else {
118                             tags.push((tag_name, r));
119                         }
120                     }
121                     continue 'top;
122                 }
123                 // Some chars like ðŸ’© are longer than 1 character, so we need to skip the other
124                 // bytes as well to prevent stopping "in the middle" of a char.
125                 for _ in 0..c.len_utf8() {
126                     iter.next();
127                 }
128             }
129         }
130         // Some chars like ðŸ’© are longer than 1 character, so we need to skip the other
131         // bytes as well to prevent stopping "in the middle" of a char.
132         for _ in 0..c.len_utf8() - 1 {
133             iter.next();
134         }
135     }
136 }
137
138 impl<'a, 'tcx> DocFolder for InvalidHtmlTagsLinter<'a, 'tcx> {
139     fn fold_item(&mut self, item: Item) -> Option<Item> {
140         let hir_id = match self.cx.as_local_hir_id(item.def_id) {
141             Some(hir_id) => hir_id,
142             None => {
143                 // If non-local, no need to check anything.
144                 return self.fold_item_recur(item);
145             }
146         };
147         let dox = item.attrs.collapsed_doc_value().unwrap_or_default();
148         if !dox.is_empty() {
149             let cx = &self.cx;
150             let report_diag = |msg: &str, range: &Range<usize>| {
151                 let sp = match super::source_span_for_markdown_range(cx, &dox, range, &item.attrs) {
152                     Some(sp) => sp,
153                     None => span_of_attrs(&item.attrs).unwrap_or(item.source.span()),
154                 };
155                 cx.tcx.struct_span_lint_hir(lint::builtin::INVALID_HTML_TAGS, hir_id, sp, |lint| {
156                     lint.build(msg).emit()
157                 });
158             };
159
160             let mut tags = Vec::new();
161
162             let p = Parser::new_ext(&dox, opts()).into_offset_iter();
163
164             for (event, range) in p {
165                 match event {
166                     Event::Html(text) => extract_tag(&mut tags, &text, range, &report_diag),
167                     _ => {}
168                 }
169             }
170
171             for (tag, range) in tags.iter().filter(|(t, _)| {
172                 let t = t.to_lowercase();
173                 ALLOWED_UNCLOSED.iter().find(|&&at| at == t).is_none()
174             }) {
175                 report_diag(&format!("unclosed HTML tag `{}`", tag), range);
176             }
177         }
178
179         self.fold_item_recur(item)
180     }
181 }