1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ProvidesKind {
33 Package,
34 Class,
35 File,
36}
37
38impl ProvidesKind {
39 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#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ProvidesDecl {
58 pub kind: ProvidesKind,
59 pub name: SmolStr,
60 pub info: Option<SmolStr>,
63 pub date: Option<SmolStr>,
66 pub version: Option<SmolStr>,
69 pub range: TextRange,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct NeedsFormatDecl {
77 pub format: SmolStr,
78 pub date: Option<SmolStr>,
79 pub range: TextRange,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct OptionDecl {
87 pub name: Option<SmolStr>,
89 pub range: TextRange,
91}
92
93pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum ProvidesForm {
110 Latex2e,
111 Expl3,
112}
113
114pub 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 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 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
154pub 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
169pub 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
186fn 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
204fn has_trailing_star(command: &SyntaxNode) -> bool {
210 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, }
220 }
221 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
238fn 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
252fn 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
261fn 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
277fn 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 let cmd = command_named(
365 "\\ProvidesPackage{\\jobname}[2024/01/01]\n",
366 "ProvidesPackage",
367 );
368 assert_eq!(provides_from_command(&cmd), None);
369 }
370}