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