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