Skip to main content

jsslint_core/rules/
markup.rs

1//! Markup rules — mirrors `journals/jss/rules/markup.py`
2//! (JSS-MARKUP-001..004). The noisiest, most heuristic-laden rule
3//! module in the Python codebase (per the porting plan).
4//!
5//! Offset convention (see individual helper doc comments for the ones
6//! that matter): helpers taking `chars: &str` operate on a single
7//! `CharsNode`'s own text using regex BYTE offsets directly as an
8//! approximation of Python's codepoint offsets — the same accepted
9//! approximation `abbreviations.rs::looks_like_author_initial` already
10//! uses (only wrong in the presence of non-ASCII text within the
11//! lookback/lookahead window, validated clean by the fixture sweep).
12//! Helpers taking `source: &[char]` (the whole document) and an
13//! `abs_pos`/`abs_end` always use exact codepoint offsets, matching
14//! `Fix`/`Violation` position units — those `abs_pos` values are
15//! computed via the established `c.span.pos + c.chars[..byte_off]
16//! .chars().count()` conversion before being passed in.
17
18use super::py_repr;
19use super::tex_common::{tex_violation, tex_violation_with_fix};
20use crate::report::{Fix, FixConfidence, Violation};
21use crate::terms::TERMS;
22use crate::tex::extract;
23use crate::tex::node::{GroupDelims, GroupNode, MacroNode, Node};
24use crate::tex::position::LineIndex;
25use crate::tex::prose::{
26    is_in_prose_context, is_inside_verbatim, walk, walk_with_context, Slot, MARKUP_MACROS,
27    SECTION_MACROS,
28};
29use crate::tex::ParsedTex;
30use regex::Regex;
31use std::collections::HashSet;
32use std::sync::LazyLock;
33
34// --- shared low-level helpers -----------------------------------------
35
36fn byte_at(s: &str, i: usize) -> Option<u8> {
37    s.as_bytes().get(i).copied()
38}
39
40fn floor_char_boundary(s: &str, mut i: usize) -> usize {
41    i = i.min(s.len());
42    while i > 0 && !s.is_char_boundary(i) {
43        i -= 1;
44    }
45    i
46}
47
48fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
49    let n = s.len();
50    if i >= n {
51        return n;
52    }
53    while i < n && !s.is_char_boundary(i) {
54        i += 1;
55    }
56    i
57}
58
59fn line_start(chars: &[char], pos: usize) -> usize {
60    match chars[..pos.min(chars.len())]
61        .iter()
62        .rposition(|&c| c == '\n')
63    {
64        Some(i) => i + 1,
65        None => 0,
66    }
67}
68
69fn line_end(chars: &[char], pos: usize) -> usize {
70    let pos = pos.min(chars.len());
71    match chars[pos..].iter().position(|&c| c == '\n') {
72        Some(i) => pos + i,
73        None => chars.len(),
74    }
75}
76
77static TOKEN_RE: LazyLock<Regex> =
78    LazyLock::new(|| Regex::new(r"[A-Za-z][A-Za-z0-9+\-]*").unwrap());
79
80const LANG_HYPHEN_STATS_TAILS: &[&str] = &["squared", "package", "packages"];
81
82fn is_lang_hyphen_term(_prefix: &str, tail: &str) -> bool {
83    let lower = tail.to_lowercase();
84    LANG_HYPHEN_STATS_TAILS.contains(&lower.as_str())
85}
86
87fn is_initial(chars: &str, offset: usize) -> bool {
88    byte_at(chars, offset + 1) == Some(b'.')
89}
90
91fn is_superscripted(chars: &str, offset: usize, token_len: usize) -> bool {
92    byte_at(chars, offset + token_len) == Some(b'^')
93}
94
95static PATH_SEGMENT_RE: LazyLock<Regex> =
96    LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]+").unwrap());
97
98fn is_filename_context(chars: &str, offset: usize, token_len: usize) -> bool {
99    if offset > 0 && matches!(byte_at(chars, offset - 1), Some(b'.') | Some(b'/')) {
100        return true;
101    }
102    let tail = offset + token_len;
103    if byte_at(chars, tail) == Some(b'/') {
104        if let Some(rest) = chars.get(tail + 1..) {
105            return PATH_SEGMENT_RE.is_match(rest);
106        }
107    }
108    false
109}
110
111static MACRO_DEF_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
112    Regex::new(
113        r"^[ \t]*\\(?:(?:re)?new(?:command|environment)|providecommand|def|DeclareMathOperator|DeclareRobustCommand|newcolumntype|newtheorem)\b",
114    )
115    .unwrap()
116});
117
118fn is_macro_definition_line(source: &[char], abs_pos: usize) -> bool {
119    let ls = line_start(source, abs_pos);
120    let le = line_end(source, ls);
121    let line: String = source[ls..le].iter().collect();
122    MACRO_DEF_LINE_RE.is_match(&line)
123}
124
125static CODE_MACRO_REDEF_RE: LazyLock<Regex> = LazyLock::new(|| {
126    Regex::new(
127        r"\\(?:(?:re)?newcommand|providecommand|def|DeclareRobustCommand)\b\*?\s*\{?\s*\\(?:code|pkg|proglang)\b",
128    )
129    .unwrap()
130});
131
132fn is_code_macro_redefinition(source: &[char], abs_pos: usize) -> bool {
133    let ls = line_start(source, abs_pos);
134    let le = line_end(source, abs_pos);
135    let line: String = source[ls..le].iter().collect();
136    CODE_MACRO_REDEF_RE.is_match(&line)
137}
138
139fn is_escaped(source: &[char], idx: usize) -> bool {
140    let mut n = 0u32;
141    let mut j = idx as isize - 1;
142    while j >= 0 && source[j as usize] == '\\' {
143        n += 1;
144        j -= 1;
145    }
146    n % 2 == 1
147}
148
149static MACRO_DEF_HEAD_BEFORE_BRACE_RE: LazyLock<Regex> = LazyLock::new(|| {
150    Regex::new(
151        r"\\(?:(?:re)?new(?:command|environment)|providecommand|def|DeclareMathOperator|DeclareRobustCommand|newcolumntype|newtheorem)\*?(?:(?:\s|%[^\n]*\n)*(?:\{[^{}]*\}|\[[^\]]*\]|\\[A-Za-z@]+))*(?:\s|%[^\n]*\n)*\z",
152    )
153    .unwrap()
154});
155
156const DEF_BODY_SCAN_LIMIT: usize = 4000;
157
158fn is_in_macro_definition_body(source: &[char], abs_pos: usize) -> bool {
159    let mut depth: i32 = 0;
160    let lower = abs_pos.saturating_sub(DEF_BODY_SCAN_LIMIT) as isize;
161    let mut i = abs_pos as isize - 1;
162    while i >= lower {
163        let ii = i as usize;
164        let c = source[ii];
165        if (c == '{' || c == '}') && !is_escaped(source, ii) {
166            if c == '}' {
167                depth += 1;
168            } else if depth == 0 {
169                let head_start = ii.saturating_sub(120);
170                let head: String = source[head_start..ii].iter().collect();
171                return MACRO_DEF_HEAD_BEFORE_BRACE_RE.is_match(&head);
172            } else {
173                depth -= 1;
174            }
175        }
176        i -= 1;
177    }
178    false
179}
180
181static EPONYM_FOLLOWERS_RE: LazyLock<Regex> = LazyLock::new(|| {
182    Regex::new(r"(?i)^[\s~]+(?:index|indices|statistic|statistics|criterion|criteria|step|steps)\b")
183        .unwrap()
184});
185static POSSESSIVE_RE: LazyLock<Regex> =
186    LazyLock::new(|| Regex::new(r"[A-Za-z](?:'|\u{2019})s\z").unwrap());
187
188fn is_eponym_letter(chars: &str, offset: usize, token: &str) -> bool {
189    if token.len() != 1 {
190        return false;
191    }
192    let head_end = offset.min(chars.len());
193    let head = chars[..head_end].trim_end();
194    if POSSESSIVE_RE.is_match(head) {
195        return true;
196    }
197    let tail = chars.get(offset + 1..).unwrap_or("");
198    EPONYM_FOLLOWERS_RE.is_match(tail)
199}
200
201const LABEL_WORDS: &[&str] = &[
202    "panel",
203    "panels",
204    "group",
205    "groups",
206    "class",
207    "classes",
208    "model",
209    "models",
210    "compartment",
211    "compartments",
212    "column",
213    "columns",
214    "plate",
215    "arm",
216    "arms",
217    "factor",
218    "factors",
219    "level",
220    "levels",
221    "label",
222    "labels",
223    "letter",
224    "letters",
225];
226
227static LABEL_WORDS_RE: LazyLock<Regex> = LazyLock::new(|| {
228    let mut sorted: Vec<&str> = LABEL_WORDS.to_vec();
229    sorted.sort_unstable();
230    Regex::new(&format!(r"(?i)\b(?:{})\b", sorted.join("|"))).unwrap()
231});
232
233static ENUM_CHAIN_RE: LazyLock<Regex> = LazyLock::new(|| {
234    Regex::new(r"\b[A-Z]\b(?:\s*,\s*[A-Z]\b)+\s*(?:,\s*)?(?:or|and)\s+[A-Z]\b").unwrap()
235});
236
237static PREV_WORD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([A-Za-z]+)\s+\z").unwrap());
238
239fn near_label_word(chars: &str, start: usize, end: usize) -> bool {
240    let head_lo = floor_char_boundary(chars, start.saturating_sub(24));
241    let head_hi = start.min(chars.len());
242    let head = if head_lo <= head_hi {
243        &chars[head_lo..head_hi]
244    } else {
245        ""
246    };
247    let tail_lo = end.min(chars.len());
248    let tail_hi = ceil_char_boundary(chars, (end + 24).min(chars.len()));
249    let tail = if tail_lo <= tail_hi {
250        &chars[tail_lo..tail_hi]
251    } else {
252        ""
253    };
254    LABEL_WORDS_RE.is_match(head) || LABEL_WORDS_RE.is_match(tail)
255}
256
257/// Mirrors `markup.py::_is_label_letter`. The `ENUM_CHAIN_RE` scan is
258/// a full-string search filtered to matches starting within
259/// `[offset-40, offset+41)`, rather than a sliced substring, since
260/// `ENUM_CHAIN_RE` starts with `\b` — slicing first would corrupt that
261/// boundary assertion at the window edge (Python's
262/// `finditer(chars, pos, endpos)` sees the real surrounding text, a
263/// sliced Rust substring would not).
264fn is_label_letter(chars: &str, offset: usize, token: &str) -> bool {
265    if token.len() != 1 {
266        return false;
267    }
268    let head = &chars[..offset.min(chars.len())];
269    if let Some(caps) = PREV_WORD_RE.captures(head) {
270        let w = caps.get(1).unwrap().as_str().to_lowercase();
271        if LABEL_WORDS.contains(&w.as_str()) {
272            return true;
273        }
274    }
275    let win_lo = offset.saturating_sub(40);
276    let win_hi = offset + 41;
277    for m in ENUM_CHAIN_RE.find_iter(chars) {
278        if m.start() < win_lo || m.start() >= win_hi {
279            continue;
280        }
281        if m.start() <= offset && offset < m.end() {
282            return near_label_word(chars, m.start(), m.end());
283        }
284    }
285    false
286}
287
288fn is_tex_amp_adjacent(source: &[char], abs_pos: usize, abs_end: usize) -> bool {
289    let after_ok = source.get(abs_end..abs_end + 2) == Some(&['\\', '&'][..]);
290    let before_ok = abs_pos >= 2 && source.get(abs_pos - 2..abs_pos) == Some(&['\\', '&'][..]);
291    after_ok || before_ok
292}
293
294fn is_table_cell_letter(source: &[char], abs_pos: usize, abs_end: usize) -> bool {
295    let ls = line_start(source, abs_pos);
296    let le = line_end(source, abs_end);
297    let before: String = source[ls..abs_pos].iter().collect();
298    let before = before.trim_end().trim_end_matches('{');
299    let after: String = source[abs_end..le].iter().collect();
300    let after = after.trim_start().trim_start_matches('}');
301    before.ends_with('&') && (after.starts_with('&') || after.starts_with("\\\\"))
302}
303
304static OPTION_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
305    Regex::new(
306        r"\b(?:language|style|backgroundcolor|basicstyle|keywordstyle|commentstyle|stringstyle|columns|frame|caption|label|numbers|numberstyle|firstline|lastline|tabsize|breaklines|formatcom)\s*=\s*\z",
307    )
308    .unwrap()
309});
310
311fn is_option_list_value(chars: &str, offset: usize) -> bool {
312    let lo = floor_char_boundary(chars, offset.saturating_sub(50));
313    let hi = offset.min(chars.len());
314    if lo > hi {
315        return false;
316    }
317    OPTION_KEY_RE.is_match(&chars[lo..hi])
318}
319
320// ---------------------------------------------------------------------
321// JSS-MARKUP-001 / MARKUP-002 — language / package names in prose
322// ---------------------------------------------------------------------
323
324const SANDWICH_FOLLOWERS: &[&str] = &[
325    "estimator",
326    "estimators",
327    "matrix",
328    "matrices",
329    "type",
330    "types",
331    "expression",
332    "expressions",
333    "method",
334    "methods",
335    "form",
336    "forms",
337    "covariance",
338    "covariances",
339    "construction",
340    "coefficient",
341    "coefficients",
342    "variance",
343    "variances",
344    "formula",
345    "formulae",
346    "formulas",
347    "product",
348    "products",
349    "meat",
350    "bread",
351    "any",
352];
353
354static OF_THE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)of the\s*\z").unwrap());
355static NEXT_WORD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z]+").unwrap());
356
357/// Mirrors `markup.py::_disambiguates_to_method` — currently only
358/// `_PACKAGE_TERM_DISAMBIGUATORS` has a `"sandwich"` entry, so the
359/// dict lookup is inlined as a direct equality check.
360fn disambiguates_to_method(chars: &str, offset: usize, token: &str) -> bool {
361    if token != "sandwich" {
362        return false;
363    }
364    let head = &chars[..offset.min(chars.len())];
365    if OF_THE_RE.is_match(head) {
366        return true;
367    }
368    let after_token = offset + token.len();
369    let tail = chars.get(after_token..).unwrap_or("");
370    let tail = tail.trim_start_matches([' ', '\t', '\r', '\n', '\u{0c}', '\u{0b}', '_', '*']);
371    if tail.is_empty() {
372        return false;
373    }
374    let Some(m) = NEXT_WORD_RE.find(tail) else {
375        return false;
376    };
377    SANDWICH_FOLLOWERS.contains(&m.as_str().to_lowercase().as_str())
378}
379
380const BIB_ENV_NAMES: &[&str] = &["thebibliography"];
381
382fn is_inside_bibliography(ancestors: &[&Node]) -> bool {
383    ancestors
384        .iter()
385        .any(|anc| matches!(anc, Node::Environment(e) if BIB_ENV_NAMES.contains(&e.environmentname.as_str())))
386}
387
388#[allow(clippy::too_many_arguments)]
389fn check_bare_terms(
390    file: &str,
391    parsed: &ParsedTex,
392    terms: &HashSet<String>,
393    rule_id: &str,
394    wrap_macro: &str,
395    skip_initials: bool,
396    emit_fix: bool,
397) -> Vec<Violation> {
398    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
399    let mut out = Vec::new();
400    walk(&parsed.nodes, &mut |node, ancestors| {
401        let Node::Chars(c) = node else { return };
402        if !is_in_prose_context(ancestors) {
403            return;
404        }
405        let in_bib = is_inside_bibliography(ancestors);
406        for m in TOKEN_RE.find_iter(&c.chars) {
407            let offset = m.start();
408            let token = m.as_str();
409            if !terms.contains(token) {
410                if let Some(dash) = token.find('-') {
411                    let prefix = &token[..dash];
412                    let tail = &token[dash + 1..];
413                    if terms.contains(prefix) && !is_lang_hyphen_term(prefix, tail) {
414                        let abs_pos = c.span.pos + c.chars[..offset].chars().count();
415                        let abs_end = abs_pos + prefix.chars().count();
416                        let fix = emit_fix.then(|| Fix {
417                            start: abs_pos,
418                            end: abs_end,
419                            replacement: format!("\\{wrap_macro}{{{prefix}}}"),
420                            description: format!("wrap {prefix} in \\{wrap_macro}{{}}"),
421                            confidence: FixConfidence::Safe,
422                        });
423                        out.push(tex_violation_with_fix(
424                            file,
425                            &line_index,
426                            abs_pos,
427                            rule_id,
428                            Some(format!(
429                                "Wrap {} in \\{wrap_macro}{{{prefix}}} (found bare in {}).",
430                                py_repr(prefix),
431                                py_repr(token)
432                            )),
433                            fix,
434                        ));
435                    }
436                }
437                continue;
438            }
439            if in_bib && token.len() == 1 {
440                continue;
441            }
442            if skip_initials && token.len() == 1 && is_initial(&c.chars, offset) {
443                continue;
444            }
445            if is_superscripted(&c.chars, offset, token.len()) {
446                continue;
447            }
448            if is_filename_context(&c.chars, offset, token.len()) {
449                continue;
450            }
451            if is_option_list_value(&c.chars, offset) {
452                continue;
453            }
454            if disambiguates_to_method(&c.chars, offset, token) {
455                continue;
456            }
457            if is_eponym_letter(&c.chars, offset, token) {
458                continue;
459            }
460            if is_label_letter(&c.chars, offset, token) {
461                continue;
462            }
463            let abs_pos = c.span.pos + c.chars[..offset].chars().count();
464            let abs_end = abs_pos + token.len();
465            if is_macro_definition_line(&parsed.chars, abs_pos) {
466                continue;
467            }
468            if is_in_macro_definition_body(&parsed.chars, abs_pos) {
469                continue;
470            }
471            if is_tex_amp_adjacent(&parsed.chars, abs_pos, abs_end) {
472                continue;
473            }
474            if token.len() == 1 && is_table_cell_letter(&parsed.chars, abs_pos, abs_end) {
475                continue;
476            }
477            let fix = emit_fix.then(|| Fix {
478                start: abs_pos,
479                end: abs_end,
480                replacement: format!("\\{wrap_macro}{{{token}}}"),
481                description: format!("wrap {token} in \\{wrap_macro}{{}}"),
482                confidence: FixConfidence::Safe,
483            });
484            out.push(tex_violation_with_fix(
485                file,
486                &line_index,
487                abs_pos,
488                rule_id,
489                Some(format!(
490                    "Wrap {} in \\{wrap_macro}{{{token}}}.",
491                    py_repr(token)
492                )),
493                fix,
494            ));
495        }
496    });
497    out
498}
499
500/// JSS-MARKUP-001 — programming-language names wrapped in `\proglang{}`.
501pub fn check_markup_001(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
502    check_bare_terms(
503        file,
504        parsed,
505        &TERMS.languages,
506        "JSS-MARKUP-001",
507        "proglang",
508        true,
509        true,
510    )
511}
512
513static TITLE_PKG_IDIOM_RE: LazyLock<Regex> =
514    LazyLock::new(|| Regex::new(r"^\s*([a-z][A-Za-z0-9.]*)\s*(?::|--|\u{2014}|\z)").unwrap());
515static TITLE_PKG_SOURCE_RE: LazyLock<Regex> =
516    LazyLock::new(|| Regex::new(r"\\title\s*\{\s*\\pkg\s*\{").unwrap());
517
518/// Char-only projection of a title group up to the first macro child.
519/// Mirrors `markup.py::_project_title_plain_text`.
520fn project_title_plain_text(group: &GroupNode) -> String {
521    let mut parts = String::new();
522    for child in &group.nodelist {
523        match child {
524            Node::Chars(c) => parts.push_str(&c.chars),
525            Node::Macro(_) => break,
526            Node::Specials(s) => parts.push_str(&s.chars),
527            _ => {}
528        }
529    }
530    parts.trim_start().to_string()
531}
532
533/// Flags `\title{pkgname ...}` openings as MARKUP-002. Mirrors
534/// `markup.py::_check_title_package_idiom`.
535fn check_title_package_idiom(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
536    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
537    let mut out = Vec::new();
538    extract::iter_with_parent_visit(&parsed.nodes, &mut |_parent, _idx, node| {
539        let Node::Macro(m) = node else { return };
540        if m.macroname != "title" {
541            return;
542        }
543        let group = m.args.iter().find_map(|a| match a {
544            Some(Node::Group(g)) => Some(g),
545            _ => None,
546        });
547        let Some(group) = group else { return };
548        let text = project_title_plain_text(group);
549        let Some(caps) = TITLE_PKG_IDIOM_RE.captures(&text) else {
550            return;
551        };
552        let pkg_name = caps.get(1).unwrap().as_str();
553        let macro_end = m.span.pos + m.span.len;
554        let source: String = parsed.chars[m.span.pos..macro_end].iter().collect();
555        if TITLE_PKG_SOURCE_RE.is_match(&source) {
556            return;
557        }
558        out.push(tex_violation(
559            file,
560            &line_index,
561            m.span.pos,
562            "JSS-MARKUP-002",
563            Some(format!(
564                "Wrap the leading package name {} in \\pkg{{{pkg_name}}} in the \\title{{}} body.",
565                py_repr(pkg_name)
566            )),
567        ));
568    });
569    out
570}
571
572/// JSS-MARKUP-002 — software-package names wrapped in `\pkg{}`.
573pub fn check_markup_002(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
574    let mut out = check_bare_terms(
575        file,
576        parsed,
577        &TERMS.r_packages,
578        "JSS-MARKUP-002",
579        "pkg",
580        false,
581        true,
582    );
583    out.extend(check_title_package_idiom(file, parsed));
584    out
585}
586
587// ---------------------------------------------------------------------
588// JSS-MARKUP-003 — inline function/argument names + R sentinel values
589// ---------------------------------------------------------------------
590
591static FUNCTION_CALL_RE: LazyLock<Regex> =
592    LazyLock::new(|| Regex::new(r"\b[a-zA-Z][a-zA-Z0-9_.]*\(\s*\)").unwrap());
593
594static OPEN_CODE_MACRO_RE: LazyLock<Regex> = LazyLock::new(|| {
595    Regex::new(r"\\(?:code|texttt|pkg|proglang|verb|fct|command|samp|file)\*?\{[^{}]*\z").unwrap()
596});
597
598fn already_code_wrapped(source: &[char], abs_pos: usize) -> bool {
599    let ls = line_start(source, abs_pos);
600    let region: String = source[ls..abs_pos].iter().collect();
601    OPEN_CODE_MACRO_RE.is_match(&region)
602}
603
604static WRAPPER_DEF_RE: LazyLock<Regex> = LazyLock::new(|| {
605    Regex::new(
606        r"\\(?:def\s*\\([A-Za-z@]+)|(?:re)?newcommand\*?\s*\{\s*\\([A-Za-z@]+)\s*\})[^\n]*?(?:\\lstinline\b|\\(?:e|E)?[vV]erb\b|\\mintinline\b|\\texttt\s*\{\s*#|\\code\s*\{\s*#)",
607    )
608    .unwrap()
609});
610
611fn custom_code_wrapper_macros(source: &str) -> HashSet<String> {
612    let mut names = HashSet::new();
613    for caps in WRAPPER_DEF_RE.captures_iter(source) {
614        let name = caps
615            .get(1)
616            .or_else(|| caps.get(2))
617            .expect("one alternative always captures")
618            .as_str()
619            .to_string();
620        names.insert(name);
621    }
622    names
623}
624
625fn inside_custom_wrapper(ancestors: &[&Node], wrappers: &HashSet<String>) -> bool {
626    if wrappers.is_empty() {
627        return false;
628    }
629    ancestors
630        .iter()
631        .any(|anc| matches!(anc, Node::Macro(m) if wrappers.contains(&m.macroname)))
632}
633
634const R_SENTINEL_VALUES: &[&str] = &[
635    "NULL",
636    "NA",
637    "NA_integer_",
638    "NA_real_",
639    "NA_character_",
640    "NA_complex_",
641    "TRUE",
642    "FALSE",
643];
644static SENTINEL_TOKEN_RE: LazyLock<Regex> =
645    LazyLock::new(|| Regex::new(r"[A-Za-z][A-Za-z0-9_]*").unwrap());
646
647const INVISIBLE_TEXT_MACROS: &[&str] = &["index", "nomenclature", "glossary", "glsadd"];
648
649fn is_inside_invisible_macro(ancestors: &[&Node]) -> bool {
650    ancestors
651        .iter()
652        .any(|anc| matches!(anc, Node::Macro(m) if INVISIBLE_TEXT_MACROS.contains(&m.macroname.as_str())))
653}
654
655const ALGORITHM_ENVS: &[&str] = &[
656    "algorithmic",
657    "algorithm",
658    "algorithm2e",
659    "algorithmicx",
660    "algorithmial",
661    "pseudocode",
662    "ALC@g",
663];
664
665fn is_inside_algorithm(ancestors: &[&Node]) -> bool {
666    ancestors
667        .iter()
668        .any(|anc| matches!(anc, Node::Environment(e) if ALGORITHM_ENVS.contains(&e.environmentname.as_str())))
669}
670
671static OPEN_SHORT_OPTARG_RE: LazyLock<Regex> = LazyLock::new(|| {
672    Regex::new(r"\\(?:caption|(?:sub)*section|paragraph|subparagraph|chapter|part)\*?\[[^\]]*\z")
673        .unwrap()
674});
675
676fn in_short_optarg(source: &[char], abs_pos: usize) -> bool {
677    let ls = line_start(source, abs_pos);
678    let region: String = source[ls..abs_pos].iter().collect();
679    OPEN_SHORT_OPTARG_RE.is_match(&region)
680}
681
682static EMAIL_RE: LazyLock<Regex> =
683    LazyLock::new(|| Regex::new(r"^[\w.+-]+@[\w-]+\.[\w.-]+\z").unwrap());
684static URL_RE: LazyLock<Regex> =
685    LazyLock::new(|| Regex::new(r"^(?:https?://|www\.|ftp://)").unwrap());
686static DOI_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^10\.\d{4,}/").unwrap());
687static NUM_LABEL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+:?\z").unwrap());
688
689fn texttt_is_noncode(inner: &str) -> bool {
690    let inner = inner.trim();
691    EMAIL_RE.is_match(inner)
692        || URL_RE.is_match(inner)
693        || inner.contains("//")
694        || DOI_RE.is_match(inner)
695        || NUM_LABEL_RE.is_match(inner)
696}
697
698fn is_inside_jss_markup(ancestors: &[&Node]) -> bool {
699    ancestors
700        .iter()
701        .any(|anc| matches!(anc, Node::Macro(m) if MARKUP_MACROS.contains(&m.macroname.as_str())))
702}
703
704/// JSS-MARKUP-003 — inline function/argument names and bare R
705/// sentinel values wrapped in `\code{}`; `\texttt{}` retargeted to the
706/// appropriate JSS markup macro.
707pub fn check_markup_003(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
708    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
709    let mut out = Vec::new();
710    let wrappers = custom_code_wrapper_macros(&parsed.source);
711    let top: Vec<Slot> = parsed.nodes.iter().map(Some).collect();
712    let mut ancestors: Vec<&Node> = Vec::new();
713    walk_with_context(&top, &mut ancestors, &mut |node, ancestors, parent, idx| {
714        if let Node::Macro(m) = node {
715            if m.macroname == "texttt" {
716                if is_inside_verbatim(ancestors) {
717                    return;
718                }
719                if is_inside_bibliography(ancestors) {
720                    return;
721                }
722                if is_inside_jss_markup(ancestors) {
723                    return;
724                }
725                if is_inside_invisible_macro(ancestors) {
726                    return;
727                }
728                if is_inside_algorithm(ancestors) {
729                    return;
730                }
731                if in_short_optarg(&parsed.chars, m.span.pos) {
732                    return;
733                }
734                if is_code_macro_redefinition(&parsed.chars, m.span.pos) {
735                    return;
736                }
737                let inner = extract::macro_args_text(m, parent, idx);
738                let inner = inner.trim();
739                if texttt_is_noncode(inner) {
740                    return;
741                }
742                let (target_macro, suggestion) = if TERMS.languages.contains(inner) {
743                    (
744                        "proglang",
745                        format!(
746                            "\\texttt{{{inner}}} names a programming language; wrap as \\proglang{{{inner}}}."
747                        ),
748                    )
749                } else if TERMS.r_packages.contains(inner) {
750                    (
751                        "pkg",
752                        format!(
753                            "\\texttt{{{inner}}} names an R package; wrap as \\pkg{{{inner}}}."
754                        ),
755                    )
756                } else {
757                    (
758                        "code",
759                        "Replace \\texttt{...} with the appropriate JSS markup: \\code{...} for inline code, \\pkg{...} for package names, \\proglang{...} for language names."
760                            .to_string(),
761                    )
762                };
763                out.push(tex_violation_with_fix(
764                    file,
765                    &line_index,
766                    m.span.pos,
767                    "JSS-MARKUP-003",
768                    Some(suggestion),
769                    Some(Fix {
770                        start: m.span.pos,
771                        end: m.span.pos + "\\texttt".len(),
772                        replacement: format!("\\{target_macro}"),
773                        description: format!("replace \\texttt with \\{target_macro}"),
774                        confidence: FixConfidence::Safe,
775                    }),
776                ));
777                return;
778            }
779        }
780        let Node::Chars(c) = node else { return };
781        if !is_in_prose_context(ancestors) {
782            return;
783        }
784        if inside_custom_wrapper(ancestors, &wrappers) {
785            return;
786        }
787        if is_inside_invisible_macro(ancestors) {
788            return;
789        }
790        if is_inside_algorithm(ancestors) {
791            return;
792        }
793        if is_inside_bibliography(ancestors) {
794            return;
795        }
796        for m in FUNCTION_CALL_RE.find_iter(&c.chars) {
797            let abs_pos = c.span.pos + c.chars[..m.start()].chars().count();
798            if already_code_wrapped(&parsed.chars, abs_pos) {
799                continue;
800            }
801            if in_short_optarg(&parsed.chars, abs_pos) {
802                continue;
803            }
804            out.push(tex_violation(
805                file,
806                &line_index,
807                abs_pos,
808                "JSS-MARKUP-003",
809                Some(format!(
810                    "Wrap {} in \\code{{{}}}.",
811                    py_repr(m.as_str()),
812                    m.as_str()
813                )),
814            ));
815        }
816        for m in SENTINEL_TOKEN_RE.find_iter(&c.chars) {
817            let token = m.as_str();
818            if !R_SENTINEL_VALUES.contains(&token) {
819                continue;
820            }
821            let start = m.start();
822            let end = m.end();
823            if c.chars[..start].trim_end().ends_with('=') {
824                continue;
825            }
826            let next_char = c.chars[end..].chars().next();
827            if matches!(next_char, Some('\'') | Some('\u{2019}')) {
828                continue;
829            }
830            let abs_pos = c.span.pos + c.chars[..start].chars().count();
831            let abs_end = c.span.pos + c.chars[..end].chars().count();
832            if in_short_optarg(&parsed.chars, abs_pos) {
833                continue;
834            }
835            out.push(tex_violation_with_fix(
836                file,
837                &line_index,
838                abs_pos,
839                "JSS-MARKUP-003",
840                Some(format!(
841                    "Wrap bare R sentinel {} in \\code{{{token}}}.",
842                    py_repr(token)
843                )),
844                Some(Fix {
845                    start: abs_pos,
846                    end: abs_end,
847                    replacement: format!("\\code{{{token}}}"),
848                    description: format!("wrap {token} in \\code{{}}"),
849                    confidence: FixConfidence::Safe,
850                }),
851            ));
852        }
853    });
854    out
855}
856
857// ---------------------------------------------------------------------
858// JSS-MARKUP-004 — section titles with markup need a plain-text shim
859// ---------------------------------------------------------------------
860
861const NON_MARKUP_TITLE_MACROS: &[&str] = &[
862    "label",
863    "index",
864    "nocite",
865    "ignorespaces",
866    "dots",
867    "ldots",
868    "cdots",
869    "vdots",
870    "textbackslash",
871    "textunderscore",
872    "'",
873    "`",
874    "\"",
875    "^",
876    "~",
877    "=",
878    ".",
879    "u",
880    "v",
881    "H",
882    "t",
883    "c",
884    "d",
885    "b",
886    "r",
887    "k",
888    " ",
889    ",",
890    ";",
891    ":",
892    "!",
893    "&",
894    "_",
895    "$",
896    "#",
897    "%",
898    "{",
899    "}",
900    "ss",
901    "aa",
902    "AA",
903    "ae",
904    "AE",
905    "oe",
906    "OE",
907    "o",
908    "O",
909    "l",
910    "L",
911    "i",
912    "j",
913    "S",
914    "P",
915];
916
917fn has_optional_shim(macro_node: &MacroNode) -> bool {
918    macro_node
919        .args
920        .iter()
921        .any(|a| matches!(a, Some(Node::Group(g)) if g.delims == GroupDelims::Bracket))
922}
923
924fn mandatory_arg_contains_markup(macro_node: &MacroNode) -> bool {
925    for arg in &macro_node.args {
926        let Some(Node::Group(g)) = arg else { continue };
927        let mut found = false;
928        walk(&g.nodelist, &mut |node, _ancestors| match node {
929            Node::Math(_) => found = true,
930            Node::Macro(m) if !NON_MARKUP_TITLE_MACROS.contains(&m.macroname.as_str()) => {
931                found = true;
932            }
933            _ => {}
934        });
935        return found;
936    }
937    false
938}
939
940fn collect_plain_text(nodes: &[Node], out: &mut String) {
941    for node in nodes {
942        match node {
943            Node::Math(_) => continue,
944            Node::Chars(c) => out.push_str(&c.chars),
945            Node::Group(g) => collect_plain_text(&g.nodelist, out),
946            Node::Environment(e) => collect_plain_text(&e.nodelist, out),
947            Node::Macro(m) => {
948                if NON_MARKUP_TITLE_MACROS.contains(&m.macroname.as_str()) {
949                    continue;
950                }
951                for arg in &m.args {
952                    if let Some(Node::Group(g)) = arg {
953                        if g.delims == GroupDelims::Brace {
954                            collect_plain_text(&g.nodelist, out);
955                        }
956                    }
957                }
958            }
959            Node::Specials(_) | Node::Comment(_) => {}
960        }
961    }
962}
963
964static WHITESPACE_RUN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
965
966fn project_section_title_to_plain(group: &GroupNode) -> String {
967    let mut text = String::new();
968    collect_plain_text(&group.nodelist, &mut text);
969    WHITESPACE_RUN_RE.replace_all(&text, " ").trim().to_string()
970}
971
972fn build_markup_004_fix(macro_node: &MacroNode) -> Option<Fix> {
973    let mandatory_group = macro_node.args.iter().find_map(|a| match a {
974        Some(Node::Group(g)) if g.delims == GroupDelims::Brace => Some(g),
975        _ => None,
976    })?;
977    let plain = project_section_title_to_plain(mandatory_group);
978    if plain.is_empty() {
979        return None;
980    }
981    let insert_at = mandatory_group.span.pos;
982    Some(Fix {
983        start: insert_at,
984        end: insert_at,
985        replacement: format!("[{plain}]"),
986        description: format!("Insert plain-text optional arg [{plain}] before the section title."),
987        confidence: FixConfidence::Safe,
988    })
989}
990
991/// JSS-MARKUP-004 — section titles with markup supply a plain-text
992/// optional arg: `\section[plain]{markup}`.
993pub fn check_markup_004(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
994    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
995    let mut out = Vec::new();
996    extract::iter_with_parent_visit(&parsed.nodes, &mut |_parent, _idx, node| {
997        let Node::Macro(m) = node else { return };
998        if !SECTION_MACROS.contains(&m.macroname.as_str()) {
999            return;
1000        }
1001        if has_optional_shim(m) {
1002            return;
1003        }
1004        if !mandatory_arg_contains_markup(m) {
1005            return;
1006        }
1007        out.push(tex_violation_with_fix(
1008            file,
1009            &line_index,
1010            m.span.pos,
1011            "JSS-MARKUP-004",
1012            Some(
1013                "Provide a plain-text section title as the optional argument: \\section[plain]{...}."
1014                    .to_string(),
1015            ),
1016            build_markup_004_fix(m),
1017        ));
1018    });
1019    out
1020}