Skip to main content

dprint_plugin_json/
format_text.rs

1use std::path::Path;
2
3use dprint_core::configuration::resolve_new_line_kind;
4use dprint_core::formatting::PrintOptions;
5use jsonc_parser::CollectOptions;
6use jsonc_parser::CommentCollectionStrategy;
7use jsonc_parser::ParseResult;
8use jsonc_parser::errors::ParseError;
9use jsonc_parser::parse_to_ast;
10
11use super::configuration::Configuration;
12use super::generation::generate;
13
14/// Error that occurs while formatting.
15///
16/// The [`Display`](std::fmt::Display) output is a formatted diagnostic, while
17/// the underlying [`ParseError`] can be recovered via [`Error::source`](std::error::Error::source).
18#[derive(Debug, thiserror::Error)]
19#[error("{diagnostic}")]
20pub struct FormatError {
21  diagnostic: String,
22  #[source]
23  source: ParseError,
24}
25
26impl FormatError {
27  /// The error message without position or source highlight (ex. `Unexpected token`).
28  pub fn message(&self) -> String {
29    self.source.kind().to_string()
30  }
31
32  /// The parser error that caused this formatting error.
33  pub fn parse_error(&self) -> &ParseError {
34    &self.source
35  }
36}
37
38pub fn format_text(path: &Path, text: &str, config: &Configuration) -> Result<Option<String>, FormatError> {
39  let result = format_text_inner(path, text, config)?;
40  if result == text { Ok(None) } else { Ok(Some(result)) }
41}
42
43fn format_text_inner(path: &Path, text: &str, config: &Configuration) -> Result<String, FormatError> {
44  let text = strip_bom(text);
45  let parse_result = parse(text)?;
46  let is_jsonc = is_jsonc_file(path, config);
47  Ok(dprint_core::formatting::format(
48    || generate(parse_result, text, config, is_jsonc),
49    config_to_print_options(text, config),
50  ))
51}
52
53#[cfg(feature = "tracing")]
54pub fn trace_file(text: &str, config: &Configuration) -> dprint_core::formatting::TracingResult {
55  let parse_result = parse(text).unwrap();
56
57  dprint_core::formatting::trace_printing(
58    || generate(parse_result, text, config),
59    config_to_print_options(text, config),
60  )
61}
62
63fn strip_bom(text: &str) -> &str {
64  text.strip_prefix("\u{FEFF}").unwrap_or(text)
65}
66
67fn parse(text: &str) -> Result<ParseResult<'_>, FormatError> {
68  let parse_result = parse_to_ast(
69    text,
70    &CollectOptions {
71      comments: CommentCollectionStrategy::Separate,
72      tokens: true,
73    },
74    &Default::default(),
75  );
76  match parse_result {
77    Ok(result) => Ok(result),
78    Err(err) => {
79      let diagnostic = dprint_core::formatting::utils::string_utils::format_diagnostic(
80        Some((err.range().start, err.range().end)),
81        &err.kind().to_string(),
82        text,
83      );
84      Err(FormatError { diagnostic, source: err })
85    }
86  }
87}
88
89fn config_to_print_options(text: &str, config: &Configuration) -> PrintOptions {
90  PrintOptions {
91    indent_width: config.indent_width,
92    max_width: config.line_width,
93    use_tabs: config.use_tabs,
94    new_line_text: resolve_new_line_kind(text, config.new_line_kind),
95  }
96}
97
98fn is_jsonc_file(path: &Path, config: &Configuration) -> bool {
99  fn has_jsonc_extension(path: &Path) -> bool {
100    if let Some(ext) = path.extension() {
101      return ext.to_string_lossy().to_ascii_lowercase() == "jsonc";
102    }
103
104    false
105  }
106
107  fn is_special_json_file(path: &Path, config: &Configuration) -> bool {
108    let path = path.to_string_lossy();
109    for file_name in &config.json_trailing_comma_files {
110      if path.ends_with(file_name) {
111        return true;
112      }
113    }
114
115    false
116  }
117
118  has_jsonc_extension(path) || is_special_json_file(path, config)
119}
120
121#[cfg(test)]
122mod tests {
123  use std::path::PathBuf;
124
125  use crate::configuration::ConfigurationBuilder;
126
127  use super::super::configuration::resolve_config;
128  use super::*;
129  use dprint_core::configuration::*;
130
131  #[test]
132  fn should_error_on_syntax_diagnostic() {
133    let global_config = GlobalConfiguration::default();
134    let config = resolve_config(ConfigKeyMap::new(), &global_config).config;
135    let message = format_text(Path::new("."), "{ &*&* }", &config)
136      .err()
137      .unwrap()
138      .to_string();
139    assert_eq!(
140      message,
141      concat!("Line 1, column 3: Unexpected token\n", "\n", "  { &*&* }\n", "    ~")
142    );
143  }
144
145  #[test]
146  fn no_panic_diagnostic_at_multibyte_char() {
147    let global_config = GlobalConfiguration::default();
148    let config = resolve_config(ConfigKeyMap::new(), &global_config).config;
149    let message = format_text(Path::new("."), "{ \"a\":\u{200b}5 }", &config)
150      .err()
151      .unwrap()
152      .to_string();
153    assert_eq!(
154      message,
155      "Line 1, column 7: Unexpected token\n\n  { \"a\":\u{200b}5 }\n        ~"
156    );
157  }
158
159  #[test]
160  fn no_panic_diagnostic_multiple_values() {
161    let global_config = GlobalConfiguration::default();
162    let config = resolve_config(ConfigKeyMap::new(), &global_config).config;
163    let message = format_text(Path::new("."), "{},\n", &config).err().unwrap().to_string();
164    assert_eq!(
165      message,
166      "Line 1, column 3: Text cannot contain more than one JSON value\n\n  {},\n    ~"
167    );
168  }
169
170  #[test]
171  fn test_is_jsonc_file() {
172    let config = ConfigurationBuilder::new()
173      .json_trailing_comma_files(vec!["tsconfig.json".to_string(), ".vscode/settings.json".to_string()])
174      .build();
175    assert!(!is_jsonc_file(&PathBuf::from("/asdf.json"), &config));
176    assert!(is_jsonc_file(&PathBuf::from("/asdf.jsonc"), &config));
177    assert!(is_jsonc_file(&PathBuf::from("/ASDF.JSONC"), &config));
178    assert!(is_jsonc_file(&PathBuf::from("/tsconfig.json"), &config));
179    assert!(is_jsonc_file(&PathBuf::from("/test/.vscode/settings.json"), &config));
180    assert!(!is_jsonc_file(&PathBuf::from("/test/vscode/settings.json"), &config));
181    if cfg!(windows) {
182      assert!(is_jsonc_file(&PathBuf::from("test\\.vscode\\settings.json"), &config));
183    }
184  }
185
186  #[test]
187  fn should_strip_bom() {
188    for input_text in ["\u{FEFF}{}", "\u{FEFF}{ }"] {
189      let global_config = GlobalConfiguration::default();
190      let config = resolve_config(ConfigKeyMap::new(), &global_config).config;
191      let output_text = format_text(Path::new("."), input_text, &config).unwrap().unwrap();
192      assert_eq!(output_text, "{}\n");
193    }
194  }
195}