Skip to main content

jsslint_core/rules/
code_style.rs

1//! Code-style rules — mirrors `journals/jss/rules/code_style.py`
2//! (JSS-CODE-001/002/003).
3
4use super::tex_common::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, CODE_INPUT_ENVS};
9use crate::tex::{position::LineIndex, ParsedTex};
10use regex::Regex;
11use std::sync::LazyLock;
12
13static COMMENT_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
14    // Python's `(?m)#+[^\S\n]+\S[^\n]*` — Rust: match per-line via
15    // `(?m)` multi-line mode (`regex` crate supports it); `[^\S\n]`
16    // (whitespace-but-not-newline) has no single-char Rust class, so
17    // spell it as `[ \t\r\x0c\x0b]` (ASCII horizontal/vertical
18    // whitespace minus `\n`, matching Python's `\s` minus `\n` for the
19    // ASCII case this corpus uses).
20    Regex::new(r"(?m)#+[ \t\r\x0c\x0b]+\S[^\n]*").unwrap()
21});
22
23static LIBRARY_UNQUOTED_RE: LazyLock<Regex> = LazyLock::new(|| {
24    Regex::new(r"\b(?:library|data|require|requireNamespace)\(\s*([A-Za-z][A-Za-z0-9_.]*)\s*[,)]")
25        .unwrap()
26});
27
28static MISSING_SPACES_RE: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(
30        r"(?:[A-Za-z0-9_.)\]](?:<<-|<-|->|==|!=|<=|>=)[A-Za-z0-9_.(\[])|(?:[A-Za-z0-9_.)\]][=+\-*/][A-Za-z0-9_.(\[])|(?:,[A-Za-z0-9_.(\[])",
31    )
32    .unwrap()
33});
34
35static CODE_ENV_MISSING_COMMA_SPACE_RE: LazyLock<Regex> =
36    LazyLock::new(|| Regex::new(r#",[A-Za-z0-9_.(\["']"#).unwrap());
37static CODE_ENV_COMMENT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"#[^\n]*").unwrap());
38static CODE_ENV_STRING_RE: LazyLock<Regex> =
39    LazyLock::new(|| Regex::new(r#""[^"\n]*"|'[^'\n]*'"#).unwrap());
40static CLEAN_R_IDENT_RE: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9._]*$").unwrap());
42static IDENTIFIER_ONLY_RE: LazyLock<Regex> =
43    LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_.\-]*$").unwrap());
44static VERSION_OR_LABEL_RE: LazyLock<Regex> = LazyLock::new(|| {
45    Regex::new(r"^(?:[0-9][\w.\-]*|[A-Za-z][\w.\-]*\s*\\?%[\w.\-\s\\]*)$").unwrap()
46});
47// Note: Python's `code_style.py` also defines `_KEYWORD_ARG_RE` at this
48// point in the module but never references it anywhere else — dead
49// code in the source being ported, not an oversight here. Omitted.
50static PATH_LIKE_RE: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]*)+/?$").unwrap());
52// `\code{--fix}`, `\code{--fix --dry-run}`, `\code{.jss-lint.toml}`,
53// `\code{cargo install jsslint-cli}` — command-line flags, dotfile
54// names, and space-separated command words. A leading dash is option
55// syntax and internal hyphens are part of the name, not subtraction;
56// applied per whitespace-separated token so a real expression anywhere
57// in the sample still trips the operator check.
58static NAME_LIKE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
59    Regex::new(r"^(?:--?|\.)?[A-Za-z][A-Za-z0-9_.\-]*$|^[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]*)+/?$")
60        .unwrap()
61});
62static SCIENTIFIC_NOTATION_RE: LazyLock<Regex> =
63    LazyLock::new(|| Regex::new(r"\b\d+(?:\.\d+)?[eE][-+]?\d+\b").unwrap());
64static STRING_LITERAL_RE: LazyLock<Regex> =
65    LazyLock::new(|| Regex::new(r#""[^"\n]*"|'[^'\n]*'"#).unwrap());
66
67const SINGLE_CHAR_ESCAPE_MACROS: &[&str] = &["%", "&", "_", "#", "$", "{", "}", "\\"];
68
69/// JSS-CODE-001 — code-display envs contain no `#`-style comments.
70pub fn check_code_001(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
71    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
72    let mut out = Vec::new();
73    walk(&parsed.nodes, &mut |node, _ancestors| {
74        let Node::Environment(env) = node else { return };
75        if !CODE_INPUT_ENVS.contains(&env.environmentname.as_str()) {
76            return;
77        }
78        for child in &env.nodelist {
79            let Node::Chars(c) = child else { continue };
80            let Some(m) = COMMENT_LINE_RE.find(&c.chars) else {
81                continue;
82            };
83            let abs_pos = c.span.pos + c.chars[..m.start()].chars().count();
84            out.push(tex_violation_with_fix(
85                file,
86                &line_index,
87                abs_pos,
88                "JSS-CODE-001",
89                Some("Move the comment into the surrounding LaTeX text.".to_string()),
90                None,
91            ));
92            break; // one violation per code block is enough
93        }
94    });
95    out
96}
97
98/// JSS-CODE-002 — `library()`/`data()`/`require()`/`requireNamespace()`
99/// quote their first argument.
100pub fn check_code_002(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
101    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
102    let mut out = Vec::new();
103    walk(&parsed.nodes, &mut |node, _ancestors| {
104        let Node::Environment(env) = node else { return };
105        if !CODE_INPUT_ENVS.contains(&env.environmentname.as_str()) {
106            return;
107        }
108        for child in &env.nodelist {
109            let Node::Chars(c) = child else { continue };
110            for m in LIBRARY_UNQUOTED_RE.captures_iter(&c.chars) {
111                let whole = m.get(0).unwrap();
112                let arg = m.get(1).unwrap();
113                let abs_pos = c.span.pos + c.chars[..whole.start()].chars().count();
114                let bareword = arg.as_str();
115                // Python's `.replace()` (no count) replaces every
116                // occurrence, not just the first — match that exactly
117                // rather than assume `bareword` appears once.
118                let quoted = whole.as_str().replace(bareword, &format!("\"{bareword}\""));
119                let suggestion = format!("Quote the argument: e.g., {}.", super::py_repr(&quoted));
120                let fix = if CLEAN_R_IDENT_RE.is_match(bareword) {
121                    let bareword_start = c.span.pos + c.chars[..arg.start()].chars().count();
122                    let bareword_end = c.span.pos + c.chars[..arg.end()].chars().count();
123                    Some(Fix {
124                        start: bareword_start,
125                        end: bareword_end,
126                        replacement: format!("\"{bareword}\""),
127                        description: "quote first argument to library() / data()".to_string(),
128                        confidence: FixConfidence::Safe,
129                    })
130                } else {
131                    None
132                };
133                out.push(tex_violation_with_fix(
134                    file,
135                    &line_index,
136                    abs_pos,
137                    "JSS-CODE-002",
138                    Some(suggestion),
139                    fix,
140                ));
141            }
142        }
143    });
144    out
145}
146
147/// JSS-CODE-003 — code samples use spaces around operators and after
148/// commas, checked both inside code-display envs and `\code{...}`.
149pub fn check_code_003(file: &str, parsed: &ParsedTex) -> Vec<Violation> {
150    let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
151    let mut out = Vec::new();
152
153    walk(&parsed.nodes, &mut |node, _ancestors| {
154        let Node::Environment(env) = node else { return };
155        if !CODE_INPUT_ENVS.contains(&env.environmentname.as_str()) {
156            return;
157        }
158        if let Some(v) = scan_code_env_for_spacing(file, &line_index, env) {
159            out.push(v);
160        }
161    });
162
163    extract::iter_with_parent_visit(&parsed.nodes, &mut |parent: &[Slot], idx, node| {
164        let Node::Macro(m) = node else { return };
165        if m.macroname != "code" {
166            return;
167        }
168        let Some(group) = first_group_arg(m, parent, idx) else {
169            return;
170        };
171        let text = group_plain_text(group);
172        if text.is_empty() {
173            return;
174        }
175        let stripped = text.trim();
176        if IDENTIFIER_ONLY_RE.is_match(stripped)
177            || VERSION_OR_LABEL_RE.is_match(stripped)
178            || PATH_LIKE_RE.is_match(stripped)
179        {
180            return;
181        }
182        // Command line made of flag / name / path tokens only.
183        if !stripped.is_empty()
184            && stripped
185                .split_whitespace()
186                .all(|t| NAME_LIKE_TOKEN_RE.is_match(t))
187        {
188            return;
189        }
190        let cleaned = SCIENTIFIC_NOTATION_RE.replace_all(&text, "");
191        let cleaned = STRING_LITERAL_RE.replace_all(&cleaned, "S");
192        if MISSING_SPACES_RE.is_match(&cleaned) {
193            out.push(tex_violation_with_fix(
194                file,
195                &line_index,
196                m.span.pos,
197                "JSS-CODE-003",
198                Some("Add spaces around operators and after commas in the code sample (e.g., 'y = a + b * x').".to_string()),
199                None,
200            ));
201        }
202    });
203
204    out
205}
206
207fn scan_code_env_for_spacing(
208    file: &str,
209    line_index: &LineIndex,
210    env: &crate::tex::node::EnvironmentNode,
211) -> Option<Violation> {
212    for child in &env.nodelist {
213        let Node::Chars(c) = child else { continue };
214        let cleaned = CODE_ENV_COMMENT_RE.replace_all(&c.chars, "");
215        let cleaned = SCIENTIFIC_NOTATION_RE.replace_all(&cleaned, "");
216        let cleaned = CODE_ENV_STRING_RE.replace_all(&cleaned, "S");
217        if CODE_ENV_MISSING_COMMA_SPACE_RE.is_match(&cleaned)
218            || MISSING_SPACES_RE.is_match(&cleaned)
219        {
220            return Some(tex_violation_with_fix(
221                file,
222                line_index,
223                env.span.pos,
224                "JSS-CODE-003",
225                Some(
226                    "Add spaces around operators and after commas in the code sample (e.g., 'f(x = 1, y = 2)' rather than 'f(x=1,y=2)')."
227                        .to_string(),
228                ),
229                None,
230            ));
231        }
232    }
233    None
234}
235
236fn first_group_arg<'a>(
237    macro_node: &'a MacroNode,
238    parent: &[Slot<'a>],
239    idx: usize,
240) -> Option<&'a GroupNode> {
241    for arg in &macro_node.args {
242        if let Some(Node::Group(g)) = arg {
243            return Some(g);
244        }
245    }
246    extract::next_group_arg(parent, idx)
247}
248
249/// Mirrors `code_style.py::_group_plain_text` — reconstructs
250/// `\code{...}` content preserving LaTeX special escapes, including
251/// the `\?`-is-really-`\%` remap left by `neutralize::verbatim_args`
252/// (see that function's doc comment).
253fn group_plain_text(group: &GroupNode) -> String {
254    let mut out = String::new();
255    for child in &group.nodelist {
256        match child {
257            Node::Chars(c) => out.push_str(&c.chars),
258            Node::Specials(s) => out.push_str(&s.chars),
259            Node::Macro(m) if SINGLE_CHAR_ESCAPE_MACROS.contains(&m.macroname.as_str()) => {
260                out.push_str(&m.macroname);
261            }
262            Node::Macro(m) if m.macroname == "?" => out.push('%'),
263            _ => {}
264        }
265    }
266    out.trim().to_string()
267}