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 uncommitted_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 uncommitted_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.uncommitted_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.uncommitted_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}