use crate::linter::diagnostics::{Diagnostic, DiagnosticNoteKind, Location};
use crate::linter::rules::{DiagnosticCode, LintContext, Requirement, Rule, RuleMeta};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
pub const FOOTNOTE_AFTER_IMAGE: &str = "footnote-after-image";
pub struct FootnoteAfterImageRule;
struct Stakes {
caption: bool,
id: bool,
}
impl Stakes {
fn worth_reporting(&self) -> bool {
self.caption || self.id
}
}
impl Rule for FootnoteAfterImageRule {
fn name(&self) -> &str {
FOOTNOTE_AFTER_IMAGE
}
fn metadata(&self) -> RuleMeta {
RuleMeta {
name: FOOTNOTE_AFTER_IMAGE,
default_on: true,
requires: Requirement::Footnotes,
auto_fix: false,
codes: const { &[DiagnosticCode::warning(FOOTNOTE_AFTER_IMAGE)] },
}
}
fn node_interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::PARAGRAPH]
}
fn check(&self, cx: &LintContext) -> Vec<Diagnostic> {
if !cx.config.extensions.implicit_figures {
return Vec::new();
}
let mut diagnostics = Vec::new();
for paragraph in cx.nodes(SyntaxKind::PARAGRAPH) {
let significant = significant_children(paragraph);
let [first, second] = significant.as_slice() else {
continue;
};
if first.kind() != SyntaxKind::IMAGE_LINK || !is_footnote(second.kind()) {
continue;
}
let (Some(image), Some(footnote)) = (first.as_node(), second.as_node()) else {
continue;
};
if let Some(diagnostic) = report(cx, image, footnote) {
diagnostics.push(diagnostic);
}
}
diagnostics.sort_by_key(|d| d.location.range.start());
diagnostics
}
}
fn report(cx: &LintContext, image: &SyntaxNode, footnote: &SyntaxNode) -> Option<Diagnostic> {
let stakes = stakes(image);
if !stakes.worth_reporting() {
return None;
}
let location = Location::from_node(footnote, cx.input);
let mut diagnostic = Diagnostic::warning(
location,
FOOTNOTE_AFTER_IMAGE,
"footnote attached to a standalone image keeps it from becoming a figure",
)
.with_note(
DiagnosticNoteKind::Note,
"an image becomes a figure only when it is alone in its paragraph; \
the trailing footnote demotes it",
);
if stakes.caption {
diagnostic = diagnostic.with_note(
DiagnosticNoteKind::Note,
"the caption text will render as the image's alt attribute, not as a caption",
);
}
if stakes.id {
diagnostic = diagnostic.with_note(
DiagnosticNoteKind::Note,
"the id no longer labels a figure, so cross-references to it will not resolve",
);
}
Some(diagnostic.with_note(
DiagnosticNoteKind::Help,
"move the footnote inside the caption, as in `![Caption. ^[note]](img.jpg)`, \
or separate it from the image with a blank line to keep the figure",
))
}
fn stakes(image: &SyntaxNode) -> Stakes {
let caption = child_of_kind(image, SyntaxKind::IMAGE_ALT)
.is_some_and(|alt| !alt.text().to_string().trim().is_empty());
let id = image
.descendants_with_tokens()
.any(|element| element.kind() == SyntaxKind::ATTR_ID);
Stakes { caption, id }
}
fn is_footnote(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::INLINE_FOOTNOTE | SyntaxKind::FOOTNOTE_REFERENCE
)
}
fn significant_children(node: &SyntaxNode) -> Vec<SyntaxElement> {
node.children_with_tokens()
.filter(|element| !is_trivia(element))
.collect()
}
fn is_trivia(element: &SyntaxElement) -> bool {
match element {
SyntaxElement::Token(token) => {
matches!(
token.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::BLANK_LINE
) || (token.kind() == SyntaxKind::TEXT && token.text().trim().is_empty())
}
SyntaxElement::Node(node) => matches!(node.kind(), SyntaxKind::BLANK_LINE),
}
}
fn child_of_kind(node: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxNode> {
node.children().find(|child| child.kind() == kind)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, Flavor};
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()));
FootnoteAfterImageRule.check_tree(&tree, input, &config, None)
}
fn quarto() -> Config {
Config {
flavor: Flavor::Quarto,
..Config::default()
}
}
#[test]
fn issue_456_repro() {
let input = "{#fig-1}\n^[A note about the figure.]\n";
let diagnostics = lint_with(input, quarto());
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].code, FOOTNOTE_AFTER_IMAGE);
assert_eq!(diagnostics[0].location.line, 2);
assert!(diagnostics[0].fix.is_none());
}
#[test]
fn span_covers_only_the_footnote() {
let input = "\n^[note]\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
let range = diagnostics[0].location.range;
assert_eq!(&input[range], "^[note]");
}
#[test]
fn flags_same_line_footnote() {
let input = "^[note]\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].location.line, 1);
}
#[test]
fn flags_same_line_footnote_after_space() {
let diagnostics = parse_and_lint(" ^[note]\n");
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
}
#[test]
fn flags_footnote_reference() {
let input = "\n[^1]\n\n[^1]: The note body.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].location.line, 2);
}
#[test]
fn flags_multiline_inline_footnote() {
let input = "{#fig-1}\n^[First line.\n Second line.]\n";
let diagnostics = lint_with(input, quarto());
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert_eq!(diagnostics[0].location.line, 2);
}
#[test]
fn flags_image_with_id_but_no_caption() {
let diagnostics = lint_with("{#fig-1}\n^[note]\n", quarto());
assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
assert!(
diagnostics[0]
.notes
.iter()
.any(|note| note.message.contains("cross-references")),
"{diagnostics:#?}"
);
}
#[test]
fn flags_each_occurrence() {
let input = "\n^[first]\n\n\n^[second]\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 2, "{diagnostics:#?}");
assert_eq!(diagnostics[0].location.line, 2);
assert_eq!(diagnostics[1].location.line, 5);
}
#[test]
fn does_not_flag_footnote_inside_caption() {
let input = "![A caption ^[note here.] end.](img.jpg){#fig-1}\n";
assert!(lint_with(input, quarto()).is_empty());
}
#[test]
fn does_not_flag_footnote_after_blank_line() {
let input = "{#fig-1}\n\n^[A separate remark.]\n";
assert!(lint_with(input, quarto()).is_empty());
}
#[test]
fn does_not_flag_image_without_caption_or_id() {
assert!(parse_and_lint("\n^[note]\n").is_empty());
}
#[test]
fn does_not_flag_when_paragraph_has_other_prose() {
let input = "\n^[note] and trailing prose\n";
assert!(parse_and_lint(input).is_empty(), "{input:?}");
}
#[test]
fn does_not_flag_inline_image_amid_prose() {
let input = "See  here^[note] for details.\n";
assert!(parse_and_lint(input).is_empty());
}
#[test]
fn does_not_flag_footnote_after_plain_paragraph() {
assert!(parse_and_lint("Just prose.\n^[note]\n").is_empty());
}
#[test]
fn does_not_flag_without_implicit_figures() {
let mut config = Config::default();
config.extensions.implicit_figures = false;
assert!(lint_with("\n^[note]\n", config.clone()).is_empty());
assert!(lint_with("^[note]\n", config).is_empty());
}
}