use std::path::{Path, PathBuf};
use rowan::{TextRange, TextSize};
use crate::ast::{command_name, nth_group_inner, nth_group_text};
use crate::completion::FileArgKind;
use crate::project::package::dtx_source_of;
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LinkTarget {
pub range: TextRange,
pub target: PathBuf,
}
pub(crate) fn document_links(root: &SyntaxNode, base_dir: Option<&Path>) -> Vec<LinkTarget> {
let mut links = Vec::new();
for command in root
.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
{
let Some(name) = command_name(&command) else {
continue;
};
let Some(class) = classify(&name) else {
continue;
};
collect_command(&command, class, base_dir, &mut links);
}
links
}
#[derive(Debug, Clone, Copy)]
enum LinkClass {
Single {
group: usize,
ext: &'static str,
dtx: bool,
},
List { ext: &'static str, dtx: bool },
ImportPair,
Graphics,
}
fn classify(name: &str) -> Option<LinkClass> {
Some(match name {
"input" | "include" | "subfile" => LinkClass::Single {
group: 0,
ext: "tex",
dtx: false,
},
"import" | "subimport" => LinkClass::ImportPair,
"usepackage" | "RequirePackage" => LinkClass::List {
ext: "sty",
dtx: true,
},
"documentclass" | "LoadClass" | "LoadClassWithOptions" => LinkClass::Single {
group: 0,
ext: "cls",
dtx: true,
},
"bibliography" => LinkClass::List {
ext: "bib",
dtx: false,
},
"addbibresource" => LinkClass::Single {
group: 0,
ext: "bib",
dtx: false,
},
"includegraphics" => LinkClass::Graphics,
_ => return None,
})
}
fn collect_command(
command: &SyntaxNode,
class: LinkClass,
base_dir: Option<&Path>,
out: &mut Vec<LinkTarget>,
) {
match class {
LinkClass::Single { group, ext, dtx } => {
let Some((range, raw)) = nth_group_inner(command, group) else {
return;
};
let name = raw.trim();
if name.is_empty() {
return;
}
if let Some(target) = resolve_existing(name, &[ext], dtx, base_dir) {
out.push(LinkTarget { range, target });
}
}
LinkClass::List { ext, dtx } => {
let Some((inner_range, inner)) = nth_group_inner(command, 0) else {
return;
};
for (name, range) in comma_spans(&inner, inner_range) {
if let Some(target) = resolve_existing(name, &[ext], dtx, base_dir) {
out.push(LinkTarget { range, target });
}
}
}
LinkClass::ImportPair => {
let (Some(dir), Some((range, file))) =
(nth_group_text(command, 0), nth_group_inner(command, 1))
else {
return;
};
let file = file.trim();
if file.is_empty() {
return;
}
let joined = PathBuf::from(dir.trim()).join(file);
let raw = joined.to_string_lossy();
if let Some(target) = resolve_existing(&raw, &["tex"], false, base_dir) {
out.push(LinkTarget { range, target });
}
}
LinkClass::Graphics => {
let Some((range, raw)) = nth_group_inner(command, 0) else {
return;
};
let name = raw.trim();
if name.is_empty() {
return;
}
let exts = FileArgKind::Graphics.extensions();
if let Some(target) = resolve_existing(name, exts, false, base_dir) {
out.push(LinkTarget { range, target });
}
}
}
}
fn resolve_existing(
raw: &str,
exts: &[&str],
dtx: bool,
base_dir: Option<&Path>,
) -> Option<PathBuf> {
let raw = PathBuf::from(raw);
let mut candidates: Vec<PathBuf> = if raw.extension().is_some() {
vec![raw.clone()]
} else {
exts.iter().map(|ext| raw.with_extension(ext)).collect()
};
if dtx {
let dtx_of: Vec<PathBuf> = candidates.iter().filter_map(|c| dtx_source_of(c)).collect();
candidates.extend(dtx_of);
}
candidates.into_iter().find_map(|candidate| {
let resolved = match base_dir {
Some(dir) if candidate.is_relative() => dir.join(candidate),
_ => candidate,
};
resolved.is_file().then_some(resolved)
})
}
fn comma_spans(inner: &str, inner_range: TextRange) -> Vec<(&str, TextRange)> {
let base = inner_range.start();
let mut out = Vec::new();
let mut seg_off = 0usize;
for segment in inner.split(',') {
let name = segment.trim();
if !name.is_empty() {
let lo = segment.len() - segment.trim_start().len();
let start = base + TextSize::from((seg_off + lo) as u32);
let end = start + TextSize::from(name.len() as u32);
out.push((name, TextRange::new(start, end)));
}
seg_off += segment.len() + 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn links(src: &str, base_dir: &Path) -> Vec<LinkTarget> {
let root = SyntaxNode::new_root(parse(src).green);
document_links(&root, Some(base_dir))
}
fn underlined<'a>(src: &'a str, link: &LinkTarget) -> &'a str {
&src[link.range]
}
#[test]
fn input_links_only_when_the_tex_file_exists() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("chap1.tex"), "").unwrap();
let src = "\\input{chap1}\n\\input{missing}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 1);
assert_eq!(underlined(src, &got[0]), "chap1");
assert_eq!(got[0].target, dir.path().join("chap1.tex"));
}
#[test]
fn explicit_extension_is_kept_verbatim() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.ltx"), "").unwrap();
let src = "\\include{notes.ltx}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, dir.path().join("notes.ltx"));
}
#[test]
fn usepackage_list_links_each_local_sty_separately() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("mypkg.sty"), "").unwrap();
let src = "\\usepackage{mypkg,amsmath}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 1);
assert_eq!(underlined(src, &got[0]), "mypkg");
assert_eq!(got[0].target, dir.path().join("mypkg.sty"));
}
#[test]
fn documentclass_falls_back_to_dtx() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("myclass.dtx"), "").unwrap();
let src = "\\documentclass{myclass}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, dir.path().join("myclass.dtx"));
}
#[test]
fn bibliography_defaults_bib_extension() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("refs.bib"), "").unwrap();
let src = "\\bibliography{refs}\n\\addbibresource{refs.bib}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 2);
assert!(got.iter().all(|l| l.target == dir.path().join("refs.bib")));
}
#[test]
fn includegraphics_guesses_the_first_existing_extension() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fig.png"), "").unwrap();
let src = "\\includegraphics{fig}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, dir.path().join("fig.png"));
}
#[test]
fn import_joins_dir_and_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/part.tex"), "").unwrap();
let src = "\\import{sub}{part}\n";
let got = links(src, dir.path());
assert_eq!(got.len(), 1);
assert_eq!(underlined(src, &got[0]), "part");
assert_eq!(got[0].target, dir.path().join("sub/part.tex"));
}
#[test]
fn nested_macro_argument_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let src = "\\input{\\foo}\n";
assert!(links(src, dir.path()).is_empty());
}
}