Skip to main content

jsslint_core/rules/
structure.rs

1//! Structure rules — mirrors `journals/jss/rules/structure.py`
2//! (JSS-STRUCT-001..006).
3
4use 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
45/// JSS-STRUCT-001 — document ends with a summary/discussion section
46/// before the bibliography.
47pub 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
107// --- JSS-STRUCT-002 -------------------------------------------------------
108
109static ACKNOWLEDGEMENT_WORD_RE: LazyLock<Regex> =
110    LazyLock::new(|| regex_ignore_case(r"\backnowledgement(s?)\b"));
111
112/// Mirrors `structure.py::_struct_002_replacement`: drop the `e`/`E`
113/// between `g` (codepoint index 9) and `m` (index 11).
114fn 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
121/// JSS-STRUCT-002 — "Acknowledgments" uses American spelling.
122pub 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(&macro_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
165// --- JSS-STRUCT-003 -------------------------------------------------------
166
167fn 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
188/// JSS-STRUCT-003 — appendix sections have descriptive titles.
189pub 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
217/// JSS-STRUCT-004 — no hand-written `thebibliography`.
218pub 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
234// --- JSS-STRUCT-005 -------------------------------------------------------
235
236static 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 &macro_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
263/// `(chars_node, match_byte_start)` for every whitespace-flanked `and`
264/// word at the top level of `group` (not inside nested macros/groups),
265/// stopping at the first `\\` row-break macro. Mirrors
266/// `structure.py::_iter_text_and_offsets`.
267fn 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
283/// JSS-STRUCT-005 — `\author{}` separates authors with `\And`/`\AND`.
284pub 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, // "\and"
301                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; // "and"
337            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
357// --- JSS-STRUCT-006 -------------------------------------------------------
358
359fn 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
402/// JSS-STRUCT-006 — appendix follows the bibliography with a page
403/// separator.
404pub 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}