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}