badness 0.6.0

A language server, formatter, and linter for LaTeX
//! `missing-nonbreaking-space`: a plain space where a TeX tie (`~`) belongs,
//! between a word and a cross-reference or citation command.
//!
//! LaTeX typographic convention ties a reference to its preceding word with a
//! non-breaking space so the two never split across a line break:
//! `Figure~\ref{x}`, `Eq.~\eqref{z}`, `see~\cite{a}`. Authors routinely write a
//! plain space instead, letting LaTeX break the line between the word and the
//! number. This rule flags that space and offers a fix replacing it with `~`.
//!
//! The fix is `Unsafe` (the codebase's first): inserting a tie changes
//! line-breaking, which is exactly the spacing change `Applicability::Unsafe`
//! exists for. So `lint --fix` leaves it alone; `--unsafe-fixes` and the LSP
//! code action apply it. It is still correct by construction (tenet 1) — a
//! `TILDE` is valid wherever the whitespace stood, so the result parses and is
//! lossless.
//!
//! Scope is deliberately tight: only a same-line `WORD WHITESPACE \cmd` shape is
//! flagged. A *source line break* before the command (`Figure\n\ref{x}`) is also
//! a breakable space in LaTeX, but replacing a newline with `~` reflows the
//! source and overlaps the formatter's job, so it is left for a later pass.
//!
//! The command table lives here, not in `data/signatures.json`: which commands
//! want a tie is a lint judgment, not the structural arity/verbatim fact the
//! signature DB carries (AGENTS.md core decision #2).
//!
//! **What earns a tie: an orphanable unit.** The tie convention exists so a line
//! break cannot strand a self-contained blob—a bare number/label, or a
//! bracketed/parenthetical/superscript citation—alone at a line edge. We flag a
//! command only when its *rendered output* is such a unit:
//!
//! - **Bare-number references** (`\ref`, `\eqref`, `\pageref`, `\labelcref`): the
//!   author writes the noun (`Figure \ref{x}` → "Figure 2"), so a break orphans a
//!   lone number. The canonical case.
//! - **Bracketed/parenthetical/superscript citations** (`\cite`, `\citep`,
//!   `\parencite`, `\autocite`, `\footcite`, `\supercite`, …): the whole citation
//!   is a self-contained blob (`(Smith 2020)`, `[3]`, a footnote mark) that can
//!   orphan at line start.
//!
//! Deliberately **not** flagged, because a break there orphans nothing:
//!
//! - **Self-describing references** (`\autoref`, `\cref`, `\Cref`, `\nameref`,
//!   `\vref`, `\Vref`, `\cpageref`, `\crefrange`, `\Crefrange`): these emit the
//!   noun themselves (`\autoref{x}` → "Figure 2"), so the space before them is
//!   ordinary prose ("in Figure 2"). cleveref also ties the noun to its number
//!   internally, so the only meaningful tie is one we cannot see.
//! - **Textual citations** (`\textcite`, `\citet`, `\citeauthor`, `\citeyear`,
//!   `\citealt`, `\citealp`, `\fullcite`): these weave an author name into the
//!   running prose (`\textcite{x}` → "Smith (2020)"), so there is no bracketed
//!   unit to strand.
//! - **`\nocite` and friends**: no visible output, so a tie is meaningless.

use std::path::PathBuf;

use crate::ast::command_name;
use crate::linter::diagnostic::{Diagnostic, Fix, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind};

use super::{Example, Rule, RuleContext};

const EXAMPLES: &[Example] = &[Example {
    caption: "A plain space where a tie belongs before a cross-reference:",
    source: "see Figure \\ref{fig:plot}\n",
}];

/// Commands whose rendered output is an orphanable unit—a bare number/label or a
/// bracketed/parenthetical/superscript citation blob—that should be tied to the
/// preceding word. See the module docs for what is excluded and why: this omits
/// self-describing references (`\autoref`, `\cref`, …), textual citations
/// (`\textcite`, `\citet`, …), and `\nocite`.
const TIE_COMMANDS: &[&str] = &[
    // Bare-number / parenthetical-number references. The author supplies the
    // noun (`Figure \ref{x}` → "Figure 2"), so a break strands a lone number.
    "ref",
    "eqref",
    "pageref",
    "labelcref",
    // Bracketed / parenthetical / superscript citations (natbib + biblatex).
    // The whole citation is a self-contained blob a break can orphan.
    "cite",
    "citep",
    "citeyearpar",
    "parencite",
    "Parencite",
    "autocite",
    "Autocite",
    "footcite",
    "smartcite",
    "Smartcite",
    "supercite",
];

