use std::path::PathBuf;
use crate::ast::{command_name, control_word_range, nth_group_text};
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::semantic::signature;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::{Example, Rule, RuleContext, StreamVisitor};
const LEVEL_NAMES: [&str; 7] = [
"part",
"chapter",
"section",
"subsection",
"subsubsection",
"paragraph",
"subparagraph",
];
const EXAMPLES: &[Example] = &[Example {
caption: "A heading that drops two levels at once (skipping `\\subsection`):",
source: "\\section{Introduction}\n\\subsubsection{Details}\n",
}];
pub struct SectioningLevelJump;
impl Rule for SectioningLevelJump {
fn id(&self) -> &'static str {
"sectioning-level-jump"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a structural heading that descends more than one level below the \
preceding structural heading -- `\\section` straight to \
`\\subsubsection`, skipping `\\subsection` (textidote's `sh:secskip`). \
The active ladder follows the document class: `\\chapter` is included \
only for classes known to provide it or when the source uses it, while \
unknown classes conservatively omit it. `\\paragraph` and \
`\\subparagraph` are transparent because technical papers commonly use \
them as run-in labels rather than outline subdivisions. Only *downward* \
jumps are flagged -- climbing back up and repeated headings at one level \
are normal. The comparison is relative to the previous structural \
heading, never an absolute top level. Report-only: repairing a skip is a \
structural choice for the author, not a correct-by-construction edit."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn stream(&self) -> Option<Box<dyn StreamVisitor>> {
Some(Box::new(SectioningLevelJumpVisitor {
prev_level: None,
has_chapter: false,
}))
}
}
struct SectioningLevelJumpVisitor {
prev_level: Option<u8>,
has_chapter: bool,
}
fn class_has_chapter(name: &str) -> bool {
matches!(
name,
"amsbook"
| "book"
| "extbook"
| "extreport"
| "memoir"
| "report"
| "scrbook"
| "scrreprt"
| "tufte-book"
)
}
fn active_level(level: u8, has_chapter: bool) -> u8 {
if !has_chapter && level > 1 {
level - 1
} else {
level
}
}
fn is_starred(command: &SyntaxNode) -> bool {
for child in command.children_with_tokens() {
match child {
SyntaxElement::Node(n) if n.kind() == SyntaxKind::DOC_COMMENT => continue,
SyntaxElement::Node(_) => return false,
SyntaxElement::Token(t) => match t.kind() {
SyntaxKind::CONTROL_WORD
| SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::COMMENT => continue,
SyntaxKind::WORD if t.text() == "*" => return true,
_ => return false,
},
}
}
false
}
impl StreamVisitor for SectioningLevelJumpVisitor {
fn visit(&mut self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(node) = el.as_node() else {
return;
};
if node.kind() != SyntaxKind::COMMAND {
return;
}
let Some(name) = command_name(node) else {
return;
};
if name == "documentclass" {
self.has_chapter =
nth_group_text(node, 0).is_some_and(|class| class_has_chapter(class.trim()));
return;
}
let Some(level) = signature::builtin()
.command(&name)
.and_then(|c| c.sectioning)
else {
return;
};
if is_starred(node) {
return;
}
if matches!(name.as_str(), "paragraph" | "subparagraph") {
return;
}
if name == "chapter" {
self.has_chapter = true;
}
if let Some(prev) = self.prev_level
&& active_level(level, self.has_chapter) > active_level(prev, self.has_chapter) + 1
{
let expected_level = if !self.has_chapter && prev == 0 {
2 } else {
prev + 1
};
let expected = LEVEL_NAMES[expected_level as usize];
let previous = LEVEL_NAMES[prev as usize];
let range = control_word_range(node).unwrap_or_else(|| node.text_range());
sink.push(Diagnostic {
rule: "sectioning-level-jump",
severity: Severity::Warning,
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message: format!(
"`\\{name}` skips a sectioning level after `\\{previous}` \
(expected `\\{expected}`)"
),
fix: None,
related: Vec::new(),
});
}
self.prev_level = Some(level);
}
}
#[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,
None,
);
let mut out = Vec::new();
let mut visitor = SectioningLevelJump.stream().expect("streaming rule");
for el in root.descendants_with_tokens() {
visitor.visit(&el, &ctx, &mut out);
}
visitor.finish(&ctx, &mut out);
out
}
#[test]
fn flags_section_to_subsubsection() {
let src = "\\section{A}\n\\subsubsection{B}\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "sectioning-level-jump");
assert!(
out[0].message.contains("\\subsubsection")
&& out[0].message.contains("\\section")
&& out[0].message.contains("expected `\\subsection`"),
"got: {}",
out[0].message
);
assert!(out[0].fix.is_none());
let at = src.find("\\subsubsection").unwrap();
assert_eq!(
(out[0].start, out[0].end),
(at, at + "\\subsubsection".len())
);
}
#[test]
fn stepwise_descent_is_fine() {
assert!(findings("\\section{A}\n\\subsection{B}\n\\subsubsection{C}\n").is_empty());
}
#[test]
fn climbing_back_up_is_fine() {
assert!(
findings("\\section{A}\n\\subsection{B}\n\\subsubsection{C}\n\\section{D}\n")
.is_empty()
);
}
#[test]
fn repeated_same_level_is_fine() {
assert!(findings("\\section{A}\n\\section{B}\n\\section{C}\n").is_empty());
}
#[test]
fn first_heading_sets_baseline_not_flagged() {
assert!(findings("\\subsubsection{A}\n").is_empty());
}
#[test]
fn sibling_after_jump_is_not_reflagged() {
let out = findings("\\section{A}\n\\subsubsection{B}\n\\subsubsection{C}\n");
assert_eq!(out.len(), 1);
}
#[test]
fn part_to_section_skips_chapter_in_book() {
let out = findings("\\documentclass{book}\n\\part{A}\n\\section{B}\n");
assert_eq!(out.len(), 1);
assert!(
out[0].message.contains("expected `\\chapter`"),
"got: {}",
out[0].message
);
}
#[test]
fn part_to_section_is_fine_in_article() {
assert!(findings("\\documentclass{article}\n\\part{A}\n\\section{B}\n").is_empty());
}
#[test]
fn unknown_class_does_not_assume_a_chapter_level() {
assert!(findings("\\documentclass{custom}\n\\part{A}\n\\section{B}\n").is_empty());
}
#[test]
fn an_encountered_chapter_proves_the_level_exists() {
let out = findings("\\documentclass{custom}\n\\chapter{A}\n\\subsection{B}\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("expected `\\section`"));
}
#[test]
fn part_to_subsection_skips_section_in_article() {
let out = findings("\\documentclass{article}\n\\part{A}\n\\subsection{B}\n");
assert_eq!(out.len(), 1);
assert!(
out[0].message.contains("expected `\\section`"),
"got: {}",
out[0].message
);
}
#[test]
fn paragraph_headings_are_transparent() {
assert!(findings("\\section{A}\n\\paragraph{B}\n\\subparagraph{C}\n").is_empty());
}
#[test]
fn paragraph_does_not_hide_a_later_jump() {
let out = findings("\\section{A}\n\\paragraph{note}\n\\subsubsection{B}\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("expected `\\subsection`"));
}
#[test]
fn non_sectioning_commands_ignored() {
assert!(findings("\\textbf{A}\n\\emph{B}\n\\label{c}\n").is_empty());
}
#[test]
fn starred_heading_is_not_flagged() {
assert!(findings("\\section{A}\n\\subsubsection*{B}\n").is_empty());
}
#[test]
fn starred_heading_with_leading_comment_is_not_flagged() {
let src = "\\section{A}\n%a comment\n%another\n\\subsubsection*{B}\n";
assert!(findings(src).is_empty(), "got: {:?}", findings(src));
}
#[test]
fn starred_heading_does_not_set_baseline() {
let out = findings("\\section{A}\n\\subsubsection*{note}\n\\subsubsection{B}\n");
assert_eq!(out.len(), 1, "got: {out:?}");
assert!(out[0].message.contains("expected `\\subsection`"));
}
#[test]
fn each_jump_flagged_independently() {
let out = findings("\\section{A}\n\\subsubsection{B}\n\\section{C}\n\\subsubsection{D}\n");
assert_eq!(out.len(), 2);
}
}