From 1efcb6ad9abc0ab0c1af8092ee73dce90e7ab8e3 Mon Sep 17 00:00:00 2001 From: Noritada Kobayashi Date: Fri, 9 Dec 2022 23:45:45 +0900 Subject: [PATCH] Import the initial implementation of AsciiDoc-to-Markdown conversion --- xtask/src/publish/notes.rs | 371 +++++++++++++++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 xtask/src/publish/notes.rs diff --git a/xtask/src/publish/notes.rs b/xtask/src/publish/notes.rs new file mode 100644 index 00000000000..257c30a208b --- /dev/null +++ b/xtask/src/publish/notes.rs @@ -0,0 +1,371 @@ +use anyhow::{anyhow, bail}; +use std::{ + io::{BufRead, Lines}, + iter::Peekable, +}; + +const LISTING_DELIMITER: &'static str = "----"; + +struct Converter<'a, 'b, R: BufRead> { + iter: &'a mut Peekable>, + output: &'b mut String, +} + +impl<'a, 'b, R: BufRead> Converter<'a, 'b, R> { + fn new(iter: &'a mut Peekable>, output: &'b mut String) -> Self { + Self { iter, output } + } + + fn process(&mut self) -> anyhow::Result<()> { + self.process_document_header()?; + self.skip_blank_lines()?; + self.output.push('\n'); + + loop { + let line = self.iter.peek().unwrap().as_deref().map_err(|e| anyhow!("{e}"))?; + if get_title(line).is_some() { + let line = self.iter.next().unwrap().unwrap(); + let (level, title) = get_title(&line).unwrap(); + self.write_title(level, title); + } else if get_list_item(line).is_some() { + self.process_list()?; + } else if line.starts_with('[') { + self.process_source_code_block(0)?; + } else if line.starts_with(LISTING_DELIMITER) { + self.process_listing_block(None, 0)?; + } else { + self.process_paragraph(0)?; + } + + self.skip_blank_lines()?; + if self.iter.peek().is_none() { + break; + } + self.output.push('\n'); + } + Ok(()) + } + + fn process_document_header(&mut self) -> anyhow::Result<()> { + self.process_document_title()?; + + while let Some(line) = self.iter.next() { + let line = line?; + if line.is_empty() { + break; + } + if !line.starts_with(':') { + self.write_line(&line, 0) + } + } + + Ok(()) + } + + fn process_document_title(&mut self) -> anyhow::Result<()> { + if let Some(Ok(line)) = self.iter.next() { + if let Some((level, title)) = get_title(&line) { + if level == 1 { + self.write_title(level, title); + return Ok(()); + } + } + } + bail!("document title not found") + } + + fn process_list(&mut self) -> anyhow::Result<()> { + while let Some(line) = self.iter.next() { + let line = line?; + if line.is_empty() { + break; + } + + if let Some(item) = get_list_item(&line) { + self.write_list_item(item); + } else if line == "+" { + let line = self + .iter + .peek() + .ok_or_else(|| anyhow!("list continuation unexpectedly terminated"))?; + let line = line.as_deref().map_err(|e| anyhow!("{e}"))?; + if line.starts_with('[') { + self.write_line("", 0); + self.process_source_code_block(1)?; + } else if line.starts_with(LISTING_DELIMITER) { + self.write_line("", 0); + self.process_listing_block(None, 1)?; + } else { + self.write_line("", 0); + self.process_paragraph(1)?; + } + } else { + bail!("not a list block") + } + } + + Ok(()) + } + + fn process_source_code_block(&mut self, level: usize) -> anyhow::Result<()> { + if let Some(Ok(line)) = self.iter.next() { + if let Some(styles) = line.strip_prefix("[source").and_then(|s| s.strip_suffix(']')) { + let mut styles = styles.split(','); + if !styles.next().unwrap().is_empty() { + bail!("not a source code block"); + } + let language = styles.next(); + return self.process_listing_block(language, level); + } + } + bail!("not a source code block") + } + + fn process_listing_block(&mut self, style: Option<&str>, level: usize) -> anyhow::Result<()> { + if let Some(Ok(line)) = self.iter.next() { + if line == LISTING_DELIMITER { + self.write_indent(level); + self.output.push_str("```"); + if let Some(style) = style { + self.output.push_str(style); + } + self.output.push('\n'); + while let Some(line) = self.iter.next() { + let line = line?; + if line == LISTING_DELIMITER { + self.write_line("```", level); + return Ok(()); + } else { + self.write_line(&line, level); + } + } + bail!("listing block is not terminated") + } + } + bail!("not a listing block") + } + + fn process_paragraph(&mut self, level: usize) -> anyhow::Result<()> { + while let Some(line) = self.iter.peek() { + let line = line.as_deref().map_err(|e| anyhow!("{e}"))?; + if line.is_empty() || (level > 0 && line == "+") { + break; + } + + self.write_indent(level); + let line = self.iter.next().unwrap()?; + if line.ends_with('+') { + let line = &line[..(line.len() - 1)]; + self.output.push_str(line); + self.output.push('\\'); + } else { + self.output.push_str(&line); + } + self.output.push('\n'); + } + + Ok(()) + } + + fn skip_blank_lines(&mut self) -> anyhow::Result<()> { + while let Some(line) = self.iter.peek() { + if !line.as_deref().unwrap().is_empty() { + break; + } + self.iter.next().unwrap()?; + } + Ok(()) + } + + fn write_title(&mut self, level: usize, title: &str) { + for _ in 0..level { + self.output.push('#'); + } + self.output.push(' '); + self.output.push_str(title); + self.output.push('\n'); + } + + fn write_list_item(&mut self, item: &str) { + self.output.push_str("- "); + self.output.push_str(item); + self.output.push('\n'); + } + + fn write_indent(&mut self, level: usize) { + for _ in 0..level { + self.output.push_str(" "); + } + } + + fn write_line(&mut self, line: &str, level: usize) { + self.write_indent(level); + self.output.push_str(line); + self.output.push('\n'); + } +} + +pub(crate) fn convert_asciidoc_to_markdown(input: R) -> anyhow::Result +where + R: BufRead, +{ + let mut output = String::new(); + let mut iter = input.lines().peekable(); + + let mut converter = Converter::new(&mut iter, &mut output); + converter.process()?; + + Ok(output) +} + +fn get_title(line: &str) -> Option<(usize, &str)> { + const MARKER: char = '='; + let mut iter = line.chars(); + if iter.next()? != MARKER { + return None; + } + let mut count = 1; + loop { + match iter.next() { + Some(MARKER) => { + count += 1; + } + Some(' ') => { + break; + } + _ => return None, + } + } + Some((count, iter.as_str())) +} + +fn get_list_item(line: &str) -> Option<&str> { + const MARKER: &'static str = "* "; + if line.starts_with(MARKER) { + let item = &line[MARKER.len()..]; + Some(item) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_asciidoc_to_markdown_conversion() { + let input = "\ += Changelog #256 +:sectanchors: +:page-layout: post + +Hello! + +Commit: commit:0123456789abcdef0123456789abcdef01234567[] + +Release: release:2022-01-01[] + +== New Features + +* pr:1111[] foo bar baz +* pr:2222[] foo bar baz ++ +image::https://example.com/animation.gif[] ++ +video::https://example.com/movie.mp4[options=\"autoplay,loop\"] ++ +[source,bash] +---- +rustup update nightly +---- ++ +---- +This is a plain listing. +---- ++ +paragraph +paragraph + +== Fixes + +* pr:3333[] foo bar baz +* pr:4444[] foo bar baz + +== Internal Improvements + +* pr:5555[] foo bar baz +* pr:6666[] foo bar baz + +The highlight of the month is probably pr:1111[]. + +[source,bash] +---- +rustup update nightly +---- + +[source] +---- +rustup update nightly +---- + +---- +This is a plain listing. +---- +"; + let expected = "\ +# Changelog #256 + +Hello! + +Commit: commit:0123456789abcdef0123456789abcdef01234567[] \\ +Release: release:2022-01-01[] + +## New Features + +- pr:1111[] foo bar baz +- pr:2222[] foo bar baz + + image::https://example.com/animation.gif[] + + video::https://example.com/movie.mp4[options=\"autoplay,loop\"] + + ```bash + rustup update nightly + ``` + + ``` + This is a plain listing. + ``` + + paragraph + paragraph + +## Fixes + +- pr:3333[] foo bar baz +- pr:4444[] foo bar baz + +## Internal Improvements + +- pr:5555[] foo bar baz +- pr:6666[] foo bar baz + +The highlight of the month is probably pr:1111[]. + +```bash +rustup update nightly +``` + +``` +rustup update nightly +``` + +``` +This is a plain listing. +``` +"; + let actual = convert_asciidoc_to_markdown(std::io::Cursor::new(input)).unwrap(); + + assert_eq!(actual, expected); + } +}