diff --git a/src/doc/rustdoc/src/lints.md b/src/doc/rustdoc/src/lints.md index 9dee33ef6eb85..abd436bb5561c 100644 --- a/src/doc/rustdoc/src/lints.md +++ b/src/doc/rustdoc/src/lints.md @@ -456,3 +456,31 @@ note: the lint level is defined here | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: Remove explicit link instead ``` + +## `invalid_markdown_table` + +This lint is **warn-by-default**. It detects unescaped pipes (`|`) in table rows which +lead to some row cells being ignored. For example: + +```rust +//! | col1 | +//! | ---- | +//! | `code_with(|arg| arg)` | +``` + +Which will give: + +```text +error: table row has too many columns + --> $DIR/foo.rs:5:18 + | +5 | //! | `code_with(|arg| arg)` | + | ^ help: any content after this column divider is discarded + | + = help: to escape `|` characters in tables, add a `\` before them like `\|` +note: the lint level is defined here + --> $DIR/foo.rs:1:9 + | +1 | #![deny(rustdoc::invalid_markdown_table)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +``` diff --git a/src/librustdoc/lint.rs b/src/librustdoc/lint.rs index 1c3d1c421b545..5d8675aecb86a 100644 --- a/src/librustdoc/lint.rs +++ b/src/librustdoc/lint.rs @@ -209,6 +209,17 @@ declare_rustdoc_lint! { "detects unused footnote definitions" } +declare_rustdoc_lint! { + /// This lint is **warn-by-default**. It detects unescaped pipes in table rows which + /// lead to some row cells being ignored. This is a `rustdoc` only lint, see the + /// documentation in the [rustdoc book]. + /// + /// [rustdoc book]: ../../../rustdoc/lints.html#invalid_markdown_table + INVALID_MARKDOWN_TABLE, + Warn, + "detects unescaped pipe in table rows in doc comments" +} + pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { vec![ BROKEN_INTRA_DOC_LINKS, @@ -224,6 +235,7 @@ pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { REDUNDANT_EXPLICIT_LINKS, BROKEN_FOOTNOTE, UNUSED_FOOTNOTE_DEFINITION, + INVALID_MARKDOWN_TABLE, ] }); diff --git a/src/librustdoc/passes/lint.rs b/src/librustdoc/passes/lint.rs index bb952b32393cf..a417bbaab4ed5 100644 --- a/src/librustdoc/passes/lint.rs +++ b/src/librustdoc/passes/lint.rs @@ -5,6 +5,7 @@ mod bare_urls; mod check_code_block_syntax; mod footnotes; mod html_tags; +mod invalid_markdown_table; mod redundant_explicit_links; mod unescaped_backticks; @@ -35,6 +36,7 @@ impl DocVisitor<'_> for Linter<'_, '_> { if !dox.is_empty() { let may_have_link = dox.contains(&[':', '['][..]); let may_have_block_comment_or_html = dox.contains(['<', '>']); + let may_have_table = dox.contains(&['|'][..]); // ~~~rust // // This is a real, supported commonmark syntax for block code // ~~~ @@ -51,6 +53,9 @@ impl DocVisitor<'_> for Linter<'_, '_> { if may_have_block_comment_or_html { html_tags::visit_item(self.cx, item, hir_id, &dox); } + if may_have_table { + invalid_markdown_table::visit_item(self.cx, item, hir_id, &dox); + } } self.visit_item_recur(item) diff --git a/src/librustdoc/passes/lint/invalid_markdown_table.rs b/src/librustdoc/passes/lint/invalid_markdown_table.rs new file mode 100644 index 0000000000000..dd44f2ec92445 --- /dev/null +++ b/src/librustdoc/passes/lint/invalid_markdown_table.rs @@ -0,0 +1,120 @@ +//! Detects table rows where some content seems to have been discarded because there are too many +//! pipe characters. + +use std::ops::Range; + +use rustc_hir::HirId; +use rustc_macros::Diagnostic; +use rustc_resolve::rustdoc::pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use rustc_resolve::rustdoc::source_span_for_markdown_range; + +use crate::clean::*; +use crate::core::DocContext; +use crate::html::markdown::main_body_opts; + +#[derive(Diagnostic)] +#[diag("table row has too many columns")] +#[help(r"to escape `|` characters in tables, add a `\` before them like `\|`")] +struct UnescapedPipeInTableCell { + #[primary_span] + #[label("any content after this column divider is discarded")] + span: rustc_span::Span, +} + +#[derive(Diagnostic)] +#[diag("unused content after last table cell")] +struct ContentAfterLastPipe { + #[primary_span] + #[label("this content is discarded")] + span: rustc_span::Span, +} + +pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId, dox: &str) { + let mut p = Parser::new_ext(dox, main_body_opts()).into_offset_iter(); + + while let Some((event, _range)) = p.next() { + if Event::Start(Tag::TableRow) == event { + let mut prev_range = None; + while let Some((event, range)) = p.next() { + match event { + Event::End(TagEnd::TableCell) => { + prev_range = Some(range); + } + Event::End(TagEnd::TableRow) => { + if let Some(prev_range) = &prev_range + // So here what is happening: when `pulldown-cmark` is parsing a table + // and a table row has too many cells, it doesn't emit events for the + // extra cells. So the only way for us to know these extra cells exist + // is to compare the row's span with the last emitted cell event's span. + // If the span ends don't match, then there are extra cells. + && prev_range.end + 1 < range.end + { + // Something seems wrong, the range diff doesn't match, some content + // was left out. + let mut after_last_cell_range = + Range { start: prev_range.end + 1, end: range.end }; + if dox[after_last_cell_range.clone()].trim().is_empty() { + // Seems all good so let's ignore it and continue;. + continue; + } + // Check if any pipes appear after the end of the row. + let mut iter = dox[after_last_cell_range.clone()].bytes().peekable(); + let mut found_divider = false; + while let Some(c) = iter.next() { + // the sequence `\\|` still escapes the pipe because GFM + // processes block structures like tables in its own pass + if c == b'\\' && iter.peek() == Some(&b'|') { + iter.next(); + } else if c == b'|' { + found_divider = true; + break; + } + } + if found_divider { + // Seems like a pipe was not escaped as it should have been. + let last_cell_separator = + Range { start: prev_range.end, end: prev_range.end + 1 }; + + if let Some((span, _)) = source_span_for_markdown_range( + cx.tcx, + dox, + &last_cell_separator, + &item.attrs.doc_strings, + ) { + cx.tcx.emit_node_span_lint( + crate::lint::INVALID_MARKDOWN_TABLE, + hir_id, + span, + UnescapedPipeInTableCell { span }, + ); + } + } else { + // An unclosed cell maybe? There is content after the last cell so + // let's lint about it. + let content = &dox[after_last_cell_range.clone()]; + after_last_cell_range.end -= + content.len() - content.trim_end().len(); + + if let Some((span, _)) = source_span_for_markdown_range( + cx.tcx, + dox, + &after_last_cell_range, + &item.attrs.doc_strings, + ) { + cx.tcx.emit_node_span_lint( + crate::lint::INVALID_MARKDOWN_TABLE, + hir_id, + span, + ContentAfterLastPipe { span }, + ); + } + } + } + } + Event::End(TagEnd::Table) => break, + _ => {} + } + } + } + } +} diff --git a/tests/rustdoc-ui/lints/invalid-html-tags.rs b/tests/rustdoc-ui/lints/invalid-html-tags.rs index d0aa97c9e4074..7a244e6cc58f5 100644 --- a/tests/rustdoc-ui/lints/invalid-html-tags.rs +++ b/tests/rustdoc-ui/lints/invalid-html-tags.rs @@ -1,5 +1,6 @@ #![deny(rustdoc::invalid_html_tags)] //~^ NOTE the lint level is defined here +#![allow(rustdoc::invalid_markdown_table)] //!

