use std::path::{Path, PathBuf};
use rowan::TextRange;
use crate::ast::{command_name, nth_group_text};
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)]
pub enum PackageKind {
UsePackage,
RequirePackage,
DocumentClass,
LoadClass,
LoadClassWithOptions,
}
impl PackageKind {
fn extension(self) -> &'static str {
match self {
PackageKind::UsePackage | PackageKind::RequirePackage => "sty",
PackageKind::DocumentClass
| PackageKind::LoadClass
| PackageKind::LoadClassWithOptions => "cls",
}
}
fn is_list(self) -> bool {
matches!(self, PackageKind::UsePackage | PackageKind::RequirePackage)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::Update)]
pub enum PackageTarget {
Path(PathBuf),
Dynamic,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::Update)]
pub struct PackageEdgeKey {
pub kind: PackageKind,
pub target: PackageTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageEdge {
pub kind: PackageKind,
pub target: PackageTarget,
pub range: TextRange,
}
impl PackageEdge {
pub fn key(&self) -> PackageEdgeKey {
PackageEdgeKey {
kind: self.kind,
target: self.target.clone(),
}
}
}
pub fn collect_package_edges(root: &SyntaxNode, base_dir: Option<&Path>) -> Vec<PackageEdge> {
root.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
.flat_map(|node| package_edges_of(&node, base_dir))
.collect()
}
pub fn collect_package_edge_keys(
root: &SyntaxNode,
base_dir: Option<&Path>,
) -> Vec<PackageEdgeKey> {
root.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
.flat_map(|node| package_edges_of(&node, base_dir))
.map(|edge| edge.key())
.collect()
}
fn package_edges_of(command: &SyntaxNode, base_dir: Option<&Path>) -> Vec<PackageEdge> {
let Some(kind) = command_name(command).and_then(|name| package_kind(&name)) else {
return Vec::new();
};
let range = command.text_range();
let ext = kind.extension();
let dynamic = || {
vec![PackageEdge {
kind,
target: PackageTarget::Dynamic,
range,
}]
};
let Some(text) = nth_group_text(command, 0) else {
return dynamic();
};
if kind.is_list() {
let edges: Vec<PackageEdge> = text
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| PackageEdge {
kind,
target: PackageTarget::Path(resolve(PathBuf::from(name), ext, base_dir)),
range,
})
.collect();
if edges.is_empty() { dynamic() } else { edges }
} else {
let name = text.trim();
if name.is_empty() {
return dynamic();
}
vec![PackageEdge {
kind,
target: PackageTarget::Path(resolve(PathBuf::from(name), ext, base_dir)),
range,
}]
}
}
fn package_kind(name: &str) -> Option<PackageKind> {
Some(match name {
"usepackage" => PackageKind::UsePackage,
"RequirePackage" => PackageKind::RequirePackage,
"documentclass" => PackageKind::DocumentClass,
"LoadClass" => PackageKind::LoadClass,
"LoadClassWithOptions" => PackageKind::LoadClassWithOptions,
_ => return None,
})
}
fn resolve(raw: PathBuf, ext: &str, base_dir: Option<&Path>) -> PathBuf {
let with_ext = if raw.extension().is_none() {
raw.with_extension(ext)
} else {
raw
};
match base_dir {
Some(dir) if with_ext.is_relative() => dir.join(with_ext),
_ => with_ext,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn edges(src: &str, base_dir: Option<&Path>) -> Vec<PackageEdge> {
let root = SyntaxNode::new_root(parse(src).green);
collect_package_edges(&root, base_dir)
}
fn targets(src: &str, base_dir: Option<&Path>) -> Vec<PackageTarget> {
edges(src, base_dir).into_iter().map(|e| e.target).collect()
}
#[test]
fn usepackage_appends_sty_and_resolves_against_base_dir() {
let base = PathBuf::from("/proj");
let e = edges("\\usepackage{mypkg}\n", Some(&base));
assert_eq!(e.len(), 1);
assert_eq!(e[0].kind, PackageKind::UsePackage);
assert_eq!(
e[0].target,
PackageTarget::Path(PathBuf::from("/proj/mypkg.sty"))
);
}
#[test]
fn documentclass_appends_cls() {
let e = edges("\\documentclass{myclass}\n", None);
assert_eq!(e[0].kind, PackageKind::DocumentClass);
assert_eq!(
e[0].target,
PackageTarget::Path(PathBuf::from("myclass.cls"))
);
}
#[test]
fn require_package_and_load_class_recognized() {
let rp = edges("\\RequirePackage{tools}\n", None);
assert_eq!(rp[0].kind, PackageKind::RequirePackage);
assert_eq!(
rp[0].target,
PackageTarget::Path(PathBuf::from("tools.sty"))
);
let lc = edges("\\LoadClass{base}\n", None);
assert_eq!(lc[0].kind, PackageKind::LoadClass);
assert_eq!(lc[0].target, PackageTarget::Path(PathBuf::from("base.cls")));
let lco = edges("\\LoadClassWithOptions{base}\n", None);
assert_eq!(lco[0].kind, PackageKind::LoadClassWithOptions);
assert_eq!(
lco[0].target,
PackageTarget::Path(PathBuf::from("base.cls"))
);
}
#[test]
fn usepackage_splits_comma_list_into_one_edge_each() {
let base = PathBuf::from("/proj");
let t = targets("\\usepackage{amsmath, amssymb}\n", Some(&base));
assert_eq!(
t,
vec![
PackageTarget::Path(PathBuf::from("/proj/amsmath.sty")),
PackageTarget::Path(PathBuf::from("/proj/amssymb.sty")),
]
);
}
#[test]
fn options_are_skipped() {
let e = edges("\\usepackage[utf8]{inputenc}\n", None);
assert_eq!(
e[0].target,
PackageTarget::Path(PathBuf::from("inputenc.sty"))
);
}
#[test]
fn explicit_extension_is_kept() {
let e = edges("\\usepackage{local.sty}\n", None);
assert_eq!(e[0].target, PackageTarget::Path(PathBuf::from("local.sty")));
}
#[test]
fn subdirectory_name_resolves() {
let e = edges("\\usepackage{styles/mypkg}\n", None);
assert_eq!(
e[0].target,
PackageTarget::Path(PathBuf::from("styles/mypkg.sty"))
);
}
#[test]
fn absolute_target_ignores_base_dir() {
let base = PathBuf::from("/proj");
let e = edges("\\documentclass{/abs/myclass}\n", Some(&base));
assert_eq!(
e[0].target,
PackageTarget::Path(PathBuf::from("/abs/myclass.cls"))
);
}
#[test]
fn missing_argument_is_dynamic() {
let e = edges("\\usepackage\n", None);
assert_eq!(e.len(), 1);
assert_eq!(e[0].target, PackageTarget::Dynamic);
}
#[test]
fn nested_macro_argument_is_dynamic() {
let e = edges("\\usepackage{\\mypkgname}\n", None);
assert_eq!(e[0].target, PackageTarget::Dynamic);
}
#[test]
fn empty_list_is_dynamic() {
let e = edges("\\usepackage{}\n", None);
assert_eq!(e.len(), 1);
assert_eq!(e[0].target, PackageTarget::Dynamic);
}
#[test]
fn non_load_commands_are_ignored() {
let e = edges("\\input{a}\n\\section{Hi}\n\\includegraphics{logo}\n", None);
assert!(e.is_empty());
}
#[test]
fn multiple_edges_are_collected_in_source_order() {
let t = targets("\\documentclass{cls}\n\\usepackage{a,b}\n", None);
assert_eq!(
t,
vec![
PackageTarget::Path(PathBuf::from("cls.cls")),
PackageTarget::Path(PathBuf::from("a.sty")),
PackageTarget::Path(PathBuf::from("b.sty")),
]
);
}
#[test]
fn comma_list_shares_one_range() {
let e = edges("\\usepackage{a,b}\n", None);
assert_eq!(e.len(), 2);
assert_eq!(e[0].range, e[1].range);
}
}