use std::path::PathBuf;
use crate::ast::{command_name, control_word_range};
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::semantic::define::{is_definition_command, scan_definitions};
use crate::semantic::signature::{self, SignatureDb};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
use super::{Example, Rule, RuleContext, StreamVisitor};
const EXAMPLES: &[Example] = &[
Example {
caption: "A fraction missing its denominator:",
source: "$\\frac{1}$\n",
},
Example {
caption: "A command left bare at the end of a group, with nothing to take:",
source: "\\emph{see \\textbf}\n",
},
];
pub struct MissingRequiredArgument;
impl Rule for MissingRequiredArgument {
fn id(&self) -> &'static str {
"missing-required-argument"
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a command invoked with fewer `{…}` groups than the required arity \
in its curated built-in signature (ChkTeX warning 14, decided on the \
parse tree and signature database rather than line heuristics). TeX also \
accepts unbraced single-token arguments (`\\frac12`), so the rule stays \
silent whenever a following token could still supply the missing \
argument and fires only at a hard boundary: the end of the enclosing \
group, math shell, or environment, an alignment `&`, a `\\\\` line \
break, a blank line, or the end of the file. Contexts where a bare \
command is deliberate are skipped -- macro-definition bodies \
(`\\newcommand{\\bold}{\\textbf}`), arguments of unknown commands, \
standalone `{…}` scope groups, `\\let`-style alias forms, and names the \
file itself redefines. Report-only: the missing argument's content is \
the author's to write, so no fix is correct by construction."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn stream(&self) -> Option<Box<dyn StreamVisitor>> {
Some(Box::new(MissingRequiredArgumentVisitor { user_defs: None }))
}
}
struct MissingRequiredArgumentVisitor {
user_defs: Option<SignatureDb>,
}
impl StreamVisitor for MissingRequiredArgumentVisitor {
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;
};
let Some(sig) = signature::builtin().command(&name) else {
return;
};
if sig.verbatim {
return;
}
let required = sig.args.iter().filter(|a| a.required).count();
if required == 0 {
return;
}
let user_defs = self
.user_defs
.get_or_insert_with(|| scan_definitions(ctx.root));
if user_defs.command(&name).is_some() {
return;
}
let braced = node
.children()
.filter(|child| child.kind() == SyntaxKind::GROUP)
.count();
if braced >= required
|| in_unsafe_group(node)
|| follows_bare_command(node)
|| !at_hard_boundary(node)
{
return;
}
let range = control_word_range(node).unwrap_or_else(|| node.text_range());
let message = match (braced, required) {
(0, 1) => format!("`\\{name}` is missing its required argument"),
(0, _) => format!("`\\{name}` is missing its {required} required arguments"),
_ => format!(
"`\\{name}` is missing {} of its {required} required arguments",
required - braced
),
};
sink.push(Diagnostic {
rule: "missing-required-argument",
severity: Severity::Warning,
path: PathBuf::new(),
start: usize::from(range.start()),
end: usize::from(range.end()),
message,
fix: None,
});
}
}
fn is_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE | SyntaxKind::COMMENT | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD
)
}
fn in_unsafe_group(command: &SyntaxNode) -> bool {
for ancestor in command.ancestors().skip(1) {
if !matches!(ancestor.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) {
continue;
}
let Some(owner) = ancestor.parent() else {
return true;
};
match owner.kind() {
SyntaxKind::SUBSCRIPT | SyntaxKind::SUPERSCRIPT => {}
SyntaxKind::COMMAND => {
let known_non_definition = command_name(&owner).is_some_and(|owner_name| {
!is_definition_command(&owner_name)
&& signature::builtin().command(&owner_name).is_some()
});
if !known_non_definition {
return true;
}
}
_ => return true,
}
}
false
}
fn follows_bare_command(command: &SyntaxNode) -> bool {
let mut newlines = 0;
let mut prev = command.prev_sibling_or_token();
while let Some(el) = prev {
let kind = el.kind();
if kind == SyntaxKind::NEWLINE {
newlines += 1;
if newlines >= 2 {
return false; }
} else if !is_trivia(kind) {
return el.as_node().is_some_and(is_bare_command);
}
prev = prev_of(&el);
}
false
}
fn is_bare_command(node: &SyntaxNode) -> bool {
node.kind() == SyntaxKind::COMMAND
&& !node.children_with_tokens().any(|child| {
matches!(
child.kind(),
SyntaxKind::GROUP | SyntaxKind::OPTIONAL | SyntaxKind::VERB
)
})
}
fn at_hard_boundary(command: &SyntaxNode) -> bool {
let mut newlines = 0;
let mut cursor: SyntaxElement = command.clone().into();
loop {
let mut next = next_of(&cursor);
while let Some(el) = next {
let kind = el.kind();
if kind == SyntaxKind::NEWLINE {
newlines += 1;
if newlines >= 2 {
return true; }
} else if !is_trivia(kind) {
return match kind {
SyntaxKind::R_BRACE
| SyntaxKind::DOLLAR
| SyntaxKind::AMPERSAND
| SyntaxKind::LINE_BREAK
| SyntaxKind::END => true,
SyntaxKind::CONTROL_SYMBOL => {
el.as_token().is_some_and(|token| token.text() == "\\]")
}
_ => false,
};
}
next = next_of(&el);
}
match parent_of(&cursor) {
Some(parent) => cursor = parent.into(),
None => return true, }
}
}
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(),
}
}
fn prev_of(el: &SyntaxElement) -> Option<SyntaxElement> {
match el {
rowan::NodeOrToken::Node(node) => node.prev_sibling_or_token(),
rowan::NodeOrToken::Token(token) => token.prev_sibling_or_token(),
}
}
fn parent_of(el: &SyntaxElement) -> Option<SyntaxNode> {
match el {
rowan::NodeOrToken::Node(node) => node.parent(),
rowan::NodeOrToken::Token(token) => token.parent(),
}
}
#[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();
let mut visitor = MissingRequiredArgument.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_frac_missing_denominator_in_inline_math() {
let src = "$\\frac{1}$\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(out[0].rule, "missing-required-argument");
assert_eq!(
out[0].message,
"`\\frac` is missing 1 of its 2 required arguments"
);
assert!(out[0].fix.is_none());
let at = src.find("\\frac").unwrap();
assert_eq!((out[0].start, out[0].end), (at, at + "\\frac".len()));
}
#[test]
fn flags_bare_command_at_end_of_known_command_argument() {
let out = findings("\\emph{see \\textbf}\n");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].message,
"`\\textbf` is missing its required argument"
);
}
#[test]
fn flags_before_blank_line() {
let out = findings("\\frac{1}\n\nmore text\n");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].message,
"`\\frac` is missing 1 of its 2 required arguments"
);
}
#[test]
fn flags_at_end_of_file() {
let out = findings("\\textbf\n");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].message,
"`\\textbf` is missing its required argument"
);
}
#[test]
fn flags_completely_bare_frac() {
let out = findings("$\\frac$\n");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].message,
"`\\frac` is missing its 2 required arguments"
);
}
#[test]
fn flags_before_alignment_tab() {
let out = findings("\\begin{tabular}{ll}\n\\textbf & b\n\\end{tabular}\n");
assert_eq!(out.len(), 1);
assert!(
out[0].message.contains("\\textbf"),
"got: {}",
out[0].message
);
}
#[test]
fn flags_before_line_break_and_end_in_math_environment() {
let out = findings("\\begin{align}\n\\frac \\\\\nx&\\emph\n\\end{align}\n");
let names: Vec<&str> = out
.iter()
.map(|d| d.message.split('`').nth(1).unwrap())
.collect();
assert_eq!(names, vec!["\\frac", "\\emph"]);
}
#[test]
fn optional_group_does_not_satisfy_required_arity() {
let out = findings("$\\sqrt[3]$\n");
assert_eq!(out.len(), 1);
assert!(out[0].message.contains("\\sqrt"), "got: {}", out[0].message);
}
#[test]
fn silent_when_unbraced_token_could_be_the_argument() {
assert!(findings("$\\frac12$\n").is_empty());
assert!(findings("$\\frac{1}2$\n").is_empty());
assert!(findings("$\\frac\\alpha\\beta$\n").is_empty());
assert!(findings("\\textbf~x\n").is_empty());
}
#[test]
fn silent_when_arity_is_satisfied() {
assert!(findings("\\textbf{x} and $\\frac{1}{2}$\n").is_empty());
}
#[test]
fn silent_on_starred_form() {
assert!(findings("\\section*{A}\n").is_empty());
}
#[test]
fn silent_when_groups_attach_across_comment_and_newline() {
assert!(findings("$\\frac{1} % half\n{2}$\n").is_empty());
}
#[test]
fn silent_in_definition_body() {
assert!(findings("\\newcommand{\\bold}{\\textbf}\n").is_empty());
}
#[test]
fn silent_in_starred_definition_body() {
assert!(findings("\\newcommand*{\\bold}{\\textbf}\n").is_empty());
}
#[test]
fn silent_in_standalone_scope_group() {
assert!(findings("{\\textbf}\n").is_empty());
}
#[test]
fn silent_in_unknown_command_argument() {
assert!(findings("\\mymacro{\\textbf}\n").is_empty());
}
#[test]
fn silent_after_bare_command_alias_form() {
assert!(findings("\\let\\bold\\textbf\n\nx\n").is_empty());
}
#[test]
fn silent_when_name_is_redefined_in_the_file() {
assert!(findings("\\renewcommand{\\emph}{nothing}\nsee \\emph\n").is_empty());
}
#[test]
fn silent_on_verbatim_argument_command() {
assert!(findings("\\mintinline\n").is_empty());
}
}