example.rs

  1use std::{
  2    borrow::Cow,
  3    env,
  4    fmt::{self, Display},
  5    fs,
  6    io::Write,
  7    mem,
  8    path::{Path, PathBuf},
  9};
 10
 11use anyhow::{Context as _, Result};
 12use clap::ValueEnum;
 13use gpui::http_client::Url;
 14use pulldown_cmark::CowStr;
 15use serde::{Deserialize, Serialize};
 16
 17const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff";
 18const EDIT_HISTORY_HEADING: &str = "Edit History";
 19const CURSOR_POSITION_HEADING: &str = "Cursor Position";
 20const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
 21const EXPECTED_EXCERPTS_HEADING: &str = "Expected Excerpts";
 22const REPOSITORY_URL_FIELD: &str = "repository_url";
 23const REVISION_FIELD: &str = "revision";
 24
 25#[derive(Debug)]
 26pub struct NamedExample {
 27    pub name: String,
 28    pub example: Example,
 29}
 30
 31#[derive(Debug, Serialize, Deserialize)]
 32pub struct Example {
 33    pub repository_url: String,
 34    pub revision: String,
 35    pub uncomitted_diff: String,
 36    pub cursor_path: PathBuf,
 37    pub cursor_position: String,
 38    pub edit_history: Vec<String>,
 39    pub expected_patch: String,
 40    pub expected_excerpts: Vec<ExpectedExcerpt>,
 41}
 42
 43#[derive(Debug, Serialize, Deserialize)]
 44pub struct ExpectedExcerpt {
 45    path: PathBuf,
 46    text: String,
 47}
 48
 49#[derive(ValueEnum, Debug, Clone)]
 50pub enum ExampleFormat {
 51    Json,
 52    Toml,
 53    Md,
 54}
 55
 56impl NamedExample {
 57    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
 58        let path = path.as_ref();
 59        let content = std::fs::read_to_string(path)?;
 60        let ext = path.extension();
 61
 62        match ext.and_then(|s| s.to_str()) {
 63            Some("json") => Ok(Self {
 64                name: path.file_stem().unwrap_or_default().display().to_string(),
 65                example: serde_json::from_str(&content)?,
 66            }),
 67            Some("toml") => Ok(Self {
 68                name: path.file_stem().unwrap_or_default().display().to_string(),
 69                example: toml::from_str(&content)?,
 70            }),
 71            Some("md") => Self::parse_md(&content),
 72            Some(_) => {
 73                anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display());
 74            }
 75            None => {
 76                anyhow::bail!(
 77                    "Failed to determine example type since the file does not have an extension."
 78                );
 79            }
 80        }
 81    }
 82
 83    pub fn parse_md(input: &str) -> Result<Self> {
 84        use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
 85
 86        let parser = Parser::new(input);
 87
 88        let mut named = NamedExample {
 89            name: String::new(),
 90            example: Example {
 91                repository_url: String::new(),
 92                revision: String::new(),
 93                uncomitted_diff: String::new(),
 94                cursor_path: PathBuf::new(),
 95                cursor_position: String::new(),
 96                edit_history: Vec::new(),
 97                expected_patch: String::new(),
 98                expected_excerpts: Vec::new(),
 99            },
100        };
101
102        let mut text = String::new();
103        let mut current_section = String::new();
104        let mut block_info: CowStr = "".into();
105
106        for event in parser {
107            match event {
108                Event::Text(line) => {
109                    text.push_str(&line);
110
111                    if !named.name.is_empty()
112                        && current_section.is_empty()
113                        // in h1 section
114                        && let Some((field, value)) = line.split_once('=')
115                    {
116                        match field.trim() {
117                            REPOSITORY_URL_FIELD => {
118                                named.example.repository_url = value.trim().to_string();
119                            }
120                            REVISION_FIELD => {
121                                named.example.revision = value.trim().to_string();
122                            }
123                            _ => {
124                                eprintln!("Warning: Unrecognized field `{field}`");
125                            }
126                        }
127                    }
128                }
129                Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
130                    if !named.name.is_empty() {
131                        anyhow::bail!(
132                            "Found multiple H1 headings. There should only be one with the name of the example."
133                        );
134                    }
135                    named.name = mem::take(&mut text);
136                }
137                Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
138                    current_section = mem::take(&mut text);
139                }
140                Event::End(TagEnd::Heading(level)) => {
141                    anyhow::bail!("Unexpected heading level: {level}");
142                }
143                Event::Start(Tag::CodeBlock(kind)) => {
144                    match kind {
145                        CodeBlockKind::Fenced(info) => {
146                            block_info = info;
147                        }
148                        CodeBlockKind::Indented => {
149                            anyhow::bail!("Unexpected indented codeblock");
150                        }
151                    };
152                }
153                Event::Start(_) => {
154                    text.clear();
155                    block_info = "".into();
156                }
157                Event::End(TagEnd::CodeBlock) => {
158                    let block_info = block_info.trim();
159                    if current_section.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
160                        named.example.uncomitted_diff = mem::take(&mut text);
161                    } else if current_section.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
162                        named.example.edit_history.push(mem::take(&mut text));
163                    } else if current_section.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
164                        named.example.cursor_path = block_info.into();
165                        named.example.cursor_position = mem::take(&mut text);
166                    } else if current_section.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
167                        named.example.expected_patch = mem::take(&mut text);
168                    } else if current_section.eq_ignore_ascii_case(EXPECTED_EXCERPTS_HEADING) {
169                        named.example.expected_excerpts.push(ExpectedExcerpt {
170                            path: block_info.into(),
171                            text: mem::take(&mut text),
172                        });
173                    } else {
174                        eprintln!("Warning: Unrecognized section `{current_section:?}`")
175                    }
176                }
177                _ => {}
178            }
179        }
180
181        if named.example.cursor_path.as_path() == Path::new("")
182            || named.example.cursor_position.is_empty()
183        {
184            anyhow::bail!("Missing cursor position codeblock");
185        }
186
187        Ok(named)
188    }
189
190    pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> {
191        match format {
192            ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?),
193            ExampleFormat::Toml => {
194                Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?)
195            }
196            ExampleFormat::Md => Ok(write!(out, "{}", self)?),
197        }
198    }
199
200    #[allow(unused)]
201    pub async fn setup_worktree(&self) -> Result<PathBuf> {
202        let worktrees_dir = env::current_dir()?.join("target").join("zeta-worktrees");
203        let repos_dir = env::current_dir()?.join("target").join("zeta-repos");
204        fs::create_dir_all(&repos_dir)?;
205        fs::create_dir_all(&worktrees_dir)?;
206
207        let (repo_owner, repo_name) = self.repo_name()?;
208
209        let repo_dir = repos_dir.join(repo_owner.as_ref()).join(repo_name.as_ref());
210        if !repo_dir.is_dir() {
211            fs::create_dir_all(&repo_dir)?;
212            run_git(&repo_dir, &["init"]).await?;
213            run_git(
214                &repo_dir,
215                &["remote", "add", "origin", &self.example.repository_url],
216            )
217            .await?;
218        }
219
220        run_git(
221            &repo_dir,
222            &["fetch", "--depth", "1", "origin", &self.example.revision],
223        )
224        .await?;
225
226        let worktree_path = worktrees_dir.join(&self.name);
227
228        if worktree_path.is_dir() {
229            run_git(&worktree_path, &["clean", "--force", "-d"]).await?;
230            run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?;
231            run_git(&worktree_path, &["checkout", &self.example.revision]).await?;
232        } else {
233            let worktree_path_string = worktree_path.to_string_lossy();
234            run_git(
235                &repo_dir,
236                &[
237                    "worktree",
238                    "add",
239                    "-f",
240                    &worktree_path_string,
241                    &self.example.revision,
242                ],
243            )
244            .await?;
245        }
246
247        Ok(worktree_path)
248    }
249
250    #[allow(unused)]
251    fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> {
252        // git@github.com:owner/repo.git
253        if self.example.repository_url.contains('@') {
254            let (owner, repo) = self
255                .example
256                .repository_url
257                .split_once(':')
258                .context("expected : in git url")?
259                .1
260                .split_once('/')
261                .context("expected / in git url")?;
262            Ok((
263                Cow::Borrowed(owner),
264                Cow::Borrowed(repo.trim_end_matches(".git")),
265            ))
266        // http://github.com/owner/repo.git
267        } else {
268            let url = Url::parse(&self.example.repository_url)?;
269            let mut segments = url.path_segments().context("empty http url")?;
270            let owner = segments
271                .next()
272                .context("expected owner path segment")?
273                .to_string();
274            let repo = segments
275                .next()
276                .context("expected repo path segment")?
277                .trim_end_matches(".git")
278                .to_string();
279            assert!(segments.next().is_none());
280
281            Ok((owner.into(), repo.into()))
282        }
283    }
284}
285
286async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
287    let output = smol::process::Command::new("git")
288        .current_dir(repo_path)
289        .args(args)
290        .output()
291        .await?;
292
293    anyhow::ensure!(
294        output.status.success(),
295        "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}",
296        args.join(" "),
297        repo_path.display(),
298        output.status,
299        String::from_utf8_lossy(&output.stderr),
300        String::from_utf8_lossy(&output.stdout),
301    );
302    Ok(String::from_utf8(output.stdout)?.trim().to_string())
303}
304
305impl Display for NamedExample {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        write!(f, "# {}\n\n", self.name)?;
308        write!(
309            f,
310            "{REPOSITORY_URL_FIELD} = {}\n",
311            self.example.repository_url
312        )?;
313        write!(f, "{REVISION_FIELD} = {}\n\n", self.example.revision)?;
314
315        write!(f, "## {UNCOMMITTED_DIFF_HEADING}\n\n")?;
316        write!(f, "`````diff\n")?;
317        write!(f, "{}", self.example.uncomitted_diff)?;
318        write!(f, "`````\n")?;
319
320        if !self.example.edit_history.is_empty() {
321            write!(f, "`````diff\n")?;
322            for item in &self.example.edit_history {
323                write!(f, "{item}")?;
324            }
325            write!(f, "`````\n")?;
326        }
327
328        write!(
329            f,
330            "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n",
331            self.example.cursor_path.display(),
332            self.example.cursor_position
333        )?;
334        write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?;
335
336        if !self.example.expected_patch.is_empty() {
337            write!(
338                f,
339                "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n",
340                self.example.expected_patch
341            )?;
342        }
343
344        if !self.example.expected_excerpts.is_empty() {
345            write!(f, "\n## {EXPECTED_EXCERPTS_HEADING}\n\n")?;
346
347            for excerpt in &self.example.expected_excerpts {
348                write!(
349                    f,
350                    "`````{}{}\n{}`````\n\n",
351                    excerpt
352                        .path
353                        .extension()
354                        .map(|ext| format!("{} ", ext.to_string_lossy()))
355                        .unwrap_or_default(),
356                    excerpt.path.display(),
357                    excerpt.text
358                )?;
359            }
360        }
361
362        Ok(())
363    }
364}