use super::*;
use crate::bib::ast as bib_ast;
use crate::bib::syntax::{SyntaxKind as BibSyntaxKind, SyntaxNode as BibSyntaxNode};
use crate::semantic::signature::ArgSpec;
use lsp_types::{Documentation, MarkupContent, MarkupKind};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind")]
pub(crate) enum CompletionResolveData {
Citation { lint_path: PathBuf, key: String },
Command { name: String, file: PathBuf },
Environment { name: String, file: PathBuf },
}
impl CompletionResolveData {
pub(crate) fn into_value(self) -> Option<serde_json::Value> {
serde_json::to_value(self).ok()
}
}
pub(crate) fn resolve(
snapshot: &Analysis,
mut item: CompletionItem,
members: Vec<ProjectMember>,
) -> CompletionItem {
let Some(data) = item
.data
.clone()
.and_then(|v| serde_json::from_value::<CompletionResolveData>(v).ok())
else {
return item;
};
let detail_doc = match data {
CompletionResolveData::Citation { lint_path, key } => {
citation_detail(snapshot, members, &lint_path, &key)
}
CompletionResolveData::Command { name, file } => {
command_detail(snapshot, members, &file, &name)
}
CompletionResolveData::Environment { name, file } => {
environment_detail(snapshot, members, &file, &name)
}
};
if let Some((detail, documentation)) = detail_doc {
item.detail = Some(detail);
item.documentation = Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: documentation,
}));
}
item
}
fn citation_detail(
snapshot: &Analysis,
members: Vec<ProjectMember>,
lint_path: &Path,
key: &str,
) -> Option<(String, String)> {
let (_, citations) = snapshot.resolve_project(members);
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(key))
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;
};
let documentation = super::hover::render_entry(&entry.entry_type, &entry.key, &node);
let detail =
citation_inline_detail(&node).unwrap_or_else(|| format!("@{}", entry.entry_type));
return Some((detail, documentation));
}
None
}
fn citation_inline_detail(node: &BibSyntaxNode) -> Option<String> {
let author = bib_field(node, "author").or_else(|| bib_field(node, "editor"));
let year = bib_field(node, "year");
match (author, year) {
(Some(a), Some(y)) => Some(format!("{} ({y})", first_author(&a))),
(Some(a), None) => Some(first_author(&a)),
(None, Some(y)) => Some(format!("({y})")),
(None, None) => None,
}
}
fn bib_field(node: &BibSyntaxNode, want: &str) -> Option<String> {
for field in bib_ast::fields(node) {
let Some(name) = bib_ast::field_name(&field) else {
continue;
};
if !name.eq_ignore_ascii_case(want) {
continue;
}
let value = bib_ast::field_value(&field).map(|v| super::hover::clean_value(&v))?;
return (!value.is_empty()).then_some(value);
}
None
}
fn first_author(authors: &str) -> String {
authors
.split(" and ")
.next()
.unwrap_or(authors)
.trim()
.to_string()
}
fn command_detail(
snapshot: &Analysis,
members: Vec<ProjectMember>,
file: &Path,
name: &str,
) -> Option<(String, String)> {
let scope = scope_for(snapshot, members, file);
let (sig, user) = super::hover::lookup_command(&scope, name)?;
let mut detail = format!("\\{name}");
for arg in sig.args.iter() {
detail.push_str(super::hover::arg_slot(arg.kind));
}
Some((detail, super::hover::render_command(name, sig, user)))
}
fn environment_detail(
snapshot: &Analysis,
members: Vec<ProjectMember>,
file: &Path,
name: &str,
) -> Option<(String, String)> {
let scope = scope_for(snapshot, members, file);
let (sig, user) = super::hover::lookup_environment(&scope, name)?;
let detail = format!("\\begin{{{name}}}{}", arg_slots(&sig.args));
Some((detail, super::hover::render_environment(name, sig, user)))
}
fn scope_for(snapshot: &Analysis, members: Vec<ProjectMember>, file: &Path) -> SignatureDb {
match snapshot.lookup_file(file) {
Some(source) => snapshot.scope_signatures(members, source).clone(),
None => SignatureDb::default(),
}
}
fn arg_slots(args: &[ArgSpec]) -> String {
args.iter()
.map(|a| super::hover::arg_slot(a.kind))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
fn complete(
db: &IncrementalDatabase,
path: &Path,
src: &str,
needle: &str,
) -> Vec<CompletionItem> {
let snapshot = db.snapshot();
let members = super::members_of(&snapshot);
let offset = src.find(needle).expect("needle present") + needle.len();
let idx = LineIndex::new(src);
let (line, character) = idx.utf16_position(src, offset);
let uri: Uri = format!("file://{}", path.display()).parse().expect("uri");
super::compute_completion(
&snapshot,
&uri,
path,
src,
Position { line, character },
members,
)
}
fn resolve_item(db: &IncrementalDatabase, item: CompletionItem) -> CompletionItem {
let snapshot = db.snapshot();
let members = super::members_of(&snapshot);
resolve(&snapshot, item, members)
}
fn documentation(item: &CompletionItem) -> String {
match item.documentation.as_ref().expect("documentation") {
Documentation::MarkupContent(m) => m.value.clone(),
other => panic!("expected markup, got {other:?}"),
}
}
#[test]
fn citation_resolves_to_card_and_detail() {
let tex = "\\addbibresource{refs.bib}\n\\cite{knu";
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 items = complete(&db, tex_path, tex, "knu");
let item = items
.into_iter()
.find(|i| i.label == "knuth1984")
.expect("knuth1984 candidate");
assert!(item.documentation.is_none(), "documentation is lazy");
assert!(item.data.is_some(), "carries resolve data");
let resolved = resolve_item(&db, item);
let doc = documentation(&resolved);
assert!(doc.contains("@book"), "type: {doc}");
assert!(doc.contains("The TeXbook"), "title: {doc}");
assert!(doc.contains("Knuth"), "author: {doc}");
let detail = resolved.detail.expect("detail");
assert!(detail.contains("Knuth"), "detail author: {detail}");
assert!(detail.contains("1984"), "detail year: {detail}");
}
#[test]
fn command_resolves_to_signature() {
let src = "\\sec";
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
let items = complete(&db, path, src, "\\sec");
let item = items
.into_iter()
.find(|i| i.label == "section")
.expect("section candidate");
assert!(item.documentation.is_none(), "documentation is lazy");
let resolved = resolve_item(&db, item);
let doc = documentation(&resolved);
assert!(doc.contains("\\section"), "prototype: {doc}");
assert!(doc.contains("sectioning level"), "facts: {doc}");
assert_eq!(resolved.detail.as_deref(), Some("\\section[]{}"), "detail");
}
#[test]
fn environment_resolves_to_signature() {
let src = "\\begin{ali";
let path = Path::new("/p/main.tex");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, src.to_string());
let items = complete(&db, path, src, "{ali");
let item = items
.into_iter()
.find(|i| i.label == "align")
.expect("align candidate");
let resolved = resolve_item(&db, item);
let doc = documentation(&resolved);
assert!(doc.contains("\\begin{align}"), "prototype: {doc}");
assert!(doc.contains("math"), "facts: {doc}");
}
#[test]
fn item_without_data_round_trips_unchanged() {
let mut db = IncrementalDatabase::default();
db.upsert_file(Path::new("/p/main.tex"), String::new());
let item = CompletionItem {
label: "bare".to_owned(),
..Default::default()
};
let resolved = resolve_item(&db, item.clone());
assert_eq!(resolved, item);
}
}