1use anyhow::{Context as _, Result};
2use serde::{Deserialize, Serialize};
3use std::{borrow::Cow, fmt::Write as _, mem, path::Path, sync::Arc};
4
5pub const CURSOR_POSITION_MARKER: &str = "[CURSOR_POSITION]";
6
7#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
8pub struct ExampleSpec {
9 #[serde(default)]
10 pub name: String,
11 pub repository_url: String,
12 pub revision: String,
13 #[serde(default)]
14 pub uncommitted_diff: String,
15 pub cursor_path: Arc<Path>,
16 pub cursor_position: String,
17 pub edit_history: String,
18 pub expected_patches: Vec<String>,
19}
20
21const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff";
22const EDIT_HISTORY_HEADING: &str = "Edit History";
23const CURSOR_POSITION_HEADING: &str = "Cursor Position";
24const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
25const EXPECTED_CONTEXT_HEADING: &str = "Expected Context";
26
27#[derive(Serialize, Deserialize)]
28struct FrontMatter<'a> {
29 repository_url: Cow<'a, str>,
30 revision: Cow<'a, str>,
31}
32
33impl ExampleSpec {
34 /// Format this example spec as markdown.
35 pub fn to_markdown(&self) -> String {
36 use std::fmt::Write as _;
37
38 let front_matter = FrontMatter {
39 repository_url: Cow::Borrowed(&self.repository_url),
40 revision: Cow::Borrowed(&self.revision),
41 };
42 let front_matter_toml =
43 toml::to_string_pretty(&front_matter).unwrap_or_else(|_| String::new());
44
45 let mut markdown = String::new();
46
47 _ = writeln!(markdown, "+++");
48 markdown.push_str(&front_matter_toml);
49 if !markdown.ends_with('\n') {
50 markdown.push('\n');
51 }
52 _ = writeln!(markdown, "+++");
53 markdown.push('\n');
54
55 _ = writeln!(markdown, "# {}", self.name);
56 markdown.push('\n');
57
58 if !self.uncommitted_diff.is_empty() {
59 _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING);
60 _ = writeln!(markdown);
61 _ = writeln!(markdown, "```diff");
62 markdown.push_str(&self.uncommitted_diff);
63 if !markdown.ends_with('\n') {
64 markdown.push('\n');
65 }
66 _ = writeln!(markdown, "```");
67 markdown.push('\n');
68 }
69
70 _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING);
71 _ = writeln!(markdown);
72
73 if self.edit_history.is_empty() {
74 _ = writeln!(markdown, "(No edit history)");
75 _ = writeln!(markdown);
76 } else {
77 _ = writeln!(markdown, "```diff");
78 markdown.push_str(&self.edit_history);
79 if !markdown.ends_with('\n') {
80 markdown.push('\n');
81 }
82 _ = writeln!(markdown, "```");
83 markdown.push('\n');
84 }
85
86 _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING);
87 _ = writeln!(markdown);
88 _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy());
89 markdown.push_str(&self.cursor_position);
90 if !markdown.ends_with('\n') {
91 markdown.push('\n');
92 }
93 _ = writeln!(markdown, "```");
94 markdown.push('\n');
95
96 _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING);
97 markdown.push('\n');
98 for patch in &self.expected_patches {
99 _ = writeln!(markdown, "```diff");
100 markdown.push_str(patch);
101 if !markdown.ends_with('\n') {
102 markdown.push('\n');
103 }
104 _ = writeln!(markdown, "```");
105 markdown.push('\n');
106 }
107
108 markdown
109 }
110
111 /// Parse an example spec from markdown.
112 pub fn from_markdown(mut input: &str) -> anyhow::Result<Self> {
113 use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd};
114
115 let mut spec = ExampleSpec {
116 name: String::new(),
117 repository_url: String::new(),
118 revision: String::new(),
119 uncommitted_diff: String::new(),
120 cursor_path: Path::new("").into(),
121 cursor_position: String::new(),
122 edit_history: String::new(),
123 expected_patches: Vec::new(),
124 };
125
126 if let Some(rest) = input.strip_prefix("+++\n")
127 && let Some((front_matter, rest)) = rest.split_once("+++\n")
128 {
129 if let Ok(data) = toml::from_str::<FrontMatter<'_>>(front_matter) {
130 spec.repository_url = data.repository_url.into_owned();
131 spec.revision = data.revision.into_owned();
132 }
133 input = rest.trim_start();
134 }
135
136 let parser = Parser::new(input);
137 let mut text = String::new();
138 let mut block_info: CowStr = "".into();
139
140 #[derive(PartialEq)]
141 enum Section {
142 Start,
143 UncommittedDiff,
144 EditHistory,
145 CursorPosition,
146 ExpectedExcerpts,
147 ExpectedPatch,
148 Other,
149 }
150
151 let mut current_section = Section::Start;
152
153 for event in parser {
154 match event {
155 Event::Text(line) => {
156 text.push_str(&line);
157 }
158 Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
159 spec.name = mem::take(&mut text);
160 }
161 Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
162 let title = mem::take(&mut text);
163 current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
164 Section::UncommittedDiff
165 } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
166 Section::EditHistory
167 } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
168 Section::CursorPosition
169 } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
170 Section::ExpectedPatch
171 } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) {
172 Section::ExpectedExcerpts
173 } else {
174 Section::Other
175 };
176 }
177 Event::End(TagEnd::Heading(HeadingLevel::H3)) => {
178 mem::take(&mut text);
179 }
180 Event::End(TagEnd::Heading(HeadingLevel::H4)) => {
181 mem::take(&mut text);
182 }
183 Event::End(TagEnd::Heading(level)) => {
184 anyhow::bail!("Unexpected heading level: {level}");
185 }
186 Event::Start(Tag::CodeBlock(kind)) => {
187 match kind {
188 CodeBlockKind::Fenced(info) => {
189 block_info = info;
190 }
191 CodeBlockKind::Indented => {
192 anyhow::bail!("Unexpected indented codeblock");
193 }
194 };
195 }
196 Event::Start(_) => {
197 text.clear();
198 block_info = "".into();
199 }
200 Event::End(TagEnd::CodeBlock) => {
201 let block_info = block_info.trim();
202 match current_section {
203 Section::UncommittedDiff => {
204 spec.uncommitted_diff = mem::take(&mut text);
205 }
206 Section::EditHistory => {
207 spec.edit_history.push_str(&mem::take(&mut text));
208 }
209 Section::CursorPosition => {
210 spec.cursor_path = Path::new(block_info).into();
211 spec.cursor_position = mem::take(&mut text);
212 }
213 Section::ExpectedExcerpts => {
214 mem::take(&mut text);
215 }
216 Section::ExpectedPatch => {
217 spec.expected_patches.push(mem::take(&mut text));
218 }
219 Section::Start | Section::Other => {}
220 }
221 }
222 _ => {}
223 }
224 }
225
226 if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() {
227 anyhow::bail!("Missing cursor position codeblock");
228 }
229
230 Ok(spec)
231 }
232
233 /// Returns the excerpt of text around the cursor, and the offset of the cursor within that
234 /// excerpt.
235 ///
236 /// The cursor's position is marked with a special comment that appears
237 /// below the cursor line, which contains the string `[CURSOR_POSITION]`,
238 /// preceded by an arrow marking the cursor's column. The arrow can be
239 /// either:
240 /// - `^` - The cursor column is at the position of the `^` character (pointing up to the cursor)
241 /// - `<` - The cursor column is at the first non-whitespace character on that line.
242 pub fn cursor_excerpt(&self) -> Result<(String, usize)> {
243 let input = &self.cursor_position;
244
245 let marker_offset = input
246 .find(CURSOR_POSITION_MARKER)
247 .context("missing [CURSOR_POSITION] marker")?;
248 let marker_line_start = input[..marker_offset]
249 .rfind('\n')
250 .map(|pos| pos + 1)
251 .unwrap_or(0);
252 let marker_line_end = input[marker_line_start..]
253 .find('\n')
254 .map(|pos| marker_line_start + pos + 1)
255 .unwrap_or(input.len());
256 let marker_line = &input[marker_line_start..marker_line_end].trim_end_matches('\n');
257
258 let cursor_column = if let Some(cursor_offset) = marker_line.find('^') {
259 cursor_offset
260 } else if let Some(less_than_pos) = marker_line.find('<') {
261 marker_line
262 .find(|c: char| !c.is_whitespace())
263 .unwrap_or(less_than_pos)
264 } else {
265 anyhow::bail!(
266 "cursor position marker line must contain '^' or '<' before [CURSOR_POSITION]"
267 );
268 };
269
270 let mut excerpt = input[..marker_line_start].to_string() + &input[marker_line_end..];
271 excerpt.truncate(excerpt.trim_end_matches('\n').len());
272
273 // The cursor is on the line above the marker line.
274 let cursor_line_end = marker_line_start.saturating_sub(1);
275 let cursor_line_start = excerpt[..cursor_line_end]
276 .rfind('\n')
277 .map(|pos| pos + 1)
278 .unwrap_or(0);
279 let cursor_offset = cursor_line_start + cursor_column;
280
281 Ok((excerpt, cursor_offset))
282 }
283
284 /// Sets the cursor position excerpt from a plain excerpt and cursor byte offset.
285 ///
286 /// The `line_comment_prefix` is used to format the marker line as a comment.
287 /// If the cursor column is less than the comment prefix length, the `<` format is used.
288 /// Otherwise, the `^` format is used.
289 pub fn set_cursor_excerpt(
290 &mut self,
291 excerpt: &str,
292 cursor_offset: usize,
293 line_comment_prefix: &str,
294 ) {
295 // Find which line the cursor is on and its column
296 let cursor_line_start = excerpt[..cursor_offset]
297 .rfind('\n')
298 .map(|pos| pos + 1)
299 .unwrap_or(0);
300 let cursor_line_end = excerpt[cursor_line_start..]
301 .find('\n')
302 .map(|pos| cursor_line_start + pos + 1)
303 .unwrap_or(excerpt.len());
304 let cursor_line = &excerpt[cursor_line_start..cursor_line_end];
305 let cursor_line_indent = &cursor_line[..cursor_line.len() - cursor_line.trim_start().len()];
306 let cursor_column = cursor_offset - cursor_line_start;
307
308 // Build the marker line
309 let mut marker_line = String::new();
310 if cursor_column < line_comment_prefix.len() {
311 for _ in 0..cursor_column {
312 marker_line.push(' ');
313 }
314 marker_line.push_str(line_comment_prefix);
315 write!(marker_line, " <{}", CURSOR_POSITION_MARKER).unwrap();
316 } else {
317 if cursor_column >= cursor_line_indent.len() + line_comment_prefix.len() {
318 marker_line.push_str(cursor_line_indent);
319 }
320 marker_line.push_str(line_comment_prefix);
321 while marker_line.len() < cursor_column {
322 marker_line.push(' ');
323 }
324 write!(marker_line, "^{}", CURSOR_POSITION_MARKER).unwrap();
325 }
326
327 // Build the final cursor_position string
328 let mut result = String::with_capacity(excerpt.len() + marker_line.len() + 2);
329 result.push_str(&excerpt[..cursor_line_end]);
330 if !result.ends_with('\n') {
331 result.push('\n');
332 }
333 result.push_str(&marker_line);
334 if cursor_line_end < excerpt.len() {
335 result.push('\n');
336 result.push_str(&excerpt[cursor_line_end..]);
337 }
338
339 self.cursor_position = result;
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use indoc::indoc;
347
348 #[test]
349 fn test_cursor_excerpt_with_caret() {
350 let mut spec = ExampleSpec {
351 name: String::new(),
352 repository_url: String::new(),
353 revision: String::new(),
354 uncommitted_diff: String::new(),
355 cursor_path: Path::new("test.rs").into(),
356 cursor_position: String::new(),
357 edit_history: String::new(),
358 expected_patches: Vec::new(),
359 };
360
361 // Cursor before `42`
362 let excerpt = indoc! {"
363 fn main() {
364 let x = 42;
365 println!(\"{}\", x);
366 }"
367 };
368 let offset = excerpt.find("42").unwrap();
369 let position_string = indoc! {"
370 fn main() {
371 let x = 42;
372 // ^[CURSOR_POSITION]
373 println!(\"{}\", x);
374 }"
375 }
376 .to_string();
377
378 spec.set_cursor_excerpt(excerpt, offset, "//");
379 assert_eq!(spec.cursor_position, position_string);
380 assert_eq!(
381 spec.cursor_excerpt().unwrap(),
382 (excerpt.to_string(), offset)
383 );
384
385 // Cursor after `l` in `let`
386 let offset = excerpt.find("et x").unwrap();
387 let position_string = indoc! {"
388 fn main() {
389 let x = 42;
390 // ^[CURSOR_POSITION]
391 println!(\"{}\", x);
392 }"
393 }
394 .to_string();
395
396 spec.set_cursor_excerpt(excerpt, offset, "//");
397 assert_eq!(spec.cursor_position, position_string);
398 assert_eq!(
399 spec.cursor_excerpt().unwrap(),
400 (excerpt.to_string(), offset)
401 );
402
403 // Cursor before `let`
404 let offset = excerpt.find("let").unwrap();
405 let position_string = indoc! {"
406 fn main() {
407 let x = 42;
408 // ^[CURSOR_POSITION]
409 println!(\"{}\", x);
410 }"
411 }
412 .to_string();
413
414 spec.set_cursor_excerpt(excerpt, offset, "//");
415 assert_eq!(spec.cursor_position, position_string);
416 assert_eq!(
417 spec.cursor_excerpt().unwrap(),
418 (excerpt.to_string(), offset)
419 );
420
421 // Cursor at beginning of the line with `let`
422 let offset = excerpt.find(" let").unwrap();
423 let position_string = indoc! {"
424 fn main() {
425 let x = 42;
426 // <[CURSOR_POSITION]
427 println!(\"{}\", x);
428 }"
429 }
430 .to_string();
431
432 spec.set_cursor_excerpt(excerpt, offset, "//");
433 assert_eq!(spec.cursor_position, position_string);
434 assert_eq!(
435 spec.cursor_excerpt().unwrap(),
436 (excerpt.to_string(), offset)
437 );
438
439 // Cursor at end of line, after the semicolon
440 let offset = excerpt.find(';').unwrap() + 1;
441 let position_string = indoc! {"
442 fn main() {
443 let x = 42;
444 // ^[CURSOR_POSITION]
445 println!(\"{}\", x);
446 }"
447 }
448 .to_string();
449
450 spec.set_cursor_excerpt(excerpt, offset, "//");
451 assert_eq!(spec.cursor_position, position_string);
452 assert_eq!(
453 spec.cursor_excerpt().unwrap(),
454 (excerpt.to_string(), offset)
455 );
456
457 // Caret at end of file (no trailing newline)
458 let excerpt = indoc! {"
459 fn main() {
460 let x = 42;"
461 };
462 let offset = excerpt.find(';').unwrap() + 1;
463 let position_string = indoc! {"
464 fn main() {
465 let x = 42;
466 // ^[CURSOR_POSITION]"
467 }
468 .to_string();
469
470 spec.set_cursor_excerpt(excerpt, offset, "//");
471 assert_eq!(spec.cursor_position, position_string);
472 assert_eq!(
473 spec.cursor_excerpt().unwrap(),
474 (excerpt.to_string(), offset)
475 );
476 }
477}