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    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}