use std::path::PathBuf;
use crate::ast::{command_name, control_word_range};
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::semantic::signature;
use crate::syntax::SyntaxKind;
use super::{Example, Rule, RuleContext};
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 heading that descends more than one sectioning level below the \
preceding heading -- `\\section` straight to `\\subsubsection`, skipping \
`\\subsection` (textidote's `sh:secskip`). Standard sectioning commands \
form a fixed ladder (`\\part`, `\\chapter`, `\\section`, `\\subsection`, \
`\\subsubsection`, `\\paragraph`, `\\subparagraph`); descending it a rung \
at a time keeps the outline sound, and a jump usually signals the wrong \
command. Only *downward* jumps between consecutive headings are flagged -- \
climbing back up to close sections is normal, as are repeated headings at \
one level. The comparison is relative to the previous heading, never an \
absolute top level, so an `article` opening with `\\section` is fine. \
Report-only: repairing a skip (promote the heading or insert an \
intermediate one) is a structural choice for the author, not a \
correct-by-construction edit."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let mut prev_level: Option<u8> = None;
for node in ctx.root.descendants() {
if node.kind() != SyntaxKind::COMMAND {
continue;
}
let Some(name) = command_name(&node) else {
continue;
};
let Some(level) = signature::builtin()
.command(&name)
.and_then(|c| c.sectioning)
else {
continue;
};
if let Some(prev) = prev_level
&& level > prev + 1
{
let expected = LEVEL_NAMES[(prev + 1) 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: self.id(),
severity: self.default_severity(),
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,
});
}
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 {
path: std::path::Path::new("x.tex"),
root: &root,
model: &model,
resolution: None,
citations: None,
};
let mut out = Vec::new();
SectioningLevelJump.check_file(&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() {
let out = findings("\\part{A}\n\\section{B}\n");
assert_eq!(out.len(), 1);
assert!(
out[0].message.contains("expected `\\chapter`"),
"got: {}",
out[0].message
);
}
#[test]
fn non_sectioning_commands_ignored() {
assert!(findings("\\textbf{A}\n\\emph{B}\n\\label{c}\n").is_empty());
}
#[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);
}
}