1use anyhow::{anyhow, bail};
2use std::{
3 borrow::Cow,
4 io::{BufRead, Lines},
5 iter::Peekable,
6};
7
8const LISTING_DELIMITER: &str = "----";
9const IMAGE_BLOCK_PREFIX: &str = "image::";
10const VIDEO_BLOCK_PREFIX: &str = "video::";
11
12struct Converter<'a, 'b, R: BufRead> {
13 iter: &'a mut Peekable<Lines<R>>,
14 output: &'b mut String,
15}
16
17impl<'a, 'b, R: BufRead> Converter<'a, 'b, R> {
18 fn new(iter: &'a mut Peekable<Lines<R>>, output: &'b mut String) -> Self {
19 Self { iter, output }
20 }
21
22 fn process(&mut self) -> anyhow::Result<()> {
23 self.process_document_header()?;
24 self.skip_blank_lines()?;
25 self.output.push('\n');
26
27 loop {
28 let line = self.iter.peek().unwrap().as_deref().map_err(|e| anyhow!("{e}"))?;
29 if get_title(line).is_some() {
30 let line = self.iter.next().unwrap().unwrap();
31 let (level, title) = get_title(&line).unwrap();
32 self.write_title(level, title);
33 } else if get_list_item(line).is_some() {
34 self.process_list()?;
35 } else if line.starts_with('[') {
36 self.process_source_code_block(0)?;
37 } else if line.starts_with(LISTING_DELIMITER) {
38 self.process_listing_block(None, 0)?;
39 } else if line.starts_with('.') {
40 self.process_block_with_title(0)?;
41 } else if line.starts_with(IMAGE_BLOCK_PREFIX) {
42 self.process_image_block(None, 0)?;
43 } else if line.starts_with(VIDEO_BLOCK_PREFIX) {
44 self.process_video_block(None, 0)?;
45 } else {
46 self.process_paragraph(0, |line| line.is_empty())?;
47 }
48
49 self.skip_blank_lines()?;
50 if self.iter.peek().is_none() {
51 break;
52 }
53 self.output.push('\n');
54 }
55 Ok(())
56 }
57
58 fn process_document_header(&mut self) -> anyhow::Result<()> {
59 self.process_document_title()?;
60
61 while let Some(line) = self.iter.next() {
62 let line = line?;
63 if line.is_empty() {
64 break;
65 }
66 if !line.starts_with(':') {
67 self.write_line(&line, 0)
68 }
69 }
70
71 Ok(())
72 }
73
74 fn process_document_title(&mut self) -> anyhow::Result<()> {
75 if let Some(Ok(line)) = self.iter.next()
76 && let Some((level, title)) = get_title(&line)
77 {
78 let title = process_inline_macros(title)?;
79 if level == 1 {
80 self.write_title(level, &title);
81 return Ok(());
82 }
83 }
84 bail!("document title not found")
85 }
86
87 fn process_list(&mut self) -> anyhow::Result<()> {
88 let mut nesting = ListNesting::default();
89 while let Some(line) = self.iter.peek() {
90 let line = line.as_deref().map_err(|e| anyhow!("{e}"))?;
91
92 if get_list_item(line).is_some() {
93 let line = self.iter.next().unwrap()?;
94 let line = process_inline_macros(&line)?;
95 let (marker, item) = get_list_item(&line).unwrap();
96 nesting.set_current(marker);
97 self.write_list_item(item, &nesting);
98 self.process_paragraph(nesting.indent(), |line| {
99 line.is_empty() || get_list_item(line).is_some() || line == "+"
100 })?;
101 } else if line == "+" {
102 let _ = self.iter.next().unwrap()?;
103 let line = self
104 .iter
105 .peek()
106 .ok_or_else(|| anyhow!("list continuation unexpectedly terminated"))?;
107 let line = line.as_deref().map_err(|e| anyhow!("{e}"))?;
108
109 let indent = nesting.indent();
110 if line.starts_with('[') {
111 self.write_line("", 0);
112 self.process_source_code_block(indent)?;
113 } else if line.starts_with(LISTING_DELIMITER) {
114 self.write_line("", 0);
115 self.process_listing_block(None, indent)?;
116 } else if line.starts_with('.') {
117 self.write_line("", 0);
118 self.process_block_with_title(indent)?;
119 } else if line.starts_with(IMAGE_BLOCK_PREFIX) {
120 self.write_line("", 0);
121 self.process_image_block(None, indent)?;
122 } else if line.starts_with(VIDEO_BLOCK_PREFIX) {
123 self.write_line("", 0);
124 self.process_video_block(None, indent)?;
125 } else {
126 self.write_line("", 0);
127 let current = nesting.current().unwrap();
128 self.process_paragraph(indent, |line| {
129 line.is_empty()
130 || get_list_item(line).filter(|(m, _)| m == current).is_some()
131 || line == "+"
132 })?;
133 }
134 } else {
135 break;
136 }
137 self.skip_blank_lines()?;
138 }
139
140 Ok(())
141 }
142
143 fn process_source_code_block(&mut self, level: usize) -> anyhow::Result<()> {
144 if let Some(Ok(line)) = self.iter.next()
145 && let Some(styles) = line.strip_prefix("[source").and_then(|s| s.strip_suffix(']'))
146 {
147 let mut styles = styles.split(',');
148 if !styles.next().unwrap().is_empty() {
149 bail!("not a source code block");
150 }
151 let language = styles.next();
152 return self.process_listing_block(language, level);
153 }
154 bail!("not a source code block")
155 }
156
157 fn process_listing_block(&mut self, style: Option<&str>, level: usize) -> anyhow::Result<()> {
158 if let Some(Ok(line)) = self.iter.next()
159 && line == LISTING_DELIMITER
160 {
161 self.write_indent(level);
162 self.output.push_str("```");
163 if let Some(style) = style {
164 self.output.push_str(style);
165 }
166 self.output.push('\n');
167 while let Some(line) = self.iter.next() {
168 let line = line?;
169 if line == LISTING_DELIMITER {
170 self.write_line("```", level);
171 return Ok(());
172 } else {
173 self.write_line(&line, level);
174 }
175 }
176 bail!("listing block is not terminated")
177 }
178 bail!("not a listing block")
179 }
180
181 fn process_block_with_title(&mut self, level: usize) -> anyhow::Result<()> {
182 if let Some(Ok(line)) = self.iter.next() {
183 let title =
184 line.strip_prefix('.').ok_or_else(|| anyhow!("extraction of the title failed"))?;
185
186 let line = self
187 .iter
188 .peek()
189 .ok_or_else(|| anyhow!("target block for the title is not found"))?;
190 let line = line.as_deref().map_err(|e| anyhow!("{e}"))?;
191 if line.starts_with(IMAGE_BLOCK_PREFIX) {
192 return self.process_image_block(Some(title), level);
193 } else if line.starts_with(VIDEO_BLOCK_PREFIX) {
194 return self.process_video_block(Some(title), level);
195 } else {
196 bail!("title for that block type is not supported");
197 }
198 }
199 bail!("not a title")
200 }
201
202 fn process_image_block(&mut self, caption: Option<&str>, level: usize) -> anyhow::Result<()> {
203 if let Some(Ok(line)) = self.iter.next()
204 && let Some((url, attrs)) = parse_media_block(&line, IMAGE_BLOCK_PREFIX)
205 {
206 let alt =
207 if let Some(stripped) = attrs.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
208 stripped
209 } else {
210 attrs
211 };
212 if let Some(caption) = caption {
213 self.write_caption_line(caption, level);
214 }
215 self.write_indent(level);
216 self.output.push_str(";
219 self.output.push_str(url);
220 self.output.push_str(")\n");
221 return Ok(());
222 }
223 bail!("not a image block")
224 }
225
226 fn process_video_block(&mut self, caption: Option<&str>, level: usize) -> anyhow::Result<()> {
227 if let Some(Ok(line)) = self.iter.next()
228 && let Some((url, attrs)) = parse_media_block(&line, VIDEO_BLOCK_PREFIX)
229 {
230 let html_attrs = match attrs {
231 "options=loop" => "controls loop",
232 r#"options="autoplay,loop""# => "autoplay controls loop",
233 _ => bail!("unsupported video syntax"),
234 };
235 if let Some(caption) = caption {
236 self.write_caption_line(caption, level);
237 }
238 self.write_indent(level);
239 self.output.push_str(r#"<video src=""#);
240 self.output.push_str(url);
241 self.output.push_str(r#"" "#);
242 self.output.push_str(html_attrs);
243 self.output.push_str(">Your browser does not support the video tag.</video>\n");
244 return Ok(());
245 }
246 bail!("not a video block")
247 }
248
249 fn process_paragraph<P>(&mut self, level: usize, predicate: P) -> anyhow::Result<()>
250 where
251 P: Fn(&str) -> bool,
252 {
253 while let Some(line) = self.iter.peek() {
254 let line = line.as_deref().map_err(|e| anyhow!("{e}"))?;
255 if predicate(line) {
256 break;
257 }
258
259 self.write_indent(level);
260 let line = self.iter.next().unwrap()?;
261 let line = line.trim_start();
262 let line = process_inline_macros(line)?;
263 if let Some(stripped) = line.strip_suffix('+') {
264 self.output.push_str(stripped);
265 self.output.push('\\');
266 } else {
267 self.output.push_str(&line);
268 }
269 self.output.push('\n');
270 }
271
272 Ok(())
273 }
274
275 fn skip_blank_lines(&mut self) -> anyhow::Result<()> {
276 while let Some(line) = self.iter.peek() {
277 if !line.as_deref().unwrap().is_empty() {
278 break;
279 }
280 self.iter.next().unwrap()?;
281 }
282 Ok(())
283 }
284
285 fn write_title(&mut self, indent: usize, title: &str) {
286 for _ in 0..indent {
287 self.output.push('#');
288 }
289 self.output.push(' ');
290 self.output.push_str(title);
291 self.output.push('\n');
292 }
293
294 fn write_list_item(&mut self, item: &str, nesting: &ListNesting) {
295 let (marker, indent) = nesting.marker();
296 self.write_indent(indent);
297 self.output.push_str(marker);
298 self.output.push_str(item);
299 self.output.push('\n');
300 }
301
302 fn write_caption_line(&mut self, caption: &str, indent: usize) {
303 self.write_indent(indent);
304 self.output.push('_');
305 self.output.push_str(caption);
306 self.output.push_str("_\\\n");
307 }
308
309 fn write_indent(&mut self, indent: usize) {
310 for _ in 0..indent {
311 self.output.push(' ');
312 }
313 }
314
315 fn write_line(&mut self, line: &str, indent: usize) {
316 self.write_indent(indent);
317 self.output.push_str(line);
318 self.output.push('\n');
319 }
320}
321
322pub(crate) fn convert_asciidoc_to_markdown<R>(input: R) -> anyhow::Result<String>
323where
324 R: BufRead,
325{
326 let mut output = String::new();
327 let mut iter = input.lines().peekable();
328
329 let mut converter = Converter::new(&mut iter, &mut output);
330 converter.process()?;
331
332 Ok(output)
333}
334
335fn get_title(line: &str) -> Option<(usize, &str)> {
336 strip_prefix_symbol(line, '=')
337}
338
339fn get_list_item(line: &str) -> Option<(ListMarker, &str)> {
340 const HYPHEN_MARKER: &str = "- ";
341 if let Some(text) = line.strip_prefix(HYPHEN_MARKER) {
342 Some((ListMarker::Hyphen, text))
343 } else if let Some((count, text)) = strip_prefix_symbol(line, '*') {
344 Some((ListMarker::Asterisk(count), text))
345 } else if let Some((count, text)) = strip_prefix_symbol(line, '.') {
346 Some((ListMarker::Dot(count), text))
347 } else {
348 None
349 }
350}
351
352fn strip_prefix_symbol(line: &str, symbol: char) -> Option<(usize, &str)> {
353 let mut iter = line.chars();
354 if iter.next()? != symbol {
355 return None;
356 }
357 let mut count = 1;
358 loop {
359 match iter.next() {
360 Some(ch) if ch == symbol => {
361 count += 1;
362 }
363 Some(' ') => {
364 break;
365 }
366 _ => return None,
367 }
368 }
369 Some((count, iter.as_str()))
370}
371
372fn parse_media_block<'a>(line: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> {
373 if let Some(line) = line.strip_prefix(prefix)
374 && let Some((url, rest)) = line.split_once('[')
375 && let Some(attrs) = rest.strip_suffix(']')
376 {
377 return Some((url, attrs));
378 }
379 None
380}
381
382#[derive(Debug)]
383struct ListNesting(Vec<ListMarker>);
384
385impl ListNesting {
386 fn current(&mut self) -> Option<&ListMarker> {
387 self.0.last()
388 }
389
390 fn set_current(&mut self, marker: ListMarker) {
391 let Self(markers) = self;
392 if let Some(index) = markers.iter().position(|m| *m == marker) {
393 markers.truncate(index + 1);
394 } else {
395 markers.push(marker);
396 }
397 }
398
399 fn indent(&self) -> usize {
400 self.0.iter().map(|m| m.in_markdown().len()).sum()
401 }
402
403 fn marker(&self) -> (&str, usize) {
404 let Self(markers) = self;
405 let indent = markers.iter().take(markers.len() - 1).map(|m| m.in_markdown().len()).sum();
406 let marker = match markers.last() {
407 None => "",
408 Some(marker) => marker.in_markdown(),
409 };
410 (marker, indent)
411 }
412}
413
414impl Default for ListNesting {
415 fn default() -> Self {
416 Self(Vec::<ListMarker>::with_capacity(6))
417 }
418}
419
420#[derive(Debug, PartialEq, Eq)]
421enum ListMarker {
422 Asterisk(usize),
423 Hyphen,
424 Dot(usize),
425}
426
427impl ListMarker {
428 fn in_markdown(&self) -> &str {
429 match self {
430 ListMarker::Asterisk(_) => "- ",
431 ListMarker::Hyphen => "- ",
432 ListMarker::Dot(_) => "1. ",
433 }
434 }
435}
436
437fn process_inline_macros(line: &str) -> anyhow::Result<Cow<'_, str>> {
438 let mut chars = line.char_indices();
439 loop {
440 let (start, end, a_macro) = match get_next_line_component(&mut chars) {
441 Component::None => break,
442 Component::Text => continue,
443 Component::Macro(s, e, m) => (s, e, m),
444 };
445 let mut src = line.chars();
446 let mut processed = String::new();
447 for _ in 0..start {
448 processed.push(src.next().unwrap());
449 }
450 processed.push_str(a_macro.process()?.as_str());
451 for _ in start..end {
452 let _ = src.next().unwrap();
453 }
454 let mut pos = end;
455
456 loop {
457 let (start, end, a_macro) = match get_next_line_component(&mut chars) {
458 Component::None => break,
459 Component::Text => continue,
460 Component::Macro(s, e, m) => (s, e, m),
461 };
462 for _ in pos..start {
463 processed.push(src.next().unwrap());
464 }
465 processed.push_str(a_macro.process()?.as_str());
466 for _ in start..end {
467 let _ = src.next().unwrap();
468 }
469 pos = end;
470 }
471 for ch in src {
472 processed.push(ch);
473 }
474 return Ok(Cow::Owned(processed));
475 }
476 Ok(Cow::Borrowed(line))
477}
478
479fn get_next_line_component(chars: &mut std::str::CharIndices<'_>) -> Component {
480 let (start, mut macro_name) = match chars.next() {
481 None => return Component::None,
482 Some((_, ch)) if ch == ' ' || !ch.is_ascii() => return Component::Text,
483 Some((pos, ch)) => (pos, String::from(ch)),
484 };
485 loop {
486 match chars.next() {
487 None => return Component::None,
488 Some((_, ch)) if ch == ' ' || !ch.is_ascii() => return Component::Text,
489 Some((_, ':')) => break,
490 Some((_, ch)) => macro_name.push(ch),
491 }
492 }
493
494 let mut macro_target = String::new();
495 loop {
496 match chars.next() {
497 None => return Component::None,
498 Some((_, ' ')) => return Component::Text,
499 Some((_, '[')) => break,
500 Some((_, ch)) => macro_target.push(ch),
501 }
502 }
503
504 let mut attr_value = String::new();
505 let end = loop {
506 match chars.next() {
507 None => return Component::None,
508 Some((pos, ']')) => break pos + 1,
509 Some((_, ch)) => attr_value.push(ch),
510 }
511 };
512
513 Component::Macro(start, end, Macro::new(macro_name, macro_target, attr_value))
514}
515
516enum Component {
517 None,
518 Text,
519 Macro(usize, usize, Macro),
520}
521
522struct Macro {
523 name: String,
524 target: String,
525 attrs: String,
526}
527
528impl Macro {
529 fn new(name: String, target: String, attrs: String) -> Self {
530 Self { name, target, attrs }
531 }
532
533 fn process(&self) -> anyhow::Result<String> {
534 let name = &self.name;
535 let text = match name.as_str() {
536 "https" => {
537 let url = &self.target;
538 let anchor_text = &self.attrs;
539 format!("[{anchor_text}](https:{url})")
540 }
541 "image" => {
542 let url = &self.target;
543 let alt = &self.attrs;
544 format!("")
545 }
546 "kbd" => {
547 let keys = self.attrs.split('+').map(|k| Cow::Owned(format!("<kbd>{k}</kbd>")));
548 keys.collect::<Vec<_>>().join("+")
549 }
550 "pr" => {
551 let pr = &self.target;
552 let url = format!("https://github.com/rust-lang/rust-analyzer/pull/{pr}");
553 format!("[`#{pr}`]({url})")
554 }
555 "commit" => {
556 let hash = &self.target;
557 let short = &hash[0..7];
558 let url = format!("https://github.com/rust-lang/rust-analyzer/commit/{hash}");
559 format!("[`{short}`]({url})")
560 }
561 "release" => {
562 let date = &self.target;
563 let url = format!("https://github.com/rust-lang/rust-analyzer/releases/{date}");
564 format!("[`{date}`]({url})")
565 }
566 _ => bail!("macro not supported: {name}"),
567 };
568 Ok(text)
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::fs::read_to_string;
576
577 #[test]
578 fn test_asciidoc_to_markdown_conversion() {
579 let input = read_to_string("test_data/input.adoc").unwrap();
580 let expected = read_to_string("test_data/expected.md").unwrap();
581 let actual = convert_asciidoc_to_markdown(std::io::Cursor::new(&input)).unwrap();
582
583 assert_eq!(actual, expected);
584 }
585
586 macro_rules! test_inline_macro_processing {
587 ($((
588 $name:ident,
589 $input:expr,
590 $expected:expr
591 ),)*) => ($(
592 #[test]
593 fn $name() {
594 let input = $input;
595 let actual = process_inline_macros(&input).unwrap();
596 let expected = $expected;
597 assert_eq!(actual, expected)
598 }
599 )*);
600 }
601
602 test_inline_macro_processing! {
603 (inline_macro_processing_for_empty_line, "", ""),
604 (inline_macro_processing_for_line_with_no_macro, "foo bar", "foo bar"),
605 (
606 inline_macro_processing_for_macro_in_line_start,
607 "kbd::[Ctrl+T] foo",
608 "<kbd>Ctrl</kbd>+<kbd>T</kbd> foo"
609 ),
610 (
611 inline_macro_processing_for_macro_in_line_end,
612 "foo kbd::[Ctrl+T]",
613 "foo <kbd>Ctrl</kbd>+<kbd>T</kbd>"
614 ),
615 (
616 inline_macro_processing_for_macro_in_the_middle_of_line,
617 "foo kbd::[Ctrl+T] foo",
618 "foo <kbd>Ctrl</kbd>+<kbd>T</kbd> foo"
619 ),
620 (
621 inline_macro_processing_for_several_macros,
622 "foo kbd::[Ctrl+T] foo kbd::[Enter] foo",
623 "foo <kbd>Ctrl</kbd>+<kbd>T</kbd> foo <kbd>Enter</kbd> foo"
624 ),
625 (
626 inline_macro_processing_for_several_macros_without_text_in_between,
627 "foo kbd::[Ctrl+T]kbd::[Enter] foo",
628 "foo <kbd>Ctrl</kbd>+<kbd>T</kbd><kbd>Enter</kbd> foo"
629 ),
630 }
631}