use smol_str::SmolStr;
use rowan::{TextRange, TextSize};
use crate::ast::{command_name, first_group_range, nth_group_inner};
use crate::semantic::SemanticModel;
use crate::semantic::label::{
CitationRef, ColorDef, ColorDefKind, GlossaryDef, GlossaryDefKind, LabelDef, LabelRef,
RefCommand,
};
use crate::semantic::pkgmeta;
use crate::syntax::{SyntaxKind, SyntaxNode};
pub fn build(root: &SyntaxNode) -> SemanticModel {
let mut model = SemanticModel::default();
for command in root
.descendants()
.filter(|node| node.kind() == SyntaxKind::COMMAND)
{
let Some(name) = command_name(&command) else {
continue;
};
if name == "label" {
if let Some((inner_range, inner)) = nth_group_inner(&command, 0) {
for (key, key_range) in key_spans(&inner, inner_range, false) {
model.labels.push(LabelDef {
name: SmolStr::from(key),
range: first_group_range(&command),
key_range,
referenced: false,
});
}
}
} else if let Some(kind) = ref_command(&name)
&& let Some((inner_range, inner)) = nth_group_inner(&command, 0)
{
for (key, key_range) in key_spans(&inner, inner_range, kind.is_key_list()) {
model.refs.push(LabelRef {
name: SmolStr::from(key),
command: kind,
range: command.text_range(),
key_range,
resolved: false,
});
}
} else if let Some(kind) = glossary_definer(&name) {
if let Some((inner_range, inner)) = nth_group_inner(&command, 0) {
for (key, key_range) in key_spans(&inner, inner_range, false) {
model.glossary_defs.push(GlossaryDef {
key: SmolStr::from(key),
kind,
range: first_group_range(&command),
key_range,
});
}
}
} else if let Some(kind) = color_definer(&name) {
if let Some((inner_range, inner)) = nth_group_inner(&command, 0) {
for (key, key_range) in key_spans(&inner, inner_range, false) {
model.color_defs.push(ColorDef {
name: SmolStr::from(key),
kind,
range: first_group_range(&command),
key_range,
});
}
}
} else if is_cite_command(&name)
&& let Some((inner_range, inner)) = nth_group_inner(&command, 0)
{
if name == "nocite" && inner.trim() == "*" {
model.nocite_all = true;
} else {
for (key, key_range) in key_spans(&inner, inner_range, true) {
model.citations.push(CitationRef {
name: SmolStr::from(key),
command: SmolStr::from(name.as_str()),
range: command.text_range(),
key_range,
});
}
}
} else if pkgmeta::provides_kind(&name).is_some() {
if model.provides.is_none()
&& let Some(decl) = pkgmeta::provides_from_command(&command)
{
model.provides = Some(decl);
}
} else if name == "NeedsTeXFormat" {
if model.needs_format.is_none()
&& let Some(decl) = pkgmeta::needs_format_from_command(&command)
{
model.needs_format = Some(decl);
}
} else if name == "DeclareOption"
&& let Some(decl) = pkgmeta::option_from_command(&command)
{
model.options.push(decl);
}
}
resolve(&mut model);
model
}
pub(crate) fn is_cite_command(name: &str) -> bool {
const EXTRA: &[&str] = &[
"parencite",
"Parencite",
"footcite",
"footcitetext",
"textcite",
"Textcite",
"smartcite",
"Smartcite",
"autocite",
"Autocite",
"supercite",
"fullcite",
"footfullcite",
"nocite",
"notecite",
"Notecite",
"pnotecite",
"fnotecite",
];
name.starts_with("cite") || name.starts_with("Cite") || EXTRA.contains(&name)
}
pub(crate) fn ref_command(name: &str) -> Option<RefCommand> {
Some(match name {
"ref" => RefCommand::Ref,
"pageref" => RefCommand::PageRef,
"eqref" => RefCommand::EqRef,
"autoref" => RefCommand::AutoRef,
"nameref" => RefCommand::NameRef,
"cref" => RefCommand::Cref,
"Cref" => RefCommand::CrefUpper,
"vref" => RefCommand::Vref,
"Vref" => RefCommand::VrefUpper,
"cpageref" => RefCommand::CpageRef,
_ => return None,
})
}
pub(crate) fn glossary_definer(name: &str) -> Option<GlossaryDefKind> {
Some(match name {
"newglossaryentry"
| "longnewglossaryentry"
| "provideglossaryentry"
| "longprovideglossaryentry" => GlossaryDefKind::Entry,
"newacronym" => GlossaryDefKind::Acronym,
"newabbreviation" => GlossaryDefKind::Abbreviation,
_ => return None,
})
}
pub(crate) fn color_definer(name: &str) -> Option<ColorDefKind> {
Some(match name {
"definecolor" => ColorDefKind::DefineColor,
"providecolor" => ColorDefKind::ProvideColor,
"colorlet" => ColorDefKind::Colorlet,
_ => return None,
})
}
pub(crate) fn is_glossary_ref_command(name: &str) -> bool {
const GLS: &[&str] = &[
"gls",
"Gls",
"GLS",
"glspl",
"Glspl",
"GLSpl",
"glstext",
"Glstext",
"glsfirst",
"Glsfirst",
"glsplural",
"Glsplural",
"glsfirstplural",
"Glsfirstplural",
"glsdesc",
"Glsdesc",
"glsname",
"Glsname",
"glssymbol",
"Glssymbol",
"glslink",
"glsdisp",
"glsadd",
"glsxtrshort",
"Glsxtrshort",
"glsxtrlong",
"Glsxtrlong",
"glsxtrfull",
"Glsxtrfull",
];
if GLS.contains(&name) {
return true;
}
for stem in ["acr", "Acr", "ACR"] {
if let Some(rest) = name.strip_prefix(stem) {
return matches!(
rest,
"short" | "shortpl" | "long" | "longpl" | "full" | "fullpl"
);
}
}
false
}
fn key_spans(inner: &str, inner_range: TextRange, split: bool) -> Vec<(&str, TextRange)> {
let base = inner_range.start();
let mut out = Vec::new();
if split {
let mut seg_off = 0usize;
for segment in inner.split(',') {
if let Some((key, lo, hi)) = trimmed_span(segment) {
out.push((key, key_range(base, seg_off + lo, seg_off + hi)));
}
seg_off += segment.len() + 1;
}
} else if let Some((key, lo, hi)) = trimmed_span(inner) {
out.push((key, key_range(base, lo, hi)));
}
out
}
fn trimmed_span(segment: &str) -> Option<(&str, usize, usize)> {
let key = segment.trim();
if key.is_empty() {
return None;
}
let lo = segment.len() - segment.trim_start().len();
Some((key, lo, lo + key.len()))
}
fn key_range(base: TextSize, lo: usize, hi: usize) -> TextRange {
TextRange::new(
base + TextSize::from(lo as u32),
base + TextSize::from(hi as u32),
)
}
fn resolve(model: &mut SemanticModel) {
for ref_idx in 0..model.refs.len() {
let name = model.refs[ref_idx].name.clone();
let mut hit = false;
for label in &mut model.labels {
if label.name == name {
label.referenced = true;
hit = true;
}
}
model.refs[ref_idx].resolved = hit;
}
}
#[cfg(test)]
mod tests {
use crate::parser::parse;
use crate::syntax::SyntaxNode;
use super::build;
fn model(src: &str) -> crate::semantic::SemanticModel {
build(&SyntaxNode::new_root(parse(src).green))
}
#[test]
fn label_key_range_excludes_command_and_braces() {
let src = "\\label{ sec:intro }\n";
let model = model(src);
let def = &model.labels()[0];
assert_eq!(def.name, "sec:intro");
assert_eq!(&src[def.key_range], "sec:intro");
}
#[test]
fn cref_list_keys_get_isolated_ranges() {
let src = "\\cref{a,b,c}\n";
let model = model(src);
let keys: Vec<_> = model
.refs()
.iter()
.map(|r| (r.name.as_str(), &src[r.key_range]))
.collect();
assert_eq!(
keys,
vec![("a", "a"), ("b", "b"), ("c", "c")],
"each key in a list command isolates its own span"
);
}
#[test]
fn newglossaryentry_key_scanned_with_range() {
let src = "\\newglossaryentry{ex}{name={example},description={an example}}\n";
let model = model(src);
let def = &model.glossary_defs()[0];
assert_eq!(def.key, "ex");
assert_eq!(def.kind, crate::semantic::label::GlossaryDefKind::Entry);
assert_eq!(&src[def.key_range], "ex");
}
#[test]
fn newacronym_optional_arg_does_not_shift_key() {
let src = "\\newacronym[longplural={frames}]{fps}{FPS}{frame rate}\n";
let model = model(src);
let def = &model.glossary_defs()[0];
assert_eq!(def.key, "fps");
assert_eq!(def.kind, crate::semantic::label::GlossaryDefKind::Acronym);
assert_eq!(&src[def.key_range], "fps");
}
#[test]
fn glossary_definer_family_scanned() {
let src = "\\longnewglossaryentry{a}{name={a}}{desc}\n\\newabbreviation{b}{B}{bee}\n\\provideglossaryentry{c}{name={c}}\n";
let model = model(src);
let keys: Vec<_> = model
.glossary_defs()
.iter()
.map(|d| d.key.as_str())
.collect();
assert_eq!(keys, vec!["a", "b", "c"]);
}
#[test]
fn glossary_nested_macro_key_skipped() {
let model = model("\\newacronym{\\foo}{F}{foo}\n");
assert!(model.glossary_defs().is_empty());
}
#[test]
fn gls_use_is_not_a_definition() {
let model = model("\\gls{ex}\\acrshort{fps}\n");
assert!(model.glossary_defs().is_empty());
}
#[test]
fn color_definers_scanned_with_ranges() {
let src = "\\definecolor{brandblue}{HTML}{0055AA}\n\\colorlet{accent}{brandblue}\n\\providecolor{muted}{gray}{0.5}\n";
let model = model(src);
let defs: Vec<_> = model
.color_defs()
.iter()
.map(|d| (d.name.as_str(), d.kind, &src[d.key_range]))
.collect();
use crate::semantic::label::ColorDefKind::*;
assert_eq!(
defs,
vec![
("brandblue", DefineColor, "brandblue"),
("accent", Colorlet, "accent"),
("muted", ProvideColor, "muted"),
]
);
}
#[test]
fn textcolor_use_is_not_a_color_definition() {
let model = model("\\textcolor{red}{x}\\color{blue}\n");
assert!(model.color_defs().is_empty());
}
#[test]
fn cite_list_keys_get_isolated_ranges() {
let src = "\\cite{ foo , bar }\n";
let model = model(src);
let keys: Vec<_> = model
.citations()
.iter()
.map(|c| (c.name.as_str(), &src[c.key_range]))
.collect();
assert_eq!(keys, vec![("foo", "foo"), ("bar", "bar")]);
}
}