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