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()..]); let lower_case_message_line = message_line.to_ascii_lowercase();
119 let message_separator = get_message_separator(file_name);
120 let is_trace = lower_case_message_line.contains("(trace)");
121
122 Spec {
123 file_name: String::from(file_name),
124 message: message_line[message_separator.len()..message_line.len() - message_separator.len()]
125 .trim()
126 .into(),
127 file_text: start_text,
128 range,
129 expected_text,
130 expected_range,
131 is_only: lower_case_message_line.contains("(only)") || is_trace,
132 is_trace,
133 skip: lower_case_message_line.contains("(skip)"),
134 skip_format_twice: lower_case_message_line.contains("(skip-format-twice)"),
135 config: config.clone(),
136 }
137 }
138
139 fn parse_range(text: &str) -> (String, Option<Range<usize>>) {
140 let Some(start) = text.find(RANGE_START) else {
141 assert!(!text.contains(RANGE_END), "Found {} without {}.", RANGE_END, RANGE_START);
142 return (text.to_string(), None);
143 };
144 let end = text[start..]
145 .find(RANGE_END)
146 .unwrap_or_else(|| panic!("Found {} without {}.", RANGE_START, RANGE_END))
147 + start
148 - RANGE_START.len();
149 let text = text.replacen(RANGE_START, "", 1).replacen(RANGE_END, "", 1);
150 assert!(
151 !text.contains(RANGE_START) && !text.contains(RANGE_END),
152 "Only one range may be specified per text."
153 );
154 (text, Some(start..end))
155 }
156
157 fn get_message_separator(file_name: &str) -> &'static str {
158 if file_name.ends_with(".md") { "!!" } else { "==" }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn it_parses() {
168 let specs = parse_specs(
169 vec![
170 "== message 1 ==",
171 "start",
172 "multiple",
173 "",
174 "[expect]",
175 "expected",
176 "multiple",
177 "",
178 "== message 2 (only) (skip) (skip-format-twice) ==",
179 "start2",
180 "",
181 "[expect]",
182 "expected2",
183 "",
184 "== message 3 (trace) ==",
185 "test",
186 "",
187 "[expect]",
188 "test",
189 "",
190 ]
191 .join("\n"),
192 &ParseSpecOptions { default_file_name: "test.ts" },
193 );
194
195 assert_eq!(specs.len(), 3);
196 assert_eq!(
197 specs[0],
198 Spec {
199 file_name: "test.ts".into(),
200 file_text: "start\nmultiple\n".into(),
201 range: None,
202 expected_text: "expected\nmultiple\n".into(),
203 expected_range: None,
204 message: "message 1".into(),
205 is_only: false,
206 is_trace: false,
207 skip: false,
208 skip_format_twice: false,
209 config: Default::default(),
210 }
211 );
212 assert_eq!(
213 specs[1],
214 Spec {
215 file_name: "test.ts".into(),
216 file_text: "start2\n".into(),
217 range: None,
218 expected_text: "expected2\n".into(),
219 expected_range: None,
220 message: "message 2 (only) (skip) (skip-format-twice)".into(),
221 is_only: true,
222 is_trace: false,
223 skip: true,
224 skip_format_twice: true,
225 config: Default::default(),
226 }
227 );
228 assert_eq!(
229 specs[2],
230 Spec {
231 file_name: "test.ts".into(),
232 file_text: "test\n".into(),
233 range: None,
234 expected_text: "test\n".into(),
235 expected_range: None,
236 message: "message 3 (trace)".into(),
237 is_only: true,
238 is_trace: true,
239 skip: false,
240 skip_format_twice: false,
241 config: Default::default(),
242 }
243 );
244 }
245
246 #[test]
247 fn it_parses_with_file_name() {
248 let specs = parse_specs(
249 vec!["-- asdf.ts --", "== message ==", "start", "[expect]", "expected"].join("\n"),
250 &ParseSpecOptions { default_file_name: "test.ts" },
251 );
252
253 assert_eq!(specs.len(), 1);
254 assert_eq!(
255 specs[0],
256 Spec {
257 file_name: "asdf.ts".into(),
258 file_text: "start".into(),
259 range: None,
260 expected_text: "expected".into(),
261 expected_range: None,
262 message: "message".into(),
263 is_only: false,
264 is_trace: false,
265 skip: false,
266 skip_format_twice: false,
267 config: Default::default(),
268 }
269 );
270 }
271
272 #[test]
273 fn it_parses_with_config() {
274 let specs = parse_specs(
275 vec![
276 "-- asdf.ts --",
277 "~~ test.test: other, lineWidth: 40 ~~",
278 "== message ==",
279 "start",
280 "[expect]",
281 "expected",
282 ]
283 .join("\n"),
284 &ParseSpecOptions { default_file_name: "test.ts" },
285 );
286
287 assert_eq!(specs.len(), 1);
288 assert_eq!(
289 specs[0],
290 Spec {
291 file_name: "asdf.ts".into(),
292 file_text: "start".into(),
293 range: None,
294 expected_text: "expected".into(),
295 expected_range: None,
296 message: "message".into(),
297 is_only: false,
298 is_trace: false,
299 skip: false,
300 skip_format_twice: false,
301 config: [("test.test".into(), "other".into()), ("lineWidth".into(), 40.into())]
302 .iter()
303 .cloned()
304 .collect(),
305 }
306 );
307 }
308
309 #[test]
310 fn it_parses_ranges() {
311 let specs = parse_specs(
312 [
313 "== message ==",
314 "a [|b|] c",
315 "[expect]",
316 "a [|B|] c",
317 "",
318 "== cursor ==",
319 "a [||]b",
320 "[expect]",
321 "a b",
322 ]
323 .join(
324 "
325",
326 ),
327 &ParseSpecOptions { default_file_name: "test.ts" },
328 );
329
330 assert_eq!(specs.len(), 2);
331 assert_eq!(specs[0].file_text, "a b c");
332 assert_eq!(specs[0].range, Some(2..3));
333 assert_eq!(
334 specs[0].expected_text,
335 "a B c
336"
337 );
338 assert_eq!(specs[0].expected_range, Some(2..3));
339 assert_eq!(specs[1].file_text, "a b");
340 assert_eq!(specs[1].range, Some(2..2));
341 assert_eq!(specs[1].expected_text, "a b");
342 assert_eq!(specs[1].expected_range, None);
343 }
344
345 #[test]
346 #[should_panic(expected = "Found [| without |].")]
347 fn it_panics_on_unclosed_range() {
348 parse_specs(
349 "== message ==
350a [|b
351[expect]
352a b"
353 .to_string(),
354 &ParseSpecOptions { default_file_name: "test.ts" },
355 );
356 }
357
358 #[test]
359 fn it_parses_markdown() {
360 let specs = parse_specs(
361 vec![
362 "!! message 1 !!",
363 "start",
364 "multiple",
365 "",
366 "[expect]",
367 "expected",
368 "multiple",
369 "",
370 "!! message 2 (only) (skip) (skip-format-twice) !!",
371 "start2",
372 "",
373 "[expect]",
374 "expected2",
375 "",
376 ]
377 .join("\n"),
378 &ParseSpecOptions { default_file_name: "test.md" },
379 );
380
381 assert_eq!(specs.len(), 2);
382 assert_eq!(
383 specs[0],
384 Spec {
385 file_name: "test.md".into(),
386 file_text: "start\nmultiple\n".into(),
387 range: None,
388 expected_text: "expected\nmultiple\n".into(),
389 expected_range: None,
390 message: "message 1".into(),
391 is_only: false,
392 is_trace: false,
393 skip: false,
394 skip_format_twice: false,
395 config: Default::default(),
396 }
397 );
398 assert_eq!(
399 specs[1],
400 Spec {
401 file_name: "test.md".into(),
402 file_text: "start2\n".into(),
403 range: None,
404 expected_text: "expected2\n".into(),
405 expected_range: None,
406 message: "message 2 (only) (skip) (skip-format-twice)".into(),
407 is_only: true,
408 is_trace: false,
409 skip: true,
410 skip_format_twice: true,
411 config: Default::default(),
412 }
413 );
414 }
415}