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