use rowan::TextRange;
use smol_str::SmolStr;
use crate::ast::{command_name, control_word_range, nth_group_text};
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvidesKind {
Package,
Class,
File,
}
impl ProvidesKind {
pub fn noun(self) -> &'static str {
match self {
ProvidesKind::Package => "package",
ProvidesKind::Class => "class",
ProvidesKind::File => "file",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvidesDecl {
pub kind: ProvidesKind,
pub name: SmolStr,
pub info: Option<SmolStr>,
pub date: Option<SmolStr>,
pub version: Option<SmolStr>,
pub range: TextRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeedsFormatDecl {
pub format: SmolStr,
pub date: Option<SmolStr>,
pub range: TextRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionDecl {
pub name: Option<SmolStr>,
pub range: TextRange,
}
pub fn provides_kind(name: &str) -> Option<(ProvidesKind, ProvidesForm)> {
Some(match name {
"ProvidesPackage" => (ProvidesKind::Package, ProvidesForm::Latex2e),
"ProvidesClass" => (ProvidesKind::Class, ProvidesForm::Latex2e),
"ProvidesFile" => (ProvidesKind::File, ProvidesForm::Latex2e),
"ProvidesExplPackage" => (ProvidesKind::Package, ProvidesForm::Expl3),
"ProvidesExplClass" => (ProvidesKind::Class, ProvidesForm::Expl3),
"ProvidesExplFile" => (ProvidesKind::File, ProvidesForm::Expl3),
_ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvidesForm {
Latex2e,
Expl3,
}
pub fn provides_from_command(command: &SyntaxNode) -> Option<ProvidesDecl> {
let name = command_name(command)?;
let (kind, form) = provides_kind(&name)?;
let range = control_word_range(command)?;
let pkg_name = nth_group_text(command, 0)?;
let (info, date, version) = match form {
ProvidesForm::Latex2e => {
match first_optional_text(command) {
Some(info) => {
let (date, version) = split_date_version(&info);
(Some(SmolStr::from(info.trim())), date, version)
}
None => (None, None, None),
}
}
ProvidesForm::Expl3 => {
let date = nonempty(nth_group_text(command, 1));
let version = nonempty(nth_group_text(command, 2));
let desc = nonempty(nth_group_text(command, 3));
(desc, date, version)
}
};
Some(ProvidesDecl {
kind,
name: SmolStr::from(pkg_name.trim()),
info,
date,
version,
range,
})
}
pub fn needs_format_from_command(command: &SyntaxNode) -> Option<NeedsFormatDecl> {
if command_name(command).as_deref() != Some("NeedsTeXFormat") {
return None;
}
let range = control_word_range(command)?;
let format = nth_group_text(command, 0)?;
let date = first_optional_text(command).and_then(|t| nonempty(Some(t)));
Some(NeedsFormatDecl {
format: SmolStr::from(format.trim()),
date,
range,
})
}
pub fn option_from_command(command: &SyntaxNode) -> Option<OptionDecl> {
if command_name(command).as_deref() != Some("DeclareOption") {
return None;
}
let range = control_word_range(command)?;
let name = if has_trailing_star(command) {
None
} else {
nth_group_text(command, 0).map(|n| SmolStr::from(n.trim()))
};
Some(OptionDecl { name, range })
}
fn first_optional_text(command: &SyntaxNode) -> Option<String> {
let optional = command
.children()
.find(|child| child.kind() == SyntaxKind::OPTIONAL)?;
let mut text = String::new();
for element in optional.children_with_tokens() {
match element {
rowan::NodeOrToken::Token(token) => match token.kind() {
SyntaxKind::L_BRACKET | SyntaxKind::R_BRACKET => {}
_ => text.push_str(token.text()),
},
rowan::NodeOrToken::Node(_) => return None,
}
}
Some(text)
}
fn has_trailing_star(command: &SyntaxNode) -> bool {
let mut sibling = command.next_sibling_or_token();
while let Some(el) = sibling {
match el {
rowan::NodeOrToken::Token(token) => match token.kind() {
SyntaxKind::WHITESPACE | SyntaxKind::COMMENT => {
sibling = token.next_sibling_or_token();
}
SyntaxKind::WORD => return token.text() == "*",
_ => return false,
},
rowan::NodeOrToken::Node(_) => return false,
}
}
false
}
fn split_date_version(info: &str) -> (Option<SmolStr>, Option<SmolStr>) {
let mut fields = info.split_whitespace();
let date = fields.next().filter(|f| is_date_like(f)).map(SmolStr::from);
let version = info
.split_whitespace()
.find(|f| is_version_like(f))
.map(SmolStr::from);
(date, version)
}
fn is_date_like(field: &str) -> bool {
let parts: Vec<&str> = field.split('/').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
fn is_version_like(field: &str) -> bool {
if is_date_like(field) {
return false;
}
let mut bytes = field.bytes();
match bytes.next() {
Some(b'v') | Some(b'V') => field[1..]
.bytes()
.next()
.is_some_and(|b| b.is_ascii_digit()),
_ => false,
}
}
fn nonempty(text: Option<String>) -> Option<SmolStr> {
text.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.map(SmolStr::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::syntax::SyntaxNode;
fn command_named(src: &str, name: &str) -> SyntaxNode {
let root = SyntaxNode::new_root(parse(src).green);
root.descendants()
.filter(|n| n.kind() == SyntaxKind::COMMAND)
.find(|n| command_name(n).as_deref() == Some(name))
.expect("command present")
}
#[test]
fn provides_package_latex2e() {
let cmd = command_named(
"\\ProvidesPackage{mypkg}[2024/01/01 v1.2 My package]\n",
"ProvidesPackage",
);
let decl = provides_from_command(&cmd).expect("extracted");
assert_eq!(decl.kind, ProvidesKind::Package);
assert_eq!(decl.name, "mypkg");
assert_eq!(decl.date.as_deref(), Some("2024/01/01"));
assert_eq!(decl.version.as_deref(), Some("v1.2"));
assert_eq!(decl.info.as_deref(), Some("2024/01/01 v1.2 My package"));
}
#[test]
fn provides_class_no_bracket() {
let cmd = command_named("\\ProvidesClass{myclass}\n", "ProvidesClass");
let decl = provides_from_command(&cmd).expect("extracted");
assert_eq!(decl.kind, ProvidesKind::Class);
assert_eq!(decl.name, "myclass");
assert_eq!(decl.info, None);
assert_eq!(decl.date, None);
assert_eq!(decl.version, None);
}
#[test]
fn provides_expl_package_four_groups() {
let cmd = command_named(
"\\ProvidesExplPackage{mypkg}{2024/01/01}{1.2}{My package}\n",
"ProvidesExplPackage",
);
let decl = provides_from_command(&cmd).expect("extracted");
assert_eq!(decl.name, "mypkg");
assert_eq!(decl.date.as_deref(), Some("2024/01/01"));
assert_eq!(decl.version.as_deref(), Some("1.2"));
assert_eq!(decl.info.as_deref(), Some("My package"));
}
#[test]
fn needs_tex_format() {
let cmd = command_named("\\NeedsTeXFormat{LaTeX2e}[2020/10/01]\n", "NeedsTeXFormat");
let decl = needs_format_from_command(&cmd).expect("extracted");
assert_eq!(decl.format, "LaTeX2e");
assert_eq!(decl.date.as_deref(), Some("2020/10/01"));
}
#[test]
fn declare_option_named() {
let cmd = command_named("\\DeclareOption{draft}{\\@draft}\n", "DeclareOption");
let decl = option_from_command(&cmd).expect("extracted");
assert_eq!(decl.name.as_deref(), Some("draft"));
}
#[test]
fn declare_option_star_is_default_handler() {
let cmd = command_named(
"\\DeclareOption*{\\PassOptionsToPackage{\\CurrentOption}{base}}\n",
"DeclareOption",
);
let decl = option_from_command(&cmd).expect("extracted");
assert_eq!(decl.name, None);
}
#[test]
fn nested_macro_name_is_skipped() {
let cmd = command_named(
"\\ProvidesPackage{\\jobname}[2024/01/01]\n",
"ProvidesPackage",
);
assert_eq!(provides_from_command(&cmd), None);
}
}