use std::collections::HashMap;
use std::path::{Path, PathBuf};
use smol_str::SmolStr;
use crate::ast::command_name;
use crate::incremental::{IncrementalDb, QueryKind, QueryLogEntry, file_package_option_facts};
use crate::project::graph::Project;
use crate::project::include::collect_include_edge_keys;
use crate::project::package::{PackageTarget, collect_package_edges};
use crate::semantic::SemanticModel;
use crate::syntax::{SyntaxKind, SyntaxNode};
const DYNAMIC_OPTION_COMMANDS: &[&str] = &[
"RequirePackageWithOptions",
"LoadClassWithOptions",
"DeclareOptionX",
"ProcessOptionsX",
"SetupKeyvalOptions",
"DeclareStringOption",
"DeclareBoolOption",
"DeclareComplementaryOption",
"DeclareVoidOption",
"DeclareDefaultOption",
"ProcessKeyvalOptions",
"DeclareKeys",
"DeclareUnknownKeyHandler",
"ProcessKeyOptions",
"ProcessKeysOptions",
"ProcessPgfOptions",
"ProcessPgfPackageOptions",
];
const OPTION_PROCESSOR_PACKAGES: &[&str] = &[
"xkeyval",
"kvoptions",
"kvoptions-patch",
"l3keys2e",
"pgfopts",
"options",
];
#[derive(Debug, Clone, PartialEq, Eq, salsa::Update)]
pub struct PackageOptionFacts {
pub path: PathBuf,
pub declared: Vec<SmolStr>,
pub handles_unknown: bool,
}
impl PackageOptionFacts {
pub fn declares(&self, option: &str) -> bool {
self.declared
.binary_search_by(|d| d.as_str().cmp(option))
.is_ok()
}
}
pub fn package_option_facts(
path: &Path,
root: &SyntaxNode,
model: &SemanticModel,
) -> Option<PackageOptionFacts> {
if path.extension().and_then(|e| e.to_str()) != Some("sty") {
return None;
}
let mut declared: Vec<SmolStr> = Vec::new();
let mut handles_unknown = false;
for decl in model.options() {
match &decl.name {
Some(name) => declared.push(name.clone()),
None => handles_unknown = true,
}
}
declared.sort_unstable();
declared.dedup();
handles_unknown = handles_unknown
|| root
.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
.filter_map(|node| command_name(&node))
.any(|name| DYNAMIC_OPTION_COMMANDS.contains(&name.as_str()))
|| collect_package_edges(root, None).iter().any(|edge| {
let PackageTarget::Path(target) = &edge.target else {
return false;
};
target
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| OPTION_PROCESSOR_PACKAGES.contains(&stem))
})
|| !collect_include_edge_keys(root, None).is_empty();
Some(PackageOptionFacts {
path: path.to_path_buf(),
declared,
handles_unknown,
})
}
#[derive(Debug, Default)]
pub struct ResolvedPackageOptions {
files: HashMap<PathBuf, PackageOptionFacts>,
}
impl ResolvedPackageOptions {
pub fn build(facts: impl IntoIterator<Item = PackageOptionFacts>) -> Self {
Self {
files: facts.into_iter().map(|f| (f.path.clone(), f)).collect(),
}
}
pub fn get(&self, target: &Path) -> Option<&PackageOptionFacts> {
self.files.get(target)
}
}
#[salsa::tracked(returns(ref), no_eq, unsafe(non_update_types))]
pub fn resolved_package_options<'db>(
db: &'db dyn IncrementalDb,
project: Project<'db>,
) -> ResolvedPackageOptions {
db.record_query(QueryLogEntry {
kind: QueryKind::ResolvedPackageOptions,
file: None,
});
ResolvedPackageOptions::build(
project
.members(db)
.iter()
.filter(|member| member.kind.is_latex())
.filter_map(|member| file_package_option_facts(db, member.file).clone()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_with_flavor;
use crate::syntax::SyntaxNode;
fn facts(path: &str, src: &str) -> Option<PackageOptionFacts> {
let kind = crate::file_discovery::file_kind_or_tex(Path::new(path));
let root = SyntaxNode::new_root(parse_with_flavor(src, kind.lex_config()).green);
let model = SemanticModel::build(&root);
package_option_facts(Path::new(path), &root, &model)
}
#[test]
fn declared_options_are_extracted_sorted() {
let f = facts(
"/p/mypkg.sty",
"\\ProvidesPackage{mypkg}\n\\DeclareOption{final}{}\n\\DeclareOption{draft}{}\n\\ProcessOptions\\relax\n",
)
.expect("sty facts");
assert_eq!(
f.declared,
vec![SmolStr::from("draft"), SmolStr::from("final")]
);
assert!(!f.handles_unknown);
assert!(f.declares("draft"));
assert!(!f.declares("typo"));
}
#[test]
fn star_handler_marks_handles_unknown() {
let f = facts(
"/p/mypkg.sty",
"\\DeclareOption{a}{}\n\\DeclareOption*{\\OptionNotUsed}\n",
)
.expect("sty facts");
assert!(f.handles_unknown);
}
#[test]
fn dynamic_named_declare_option_marks_handles_unknown() {
let f = facts("/p/mypkg.sty", "\\DeclareOption{\\myopt}{}\n").expect("sty facts");
assert!(f.handles_unknown);
}
#[test]
fn keyval_family_commands_mark_handles_unknown() {
for src in [
"\\SetupKeyvalOptions{family=my,prefix=my@}\n",
"\\ProcessKeyOptions\n",
"\\DeclareOptionX{width}{}\n",
] {
let f = facts("/p/mypkg.sty", src).expect("sty facts");
assert!(f.handles_unknown, "for {src:?}");
}
}
#[test]
fn option_processor_package_load_marks_handles_unknown() {
let f = facts("/p/mypkg.sty", "\\RequirePackage{kvoptions}\n").expect("sty facts");
assert!(f.handles_unknown);
}
#[test]
fn input_edge_marks_handles_unknown() {
let f = facts("/p/mypkg.sty", "\\input{mypkg-options}\n").expect("sty facts");
assert!(f.handles_unknown);
}
#[test]
fn plain_package_load_does_not_mark() {
let f = facts("/p/mypkg.sty", "\\RequirePackage{graphicx}\n").expect("sty facts");
assert!(!f.handles_unknown);
}
#[test]
fn non_sty_paths_have_no_facts() {
assert!(facts("/p/myclass.cls", "\\DeclareOption{a}{}\n").is_none());
assert!(facts("/p/mypkg.dtx", "\\DeclareOption{a}{}\n").is_none());
assert!(facts("/p/main.tex", "").is_none());
}
#[test]
fn resolved_map_is_keyed_by_path() {
let f = facts("/p/mypkg.sty", "\\DeclareOption{a}{}\n").expect("sty facts");
let resolved = ResolvedPackageOptions::build([f]);
assert!(resolved.get(Path::new("/p/mypkg.sty")).is_some());
assert!(resolved.get(Path::new("/p/other.sty")).is_none());
}
}