From 54947cf803057450071fc29fb4ec83549e981816 Mon Sep 17 00:00:00 2001 From: Guillaume Gomez Date: Mon, 20 Jul 2026 00:34:00 +0200 Subject: [PATCH 1/3] Add new `unescaped_pipe_in_table_cell` rustdoc lint --- src/librustdoc/lint.rs | 12 +++ src/librustdoc/passes/lint.rs | 5 + .../passes/lint/table_pipe_escape.rs | 99 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/librustdoc/passes/lint/table_pipe_escape.rs diff --git a/src/librustdoc/lint.rs b/src/librustdoc/lint.rs index 91f92b799889b..0446a0d6f15dd 100644 --- a/src/librustdoc/lint.rs +++ b/src/librustdoc/lint.rs @@ -196,6 +196,17 @@ declare_rustdoc_lint! { "detects redundant explicit links in doc comments" } +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#unescaped_pipe_in_table_cell + UNESCAPED_PIPE_IN_TABLE_CELL, + Warn, + "detects unescaped pipe in table rows in doc comments" +} + pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { vec![ BROKEN_INTRA_DOC_LINKS, @@ -209,6 +220,7 @@ pub(crate) static RUSTDOC_LINTS: Lazy> = Lazy::new(|| { MISSING_CRATE_LEVEL_DOCS, UNESCAPED_BACKTICKS, REDUNDANT_EXPLICIT_LINKS, + UNESCAPED_PIPE_IN_TABLE_CELL, ] }); diff --git a/src/librustdoc/passes/lint.rs b/src/librustdoc/passes/lint.rs index 7740d14148bf0..7d4f8d916dd70 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 html_tags; mod redundant_explicit_links; +mod table_pipe_escape; mod unescaped_backticks; use super::Pass; @@ -34,6 +35,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 // ~~~ @@ -49,6 +51,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 { + table_pipe_escape::visit_item(self.cx, item, hir_id, &dox); + } } self.visit_item_recur(item) diff --git a/src/librustdoc/passes/lint/table_pipe_escape.rs b/src/librustdoc/passes/lint/table_pipe_escape.rs new file mode 100644 index 0000000000000..1ba19eabab1ac --- /dev/null +++ b/src/librustdoc/passes/lint/table_pipe_escape.rs @@ -0,0 +1,99 @@ +//! 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("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, +} + +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 let Event::Start(Tag::Table(_)) = event + && let Some((Event::Start(Tag::TableHead), _)) = p.next() + { + let mut expected_cells = 0; + while let Some((event, _)) = p.next() { + match event { + Event::End(TagEnd::TableCell) => expected_cells += 1, + Event::End(TagEnd::TableHead) => break, + _ => {} + } + } + 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. We now check the number of unescaped `|`. + let row = &dox[range.clone()]; + let mut iter = row.chars(); + let mut divider_count = 0; + while let Some(c) = iter.next() { + if c == '\\' { + iter.next(); + } else if c == '|' { + divider_count += 1; + } + } + // + 1 is to handle the `|` at the end of the table row. + if divider_count <= expected_cells + 1 + || dox[Range { start: prev_range.end + 1, end: range.end }] + .trim() + .is_empty() + { + // Seems all good so let's ignore it and continue;. + continue; + } + 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::UNESCAPED_PIPE_IN_TABLE_CELL, + hir_id, + span, + UnescapedPipeInTableCell { span }, + ); + } + } + } + Event::End(TagEnd::Table) => break, + _ => {} + } + } + } + } +} From ec0f4db8e10e41855237e1249381d816e3e76d12 Mon Sep 17 00:00:00 2001 From: Guillaume Gomez Date: Mon, 20 Jul 2026 00:34:41 +0200 Subject: [PATCH 2/3] Add ui regression test for new rustdoc `unescaped_pipe_in_table_cell` lint --- .../lints/unescaped_pipe_in_table_cell.rs | 38 +++++++++++++++++++ .../lints/unescaped_pipe_in_table_cell.stderr | 31 +++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.rs create mode 100644 tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.stderr diff --git a/tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.rs b/tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.rs new file mode 100644 index 0000000000000..1a0918340c646 --- /dev/null +++ b/tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.rs @@ -0,0 +1,38 @@ +#![deny(rustdoc::unescaped_pipe_in_table_cell)] + +//! | col1 | +//! | ---- | +//! | `code_with(|arg| arg)` | +//~^ ERROR unescaped_pipe_in_table_cell +//! | one `|` b | +//~^ ERROR unescaped_pipe_in_table_cell +//! +// Testing another lint emission on the same doc comment. +//! +//! | col1 | +//! | ---- | +//! | `code_with(|arg| arg)` | +//~^ ERROR unescaped_pipe_in_table_cell + +// We check that the extra whitespace characters at the end of the line won't trigger +// the lint. +mod b { + //! | col | + //! | ---- | + #![doc = "| code_with | "] +} + +// We check that the `\|` is correctly handled as well (ie not emitting the lint). +mod c { + //! | col | + //! | ---- | + //! | a \| still same cell | +} + +// We check that content after a table row is ignored. +mod d { + //! | col | + //! | ---- | + //! | code_with | aaaaa + //^ the "aaaaa" part will be ignored. +} diff --git a/tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.stderr b/tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.stderr new file mode 100644 index 0000000000000..03a10c078cded --- /dev/null +++ b/tests/rustdoc-ui/lints/unescaped_pipe_in_table_cell.stderr @@ -0,0 +1,31 @@ +error: table row has too many columns + --> $DIR/unescaped_pipe_in_table_cell.rs:5:18 + | +LL | //! | `code_with(|arg| arg)` | + | ^ 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/unescaped_pipe_in_table_cell.rs:1:9 + | +LL | #![deny(rustdoc::unescaped_pipe_in_table_cell)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: table row has too many columns + --> $DIR/unescaped_pipe_in_table_cell.rs:7:12 + | +LL | //! | one `|` b | + | ^ any content after this column divider is discarded + | + = help: to escape `|` characters in tables, add a `\` before them like `\|` + +error: table row has too many columns + --> $DIR/unescaped_pipe_in_table_cell.rs:14:18 + | +LL | //! | `code_with(|arg| arg)` | + | ^ any content after this column divider is discarded + | + = help: to escape `|` characters in tables, add a `\` before them like `\|` + +error: aborting due to 3 previous errors + From 6fd9b1bce2dd4cd99e32dc333104810bef776cc0 Mon Sep 17 00:00:00 2001 From: Guillaume Gomez Date: Mon, 20 Jul 2026 00:38:02 +0200 Subject: [PATCH 3/3] Add documentation for rustdoc `unescaped_pipe_in_table_cell` lint --- src/doc/rustdoc/src/lints.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/doc/rustdoc/src/lints.md b/src/doc/rustdoc/src/lints.md index 9dee33ef6eb85..8c284a339d210 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 ``` + +## `unescaped_pipe_in_table_cell` + +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/unescaped_pipe_in_table_cell.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/unescaped_pipe_in_table_cell.rs:1:9 + | +1 | #![deny(rustdoc::unescaped_pipe_in_table_cell)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +```