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