1use 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 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});
47static PATH_LIKE_RE: LazyLock<Regex> =
51 LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]*)+/?$").unwrap());
52static 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
69pub 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; }
94 });
95 out
96}
97
98pub 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 let quoted = whole.as_str().replace(bareword, &format!("\"{bareword}\""));
119 let suggestion = format!("Quote the argument: e.g., {}.", super::py_repr("ed));
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
147pub 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 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 ¯o_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
249fn 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}