use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CitationMode {
#[default]
Normal,
AuthorInText,
SuppressAuthor,
NoParen,
}
pub fn citation_mode_from_latex_command(command: &str) -> Option<CitationMode> {
let normalized = command
.trim_start_matches('\\')
.trim_end_matches('*')
.to_ascii_lowercase();
match normalized.as_str() {
"cite" | "citep" | "citeal" | "citealp" | "citealt" | "parencite" | "autocite"
| "footcite" | "smartcite" | "supercite" | "fullcite" | "footfullcite" | "cites"
| "parencites" | "autocites" => Some(CitationMode::Normal),
"citet" | "textcite" | "textcites" => Some(CitationMode::AuthorInText),
"citeyear" | "citeyearpar" => Some(CitationMode::SuppressAuthor),
"citeauthor" => Some(CitationMode::NoParen),
_ => None,
}
}
pub fn citation_mode_from_typst_form(form: Option<&str>) -> CitationMode {
let normalized = form
.map(str::trim)
.map(|s| s.trim_matches('"').trim_matches('\''))
.unwrap_or("");
match normalized {
"prose" => CitationMode::AuthorInText,
"year" => CitationMode::SuppressAuthor,
"author" => CitationMode::NoParen,
_ => CitationMode::Normal,
}
}
pub fn reference_type_from_latex_command(command: &str) -> Option<ReferenceType> {
match command.trim_start_matches('\\') {
"ref" => Some(ReferenceType::Basic),
"autoref" | "cref" | "Cref" | "nameref" => Some(ReferenceType::Named),
"pageref" => Some(ReferenceType::Page),
"eqref" => Some(ReferenceType::Equation),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct Citation {
pub key: String,
pub prefix: Option<String>,
pub suffix: Option<String>,
pub mode: CitationMode,
pub locator: Option<String>,
}
impl Citation {
pub fn new(key: String) -> Self {
Self {
key,
prefix: None,
suffix: None,
mode: CitationMode::Normal,
locator: None,
}
}
pub fn with_mode(key: String, mode: CitationMode) -> Self {
Self {
key,
prefix: None,
suffix: None,
mode,
locator: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CiteGroup {
pub citations: Vec<Citation>,
pub prefix: Option<String>,
pub suffix: Option<String>,
}
impl CiteGroup {
pub fn new() -> Self {
Self::default()
}
pub fn single(citation: Citation) -> Self {
Self {
citations: vec![citation],
prefix: None,
suffix: None,
}
}
pub fn push(&mut self, citation: Citation) {
self.citations.push(citation);
}
}
#[derive(Debug, Clone)]
pub struct Label {
pub id: String,
pub label_type: LabelType,
pub number: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LabelType {
Section,
Figure,
Table,
Equation,
Theorem,
Item,
}
impl Label {
pub fn new(id: String, label_type: LabelType) -> Self {
Self {
id,
label_type,
number: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Reference {
pub target: String,
pub ref_type: ReferenceType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceType {
Basic,
Named,
Page,
Equation,
}
impl Reference {
pub fn new(target: String) -> Self {
Self {
target,
ref_type: ReferenceType::Basic,
}
}
pub fn named(target: String) -> Self {
Self {
target,
ref_type: ReferenceType::Named,
}
}
}
#[derive(Debug, Default)]
pub struct RefDatabase {
labels: HashMap<String, Label>,
counters: HashMap<LabelType, u32>,
}
impl RefDatabase {
pub fn new() -> Self {
Self::default()
}
pub fn register_label(&mut self, id: String, label_type: LabelType) -> &Label {
let counter = self.counters.entry(label_type).or_insert(0);
*counter += 1;
let label = Label {
id: id.clone(),
label_type,
number: Some(counter.to_string()),
};
self.labels.insert(id.clone(), label);
self.labels.get(&id).unwrap()
}
pub fn get_label(&self, id: &str) -> Option<&Label> {
self.labels.get(id)
}
pub fn has_label(&self, id: &str) -> bool {
self.labels.contains_key(id)
}
}
pub fn parse_latex_citation(input: &str) -> Option<CiteGroup> {
let input = input.trim();
let (mode, rest) = if let Some(rest) = input.strip_prefix("\\cite{") {
(CitationMode::Normal, rest)
} else if let Some(rest) = input.strip_prefix("\\citep{") {
(CitationMode::Normal, rest)
} else if let Some(rest) = input.strip_prefix("\\citet{") {
(CitationMode::AuthorInText, rest)
} else if let Some(rest) = input.strip_prefix("\\citeyear{") {
(CitationMode::SuppressAuthor, rest)
} else if let Some(rest) = input.strip_prefix("\\citeauthor{") {
(CitationMode::NoParen, rest)
} else if let Some(rest) = input.strip_prefix("\\parencite{") {
(CitationMode::Normal, rest)
} else if let Some(rest) = input.strip_prefix("\\textcite{") {
(CitationMode::AuthorInText, rest)
} else if let Some(rest) = input.strip_prefix("\\autocite{") {
(CitationMode::Normal, rest)
} else {
return None;
};
let end = rest.find('}')?;
let keys_str = &rest[..end];
let mut group = CiteGroup::new();
for key in keys_str.split(',') {
let key = key.trim();
if !key.is_empty() {
let mut citation = Citation::new(key.to_string());
citation.mode = mode;
group.push(citation);
}
}
Some(group)
}
pub fn parse_latex_citation_full(input: &str) -> Option<CiteGroup> {
let input = input.trim();
let cmd_end = input.find('{')?;
let cmd = &input[..cmd_end];
let rest = &input[cmd_end..];
let mode = match cmd {
"\\cite" | "\\citep" | "\\parencite" | "\\autocite" => CitationMode::Normal,
"\\citet" | "\\textcite" => CitationMode::AuthorInText,
"\\citeyear" | "\\citeyearpar" => CitationMode::SuppressAuthor,
"\\citeauthor" => CitationMode::NoParen,
_ => CitationMode::Normal,
};
let (prefix, suffix, rest) = parse_optional_args(rest);
if !rest.starts_with('{') {
return None;
}
let end = find_matching_brace(rest)?;
let keys_str = &rest[1..end];
let mut group = CiteGroup::new();
group.prefix = prefix;
group.suffix = suffix;
for key in keys_str.split(',') {
let key = key.trim();
if !key.is_empty() {
let mut citation = Citation::new(key.to_string());
citation.mode = mode;
group.push(citation);
}
}
Some(group)
}
fn parse_optional_args(input: &str) -> (Option<String>, Option<String>, &str) {
let mut rest = input;
let mut first_opt = None;
let mut second_opt = None;
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
first_opt = Some(rest[1..end].to_string());
rest = &rest[end + 1..];
}
}
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
second_opt = Some(rest[1..end].to_string());
rest = &rest[end + 1..];
}
}
let (prefix, suffix) = match (first_opt, second_opt) {
(Some(a), Some(b)) => (Some(a), Some(b)),
(Some(a), None) => (None, Some(a)),
_ => (None, None),
};
(prefix, suffix, rest)
}
fn find_matching_brace(s: &str) -> Option<usize> {
let mut depth = 0;
for (i, c) in s.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
pub fn parse_latex_ref(input: &str) -> Option<Reference> {
let input = input.trim();
let (ref_type, rest) = if let Some(rest) = input.strip_prefix("\\ref{") {
(ReferenceType::Basic, rest)
} else if let Some(rest) = input.strip_prefix("\\eqref{") {
(ReferenceType::Equation, rest)
} else if let Some(rest) = input.strip_prefix("\\pageref{") {
(ReferenceType::Page, rest)
} else if let Some(rest) = input.strip_prefix("\\autoref{") {
(ReferenceType::Named, rest)
} else if let Some(rest) = input.strip_prefix("\\cref{") {
(ReferenceType::Named, rest)
} else if let Some(rest) = input.strip_prefix("\\Cref{") {
(ReferenceType::Named, rest)
} else {
return None;
};
let end = rest.find('}')?;
let target = rest[..end].trim().to_string();
Some(Reference { target, ref_type })
}
pub fn parse_latex_label(input: &str) -> Option<String> {
let input = input.trim();
if !input.starts_with("\\label{") {
return None;
}
let rest = &input["\\label{".len()..];
let end = rest.find('}')?;
Some(rest[..end].trim().to_string())
}
pub fn parse_typst_citation(input: &str) -> Option<CiteGroup> {
let input = input.trim();
if !(input.starts_with("#cite(") || input.starts_with("cite(")) {
return None;
}
let mut group = CiteGroup::new();
let mode = citation_mode_from_typst_form(extract_named_string_arg(input, "form"));
group.suffix = extract_named_bracket_arg(input, "supplement");
let mut cursor = input;
while let Some(start) = cursor.find('<') {
let rest = &cursor[start + 1..];
let end = rest.find('>')?;
let key = rest[..end].trim();
if !key.is_empty() {
group.push(Citation::with_mode(key.to_string(), mode));
}
cursor = &rest[end + 1..];
}
if group.citations.is_empty() {
None
} else {
Some(group)
}
}
fn extract_named_string_arg<'a>(input: &'a str, name: &str) -> Option<&'a str> {
let pattern = format!("{}:", name);
let start = input.find(&pattern)? + pattern.len();
let rest = input[start..].trim_start();
let quote = rest.chars().next()?;
if quote != "\"".chars().next().unwrap() && quote != "'".chars().next().unwrap() {
return None;
}
let rest = &rest[quote.len_utf8()..];
let end = rest.find(quote)?;
Some(&rest[..end])
}
fn extract_named_bracket_arg(input: &str, name: &str) -> Option<String> {
let pattern = format!("{}:", name);
let start = input.find(&pattern)? + pattern.len();
let rest = input[start..].trim_start();
if !rest.starts_with('[') {
return None;
}
let end = rest.find(']')?;
Some(rest[1..end].trim().to_string())
}
pub fn parse_typst_ref(input: &str) -> Option<Reference> {
let input = input.trim();
if !input.starts_with('@') {
return None;
}
let target: String = input[1..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == ':' || *c == '.')
.collect();
if target.is_empty() {
return None;
}
Some(Reference::new(target))
}
pub fn parse_typst_label(input: &str) -> Option<String> {
let input = input.trim();
if !input.starts_with('<') {
return None;
}
let end = input.find('>')?;
let label = input[1..end].to_string();
if label.is_empty() {
None
} else {
Some(label)
}
}
pub fn citation_to_latex(group: &CiteGroup) -> String {
if group.citations.is_empty() {
return String::new();
}
let mode = group.citations[0].mode;
let cmd = match mode {
CitationMode::Normal => "\\cite",
CitationMode::AuthorInText => "\\citet",
CitationMode::SuppressAuthor => "\\citeyear",
CitationMode::NoParen => "\\citeauthor",
};
let keys: Vec<&str> = group.citations.iter().map(|c| c.key.as_str()).collect();
let mut result = String::new();
result.push_str(cmd);
if let Some(ref prefix) = group.prefix {
if let Some(ref suffix) = group.suffix {
result.push_str(&format!("[{}][{}]", prefix, suffix));
} else {
result.push_str(&format!("[{}]", prefix));
}
} else if let Some(ref suffix) = group.suffix {
result.push_str(&format!("[{}]", suffix));
}
result.push('{');
result.push_str(&keys.join(", "));
result.push('}');
result
}
pub fn reference_to_latex(reference: &Reference) -> String {
let cmd = match reference.ref_type {
ReferenceType::Basic => "\\ref",
ReferenceType::Named => "\\autoref",
ReferenceType::Page => "\\pageref",
ReferenceType::Equation => "\\eqref",
};
format!("{}{{{}}}", cmd, reference.target)
}
pub fn label_to_latex(id: &str) -> String {
format!("\\label{{{}}}", id)
}
pub fn citation_to_typst(group: &CiteGroup) -> String {
if group.citations.is_empty() {
return String::new();
}
let mut result = String::from("#cite(");
let keys: Vec<String> = group
.citations
.iter()
.map(|c| format!("<{}>", c.key))
.collect();
result.push_str(&keys.join(", "));
let mode = group.citations[0].mode;
match mode {
CitationMode::AuthorInText => {
result.push_str(", form: \"prose\"");
}
CitationMode::SuppressAuthor => {
result.push_str(", form: \"year\"");
}
CitationMode::NoParen => {
result.push_str(", form: \"author\"");
}
_ => {}
}
if let Some(ref suffix) = group.suffix {
result.push_str(&format!(", supplement: [{}]", suffix));
}
result.push(')');
if let Some(ref prefix) = group.prefix {
format!("{} {}", prefix, result)
} else {
result
}
}
fn is_simple_key(key: &str) -> bool {
key.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
pub fn reference_to_typst(reference: &Reference) -> String {
match reference.ref_type {
ReferenceType::Equation => {
let target = if reference.target.starts_with("eq-") {
reference.target.clone()
} else {
format!("eq-{}", reference.target)
};
if is_simple_key(&target) {
format!("@{}", target)
} else {
format!("#ref(<{}>)", target)
}
}
ReferenceType::Page => {
let target = if is_simple_key(&reference.target) {
format!("@{}", reference.target)
} else {
format!("#ref(<{}>)", reference.target)
};
format!("#locate(loc => {{{}.page()}})", target)
}
_ => {
if is_simple_key(&reference.target) {
format!("@{}", reference.target)
} else {
format!("#ref(<{}>)", reference.target)
}
}
}
}
pub fn label_to_typst(id: &str) -> String {
format!("<{}>", id)
}
#[derive(Debug, Clone, Default)]
pub enum BibStyle {
#[default]
Numeric,
AuthorYear,
Alpha,
Custom(String),
}
#[derive(Debug, Clone, Default)]
pub struct BibConfig {
pub files: Vec<String>,
pub style: BibStyle,
pub title: Option<String>,
}
pub fn parse_latex_bibliography(input: &str) -> Option<BibConfig> {
let mut config = BibConfig::default();
if let Some(start) = input.find("\\bibliography{") {
let rest = &input[start + "\\bibliography{".len()..];
if let Some(end) = rest.find('}') {
for file in rest[..end].split(',') {
config.files.push(file.trim().to_string());
}
}
}
if let Some(start) = input.find("\\bibliographystyle{") {
let rest = &input[start + "\\bibliographystyle{".len()..];
if let Some(end) = rest.find('}') {
let style_name = rest[..end].trim();
config.style = match style_name {
"plain" | "unsrt" | "abbrv" => BibStyle::Numeric,
"alpha" => BibStyle::Alpha,
"apalike" | "natbib" | "chicago" => BibStyle::AuthorYear,
other => BibStyle::Custom(other.to_string()),
};
}
}
if config.files.is_empty() {
None
} else {
Some(config)
}
}
pub fn bibliography_to_typst(config: &BibConfig) -> String {
let mut result = String::new();
if !config.files.is_empty() {
let files: Vec<String> = config
.files
.iter()
.map(|f| {
let f = if f.ends_with(".bib") {
f.clone()
} else {
format!("{}.bib", f)
};
format!("\"{}\"", f)
})
.collect();
result.push_str("#bibliography(");
result.push_str(&files.join(", "));
match &config.style {
BibStyle::AuthorYear => {
result.push_str(", style: \"apa\"");
}
BibStyle::Alpha => {
result.push_str(", style: \"alphanumeric\"");
}
BibStyle::Custom(style) => {
result.push_str(&format!(", style: \"{}\"", style));
}
_ => {}
}
if let Some(ref title) = config.title {
result.push_str(&format!(", title: \"{}\"", title));
}
result.push_str(")\n");
}
result
}
pub fn bibliography_to_latex(config: &BibConfig) -> String {
let mut result = String::new();
let style = match &config.style {
BibStyle::Numeric => "plain",
BibStyle::AuthorYear => "apalike",
BibStyle::Alpha => "alpha",
BibStyle::Custom(s) => s.as_str(),
};
result.push_str(&format!("\\bibliographystyle{{{}}}\n", style));
if !config.files.is_empty() {
let files: Vec<&str> = config
.files
.iter()
.map(|f| f.trim_end_matches(".bib"))
.collect();
result.push_str(&format!("\\bibliography{{{}}}\n", files.join(",")));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_cite() {
let group = parse_latex_citation("\\cite{key1}").unwrap();
assert_eq!(group.citations.len(), 1);
assert_eq!(group.citations[0].key, "key1");
}
#[test]
fn test_parse_multiple_cite() {
let group = parse_latex_citation("\\cite{key1, key2, key3}").unwrap();
assert_eq!(group.citations.len(), 3);
}
#[test]
fn test_parse_citet() {
let group = parse_latex_citation("\\citet{author2020}").unwrap();
assert_eq!(group.citations[0].mode, CitationMode::AuthorInText);
}
#[test]
fn test_parse_typst_citation_is_explicit_only() {
assert!(parse_typst_citation("@author2020").is_none());
let reference = parse_typst_ref("@author2020").unwrap();
assert_eq!(reference.target, "author2020");
assert_eq!(reference.ref_type, ReferenceType::Basic);
}
#[test]
fn test_parse_typst_cite_func() {
let group = parse_typst_citation("#cite(<key>, form: \"prose\")").unwrap();
assert_eq!(group.citations[0].key, "key");
assert_eq!(group.citations[0].mode, CitationMode::AuthorInText);
}
#[test]
fn test_citation_to_latex() {
let citation = Citation::new("test2020".to_string());
let group = CiteGroup::single(citation);
let latex = citation_to_latex(&group);
assert_eq!(latex, "\\cite{test2020}");
}
#[test]
fn test_citation_to_typst() {
let citation = Citation::new("test2020".to_string());
let group = CiteGroup::single(citation);
let typst = citation_to_typst(&group);
assert_eq!(typst, r#"#cite(<test2020>)"#);
}
#[test]
fn test_citation_to_typst_with_prefix_and_suffix() {
let mut group = CiteGroup::single(Citation::new("test2020".to_string()));
group.prefix = Some("see".to_string());
group.suffix = Some("ch. 2".to_string());
let typst = citation_to_typst(&group);
assert_eq!(typst, r#"see #cite(<test2020>, supplement: [ch. 2])"#);
}
#[test]
fn test_citation_to_typst_author_form() {
let citation = Citation::with_mode("test2020".to_string(), CitationMode::NoParen);
let group = CiteGroup::single(citation);
let typst = citation_to_typst(&group);
assert_eq!(typst, r#"#cite(<test2020>, form: "author")"#);
}
#[test]
fn test_parse_ref() {
let reference = parse_latex_ref("\\ref{fig:example}").unwrap();
assert_eq!(reference.target, "fig:example");
assert_eq!(reference.ref_type, ReferenceType::Basic);
}
#[test]
fn test_parse_eqref() {
let reference = parse_latex_ref("\\eqref{eq:main}").unwrap();
assert_eq!(reference.ref_type, ReferenceType::Equation);
}
#[test]
fn test_reference_to_latex() {
let reference = Reference::new("fig:1".to_string());
assert_eq!(reference_to_latex(&reference), "\\ref{fig:1}");
}
#[test]
fn test_reference_to_typst() {
let reference = Reference::new("fig-1".to_string());
assert_eq!(reference_to_typst(&reference), "@fig-1");
}
#[test]
fn test_reference_to_typst_equation_and_page() {
let equation = Reference {
target: "energy".to_string(),
ref_type: ReferenceType::Equation,
};
assert_eq!(reference_to_typst(&equation), "@eq-energy");
let page = Reference {
target: "fig-one".to_string(),
ref_type: ReferenceType::Page,
};
assert_eq!(
reference_to_typst(&page),
"#locate(loc => {@fig-one.page()})"
);
}
#[test]
fn test_label_conversions() {
assert_eq!(label_to_latex("sec:intro"), "\\label{sec:intro}");
assert_eq!(label_to_typst("sec-intro"), "<sec-intro>");
}
#[test]
fn test_parse_latex_label() {
let label = parse_latex_label("\\label{fig:example}").unwrap();
assert_eq!(label, "fig:example");
}
#[test]
fn test_parse_typst_label() {
let label = parse_typst_label("<fig-example>").unwrap();
assert_eq!(label, "fig-example");
}
#[test]
fn test_bibliography_parsing() {
let input = r#"
\bibliographystyle{apalike}
\bibliography{refs,more_refs}
"#;
let config = parse_latex_bibliography(input).unwrap();
assert_eq!(config.files.len(), 2);
assert!(matches!(config.style, BibStyle::AuthorYear));
}
#[test]
fn test_bibliography_to_typst() {
let config = BibConfig {
files: vec!["refs.bib".to_string()],
style: BibStyle::AuthorYear,
title: None,
};
let typst = bibliography_to_typst(&config);
assert!(typst.contains("#bibliography"));
assert!(typst.contains("refs.bib"));
}
#[test]
fn test_ref_database() {
let mut db = RefDatabase::new();
db.register_label("fig:1".to_string(), LabelType::Figure);
db.register_label("fig:2".to_string(), LabelType::Figure);
assert!(db.has_label("fig:1"));
assert_eq!(db.get_label("fig:1").unwrap().number, Some("1".to_string()));
assert_eq!(db.get_label("fig:2").unwrap().number, Some("2".to_string()));
}
}