1use 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
34fn 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
257fn 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
320const 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
357fn 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
500pub 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
518fn 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
533fn 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
572pub 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
587static 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(®ion)
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(®ion)
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
704pub 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
857const 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 ¯o_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
991pub 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}