xtask/publish/
notes.rs

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("![");
217            self.output.push_str(alt);
218            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!("![{alt}]({url})")
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}