Skip to main content

citum_engine/render/
org.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Org-mode output format.
7
8use super::format::{OutputFormat, QuoteMarks};
9use citum_schema::template::WrapPunctuation;
10
11/// Renders processed citations and bibliography entries as org-mode markup.
12///
13/// Does not override [`OutputFormat::visible_runs`]: org-mode's `/.../`,
14/// `*...*`, `~...~`, `^...^` markers are unescaped single characters (see
15/// [`OutputFormat::text`] below), so a data field containing a literal
16/// instance of one is already indistinguishable from markup in the rendered
17/// output itself — a lexer here could not resolve that ambiguity any better
18/// than the renderer did. Boundary/dedup logic for this backend falls back
19/// to the raw text default, a known, documented gap (see bean `csl26-ztxq`).
20#[derive(Default, Clone)]
21pub struct OrgOutputFormat;
22
23impl OutputFormat for OrgOutputFormat {
24    type Output = String;
25
26    fn text(&self, s: &str) -> Self::Output {
27        s.to_string()
28    }
29
30    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
31        items.join(delimiter)
32    }
33
34    fn finish(&self, output: Self::Output) -> String {
35        output
36    }
37
38    /// Render a heading using org-mode stars (`*`, `**`, ...).
39    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
40        let marks = "*".repeat(level.max(1) as usize);
41        format!("{marks} {content}\n\n")
42    }
43
44    /// Render content with emphasis (italics in org-mode: /text/).
45    fn emph(&self, content: Self::Output) -> Self::Output {
46        if content.is_empty() {
47            return content;
48        }
49        format!("/{content}/")
50    }
51
52    /// Render content with strong emphasis (bold in org-mode: *text*).
53    fn strong(&self, content: Self::Output) -> Self::Output {
54        if content.is_empty() {
55            return content;
56        }
57        format!("*{content}*")
58    }
59
60    /// Render content in small capitals (org-mode uses ~text~).
61    fn small_caps(&self, content: Self::Output) -> Self::Output {
62        if content.is_empty() {
63            return content;
64        }
65        format!("~{content}~")
66    }
67
68    fn superscript(&self, content: Self::Output) -> Self::Output {
69        if content.is_empty() {
70            return content;
71        }
72        format!("^{content}^")
73    }
74
75    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
76        if content.is_empty() {
77            return content;
78        }
79        // Org-mode doesn't have native quotation marks; use the locale marks as-is.
80        let (open, close) = marks.for_depth(0);
81        format!("{open}{content}{close}")
82    }
83
84    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
85        format!("{prefix}{content}{suffix}")
86    }
87
88    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
89        format!("{prefix}{content}{suffix}")
90    }
91
92    fn wrap_punctuation(
93        &self,
94        wrap: &WrapPunctuation,
95        content: Self::Output,
96        marks: &QuoteMarks,
97    ) -> Self::Output {
98        match wrap {
99            WrapPunctuation::Parentheses => format!("({content})"),
100            WrapPunctuation::Brackets => format!("[{content}]"),
101            WrapPunctuation::Quotes => self.quote(content, marks),
102        }
103    }
104
105    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
106        // Org-mode doesn't support semantic classes; just return the content
107        content
108    }
109
110    fn annotation(&self, content: Self::Output) -> Self::Output {
111        if content.is_empty() {
112            return content;
113        }
114        format!(
115            "\n\n#+begin_citum_annotation\n{}\n#+end_citum_annotation",
116            content
117        )
118    }
119
120    /// Render a hyperlink in org-mode format: `[[url][text]]`
121    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
122        format!("[[{url}][{content}]]")
123    }
124
125    fn entry(
126        &self,
127        _id: &str,
128        content: Self::Output,
129        _url: Option<&str>,
130        _metadata: &super::format::ProcEntryMetadata,
131    ) -> Self::Output {
132        content
133    }
134}
135
136#[cfg(test)]
137#[allow(
138    clippy::unwrap_used,
139    clippy::expect_used,
140    clippy::panic,
141    clippy::indexing_slicing,
142    clippy::todo,
143    clippy::unimplemented,
144    clippy::unreachable,
145    clippy::get_unwrap,
146    reason = "Panicking is acceptable and often desired in tests."
147)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_org_emph() {
153        let fmt = OrgOutputFormat;
154        let result = fmt.emph(fmt.text("italic text"));
155        assert_eq!(result, "/italic text/");
156    }
157
158    #[test]
159    fn test_org_strong() {
160        let fmt = OrgOutputFormat;
161        let result = fmt.strong(fmt.text("bold text"));
162        assert_eq!(result, "*bold text*");
163    }
164
165    #[test]
166    fn test_org_small_caps() {
167        let fmt = OrgOutputFormat;
168        let result = fmt.small_caps(fmt.text("small caps"));
169        assert_eq!(result, "~small caps~");
170    }
171
172    #[test]
173    fn test_org_link() {
174        let fmt = OrgOutputFormat;
175        let result = fmt.link("https://example.com", fmt.text("Example"));
176        assert_eq!(result, "[[https://example.com][Example]]");
177    }
178
179    #[test]
180    fn test_org_empty_content() {
181        let fmt = OrgOutputFormat;
182        let result = fmt.emph(fmt.text(""));
183        assert_eq!(result, "");
184    }
185}