Skip to main content

citum_engine/processor/document/
pipeline.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! High-level document-processing orchestration.
7
8use super::output::{
9    HtmlPlaceholderRegistry, RenderedDocumentBody, append_document_bibliography,
10    bibliography_block_placeholder, render_document_bibliography_block_replacement,
11    rewrite_document_markup_for_typst, stage_document_bibliography_blocks,
12};
13use super::{BibliographyBlock, CitationParser, DocumentFormat, ParsedDocument};
14use crate::error::ProcessorError;
15use crate::processor::Processor;
16use crate::processor::run_state::{FinalizedRun, RunState};
17
18/// Format a bibliography section heading for the trailing-bibliography path.
19///
20/// Produces format-specific H2-level heading markup. HTML content in the
21/// trailing-bibliography path is inserted after `finalize_html_output`, so
22/// headings must already be HTML — hence the explicit `DocumentFormat::Html`
23/// arm, which emits a pre-escaped `<h2>` element. The heading text is sourced
24/// from YAML/JSON, so `&`, `<`, and `>` are escaped to prevent markup injection.
25fn render_bibliography_section_heading(heading: &str, format: DocumentFormat) -> String {
26    match format {
27        DocumentFormat::Html => {
28            let escaped = heading
29                .replace('&', "&amp;")
30                .replace('<', "&lt;")
31                .replace('>', "&gt;");
32            format!("<h2>{escaped}</h2>\n\n")
33        }
34        DocumentFormat::Latex => format!("\\subsection*{{{heading}}}\n\n"),
35        DocumentFormat::Typst => format!("== {heading}\n\n"),
36        _ => format!("## {heading}\n\n"),
37    }
38}
39
40impl Processor {
41    /// Process citations in a document and append a bibliography.
42    ///
43    /// This is the primary document-level entry point. It:
44    /// 1. Parses the source document using the provided adapter.
45    /// 2. Resolves frontmatter overrides (integral-name policy, bibliography options).
46    /// 3. Chooses a bibliography orchestration path based on frontmatter and document blocks.
47    ///
48    /// # Errors
49    ///
50    /// Returns `ProcessorError::FrontmatterParse` when the document's
51    /// frontmatter fails to parse.
52    #[allow(
53        clippy::string_slice,
54        reason = "parser-guaranteed boundaries and indices"
55    )]
56    pub fn process_document<P, F>(
57        &self,
58        content: &str,
59        parser: &P,
60        format: DocumentFormat,
61    ) -> Result<String, ProcessorError>
62    where
63        P: CitationParser,
64        F: crate::render::format::OutputFormat<Output = String>,
65    {
66        let mut parsed = parser.parse_document(content, &self.locale);
67
68        if let Some(err) = &parsed.frontmatter_error {
69            return Err(ProcessorError::FrontmatterParse(err.clone()));
70        }
71
72        // `options.*` fields take precedence over the legacy top-level fields.
73        let effective_integral_override = parsed
74            .frontmatter_options
75            .as_ref()
76            .and_then(|o| o.integral_name_memory.as_ref())
77            .or(parsed.frontmatter_integral_name_memory.as_ref());
78        let owned_integral =
79            self.processor_with_document_integral_name_override(effective_integral_override);
80
81        // `options.org-abbreviation-memory` takes precedence over the legacy top-level field.
82        let effective_org_override = parsed
83            .frontmatter_options
84            .as_ref()
85            .and_then(|o| o.org_abbreviation_memory.as_ref())
86            .or(parsed.frontmatter_org_abbreviation_memory.as_ref());
87        let owned_org = {
88            let base = owned_integral.as_ref().unwrap_or(self);
89            base.processor_with_document_org_abbreviation_override(effective_org_override)
90        };
91
92        // Apply presentation overrides from the options block.
93        let owned_bib = parsed
94            .frontmatter_options
95            .as_ref()
96            .filter(|o| o.bibliography.is_some() || o.multilingual.is_some())
97            .map(|options| {
98                let base = owned_org
99                    .as_ref()
100                    .or(owned_integral.as_ref())
101                    .unwrap_or(self);
102                base.processor_with_bibliography_override(options)
103            });
104
105        let processor = owned_bib
106            .as_ref()
107            .or(owned_org.as_ref())
108            .or(owned_integral.as_ref())
109            .unwrap_or(self);
110        let run = processor.begin_run();
111        let body = &content[parsed.body_start..];
112        if let Some(groups) = parsed.frontmatter_groups.take() {
113            return Ok(processor.process_document_with_frontmatter_groups::<P, F>(
114                body, parsed, groups, parser, format, run,
115            ));
116        }
117
118        if !parsed.bibliography_blocks.is_empty() {
119            return Ok(processor.process_document_with_bibliography_blocks::<P, F>(
120                body,
121                std::mem::take(&mut parsed.bibliography_blocks),
122                parser,
123                format,
124                run,
125            ));
126        }
127
128        Ok(processor
129            .process_document_with_default_bibliography::<P, F>(body, parsed, parser, format, run))
130    }
131
132    /// Orchestrate document processing with custom frontmatter bibliography groups.
133    ///
134    /// Renders each group as an ordered section via the shared
135    /// [`Processor::render_document_bibliography_blocks`] primitive, then
136    /// appends all sections as a trailing bibliography under the standard
137    /// "Bibliography" heading. Groups with no matching entries are omitted
138    /// silently; references not matched by any group selector are not rendered
139    /// (use a catch-all group with `selector: {}` or a `not:` negation to
140    /// capture unmatched entries).
141    #[allow(
142        clippy::too_many_arguments,
143        reason = "internal helper, all params load-bearing"
144    )]
145    fn process_document_with_frontmatter_groups<P, F>(
146        &self,
147        body: &str,
148        parsed: ParsedDocument,
149        groups: Vec<citum_schema::grouping::BibliographyGroup>,
150        parser: &P,
151        format: DocumentFormat,
152        run: RunState,
153    ) -> String
154    where
155        P: CitationParser,
156        F: crate::render::format::OutputFormat<Output = String>,
157    {
158        self.render_document_with_trailing_bibliography::<P, F, _>(
159            body,
160            parsed,
161            parser,
162            format,
163            run,
164            |processor, run| {
165                let rendered_blocks =
166                    processor.render_document_bibliography_blocks::<F>(&groups, None, None, run);
167                let mut output = String::new();
168                for block in rendered_blocks {
169                    if block.entries.is_empty() {
170                        continue;
171                    }
172                    if !output.is_empty() {
173                        output.push_str("\n\n");
174                    }
175                    if let Some(heading) = block.heading {
176                        output.push_str(&render_bibliography_section_heading(&heading, format));
177                    }
178                    output.push_str(&block.body);
179                }
180                output
181            },
182        )
183    }
184
185    /// Orchestrate document processing with explicit bibliography blocks.
186    fn process_document_with_bibliography_blocks<P, F>(
187        &self,
188        body: &str,
189        blocks: Vec<BibliographyBlock>,
190        parser: &P,
191        format: DocumentFormat,
192        mut run: RunState,
193    ) -> String
194    where
195        P: CitationParser,
196        F: crate::render::format::OutputFormat<Output = String>,
197    {
198        let staged = stage_document_bibliography_blocks(body, &blocks);
199        let parsed_staged = parser.parse_document(&staged, &self.locale);
200        let mut rendered = self.render_document_body::<F>(&staged, parsed_staged, format, &mut run);
201        let run = run.finalize();
202        self.replace_document_bibliography_blocks::<F>(&mut rendered, &blocks, format, &run);
203        self.finalize_document_output::<P, F>(parser, format, rendered)
204    }
205
206    /// Process a document with bibliography groups supplied by the caller.
207    ///
208    /// Unlike the fenced-div path, the caller provides an ordered slice of
209    /// [`citum_schema::grouping::BibliographyGroup`]s (e.g. from `--bibliography-blocks` on the CLI or
210    /// a session-level block list) rather than `:::bibliography{...}` markers
211    /// embedded in the document. Citations are processed exactly as in
212    /// `process_document`; the trailing bibliography is replaced by one
213    /// rendered section per supplied group using the shared
214    /// `render_document_bibliography_blocks` primitive.
215    pub fn process_document_with_caller_blocks<P, F>(
216        &self,
217        content: &str,
218        blocks: &[citum_schema::grouping::BibliographyGroup],
219        parser: &P,
220        format: DocumentFormat,
221    ) -> String
222    where
223        P: CitationParser,
224        F: crate::render::format::OutputFormat<Output = String>,
225    {
226        let mut run = self.begin_run();
227        let parsed = parser.parse_document(content, &self.locale);
228        let body = content.get(parsed.body_start..).unwrap_or(content);
229        let mut rendered = self.render_document_body::<F>(body, parsed, format, &mut run);
230        let run = run.finalize();
231        // Render ordered sectional blocks via the unified primitive.
232        let rendered_groups =
233            self.render_document_bibliography_blocks::<F>(blocks, None, None, &run);
234        for rendered_group in rendered_groups {
235            let section = render_document_bibliography_block_replacement(
236                rendered.placeholders.as_mut(),
237                format,
238                rendered_group.heading,
239                rendered_group.body,
240            );
241            rendered.content.push_str("\n\n");
242            rendered.content.push_str(&section);
243        }
244        self.finalize_document_output::<P, F>(parser, format, rendered)
245    }
246
247    /// Orchestrate document processing with the default trailing bibliography.
248    fn process_document_with_default_bibliography<P, F>(
249        &self,
250        body: &str,
251        parsed: ParsedDocument,
252        parser: &P,
253        format: DocumentFormat,
254        run: RunState,
255    ) -> String
256    where
257        P: CitationParser,
258        F: crate::render::format::OutputFormat<Output = String>,
259    {
260        self.render_document_with_trailing_bibliography::<P, F, _>(
261            body,
262            parsed,
263            parser,
264            format,
265            run,
266            |p: &super::super::Processor, run| {
267                p.render_document_bibliography::<F>(true, None, None, run)
268                    .content
269            },
270        )
271    }
272
273    /// Generic helper for rendering document body + trailing bibliography.
274    ///
275    /// Owns `run` for the whole body-then-bibliography sequence: the body is
276    /// rendered while `run` accumulates cite-order state (`&mut RunState`),
277    /// then `run` is finalized once, and `render_bibliography` receives the
278    /// resulting `&FinalizedRun` — so bibliography rendering always sees the
279    /// complete state left by every citation in the body.
280    fn render_document_with_trailing_bibliography<P, F, B>(
281        &self,
282        body: &str,
283        parsed: ParsedDocument,
284        parser: &P,
285        format: DocumentFormat,
286        mut run: RunState,
287        render_bibliography: B,
288    ) -> String
289    where
290        P: CitationParser,
291        F: crate::render::format::OutputFormat<Output = String>,
292        B: FnOnce(&Self, &FinalizedRun) -> String,
293    {
294        let mut rendered = self.render_document_body::<F>(body, parsed, format, &mut run);
295        let run = run.finalize();
296        let bibliography = render_bibliography(self, &run);
297        append_document_bibliography(&mut rendered, format, bibliography);
298        self.finalize_document_output::<P, F>(parser, format, rendered)
299    }
300
301    /// Render the citation-annotated document body.
302    ///
303    /// Governs the choice between note-style and inline-style processing,
304    /// and handles placeholder registration for format finalization.
305    /// HTML and terminal formats (Typst, LaTeX) both use the placeholder path
306    /// so that body markup can be converted after citations are spliced in.
307    fn render_document_body<F>(
308        &self,
309        content: &str,
310        parsed: ParsedDocument,
311        format: DocumentFormat,
312        run: &mut RunState,
313    ) -> RenderedDocumentBody
314    where
315        F: crate::render::format::OutputFormat<Output = String>,
316    {
317        if matches!(format, DocumentFormat::Html) {
318            let mut placeholders = HtmlPlaceholderRegistry::default();
319            let content = if self.is_note_style() {
320                self.process_note_document_html(content, parsed, &mut placeholders, run)
321            } else {
322                self.process_inline_document_html(content, parsed, &mut placeholders, run)
323            };
324            return RenderedDocumentBody {
325                content,
326                placeholders: Some(placeholders),
327                trailing: None,
328            };
329        }
330
331        // Terminal formats (Typst, LaTeX) need the same placeholder flow so
332        // the body markup can be converted to the target format after citations
333        // are replaced with NUL-token placeholders. This is a converted-output
334        // path, not passthrough; passthrough is limited to Plain/Djot/Markdown.
335        if matches!(format, DocumentFormat::Typst | DocumentFormat::Latex) {
336            let mut placeholders = HtmlPlaceholderRegistry::default();
337            // Note styles still emit source footnote syntax that the terminal
338            // body renderer does not yet model, so keep that narrow legacy
339            // exception isolated from author-date terminal conversion.
340            let content = if self.is_note_style() {
341                self.process_note_document::<F>(content, parsed, run)
342            } else {
343                self.process_inline_document_with_placeholders::<F>(
344                    content,
345                    parsed,
346                    &mut placeholders,
347                    run,
348                )
349            };
350            return RenderedDocumentBody {
351                content,
352                placeholders: if self.is_note_style() {
353                    None
354                } else {
355                    Some(placeholders)
356                },
357                trailing: None,
358            };
359        }
360
361        let content = if self.is_note_style() {
362            self.process_note_document::<F>(content, parsed, run)
363        } else {
364            self.process_inline_document::<F>(content, parsed, run)
365        };
366
367        RenderedDocumentBody {
368            content,
369            placeholders: None,
370            trailing: None,
371        }
372    }
373
374    /// Splice `F`-rendered citations into document markup using NUL placeholders.
375    ///
376    /// Mirrors `process_inline_document_html` but renders citations using the
377    /// generic format `F` (e.g. Typst or LaTeX) instead of HTML. The
378    /// surrounding body markup still contains the source syntax at this point;
379    /// `finalize_document_output` converts it after placeholder substitution.
380    #[allow(
381        clippy::string_slice,
382        reason = "parser-guaranteed boundaries and indices"
383    )]
384    fn process_inline_document_with_placeholders<F>(
385        &self,
386        content: &str,
387        parsed: ParsedDocument,
388        placeholders: &mut HtmlPlaceholderRegistry,
389        run: &mut RunState,
390    ) -> String
391    where
392        F: crate::render::format::OutputFormat<Output = String>,
393    {
394        let mut result = String::new();
395        let mut last_idx = 0;
396        let normalized = self.normalize_integral_name_citations(&parsed, run);
397
398        for (parsed, citation) in parsed.citations.iter().zip(normalized) {
399            debug_assert!(
400                parsed.end <= content.len(),
401                "citation offset {} exceeds body length {}; parser must emit \
402                 body-relative offsets",
403                parsed.end,
404                content.len()
405            );
406            result.push_str(&content[last_idx..parsed.start]);
407            match self.process_citation_with_format::<F>(&citation, run) {
408                Ok(rendered) => result.push_str(&placeholders.push_inline(rendered)),
409                Err(_) => result.push_str(&content[parsed.start..parsed.end]),
410            }
411            last_idx = parsed.end;
412        }
413
414        result.push_str(&content[last_idx..]);
415        result
416    }
417
418    /// Splice rendered citations into document markup for non-note styles.
419    #[allow(
420        clippy::string_slice,
421        reason = "parser-guaranteed boundaries and indices"
422    )]
423    fn process_inline_document<F>(
424        &self,
425        content: &str,
426        parsed: ParsedDocument,
427        run: &mut RunState,
428    ) -> String
429    where
430        F: crate::render::format::OutputFormat<Output = String>,
431    {
432        let mut result = String::new();
433        let mut last_idx = 0;
434        let normalized = self.normalize_integral_name_citations(&parsed, run);
435
436        for (parsed, citation) in parsed.citations.iter().zip(normalized) {
437            debug_assert!(
438                parsed.end <= content.len(),
439                "citation offset {} exceeds body length {}; parser must emit \
440                 body-relative offsets",
441                parsed.end,
442                content.len()
443            );
444            result.push_str(&content[last_idx..parsed.start]);
445            match self.process_citation_with_format::<F>(&citation, run) {
446                Ok(rendered) => result.push_str(&rendered),
447                Err(_) => result.push_str(&content[parsed.start..parsed.end]),
448            }
449            last_idx = parsed.end;
450        }
451
452        result.push_str(&content[last_idx..]);
453        result
454    }
455
456    /// Splice HTML-rendered citations into document markup using placeholders.
457    #[allow(
458        clippy::string_slice,
459        reason = "parser-guaranteed boundaries and indices"
460    )]
461    fn process_inline_document_html(
462        &self,
463        content: &str,
464        parsed: ParsedDocument,
465        placeholders: &mut HtmlPlaceholderRegistry,
466        run: &mut RunState,
467    ) -> String {
468        let mut result = String::new();
469        let mut last_idx = 0;
470        let normalized = self.normalize_integral_name_citations(&parsed, run);
471
472        for (parsed, citation) in parsed.citations.iter().zip(normalized) {
473            debug_assert!(
474                parsed.end <= content.len(),
475                "citation offset {} exceeds body length {}; parser must emit \
476                 body-relative offsets",
477                parsed.end,
478                content.len()
479            );
480            result.push_str(&content[last_idx..parsed.start]);
481            match self.process_citation_with_format::<crate::render::html::Html>(&citation, run) {
482                Ok(rendered) => result.push_str(&placeholders.push_inline(rendered)),
483                Err(_) => result.push_str(&content[parsed.start..parsed.end]),
484            }
485            last_idx = parsed.end;
486        }
487
488        result.push_str(&content[last_idx..]);
489        result
490    }
491
492    /// Replace bibliography block placeholders with rendered content.
493    fn replace_document_bibliography_blocks<F>(
494        &self,
495        rendered: &mut RenderedDocumentBody,
496        blocks: &[BibliographyBlock],
497        format: DocumentFormat,
498        run: &FinalizedRun,
499    ) where
500        F: crate::render::format::OutputFormat<Output = String>,
501    {
502        let groups: Vec<_> = blocks.iter().map(|b| b.group.clone()).collect();
503        let rendered_groups =
504            self.render_document_bibliography_blocks::<F>(&groups, None, None, run);
505        for (index, rendered_group) in rendered_groups.into_iter().enumerate() {
506            let placeholder = bibliography_block_placeholder(index);
507            let replacement = render_document_bibliography_block_replacement(
508                rendered.placeholders.as_mut(),
509                format,
510                rendered_group.heading,
511                rendered_group.body,
512            );
513            rendered.content = rendered.content.replace(&placeholder, &replacement);
514        }
515    }
516
517    /// Perform final document rewrites and resolve placeholders.
518    ///
519    /// For HTML: converts body markup via `finalize_html_output` then
520    /// substitutes citation placeholder tokens.
521    /// For Typst/LaTeX: converts body markup via `render_body_markup::<F>`
522    /// then substitutes citation placeholder tokens.
523    /// For other formats: returns the spliced content as-is.
524    fn finalize_document_output<P, F>(
525        &self,
526        parser: &P,
527        format: DocumentFormat,
528        rendered: RenderedDocumentBody,
529    ) -> String
530    where
531        P: CitationParser,
532        F: crate::render::format::OutputFormat<Output = String>,
533    {
534        let mut result = if let Some(placeholders) = rendered.placeholders {
535            let fmt = F::default();
536            let converted = match format {
537                DocumentFormat::Html => parser.finalize_html_output(&rendered.content),
538                DocumentFormat::Typst | DocumentFormat::Latex => {
539                    parser.render_body_markup(&rendered.content, &fmt)
540                }
541                _ => rendered.content,
542            };
543            placeholders.apply(converted)
544        } else {
545            // Passthrough path for Plain/Djot/Markdown, plus the isolated
546            // note-style Typst/LaTeX exception documented in render_document_body.
547            // Keep the heading-rewrite for Typst in case headings came from
548            // bibliography group labels rather than body markup.
549            let content = rewrite_document_markup_for_typst(rendered.content, format);
550            match format {
551                DocumentFormat::Html => parser.finalize_html_output(&content),
552                _ => content,
553            }
554        };
555        // Append any trailing content (e.g. Typst/LaTeX bibliography) that was
556        // deferred so it would not pass through the body markup converter.
557        // Trim the body's trailing whitespace first: the markup renderer may
558        // have added paragraph-separator newlines that would otherwise double
559        // the leading newlines of the bibliography heading.
560        if let Some(tail) = rendered.trailing {
561            let trimmed = result.trim_end_matches('\n');
562            result = format!("{trimmed}{tail}");
563        }
564        result
565    }
566}