Skip to main content

badness_parser/semantic/
pkgmeta.rs

1//! Static **recognition** of the package/class authoring commands — the metadata a
2//! `.sty`/`.cls` declares about itself. Definitions are inspected, not executed.
3//!
4//! Three declarations are extracted, mirroring how `\definecolor`/`\newglossaryentry`
5//! feed the [`SemanticModel`](super::SemanticModel):
6//!
7//! - **`\ProvidesPackage`/`\ProvidesClass`/`\ProvidesFile`** and the expl3
8//!   **`\ProvidesExplPackage`/`Class`/`File`** — the package/class *identity*
9//!   (name, date, version, description) → [`ProvidesDecl`].
10//! - **`\NeedsTeXFormat`** — the required format and optional release date →
11//!   [`NeedsFormatDecl`].
12//! - **`\DeclareOption`** (and the starred default handler `\DeclareOption*`) — a
13//!   declared option name → [`OptionDecl`].
14//!
15//! `\ProcessOptions`/`\ExecuteOptions` carry no extractable identity, so they get no
16//! model entry — the LSP hover renders a static note for them.
17//!
18//! Extraction populates [`SemanticModel`](super::SemanticModel) during its CST walk.
19//! Consumers match declarations by their control-word range.
20
21use rowan::TextRange;
22use smol_str::SmolStr;
23
24use crate::ast::{AstNode, Optional, child, command_name, control_word_range, nth_group_text};
25use crate::syntax::{SyntaxKind, SyntaxNode};
26
27/// Which of the three `\Provides…` namespaces a declaration names.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ProvidesKind {
30    Package,
31    Class,
32    File,
33}
34
35impl ProvidesKind {
36    /// The lowercase noun for rendering (`package`, `class`, `file`).
37    pub fn noun(self) -> &'static str {
38        match self {
39            ProvidesKind::Package => "package",
40            ProvidesKind::Class => "class",
41            ProvidesKind::File => "file",
42        }
43    }
44}
45
46/// A `\ProvidesPackage`/`Class`/`File` (or its expl3 variant) self-identification.
47///
48/// The LaTeX2e form is `\ProvidesPackage{name}[date version and other info]`; the
49/// bracket's free text is kept verbatim in [`info`](Self::info), with a best-effort
50/// split into [`date`](Self::date) and [`version`](Self::version). The expl3 form
51/// `\ProvidesExplPackage{name}{date}{version}{description}` fills the fields from its
52/// four braced groups directly.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ProvidesDecl {
55    pub kind: ProvidesKind,
56    pub name: SmolStr,
57    /// The raw `[date version desc]` bracket text (LaTeX2e form), or the description
58    /// group (expl3 form). `None` when absent.
59    pub info: Option<SmolStr>,
60    /// Best-effort release date (`YYYY/MM/DD`), split from `info` or the expl3 date
61    /// group.
62    pub date: Option<SmolStr>,
63    /// Best-effort version (a `v`-prefixed or digit-leading token), split from `info`
64    /// or the expl3 version group.
65    pub version: Option<SmolStr>,
66    /// The `\ProvidesPackage` control-word token range — the hover anchor and the key
67    /// the LSP matches the cursor against.
68    pub range: TextRange,
69}
70
71/// A `\NeedsTeXFormat{format}[date]` declaration.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct NeedsFormatDecl {
74    pub format: SmolStr,
75    pub date: Option<SmolStr>,
76    /// The `\NeedsTeXFormat` control-word token range.
77    pub range: TextRange,
78}
79
80/// A `\DeclareOption{name}{code}` declaration, or the starred default handler
81/// `\DeclareOption*{code}` (with [`name`](Self::name) `None`).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct OptionDecl {
84    /// The declared option name, or `None` for the `\DeclareOption*` default handler.
85    pub name: Option<SmolStr>,
86    /// The `\DeclareOption` control-word token range.
87    pub range: TextRange,
88}
89
90/// Whether `name` is a `\Provides…` declaration, and which namespace it names.
91pub fn provides_kind(name: &str) -> Option<(ProvidesKind, ProvidesForm)> {
92    Some(match name {
93        "ProvidesPackage" => (ProvidesKind::Package, ProvidesForm::Latex2e),
94        "ProvidesClass" => (ProvidesKind::Class, ProvidesForm::Latex2e),
95        "ProvidesFile" => (ProvidesKind::File, ProvidesForm::Latex2e),
96        "ProvidesExplPackage" => (ProvidesKind::Package, ProvidesForm::Expl3),
97        "ProvidesExplClass" => (ProvidesKind::Class, ProvidesForm::Expl3),
98        "ProvidesExplFile" => (ProvidesKind::File, ProvidesForm::Expl3),
99        _ => return None,
100    })
101}
102
103/// The argument shape of a `\Provides…` declaration: the LaTeX2e `{name}[info]` or the
104/// expl3 `{name}{date}{version}{description}`.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ProvidesForm {
107    Latex2e,
108    Expl3,
109}
110
111/// Extract a [`ProvidesDecl`] from a `\Provides…` `COMMAND` node, or `None` if the
112/// name group is a non-literal (nested macro) or the command is not a `\Provides…`.
113pub fn provides_from_command(command: &SyntaxNode) -> Option<ProvidesDecl> {
114    let name = command_name(command)?;
115    let (kind, form) = provides_kind(&name)?;
116    let range = control_word_range(command)?;
117    let pkg_name = nth_group_text(command, 0)?;
118
119    let (info, date, version) = match form {
120        ProvidesForm::Latex2e => {
121            // `[date version and other info]` is an OPTIONAL node, not a GROUP, so it
122            // never shifts the name's group index. Keep the free text verbatim and
123            // split it best-effort.
124            match first_optional_text(command) {
125                Some(info) => {
126                    let (date, version) = split_date_version(&info);
127                    (Some(SmolStr::from(info.trim())), date, version)
128                }
129                None => (None, None, None),
130            }
131        }
132        ProvidesForm::Expl3 => {
133            // `{name}{date}{version}{description}` — fields straight from the groups.
134            let date = nonempty(nth_group_text(command, 1));
135            let version = nonempty(nth_group_text(command, 2));
136            let desc = nonempty(nth_group_text(command, 3));
137            (desc, date, version)
138        }
139    };
140
141    Some(ProvidesDecl {
142        kind,
143        name: SmolStr::from(pkg_name.trim()),
144        info,
145        date,
146        version,
147        range,
148    })
149}
150
151/// Extract a [`NeedsFormatDecl`] from a `\NeedsTeXFormat` `COMMAND` node.
152pub fn needs_format_from_command(command: &SyntaxNode) -> Option<NeedsFormatDecl> {
153    if command_name(command).as_deref() != Some("NeedsTeXFormat") {
154        return None;
155    }
156    let range = control_word_range(command)?;
157    let format = nth_group_text(command, 0)?;
158    let date = first_optional_text(command).and_then(|t| nonempty(Some(t)));
159    Some(NeedsFormatDecl {
160        format: SmolStr::from(format.trim()),
161        date,
162        range,
163    })
164}
165
166/// Extract an [`OptionDecl`] from a `\DeclareOption` `COMMAND` node. The non-star form
167/// `\DeclareOption{name}{code}` reads the name from group 0; the starred default
168/// handler `\DeclareOption*{code}` (recognized by the `*` `WORD` the parser folds into
169/// the invocation) records `name: None`.
170pub fn option_from_command(command: &SyntaxNode) -> Option<OptionDecl> {
171    if command_name(command).as_deref() != Some("DeclareOption") {
172        return None;
173    }
174    let range = control_word_range(command)?;
175    let name = if has_trailing_star(command) {
176        None
177    } else {
178        nth_group_text(command, 0).map(|n| SmolStr::from(n.trim()))
179    };
180    Some(OptionDecl { name, range })
181}
182
183/// The text inside the first `OPTIONAL` (`[…]`) child of `command`, braces stripped.
184/// `None` when there is no optional or it holds non-literal content — a nested macro
185/// in the bracket makes the whole thing unresolvable, like [`nth_group_text`].
186fn first_optional_text(command: &SyntaxNode) -> Option<String> {
187    let optional = child::<Optional>(command)?;
188    let mut text = String::new();
189    for element in optional.syntax().children_with_tokens() {
190        match element {
191            rowan::NodeOrToken::Token(token) => match token.kind() {
192                SyntaxKind::L_BRACKET | SyntaxKind::R_BRACKET => {}
193                _ => text.push_str(token.text()),
194            },
195            rowan::NodeOrToken::Node(_) => return None,
196        }
197    }
198    Some(text)
199}
200
201/// Whether a `*` `WORD` marks `command` as the starred form. A starred variant with a
202/// following argument folds the `*` into the invocation (`at_star_variant_marker`),
203/// so it is a *child* token between the control word and the first attached group; a bare
204/// `\DeclareOption*` with nothing after the star does not fold and leaves the `*` a
205/// *sibling* `WORD` — both shapes count.
206fn has_trailing_star(command: &SyntaxNode) -> bool {
207    // Folded form: the `*` is a child token before any attached argument group.
208    for el in command.children_with_tokens() {
209        match el {
210            rowan::NodeOrToken::Token(token) => match token.kind() {
211                SyntaxKind::CONTROL_WORD | SyntaxKind::WHITESPACE | SyntaxKind::COMMENT => {}
212                SyntaxKind::WORD if token.text() == "*" => return true,
213                _ => break,
214            },
215            rowan::NodeOrToken::Node(_) => break, // an argument group — no star before it
216        }
217    }
218    // Unfolded fallback: a `*` the parser left as a following sibling token.
219    let mut sibling = command.next_sibling_or_token();
220    while let Some(el) = sibling {
221        match el {
222            rowan::NodeOrToken::Token(token) => match token.kind() {
223                SyntaxKind::WHITESPACE | SyntaxKind::COMMENT => {
224                    sibling = token.next_sibling_or_token();
225                }
226                SyntaxKind::WORD => return token.text() == "*",
227                _ => return false,
228            },
229            rowan::NodeOrToken::Node(_) => return false,
230        }
231    }
232    false
233}
234
235/// Best-effort split of a LaTeX2e `[date version and other info]` free-text bracket:
236/// a leading `YYYY/MM/DD`-shaped field becomes the date, and a `v`-prefixed or
237/// digit-leading token becomes the version. Everything is heuristic and free text, so
238/// a miss just leaves the field `None` (the raw `info` is always kept).
239fn split_date_version(info: &str) -> (Option<SmolStr>, Option<SmolStr>) {
240    let mut fields = info.split_whitespace();
241    let date = fields.next().filter(|f| is_date_like(f)).map(SmolStr::from);
242    let version = info
243        .split_whitespace()
244        .find(|f| is_version_like(f))
245        .map(SmolStr::from);
246    (date, version)
247}
248
249/// A `YYYY/MM/DD`-shaped token (the LaTeX package date convention).
250fn is_date_like(field: &str) -> bool {
251    let parts: Vec<&str> = field.split('/').collect();
252    parts.len() == 3
253        && parts
254            .iter()
255            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
256}
257
258/// A version-ish token: a `v`/`V` prefix followed by a digit (`v1.2`), or a bare
259/// digit-leading token that is not itself a date.
260fn is_version_like(field: &str) -> bool {
261    if is_date_like(field) {
262        return false;
263    }
264    let mut bytes = field.bytes();
265    match bytes.next() {
266        Some(b'v') | Some(b'V') => field[1..]
267            .bytes()
268            .next()
269            .is_some_and(|b| b.is_ascii_digit()),
270        _ => false,
271    }
272}
273
274/// Trim a group's text and drop it if empty.
275fn nonempty(text: Option<String>) -> Option<SmolStr> {
276    text.map(|t| t.trim().to_string())
277        .filter(|t| !t.is_empty())
278        .map(SmolStr::from)
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::parser::parse;
285    use crate::syntax::SyntaxNode;
286
287    fn command_named(src: &str, name: &str) -> SyntaxNode {
288        let root = SyntaxNode::new_root(parse(src).green);
289        root.descendants()
290            .filter(|n| n.kind() == SyntaxKind::COMMAND)
291            .find(|n| command_name(n).as_deref() == Some(name))
292            .expect("command present")
293    }
294
295    #[test]
296    fn provides_package_latex2e() {
297        let cmd = command_named(
298            "\\ProvidesPackage{mypkg}[2024/01/01 v1.2 My package]\n",
299            "ProvidesPackage",
300        );
301        let decl = provides_from_command(&cmd).expect("extracted");
302        assert_eq!(decl.kind, ProvidesKind::Package);
303        assert_eq!(decl.name, "mypkg");
304        assert_eq!(decl.date.as_deref(), Some("2024/01/01"));
305        assert_eq!(decl.version.as_deref(), Some("v1.2"));
306        assert_eq!(decl.info.as_deref(), Some("2024/01/01 v1.2 My package"));
307    }
308
309    #[test]
310    fn provides_class_no_bracket() {
311        let cmd = command_named("\\ProvidesClass{myclass}\n", "ProvidesClass");
312        let decl = provides_from_command(&cmd).expect("extracted");
313        assert_eq!(decl.kind, ProvidesKind::Class);
314        assert_eq!(decl.name, "myclass");
315        assert_eq!(decl.info, None);
316        assert_eq!(decl.date, None);
317        assert_eq!(decl.version, None);
318    }
319
320    #[test]
321    fn provides_expl_package_four_groups() {
322        let cmd = command_named(
323            "\\ProvidesExplPackage{mypkg}{2024/01/01}{1.2}{My package}\n",
324            "ProvidesExplPackage",
325        );
326        let decl = provides_from_command(&cmd).expect("extracted");
327        assert_eq!(decl.name, "mypkg");
328        assert_eq!(decl.date.as_deref(), Some("2024/01/01"));
329        assert_eq!(decl.version.as_deref(), Some("1.2"));
330        assert_eq!(decl.info.as_deref(), Some("My package"));
331    }
332
333    #[test]
334    fn needs_tex_format() {
335        let cmd = command_named("\\NeedsTeXFormat{LaTeX2e}[2020/10/01]\n", "NeedsTeXFormat");
336        let decl = needs_format_from_command(&cmd).expect("extracted");
337        assert_eq!(decl.format, "LaTeX2e");
338        assert_eq!(decl.date.as_deref(), Some("2020/10/01"));
339    }
340
341    #[test]
342    fn declare_option_named() {
343        let cmd = command_named("\\DeclareOption{draft}{\\@draft}\n", "DeclareOption");
344        let decl = option_from_command(&cmd).expect("extracted");
345        assert_eq!(decl.name.as_deref(), Some("draft"));
346    }
347
348    #[test]
349    fn declare_option_star_is_default_handler() {
350        let cmd = command_named(
351            "\\DeclareOption*{\\PassOptionsToPackage{\\CurrentOption}{base}}\n",
352            "DeclareOption",
353        );
354        let decl = option_from_command(&cmd).expect("extracted");
355        assert_eq!(decl.name, None);
356    }
357
358    #[test]
359    fn nested_macro_name_is_skipped() {
360        // A non-literal name group yields `None`, conservative like `\label{\foo}`.
361        let cmd = command_named(
362            "\\ProvidesPackage{\\jobname}[2024/01/01]\n",
363            "ProvidesPackage",
364        );
365        assert_eq!(provides_from_command(&cmd), None);
366    }
367}