pub struct MissingNonbreakingSpace;

impl Rule for MissingNonbreakingSpace {
    fn id(&self) -> &'static str {
        "missing-nonbreaking-space"
    }

    fn default_severity(&self) -> Severity {
        Severity::Warning
    }

    fn description(&self) -> &'static str {
        "Flag a plain space where a TeX tie (`~`) belongs, before a command whose \
         output a line break would orphan: a bare-number reference (`Figure \
         \\ref{x}`, `\\eqref`, `\\pageref`) or a bracketed citation (`see \
         \\cite{a}`, `\\parencite`, `\\autocite`). A tie keeps the reference on \
         the same line. Self-describing references (`\\autoref`, `\\cref`) and \
         textual citations (`\\textcite`, `\\citet`) are not flagged -- they emit \
         their own noun, so a break orphans nothing. Only the same-line `WORD \
         SPACE \\cmd` shape is flagged. The fix is **unsafe** -- inserting a tie \
         changes line breaking -- so `--fix` leaves it alone; `--unsafe-fixes` and \
         the editor code action apply it."
    }

    fn examples(&self) -> &'static [Example] {
        EXAMPLES
    }

    fn interests(&self) -> &'static [SyntaxKind] {
        &[SyntaxKind::COMMAND]
    }

    fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
        let Some(command) = el.as_node() else {
            return;
        };
        let Some(name) = command_name(command) else {
            return;
        };
        if !TIE_COMMANDS.contains(&name.as_str()) {
            return;
        }
        // The token immediately before the control word. Trivia floats as a
        // sibling (it is not absorbed into the COMMAND node), and `prev_token`
        // walks globally across node boundaries, so this reaches the real
        // preceding token regardless of nesting.
        let Some(ws) = command.first_token().and_then(|t| t.prev_token()) else {
            return;
        };
        // Only a same-line plain space is a tie candidate. A `~` (already tied),
        // a newline, or a `{`/`}` all fall through here.
        if ws.kind() != SyntaxKind::WHITESPACE {
            return;
        }
        // Require a real word before the space so we never tie at sentence or
        // paragraph start, after `{`, or after another command's `}`.
        if ws.prev_token().map(|t| t.kind()) != Some(SyntaxKind::WORD) {
            return;
        }

        let range = ws.text_range();
        let start = usize::from(range.start());
        let end = usize::from(range.end());
        // Replace the whole whitespace run (lexed as one token) with a single
        // tie. Correct by construction: a `TILDE` is valid here, the splice is
        // contiguous, so the result parses and stays lossless. Unsafe because it
        // alters line-breaking.
        let fix = Fix::unsafe_(
            start,
            end,
            "~",
            format!("Replace the space before `\\{name}` with a non-breaking space `~`"),
        );
        sink.push(Diagnostic {
            rule: self.id(),
            severity: self.default_severity(),
            path: PathBuf::new(),
            start,
            end,
            message: format!(
                "missing non-breaking space before `\\{name}`; use a tie `~` so the reference stays on the same line"
            ),
            fix: Some(fix),
        });
    }
}

