Skip to main content

latexml/
bib_session.rs

1//! The recursive BibTeX session behind post-processing's bibliography.
2//!
3//! Port of `LaTeXML::Post::MakeBibliography::convertBibliography`
4//! (`MakeBibliography.pm` L171-242): a raw `.bib` is not parsed by a bespoke
5//! reader, it is **converted** — by the same engine, through `BibTeX.pool`, with
6//! the article's own class and packages preloaded so the fields mean what the
7//! article meant.
8//!
9//! It lives here rather than in `latexml_post` because a conversion needs
10//! `convert_document`, which needs this crate's model loader; `latexml_post`
11//! depending on `latexml_oxide` would be a cycle. `latexml_post` declares the
12//! hook, this module fills it in — see [`install`].
13
14use latexml_core::{
15  Core, CoreOptions,
16  binding::content::{InputDefinitionOptions, input_definitions},
17  common::{DigestionMode, error::*},
18  s,
19};
20// The post-phase diagnostic macros: plain reporters, with none of the
21// too-many-errors escalation the engine-side `Error!` carries. This module runs
22// inside post-processing, and an inner-session failure must not itself become a
23// document Fatal — the failure is already reported as an error, and Perl's
24// convertBibliography likewise just returns empty-handed (L240-242).
25use latexml_post::{
26  Error, Info,
27  document::{PostDocument, PostDocumentOptions},
28  make_bibliography::{BibConversionRequest, RawBibSource, set_bib_converter},
29};
30
31use crate::core_interface::{DigestionAPI, DigestionOptions};
32
33/// The commands a real `.bst`-generated `.bbl` provides before its entries.
34///
35/// BibTeX copies a field into the `.bbl`, whose preamble opens with a block of
36/// `\providecommand`s so `\url{…}` in a `note` renders even in a document that
37/// loads neither `hyperref` nor `url.sty`. We convert the `.bib` directly, one
38/// step earlier, so without this block we raise `undefined:\url` where real
39/// LaTeX renders text.
40///
41/// It is also what makes the percent-encoded-URL family work at all, and that is
42/// the deeper reason to keep it: `\url` is declared with a **Semiverbatim**
43/// parameter, so once it is defined, `\url{…/B130936%20Law%20of%20War.pdf}` reads
44/// its argument with `%` at catcode 12. Measured against same-host Perl on the
45/// same input: with `url.sty` present both engines emit 0 errors and identical
46/// XML; without it both mis-read the `%`. So this block is not a Rust patch
47/// papering over the engine — it is the condition under which the engine's own
48/// parameter types can act.
49///
50/// `\providecommand` is the right primitive precisely because it defers: a
51/// document that DOES load `hyperref` keeps hyperref's `\url`. Bodies mirror the
52/// conventional `.bbl` definitions (plain/natbib/revtex all ship these shapes).
53const BBL_STANDARD_FALLBACKS: &str = concat!(
54  r"\providecommand{\url}[1]{\texttt{#1}}",
55  r"\providecommand{\urlprefix}{URL }",
56  r"\providecommand{\doi}[1]{doi:#1}",
57  r"\providecommand{\bibinfo}[2]{#2}",
58  r"\providecommand{\eprint}[2][]{\url{#2}}",
59  r"\providecommand{\selectlanguage}[1]{\relax}",
60  r"\providecommand{\newblock}{}",
61);
62
63/// Install this crate's recursive-session implementation into `latexml_post`.
64///
65/// Call once per thread before running the post-processing pipeline.
66pub fn install() { set_bib_converter(convert); }
67
68/// Assemble the sources into one conversion payload.
69///
70/// Perl `MakeBibliography.pm` L147-163: a lone file is converted as-is (so
71/// locators name it), and several are concatenated into one `literal:` payload —
72/// each followed by `%\n` — so that a single session sees them all and `@string`
73/// macros defined in one file are in scope for the next.
74///
75/// The file reading deliberately does NOT use `read_to_string`: a strict UTF-8
76/// read hard-errors on the first stray Cp1252 byte and would silently lose the
77/// whole bibliography (witness 2605.00490). `decode_input_bytes` is the shared
78/// decoder — UTF-8, else a lossless Latin-1 passthrough — that both `.bib` read
79/// sites already use.
80fn payload(sources: &[RawBibSource]) -> Option<String> {
81  if let [RawBibSource::Path(path)] = sources {
82    return Some(path.clone());
83  }
84  let mut combined = String::from("literal:");
85  for source in sources {
86    match source {
87      RawBibSource::Literal(data) => combined.push_str(data),
88      RawBibSource::Path(path) => match std::fs::read(path) {
89        Ok(bytes) => combined.push_str(&latexml_core::mouth::decode_input_bytes(&bytes)),
90        // Perl L162: `Info("open", ...)` — one unreadable file must not sink
91        // the bibliographies that ARE readable.
92        Err(e) => Info!(
93          "open",
94          path,
95          "Couldn't open bibliography file {}: {}",
96          path,
97          e
98        ),
99      },
100    }
101    combined.push_str("%\n");
102  }
103  if combined == "literal:" {
104    None
105  } else {
106    Some(combined)
107  }
108}
109
110/// Make this thread's engine ready to digest BibTeX, **keeping the document's
111/// own definitions**.
112///
113/// Perl spins a brand-new converter and rebuilds an approximation of the
114/// article's environment from `[options]class.cls` + `[options]pkg.sty` preloads
115/// recovered from the `<?latexml?>` PIs — and says so in a comment right above
116/// `convertBibliography` (`MakeBibliography.pm` L174-177):
117///
118/// > In general, it should use the same STATE information as the main document,
119/// > so IF that state is still around, we should use it! That's for future
120/// > enhancement!!!
121///
122/// It is still around: post-processing runs in the same thread, right after
123/// digestion. Using it is the enhancement Perl asks for, and it is strictly more
124/// faithful to the *document*: preloads recover the class and packages but not
125/// the author's own preamble macros, which is precisely what a `howpublished` or
126/// `note` field tends to use (Perl's L184 comment — "custom macros often used in
127/// e.g. howpublished field" — is the stated reason for preloading at all).
128///
129/// It also avoids two concrete hazards. A second `initialize_singletons` on one
130/// thread re-runs the pools' `Let!`s (WISDOM #67 — that is a *non-unwinding*
131/// abort, not a catchable failure), and it resets the `REPORT` the outer
132/// document is still counting into, so Perl's `MergeStatus` would have to be
133/// re-implemented by hand. Sharing the state makes both moot.
134///
135/// So all this needs is `BibTeX.pool` on top. The preloads are still consulted —
136/// but only for the fallback path below, where there is no live session to
137/// share (a `--post`-only run over an existing `.xml`, where nothing was ever
138/// digested in this process).
139fn ensure_session(request: &BibConversionRequest) -> Result<()> {
140  if latexml_core::state::has_value("LATEXML_VERSION") {
141    // Live session: add the pool the document never needed. Idempotent —
142    // `input_definitions` early-returns on its own `_loaded` flag.
143    input_definitions("BibTeX", InputDefinitionOptions {
144      extension: Some("pool".into()),
145      ..InputDefinitionOptions::default()
146    })
147  } else {
148    // No conversion happened in this process (XML input). This IS the thread's
149    // first initialization, so it is the safe case for `initialize_singletons`,
150    // and Perl's preload reconstruction is exactly right here.
151    let mut preloads = vec![s!("TeX.pool"), s!("BibTeX.pool")];
152    preloads.extend(request.preloads.iter().cloned());
153    let mut core = Core::new(CoreOptions {
154      preload: Some(request.preloads.clone()),
155      search_paths: Some(request.search_paths.clone()),
156      ..CoreOptions::default()
157    });
158    core.initialize_singletons(preloads)
159  }
160}
161
162/// Ensure `\url` exists, and exists with its REAL definition.
163///
164/// [`BBL_STANDARD_FALLBACKS`] alone would define it as
165/// `\providecommand{\url}[1]{\texttt{#1}}` — an ordinary `{}` argument, which
166/// renders but gives no catcode protection. Its argument is then read with `%`
167/// at catcode 14, so a percent-encoded URL (`…/B130936%20Law%20of%20War.pdf` —
168/// entirely routine in a `howpublished`) is truncated at the first `%`, taking
169/// the closing brace with it.
170///
171/// Loading LaTeXML's own `url.sty` binding instead gives `\url` the
172/// **Semiverbatim** parameter it is supposed to have (`url_sty.rs`), and the
173/// truncation disappears. That is what a document which actually uses `\url`
174/// would have had: a `.bst` emitting `\url{…}` into the `.bbl` assumes the
175/// document loads `url` or `hyperref`, and nearly all do.
176///
177/// SURPASS-PERL, and deliberately: same-host Perl truncates this input (measured
178/// — `latexmlc` renders `https://example.org/B130936` and spills the raw entry
179/// into the note), because it defines nothing here at all. So does pdflatex
180/// without a url package. This continues the beyond-Perl content-recovery line
181/// already taken by the `.bbl` fallback block itself; it does not silence any
182/// diagnostic Perl raises for a *correct* document, since a document that loads
183/// `url`/`hyperref` keeps its own definition — `input_definitions` early-returns
184/// on the `_loaded` flag, and `\providecommand` defers.
185fn provide_url_command() -> Result<()> {
186  if latexml_core::state::lookup_meaning(&latexml_core::T_CS!("\\url")).is_some() {
187    return Ok(());
188  }
189  input_definitions("url", InputDefinitionOptions {
190    extension: Some("sty".into()),
191    handleoptions: true,
192    ..InputDefinitionOptions::default()
193  })
194}
195
196/// Run the recursive session and hand back its document.
197fn convert(request: &BibConversionRequest) -> Option<PostDocument> {
198  let source = payload(&request.sources)?;
199  let stage = if source.starts_with("literal:") {
200    s!("Recursive MakeBibliography Anonymous Bib String")
201  } else {
202    s!("Recursive MakeBibliography {source}")
203  };
204  note_begin(&stage);
205
206  // The document's OWN state, still live, is the session — see the module docs
207  // for why that is both better and cheaper than a fresh one. `Core` is a thin
208  // handle over the thread-local engine (`Core::new` would install a *new*
209  // State and throw the document's away), so it is constructed directly.
210  let mut core = Core {
211    preload: request.preloads.clone(),
212  };
213
214  let result = (|| -> Result<PostDocument> {
215    ensure_session(request)?;
216    // Give the fields what a `.bbl` would have given them, once the session is
217    // ready and before any entry is digested.
218    provide_url_command()?;
219    latexml_core::stomach::digest(latexml_core::mouth::tokenize(BBL_STANDARD_FALLBACKS))?;
220
221    // Digest only what the document cites — a `.bib` is a library, and
222    // `bibtex(1)` reads the `.aux`'s `\citation` records rather than the whole
223    // file. Set around `digest_file` only: the filter lives in the same
224    // thread-local State the outer document uses, so leaving it installed
225    // would leak into any later `.bib` in this process.
226    latexml_engine::pre_bibtex::set_wanted_keys(request.wanted_keys.clone());
227    // Hide the OUTER document's bibliography count for the duration of the
228    // recursive digestion — the one value reusing the live State would
229    // otherwise leak (see the module docs for why the State is shared).
230    // `{bibtex@bibliography}` runs `begin_bibliography_clean`, which does
231    // `n_bibliographies += 1` and names the session's ids
232    // `<docid>bib<radix_alpha(n-1)>` (latex_constructs.rs:2392-2398). Perl
233    // builds a FRESH State per `.bib` (MakeBibliography.pm:181-215), so its
234    // inner session always sees 0 and mints `bib.bibN`; sharing ours made the
235    // FIRST `.bib` of a one-bibliography document mint `biba.bibN` — the
236    // second slot of the multi-bibliography sequence. Post then strips `^bib`
237    // and re-prepends the OUTER bibid (MakeBibliography.pm:407-415), which is
238    // exactly why the inner ids must be `bib`-rooted regardless of which
239    // bibliography they end up in.
240    let outer_n_bibliographies = latexml_core::state::lookup_int("n_bibliographies");
241    latexml_core::state::assign_value(
242      "n_bibliographies",
243      0,
244      Some(latexml_core::state::Scope::Global),
245    );
246    let digested = core.digest_file(source.clone(), DigestionOptions {
247      mode: Some(DigestionMode::BibTeX),
248      noinitialize: Some(true),
249      ..DigestionOptions::default()
250    });
251    latexml_core::state::assign_value(
252      "n_bibliographies",
253      outer_n_bibliographies,
254      Some(latexml_core::state::Scope::Global),
255    );
256    latexml_engine::pre_bibtex::clear_wanted_keys();
257    let digested = digested?;
258    let document = core.convert_document(digested)?;
259    Ok(PostDocument::new(document.document, PostDocumentOptions {
260      source_directory: Some(".".to_string()),
261      ..PostDocumentOptions::default()
262    }))
263  })();
264
265  // Perl `MergeStatus` (L237) ADDS the inner session's tally to the outer
266  // document's (Common/Error.pm L669-686) — a `.bib` that raises errors makes
267  // the document an error document, which is what the corpus signal depends on.
268  // Sharing the live state gives that for free: the counters never left.
269  note_end(&stage);
270
271  match result {
272    Ok(bibdoc) => Some(bibdoc),
273    Err(e) => {
274      Error!(
275        "bibliography",
276        "convert",
277        "Recursive bibliography conversion failed: {}",
278        e
279      );
280      None
281    },
282  }
283}