use std::path::{Path, PathBuf};
use rowan::TextRange;
use crate::ast::{command_name, nth_group_text};
use crate::project::package::{OptionArg, load_option_args};
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::SalsaValue)]
pub enum IncludeKind {
Input,
Include,
Import,
SubImport,
SubFile,
SubFileInclude,
SubFilesParent,
GlsEntries,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::SalsaValue)]
pub enum IncludeTarget {
Path(PathBuf),
Dynamic,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::SalsaValue)]
pub struct IncludeEdgeKey {
pub kind: IncludeKind,
pub target: IncludeTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludeEdge {
pub kind: IncludeKind,
pub target: IncludeTarget,
pub range: TextRange,
}
impl IncludeEdge {
pub fn key(&self) -> IncludeEdgeKey {
IncludeEdgeKey {
kind: self.kind,
target: self.target.clone(),
}
}
}
pub fn collect_include_edges(root: &SyntaxNode, base_dir: Option<&Path>) -> Vec<IncludeEdge> {
root.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
.filter_map(|node| include_edge(&node, base_dir))
.collect()
}
pub fn collect_include_edge_keys(
root: &SyntaxNode,
base_dir: Option<&Path>,
) -> Vec<IncludeEdgeKey> {
root.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
.filter_map(|node| include_edge(&node, base_dir))
.map(|edge| edge.key())
.collect()
}
fn include_edge(command: &SyntaxNode, base_dir: Option<&Path>) -> Option<IncludeEdge> {
let name = command_name(command)?;
let kind = match name.as_str() {
"documentclass" => return subfiles_parent_edge(command, base_dir),
_ => include_kind(&name)?,
};
let target = include_target(command, kind, base_dir);
Some(IncludeEdge {
kind,
target,
range: command.text_range(),
})
}
fn include_kind(name: &str) -> Option<IncludeKind> {
Some(match name {
"input" => IncludeKind::Input,
"include" => IncludeKind::Include,
"import" => IncludeKind::Import,
"subimport" => IncludeKind::SubImport,
"subfile" => IncludeKind::SubFile,
"subfileinclude" => IncludeKind::SubFileInclude,
"loadglsentries" => IncludeKind::GlsEntries,
_ => return None,
})
}
pub fn subfiles_parent_arg(command: &SyntaxNode) -> Option<OptionArg> {
if !is_subfiles_class(command) {
return None;
}
match load_option_args(command)?.as_slice() {
[arg] => Some(arg.clone()),
_ => None,
}
}
fn is_subfiles_class(command: &SyntaxNode) -> bool {
nth_group_text(command, 0).is_some_and(|name| name.trim() == "subfiles")
}
fn subfiles_parent_edge(command: &SyntaxNode, base_dir: Option<&Path>) -> Option<IncludeEdge> {
if !is_subfiles_class(command) {
return None;
}
let target = match subfiles_parent_arg(command) {
Some(arg) => IncludeTarget::Path(resolve_tex(PathBuf::from(arg.text.as_str()), base_dir)),
None => IncludeTarget::Dynamic,
};
Some(IncludeEdge {
kind: IncludeKind::SubFilesParent,
target,
range: command.text_range(),
})
}
fn include_target(
command: &SyntaxNode,
kind: IncludeKind,
base_dir: Option<&Path>,
) -> IncludeTarget {
let raw = match kind {
IncludeKind::Import | IncludeKind::SubImport => {
match (nth_group_text(command, 0), nth_group_text(command, 1)) {
(Some(dir), Some(file)) => PathBuf::from(dir).join(file),
_ => return IncludeTarget::Dynamic,
}
}
_ => match nth_group_text(command, 0) {
Some(file) => PathBuf::from(file),
None => return IncludeTarget::Dynamic,
},
};
IncludeTarget::Path(resolve_tex(raw, base_dir))
}
fn resolve_tex(raw: PathBuf, base_dir: Option<&Path>) -> PathBuf {
let with_ext = if raw.extension().is_none() {
raw.with_extension("tex")
} else {
raw
};
resolve_against(with_ext, base_dir)
}
fn resolve_against(path: PathBuf, base_dir: Option<&Path>) -> PathBuf {
let joined = match base_dir {
Some(dir) if path.is_relative() => dir.join(path),
_ => path,
};
lexically_normalize(joined)
}
fn lexically_normalize(path: PathBuf) -> PathBuf {
use std::path::Component;
if !path
.components()
.any(|c| matches!(c, Component::CurDir | Component::ParentDir))
{
return path;
}
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir
if matches!(out.components().next_back(), Some(Component::Normal(_))) =>
{
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::SalsaValue)]
pub enum BibTarget {
Path(PathBuf),
Dynamic,
}
pub fn collect_bib_resource_targets(root: &SyntaxNode, base_dir: Option<&Path>) -> Vec<BibTarget> {
let mut targets = Vec::new();
for command in root
.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
{
let Some(name) = command_name(&command) else {
continue;
};
match name.as_str() {
"bibliography" => match nth_group_text(&command, 0) {
Some(list) => {
for entry in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
targets.push(resolve_bib(PathBuf::from(entry), base_dir));
}
if list.split(',').all(|e| e.trim().is_empty()) {
targets.push(BibTarget::Dynamic);
}
}
None => targets.push(BibTarget::Dynamic),
},
"addbibresource" => match nth_group_text(&command, 0) {
Some(file) => targets.push(resolve_bib(PathBuf::from(file), base_dir)),
None => targets.push(BibTarget::Dynamic),
},
_ => {}
}
}
targets
}
fn resolve_bib(raw: PathBuf, base_dir: Option<&Path>) -> BibTarget {
let with_ext = if raw.extension().is_none() {
raw.with_extension("bib")
} else {
raw
};
BibTarget::Path(resolve_against(with_ext, base_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn edges(src: &str, base_dir: Option<&Path>) -> Vec<IncludeEdge> {
let root = SyntaxNode::new_root(parse(src).green);
collect_include_edges(&root, base_dir)
}
#[test]
fn input_appends_tex_and_resolves_against_base_dir() {
let base = PathBuf::from("/proj");
let e = edges("\\input{chapters/intro}\n", Some(&base));
assert_eq!(e.len(), 1);
assert_eq!(e[0].kind, IncludeKind::Input);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("/proj/chapters/intro.tex"))
);
}
#[test]
fn explicit_extension_is_kept() {
let e = edges("\\input{logo.pdf_tex}\n", None);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("logo.pdf_tex"))
);
}
#[test]
fn include_is_recognized_as_its_own_kind() {
let e = edges("\\include{body}\n", None);
assert_eq!(e[0].kind, IncludeKind::Include);
assert_eq!(e[0].target, IncludeTarget::Path(PathBuf::from("body.tex")));
}
#[test]
fn underscores_and_slashes_in_path_reassemble() {
let e = edges("\\input{parts/my_section}\n", None);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("parts/my_section.tex"))
);
}
#[test]
fn import_joins_directory_and_file() {
let base = PathBuf::from("/proj");
let e = edges("\\import{sub/dir/}{chapter}\n", Some(&base));
assert_eq!(e[0].kind, IncludeKind::Import);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("/proj/sub/dir/chapter.tex"))
);
}
#[test]
fn subimport_and_subfile_are_recognized() {
let si = edges("\\subimport{d}{f}\n", None);
assert_eq!(si[0].kind, IncludeKind::SubImport);
assert_eq!(si[0].target, IncludeTarget::Path(PathBuf::from("d/f.tex")));
let sf = edges("\\subfile{sections/one}\n", None);
assert_eq!(sf[0].kind, IncludeKind::SubFile);
assert_eq!(
sf[0].target,
IncludeTarget::Path(PathBuf::from("sections/one.tex"))
);
}
#[test]
fn subfileinclude_is_recognized() {
let e = edges("\\subfileinclude{sections/one}\n", None);
assert_eq!(e[0].kind, IncludeKind::SubFileInclude);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("sections/one.tex"))
);
}
#[test]
fn subfiles_class_option_is_a_parent_edge() {
let base = PathBuf::from("/proj/chapters");
let e = edges("\\documentclass[../main.tex]{subfiles}\n", Some(&base));
assert_eq!(e.len(), 1);
assert_eq!(e[0].kind, IncludeKind::SubFilesParent);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("/proj/main.tex"))
);
}
#[test]
fn subfiles_parent_defaults_the_tex_extension() {
let e = edges("\\documentclass[main]{subfiles}\n", None);
assert_eq!(e[0].target, IncludeTarget::Path(PathBuf::from("main.tex")));
}
#[test]
fn ordinary_documentclass_is_not_an_include() {
assert!(edges("\\documentclass[a4paper,12pt]{article}\n", None).is_empty());
assert!(edges("\\documentclass{article}\n", None).is_empty());
}
#[test]
fn subfiles_without_a_readable_parent_is_dynamic() {
for src in [
"\\documentclass{subfiles}\n",
"\\documentclass[\\parentfile]{subfiles}\n",
"\\documentclass[a,b]{subfiles}\n",
] {
let e = edges(src, None);
assert_eq!(e.len(), 1, "expected one edge for {src:?}");
assert_eq!(e[0].kind, IncludeKind::SubFilesParent);
assert_eq!(e[0].target, IncludeTarget::Dynamic, "for {src:?}");
}
}
#[test]
fn loadglsentries_is_recognized_with_optional_arg() {
let e = edges("\\loadglsentries[main]{glossary/entries}\n", None);
assert_eq!(e[0].kind, IncludeKind::GlsEntries);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("glossary/entries.tex"))
);
}
#[test]
fn absolute_target_ignores_base_dir() {
let base = PathBuf::from("/proj");
let e = edges("\\input{/abs/preamble}\n", Some(&base));
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("/abs/preamble.tex"))
);
}
#[test]
fn parent_segments_collapse_against_the_base_dir() {
let base = PathBuf::from("/proj/chapters");
let e = edges("\\input{../shared/preamble}\n", Some(&base));
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("/proj/shared/preamble.tex"))
);
}
#[test]
fn a_leading_parent_segment_is_preserved() {
let e = edges("\\input{../main}\n", None);
assert_eq!(
e[0].target,
IncludeTarget::Path(PathBuf::from("../main.tex"))
);
}
#[test]
fn missing_argument_is_dynamic() {
let e = edges("\\input\n", None);
assert_eq!(e[0].target, IncludeTarget::Dynamic);
}
#[test]
fn import_with_one_group_is_dynamic() {
let e = edges("\\import{onlydir}\n", None);
assert_eq!(e[0].target, IncludeTarget::Dynamic);
}
#[test]
fn nested_macro_argument_is_dynamic() {
let e = edges("\\input{\\jobname}\n", None);
assert_eq!(e[0].target, IncludeTarget::Dynamic);
}
#[test]
fn parameter_argument_is_dynamic() {
let e = edges("\\input{#1}\n", None);
assert_eq!(e[0].target, IncludeTarget::Dynamic);
}
#[test]
fn bare_input_without_braces_is_not_an_edge() {
let e = edges("\\input foo.tex\n", None);
assert_eq!(e.len(), 1);
assert_eq!(e[0].target, IncludeTarget::Dynamic);
}
#[test]
fn non_inclusion_commands_are_ignored() {
let e = edges(
"\\includegraphics{logo}\n\\usepackage{amsmath}\n\\section{Hi}\n",
None,
);
assert!(e.is_empty());
}
#[test]
fn multiple_edges_are_collected_in_source_order() {
let e = edges("\\input{a}\n\\include{b}\n", None);
let names: Vec<_> = e
.iter()
.map(|edge| match &edge.target {
IncludeTarget::Path(p) => p.clone(),
IncludeTarget::Dynamic => PathBuf::from("<dyn>"),
})
.collect();
assert_eq!(names, vec![PathBuf::from("a.tex"), PathBuf::from("b.tex")]);
}
fn bib_targets(src: &str, base_dir: Option<&Path>) -> Vec<BibTarget> {
let root = SyntaxNode::new_root(parse(src).green);
collect_bib_resource_targets(&root, base_dir)
}
#[test]
fn bibliography_splits_comma_list_and_defaults_bib() {
let base = PathBuf::from("/proj");
let t = bib_targets("\\bibliography{refs,extra}\n", Some(&base));
assert_eq!(
t,
vec![
BibTarget::Path(PathBuf::from("/proj/refs.bib")),
BibTarget::Path(PathBuf::from("/proj/extra.bib")),
]
);
}
#[test]
fn addbibresource_keeps_explicit_extension() {
let t = bib_targets("\\addbibresource{refs.bib}\n", None);
assert_eq!(t, vec![BibTarget::Path(PathBuf::from("refs.bib"))]);
}
#[test]
fn addbibresource_without_extension_defaults_bib() {
let t = bib_targets("\\addbibresource{refs}\n", None);
assert_eq!(t, vec![BibTarget::Path(PathBuf::from("refs.bib"))]);
}
#[test]
fn bibliography_missing_argument_is_dynamic() {
let t = bib_targets("\\bibliography\n", None);
assert_eq!(t, vec![BibTarget::Dynamic]);
}
#[test]
fn non_bibliography_commands_are_ignored() {
assert!(bib_targets("\\input{a}\n\\cite{k}\n", None).is_empty());
}
}