#[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 {
            path: std::path::Path::new("x.tex"),
            root: &root,
            model: &model,
            resolution: None,
            citations: None,
        };
        let mut out = Vec::new();
        for el in root.descendants_with_tokens() {
            if MissingNonbreakingSpace.interests().contains(&el.kind()) {
                MissingNonbreakingSpace.check(&el, &ctx, &mut out);
            }
        }
        out
    }

    #[test]
    fn flags_space_before_ref() {
        let out = findings("Figure \\ref{x}\n");
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].rule, "missing-nonbreaking-space");
        // Caret on the single space (byte 6..7), not the command.
        assert_eq!((out[0].start, out[0].end), (6, 7));
    }

    #[test]
    fn flags_space_before_cite() {
        assert_eq!(findings("see \\cite{a}\n").len(), 1);
    }

    #[test]
    fn accepts_word_ending_in_period() {
        // `.` is a word character, so `Eq.` is one WORD token and ties normally.
        assert_eq!(findings("Eq. \\eqref{z}\n").len(), 1);
    }

    #[test]
    fn tie_already_present_is_clean() {
        assert!(findings("Figure~\\ref{x}\n").is_empty());
    }

    #[test]
    fn command_at_input_start_is_clean() {
        assert!(findings("\\ref{x}\n").is_empty());
    }

    #[test]
    fn after_brace_is_clean() {
        // Inside a group (prev is `{`) and after a command's `}` (prev-prev is
        // `}`, not a WORD) both stay quiet.
        assert!(findings("{\\ref{x}}\n").is_empty());
        assert!(findings("\\textbf{a} \\ref{x}\n").is_empty());
    }

    #[test]
    fn nocite_is_not_flagged() {
        assert!(findings("foo \\nocite{x}\n").is_empty());
    }

    #[test]
    fn self_describing_refs_are_not_flagged() {
        // These emit the reference noun themselves ("Figure 2"), so the space
        // before them is ordinary prose, not an orphanable number.
        for cmd in ["autoref", "cref", "Cref", "nameref", "vref", "cpageref"] {
            assert!(
                findings(&format!("in \\{cmd}{{x}}\n")).is_empty(),
                "\\{cmd} should not be flagged"
            );
        }
    }

    #[test]
    fn textual_citations_are_not_flagged() {
        // These weave an author name into the prose ("Smith (2020)"), so there
        // is no bracketed unit to strand.
        for cmd in ["textcite", "Textcite", "citet", "citeauthor", "citeyear"] {
            assert!(
                findings(&format!("shown by \\{cmd}{{x}}\n")).is_empty(),
                "\\{cmd} should not be flagged"
            );
        }
    }

    #[test]
    fn parenthetical_citations_are_flagged() {
        // Self-contained citation blobs that a break can orphan.
        for cmd in ["parencite", "autocite", "footcite", "supercite", "citep"] {
            assert_eq!(
                findings(&format!("result \\{cmd}{{x}}\n")).len(),
                1,
                "\\{cmd} should be flagged"
            );
        }
    }

    #[test]
    fn newline_is_out_of_scope() {
        // A source line break before the command is not flagged in v1.
        assert!(findings("Figure\n\\ref{x}\n").is_empty());
    }

    #[test]
    fn multiple_spaces_collapse_to_one_tie() {
        use crate::linter::diagnostic::Applicability;

        let out = findings("Figure  \\ref{x}\n");
        assert_eq!(out.len(), 1);
        let fix = out[0].fix.as_ref().expect("should carry a fix");
        // The fix span covers both spaces (6..8) and replaces them with one tie.
        assert_eq!((fix.start, fix.end), (6, 8));
        assert_eq!(fix.content, "~");
        assert_eq!(fix.applicability, Applicability::Unsafe);
    }

    #[test]
    fn carries_unsafe_tie_fix() {
        use crate::linter::diagnostic::Applicability;
        use crate::linter::fix::apply_fixes;

        let src = "Figure \\ref{x}\n";
        let out = findings(src);
        let fix = out[0].fix.as_ref().expect("should carry a fix");
        assert_eq!(fix.applicability, Applicability::Unsafe);
        assert_eq!((fix.start, fix.end), (out[0].start, out[0].end));
        assert_eq!(fix.content, "~");
        // Applied with the unsafe opt-in, the space becomes a tie.
        assert_eq!(
            apply_fixes(src, std::slice::from_ref(fix), true).output,
            "Figure~\\ref{x}\n"
        );
        // Without the opt-in, the unsafe fix is skipped.
        assert_eq!(
            apply_fixes(src, std::slice::from_ref(fix), false).applied,
            0
        );
    }

    #[test]
    fn flags_each_occurrence() {
        assert_eq!(findings("Figure \\ref{a} and Table \\ref{b}\n").len(), 2);
    }
}