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<impl AsRef<str>>) -> Option<SmolStr> {
276 text.map(|text| SmolStr::new(text.as_ref().trim()))
277 .filter(|text| !text.is_empty())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::parser::parse;
284 use crate::syntax::SyntaxNode;
285
286 fn command_named(src: &str, name: &str) -> SyntaxNode {
287 let root = SyntaxNode::new_root(parse(src).green);
288 root.descendants()
289 .filter(|n| n.kind() == SyntaxKind::COMMAND)
290 .find(|n| command_name(n).as_deref() == Some(name))
291 .expect("command present")
292 }
293
294 #[test]
295 fn provides_package_latex2e() {
296 let cmd = command_named(
297 "\\ProvidesPackage{mypkg}[2024/01/01 v1.2 My package]\n",
298 "ProvidesPackage",
299 );
300 let decl = provides_from_command(&cmd).expect("extracted");
301 assert_eq!(decl.kind, ProvidesKind::Package);
302 assert_eq!(decl.name, "mypkg");
303 assert_eq!(decl.date.as_deref(), Some("2024/01/01"));
304 assert_eq!(decl.version.as_deref(), Some("v1.2"));
305 assert_eq!(decl.info.as_deref(), Some("2024/01/01 v1.2 My package"));
306 }
307
308 #[test]
309 fn provides_class_no_bracket() {
310 let cmd = command_named("\\ProvidesClass{myclass}\n", "ProvidesClass");
311 let decl = provides_from_command(&cmd).expect("extracted");
312 assert_eq!(decl.kind, ProvidesKind::Class);
313 assert_eq!(decl.name, "myclass");
314 assert_eq!(decl.info, None);
315 assert_eq!(decl.date, None);
316 assert_eq!(decl.version, None);
317 }
318
319 #[test]
320 fn provides_expl_package_four_groups() {
321 let cmd = command_named(
322 "\\ProvidesExplPackage{mypkg}{2024/01/01}{1.2}{My package}\n",
323 "ProvidesExplPackage",
324 );
325 let decl = provides_from_command(&cmd).expect("extracted");
326 assert_eq!(decl.name, "mypkg");
327 assert_eq!(decl.date.as_deref(), Some("2024/01/01"));
328 assert_eq!(decl.version.as_deref(), Some("1.2"));
329 assert_eq!(decl.info.as_deref(), Some("My package"));
330 }
331
332 #[test]
333 fn needs_tex_format() {
334 let cmd = command_named("\\NeedsTeXFormat{LaTeX2e}[2020/10/01]\n", "NeedsTeXFormat");
335 let decl = needs_format_from_command(&cmd).expect("extracted");
336 assert_eq!(decl.format, "LaTeX2e");
337 assert_eq!(decl.date.as_deref(), Some("2020/10/01"));
338 }
339
340 #[test]
341 fn declare_option_named() {
342 let cmd = command_named("\\DeclareOption{draft}{\\@draft}\n", "DeclareOption");
343 let decl = option_from_command(&cmd).expect("extracted");
344 assert_eq!(decl.name.as_deref(), Some("draft"));
345 }
346
347 #[test]
348 fn declare_option_star_is_default_handler() {
349 let cmd = command_named(
350 "\\DeclareOption*{\\PassOptionsToPackage{\\CurrentOption}{base}}\n",
351 "DeclareOption",
352 );
353 let decl = option_from_command(&cmd).expect("extracted");
354 assert_eq!(decl.name, None);
355 }
356
357 #[test]
358 fn nested_macro_name_is_skipped() {
359 let cmd = command_named(
360 "\\ProvidesPackage{\\jobname}[2024/01/01]\n",
361 "ProvidesPackage",
362 );
363 assert_eq!(provides_from_command(&cmd), None);
364 }
365}