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 CitationNonbreakingSpaceRule;
fn breakable_gap(citation: &SyntaxNode) -> Option<TextRange> {
let end = citation.text_range().start();
let mut start = end;
let mut saw_breakable = false;
let mut element = citation.prev_sibling_or_token();
let content = loop {
let Some(current) = element else {
return None;
};
let token = match current {
NodeOrToken::Node(_) => break current,
NodeOrToken::Token(ref token) => token.clone(),
};
match token.kind() {
SyntaxKind::NEWLINE => {
saw_breakable = true;
start = token.text_range().start();
element = token.prev_sibling_or_token();
}
SyntaxKind::NONBREAKING_SPACE => {
start = token.text_range().start();
element = token.prev_sibling_or_token();
}
SyntaxKind::TEXT => {
let text = token.text();
let trimmed = text.trim_end_matches([' ', '\t', '\u{a0}']);
if text[trimmed.len()..].contains([' ', '\t']) {
saw_breakable = true;
}
start = token.text_range().start() + TextSize::of(trimmed);
if trimmed.is_empty() {
element = token.prev_sibling_or_token();
} else {
break current;
}
}
_ => break current,
}
};
if !saw_breakable || content.kind() == SyntaxKind::HARD_LINE_BREAK {
return None;
}
Some(TextRange::new(start, end))
}
fn is_bracketed(citation: &SyntaxNode) -> bool {
citation
.children_with_tokens()
.next()
.is_some_and(|element| element.kind() == SyntaxKind::LINK_START)
}
impl Rule for CitationNonbreakingSpaceRule {
fn name(&self) -> &str {
"citation-nonbreaking-space"
}
fn metadata(&self) -> RuleMeta {
RuleMeta {
name: "citation-nonbreaking-space",
default_on: true,
requires: Requirement::Citations,
auto_fix: true,
codes: const { &[DiagnosticCode::warning("citation-nonbreaking-space")] },
}
}
fn node_interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::CITATION]
}
fn check(&self, cx: &LintContext) -> Vec<Diagnostic> {
let input = cx.input;
let (replacement, fix_message) = if cx.config.extensions.all_symbols_escapable {
("\\ ", "Replace with a non-breaking space (`\\ `)")
} else {
("\u{a0}", "Replace with a non-breaking space (U+00A0)")
};
let mut diagnostics = Vec::new();
for node in cx.nodes(SyntaxKind::CITATION) {
if !is_bracketed(node) {
continue;
}
let Some(gap) = breakable_gap(node) else {
continue;
};
let fix = Fix::safe(
fix_message,
vec![Edit {
range: gap,
replacement: replacement.to_string(),
}],
);
diagnostics.push(
Diagnostic::warning(
Location::from_range(gap, input),
"citation-nonbreaking-space",
"Breakable space before citation; the rendered citation can be \
stranded at the start of a line",
)
.with_fix(fix),
);
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn parse_and_lint(input: &str) -> Vec<Diagnostic> {
let config = Config::default();
lint_with_config(input, &config)
}
fn lint_with_config(input: &str, config: &Config) -> Vec<Diagnostic> {
let tree = crate::parser::parse(input, Some(config.clone()));
CitationNonbreakingSpaceRule.check_tree(&tree, input, config, None)
}
fn apply_fix(input: &str, diagnostic: &Diagnostic) -> String {
let edit = &diagnostic.fix.as_ref().expect("fix").edits[0];
let start: usize = edit.range.start().into();
let end: usize = edit.range.end().into();
format!("{}{}{}", &input[..start], edit.replacement, &input[end..])
}
#[test]
fn flags_space_before_bracketed_citation() {
let input = "Some fact [@smith2020] here.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "citation-nonbreaking-space");
let edit = &diagnostics[0].fix.as_ref().expect("fix").edits[0];
assert_eq!(&input[edit.range], " ");
assert_eq!(edit.replacement, "\\ ");
assert_eq!(
apply_fix(input, &diagnostics[0]),
"Some fact\\ [@smith2020] here.\n"
);
}
#[test]
fn flags_softbreak_before_citation() {
let input = "A line\n[@smith2020] follows.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1);
let edit = &diagnostics[0].fix.as_ref().expect("fix").edits[0];
assert_eq!(&input[edit.range], "\n");
assert_eq!(
apply_fix(input, &diagnostics[0]),
"A line\\ [@smith2020] follows.\n"
);
}
#[test]
fn softbreak_absorbs_trailing_spaces() {
let input = "A line \n[@smith2020] follows.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1);
let edit = &diagnostics[0].fix.as_ref().expect("fix").edits[0];
assert_eq!(&input[edit.range], " \n");
}
#[test]
fn space_run_is_replaced_entirely() {
let input = "Some fact [@smith2020] here.\n";
let diagnostics = parse_and_lint(input);
assert_eq!(diagnostics.len(), 1);
let edit = &diagnostics[0].fix.as_ref().expect("fix").edits[0];
assert_eq!(&input[edit.range], " ");
}
#[test]
fn nonbreaking_escape_is_compliant() {
let diagnostics = parse_and_lint("Tied\\ [@smith2020] ok.\n");
assert!(diagnostics.is_empty());
}
#[test]
fn literal_nbsp_is_compliant() {
let diagnostics = parse_and_lint("Tied\u{a0}[@smith2020] ok.\n");
assert!(diagnostics.is_empty());
}
#[test]
fn in_text_citation_is_not_flagged() {
let diagnostics = parse_and_lint("In-text @smith2020 says so.\n");
assert!(diagnostics.is_empty());
}
#[test]
fn citation_at_paragraph_start_is_not_flagged() {
let diagnostics = parse_and_lint("[@smith2020] starts the paragraph.\n");
assert!(diagnostics.is_empty());
}
#[test]
fn adjacent_punctuation_is_not_flagged() {
let diagnostics = parse_and_lint("A fact ([@smith2020]) here.\n");
assert!(diagnostics.is_empty());
}
#[test]
fn hard_line_break_is_not_flagged() {
let diagnostics = parse_and_lint("A fact\\\n[@smith2020] here.\n");
assert!(diagnostics.is_empty());
}
#[test]
fn suppress_author_citation_is_flagged() {
let diagnostics = parse_and_lint("A fact [-@smith2020] here.\n");
assert_eq!(diagnostics.len(), 1);
}
#[test]
fn fix_falls_back_to_unicode_nbsp_without_escape_extension() {
let mut config = Config::default();
config.extensions.all_symbols_escapable = false;
let input = "Some fact [@smith2020] here.\n";
let diagnostics = lint_with_config(input, &config);
assert_eq!(diagnostics.len(), 1);
let edit = &diagnostics[0].fix.as_ref().expect("fix").edits[0];
assert_eq!(edit.replacement, "\u{a0}");
}
#[test]
fn fix_is_idempotent() {
let input = "Some fact [@smith2020] here.\n";
let diagnostics = parse_and_lint(input);
let fixed = apply_fix(input, &diagnostics[0]);
assert!(parse_and_lint(&fixed).is_empty());
}
}