use std::path::PathBuf;
use crate::ast::environment_name;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::semantic::signature;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "Text after `\\end{verbatim}` is silently discarded by LaTeX:",
source: "\\begin{verbatim}\nsample\n\\end{verbatim} and more\n",
}];
pub struct VerbatimTrailingText;
impl Rule for VerbatimTrailingText {
fn id(&self) -> &'static str {
"verbatim-trailing-text"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag non-whitespace text after a verbatim-like environment's `\\end{…}` \
on the same line (ChkTeX warning 31). LaTeX closes a verbatim environment \
by scanning line by line to `\\end{verbatim}` and then gobbling the rest \
of that line, so `\\end{verbatim} foo` silently drops `foo`. Scoped to \
verbatim-like environments — read off the parse tree (an opaque \
`VERBATIM_BODY`, or a curated built-in verbatim name for the empty-body \
case) — because ordinary environments do not gobble their `\\end` line. A \
trailing `%` comment is treated as trivia, not flagged. Report-only: \
whether to move or delete the swallowed text is the author's call, so no \
fix is correct by construction."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::ENVIRONMENT]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(env) = el.as_node() else {
return;
};
let Some(name) = verbatim_env_name(env) else {
return;
};
if !env.children().any(|c| c.kind() == SyntaxKind::END) {
return;
}
let Some((start, end)) = trailing_run(env) else {
return;
};
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!("text after `\\end{{{name}}}` on the same line is silently discarded"),
fix: None,
});
}
}
fn verbatim_env_name(env: &SyntaxNode) -> Option<String> {
let name = env
.children()
.find(|c| c.kind() == SyntaxKind::BEGIN)
.and_then(|begin| environment_name(&begin))?;
let has_body = env
.children()
.any(|c| c.kind() == SyntaxKind::VERBATIM_BODY);
let is_builtin_verbatim = signature::builtin()
.environment(&name)
.is_some_and(|sig| sig.verbatim_body);
(has_body || is_builtin_verbatim).then_some(name)
}
fn trailing_run(env: &SyntaxNode) -> Option<(usize, usize)> {
let mut start: Option<usize> = None;
let mut end: Option<usize> = None;
let mut cursor = env.next_sibling_or_token();
while let Some(el) = cursor {
match el.kind() {
SyntaxKind::NEWLINE => break,
SyntaxKind::WHITESPACE | SyntaxKind::COMMENT => {}
_ => {
let range = el.text_range();
start.get_or_insert(usize::from(range.start()));
end = Some(usize::from(range.end()));
}
}
cursor = next_of(&el);
}
Some((start?, end?))
}
fn next_of(el: &SyntaxElement) -> Option<SyntaxElement> {
match el {
rowan::NodeOrToken::Node(node) => node.next_sibling_or_token(),
rowan::NodeOrToken::Token(token) => token.next_sibling_or_token(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
fn findings(src: &str) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext::new(std::path::Path::new("x.tex"), &root, &model, None, None);
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if VerbatimTrailingText.interests().contains(&el.kind()) {
VerbatimTrailingText.check(&el, &ctx, &mut out);
}
}
out
}
#[test]
fn flags_trailing_text() {
let src = "\\begin{verbatim}\ncode\n\\end{verbatim} trailing text\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "verbatim-trailing-text");
assert!(
out[0].message.contains("\\end{verbatim}"),
"{}",
out[0].message
);
assert!(out[0].fix.is_none());
assert_eq!(&src[out[0].start..out[0].end], "trailing text");
}
#[test]
fn flags_trailing_text_on_empty_verbatim() {
let src = "\\begin{verbatim}\\end{verbatim} oops\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "oops");
}
#[test]
fn flags_trailing_text_on_lstlisting() {
let src = "\\begin{lstlisting}\nx\n\\end{lstlisting} tail\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "tail");
}
#[test]
fn flags_at_end_of_file_without_newline() {
let src = "\\begin{verbatim}\nx\n\\end{verbatim} tail";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "tail");
}
#[test]
fn span_stops_before_the_line_break() {
let src = "\\begin{verbatim}\nx\n\\end{verbatim} a b\nnext line\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "a b");
}
#[test]
fn silent_when_end_is_alone_on_its_line() {
assert!(findings("\\begin{verbatim}\nx\n\\end{verbatim}\nnext\n").is_empty());
}
#[test]
fn silent_on_trailing_whitespace_only() {
assert!(findings("\\begin{verbatim}\nx\n\\end{verbatim} \n").is_empty());
}
#[test]
fn silent_on_trailing_comment_only() {
assert!(findings("\\begin{verbatim}\nx\n\\end{verbatim} % note\n").is_empty());
}
#[test]
fn text_before_a_comment_is_still_flagged() {
let src = "\\begin{verbatim}\nx\n\\end{verbatim} foo % note\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(&src[out[0].start..out[0].end], "foo");
}
#[test]
fn silent_on_non_verbatim_environment() {
assert!(findings("\\begin{itemize}\\item a\\end{itemize} text\n").is_empty());
}
#[test]
fn flags_each_verbatim_occurrence() {
let src = "\\begin{verbatim}\na\n\\end{verbatim} one\n\
\\begin{verbatim}\nb\n\\end{verbatim} two\n";
assert_eq!(findings(src).len(), 2);
}
}