Skip to main content

jsslint_core/rules/
mod.rs

1//! Shared rule infrastructure — spec 018 Phase 2. Ports the parts of
2//! `_helpers.py` and per-rule-module helper functions that more than
3//! one bib rule needs: citation-scope resolution
4//! (`_iter_referenced_entries` / `_collect_cited_keys`), the
5//! `entry_line`/`entry_violation` factories, and the source-rescan
6//! technique `naming.py`/`house_style.py` use to locate a field value's
7//! byte span for a `Fix` (bibtexparser exposes no field-level offsets,
8//! only `entry.start_line`).
9
10pub mod abbreviations;
11pub mod bibtex;
12pub mod capitalization;
13pub mod citations;
14pub mod code_style;
15pub mod code_width;
16pub mod crossrefs;
17pub mod house_style;
18pub mod markup;
19pub mod naming;
20pub mod operators;
21pub mod preamble;
22pub mod references;
23pub mod structure;
24pub mod tex_common;
25pub mod typography;
26
27use crate::bib::{Entry, Library};
28use crate::catalogue;
29use crate::report::{Fix, Violation};
30use crate::tex::extract;
31use crate::tex::node::Node as TexNode;
32use crate::tex::prose::{Slot, CITE_MACROS_FOR_SCOPE};
33use std::collections::HashSet;
34
35/// Mimics Python's `repr()` for a plain string — used everywhere a
36/// rule message embeds a value via Python's `!r` (e.g.
37/// `f"key {entry.key!r} ..."`). Rust's `{:?}` always double-quotes;
38/// Python's `repr()` prefers single quotes and only switches to double
39/// when the string contains a `'` but no `"` (CPython's
40/// `unicode_repr`). Covers the common cases (quotes, backslash, `\n`/
41/// `\r`/`\t`); does not attempt full `\xXX`-escaping of arbitrary
42/// control characters, which none of these rules' inputs produce.
43pub fn py_repr(s: &str) -> String {
44    let has_single = s.contains('\'');
45    let has_double = s.contains('"');
46    let (quote, escape_quote) = if has_single && !has_double {
47        ('"', '"')
48    } else {
49        ('\'', '\'')
50    };
51    let mut out = String::with_capacity(s.len() + 2);
52    out.push(quote);
53    for c in s.chars() {
54        match c {
55            '\\' => out.push_str("\\\\"),
56            '\n' => out.push_str("\\n"),
57            '\r' => out.push_str("\\r"),
58            '\t' => out.push_str("\\t"),
59            c if c == escape_quote => {
60                out.push('\\');
61                out.push(c);
62            }
63            c => out.push(c),
64        }
65    }
66    out.push(quote);
67    out
68}
69
70/// 1-based line of an entry's `@type{` opening. Mirrors
71/// `_helpers.py::entry_line` (`entry.start_line` is 0-based).
72pub fn entry_line(entry: &Entry) -> u32 {
73    entry.start_line + 1
74}
75
76/// A catalogue-backed violation anchored to a bib entry's first line.
77/// Mirrors `_helpers.py::entry_violation`.
78pub fn entry_violation(
79    file: &str,
80    entry: &Entry,
81    rule_id: &str,
82    suggestion: Option<String>,
83) -> Violation {
84    let meta = catalogue::lookup(rule_id).unwrap_or_else(|| panic!("unknown rule_id {rule_id}"));
85    Violation {
86        file: file.to_string(),
87        line: entry_line(entry),
88        column: None,
89        rule_id: rule_id.to_string(),
90        severity: meta.severity,
91        message: meta.message_template.to_string(),
92        suggestion,
93        fix: None,
94    }
95}
96
97/// Same as `entry_violation` but with an attached `Fix`.
98pub fn entry_violation_with_fix(
99    file: &str,
100    entry: &Entry,
101    rule_id: &str,
102    suggestion: Option<String>,
103    fix: Option<Fix>,
104) -> Violation {
105    Violation {
106        fix,
107        ..entry_violation(file, entry, rule_id, suggestion)
108    }
109}
110
111// --- citation-scope resolution (mirrors _helpers.py) ------------------
112
113/// `(cited_keys, include_all)` from every `\cite*`/`\nocite` macro
114/// across the given tex-like node lists. Mirrors
115/// `_helpers.py::_collect_cited_keys`.
116pub fn collect_cited_keys(tex_like: &[&[TexNode]]) -> (HashSet<String>, bool) {
117    let mut keys = HashSet::new();
118    let mut include_all = false;
119    for &nodes in tex_like {
120        extract::iter_with_parent_visit(nodes, &mut |parent: &[Slot], idx, node| {
121            let TexNode::Macro(m) = node else { return };
122            if !CITE_MACROS_FOR_SCOPE.contains(&m.macroname.as_str()) {
123                return;
124            }
125            let text = extract::macro_args_text(m, parent, idx);
126            for raw in text.split(',') {
127                let key = raw.trim();
128                if key == "*" {
129                    include_all = true;
130                } else if !key.is_empty() {
131                    keys.insert(key.to_string());
132                }
133            }
134        });
135    }
136    (keys, include_all)
137}
138
139/// Entries referenced from the paper's tex-like surface (or every
140/// entry, per the bib-only / `\nocite{*}` scope-widening rules).
141/// Mirrors `_helpers.py::_iter_referenced_entries`. `tex_like` empty
142/// means "no tex/rnw/rmd input present" — the bib-only widening case.
143pub fn referenced_entries<'a>(library: &'a Library, tex_like: &[&[TexNode]]) -> Vec<&'a Entry> {
144    if tex_like.is_empty() {
145        return library.entries.iter().collect();
146    }
147    let (cited, include_all) = collect_cited_keys(tex_like);
148    let cited_lower: HashSet<String> = cited.iter().map(|k| k.to_lowercase()).collect();
149    library
150        .entries
151        .iter()
152        .filter(|e| include_all || cited_lower.contains(&e.key.to_lowercase()))
153        .collect()
154}
155
156// --- field-value Fix span location (mirrors naming.py) -----------------
157
158/// The `[start, end)` char-offset slice of `source` covering `entry`:
159/// from the start of its `@type{` line through just before the next
160/// line-starting `@` (or EOF). Mirrors `naming.py::_entry_source_span`.
161pub fn entry_source_span(source_chars: &[char], entry: &Entry) -> (usize, usize) {
162    let start = line_start_offset(source_chars, entry.start_line);
163    let mut end = source_chars.len();
164    let mut i = start + 1;
165    while i < source_chars.len() {
166        let at_line_start = i == start + 1 || source_chars[i - 1] == '\n';
167        if at_line_start && source_chars[i] == '@' {
168            end = i;
169            break;
170        }
171        i += 1;
172    }
173    (start, end)
174}
175
176fn line_start_offset(chars: &[char], line: u32) -> usize {
177    let mut offset = 0usize;
178    let mut cur = 0u32;
179    while cur < line {
180        match chars[offset..].iter().position(|&c| c == '\n') {
181            Some(rel) => {
182                offset += rel + 1;
183                cur += 1;
184            }
185            None => return chars.len(),
186        }
187    }
188    offset
189}
190
191/// Locates `field_name = <delim><expected_value><delim>` within
192/// `entry`'s source span and returns the `[start, end)` char-offset
193/// span of JUST the value (delimiters excluded, whitespace trimmed) —
194/// the same span a `Fix` should cover so the rewrite preserves the
195/// surrounding `{}`/`""` style. Mirrors `naming.py::_field_value_span`,
196/// **including its non-nested-brace limitation**: the value scanner
197/// stops at the first `}`/`"`, it does not balance nested braces (this
198/// is a deliberate parity choice, not an oversight — replicating
199/// Python's regex `[^{}]*` exactly).
200pub fn field_value_span(
201    source_chars: &[char],
202    entry: &Entry,
203    field_name: &str,
204    expected_value: &str,
205) -> Option<(usize, usize)> {
206    let (es, ee) = entry_source_span(source_chars, entry);
207    let name_chars: Vec<char> = field_name.chars().collect();
208    let mut i = es;
209    while i < ee {
210        let boundary_ok =
211            i == es || !(source_chars[i - 1].is_ascii_alphanumeric() || source_chars[i - 1] == '_');
212        let name_matches = boundary_ok
213            && i + name_chars.len() <= ee
214            && source_chars[i..i + name_chars.len()]
215                .iter()
216                .zip(&name_chars)
217                .all(|(a, b)| a.eq_ignore_ascii_case(b));
218        if !name_matches {
219            i += 1;
220            continue;
221        }
222        let mut j = skip_ws(source_chars, i + name_chars.len(), ee);
223        if j >= ee || source_chars[j] != '=' {
224            i += 1;
225            continue;
226        }
227        j = skip_ws(source_chars, j + 1, ee);
228        if j >= ee {
229            break;
230        }
231        let (mut v_start, mut v_end) = match source_chars[j] {
232            '{' => match find_char(source_chars, j + 1, ee, '}') {
233                Some(close) => (j + 1, close),
234                None => {
235                    i += 1;
236                    continue;
237                }
238            },
239            '"' => match find_char(source_chars, j + 1, ee, '"') {
240                Some(close) => (j + 1, close),
241                None => {
242                    i += 1;
243                    continue;
244                }
245            },
246            _ => {
247                i += 1;
248                continue;
249            }
250        };
251        let literal: String = source_chars[v_start..v_end].iter().collect();
252        if literal.trim() != expected_value {
253            i += 1;
254            continue;
255        }
256        // Trim in CHAR units (not `String::len()` bytes) since v_start/
257        // v_end index `source_chars`, a `&[char]` — a byte-length delta
258        // would corrupt these offsets for any non-ASCII whitespace-
259        // adjacent content.
260        let inner = &source_chars[v_start..v_end];
261        let lead_ws = inner.iter().take_while(|c| c.is_whitespace()).count();
262        let trail_ws = inner.iter().rev().take_while(|c| c.is_whitespace()).count();
263        v_start += lead_ws;
264        v_end -= trail_ws.min(inner.len() - lead_ws);
265        return Some((v_start, v_end));
266    }
267    None
268}
269
270fn skip_ws(chars: &[char], mut i: usize, end: usize) -> usize {
271    while i < end && chars[i].is_whitespace() {
272        i += 1;
273    }
274    i
275}
276
277fn find_char(chars: &[char], start: usize, end: usize, needle: char) -> Option<usize> {
278    (start..end).find(|&i| chars[i] == needle)
279}