use rowan::{TextRange, TextSize};
use crate::linter::diagnostics::{Diagnostic, DiagnosticNoteKind, Edit, Fix, Location};
use crate::linter::rules::{DiagnosticCode, LintContext, Requirement, Rule, RuleMeta};
use crate::syntax::{AstNode, FootnoteReference, SyntaxKind, SyntaxNode};
pub struct ReversedFootnoteMarkerRule;
impl Rule for ReversedFootnoteMarkerRule {
fn name(&self) -> &str {
"reversed-footnote-marker"
}
fn metadata(&self) -> RuleMeta {
RuleMeta {
name: "reversed-footnote-marker",
default_on: true,
requires: Requirement::InlineFootnotes,
auto_fix: true,
codes: const { &[DiagnosticCode::warning("reversed-footnote-marker")] },
}
}
fn node_interests(&self) -> &'static [SyntaxKind] {
&[
SyntaxKind::FOOTNOTE_REFERENCE,
SyntaxKind::UNRESOLVED_REFERENCE,
SyntaxKind::CITATION,
]
}
fn check(&self, cx: &LintContext) -> Vec<Diagnostic> {
let input = cx.input;
let mut diagnostics = Vec::new();
for node in cx.nodes(SyntaxKind::FOOTNOTE_REFERENCE) {
let Some(footnote_ref) = FootnoteReference::cast(node.clone()) else {
continue;
};
if !footnote_ref.id().chars().any(char::is_whitespace) {
continue;
}
diagnostics.push(diagnostic(
node,
input,
"Footnote label contains whitespace, so pandoc does not read `[^...]` as a \
footnote reference; write `^[...]` for an inline footnote",
));
}
for node in cx.nodes(SyntaxKind::UNRESOLVED_REFERENCE) {
if !opens_with_caret(node, input) {
continue;
}
diagnostics.push(diagnostic(
node,
input,
"`[^...]` is not a footnote reference here, so pandoc renders these brackets \
as literal text; write `^[...]` for an inline footnote",
));
}
for node in cx.nodes(SyntaxKind::CITATION) {
if !opens_with_caret(node, input) {
continue;
}
diagnostics.push(diagnostic(
node,
input,
"`[^...]` is not a footnote reference here, so pandoc reads the bracket as a \
citation instead; write `^[...]` for an inline footnote",
));
}
diagnostics
}
}
fn opens_with_caret(node: &SyntaxNode, input: &str) -> bool {
let start = usize::from(node.text_range().start());
input[start..].starts_with("[^")
}
fn diagnostic(node: &SyntaxNode, input: &str, message: &str) -> Diagnostic {
let start = node.text_range().start();
let marker = TextRange::new(start, start + TextSize::from(2));
let diagnostic = Diagnostic::warning(
Location::from_range(marker, input),
"reversed-footnote-marker",
message,
)
.with_note(
DiagnosticNoteKind::Help,
"the reference form `[^id]` takes a bare label and needs a matching `[^id]:` definition",
);
if !swap_yields_a_footnote(node, input) {
return diagnostic;
}
diagnostic.with_fix(Fix::unsafe_fix(
"Swap `[^` for the inline-footnote opener `^[`",
vec![Edit {
range: marker,
replacement: "^[".to_string(),
}],
))
}
fn swap_yields_a_footnote(node: &SyntaxNode, input: &str) -> bool {
let end = usize::from(node.text_range().end());
!matches!(input[end..].chars().next(), Some('[') | Some('('))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, Flavor};
use crate::linter::diagnostics::FixSafety;
fn parse_and_lint(input: &str) -> Vec<Diagnostic> {
lint_with(input, Config::default())
}
fn lint_with(input: &str, config: Config) -> Vec<Diagnostic> {
let tree = crate::parser::parse(input, Some(config.clone()));
ReversedFootnoteMarkerRule.check_tree(&tree, input, &config, None)
}
#[test]
fn flags_prose_in_a_footnote_reference() {
let input = "Coordinates [^also a note about them] follow.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].code, "reversed-footnote-marker");
assert_eq!(u32::from(diagnostics[0].location.range.len()), 2);
assert_eq!(diagnostics[0].location.column, 13);
}
#[test]
fn flags_prose_spanning_a_line_break() {
let input = "Coordinates [^also a note\nabout them] follow.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].code, "reversed-footnote-marker");
assert!(diagnostics[0].message.contains("literal text"));
}
#[test]
fn flags_prose_that_degrades_into_a_citation() {
let input = "Coordinates [^also a note\nabout them (@doe2026)] follow.\n";
let mut config = Config::default();
config.extensions.citations = true;
let diagnostics = lint_with(input, config);
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].code, "reversed-footnote-marker");
assert!(diagnostics[0].message.contains("citation"));
}
#[test]
fn fix_swaps_the_marker_and_is_unsafe() {
let input = "Coordinates [^also a note about them] follow.\n";
let diagnostics = parse_and_lint(input);
let fix = diagnostics[0].fix.as_ref().expect("fix");
assert_eq!(fix.safety, FixSafety::Unsafe);
assert_eq!(fix.edits.len(), 1);
assert_eq!(fix.edits[0].replacement, "^[");
let edit = &fix.edits[0];
let mut fixed = input.to_string();
fixed.replace_range(
usize::from(edit.range.start())..usize::from(edit.range.end()),
&edit.replacement,
);
assert_eq!(fixed, "Coordinates ^[also a note about them] follow.\n");
assert!(lint_with(&fixed, Config::default()).is_empty());
}
#[test]
fn no_fix_when_the_swap_would_build_a_link_instead() {
for input in [
"See [^some note](https://example.com) here.\n",
"See [^some note][ref] here.\n\n[ref]: /r\n",
] {
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1, "{input:?} -> {diagnostics:#?}");
assert!(
diagnostics[0].fix.is_none(),
"{input:?} should report without a fix, got {:#?}",
diagnostics[0].fix
);
}
}
#[test]
fn accepts_a_bare_footnote_label() {
let input = "Coordinates [^note] follow.\n\n[^note]: A note.\n";
assert!(parse_and_lint(input).is_empty());
assert!(parse_and_lint("Coordinates [^missing] follow.\n").is_empty());
}
#[test]
fn accepts_an_inline_footnote() {
let input = "Coordinates^[also a note about them] follow.\n";
assert!(parse_and_lint(input).is_empty());
}
#[test]
fn accepts_ordinary_brackets_and_citations() {
let input = "See [the docs] and [see @doe2026 for more].\n\n[the docs]: /docs\n";
let mut config = Config::default();
config.extensions.citations = true;
assert!(lint_with(input, config).is_empty());
}
#[test]
fn accepts_a_link_whose_text_starts_with_a_caret() {
let input = "See [^multi\nline](https://example.com) here.\n";
assert!(parse_and_lint(input).is_empty());
}
#[test]
fn gated_off_without_inline_footnotes() {
let input = "Coordinates [^also a note about them] follow.\n";
let config = Config {
flavor: Flavor::Gfm,
extensions: crate::config::Extensions::for_flavor(Flavor::Gfm),
..Default::default()
};
assert!(!config.extensions.inline_footnotes);
let tree = crate::parser::parse(input, Some(config.clone()));
let diagnostics = crate::linter::lint(&tree, input, &config);
assert!(
!diagnostics
.iter()
.any(|d| d.code == "reversed-footnote-marker"),
"GFM footnote labels go through markdown-it, which accepts spaces: {diagnostics:#?}"
);
}
}