use rowan::{TextSize, TokenAtOffset};
use std::collections::HashSet;
use crate::bib::ast;
use crate::bib::semantic::{MONTH_MACROS, Model, RequiredField, builtin};
use crate::bib::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BibCompletionContext {
EntryType { prefix: String },
FieldName {
entry_type: Option<String>,
prefix: String,
present: Vec<String>,
},
ValueMacro { prefix: String },
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BibCandidateKind {
EntryType,
FieldName,
StringMacro,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BibCompletionCandidate {
pub label: String,
pub kind: BibCandidateKind,
}
pub fn classify_bib_context(root: &SyntaxNode, offset: usize) -> BibCompletionContext {
let offset = TextSize::new(offset.min(u32::MAX as usize) as u32);
let (left, right) = match root.token_at_offset(offset) {
TokenAtOffset::None => return BibCompletionContext::None,
TokenAtOffset::Single(t) => (Some(t.clone()), Some(t)),
TokenAtOffset::Between(l, r) => (Some(l), Some(r)),
};
for tok in [left.as_ref(), right.as_ref()].into_iter().flatten() {
if let Some(ctx) = word_context(tok, offset) {
return ctx;
}
}
for tok in [left.as_ref(), right.as_ref()].into_iter().flatten() {
if let Some(ctx) = empty_context(tok, offset) {
return ctx;
}
}
BibCompletionContext::None
}
fn word_context(token: &SyntaxToken, offset: TextSize) -> Option<BibCompletionContext> {
if token.kind() != SyntaxKind::WORD {
return None;
}
let range = token.text_range();
if offset <= range.start() || offset > range.end() {
return None;
}
let rel = usize::from(offset - range.start());
let prefix = token.text().get(..rel).unwrap_or(token.text()).to_string();
let parent = token.parent()?;
match parent.kind() {
SyntaxKind::ENTRY_TYPE => Some(BibCompletionContext::EntryType { prefix }),
SyntaxKind::FIELD_NAME => {
let entry = enclosing_entry(token)?;
if entry.kind() != SyntaxKind::ENTRY {
return None;
}
let field = parent.parent();
Some(BibCompletionContext::FieldName {
entry_type: ast::entry_type(&entry).map(|t| t.to_lowercase()),
prefix,
present: present_field_names(&entry, field.as_ref()),
})
}
SyntaxKind::LITERAL if parent.parent().map(|p| p.kind()) == Some(SyntaxKind::VALUE) => {
Some(BibCompletionContext::ValueMacro { prefix })
}
_ => None,
}
}
fn empty_context(token: &SyntaxToken, offset: TextSize) -> Option<BibCompletionContext> {
if token.kind() == SyntaxKind::AT
&& offset == token.text_range().end()
&& token.parent().is_some_and(|p| is_entry_node(p.kind()))
{
return Some(BibCompletionContext::EntryType {
prefix: String::new(),
});
}
let entry = enclosing_entry(token)?;
match last_significant_kind_before(&entry, offset)? {
SyntaxKind::EQ => Some(BibCompletionContext::ValueMacro {
prefix: String::new(),
}),
SyntaxKind::COMMA if entry.kind() == SyntaxKind::ENTRY => {
Some(BibCompletionContext::FieldName {
entry_type: ast::entry_type(&entry).map(|t| t.to_lowercase()),
prefix: String::new(),
present: present_field_names(&entry, None),
})
}
_ => None,
}
}
fn is_entry_node(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::ENTRY
| SyntaxKind::STRING_ENTRY
| SyntaxKind::PREAMBLE_ENTRY
| SyntaxKind::COMMENT_ENTRY
)
}
fn enclosing_entry(token: &SyntaxToken) -> Option<SyntaxNode> {
token
.parent()?
.ancestors()
.find(|n| is_entry_node(n.kind()))
}
fn last_significant_kind_before(entry: &SyntaxNode, offset: TextSize) -> Option<SyntaxKind> {
entry
.descendants_with_tokens()
.filter_map(|e| e.into_token())
.filter(|t| {
!matches!(t.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE)
&& t.text_range().end() <= offset
})
.last()
.map(|t| t.kind())
}
fn present_field_names(entry: &SyntaxNode, exclude: Option<&SyntaxNode>) -> Vec<String> {
let mut names = Vec::new();
for field in ast::fields(entry) {
if let Some(ex) = exclude
&& &field == ex
{
continue;
}
if let Some(name) = ast::field_name(&field) {
names.push(name.to_lowercase());
}
}
names
}
pub fn bib_candidates(
context: &BibCompletionContext,
model: &Model,
) -> Vec<BibCompletionCandidate> {
match context {
BibCompletionContext::EntryType { prefix } => entry_type_candidates(prefix),
BibCompletionContext::FieldName {
entry_type,
prefix,
present,
} => field_name_candidates(entry_type.as_deref(), prefix, present),
BibCompletionContext::ValueMacro { prefix } => value_macro_candidates(model, prefix),
BibCompletionContext::None => Vec::new(),
}
}
fn entry_type_candidates(prefix: &str) -> Vec<BibCompletionCandidate> {
const RESERVED: [&str; 3] = ["string", "comment", "preamble"];
let prefix = prefix.to_lowercase();
let mut names: Vec<String> = builtin()
.entry_names()
.chain(RESERVED)
.filter(|name| name.starts_with(&prefix))
.map(str::to_string)
.collect();
names.sort();
names.dedup();
candidates(names, BibCandidateKind::EntryType)
}
fn field_name_candidates(
entry_type: Option<&str>,
prefix: &str,
present: &[String],
) -> Vec<BibCompletionCandidate> {
let prefix = prefix.to_lowercase();
let present: HashSet<&str> = present.iter().map(String::as_str).collect();
let db = builtin();
let mut names: Vec<String> = match entry_type.and_then(|t| db.entry(t)) {
Some(sig) => required_field_names(&sig.required)
.chain(sig.optional.iter().map(smol_str::SmolStr::as_str))
.map(str::to_string)
.collect(),
None => db.field_names().map(str::to_string).collect(),
};
names.retain(|name| name.starts_with(&prefix) && !present.contains(name.as_str()));
names.sort();
names.dedup();
candidates(names, BibCandidateKind::FieldName)
}
fn value_macro_candidates(model: &Model, prefix: &str) -> Vec<BibCompletionCandidate> {
let prefix = prefix.to_lowercase();
let mut names: Vec<String> = model
.string_defs()
.iter()
.map(|def| def.name.to_string())
.chain(MONTH_MACROS.iter().map(|m| (*m).to_string()))
.filter(|name| name.starts_with(&prefix))
.collect();
names.sort();
names.dedup();
candidates(names, BibCandidateKind::StringMacro)
}
fn required_field_names(required: &[RequiredField]) -> impl Iterator<Item = &str> {
required
.iter()
.flat_map(|req| match req {
RequiredField::One(name) => std::slice::from_ref(name),
RequiredField::OneOf(names) => names.as_slice(),
})
.map(smol_str::SmolStr::as_str)
}
fn candidates(names: Vec<String>, kind: BibCandidateKind) -> Vec<BibCompletionCandidate> {
names
.into_iter()
.map(|label| BibCompletionCandidate { label, kind })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bib::parse;
fn root(src: &str) -> SyntaxNode {
parse(src).syntax()
}
fn at(src: &str, needle: &str) -> usize {
src.find(needle).expect("needle present") + needle.len()
}
fn classify(src: &str, offset: usize) -> BibCompletionContext {
classify_bib_context(&root(src), offset)
}
fn cands(src: &str, offset: usize) -> Vec<String> {
let tree = root(src);
let ctx = classify_bib_context(&tree, offset);
let model = Model::build(&tree);
bib_candidates(&ctx, &model)
.into_iter()
.map(|c| c.label)
.collect()
}
#[test]
fn entry_type_prefix_classified() {
let src = "@art";
assert_eq!(
classify(src, at(src, "@art")),
BibCompletionContext::EntryType {
prefix: "art".to_string()
}
);
}
#[test]
fn lone_at_offers_entry_types() {
let src = "@";
assert_eq!(
classify(src, at(src, "@")),
BibCompletionContext::EntryType {
prefix: String::new()
}
);
let got = cands(src, at(src, "@"));
assert!(got.contains(&"article".to_string()), "{got:?}");
}
#[test]
fn entry_type_candidates_prefix_filtered() {
let src = "@inpro";
let got = cands(src, at(src, "@inpro"));
assert!(got.contains(&"inproceedings".to_string()), "{got:?}");
assert!(got.iter().all(|n| n.starts_with("inpro")), "{got:?}");
}
#[test]
fn reserved_types_offered() {
let src = "@s";
let got = cands(src, at(src, "@s"));
assert!(got.contains(&"string".to_string()), "{got:?}");
}
#[test]
fn field_name_in_entry_classified() {
let src = "@article{k, au}\n";
assert_eq!(
classify(src, at(src, "@article{k, au")),
BibCompletionContext::FieldName {
entry_type: Some("article".to_string()),
prefix: "au".to_string(),
present: Vec::new(),
}
);
}
#[test]
fn field_candidates_scoped_to_type() {
let src = "@article{k, au}\n";
let got = cands(src, at(src, "@article{k, au"));
assert!(got.contains(&"author".to_string()), "{got:?}");
assert!(got.iter().all(|n| n.starts_with("au")), "{got:?}");
}
#[test]
fn field_name_empty_after_comma() {
let src = "@article{k, }\n";
let got = cands(src, at(src, "@article{k, "));
assert!(got.contains(&"author".to_string()), "{got:?}");
assert!(got.contains(&"title".to_string()), "{got:?}");
}
#[test]
fn field_candidates_exclude_present() {
let src = "@article{k, author = {x}, au}\n";
let got = cands(src, at(src, "author = {x}, au"));
assert!(!got.contains(&"author".to_string()), "{got:?}");
}
#[test]
fn field_candidates_unknown_type_fallback() {
let src = "@bogustype{k, au}\n";
let got = cands(src, at(src, "@bogustype{k, au"));
assert!(got.contains(&"author".to_string()), "{got:?}");
}
#[test]
fn post_open_brace_is_key_not_field() {
let src = "@article{}\n";
assert_eq!(
classify(src, at(src, "@article{")),
BibCompletionContext::None
);
}
#[test]
fn string_entry_name_not_field() {
let src = "@string{na}\n";
assert_eq!(
classify(src, at(src, "@string{na")),
BibCompletionContext::None
);
}
#[test]
fn value_macro_after_eq() {
let src = "@article{k, journal = }\n";
assert_eq!(
classify(src, at(src, "journal = ")),
BibCompletionContext::ValueMacro {
prefix: String::new()
}
);
}
#[test]
fn value_macro_bare_word() {
let src = "@article{k, publisher = els}\n";
assert_eq!(
classify(src, at(src, "publisher = els")),
BibCompletionContext::ValueMacro {
prefix: "els".to_string()
}
);
}
#[test]
fn value_macro_candidates_include_months_and_defs() {
let src = "@string{els = {Elsevier}}\n@article{k, publisher = e}\n";
let got = cands(src, at(src, "publisher = e"));
assert!(got.contains(&"els".to_string()), "{got:?}");
let src = "@article{k, month = j}\n";
let got = cands(src, at(src, "month = j"));
assert!(got.contains(&"jan".to_string()), "{got:?}");
assert!(got.contains(&"jun".to_string()), "{got:?}");
}
#[test]
fn value_in_braces_not_macro() {
let src = "@article{k, title = {els}}\n";
assert_eq!(
classify(src, at(src, "title = {els")),
BibCompletionContext::None
);
}
#[test]
fn number_value_not_macro() {
let src = "@article{k, year = 20}\n";
assert_eq!(
classify(src, at(src, "year = 20")),
BibCompletionContext::None
);
}
}