1use std::ops::Range;
2
3#[derive(PartialEq, Eq, Debug)]
4pub struct Spec {
5 pub file_name: String,
6 pub message: String,
7 pub file_text: String,
8 pub range: Option<Range<usize>>,
10 pub expected_text: String,
11 pub expected_range: Option<Range<usize>>,
13 pub is_only: bool,
14 pub is_trace: bool,
15 pub skip: bool,
16 pub skip_format_twice: bool,
17 pub config: SpecConfigMap,
18}
19
20pub const RANGE_START: &str = "[|";
22pub const RANGE_END: &str = "|]";
24
25pub type SpecConfigMap = serde_json::Map<String, serde_json::Value>;
26
27#[derive(Debug, Clone)]
28pub struct ParseSpecOptions {
29 pub default_file_name: &'static str,
31}
32
33pub fn parse_specs(file_text: String, options: &ParseSpecOptions) -> Vec<Spec> {
34 let file_text = file_text.replace("\r\n", "\n");
36 let (file_path, file_text) = parse_file_path(file_text, options);
37 let (config, file_text) = parse_config(file_text);
38 let lines = file_text.split('\n').collect::<Vec<_>>();
39 let spec_starts = get_spec_starts(&file_path, &lines);
40 let mut specs = Vec::new();
41
42 for i in 0..spec_starts.len() {
43 let start_index = spec_starts[i];
44 let end_index = if spec_starts.len() == i + 1 { lines.len() } else { spec_starts[i + 1] };
45 let message_line = lines[start_index];
46 let spec = parse_single_spec(&file_path, message_line, &lines[(start_index + 1)..end_index], &config);
47
48 specs.push(spec);
49 }
50
51 return specs;
52
53 fn parse_file_path(file_text: String, options: &ParseSpecOptions) -> (String, String) {
54 if !file_text.starts_with("--") {
55 return (options.default_file_name.into(), file_text);
56 }
57 let last_index = file_text.find("--\n").expect("Could not find final --");
58
59 (file_text["--".len()..last_index].trim().into(), file_text[(last_index + "--\n".len())..].into())
60 }
61
62 fn parse_config(file_text: String) -> (SpecConfigMap, String) {
63 if !file_text.starts_with("~~") {
64 return (Default::default(), file_text);
65 }
66 let last_index = file_text.find("~~\n").expect("Could not find final ~~\\n");
67
68 let config_text = file_text["~~".len()..last_index].replace('\n', "");
69 let config_text = config_text.trim();
70 let mut config: SpecConfigMap = Default::default();
71
72 if config_text.starts_with('{') {
73 config = serde_json::from_str(config_text).expect("Error parsing config json.");
74 } else {
75 for item in config_text.split(',') {
76 let first_colon = item.find(':').expect("Could not find colon in config option.");
77 let key = item[0..first_colon].trim();
78 let value = item[first_colon + ":".len()..].trim();
79
80 config.insert(
81 key.into(),
82 match value.parse::<bool>() {
83 Ok(value) => value.into(),
84 Err(_) => match value.parse::<i32>() {
85 Ok(value) => value.into(),
86 Err(_) => value.into(),
87 },
88 },
89 );
90 }
91 }
92
93 (config, file_text[(last_index + "~~\n".len())..].into())
94 }
95
96 fn get_spec_starts(file_name: &str, lines: &[&str]) -> Vec<usize> {
97 let mut result = Vec::new();
98 let message_separator = get_message_separator(file_name);
99
100 if !lines.first().unwrap().starts_with(message_separator) {
101 panic!("All spec files should start with a message. (ex. {0} Message {0})", message_separator);
102 }
103
104 for (i, line) in lines.iter().enumerate() {
105 if line.starts_with(message_separator) {
106 result.push(i);
107 }
108 }
109
110 result
111 }
112
113 fn parse_single_spec(file_name: &str, message_line: &str, lines: &[&str], config: &SpecConfigMap) -> Spec {
114 let file_text = lines.join("\n");
115 let parts = file_text.split("[expect]").collect::<Vec<&str>>();
116 let (start_text, range) = parse_range(&parts[0][0..parts[0].len() - "\n".len()]); let (expected_text, expected_range) = parse_range(&parts[1]["\n".len()..]); assert!(
119 range.is_some() || expected_range.is_none(),
120 "Only a spec with a range in its text can have a range in its expected text."
121 );
122 let lower_case_message_line = message_line.to_ascii_lowercase();
123 let message_separator = get_message_separator(file_name);
124 let is_trace = lower_case_message_line.contains("(trace)");
125
126 Spec {
127 file_name: String::from(file_name),
128 message: message_line[message_separator.len()..message_line.len() - message_separator.len()]
129 .trim()
130 .into(),
131 file_text: start_text,
132 range,
133 expected_text,
134 expected_range,
135 is_only: lower_case_message_line.contains("(only)") || is_trace,
136 is_trace,
137 skip: lower_case_message_line.contains("(skip)"),
138 skip_format_twice: lower_case_message_line.contains("(skip-format-twice)"),
139 config: config.clone(),
140 }
141 }
142
143 fn parse_range(text: &str) -> (String, Option<Range<usize>>) {
144 let Some(start) = text.find(RANGE_START) else {
145 assert!(!text.contains(RANGE_END), "Found {} without {}.", RANGE_END, RANGE_START);
146 return (text.to_string(), None);
147 };
148 let inner_start = start + RANGE_START.len();
149 let inner_end = text[inner_start..]
150 .find(RANGE_END)
151 .map(|index| inner_start + index)
152 .unwrap_or_else(|| panic!("Found {} without {}.", RANGE_START, RANGE_END));
153 let (before, inner, after) = (&text[..start], &text[inner_start..inner_end], &text[inner_end + RANGE_END.len()..]);
154 assert!(
155 !before.contains(RANGE_END) && !inner.contains(RANGE_START) && !after.contains(RANGE_START) && !after.contains(RANGE_END),
156 "Only one range may be specified per text."
157 );
158 (format!("{}{}{}", before, inner, after), Some(start..start + inner.len()))
159 }
160
161 fn get_message_separator(file_name: &str) -> &'static str {
162 if file_name.ends_with(".md") { "!!" } else { "==" }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn it_parses() {
172 let specs = parse_specs(
173 vec![
174 "== message 1 ==",
175 "start",
176 "multiple",
177 "",
178 "[expect]",
179 "expected",
180 "multiple",
181 "",
182 "== message 2 (only) (skip) (skip-format-twice) ==",
183 "start2",
184 "",
185 "[expect]",
186 "expected2",
187 "",
188 "== message 3 (trace) ==",
189 "test",
190 "",
191 "[expect]",
192 "test",
193 "",
194 ]
195 .join("\n"),
196 &ParseSpecOptions { default_file_name: "test.ts" },
197 );
198
199 assert_eq!(specs.len(), 3);
200 assert_eq!(
201 specs[0],
202 Spec {
203 file_name: "test.ts".into(),
204 file_text: "start\nmultiple\n".into(),
205 range: None,
206 expected_text: "expected\nmultiple\n".into(),
207 expected_range: None,
208 message: "message 1".into(),
209 is_only: false,
210 is_trace: false,
211 skip: false,
212 skip_format_twice: false,
213 config: Default::default(),
214 }
215 );
216 assert_eq!(
217 specs[1],
218 Spec {
219 file_name: "test.ts".into(),
220 file_text: "start2\n".into(),
221 range: None,
222 expected_text: "expected2\n".into(),
223 expected_range: None,
224 message: "message 2 (only) (skip) (skip-format-twice)".into(),
225 is_only: true,
226 is_trace: false,
227 skip: true,
228 skip_format_twice: true,
229 config: Default::default(),
230 }
231 );
232 assert_eq!(
233 specs[2],
234 Spec {
235 file_name: "test.ts".into(),
236 file_text: "test\n".into(),
237 range: None,
238 expected_text: "test\n".into(),
239 expected_range: None,
240 message: "message 3 (trace)".into(),
241 is_only: true,
242 is_trace: true,
243 skip: false,
244 skip_format_twice: false,
245 config: Default::default(),
246 }
247 );
248 }
249
250 #[test]
251 fn it_parses_with_file_name() {
252 let specs = parse_specs(
253 vec!["-- asdf.ts --", "== message ==", "start", "[expect]", "expected"].join("\n"),
254 &ParseSpecOptions { default_file_name: "test.ts" },
255 );
256
257 assert_eq!(specs.len(), 1);
258 assert_eq!(
259 specs[0],
260 Spec {
261 file_name: "asdf.ts".into(),
262 file_text: "start".into(),
263 range: None,
264 expected_text: "expected".into(),
265 expected_range: None,
266 message: "message".into(),
267 is_only: false,
268 is_trace: false,
269 skip: false,
270 skip_format_twice: false,
271 config: Default::default(),
272 }
273 );
274 }
275
276 #[test]
277 fn it_parses_with_config() {
278 let specs = parse_specs(
279 vec![
280 "-- asdf.ts --",
281 "~~ test.test: other, lineWidth: 40 ~~",
282 "== message ==",
283 "start",
284 "[expect]",
285 "expected",
286 ]
287 .join("\n"),
288 &ParseSpecOptions { default_file_name: "test.ts" },
289 );
290
291 assert_eq!(specs.len(), 1);
292 assert_eq!(
293 specs[0],
294 Spec {
295 file_name: "asdf.ts".into(),
296 file_text: "start".into(),
297 range: None,
298 expected_text: "expected".into(),
299 expected_range: None,
300 message: "message".into(),
301 is_only: false,
302 is_trace: false,
303 skip: false,
304 skip_format_twice: false,
305 config: [("test.test".into(), "other".into()), ("lineWidth".into(), 40.into())]
306 .iter()
307 .cloned()
308 .collect(),
309 }
310 );
311 }
312
313 #[test]
314 fn it_parses_ranges() {
315 let specs = parse_specs(
316 [
317 "== message ==",
318 "a [|b|] c",
319 "[expect]",
320 "a [|B|] c",
321 "",
322 "== cursor ==",
323 "a [||]b",
324 "[expect]",
325 "a b",
326 ]
327 .join("\n"),
328 &ParseSpecOptions { default_file_name: "test.ts" },
329 );
330
331 assert_eq!(specs.len(), 2);
332 assert_eq!(specs[0].file_text, "a b c");
333 assert_eq!(specs[0].range, Some(2..3));
334 assert_eq!(specs[0].expected_text, "a B c\n");
335 assert_eq!(specs[0].expected_range, Some(2..3));
336 assert_eq!(specs[1].file_text, "a b");
337 assert_eq!(specs[1].range, Some(2..2));
338 assert_eq!(specs[1].expected_text, "a b");
339 assert_eq!(specs[1].expected_range, None);
340 }
341
342 #[test]
343 #[should_panic(expected = "Found [| without |].")]
344 fn it_panics_on_unclosed_range() {
345 parse_range_spec("a [|b");
346 }
347
348 #[test]
349 #[should_panic(expected = "Found [| without |].")]
350 fn it_panics_on_overlapping_markers() {
351 parse_range_spec("a [|]b");
352 }
353
354 #[test]
355 #[should_panic(expected = "Only one range may be specified per text.")]
356 fn it_panics_on_end_before_start() {
357 parse_range_spec("a |] [|b|]");
358 }
359
360 #[test]
361 #[should_panic(expected = "Only one range may be specified per text.")]
362 fn it_panics_on_multiple_ranges() {
363 parse_range_spec("[|a|] [|b|]");
364 }
365
366 #[test]
367 #[should_panic(expected = "Only a spec with a range in its text can have a range in its expected text.")]
368 fn it_panics_on_range_only_in_expected() {
369 parse_specs(
370 ["== message ==", "a b", "[expect]", "a [|b|]"].join("\n"),
371 &ParseSpecOptions { default_file_name: "test.ts" },
372 );
373 }
374
375 fn parse_range_spec(text: &str) -> Vec<Spec> {
376 parse_specs(
377 ["== message ==", text, "[expect]", "a b"].join("\n"),
378 &ParseSpecOptions { default_file_name: "test.ts" },
379 )
380 }
381
382 #[test]
383 fn it_parses_markdown() {
384 let specs = parse_specs(
385 vec![
386 "!! message 1 !!",
387 "start",
388 "multiple",
389 "",
390 "[expect]",
391 "expected",
392 "multiple",
393 "",
394 "!! message 2 (only) (skip) (skip-format-twice) !!",
395 "start2",
396 "",
397 "[expect]",
398 "expected2",
399 "",
400 ]
401 .join("\n"),
402 &ParseSpecOptions { default_file_name: "test.md" },
403 );
404
405 assert_eq!(specs.len(), 2);
406 assert_eq!(
407 specs[0],
408 Spec {
409 file_name: "test.md".into(),
410 file_text: "start\nmultiple\n".into(),
411 range: None,
412 expected_text: "expected\nmultiple\n".into(),
413 expected_range: None,
414 message: "message 1".into(),
415 is_only: false,
416 is_trace: false,
417 skip: false,
418 skip_format_twice: false,
419 config: Default::default(),
420 }
421 );
422 assert_eq!(
423 specs[1],
424 Spec {
425 file_name: "test.md".into(),
426 file_text: "start2\n".into(),
427 range: None,
428 expected_text: "expected2\n".into(),
429 expected_range: None,
430 message: "message 2 (only) (skip) (skip-format-twice)".into(),
431 is_only: true,
432 is_trace: false,
433 skip: true,
434 skip_format_twice: true,
435 config: Default::default(),
436 }
437 );
438 }
439}