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