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