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}