use std::fmt::Write as _;
use super::*;
use crate::ast::{command_name, nth_group, nth_group_inner};
use crate::bib::ast as bib_ast;
use crate::bib::syntax::{SyntaxKind as BibSyntaxKind, SyntaxNode as BibSyntaxNode};
use crate::lsp::document_link::comma_spans;
use crate::semantic::pkgmeta::{NeedsFormatDecl, OptionDecl, ProvidesDecl, provides_kind};
use crate::semantic::signature::{
ArgKind, CommandSig, EnvironmentSig, OutlineKind, PackageMeta, builtin, cwl, package_metadata,
};
use crate::semantic::{LabelContext, label_context};
use crate::syntax::{SyntaxKind, SyntaxToken};
use lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind};
pub(crate) fn compute_hover(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
build: &BuildConfig,
enc: PositionEncoding,
) -> Option<Hover> {
let idx = LineIndex::with_encoding(text, enc);
let offset = idx.offset_at(text, position.line, position.character);
let result = salsa::Cancelled::catch(AssertUnwindSafe(|| {
match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => {
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let scope = snapshot.scope_signatures(members.clone(), file);
let lint_path = snapshot.file_path(file).to_path_buf();
build_hover(
snapshot, &root, model, scope, &lint_path, members, offset, &idx, text, build,
)
}
_ => {
let root = SyntaxNode::new_root(parse(text).green);
let model = SemanticModel::build(&root);
let scanned = crate::semantic::scan_definitions(&root);
build_hover(
snapshot, &root, &model, &scanned, path, members, offset, &idx, text, build,
)
}
}
}));
result.ok().flatten()
}
#[allow(clippy::too_many_arguments)]
fn build_hover(
snapshot: &Analysis,
root: &SyntaxNode,
model: &SemanticModel,
scope: &SignatureDb,
lint_path: &Path,
members: Vec<ProjectMember>,
offset: usize,
idx: &LineIndex,
text: &str,
build: &BuildConfig,
) -> Option<Hover> {
if let Some((value, range)) = declaration_hover(model, root, offset) {
return Some(markup_hover(value, range, idx, text));
}
if let Some(target) = signature_target_at(root, offset) {
let value = match target.kind {
TargetKind::Command => {
let (sig, user) = lookup_command(scope, &target.name)?;
render_command(&target.name, sig, user)
}
TargetKind::Environment => {
let (sig, user) = lookup_environment(scope, &target.name)?;
render_environment(&target.name, sig, user)
}
};
return Some(markup_hover(value, target.range, idx, text));
}
if let Some(target) = package_target_at(root, offset) {
let meta = package_metadata(&target.name)?;
let value = render_package(&target.name, target.is_class, meta);
return Some(markup_hover(value, target.range, idx, text));
}
if let Some((name, key_range)) = citation_at(model, offset) {
let (_, citations) = snapshot.resolve_project(members);
let value = render_citation(snapshot, citations, lint_path, &name)?;
return Some(markup_hover(value, key_range, idx, text));
}
if let Some((name, key_range)) = label_target_at(model, offset) {
let (resolution, _) = snapshot.resolve_project(members);
let value = render_label(snapshot, resolution, lint_path, root, model, &name, build)?;
return Some(markup_hover(value, key_range, idx, text));
}
None
}
fn markup_hover(value: String, range: TextRange, idx: &LineIndex, text: &str) -> Hover {
Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: Some(byte_range_to_lsp(
idx,
text,
usize::from(range.start()),
usize::from(range.end()),
)),
}
}
enum TargetKind {
Command,
Environment,
}
struct SigTarget {
kind: TargetKind,
name: String,
range: TextRange,
}
fn signature_target_at(root: &SyntaxNode, offset: usize) -> Option<SigTarget> {
let at = TextSize::new(offset.min(u32::MAX as usize) as u32);
let (left, right) = match root.token_at_offset(at) {
rowan::TokenAtOffset::None => return None,
rowan::TokenAtOffset::Single(t) => (Some(t.clone()), Some(t)),
rowan::TokenAtOffset::Between(l, r) => (Some(l), Some(r)),
};
for token in [left, right].into_iter().flatten() {
if token.kind() == SyntaxKind::CONTROL_WORD
&& let Some(parent) = token.parent()
&& parent.kind() == SyntaxKind::COMMAND
{
return Some(SigTarget {
kind: TargetKind::Command,
name: token.text().trim_start_matches('\\').to_string(),
range: token.text_range(),
});
}
if let Some(target) = environment_target(&token) {
return Some(target);
}
}
None
}
fn environment_target(token: &SyntaxToken) -> Option<SigTarget> {
let group = token
.parent_ancestors()
.find(|n| n.kind() == SyntaxKind::NAME_GROUP)?;
let parent = group.parent()?;
if !matches!(parent.kind(), SyntaxKind::BEGIN | SyntaxKind::END) {
return None;
}
let (name, range) = name_group_inner(&group)?;
Some(SigTarget {
kind: TargetKind::Environment,
name,
range,
})
}
fn name_group_inner(group: &SyntaxNode) -> Option<(String, TextRange)> {
let mut text = String::new();
let mut start = None;
let mut end = None;
for token in group.children_with_tokens().filter_map(|e| e.into_token()) {
match token.kind() {
SyntaxKind::L_BRACE | SyntaxKind::R_BRACE => {}
_ => {
let r = token.text_range();
start.get_or_insert(r.start());
end = Some(r.end());
text.push_str(token.text());
}
}
}
Some((text, TextRange::new(start?, end?)))
}
pub(super) fn lookup_command<'a>(
scope: &'a SignatureDb,
name: &str,
) -> Option<(&'a CommandSig, bool)> {
if let Some(sig) = scope.command(name) {
return Some((sig, true));
}
builtin()
.command(name)
.or_else(|| cwl().command(name))
.map(|sig| (sig, false))
}
pub(super) fn lookup_environment<'a>(
scope: &'a SignatureDb,
name: &str,
) -> Option<(&'a EnvironmentSig, bool)> {
if let Some(sig) = scope.environment(name) {
return Some((sig, true));
}
builtin()
.environment(name)
.or_else(|| cwl().environment(name))
.map(|sig| (sig, false))
}
pub(super) fn arg_slot(kind: ArgKind) -> &'static str {
match kind {
ArgKind::Brace => "{}",
ArgKind::Bracket => "[]",
}
}
struct PackageTarget {
name: String,
range: TextRange,
is_class: bool,
}
fn package_loader_is_class(name: &str) -> Option<bool> {
match name {
"usepackage" | "RequirePackage" => Some(false),
"documentclass" | "LoadClass" | "LoadClassWithOptions" => Some(true),
_ => None,
}
}
fn package_target_at(root: &SyntaxNode, offset: usize) -> Option<PackageTarget> {
let at = TextSize::new(offset.min(u32::MAX as usize) as u32);
let (left, right) = match root.token_at_offset(at) {
rowan::TokenAtOffset::None => return None,
rowan::TokenAtOffset::Single(t) => (Some(t.clone()), Some(t)),
rowan::TokenAtOffset::Between(l, r) => (Some(l), Some(r)),
};
for token in [left, right].into_iter().flatten() {
let Some(group) = token
.parent_ancestors()
.find(|n| n.kind() == SyntaxKind::GROUP)
else {
continue;
};
let Some(command) = group.parent() else {
continue;
};
if command.kind() != SyntaxKind::COMMAND {
continue;
}
let Some(name) = command_name(&command) else {
continue;
};
let Some(is_class) = package_loader_is_class(&name) else {
continue;
};
if nth_group(&command, 0).as_ref() != Some(&group) {
continue;
}
let Some((inner_range, inner)) = nth_group_inner(&command, 0) else {
continue;
};
if let Some((seg, range)) = comma_spans(&inner, inner_range)
.into_iter()
.find(|(_, r)| r.contains_inclusive(at))
{
return Some(PackageTarget {
name: seg.to_string(),
range,
is_class,
});
}
}
None
}
fn render_package(name: &str, is_class: bool, meta: &PackageMeta) -> String {
let tag = if is_class { "class" } else { "package" };
let mut out = format!("**`{name}`** — {tag}");
if let Some(desc) = meta.desc {
let _ = write!(out, "\n\n{desc}");
}
if let Some(url) = meta.ctan_url() {
let _ = write!(out, "\n\n[CTAN]({url})");
}
out
}
fn declaration_hover(
model: &SemanticModel,
root: &SyntaxNode,
offset: usize,
) -> Option<(String, TextRange)> {
let (name, range) = declaration_command_at(root, offset)?;
if provides_kind(&name).is_some() {
let decl = model.provides().filter(|d| d.range == range)?;
return Some((render_provides(decl), range));
}
if name == "NeedsTeXFormat" {
let decl = model.needs_format().filter(|d| d.range == range)?;
return Some((render_needs_format(decl), range));
}
if name == "DeclareOption" {
let decl = model.options().iter().find(|d| d.range == range)?;
return Some((render_option(decl), range));
}
let note = match name.as_str() {
"ProcessOptions" => "**Processes package options** — recognized, never executed by badness",
"ExecuteOptions" => "**Executes default options** — recognized, never executed by badness",
_ => return None,
};
Some((note.to_string(), range))
}
fn declaration_command_at(root: &SyntaxNode, offset: usize) -> Option<(String, TextRange)> {
let at = TextSize::new(offset.min(u32::MAX as usize) as u32);
let (left, right) = match root.token_at_offset(at) {
rowan::TokenAtOffset::None => return None,
rowan::TokenAtOffset::Single(t) => (Some(t.clone()), Some(t)),
rowan::TokenAtOffset::Between(l, r) => (Some(l), Some(r)),
};
for token in [left, right].into_iter().flatten() {
if token.kind() == SyntaxKind::CONTROL_WORD
&& let Some(parent) = token.parent()
&& parent.kind() == SyntaxKind::COMMAND
{
return Some((
token.text().trim_start_matches('\\').to_string(),
token.text_range(),
));
}
}
None
}
fn render_provides(decl: &ProvidesDecl) -> String {
let mut out = format!("**Provides {}** `{}`", decl.kind.noun(), decl.name);
let version = decl
.version
.as_deref()
.map(|v| v.trim_start_matches(['v', 'V']));
match (version, decl.date.as_deref()) {
(Some(v), Some(d)) => {
let _ = write!(out, " — version {v} ({d})");
}
(Some(v), None) => {
let _ = write!(out, " — version {v}");
}
(None, Some(d)) => {
let _ = write!(out, " — {d}");
}
(None, None) => {}
}
if let Some(desc) = provides_description(decl) {
let _ = write!(out, "\n\n{desc}");
}
out
}
fn provides_description(decl: &ProvidesDecl) -> Option<String> {
let info = decl.info.as_deref()?;
let rest: Vec<&str> = info
.split_whitespace()
.filter(|t| Some(*t) != decl.date.as_deref() && Some(*t) != decl.version.as_deref())
.collect();
(!rest.is_empty()).then(|| rest.join(" "))
}
fn render_needs_format(decl: &NeedsFormatDecl) -> String {
let mut out = format!("**Requires format** `{}`", decl.format);
if let Some(date) = decl.date.as_deref() {
let _ = write!(out, " ({date})");
}
out
}
fn render_option(decl: &OptionDecl) -> String {
match decl.name.as_deref() {
Some(name) => format!("**Declares option** `{name}`"),
None => "**Default option handler** (`\\DeclareOption*`)".to_string(),
}
}
pub(super) fn arg_summary(args: &[crate::semantic::signature::ArgSpec]) -> Option<String> {
let req = args.iter().filter(|a| a.required).count();
let opt = args.len() - req;
let mut parts = Vec::new();
if req > 0 {
parts.push(format!("{req} required"));
}
if opt > 0 {
parts.push(format!("{opt} optional"));
}
(!parts.is_empty()).then(|| format!("{} argument{}", parts.join(", "), plural(args.len())))
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
pub(super) fn render_command(name: &str, sig: &CommandSig, user_defined: bool) -> String {
let mut out = String::new();
let _ = write!(out, "```latex\n\\{name}");
for arg in sig.args.iter() {
out.push_str(arg_slot(arg.kind));
}
out.push_str("\n```\n");
let mut facts = vec![if user_defined {
"user-defined command".to_string()
} else {
"command".to_string()
}];
if let Some(level) = sig.sectioning {
facts.push(format!("sectioning level {level}"));
}
if sig.verbatim {
facts.push("verbatim argument".to_string());
}
if let Some(summary) = arg_summary(&sig.args) {
facts.push(summary);
}
out.push_str(&facts.join(" · "));
out
}
pub(super) fn render_environment(name: &str, sig: &EnvironmentSig, user_defined: bool) -> String {
let mut out = String::new();
let _ = write!(out, "```latex\n\\begin{{{name}}}");
for arg in sig.args.iter() {
out.push_str(arg_slot(arg.kind));
}
let _ = write!(out, " … \\end{{{name}}}\n```\n");
let mut facts = vec![if user_defined {
"user-defined environment".to_string()
} else {
"environment".to_string()
}];
match sig.outline {
Some(OutlineKind::Float) => facts.push("float".to_string()),
Some(OutlineKind::Theorem) => facts.push("theorem-like".to_string()),
None => {}
}
if sig.math {
facts.push("math".to_string());
}
if sig.align {
facts.push("alignment".to_string());
}
if sig.list {
facts.push("list".to_string());
}
if sig.verbatim_body {
facts.push("verbatim body".to_string());
} else if sig.code {
facts.push("code body".to_string());
}
if let Some(summary) = arg_summary(&sig.args) {
facts.push(summary);
}
out.push_str(&facts.join(" · "));
out
}
fn citation_at(model: &SemanticModel, offset: usize) -> Option<(SmolStr, TextRange)> {
let at = TextSize::new(offset.min(u32::MAX as usize) as u32);
model
.citations()
.iter()
.find(|c| c.key_range.contains_inclusive(at))
.map(|c| (c.name.clone(), c.key_range))
}
fn render_citation(
snapshot: &Analysis,
citations: &ResolvedCitations,
lint_path: &Path,
name: &SmolStr,
) -> Option<String> {
for bib_path in citations.bib_definers(lint_path) {
let Some(file) = snapshot.lookup_file(bib_path) else {
continue;
};
let Some(entry) = snapshot
.bib_semantic_model(file)
.entries()
.iter()
.find(|e| e.key.eq_ignore_ascii_case(name))
else {
continue;
};
let root = snapshot.parsed_bib_tree(file);
let Some(node) = root
.descendants()
.find(|n| n.kind() == BibSyntaxKind::ENTRY && n.text_range() == entry.range)
else {
continue;
};
return Some(render_entry(&entry.entry_type, &entry.key, &node));
}
None
}
const HOVER_FIELDS: &[&str] = &["author", "editor", "title", "year", "journal", "booktitle"];
pub(super) fn render_entry(entry_type: &str, key: &str, node: &BibSyntaxNode) -> String {
let mut out = format!("@{entry_type} · `{key}`");
for &want in HOVER_FIELDS {
for field in bib_ast::fields(node) {
let Some(fname) = bib_ast::field_name(&field) else {
continue;
};
if !fname.eq_ignore_ascii_case(want) {
continue;
}
if let Some(value) = bib_ast::field_value(&field).map(|v| clean_value(&v))
&& !value.is_empty()
{
let _ = write!(out, "\n\n**{fname}:** {value}");
}
break;
}
}
out
}
pub(super) fn clean_value(value: &BibSyntaxNode) -> String {
let raw = value.text().to_string();
let trimmed = raw.trim();
let inner = trimmed
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.or_else(|| trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.unwrap_or(trimmed);
inner.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn label_target_at(model: &SemanticModel, offset: usize) -> Option<(SmolStr, TextRange)> {
let at = TextSize::new(offset.min(u32::MAX as usize) as u32);
model
.labels()
.iter()
.find(|l| l.key_range.contains_inclusive(at))
.map(|l| (l.name.clone(), l.key_range))
.or_else(|| {
model
.refs()
.iter()
.find(|r| r.key_range.contains_inclusive(at))
.map(|r| (r.name.clone(), r.key_range))
})
}
fn render_label(
snapshot: &Analysis,
resolution: &ResolvedLabels,
lint_path: &Path,
root: &SyntaxNode,
model: &SemanticModel,
name: &SmolStr,
build: &BuildConfig,
) -> Option<String> {
let context = model
.labels()
.iter()
.find(|l| l.name == *name)
.and_then(|def| label_context(root, def.key_range.start()))
.or_else(|| {
resolution
.definers(lint_path, name)
.iter()
.find_map(|def_path| {
let file = snapshot.lookup_file(def_path)?;
let def = snapshot
.semantic_model(file)
.labels()
.iter()
.find(|l| l.name == *name)?;
label_context(&snapshot.parsed_tree(file), def.key_range.start())
})
});
let number = label_number(snapshot, resolution, lint_path, name, build);
render_label_markdown(context.as_ref(), number.as_deref())
}
fn label_number(
snapshot: &Analysis,
resolution: &ResolvedLabels,
lint_path: &Path,
name: &SmolStr,
build: &BuildConfig,
) -> Option<String> {
super::document_aux(snapshot, resolution, lint_path, build)?
.labels
.get(name.as_str())
.cloned()
}
fn render_label_markdown(context: Option<&LabelContext>, number: Option<&str>) -> Option<String> {
let mut out = String::new();
match context {
Some(LabelContext::Section { title }) => {
out.push_str("Section");
if let Some(n) = number {
let _ = write!(out, " {n}");
}
if !title.is_empty() {
let _ = write!(out, " ({title})");
}
}
Some(LabelContext::Float { env, caption }) => {
out.push_str(&capitalize(env));
if let Some(n) = number {
let _ = write!(out, " {n}");
}
if let Some(c) = caption {
let _ = write!(out, ": {c}");
}
}
Some(LabelContext::Theorem { env, description }) => {
out.push_str(&capitalize(env));
if let Some(n) = number {
let _ = write!(out, " {n}");
}
if let Some(d) = description {
let _ = write!(out, " ({d})");
}
}
Some(LabelContext::Equation) => {
out.push_str("Equation");
if let Some(n) = number {
let _ = write!(out, " ({n})");
}
}
Some(LabelContext::Item) => {
out.push_str("Item");
if let Some(n) = number {
let _ = write!(out, " {n}");
}
}
None => {
let n = number?;
let _ = write!(out, "Label {n}");
}
}
Some(out)
}
fn capitalize(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
fn hover_md(src: &str, needle: &str) -> Option<String> {
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
let offset = src.find(needle).expect("needle present");
markdown_at(&db, path, src, offset)
}
fn markdown_at(
db: &IncrementalDatabase,
path: &Path,
src: &str,
offset: usize,
) -> Option<String> {
let snapshot = db.snapshot();
let members = super::members_of(&snapshot);
let position = byte_to_position(src, offset);
let hover = compute_hover(
&snapshot,
path,
src,
position,
members,
&BuildConfig::default(),
PositionEncoding::Utf16,
)?;
match hover.contents {
HoverContents::Markup(m) => Some(m.value),
other => panic!("expected markup, got {other:?}"),
}
}
fn byte_to_position(src: &str, offset: usize) -> Position {
let idx = LineIndex::new(src);
let (line, character) = idx.position(src, offset);
Position { line, character }
}
#[test]
fn command_signature_shows_sectioning_level() {
let md = hover_md("\\section{Intro}\n", "section").expect("hover for \\section");
assert!(md.contains("\\section"), "prototype: {md}");
assert!(md.contains("sectioning level"), "facts: {md}");
assert!(md.contains("command"), "kind: {md}");
}
#[test]
fn environment_signature_shows_math_flag() {
let src = "\\begin{align}\nx &= y\n\\end{align}\n";
let md = hover_md(src, "align").expect("hover for align");
assert!(md.contains("\\begin{align}"), "prototype: {md}");
assert!(md.contains("math"), "facts: {md}");
}
#[test]
fn user_defined_command_is_marked() {
let src = "\\newcommand{\\foo}[1]{#1}\n\\foo{bar}\n";
let offset = src.rfind("foo").expect("use site");
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
let md = markdown_at(&db, path, src, offset).expect("hover for \\foo");
assert!(md.contains("user-defined command"), "provenance: {md}");
assert!(md.contains("1 required argument"), "arity: {md}");
}
#[test]
fn provides_package_hover_shows_identity() {
let src = "\\ProvidesPackage{mypkg}[2024/01/01 v1.2 My package]\n";
let md = hover_md(src, "ProvidesPackage").expect("hover for \\ProvidesPackage");
assert!(md.contains("Provides package"), "kind: {md}");
assert!(md.contains("`mypkg`"), "name: {md}");
assert!(md.contains("version 1.2"), "version (v stripped): {md}");
assert!(md.contains("2024/01/01"), "date: {md}");
assert!(md.contains("My package"), "description: {md}");
}
#[test]
fn provides_expl_package_hover() {
let src = "\\ProvidesExplPackage{mypkg}{2024/01/01}{1.2}{My package}\n";
let md = hover_md(src, "ProvidesExplPackage").expect("hover for expl3 provides");
assert!(md.contains("Provides package"), "kind: {md}");
assert!(md.contains("version 1.2"), "version: {md}");
assert!(md.contains("My package"), "description: {md}");
}
#[test]
fn needs_tex_format_hover() {
let src = "\\NeedsTeXFormat{LaTeX2e}[2020/10/01]\n";
let md = hover_md(src, "NeedsTeXFormat").expect("hover for \\NeedsTeXFormat");
assert!(md.contains("Requires format"), "label: {md}");
assert!(md.contains("`LaTeX2e`"), "format: {md}");
assert!(md.contains("2020/10/01"), "date: {md}");
}
#[test]
fn declare_option_hover_named_and_star() {
let named = "\\DeclareOption{draft}{\\@drafttrue}\n";
let md = hover_md(named, "DeclareOption").expect("hover for \\DeclareOption");
assert!(md.contains("Declares option"), "label: {md}");
assert!(md.contains("`draft`"), "option name: {md}");
let star = "\\DeclareOption*{\\PackageWarning{p}{unknown}}\n";
let md = hover_md(star, "DeclareOption").expect("hover for \\DeclareOption*");
assert!(md.contains("Default option handler"), "star form: {md}");
}
#[test]
fn process_options_hover_is_static_note() {
let src = "\\ProcessOptions\\relax\n";
let md = hover_md(src, "ProcessOptions").expect("hover for \\ProcessOptions");
assert!(md.contains("Processes package options"), "note: {md}");
assert!(md.contains("never executed"), "hermetic note: {md}");
}
#[test]
fn citation_resolves_to_bib_entry() {
let tex = "\\addbibresource{refs.bib}\n\\cite{knuth1984}\n";
let bib = "@book{knuth1984,\n author = {Knuth, Donald E.},\n title = {The TeXbook},\n year = {1984},\n}\n";
let tex_path = Path::new("/p/main.tex");
let bib_path = Path::new("/p/refs.bib");
let mut db = IncrementalDatabase::default();
db.upsert_file(tex_path, tex.to_string());
db.upsert_file(bib_path, bib.to_string());
let offset = tex.find("knuth1984").expect("cite key");
let md = markdown_at(&db, tex_path, tex, offset).expect("hover for \\cite key");
assert!(md.contains("@book"), "type: {md}");
assert!(md.contains("knuth1984"), "key: {md}");
assert!(md.contains("The TeXbook"), "title: {md}");
assert!(md.contains("Knuth"), "author: {md}");
}
#[test]
fn no_hover_on_plain_prose() {
assert!(hover_md("Just some words here.\n", "words").is_none());
}
#[test]
fn label_ref_without_aux_shows_kind_and_context() {
let src = "\\section{Intro}\n\\label{sec:a}\nSee \\ref{sec:a}.\n";
let offset = src.rfind("sec:a").expect("ref key");
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
let md = markdown_at(&db, path, src, offset).expect("hover for \\ref key");
assert_eq!(md, "Section (Intro)");
}
#[test]
fn label_definition_site_hovers_too() {
let src = "\\begin{figure}\n\\caption{A chart}\n\\label{fig:x}\n\\end{figure}\n";
let offset = src.find("fig:x").expect("label key");
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
let md = markdown_at(&db, path, src, offset).expect("hover for \\label key");
assert_eq!(md, "Figure: A chart");
}
#[test]
fn undefined_ref_without_aux_has_no_hover() {
let src = "See \\ref{nowhere}.\n";
let offset = src.find("nowhere").expect("ref key");
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
assert!(markdown_at(&db, path, src, offset).is_none());
}
#[test]
fn label_ref_with_aux_shows_number() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("main.tex");
let src = "\\documentclass{article}\n\\begin{document}\n\\section{Intro}\n\\label{sec:a}\nSee \\ref{sec:a}.\n\\begin{equation}\nx\\label{eq:x}\n\\end{equation}\n\\eqref{eq:x}\n\\end{document}\n";
std::fs::write(&path, src).unwrap();
std::fs::write(
dir.path().join("main.aux"),
"\\newlabel{sec:a}{{1.2}{1}{Intro}{section.1.2}{}}\n\\newlabel{eq:x}{{3}{1}}\n",
)
.unwrap();
let mut db = IncrementalDatabase::default();
db.upsert_file(&path, src.to_string());
let offset = src.rfind("sec:a").expect("ref key");
let md = markdown_at(&db, &path, src, offset).expect("hover for \\ref key");
assert_eq!(md, "Section 1.2 (Intro)");
let offset = src.rfind("eq:x").expect("eqref key");
let md = markdown_at(&db, &path, src, offset).expect("hover for \\eqref key");
assert_eq!(md, "Equation (3)");
}
#[test]
fn cross_file_label_context_and_number_resolve() {
let dir = tempfile::tempdir().unwrap();
let main = dir.path().join("main.tex");
let part = dir.path().join("part.tex");
let main_src = "\\documentclass{article}\n\\begin{document}\n\\input{part}\nSee \\ref{thm:euler}.\n\\end{document}\n";
let part_src = "\\begin{theorem}[Euler]\nx \\label{thm:euler}\n\\end{theorem}\n";
std::fs::write(&main, main_src).unwrap();
std::fs::write(&part, part_src).unwrap();
std::fs::write(
dir.path().join("main.aux"),
"\\newlabel{thm:euler}{{4}{1}}\n",
)
.unwrap();
let mut db = IncrementalDatabase::default();
db.upsert_file(&main, main_src.to_string());
db.upsert_file(&part, part_src.to_string());
let offset = main_src.rfind("thm:euler").expect("ref key");
let md = markdown_at(&db, &main, main_src, offset).expect("hover for \\ref key");
assert_eq!(md, "Theorem 4 (Euler)");
}
#[test]
fn package_name_hover_shows_ctan_metadata() {
let md = hover_md("\\usepackage{amsmath}\n", "amsmath").expect("hover for amsmath");
assert!(md.contains("package"), "kind: {md}");
assert!(md.contains("AMS mathematical facilities"), "desc: {md}");
assert!(
md.contains("https://ctan.org/pkg/latex-amsmath"),
"ctan link: {md}"
);
}
#[test]
fn documentclass_name_hover_marks_class() {
let md = hover_md("\\documentclass{article}\n", "article").expect("hover for article");
assert!(md.contains("class"), "kind: {md}");
assert!(md.contains("https://ctan.org/pkg/"), "ctan link: {md}");
}
#[test]
fn package_hover_picks_the_comma_segment_under_cursor() {
let md =
hover_md("\\usepackage{amsmath, booktabs}\n", "booktabs").expect("hover for booktabs");
assert!(md.contains("Publication quality tables"), "desc: {md}");
}
#[test]
fn no_package_hover_on_the_command_word() {
let md = hover_md("\\usepackage{amsmath}\n", "usepackage");
let is_ctan = md.as_deref().is_some_and(|m| m.contains("ctan.org"));
assert!(!is_ctan, "command word should not show CTAN hover: {md:?}");
}
}