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]";
  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}