example_spec.rs

  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, Hash, 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}