use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::declarations::ResolvedDeclarations;
use crate::file_discovery::file_kind_or_tex;
use crate::parser::parse_with_declarations;
use crate::project::{PackageTarget, collect_package_edge_keys, dtx_source_of};
use crate::semantic::{SignatureDb, scan_definitions};
use crate::syntax::SyntaxNode;
pub trait PackageSource {
fn load(&self, path: &Path, declared: &ResolvedDeclarations) -> Option<(SyntaxNode, PathBuf)>;
}
pub fn collect_package_signatures(
root: &SyntaxNode,
base_dir: Option<&Path>,
src: &impl PackageSource,
declared: &ResolvedDeclarations,
) -> SignatureDb {
let mut merged = SignatureDb::default();
let mut visited: HashSet<PathBuf> = HashSet::new();
collect_loaded(root, base_dir, src, declared, &mut visited, &mut merged);
merged.merge_from(&scan_definitions(root));
merged.merge_declarations(declared);
merged
}
fn collect_loaded(
root: &SyntaxNode,
base_dir: Option<&Path>,
src: &impl PackageSource,
declared: &ResolvedDeclarations,
visited: &mut HashSet<PathBuf>,
merged: &mut SignatureDb,
) {
for edge in collect_package_edge_keys(root, base_dir) {
let PackageTarget::Path(path) = edge.target else {
continue;
};
if !visited.insert(path.clone()) {
continue;
}
if let Some((pkg_root, pkg_base)) = load_pkg(&path, src, declared) {
collect_loaded(&pkg_root, Some(&pkg_base), src, declared, visited, merged);
match path.file_stem().and_then(|s| s.to_str()) {
Some(origin) => {
merged.merge_from_package(&scan_definitions(&pkg_root), origin);
}
None => merged.merge_from(&scan_definitions(&pkg_root)),
}
}
}
}
fn load_pkg(
path: &Path,
src: &impl PackageSource,
declared: &ResolvedDeclarations,
) -> Option<(SyntaxNode, PathBuf)> {
src.load(path, declared)
.or_else(|| dtx_source_of(path).and_then(|dtx| src.load(&dtx, declared)))
}
pub struct DiskPackageSource;
impl PackageSource for DiskPackageSource {
fn load(&self, path: &Path, declared: &ResolvedDeclarations) -> Option<(SyntaxNode, PathBuf)> {
let text = std::fs::read_to_string(path).ok()?;
let parsed = parse_with_declarations(&text, file_kind_or_tex(path).lex_config(), declared);
let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
Some((parsed.syntax(), base))
}
}
pub fn disk_scope_signatures(
root: &SyntaxNode,
path: &Path,
declared: &ResolvedDeclarations,
) -> SignatureDb {
collect_package_signatures(root, path.parent(), &DiskPackageSource, declared)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use std::collections::HashMap;
struct MapSource {
files: HashMap<PathBuf, String>,
}
impl MapSource {
fn new(files: &[(&str, &str)]) -> Self {
Self {
files: files
.iter()
.map(|(p, s)| (PathBuf::from(p), s.to_string()))
.collect(),
}
}
}
impl PackageSource for MapSource {
fn load(
&self,
path: &Path,
declared: &ResolvedDeclarations,
) -> Option<(SyntaxNode, PathBuf)> {
let text = self.files.get(path)?;
let parsed =
parse_with_declarations(text, file_kind_or_tex(path).lex_config(), declared);
let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
Some((parsed.syntax(), base))
}
}
fn scope(doc: &str, base: &str, files: &[(&str, &str)]) -> SignatureDb {
let root = SyntaxNode::new_root(parse(doc).green);
collect_package_signatures(
&root,
Some(Path::new(base)),
&MapSource::new(files),
&ResolvedDeclarations::default(),
)
}
#[test]
fn pulls_in_a_local_package_definition() {
let db = scope(
"\\usepackage{mypkg}\n\\myfoo{a}{b}\n",
"/proj",
&[("/proj/mypkg.sty", "\\newcommand{\\myfoo}[2]{#1#2}\n")],
);
let sig = db.command("myfoo").expect("package command in scope");
assert_eq!(sig.args.len(), 2);
assert_eq!(db.command_origin("myfoo"), Some("mypkg"));
}
#[test]
fn unresolved_package_contributes_nothing() {
let db = scope("\\usepackage{amsmath}\n", "/proj", &[]);
assert!(db.command("amsmath").is_none());
assert_eq!(db.command_names().count(), 0);
}
#[test]
fn transitive_load_is_followed() {
let db = scope(
"\\usepackage{a}\n",
"/proj",
&[
(
"/proj/a.sty",
"\\RequirePackage{b}\n\\newcommand{\\fa}{x}\n",
),
("/proj/b.sty", "\\newcommand{\\fb}[1]{#1}\n"),
],
);
assert!(db.command("fa").is_some());
assert!(db.command("fb").is_some());
}
#[test]
fn document_definition_wins_over_package() {
let db = scope(
"\\usepackage{mypkg}\n\\newcommand{\\dup}[2]{#1#2}\n",
"/proj",
&[("/proj/mypkg.sty", "\\newcommand{\\dup}[1]{#1}\n")],
);
assert_eq!(db.command("dup").unwrap().args.len(), 2);
assert_eq!(db.command_origin("dup"), None);
}
#[test]
fn falls_back_to_dtx_when_no_sty() {
let db = scope(
"\\usepackage{mypkg}\n\\myfoo{a}{b}\n",
"/proj",
&[(
"/proj/mypkg.dtx",
"% \\begin{macrocode}\n\\newcommand{\\myfoo}[2]{#1#2}\n% \\end{macrocode}\n",
)],
);
let sig = db
.command("myfoo")
.expect("package command from .dtx in scope");
assert_eq!(sig.args.len(), 2);
assert_eq!(db.command_origin("myfoo"), Some("mypkg"));
}
#[test]
fn prefers_sty_over_dtx_when_both_present() {
let db = scope(
"\\usepackage{mypkg}\n",
"/proj",
&[
("/proj/mypkg.sty", "\\newcommand{\\myfoo}[1]{#1}\n"),
(
"/proj/mypkg.dtx",
"% \\begin{macrocode}\n\\newcommand{\\myfoo}[2]{#1#2}\n% \\end{macrocode}\n",
),
],
);
assert_eq!(db.command("myfoo").unwrap().args.len(), 1);
}
#[test]
fn formatter_scope_never_reaches_the_texmf_tree() {
let proj = tempfile::tempdir().unwrap();
let main = proj.path().join("main.tex");
std::fs::write(&main, "\\usepackage{amsmath}\n").unwrap();
let tree = tempfile::tempdir().unwrap();
let installed = tree.path().join("tex/latex/amsmath/amsmath.sty");
std::fs::create_dir_all(installed.parent().unwrap()).unwrap();
std::fs::write(&installed, "\\newcommand{\\texmfonly}{x}\n").unwrap();
let index =
crate::project::texmf::TexmfIndex::build_from_roots(&[tree.path().to_path_buf()]);
assert!(index.resolve("amsmath", &["sty"]).is_some());
let root = SyntaxNode::new_root(parse("\\usepackage{amsmath}\n").green);
let db = disk_scope_signatures(&root, &main, &ResolvedDeclarations::default());
assert!(
db.command("texmfonly").is_none(),
"the formatter must not read signatures from the TEXMF tree"
);
}
#[test]
fn load_cycle_terminates() {
let db = scope(
"\\usepackage{a}\n",
"/proj",
&[
(
"/proj/a.sty",
"\\RequirePackage{b}\n\\newcommand{\\fa}{x}\n",
),
(
"/proj/b.sty",
"\\RequirePackage{a}\n\\newcommand{\\fb}{y}\n",
),
],
);
assert!(db.command("fa").is_some());
assert!(db.command("fb").is_some());
}
}