use rowan::{NodeOrToken, TextRange, TextSize};
use crate::linter::diagnostics::{Diagnostic, Edit, Fix, Location};
use crate::linter::rules::{DiagnosticCode, LintContext, Requirement, Rule, RuleMeta};
use crate::syntax::{SyntaxKind, SyntaxNode};
pub struct FootnoteSwallowedByBracketRule;
#[derive(Clone, Copy)]
enum Swallower {
Bracket,
Paren,
}
impl Rule for FootnoteSwallowedByBracketRule {
fn name(&self) -> &str {
"footnote-swallowed-by-bracket"
}
fn metadata(&self) -> RuleMeta {
RuleMeta {
name: "footnote-swallowed-by-bracket",
default_on: true,
requires: Requirement::InlineFootnotes,
auto_fix: true,
codes: const { &[DiagnosticCode::warning("footnote-swallowed-by-bracket")] },
}
}
fn node_interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::LINK, SyntaxKind::UNRESOLVED_REFERENCE]
}
fn check(&self, cx: &LintContext) -> Vec<Diagnostic> {
let input = cx.input;
let mut diagnostics = Vec::new();
for kind in [SyntaxKind::LINK, SyntaxKind::UNRESOLVED_REFERENCE] {
for node in cx.nodes(kind) {
let Some(caret_start) = preceding_caret(node) else {
continue;
};
let Some(swallower) = classify(node) else {
continue;
};
let opener_end = node.text_range().start() + TextSize::from(1);
let location = Location::from_range(TextRange::new(caret_start, opener_end), input);
let mut diagnostic = Diagnostic::warning(
location,
"footnote-swallowed-by-bracket",
"Inline footnote is followed directly by a bracket, so pandoc reads \
the label as a link instead and drops the footnote; insert a space \
after the closing `]`",
);
if let Some(insert_at) = label_close_end(node) {
let edits = vec![Edit {
range: TextRange::new(insert_at, insert_at),
replacement: " ".to_string(),
}];
let message = "Insert a space after the inline footnote";
diagnostic = diagnostic.with_fix(match swallower {
Swallower::Bracket => Fix::safe(message, edits),
Swallower::Paren => Fix::unsafe_fix(message, edits),
});
}
diagnostics.push(diagnostic);
}
}
diagnostics
}
}
fn preceding_caret(node: &SyntaxNode) -> Option<TextSize> {
let prev = node.prev_sibling_or_token()?;
let NodeOrToken::Token(token) = prev else {
return None;
};
if token.kind() != SyntaxKind::TEXT || !token.text().ends_with('^') {
return None;
}
Some(token.text_range().end() - TextSize::from(1))
}
fn classify(node: &SyntaxNode) -> Option<Swallower> {
let after_label = node
.children_with_tokens()
.skip_while(|child| child.kind() != SyntaxKind::LINK_TEXT)
.nth(2)?;
match after_label.kind() {
SyntaxKind::LINK_DEST_START => Some(Swallower::Paren),
_ if after_label.as_token().is_some_and(|t| t.text() == "[") => Some(Swallower::Bracket),
_ => None,
}
}
fn label_close_end(node: &SyntaxNode) -> Option<TextSize> {
let label = node
.children_with_tokens()
.find(|child| child.kind() == SyntaxKind::LINK_TEXT)?;
let close = node
.children_with_tokens()
.skip_while(|child| child.kind() != SyntaxKind::LINK_TEXT)
.nth(1)?;
if close.as_token().is_none_or(|t| t.text() != "]") {
return None;
}
debug_assert_eq!(close.text_range().start(), label.text_range().end());
Some(close.text_range().end())
}