💩

//~^ ERROR unclosed HTML tag `p` diff --git a/tests/rustdoc-ui/lints/invalid-html-tags.stderr b/tests/rustdoc-ui/lints/invalid-html-tags.stderr index 15b88496b7557..d0830321536dd 100644 --- a/tests/rustdoc-ui/lints/invalid-html-tags.stderr +++ b/tests/rustdoc-ui/lints/invalid-html-tags.stderr @@ -1,5 +1,5 @@ error: unclosed HTML tag `p` - --> $DIR/invalid-html-tags.rs:4:5 + --> $DIR/invalid-html-tags.rs:5:5 | LL | //!

💩

| ^^^ @@ -11,115 +11,115 @@ LL | #![deny(rustdoc::invalid_html_tags)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^ error: unclosed HTML tag `p` - --> $DIR/invalid-html-tags.rs:4:9 + --> $DIR/invalid-html-tags.rs:5:9 | LL | //!

💩

| ^^^ error: unclosed HTML tag `unknown` - --> $DIR/invalid-html-tags.rs:12:5 + --> $DIR/invalid-html-tags.rs:13:5 | LL | /// | ^^^^^^^^^ error: unclosed HTML tag `script` - --> $DIR/invalid-html-tags.rs:15:5 + --> $DIR/invalid-html-tags.rs:16:5 | LL | ///