1use super::tex_common::{lineno_col, make_violation, tex_violation_with_fix};
5use crate::report::{Fix, FixConfidence, Violation};
6use crate::tex::extract;
7use crate::tex::node::{GroupNode, MacroNode, Node};
8use crate::tex::prose::{walk, Slot};
9use crate::tex::{position::LineIndex, ParsedTex};
10use regex::Regex;
11use std::sync::LazyLock;
12
13static SUMMARY_WORDS_RE: LazyLock<Regex> = LazyLock::new(|| {
14 regex_ignore_case(
15 r"\b(summary|discussion|conclusion|conclusions|concluding|illustrations?|examples?|applications?|outlook)\b",
16 )
17});
18static BACKMATTER_TITLE_RE: LazyLock<Regex> = LazyLock::new(|| {
19 regex_ignore_case(
20 r"^\s*(acknowledg|funding?|session\s*info|computational\s*details|appendix|appendices|references|notation|glossar|nomenclature|list\s+of\s+(?:symbols|figures|tables)|code\s+for\s+section|derivation\s+of|proof\s+of|proofs)",
21 )
22});
23const PAGEBREAK_MACROS: &[&str] = &["newpage", "clearpage", "cleardoublepage", "pagebreak"];
24const STRUCT_SECTION_MACROS: &[&str] = &[
25 "section",
26 "section*",
27 "subsection",
28 "subsection*",
29 "chapter",
30 "chapter*",
31];
32const TOP_LEVEL_SECTION_MACROS: &[&str] = &["section", "section*", "chapter", "chapter*"];
33
34fn regex_ignore_case(pattern: &str) -> Regex {
35 regex::RegexBuilder::new(pattern)
36 .case_insensitive(true)
37 .build()
38 .unwrap()
39}
40
41fn first_arg_text<'a>(macro_node: &'a MacroNode, parent: &[Slot<'a>], idx: usize) -> String {
42 extract::macro_args_text(macro_node, parent, idx)
43}
44
45pub fn check_struct_001(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
48 let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
49 let mut out = Vec::new();
50 let Some(bib_pos) = find_bibliography_pos(&parsed.nodes) else {
51 return out;
52 };
53
54 let mut sections: Vec<(&MacroNode, String)> = Vec::new();
55 extract::iter_with_parent_visit(&parsed.nodes, &mut |parent: &[Slot], idx, node| {
56 if node.span().pos >= bib_pos {
57 return;
58 }
59 let Node::Macro(m) = node else { return };
60 if !TOP_LEVEL_SECTION_MACROS.contains(&m.macroname.as_str()) {
61 return;
62 }
63 sections.push((m, first_arg_text(m, parent, idx)));
64 });
65
66 let last_content = sections
67 .iter()
68 .rev()
69 .find(|(_, title)| !BACKMATTER_TITLE_RE.is_match(title));
70 let Some((node, title)) = last_content else {
71 return out;
72 };
73 if SUMMARY_WORDS_RE.is_match(title) {
74 return out;
75 }
76 out.push(tex_violation_with_fix(
77 file,
78 &line_index,
79 node.span.pos,
80 "JSS-STRUCT-001",
81 Some(
82 "Add a 'Summary and discussion' (or similar) section before the bibliography."
83 .to_string(),
84 ),
85 None,
86 ));
87 out
88}
89
90fn find_bibliography_pos(nodes: &[Node]) -> Option<usize> {
91 let mut found = None;
92 walk(nodes, &mut |node, _ancestors| {
93 if found.is_some() {
94 return;
95 }
96 match node {
97 Node::Macro(m) if m.macroname == "bibliography" => found = Some(m.span.pos),
98 Node::Environment(e) if e.environmentname == "thebibliography" => {
99 found = Some(e.span.pos)
100 }
101 _ => {}
102 }
103 });
104 found
105}
106
107static ACKNOWLEDGEMENT_WORD_RE: LazyLock<Regex> =
110 LazyLock::new(|| regex_ignore_case(r"\backnowledgement(s?)\b"));
111
112fn struct_002_replacement(matched: &str) -> String {
115 let chars: Vec<char> = matched.chars().collect();
116 let mut out: String = chars[..10].iter().collect();
117 out.extend(&chars[11..]);
118 out
119}
120
121pub fn check_struct_002(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
123 let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
124 let mut out = Vec::new();
125 extract::iter_with_parent_visit(&parsed.nodes, &mut |parent: &[Slot], idx, node| {
126 let Node::Macro(m) = node else { return };
127 if !STRUCT_SECTION_MACROS.contains(&m.macroname.as_str()) {
128 return;
129 }
130 let title = first_arg_text(m, parent, idx);
131 if !ACKNOWLEDGEMENT_WORD_RE.is_match(&title) {
132 return;
133 }
134 let macro_src: String = parsed.chars[m.span.pos..m.span.end()].iter().collect();
135 let (line, col) = lineno_col(&line_index, m.span.pos);
136 let fix = ACKNOWLEDGEMENT_WORD_RE.find(¯o_src).map(|found| {
137 let matched = found.as_str();
138 let replacement = struct_002_replacement(matched);
139 let start = m.span.pos + macro_src[..found.start()].chars().count();
140 let end = m.span.pos + macro_src[..found.end()].chars().count();
141 Fix {
142 start,
143 end,
144 replacement: replacement.clone(),
145 description: format!(
146 "Replace {} with American spelling {}.",
147 super::py_repr(matched),
148 super::py_repr(&replacement)
149 ),
150 confidence: FixConfidence::Safe,
151 }
152 });
153 out.push(make_violation(
154 file,
155 line,
156 Some(col),
157 "JSS-STRUCT-002",
158 Some("Use 'Acknowledgments' (American spelling) — not 'Acknowledgements'.".to_string()),
159 fix,
160 ));
161 });
162 out
163}
164
165fn walk_envs<'a>(
168 nodes: &'a [Node],
169 name: &str,
170 mut visit: impl FnMut(&'a crate::tex::node::EnvironmentNode),
171) {
172 walk(nodes, &mut |node, _ancestors| {
173 if let Node::Environment(e) = node {
174 if e.environmentname == name {
175 visit(e);
176 }
177 }
178 });
179}
180
181fn is_bare_appendix(title: &str) -> bool {
182 matches!(
183 title.trim().to_lowercase().as_str(),
184 "appendix" | "appendices"
185 )
186}
187
188pub fn check_struct_003(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
190 let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
191 let mut out = Vec::new();
192 walk_envs(&parsed.nodes, "appendix", |env| {
193 extract::iter_with_parent_visit(&env.nodelist, &mut |parent: &[Slot], idx, node| {
194 let Node::Macro(m) = node else { return };
195 if !STRUCT_SECTION_MACROS.contains(&m.macroname.as_str()) {
196 return;
197 }
198 let title = first_arg_text(m, parent, idx);
199 if is_bare_appendix(&title) {
200 out.push(tex_violation_with_fix(
201 file,
202 &line_index,
203 m.span.pos,
204 "JSS-STRUCT-003",
205 Some(
206 "Give the appendix section a descriptive title (e.g., 'More technical details'), not a bare 'Appendix'."
207 .to_string(),
208 ),
209 None,
210 ));
211 }
212 });
213 });
214 out
215}
216
217pub fn check_struct_004(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
219 let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
220 let mut out = Vec::new();
221 walk_envs(&parsed.nodes, "thebibliography", |env| {
222 out.push(tex_violation_with_fix(
223 file,
224 &line_index,
225 env.span.pos,
226 "JSS-STRUCT-004",
227 Some("Replace \\begin{thebibliography}...\\end{thebibliography} with \\bibliography{<bib-file>}.".to_string()),
228 None,
229 ));
230 });
231 out
232}
233
234static TEXT_AND_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+and\s+").unwrap());
237
238fn first_group_arg<'a>(
239 macro_node: &'a MacroNode,
240 parent: &[Slot<'a>],
241 idx: usize,
242) -> Option<&'a GroupNode> {
243 for arg in ¯o_node.args {
244 if let Some(Node::Group(g)) = arg {
245 return Some(g);
246 }
247 }
248 extract::next_group_arg(parent, idx)
249}
250
251fn iter_lowercase_and(nodes: &[Node]) -> Vec<&MacroNode> {
252 let mut out = Vec::new();
253 walk(nodes, &mut |node, _ancestors| {
254 if let Node::Macro(m) = node {
255 if m.macroname == "and" {
256 out.push(m);
257 }
258 }
259 });
260 out
261}
262
263fn iter_text_and_offsets(group: &GroupNode) -> Vec<(&crate::tex::node::CharsNode, usize)> {
268 let mut out = Vec::new();
269 for child in &group.nodelist {
270 match child {
271 Node::Macro(m) if m.macroname == "\\" => break,
272 Node::Chars(c) => {
273 for m in TEXT_AND_RE.find_iter(&c.chars) {
274 out.push((c, m.start()));
275 }
276 }
277 _ => {}
278 }
279 }
280 out
281}
282
283pub fn check_struct_005(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
285 let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
286 let mut out = Vec::new();
287 extract::iter_with_parent_visit(&parsed.nodes, &mut |parent: &[Slot], idx, node| {
288 let Node::Macro(m) = node else { return };
289 if m.macroname != "author" {
290 return;
291 }
292 let Some(group) = first_group_arg(m, parent, idx) else {
293 return;
294 };
295
296 for and_node in iter_lowercase_and(&group.nodelist) {
297 let (line, col) = lineno_col(&line_index, and_node.span.pos);
298 let fix = Fix {
299 start: and_node.span.pos,
300 end: and_node.span.pos + 4, replacement: "\\And".to_string(),
302 description: "Replace lowercase \\and with capitalised \\And — the JSS-canonical author separator."
303 .to_string(),
304 confidence: FixConfidence::Safe,
305 };
306 out.push(make_violation(
307 file,
308 line,
309 Some(col),
310 "JSS-STRUCT-005",
311 Some("Separate authors with \\And (inline) or \\AND (line break), not lowercase \\and.".to_string()),
312 Some(fix),
313 ));
314 }
315
316 let mut has_macro_separator = false;
317 walk(&group.nodelist, &mut |n, _ancestors| {
318 if let Node::Macro(mm) = n {
319 if matches!(mm.macroname.as_str(), "and" | "And" | "AND") {
320 has_macro_separator = true;
321 }
322 }
323 });
324 if has_macro_separator {
325 return;
326 }
327 for (chars_node, offset) in iter_text_and_offsets(group) {
328 let Some(m) = TEXT_AND_RE.find_at(&chars_node.chars, offset) else {
329 continue;
330 };
331 let lstripped = m.as_str().trim_start();
332 let lead_ws_bytes = m.as_str().len() - lstripped.len();
333 let and_start_byte = m.start() + lead_ws_bytes;
334 let and_start =
335 chars_node.span.pos + chars_node.chars[..and_start_byte].chars().count();
336 let and_end = and_start + 3; let (line, col) = lineno_col(&line_index, and_start);
338 out.push(make_violation(
339 file,
340 line,
341 Some(col),
342 "JSS-STRUCT-005",
343 Some("Separate authors with \\And (inline) or \\AND (line break), not the literal word 'and'.".to_string()),
344 Some(Fix {
345 start: and_start,
346 end: and_end,
347 replacement: "\\And".to_string(),
348 description: "Replace literal 'and' with \\And — the JSS-canonical author separator.".to_string(),
349 confidence: FixConfidence::Safe,
350 }),
351 ));
352 }
353 });
354 out
355}
356
357fn find_bibliography_macro_pos(nodes: &[Node]) -> Option<usize> {
360 let mut found = None;
361 walk(nodes, &mut |node, _ancestors| {
362 if found.is_some() {
363 return;
364 }
365 if let Node::Macro(m) = node {
366 if m.macroname == "bibliography" {
367 found = Some(m.span.pos);
368 }
369 }
370 });
371 found
372}
373
374fn find_first_appendix_env(nodes: &[Node]) -> Option<&crate::tex::node::EnvironmentNode> {
375 let mut found = None;
376 walk_envs(nodes, "appendix", |env| {
377 if found.is_none() {
378 found = Some(env);
379 }
380 });
381 found
382}
383
384fn has_pagebreak_between(nodes: &[Node], start: usize, end: usize) -> bool {
385 let mut found = false;
386 walk(nodes, &mut |node, _ancestors| {
387 if found {
388 return;
389 }
390 if let Node::Macro(m) = node {
391 if PAGEBREAK_MACROS.contains(&m.macroname.as_str())
392 && start < m.span.pos
393 && m.span.pos < end
394 {
395 found = true;
396 }
397 }
398 });
399 found
400}
401
402pub fn check_struct_006(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
405 let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
406 let mut out = Vec::new();
407 let Some(bib_pos) = find_bibliography_macro_pos(&parsed.nodes) else {
408 return out;
409 };
410 let Some(appendix_env) = find_first_appendix_env(&parsed.nodes) else {
411 return out;
412 };
413 if appendix_env.span.pos <= bib_pos {
414 return out;
415 }
416 if has_pagebreak_between(&parsed.nodes, bib_pos, appendix_env.span.pos) {
417 return out;
418 }
419 out.push(tex_violation_with_fix(
420 file,
421 &line_index,
422 appendix_env.span.pos,
423 "JSS-STRUCT-006",
424 Some(
425 "Insert \\newpage (or \\clearpage) between \\bibliography{} and \\begin{appendix}."
426 .to_string(),
427 ),
428 Some(Fix {
429 start: appendix_env.span.pos,
430 end: appendix_env.span.pos,
431 replacement: "\\newpage\n".to_string(),
432 description: "insert \\newpage before \\appendix".to_string(),
433 confidence: FixConfidence::Safe,
434 }),
435 ));
436 out
437}