use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use rowan::TextRange;
use smol_str::SmolStr;
use crate::ast::{AstNode, Optional, child, 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,
}]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionArg {
pub text: SmolStr,
pub range: TextRange,
}
pub fn load_option_args(command: &SyntaxNode) -> Option<Vec<OptionArg>> {
let optional = child::<Optional>(command)?;
let mut tokens = Vec::new();
for element in optional.syntax().children_with_tokens() {
match element {
rowan::NodeOrToken::Token(token) => tokens.push(token),
rowan::NodeOrToken::Node(_) => return None,
}
}
let mut tokens: &[_] = &tokens;
if let Some((first, rest)) = tokens.split_first()
&& first.kind() == SyntaxKind::L_BRACKET
{
tokens = rest;
}
if let Some((last, rest)) = tokens.split_last()
&& last.kind() == SyntaxKind::R_BRACKET
{
tokens = rest;
}
let Some(first) = tokens.first() else {
return Some(Vec::new());
};
let base = usize::from(first.text_range().start());
let mut text = String::new();
for token in tokens {
text.push_str(token.text());
}
let mut args = Vec::new();
let mut offset = 0usize;
for segment in text.split(',') {
let trimmed = segment.trim();
if !trimmed.is_empty() {
let lead = segment.len() - segment.trim_start().len();
let start = base + offset + lead;
args.push(OptionArg {
text: SmolStr::from(trimmed),
range: TextRange::new(
(start as u32).into(),
((start + trimmed.len()) as u32).into(),
),
});
}
offset += segment.len() + 1;
}
Some(args)
}
pub fn resolve_load_target(name: &str, kind: PackageKind, base_dir: Option<&Path>) -> PathBuf {
resolve(PathBuf::from(name), kind.extension(), base_dir)
}
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,
})
}
pub fn dtx_source_of(target: &Path) -> Option<PathBuf> {
matches!(
target.extension().and_then(OsStr::to_str),
Some("sty" | "cls")
)
.then(|| target.with_extension("dtx"))
}
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);
}
fn option_args(src: &str) -> Option<Vec<OptionArg>> {
let root = SyntaxNode::new_root(parse(src).green);
let command = root
.descendants()
.find(|n| n.kind() == SyntaxKind::COMMAND)
.expect("a command");
load_option_args(&command)
}
fn spans(src: &str) -> Vec<(String, usize, usize)> {
option_args(src)
.expect("literal options")
.into_iter()
.map(|o| {
(
o.text.to_string(),
usize::from(o.range.start()),
usize::from(o.range.end()),
)
})
.collect()
}
#[test]
fn single_option_has_tight_range() {
let out = spans("\\usepackage[draft]{mypkg}\n");
assert_eq!(out, vec![("draft".to_string(), 12, 17)]);
}
#[test]
fn glued_comma_list_splits_with_per_segment_ranges() {
let out = spans("\\usepackage[a,b]{mypkg}\n");
assert_eq!(
out,
vec![("a".to_string(), 12, 13), ("b".to_string(), 14, 15)]
);
}
#[test]
fn whitespace_and_newlines_trim_and_shrink_ranges() {
let src = "\\usepackage[ draft ,\n final ]{mypkg}\n";
let out = spans(src);
assert_eq!(out.len(), 2);
assert_eq!(out[0].0, "draft");
assert_eq!(&src[out[0].1..out[0].2], "draft");
assert_eq!(out[1].0, "final");
assert_eq!(&src[out[1].1..out[1].2], "final");
}
#[test]
fn empty_segments_are_dropped() {
assert_eq!(option_args("\\usepackage[,]{mypkg}\n"), Some(Vec::new()));
assert_eq!(option_args("\\usepackage[ ]{mypkg}\n"), Some(Vec::new()));
assert_eq!(option_args("\\usepackage[]{mypkg}\n"), Some(Vec::new()));
}
#[test]
fn dynamic_bracket_content_is_none() {
assert_eq!(option_args("\\usepackage[\\opt]{mypkg}\n"), None);
assert_eq!(option_args("\\usepackage[a={b,c}]{mypkg}\n"), None);
}
#[test]
fn no_bracket_is_none() {
assert_eq!(option_args("\\usepackage{mypkg}\n"), None);
}
#[test]
fn resolve_load_target_matches_edge_resolution() {
let base = PathBuf::from("/proj");
assert_eq!(
resolve_load_target("mypkg", PackageKind::UsePackage, Some(&base)),
PathBuf::from("/proj/mypkg.sty")
);
let e = edges("\\usepackage{mypkg}\n", Some(&base));
assert_eq!(
e[0].target,
PackageTarget::Path(resolve_load_target(
"mypkg",
PackageKind::UsePackage,
Some(&base)
))
);
}
}