1use std::ops::Range;
9
10use super::format::{OutputFormat, QuoteMarks};
11use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
12use citum_schema::template::WrapPunctuation;
13
14#[derive(Default, Clone)]
15pub struct Djot;
17
18impl OutputFormat for Djot {
19 type Output = String;
20
21 fn text(&self, s: &str) -> Self::Output {
22 s.to_string()
24 }
25
26 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
27 items.join(delimiter)
28 }
29
30 fn finish(&self, output: Self::Output) -> String {
31 output
32 }
33
34 fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
35 let marks = "#".repeat(level.max(1) as usize);
36 format!("{marks} {content}\n\n")
37 }
38
39 fn emph(&self, content: Self::Output) -> Self::Output {
40 if content.is_empty() {
41 return content;
42 }
43 format!("_{content}_")
44 }
45
46 fn strong(&self, content: Self::Output) -> Self::Output {
47 if content.is_empty() {
48 return content;
49 }
50 format!("*{content}*")
51 }
52
53 fn small_caps(&self, content: Self::Output) -> Self::Output {
54 if content.is_empty() {
55 return content;
56 }
57 format!("[{content}]{{.small-caps}}")
58 }
59
60 fn superscript(&self, content: Self::Output) -> Self::Output {
61 if content.is_empty() {
62 return content;
63 }
64 format!("[{content}]{{.superscript}}")
65 }
66
67 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
68 if content.is_empty() {
69 return content;
70 }
71 let (open, close) = marks.for_depth(0);
72 format!("{open}{content}{close}")
73 }
74
75 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
76 format!("{prefix}{content}{suffix}")
77 }
78
79 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
80 format!("{prefix}{content}{suffix}")
81 }
82
83 fn wrap_punctuation(
84 &self,
85 wrap: &WrapPunctuation,
86 content: Self::Output,
87 marks: &QuoteMarks,
88 ) -> Self::Output {
89 match wrap {
90 WrapPunctuation::Parentheses => format!("({content})"),
91 WrapPunctuation::Brackets => format!("[{content}]"),
92 WrapPunctuation::Quotes => self.quote(content, marks),
93 }
94 }
95
96 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
97 if content.is_empty() {
98 return content;
99 }
100 format!("[{content}]{{.{class}}}")
101 }
102
103 fn annotation(&self, content: Self::Output) -> Self::Output {
104 if content.is_empty() {
105 return content;
106 }
107 format!("\n\n::: citum-annotation\n{content}\n:::")
108 }
109
110 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
111 if content.is_empty() {
112 return content;
113 }
114 format!("[{content}]({url})")
115 }
116
117 fn entry(
118 &self,
119 _id: &str,
120 content: Self::Output,
121 url: Option<&str>,
122 _metadata: &super::format::ProcEntryMetadata,
123 ) -> Self::Output {
124 if let Some(u) = url {
125 self.link(u, content)
126 } else {
127 content
128 }
129 }
130
131 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
142 let mut runs = RunBuilder::default();
143 let chars: Vec<(usize, char)> = fragment.char_indices().collect();
144 let mut i = 0;
145 let mut pending_close: Option<usize> = None;
146 while let Some(&(pos, ch)) = chars.get(i) {
147 match ch {
148 '_' | '*' => i += 1,
149 '[' => {
150 let close_i = find_matching(&chars, i, '[', ']', false);
151 let after = close_i.and_then(|c| chars.get(c + 1).map(|&(_, next)| next));
152 if matches!(after, Some('{' | '(')) {
153 pending_close = close_i;
154 i += 1;
155 } else {
156 runs.push_visible(pos, pos + 1);
157 i += 1;
158 }
159 }
160 ']' => {
161 if pending_close == Some(i) {
162 pending_close = None;
163 let mut j = i + 1;
164 match chars.get(j).map(|&(_, c)| c) {
165 Some('{') => j = skip_balanced(&chars, j, '{', '}', false),
166 Some('(') => j = skip_balanced(&chars, j, '(', ')', false),
167 _ => {}
168 }
169 i = j;
170 } else {
171 runs.push_visible(pos, pos + 1);
172 i += 1;
173 }
174 }
175 _ => {
176 runs.push_visible(pos, pos + ch.len_utf8());
177 i += 1;
178 }
179 }
180 }
181 runs.finish()
182 }
183}
184
185#[cfg(test)]
186#[allow(
187 clippy::unwrap_used,
188 clippy::expect_used,
189 clippy::panic,
190 clippy::indexing_slicing,
191 clippy::todo,
192 clippy::unimplemented,
193 clippy::unreachable,
194 clippy::get_unwrap,
195 reason = "Panicking is acceptable and often desired in tests."
196)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn test_djot_emph() {
202 let fmt = Djot;
203
204 for (input, expected) in [("", ""), ("text", "_text_")] {
205 assert_eq!(fmt.emph(input.to_string()), expected);
206 }
207 }
208
209 #[test]
210 fn test_djot_strong() {
211 let fmt = Djot;
212
213 for (input, expected) in [("", ""), ("text", "*text*")] {
214 assert_eq!(fmt.strong(input.to_string()), expected);
215 }
216 }
217
218 #[test]
219 fn test_djot_small_caps() {
220 let fmt = Djot;
221
222 for (input, expected) in [("", ""), ("text", "[text]{.small-caps}")] {
223 assert_eq!(fmt.small_caps(input.to_string()), expected);
224 }
225 }
226
227 #[test]
228 fn test_djot_quote() {
229 let fmt = Djot;
230 let marks = QuoteMarks::default();
231
232 for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
233 assert_eq!(fmt.quote(input.to_string(), &marks), expected);
234 }
235 }
236
237 #[test]
238 fn test_djot_quote_uses_locale_marks() {
239 let fmt = Djot;
240 let marks = QuoteMarks {
241 open: "\u{ab}".to_string(),
242 close: "\u{bb}".to_string(),
243 open_inner: "\u{2039}".to_string(),
244 close_inner: "\u{203a}".to_string(),
245 };
246
247 assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
248 }
249
250 #[test]
251 fn test_djot_semantic() {
252 let fmt = Djot;
253
254 for (input, class, expected) in [("", "author", ""), ("text", "author", "[text]{.author}")]
255 {
256 assert_eq!(fmt.semantic(class, input.to_string()), expected);
257 }
258 }
259
260 #[test]
261 fn test_djot_link() {
262 let fmt = Djot;
263
264 for (input, url, expected) in [
265 ("", "https://example.com", ""),
266 ("text", "https://example.com", "[text](https://example.com)"),
267 ] {
268 assert_eq!(fmt.link(url, input.to_string()), expected);
269 }
270 }
271
272 #[test]
273 fn test_djot_wrap_punctuation() {
274 let fmt = Djot;
275 let marks = QuoteMarks::default();
276
277 for (wrap, input, expected) in [
278 (WrapPunctuation::Parentheses, "text", "(text)"),
279 (WrapPunctuation::Brackets, "text", "[text]"),
280 (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
281 ] {
282 assert_eq!(
283 fmt.wrap_punctuation(&wrap, input.to_string(), &marks),
284 expected
285 );
286 }
287 }
288
289 #[test]
290 fn visible_text_strips_emph_and_strong_delimiters() {
291 let fmt = Djot;
292 assert_eq!(fmt.visible_text("_Title._"), "Title.");
293 assert_eq!(fmt.visible_text("*Title.*"), "Title.");
294 }
295
296 #[test]
297 fn visible_text_strips_semantic_span_attributes() {
298 let fmt = Djot;
299 assert_eq!(fmt.visible_text("[Smith]{.author}"), "Smith");
300 }
301
302 #[test]
303 fn visible_text_hides_link_url_keeps_text() {
304 let fmt = Djot;
305 assert_eq!(
306 fmt.visible_text("[Example](https://example.com/a.b)"),
307 "Example"
308 );
309 }
310
311 #[test]
312 fn visible_text_keeps_literal_wrap_brackets_visible() {
313 let fmt = Djot;
314 assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
316 }
317}