1use std::{
2 fmt::{self, Display},
3 io::Write,
4 mem,
5 os::unix::ffi::OsStrExt,
6 path::{Path, PathBuf},
7};
8
9use anyhow::Result;
10use clap::ValueEnum;
11use pulldown_cmark::CowStr;
12use serde::{Deserialize, Serialize};
13
14const CURSOR_POSITION_HEADING: &str = "Cursor Position";
15const EDIT_HISTORY_HEADING: &str = "Edit History";
16const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
17const EXPECTED_EXCERPTS_HEADING: &str = "Expected Excerpts";
18const REPOSITORY_URL_FIELD: &str = "repository_url";
19const REVISION_FIELD: &str = "revision";
20
21pub struct NamedExample {
22 name: String,
23 example: Example,
24}
25
26#[derive(Serialize, Deserialize)]
27pub struct Example {
28 repository_url: String,
29 commit: String,
30 cursor_path: PathBuf,
31 cursor_position: String,
32 edit_history: Vec<String>,
33 expected_patch: String,
34 expected_excerpts: Vec<ExpectedExcerpt>,
35}
36
37#[derive(Serialize, Deserialize)]
38pub struct ExpectedExcerpt {
39 path: PathBuf,
40 text: String,
41}
42
43#[derive(ValueEnum, Debug, Clone)]
44pub enum ExampleFormat {
45 Json,
46 Toml,
47 Md,
48}
49
50impl NamedExample {
51 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
52 let path = path.as_ref();
53 let content = std::fs::read_to_string(path)?;
54 let ext = path.extension();
55
56 match ext.map(|s| s.as_bytes()) {
57 Some(b"json") => Ok(Self {
58 name: path.file_name().unwrap_or_default().display().to_string(),
59 example: serde_json::from_str(&content)?,
60 }),
61 Some(b"toml") => Ok(Self {
62 name: path.file_name().unwrap_or_default().display().to_string(),
63 example: toml::from_str(&content)?,
64 }),
65 Some(b"md") => Self::parse_md(&content),
66 Some(_) => {
67 anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display());
68 }
69 None => {
70 anyhow::bail!(
71 "Failed to determine example type since the file does not have an extension."
72 );
73 }
74 }
75 }
76
77 pub fn parse_md(input: &str) -> Result<Self> {
78 use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
79
80 let parser = Parser::new(input);
81
82 let mut named = NamedExample {
83 name: String::new(),
84 example: Example {
85 repository_url: String::new(),
86 commit: String::new(),
87 cursor_path: PathBuf::new(),
88 cursor_position: String::new(),
89 edit_history: Vec::new(),
90 expected_patch: String::new(),
91 expected_excerpts: Vec::new(),
92 },
93 };
94
95 let mut text = String::new();
96 let mut current_section = String::new();
97 let mut block_info: CowStr = "".into();
98
99 for event in parser {
100 match event {
101 Event::Text(line) => {
102 text.push_str(&line);
103
104 if !named.name.is_empty()
105 && current_section.is_empty()
106 // in h1 section
107 && let Some((field, value)) = line.split_once('=')
108 {
109 match field {
110 REPOSITORY_URL_FIELD => {
111 named.example.repository_url = value.to_string();
112 }
113 REVISION_FIELD => {
114 named.example.commit = value.to_string();
115 }
116 _ => {
117 eprintln!("Warning: Unrecognized field `{field}`");
118 }
119 }
120 }
121 }
122 Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
123 if !named.name.is_empty() {
124 anyhow::bail!(
125 "Found multiple H1 headings. There should only be one with the name of the example."
126 );
127 }
128 named.name = mem::take(&mut text);
129 }
130 Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
131 current_section = mem::take(&mut text);
132 }
133 Event::End(TagEnd::Heading(level)) => {
134 anyhow::bail!("Unexpected heading level: {level}");
135 }
136 Event::Start(Tag::CodeBlock(kind)) => {
137 match kind {
138 CodeBlockKind::Fenced(info) => {
139 block_info = info;
140 }
141 CodeBlockKind::Indented => {
142 anyhow::bail!("Unexpected indented codeblock");
143 }
144 };
145 }
146 Event::Start(_) => {
147 text.clear();
148 block_info = "".into();
149 }
150 Event::End(TagEnd::CodeBlock) => {
151 if current_section.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
152 named.example.edit_history.push(mem::take(&mut text));
153 } else if current_section.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
154 let path = PathBuf::from(block_info.trim());
155 named.example.cursor_path = path;
156 named.example.cursor_position = mem::take(&mut text);
157 } else if current_section.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
158 named.example.expected_patch = mem::take(&mut text);
159 } else if current_section.eq_ignore_ascii_case(EXPECTED_EXCERPTS_HEADING) {
160 let path = PathBuf::from(block_info.trim());
161 named.example.expected_excerpts.push(ExpectedExcerpt {
162 path,
163 text: mem::take(&mut text),
164 });
165 } else {
166 eprintln!("Warning: Unrecognized section `{current_section:?}`")
167 }
168 }
169 _ => {}
170 }
171 }
172
173 if named.example.cursor_path.as_path() == Path::new("")
174 || named.example.cursor_position.is_empty()
175 {
176 anyhow::bail!("Missing cursor position codeblock");
177 }
178
179 Ok(named)
180 }
181
182 pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> {
183 match format {
184 ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?),
185 ExampleFormat::Toml => {
186 Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?)
187 }
188 ExampleFormat::Md => Ok(write!(out, "{}", self)?),
189 }
190 }
191}
192
193impl Display for NamedExample {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195 write!(f, "# {}\n\n", self.name)?;
196 write!(
197 f,
198 "{REPOSITORY_URL_FIELD} = {}\n",
199 self.example.repository_url
200 )?;
201 write!(f, "{REVISION_FIELD} = {}\n\n", self.example.commit)?;
202
203 write!(
204 f,
205 "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n",
206 self.example.cursor_path.display(),
207 self.example.cursor_position
208 )?;
209 write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?;
210
211 if !self.example.edit_history.is_empty() {
212 write!(f, "`````diff\n")?;
213 for item in &self.example.edit_history {
214 write!(f, "{item}")?;
215 }
216 write!(f, "`````\n")?;
217 }
218
219 if !self.example.expected_patch.is_empty() {
220 write!(
221 f,
222 "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n",
223 self.example.expected_patch
224 )?;
225 }
226
227 if !self.example.expected_excerpts.is_empty() {
228 write!(f, "\n## {EXPECTED_EXCERPTS_HEADING}\n\n")?;
229
230 for excerpt in &self.example.expected_excerpts {
231 write!(
232 f,
233 "`````{}{}\n{}`````\n\n",
234 excerpt
235 .path
236 .extension()
237 .map(|ext| format!("{} ", ext.to_string_lossy()))
238 .unwrap_or_default(),
239 excerpt.path.display(),
240 excerpt.text
241 )?;
242 }
243 }
244
245 Ok(())
246 }
247}