use std::path::PathBuf;
use crate::ast::command_name;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::semantic::signature;
use crate::semantic::signature::{ArgKind, ArgSpec, ContentKind};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::{Example, Rule, RuleContext};
const EXAMPLES: &[Example] = &[Example {
caption: "A blank line separating two keys, which aborts the call:",
source: "\\hypersetup{colorlinks=true,\n\nlinkcolor=blue}\n",
}];
pub struct BlankLineInKeyval;
impl Rule for BlankLineInKeyval {
fn id(&self) -> &'static str {
"blank-line-in-keyval"
}
fn emits_fix(&self) -> bool {
true
}
fn default_severity(&self) -> Severity {
Severity::Error
}
fn description(&self) -> &'static str {
"Flag a blank line at the top level of a `key=value` argument. A blank \
line is a `\\par` token and a keyval processor walks its entries with \
macros that are not `\\long`, so the call aborts -- and the error TeX \
reports names the processor rather than the command the author wrote \
(`\\hypersetup` yields \"Paragraph ended before \
`\\kv@processor@default` was complete\"), which is what makes the \
finding worth more than the compiler's own message. Scoped by \
measurement: a blank line *nested* inside a value's brace group \
(`\\tikzset{aa/.style={draw,\\n\\nthick}}`) compiles clean and is not \
flagged, an unclosed `{` is left to the parse error it already draws, \
and only the hand-curated signature tier is consulted. The autofix \
drops the blank line and keeps the following indentation; it is safe \
by construction, since it edits only whitespace and \
`ContentKind::Keyval` is exactly the claim that the processor strips \
spaces around entries."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::COMMAND]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(command) = el.as_node() else {
return;
};
let Some(name) = command_name(command) else {
return;
};
let Some(sig) = signature::builtin().command(&name) else {
return;
};
if !sig.args.iter().any(is_keyval_brace) {
return;
}
if ctx.user_definitions().command(&name).is_some() {
return;
}
let mut slot = 0usize;
for child in command.children() {
let is_bracket = match child.kind() {
SyntaxKind::GROUP => false,
SyntaxKind::OPTIONAL => true,
_ => continue,
};
let Some(spec) = match_arg_slot(&sig.args, &mut slot, is_bracket) else {
continue;
};
if !is_keyval_brace(&spec) || !is_closed(&child) {
continue;
}
for run in blank_runs(&child) {
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start: run.start,
end: run.end,
message: format!(
"blank line in `\\{name}`'s key-value argument; the `\\par` \
aborts the call"
),
fix: Some(Fix::safe(
run.start,
run.end,
run.joined.clone(),
"Remove the blank line",
)),
related: Vec::new(),
});
}
}
}
}
fn is_keyval_brace(spec: &ArgSpec) -> bool {
spec.required && spec.kind == ArgKind::Brace && spec.content == ContentKind::Keyval
}
fn match_arg_slot(args: &[ArgSpec], slot: &mut usize, is_bracket: bool) -> Option<ArgSpec> {
while *slot < args.len() {
let spec = args[*slot];
if (spec.kind == ArgKind::Bracket) == is_bracket {
*slot += 1;
return Some(spec);
}
if is_bracket {
return None;
}
*slot += 1; }
None
}
fn is_closed(group: &SyntaxNode) -> bool {
group
.children_with_tokens()
.filter_map(|e| e.into_token())
.any(|t| matches!(t.kind(), SyntaxKind::R_BRACE | SyntaxKind::R_BRACKET))
}
struct BlankRun {
start: usize,
end: usize,
joined: String,
}
fn blank_runs(group: &SyntaxNode) -> Vec<BlankRun> {
let mut out = Vec::new();
let mut run: Vec<crate::syntax::SyntaxToken> = Vec::new();
let mut flush = |run: &mut Vec<crate::syntax::SyntaxToken>| {
if run
.iter()
.filter(|t| t.kind() == SyntaxKind::NEWLINE)
.count()
>= 2
{
let start = usize::from(run[0].text_range().start());
let end = usize::from(run[run.len() - 1].text_range().end());
let text: String = run.iter().map(|t| t.text()).collect();
let joined = text
.rfind('\n')
.map(|i| text[i..].to_string())
.unwrap_or_default();
out.push(BlankRun { start, end, joined });
}
run.clear();
};
for element in group.children_with_tokens() {
match element {
SyntaxElement::Token(t)
if matches!(t.kind(), SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE) =>
{
run.push(t);
}
_ => flush(&mut run),
}
}
flush(&mut run);
out
}
#[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();
for el in root.descendants_with_tokens() {
if BlankLineInKeyval.interests().contains(&el.kind()) {
BlankLineInKeyval.check(&el, &ctx, &mut out);
}
}
out
}
fn fixed(src: &str) -> String {
let diags = findings(src);
let fixes: Vec<_> = diags.iter().filter_map(|d| d.fix.clone()).collect();
crate::linter::fix::apply_fixes(src, &fixes, false).output
}
#[test]
fn flags_a_blank_line_between_entries() {
let diags = findings("\\hypersetup{colorlinks=true,\n\nlinkcolor=blue}\n");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].rule, "blank-line-in-keyval");
assert_eq!(diags[0].severity, Severity::Error);
assert_eq!(
&"\\hypersetup{colorlinks=true,\n\nlinkcolor=blue}\n"[diags[0].start..diags[0].end],
"\n\n"
);
}
#[test]
fn fix_removes_the_blank_line_and_keeps_indentation() {
assert_eq!(
fixed("\\lstset{numbers=left,\n\n frame=single}\n"),
"\\lstset{numbers=left,\n frame=single}\n"
);
}
#[test]
fn single_newline_is_not_a_paragraph_break() {
assert!(findings("\\lstset{numbers=left,\n frame=single}\n").is_empty());
}
#[test]
fn a_blank_line_nested_in_a_value_compiles_and_is_not_flagged() {
assert!(findings("\\tikzset{aa/.style={draw,\n\nthick}}\n").is_empty());
}
#[test]
fn an_unclosed_group_is_left_to_its_parse_error() {
assert!(findings("\\lstset{numbers=left\n\nsome prose\n").is_empty());
}
#[test]
fn a_non_keyval_argument_is_not_flagged() {
assert!(findings("\\caption{one\n\ntwo}\n").is_empty());
}
#[test]
fn an_omitted_leading_optional_still_binds_the_keyval_slot() {
let diags = findings("\\setlist{noitemsep,\n\ntopsep=0pt}\n");
assert_eq!(diags.len(), 1);
}
#[test]
fn a_present_leading_optional_still_binds_the_keyval_slot() {
let diags = findings("\\setlist[itemize]{noitemsep,\n\ntopsep=0pt}\n");
assert_eq!(diags.len(), 1);
}
#[test]
fn a_redefined_name_is_the_users_macro() {
assert!(findings("\\renewcommand{\\lstset}[1]{#1}\n\\lstset{a=1,\n\nb=2}\n").is_empty());
}
#[test]
fn a_leading_blank_line_counts() {
assert_eq!(findings("\\lstset{\n\nnumbers=left}\n").len(), 1);
}
#[test]
fn a_comment_between_newlines_is_not_a_blank_line() {
assert!(findings("\\lstset{a=1,\n% note\nb=2}\n").is_empty());
}
#[test]
fn two_blank_lines_report_separately() {
assert_eq!(findings("\\lstset{a=1,\n\nb=2,\n\nc=3}\n").len(), 2);
}
}