Skip to main content

dprint_development/
spec_parser.rs

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  /// Byte range of `file_text` to format, marked in the spec with `[|` and `|]`.
9  pub range: Option<Range<usize>>,
10  pub expected_text: String,
11  /// Byte range of `expected_text` to format when formatting it a second time.
12  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
20/// Marks the start of the range to format in a spec's text.
21pub const RANGE_START: &str = "[|";
22/// Marks the end of the range to format in a spec's text.
23pub const RANGE_END: &str = "|]";
24
25pub type SpecConfigMap = serde_json::Map<String, serde_json::Value>;
26
27#[derive(Debug, Clone)]
28pub struct ParseSpecOptions {
29  /// The default file name for a parsed spec.
30  pub default_file_name: &'static str,
31}
32
33pub fn parse_specs(file_text: String, options: &ParseSpecOptions) -> Vec<Spec> {
34  // this function needs a rewrite
35  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()]); // remove last newline
117    let (expected_text, expected_range) = parse_range(&parts[1]["\n".len()..]); // remove first newline
118    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}