1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ProvidesKind {
30 Package,
31 Class,
32 File,
33}
34
35impl ProvidesKind {
36 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#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ProvidesDecl {
55 pub kind: ProvidesKind,
56 pub name: SmolStr,
57 pub info: Option<SmolStr>,
60 pub date: Option<SmolStr>,
63 pub version: Option<SmolStr>,
66 pub range: TextRange,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct NeedsFormatDecl {
74 pub format: SmolStr,
75 pub date: Option<SmolStr>,
76 pub range: TextRange,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct OptionDecl {
84 pub name: Option<SmolStr>,
86 pub range: TextRange,
88}
89
90pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ProvidesForm {
107 Latex2e,
108 Expl3,
109}
110
111pub 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 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 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
151pub 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
166pub 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
183fn 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
201fn has_trailing_star(command: &SyntaxNode) -> bool {
207 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, }
217 }
218 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
235fn 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
249fn 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
258fn 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
274fn 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 let cmd = command_named(
362 "\\ProvidesPackage{\\jobname}[2024/01/01]\n",
363 "ProvidesPackage",
364 );
365 assert_eq!(provides_from_command(&cmd), None);
366 }
367}