example.rs

  1use std::{
  2    fmt::{self, Display},
  3    io::Write,
  4    mem,
  5    os::unix::ffi::OsStrExt,
  6    path::{Path, PathBuf},
  7};
  8
  9use anyhow::Result;
 10use clap::ValueEnum;
 11use pulldown_cmark::CowStr;
 12use serde::{Deserialize, Serialize};
 13
 14const CURSOR_POSITION_HEADING: &str = "Cursor Position";
 15const EDIT_HISTORY_HEADING: &str = "Edit History";
 16const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
 17const EXPECTED_EXCERPTS_HEADING: &str = "Expected Excerpts";
 18const REPOSITORY_URL_FIELD: &str = "repository_url";
 19const REVISION_FIELD: &str = "revision";
 20
 21pub struct NamedExample {
 22    name: String,
 23    example: Example,
 24}
 25
 26#[derive(Serialize, Deserialize)]
 27pub struct Example {
 28    repository_url: String,
 29    commit: String,
 30    cursor_path: PathBuf,
 31    cursor_position: String,
 32    edit_history: Vec<String>,
 33    expected_patch: String,
 34    expected_excerpts: Vec<ExpectedExcerpt>,
 35}
 36
 37#[derive(Serialize, Deserialize)]
 38pub struct ExpectedExcerpt {
 39    path: PathBuf,
 40    text: String,
 41}
 42
 43#[derive(ValueEnum, Debug, Clone)]
 44pub enum ExampleFormat {
 45    Json,
 46    Toml,
 47    Md,
 48}
 49
 50impl NamedExample {
 51    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
 52        let path = path.as_ref();
 53        let content = std::fs::read_to_string(path)?;
 54        let ext = path.extension();
 55
 56        match ext.map(|s| s.as_bytes()) {
 57            Some(b"json") => Ok(Self {
 58                name: path.file_name().unwrap_or_default().display().to_string(),
 59                example: serde_json::from_str(&content)?,
 60            }),
 61            Some(b"toml") => Ok(Self {
 62                name: path.file_name().unwrap_or_default().display().to_string(),
 63                example: toml::from_str(&content)?,
 64            }),
 65            Some(b"md") => Self::parse_md(&content),
 66            Some(_) => {
 67                anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display());
 68            }
 69            None => {
 70                anyhow::bail!(
 71                    "Failed to determine example type since the file does not have an extension."
 72                );
 73            }
 74        }
 75    }
 76
 77    pub fn parse_md(input: &str) -> Result<Self> {
 78        use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
 79
 80        let parser = Parser::new(input);
 81
 82        let mut named = NamedExample {
 83            name: String::new(),
 84            example: Example {
 85                repository_url: String::new(),
 86                commit: String::new(),
 87                cursor_path: PathBuf::new(),
 88                cursor_position: String::new(),
 89                edit_history: Vec::new(),
 90                expected_patch: String::new(),
 91                expected_excerpts: Vec::new(),
 92            },
 93        };
 94
 95        let mut text = String::new();
 96        let mut current_section = String::new();
 97        let mut block_info: CowStr = "".into();
 98
 99        for event in parser {
100            match event {
101                Event::Text(line) => {
102                    text.push_str(&line);
103
104                    if !named.name.is_empty()
105                        && current_section.is_empty()
106                        // in h1 section
107                        && let Some((field, value)) = line.split_once('=')
108                    {
109                        match field {
110                            REPOSITORY_URL_FIELD => {
111                                named.example.repository_url = value.to_string();
112                            }
113                            REVISION_FIELD => {
114                                named.example.commit = value.to_string();
115                            }
116                            _ => {
117                                eprintln!("Warning: Unrecognized field `{field}`");
118                            }
119                        }
120                    }
121                }
122                Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
123                    if !named.name.is_empty() {
124                        anyhow::bail!(
125                            "Found multiple H1 headings. There should only be one with the name of the example."
126                        );
127                    }
128                    named.name = mem::take(&mut text);
129                }
130                Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
131                    current_section = mem::take(&mut text);
132                }
133                Event::End(TagEnd::Heading(level)) => {
134                    anyhow::bail!("Unexpected heading level: {level}");
135                }
136                Event::Start(Tag::CodeBlock(kind)) => {
137                    match kind {
138                        CodeBlockKind::Fenced(info) => {
139                            block_info = info;
140                        }
141                        CodeBlockKind::Indented => {
142                            anyhow::bail!("Unexpected indented codeblock");
143                        }
144                    };
145                }
146                Event::Start(_) => {
147                    text.clear();
148                    block_info = "".into();
149                }
150                Event::End(TagEnd::CodeBlock) => {
151                    if current_section.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
152                        named.example.edit_history.push(mem::take(&mut text));
153                    } else if current_section.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
154                        let path = PathBuf::from(block_info.trim());
155                        named.example.cursor_path = path;
156                        named.example.cursor_position = mem::take(&mut text);
157                    } else if current_section.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
158                        named.example.expected_patch = mem::take(&mut text);
159                    } else if current_section.eq_ignore_ascii_case(EXPECTED_EXCERPTS_HEADING) {
160                        let path = PathBuf::from(block_info.trim());
161                        named.example.expected_excerpts.push(ExpectedExcerpt {
162                            path,
163                            text: mem::take(&mut text),
164                        });
165                    } else {
166                        eprintln!("Warning: Unrecognized section `{current_section:?}`")
167                    }
168                }
169                _ => {}
170            }
171        }
172
173        if named.example.cursor_path.as_path() == Path::new("")
174            || named.example.cursor_position.is_empty()
175        {
176            anyhow::bail!("Missing cursor position codeblock");
177        }
178
179        Ok(named)
180    }
181
182    pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> {
183        match format {
184            ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?),
185            ExampleFormat::Toml => {
186                Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?)
187            }
188            ExampleFormat::Md => Ok(write!(out, "{}", self)?),
189        }
190    }
191}
192
193impl Display for NamedExample {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        write!(f, "# {}\n\n", self.name)?;
196        write!(
197            f,
198            "{REPOSITORY_URL_FIELD} = {}\n",
199            self.example.repository_url
200        )?;
201        write!(f, "{REVISION_FIELD} = {}\n\n", self.example.commit)?;
202
203        write!(
204            f,
205            "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n",
206            self.example.cursor_path.display(),
207            self.example.cursor_position
208        )?;
209        write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?;
210
211        if !self.example.edit_history.is_empty() {
212            write!(f, "`````diff\n")?;
213            for item in &self.example.edit_history {
214                write!(f, "{item}")?;
215            }
216            write!(f, "`````\n")?;
217        }
218
219        if !self.example.expected_patch.is_empty() {
220            write!(
221                f,
222                "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n",
223                self.example.expected_patch
224            )?;
225        }
226
227        if !self.example.expected_excerpts.is_empty() {
228            write!(f, "\n## {EXPECTED_EXCERPTS_HEADING}\n\n")?;
229
230            for excerpt in &self.example.expected_excerpts {
231                write!(
232                    f,
233                    "`````{}{}\n{}`````\n\n",
234                    excerpt
235                        .path
236                        .extension()
237                        .map(|ext| format!("{} ", ext.to_string_lossy()))
238                        .unwrap_or_default(),
239                    excerpt.path.display(),
240                    excerpt.text
241                )?;
242            }
243        }
244
245        Ok(())
246    }
247}