use std::path::PathBuf;
use crate::linter::diagnostic::{Diagnostic, Edit, Fix, Severity};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxToken};
use super::{Example, Rule, RuleContext, StreamVisitor};
const EXAMPLES: &[Example] = &[
Example {
caption: "Straight ASCII double quotes around a phrase:",
source: "He said \"hello world\" to me.\n",
},
Example {
caption: "An opening quote after a parenthesis:",
source: "(\"quoted\")\n",
},
];
pub struct StraightQuotes;
impl Rule for StraightQuotes {
fn id(&self) -> &'static str {
"straight-quotes"
}
fn emits_fix(&self) -> bool {
true
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn description(&self) -> &'static str {
"Flag a literal ASCII double quote (`\"`) used for quotation. In LaTeX a \
straight `\"` always sets a *closing* double quote, so an opening one \
comes out backwards; the correct forms are `` `` `` (two backticks) to \
open and `''` (two apostrophes) to close. A quotation is reported **once**, \
spanning both quotes, and its fix rewrites the pair in one atomic edit -- \
so a single editor code action repairs it from either end. A quote left \
unpaired (no closer before the paragraph ends) reports on its own. The fix \
is **unsafe**: it infers direction from context -- a quote preceded by \
whitespace, a line break, an opening delimiter (`(`, `[`, `{`), a backtick, \
or the start of the document opens, anything else closes -- and applies \
only under `--unsafe-fixes` or as an editor code action, since the guess \
can flip the typeset glyph. Single straight quotes (`'`) are left alone \
(they are legitimately apostrophes), and comments, verbatim, math, TeX hex \
constants (`\"2D`), and `\\pdfmapline` font maps are never touched."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn stream(&self) -> Option<Box<dyn StreamVisitor>> {
Some(Box::new(StraightQuotesVisitor::default()))
}
}
#[derive(Default)]
struct StraightQuotesVisitor {
pending: Option<usize>,
newlines: usize,
}
impl StreamVisitor for StraightQuotesVisitor {
fn visit(&mut self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(tok) = el.as_token() else {
return;
};
match tok.kind() {
SyntaxKind::NEWLINE => {
self.newlines += 1;
if self.newlines >= BLANK_LINE_NEWLINES {
self.flush(sink);
}
return;
}
SyntaxKind::WHITESPACE => return,
_ => self.newlines = 0,
}
if tok.kind() != SyntaxKind::WORD {
return;
}
let text = tok.text();
if !text.contains('"') {
return;
}
if ctx.in_math(usize::from(tok.text_range().start())) {
return;
}
if super::in_code_argument(tok) {
return;
}
if super::in_pdfmap_argument(tok) {
return;
}
let base = usize::from(tok.text_range().start());
for (offset, _) in text.match_indices('"') {
if is_hex_constant(&text[offset + 1..]) {
continue;
}
self.record(base + offset, opens_here(tok, text, offset), sink);
}
}
fn finish(&mut self, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
self.flush(sink);
}
}
const BLANK_LINE_NEWLINES: usize = 2;
impl StraightQuotesVisitor {
fn record(&mut self, start: usize, opening: bool, sink: &mut Vec<Diagnostic>) {
if opening {
if let Some(stale) = self.pending.replace(start) {
sink.push(solo(stale, true));
}
} else if let Some(open) = self.pending.take() {
sink.push(pair(open, start));
} else {
sink.push(solo(start, false));
}
}
fn flush(&mut self, sink: &mut Vec<Diagnostic>) {
if let Some(open) = self.pending.take() {
sink.push(solo(open, true));
}
}
}
fn pair(open: usize, close: usize) -> Diagnostic {
let fix = Fix::unsafe_edits(
vec![
Edit::new(open, open + 1, "``"),
Edit::new(close, close + 1, "''"),
],
"Replace the straight quotes with `` `` `` and `''`",
);
Diagnostic {
rule: StraightQuotes.id(),
severity: StraightQuotes.default_severity(),
path: PathBuf::new(),
start: open,
end: close + 1,
message: "straight double quotes; use `` `` `` (opening) and `''` (closing)".to_owned(),
fix: Some(fix),
related: Vec::new(),
}
}
fn solo(start: usize, opening: bool) -> Diagnostic {
let (replacement, kind) = if opening {
("``", "opening")
} else {
("''", "closing")
};
let end = start + 1;
Diagnostic {
rule: StraightQuotes.id(),
severity: StraightQuotes.default_severity(),
path: PathBuf::new(),
start,
end,
message: format!(
"straight double quote; use `` `` `` (opening) or `''` (closing) -- inferred {kind} here"
),
fix: Some(Fix::unsafe_(
start,
end,
replacement,
format!("Replace `\"` with `{replacement}` ({kind} quote)"),
)),
related: Vec::new(),
}
}
fn opens_here(tok: &SyntaxToken, text: &str, offset: usize) -> bool {
let before = if offset > 0 {
text[..offset].chars().next_back()
} else {
tok.prev_token().and_then(|t| t.text().chars().next_back())
};
match before {
None => true,
Some(c) => c.is_whitespace() || matches!(c, '(' | '[' | '{' | '`'),
}
}
fn is_hex_constant(after: &str) -> bool {
let run = after
.bytes()
.take_while(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F'))
.count();
run > 0
&& after[run..]
.chars()
.next()
.is_none_or(|c| !c.is_ascii_alphabetic())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::diagnostic::Applicability;
use crate::linter::fix::apply_fixes;
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::new(
std::path::Path::new("x.tex"),
&root,
&model,
None,
None,
None,
);
let mut out = Vec::new();
let mut visitor = StraightQuotes.stream().expect("a stream visitor");
for el in root.descendants_with_tokens() {
visitor.visit(&el, &ctx, &mut out);
}
visitor.finish(&ctx, &mut out);
out.sort_by_key(|d| (d.start, d.end));
out
}
fn edits(d: &Diagnostic) -> Vec<(usize, usize, &str)> {
d.fix
.as_ref()
.expect("a fix")
.edits
.iter()
.map(|e| (e.start, e.end, e.content.as_str()))
.collect()
}
#[test]
fn a_quotation_is_one_finding_with_one_paired_fix() {
let src = "He said \"hello world\" to me.\n";
let out = findings(src);
assert_eq!(out.len(), 1, "the pair is one finding, not two: {out:?}");
assert_eq!(out[0].rule, "straight-quotes");
assert_eq!((out[0].start, out[0].end), (8, 21));
let fix = out[0].fix.as_ref().expect("a fix");
assert_eq!(fix.applicability, Applicability::Unsafe);
assert_eq!(edits(&out[0]), [(8, 9, "``"), (20, 21, "''")]);
let fixes = vec![fix.clone()];
assert_eq!(apply_fixes(src, &fixes, false).applied, 0);
assert_eq!(
apply_fixes(src, &fixes, true).output,
"He said ``hello world'' to me.\n"
);
}
#[test]
fn opening_after_paren_opens() {
let out = findings("(\"quoted\")\n");
assert_eq!(out.len(), 1);
assert_eq!(edits(&out[0]), [(1, 2, "``"), (8, 9, "''")]);
}
#[test]
fn a_pair_spanning_lines_still_reports_once() {
let src = "He said \"hello\nworld\" to me.\n";
let out = findings(src);
assert_eq!(out.len(), 1);
assert_eq!(
apply_fixes(src, &[out[0].fix.clone().unwrap()], true).output,
"He said ``hello\nworld'' to me.\n"
);
}
#[test]
fn quotations_pair_independently() {
let out = findings("say \"one\" then \"two\" now\n");
assert_eq!(out.len(), 2);
assert_eq!(edits(&out[0]), [(4, 5, "``"), (8, 9, "''")]);
assert_eq!(edits(&out[1]), [(15, 16, "``"), (19, 20, "''")]);
}
#[test]
fn an_unpaired_opening_quote_reports_alone() {
let out = findings("he said \"hello\n");
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (8, 9));
assert_eq!(edits(&out[0]), [(8, 9, "``")]);
assert!(out[0].message.contains("inferred opening here"));
}
#[test]
fn a_blank_line_ends_a_pending_quotation() {
let out = findings("open \"here\n\nand \"there\n");
assert_eq!(out.len(), 2);
assert_eq!(edits(&out[0]), [(5, 6, "``")]);
assert_eq!(edits(&out[1]), [(16, 17, "``")]);
}
#[test]
fn a_comment_line_does_not_end_a_quotation() {
let out = findings("say \"hello\n% a note\nworld\" now\n");
assert_eq!(out.len(), 1);
assert_eq!(edits(&out[0]), [(4, 5, "``"), (25, 26, "''")]);
}
#[test]
fn a_second_opening_quote_supersedes_the_pending_one() {
let out = findings("\"a \"b\" c\n");
assert_eq!(out.len(), 2);
assert_eq!(edits(&out[0]), [(0, 1, "``")]);
assert_eq!(edits(&out[1]), [(3, 4, "``"), (5, 6, "''")]);
}
#[test]
fn quote_at_document_start_opens() {
let out = findings("\"Start.\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].fix.as_ref().unwrap().edits[0].content, "``");
assert_eq!((out[0].start, out[0].end), (0, 1));
}
#[test]
fn single_quotes_are_not_flagged() {
assert!(findings("don't say it's fine\n").is_empty());
}
#[test]
fn correct_ligatures_are_clean() {
assert!(findings("``already correct''\n").is_empty());
}
#[test]
fn math_is_skipped() {
assert!(findings("$x = \"y\"$\n").is_empty());
}
#[test]
fn lua_string_literals_are_skipped() {
assert!(findings("\\directlua{lfs = require(\"lfs\")}\n").is_empty());
assert!(findings("\\luadirect{token.set_macro(\"x\", \"y\")}\n").is_empty());
let out = findings("say \"hi\" \\directlua{f(\"z\")}\n");
assert_eq!(out.len(), 1);
assert_eq!(edits(&out[0]), [(4, 5, "``"), (7, 8, "''")]);
}
#[test]
fn hex_constant_bare_assignment_is_skipped() {
assert!(findings("\\mathchardef\\mdash=\"2D\n").is_empty());
assert!(findings("\\mathcode`\\-=\"2D\n").is_empty());
assert!(findings("\\chardef\\x=\"7F\n").is_empty());
}
#[test]
fn hex_constant_in_braced_slot_is_skipped() {
let src = "\\DeclareMathSymbol{\\mdash}{\\mathalpha}{operators}{\"2D}\n";
assert!(findings(src).is_empty());
}
#[test]
fn pdfmapline_delimiters_are_skipped() {
let src = "\\pdfmapline{+font <font.pfb \" -.25 SlantFont \" <font2.pfb}\n";
assert!(findings(src).is_empty());
}
#[test]
fn prose_quote_before_hex_letter_word_still_flags() {
let out = findings("He said \"Alpha\" today.\n");
assert_eq!(out.len(), 1);
assert_eq!(edits(&out[0]), [(8, 9, "``"), (14, 15, "''")]);
}
#[test]
fn all_hex_acronym_loses_only_opening_quote() {
let out = findings("the \"CAFE\" run\n");
assert_eq!(out.len(), 1);
assert_eq!(edits(&out[0]), [(9, 10, "''")]);
}
#[test]
fn tight_span_on_an_unpaired_quote() {
let out = findings("a\"b\n");
assert_eq!(out.len(), 1);
assert_eq!((out[0].start, out[0].end), (1, 2));
}
}