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}