Repository: dprint/dprint (language: rust)

Issue:
Add trailing comma when `config add`ing to a jsonc file
Version: 0.47.2

When `dprint config add` adds a plugin to the config, and the config is a jsonc one, the addition is done without adding a trailing comma to the added line. It would be good to add the comma in that case, as the reason for using jsonc is in my opinion quite likely to be the ability to use those trailing commas in the first place.


Discussion / hints:
Luckily I have been working on this full time this week: https://github.com/dprint/jsonc-parser/pull/41

It should help make this easier.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 1b425a212..d1e30b6b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1277,9 +1277,9 @@ dependencies = [
 
 [[package]]
 name = "jsonc-parser"
-version = "0.23.0"
+version = "0.24.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7725c320caac8c21d8228c1d055af27a995d371f78cc763073d3e068323641b5"
+checksum = "2ebcfcb2f2b016f7372835b99cd3be24cc13ce9823107beb0f98b09c6092ba9f"
 dependencies = [
  "indexmap 2.2.6",
  "serde_json",
diff --git a/crates/dprint/Cargo.toml b/crates/dprint/Cargo.toml
index 621a377a5..3d7625b53 100644
--- a/crates/dprint/Cargo.toml
+++ b/crates/dprint/Cargo.toml
@@ -26,7 +26,7 @@ dunce = "=1.0.4"
 fs3 = "=0.5.0"
 ignore = "=0.4.22"
 indexmap = { version = "=2.2.6", features = ["serde"] }
-jsonc-parser = { version = "=0.23.0", features = ["preserve_order"] }
+jsonc-parser = { version = "=0.24.0", features = ["cst", "preserve_order"] }
 once_cell = "=1.19.0"
 parking_lot = "=0.12.3"
 percent-encoding = "=2.3.1"
diff --git a/crates/dprint/src/commands/config.rs b/crates/dprint/src/commands/config.rs
index 316f6cac4..2f60e7a45 100644
--- a/crates/dprint/src/commands/config.rs
+++ b/crates/dprint/src/commands/config.rs
@@ -344,7 +344,7 @@ async fn run_plugin_config_updates<TEnvironment: Environment>(
         continue;
       }
 
-      let result = apply_config_changes(file_text, config_key, &changes);
+      let result = apply_config_changes(&file_text, config_key, &changes);
       all_diagnostics.extend(result.diagnostics);
       file_text = result.new_text;
     }
@@ -771,6 +771,7 @@ mod test {
     expected_urls: Vec<String>,
   }
 
+  #[track_caller]
   fn test_add(options: TestAddOptions) {
     let expected_logs = options.expected_logs.clone();
     let expected_urls = options.expected_urls.clone();
@@ -1043,7 +1044,7 @@ mod test {
   },
   "should_set": "other",
   "should_remove": {},
-  "should_set_past_version": "",
+  "should_set_past_version": ""
 }"#,
       );
       config.add_config_section(
@@ -1053,7 +1054,7 @@ mod test {
   },
   "should_set": "other",
   "should_remove": {},
-  "should_set_past_version": "",
+  "should_set_past_version": ""
 }"#,
       );
     });
@@ -1095,23 +1096,19 @@ mod test {
     "should_add": "new_value",
     "should_set": "new_value",
     "should_set_past_version": "0.1.0",
-    "new_prop1": [
-      "new_value"
-    ],
+    "new_prop1": ["new_value"],
     "new_prop2": {{
       "new_prop": "new_value"
-    }},
+    }}
   }},
   "test-plugin": {{
     "should_add": "new_value_wasm",
     "should_set": "new_value_wasm",
     "should_set_past_version": "0.1.0",
-    "new_prop1": [
-      "new_value_wasm"
-    ],
+    "new_prop1": ["new_value_wasm"],
     "new_prop2": {{
       "new_prop": "new_value_wasm"
-    }},
+    }}
   }},
   "plugins": [
     "https://plugins.dprint.dev/test-plugin.wasm",
diff --git a/crates/dprint/src/configuration/deserialize_config.rs b/crates/dprint/src/configuration/deserialize_config.rs
index b04a8b4e4..e470f4ff1 100644
--- a/crates/dprint/src/configuration/deserialize_config.rs
+++ b/crates/dprint/src/configuration/deserialize_config.rs
@@ -171,7 +171,7 @@ mod tests {
 
   #[test]
   fn should_error_when_there_is_a_parser_error() {
-    assert_error("{prop}", "Unexpected token on line 1 column 2.");
+    assert_error("{prop}", "Unexpected token on line 1 column 2");
   }
 
   #[test]
diff --git a/crates/dprint/src/configuration/manipulation.rs b/crates/dprint/src/configuration/manipulation.rs
index 5b42d5eed..0e71573cb 100644
--- a/crates/dprint/src/configuration/manipulation.rs
+++ b/crates/dprint/src/configuration/manipulation.rs
@@ -1,5 +1,3 @@
-use std::ops::Range;
-
 use anyhow::anyhow;
 use anyhow::bail;
 use anyhow::Context;
@@ -8,17 +6,16 @@ use dprint_core::configuration::ConfigKeyValue;
 use dprint_core::plugins::ConfigChange;
 use dprint_core::plugins::ConfigChangeKind;
 use dprint_core::plugins::ConfigChangePathItem;
-use jsonc_parser::ast::Array;
-use jsonc_parser::ast::Node;
-use jsonc_parser::ast::Object;
-use jsonc_parser::common::Ranged;
+use jsonc_parser::cst::CstContainerNode;
+use jsonc_parser::cst::CstInputValue;
+use jsonc_parser::cst::CstLeafNode;
+use jsonc_parser::cst::CstNode;
+use jsonc_parser::cst::CstObject;
+use jsonc_parser::json;
 
 use crate::plugins::PluginSourceReference;
 use crate::utils::PluginKind;
 
-// This entire module is pretty bad. It would be better to add some manipulation
-// capabilities to jsonc-parser.
-
 #[derive(Debug)]
 pub struct PluginUpdateInfo {
   pub name: String,
@@ -50,45 +47,12 @@ pub fn update_plugin_in_config(file_text: &str, info: &PluginUpdateInfo) -> Stri
 }
 
 pub fn add_to_plugins_array(file_text: &str, url: &str) -> Result<String> {
-  let root_obj = JsonRootObject::parse(file_text)?.root;
+  let root_node = jsonc_parser::cst::CstRootNode::parse(file_text, &Default::default()).context("Failed parsing config file.")?;
+  let root_obj = get_root_object(&root_node)?;
   let plugins = get_plugins_array(&root_obj)?;
-  let indentation_text = get_indentation_text(file_text, &root_obj);
-  let newline_char = get_newline_char(file_text);
-  // don't worry about comments or anything... too much to deal with
-  let start_pos = if let Some(last_element) = plugins.elements.last() {
-    last_element.end()
-  } else {
-    plugins.start() + 1
-  };
-  let end_pos = plugins.end() - 1;
-  let mut final_text = String::new();
-  final_text.push_str(&file_text[..start_pos]);
-  if !plugins.elements.is_empty() {
-    final_text.push(',');
-  }
-  final_text.push_str(&newline_char);
-  final_text.push_str(&indentation_text);
-  final_text.push_str(&indentation_text);
-  final_text.push_str(&format!("\"{}\"", url));
-  final_text.push_str(&newline_char);
-  final_text.push_str(&indentation_text);
-  final_text.push_str(&file_text[end_pos..]);
-  Ok(final_text)
-}
-
-pub struct JsonRootObject<'a> {
-  root: Object<'a>,
-}
-
-impl<'a> JsonRootObject<'a> {
-  pub fn parse(file_text: &'a str) -> Result<Self> {
-    let json_file =
-      jsonc_parser::parse_to_ast(file_text, &Default::default(), &Default::default()).with_context(|| "Error parsing config file.".to_string())?;
-    match json_file.value {
-      Some(jsonc_parser::ast::Value::Object(obj)) => Ok(Self { root: obj }),
-      _ => bail!("Please ensure your config file has an object in it to use this feature."),
-    }
-  }
+  plugins.ensure_multiline();
+  plugins.append(jsonc_parser::cst::CstInputValue::String(url.to_string()));
+  Ok(root_node.to_string())
 }
 
 #[derive(Default)]
@@ -97,89 +61,54 @@ pub struct ApplyConfigChangesResult {
   pub diagnostics: Vec<String>,
 }
 
-#[derive(Clone)]
-struct IndentText<'a> {
-  text: &'a str,
-  level: usize,
-}
-
-impl<'a> IndentText<'a> {
-  pub fn inc(&self) -> Self {
-    Self {
-      text: self.text,
-      level: self.level + 1,
-    }
-  }
-
-  pub fn render(&self) -> String {
-    self.text.repeat(self.level)
-  }
-}
-
-#[derive(Clone, Debug)]
-struct TextChange {
-  pub range: Range<usize>,
-  pub new_text: String,
-}
-
-pub fn apply_config_changes(mut file_text: String, plugin_key: &str, changes: &[ConfigChange]) -> ApplyConfigChangesResult {
+pub fn apply_config_changes(file_text: &str, plugin_key: &str, changes: &[ConfigChange]) -> ApplyConfigChangesResult {
   let mut diagnostics = Vec::new();
+  let root_node = match jsonc_parser::cst::CstRootNode::parse(file_text, &Default::default()) {
+    Ok(root_node) => root_node,
+    Err(err) => {
+      diagnostics.push(format!("Failed applying change since config file failed to parse: {:#}", err));
+      return ApplyConfigChangesResult {
+        new_text: file_text.to_string(),
+        diagnostics,
+      };
+    }
+  };
+  let root_obj = match get_root_object(&root_node) {
+    Ok(root_obj) => root_obj,
+    Err(err) => {
+      diagnostics.push(format!("Failed applying change: {:#}", err));
+      return ApplyConfigChangesResult {
+        new_text: file_text.to_string(),
+        diagnostics,
+      };
+    }
+  };
 
-  // it's much more reliable to reparse between each change
   for change in changes {
-    let root_obj = match JsonRootObject::parse(&file_text) {
-      Ok(root_obj) => root_obj,
-      Err(err) => {
-        diagnostics.push(format!("Failed applying change since config file failed to parse: {:#}", err));
-        return ApplyConfigChangesResult {
-          new_text: file_text.to_string(),
-          diagnostics,
-        };
-      }
-    };
-    let Some(plugin_obj) = root_obj.root.get_object(plugin_key) else {
+    let Some(plugin_obj) = root_obj.get_object(plugin_key) else {
       return Default::default();
     };
-    let indent_text = get_indentation_text(&file_text, &root_obj.root);
-    let indent_text = IndentText { text: &indent_text, level: 1 };
-    let text_change = match &change.kind {
-      ConfigChangeKind::Add(value) => match apply_add(indent_text.clone(), plugin_obj, &change.path, value) {
-        Ok(text_change) => Some(text_change),
-        Err(err) => {
+    match &change.kind {
+      ConfigChangeKind::Add(value) => {
+        if let Err(err) = apply_add(plugin_obj, &change.path, value) {
           diagnostics.push(format!("Failed adding item at path '{}': {}", display_path(plugin_key, &change.path), err));
-          None
         }
-      },
-      ConfigChangeKind::Set(value) => match apply_set(plugin_obj, &change.path, value, indent_text.clone()) {
-        Ok(text_change) => Some(text_change),
-        Err(err) => {
+      }
+      ConfigChangeKind::Set(value) => {
+        if let Err(err) = apply_set(plugin_obj, &change.path, value) {
           diagnostics.push(format!("Failed setting item at path '{}': {}", display_path(plugin_key, &change.path), err));
-          None
         }
-      },
-      ConfigChangeKind::Remove => match apply_remove(&file_text, plugin_obj, &change.path) {
-        Ok(range) => Some(TextChange {
-          range,
-          new_text: String::new(),
-        }),
-        Err(err) => {
+      }
+      ConfigChangeKind::Remove => {
+        if let Err(err) = apply_remove(plugin_obj, &change.path) {
           diagnostics.push(format!("Failed removing item at path '{}': {}", display_path(plugin_key, &change.path), err));
-          None
         }
-      },
+      }
     };
-    if let Some(text_change) = text_change {
-      file_text = format!(
-        "{}{}{}",
-        &file_text[0..text_change.range.start],
-        text_change.new_text,
-        &file_text[text_change.range.end..]
-      );
-    }
   }
 
   ApplyConfigChangesResult {
-    new_text: file_text,
+    new_text: root_node.to_string(),
     diagnostics,
   }
 }
@@ -202,73 +131,48 @@ fn display_path(plugin_key: &str, path: &[ConfigChangePathItem]) -> String {
   text
 }
 
-fn apply_add(mut indent_text: IndentText, plugin_obj: &Object, path: &[ConfigChangePathItem], value: &ConfigKeyValue) -> Result<TextChange> {
-  let mut current_node = Node::Object(plugin_obj);
+fn apply_add(plugin_obj: CstObject, path: &[ConfigChangePathItem], value: &ConfigKeyValue) -> Result<()> {
+  let mut current_node: CstNode = plugin_obj.into();
   for (path_index, path_item) in path.iter().enumerate() {
-    indent_text = indent_text.inc();
     match path_item {
       ConfigChangePathItem::String(key) => {
         if path_index == path.len() - 1 {
           let maybe_array_prop = current_node.as_object().and_then(|obj| obj.get_array(key));
           match maybe_array_prop {
             Some(array) => {
-              let indent_text = indent_text.inc();
-              match array.elements.last() {
-                Some(last_property) => {
-                  let prop_end = last_property.range().end();
-                  return Ok(TextChange {
-                    range: prop_end..prop_end,
-                    new_text: format!(",\n{}{}", indent_text.render(), config_value_to_json_text(value, &indent_text)),
-                  });
-                }
-                None => {
-                  let after_bracket_pos = array.start() + 1;
-                  return Ok(TextChange {
-                    range: after_bracket_pos..after_bracket_pos,
-                    new_text: format!("\n{}{}", indent_text.render(), config_value_to_json_text(value, &indent_text)),
-                  });
-                }
+              array.append(config_value_to_cst_json(value));
+              return Ok(());
+            }
+            None => {
+              if let Some(obj) = current_node.as_object() {
+                obj.append(key, config_value_to_cst_json(value));
+                return Ok(());
+              } else {
+                bail!("Unsupported. Could not add into {:?} with string key '{}'", current_node.to_string(), key)
               }
             }
-            None => match current_node {
-              Node::Object(obj) => match obj.properties.last() {
-                Some(last_property) => {
-                  let prop_end = last_property.range().end();
-                  return Ok(TextChange {
-                    range: prop_end..prop_end,
-                    new_text: format!(",\n{}\"{}\": {}", indent_text.render(), key, config_value_to_json_text(value, &indent_text)),
-                  });
-                }
-                None => {
-                  let after_brace_pos = obj.start() + 1;
-                  return Ok(TextChange {
-                    range: after_brace_pos..after_brace_pos,
-                    new_text: format!("\n{}\"{}\": {}", indent_text.render(), key, config_value_to_json_text(value, &indent_text)),
-                  });
-                }
-              },
-              _ => bail!("Unsupported. Could not add into {:?} with string key '{}'", current_node.kind(), key),
-            },
           }
         } else {
           let property = current_node
             .as_object()
             .and_then(|obj| obj.get(key))
-            .ok_or_else(|| anyhow!("Expected property '{}' in path.", key))?;
-          current_node = (&property.value).into();
+            .ok_or_else(|| anyhow!("Expected property '{}'.", key))?;
+          let value = property.value().ok_or_else(|| anyhow!("Expected value for property '{}'.", key))?;
+          current_node = value.into();
         }
       }
       ConfigChangePathItem::Number(array_index) => {
         let array_index = *array_index;
-        let array = current_node.as_array().ok_or_else(|| anyhow!("Expected array in path."))?;
-        if array_index >= array.elements.len() {
-          bail!("Expected array index '{}' to be less than the length of the array.", array_index);
-        }
-        let element = array.elements.get(array_index).unwrap();
+        let array = current_node.as_array().ok_or_else(|| anyhow!("Expected array."))?;
         if path_index == path.len() - 1 {
-          bail!("Adding into an array at an index is not currently supported.")
+          array.insert(array_index, config_value_to_cst_json(value));
+          return Ok(());
         } else {
-          current_node = element.into();
+          let mut elements = array.elements();
+          if array_index >= elements.len() {
+            bail!("Expected array index '{}' to be less than the length of the array.", array_index);
+          }
+          current_node = elements.remove(array_index).into();
         }
       }
     }
@@ -277,37 +181,68 @@ fn apply_add(mut indent_text: IndentText, plugin_obj: &Object, path: &[ConfigCha
   bail!("Failed to discover item to add to.")
 }
 
-fn apply_set(plugin_obj: &Object<'_>, path: &[ConfigChangePathItem], value: &ConfigKeyValue, mut indent_text: IndentText) -> Result<TextChange> {
-  let mut current_node = Node::Object(plugin_obj);
+fn apply_set(plugin_obj: CstObject, path: &[ConfigChangePathItem], value: &ConfigKeyValue) -> Result<()> {
+  fn replace_node(node: CstNode, value: CstInputValue) -> Result<()> {
+    match node {
+      CstNode::Container(n) => match n {
+        CstContainerNode::Root(_) => unreachable!(),
+        CstContainerNode::Array(n) => {
+          n.replace_with(value);
+        }
+        CstContainerNode::Object(n) => {
+          n.replace_with(value);
+        }
+        CstContainerNode::ObjectProp(_) => {
+          bail!("Cannot replace an object property.");
+        }
+      },
+      CstNode::Leaf(n) => match n {
+        CstLeafNode::BooleanLit(n) => {
+          n.replace_with(value);
+        }
+        CstLeafNode::NullKeyword(n) => {
+          n.replace_with(value);
+        }
+        CstLeafNode::NumberLit(n) => {
+          n.replace_with(value);
+        }
+        CstLeafNode::StringLit(n) => {
+          n.replace_with(value);
+        }
+        CstLeafNode::WordLit(n) => {
+          n.replace_with(value);
+        }
+        CstLeafNode::Token(_) | CstLeafNode::Whitespace(_) | CstLeafNode::Newline(_) | CstLeafNode::Comment(_) => unreachable!(),
+      },
+    }
+    Ok(())
+  }
+
+  let mut current_node: CstNode = plugin_obj.into();
   for (path_index, path_item) in path.iter().enumerate() {
-    indent_text = indent_text.inc();
     match path_item {
       ConfigChangePathItem::String(key) => {
         let property = current_node
           .as_object()
           .and_then(|obj| obj.get(key))
-          .ok_or_else(|| anyhow!("Expected property '{}' in path.", key))?;
+          .ok_or_else(|| anyhow!("Expected property '{}'.", key))?;
+        let property_value = property.value().ok_or_else(|| anyhow!("Expected value for property '{}'.", key))?;
         if path_index == path.len() - 1 {
-          return Ok(TextChange {
-            range: property.value.start()..property.value.end(),
-            new_text: config_value_to_json_text(value, &indent_text),
-          });
+          return replace_node(property_value, config_value_to_cst_json(value));
         } else {
-          current_node = (&property.value).into();
+          current_node = property_value.into();
         }
       }
       ConfigChangePathItem::Number(array_index) => {
         let array_index = *array_index;
-        let array = current_node.as_array().ok_or_else(|| anyhow!("Expected array in path."))?;
-        if array_index >= array.elements.len() {
+        let array = current_node.as_array().ok_or_else(|| anyhow!("Expected array."))?;
+        let mut elements = array.elements();
+        if array_index >= elements.len() {
           bail!("Expected array index '{}' to be less than the length of the array.", array_index);
         }
-        let element = array.elements.get(array_index).unwrap();
+        let element = elements.remove(array_index);
         if path_index == path.len() - 1 {
-          return Ok(TextChange {
-            range: element.start()..element.end(),
-            new_text: config_value_to_json_text(value, &indent_text),
-          });
+          return replace_node(element, config_value_to_cst_json(value));
         } else {
           current_node = element.into();
         }
@@ -318,56 +253,33 @@ fn apply_set(plugin_obj: &Object<'_>, path: &[ConfigChangePathItem], value: &Con
   bail!("Failed to discover item to set.")
 }
 
-fn apply_remove(file_text: &str, plugin_obj: &jsonc_parser::ast::Object, path: &[ConfigChangePathItem]) -> Result<Range<usize>> {
-  fn get_start_pos(file_text: &str, node: Node) -> usize {
-    let start_pos = node.start();
-    let start_text = &file_text[..start_pos];
-    let trimmed_text = start_text.trim_end_matches(|c| c == ' ' || c == '\t');
-    let trimmed_text = if let Some(text) = trimmed_text.strip_suffix("\r\n") {
-      text
-    } else if let Some(text) = trimmed_text.strip_suffix('\n') {
-      text
-    } else {
-      trimmed_text
-    };
-    let whitespace_width = start_text.len() - trimmed_text.len();
-    start_pos - whitespace_width
-  }
-
-  fn get_end_pos(file_text: &str, node: Node) -> usize {
-    let end_pos = node.end();
-    if file_text[end_pos..].starts_with(',') {
-      end_pos + 1
-    } else {
-      end_pos
-    }
-  }
-
-  let mut current_node = Node::Object(plugin_obj);
+fn apply_remove(plugin_obj: jsonc_parser::cst::CstObject, path: &[ConfigChangePathItem]) -> Result<()> {
+  let mut current_node: CstNode = plugin_obj.into();
   for (path_index, path_item) in path.iter().enumerate() {
     match path_item {
       ConfigChangePathItem::String(key) => {
-        let obj = current_node
-          .as_object()
-          .ok_or_else(|| anyhow!("Expected object for property '{}' in path.", key))?;
-        let property = obj.get(key).ok_or_else(|| anyhow!("Expected property '{}' in path.", key))?;
+        let obj = current_node.as_object().ok_or_else(|| anyhow!("Expected object for property '{}'.", key))?;
+        let property = obj.get(key).ok_or_else(|| anyhow!("Expected property '{}'.", key))?;
         if path_index == path.len() - 1 {
-          return Ok(get_start_pos(file_text, property.into())..get_end_pos(file_text, property.into()));
+          property.remove();
+          return Ok(());
         } else {
-          current_node = (&property.value).into();
+          current_node = property.value().ok_or_else(|| anyhow!("Failed to find value for property '{}'.", key))?;
         }
       }
       ConfigChangePathItem::Number(array_index) => {
         let array_index = *array_index;
-        let array = current_node.as_array().ok_or_else(|| anyhow!("Expected array in path."))?;
-        if array_index >= array.elements.len() {
+        let array = current_node.as_array().ok_or_else(|| anyhow!("Expected array."))?;
+        let mut elements = array.elements();
+        if array_index >= elements.len() {
           bail!("Expected array index '{}' to be less than the length of the array.", array_index);
         }
-        let element = array.elements.get(array_index).unwrap();
+        let element = elements.remove(array_index);
         if path_index == path.len() - 1 {
-          return Ok(get_start_pos(file_text, element.into())..get_end_pos(file_text, element.into()));
+          element.remove();
+          return Ok(());
         } else {
-          current_node = element.into();
+          current_node = element;
         }
       }
     }
@@ -376,75 +288,36 @@ fn apply_remove(file_text: &str, plugin_obj: &jsonc_parser::ast::Object, path: &
   bail!("Failed to discover item to remove.")
 }
 
-fn config_value_to_json_text(value: &ConfigKeyValue, indent_text: &IndentText) -> String {
+fn config_value_to_cst_json(value: &ConfigKeyValue) -> CstInputValue {
   match value {
-    ConfigKeyValue::Bool(value) => value.to_string(),
-    ConfigKeyValue::Number(value) => value.to_string(),
-    ConfigKeyValue::String(value) => format!("\"{}\"", value.replace('\"', "\\\"")),
-    ConfigKeyValue::Array(values) => {
-      let mut text = String::new();
-      text.push('[');
-      for (i, value) in values.iter().enumerate() {
-        if i == 0 {
-          text.push('\n');
-        } else {
-          text.push_str(",\n");
-        }
-        let indent_text = indent_text.inc();
-        text.push_str(&indent_text.render());
-        text.push_str(&config_value_to_json_text(value, &indent_text));
-      }
-      text.push_str(&format!("\n{}]", indent_text.render()));
-      text
-    }
-    ConfigKeyValue::Object(values) => {
-      let mut text = String::new();
-      text.push('{');
-      for (i, (key, value)) in values.iter().enumerate() {
-        if i == 0 {
-          text.push('\n');
-        } else {
-          text.push_str(",\n");
-        }
-        let indent_text = indent_text.inc();
-        text.push_str(&indent_text.render());
-        text.push_str(&format!("\"{}\": {}", key, config_value_to_json_text(value, &indent_text)));
-      }
-      text.push_str(&format!("\n{}}}", indent_text.render()));
-      text
-    }
-    ConfigKeyValue::Null => "null".to_string(),
+    ConfigKeyValue::Bool(value) => CstInputValue::Bool(*value),
+    ConfigKeyValue::Number(value) => CstInputValue::Number(value.to_string()),
+    ConfigKeyValue::String(value) => CstInputValue::String(value.clone()),
+    ConfigKeyValue::Array(values) => CstInputValue::Array(values.iter().map(config_value_to_cst_json).collect()),
+    ConfigKeyValue::Object(values) => CstInputValue::Object(values.iter().map(|(key, value)| (key.clone(), config_value_to_cst_json(value))).collect()),
+    ConfigKeyValue::Null => CstInputValue::Null,
   }
 }
 
-fn get_plugins_array<'a>(root_obj: &'a Object<'a>) -> Result<&'a Array<'a>> {
-  root_obj
-    .get_array("plugins")
-    .ok_or_else(|| anyhow!("Please ensure your config file has an object with a plugins array in it to use this feature."))
+fn get_root_object(root_node: &jsonc_parser::cst::CstRootNode) -> Result<jsonc_parser::cst::CstObject> {
+  root_node
+    .ensure_object_value()
+    .ok_or_else(|| anyhow!("Please ensure your config file has a root JSON object to use this feature."))
 }
 
-fn get_newline_char(file_text: &str) -> String {
-  if file_text.contains("\r\n") {
-    "\r\n".to_string()
-  } else {
-    "\n".to_string()
+fn get_plugins_array(root_obj: &jsonc_parser::cst::CstObject) -> Result<jsonc_parser::cst::CstArray> {
+  match root_obj.get("plugins") {
+    Some(property) => property
+      .value()
+      .and_then(|value| value.as_array())
+      .ok_or_else(|| anyhow!("Please ensure your config file has a \"plugins\" property to use this feature.")),
+    None => {
+      root_obj.append("plugins", json!([]));
+      Ok(root_obj.get("plugins").unwrap().value().unwrap().as_array().unwrap())
+    }
   }
 }
 
-fn get_indentation_text(file_text: &str, root_obj: &Object) -> String {
-  root_obj
-    .properties
-    .first()
-    .map(|first_property| {
-      let after_brace_position = root_obj.start() + 1;
-      let first_property_start = first_property.start();
-      let text = file_text[after_brace_position..first_property_start].replace('\r', "");
-      let last_line = text.split('\n').last().unwrap();
-      last_line.chars().take_while(|c| c.is_whitespace()).collect::<String>()
-    })
-    .unwrap_or_else(|| "  ".to_string())
-}
-
 #[cfg(test)]
 mod test {
   use dprint_core::configuration::ConfigKeyMap;
@@ -484,11 +357,11 @@ mod test {
     )
     .unwrap();
 
-    // don't bother... just remove it
     assert_eq!(
       final_text,
       r#"{
   "plugins": [
+    // some comment
     "value"
   ]
 }"#
@@ -535,7 +408,7 @@ mod test {
       r#"{
   "plugins": [
     "some_value",
-    "value"
+    "value",
   ]
 }"#
     );
@@ -553,12 +426,11 @@ mod test {
     )
     .unwrap();
 
-    // just remove it... too much work
     assert_eq!(
       final_text,
       r#"{
   "plugins": [
-    "some_value",
+    "some_value", // comment
     "value"
   ]
 }"#
@@ -721,9 +593,7 @@ mod test {
     "array": [
       {
         "prop": {
-          "sub": [
-            "test"
-          ]
+          "sub": ["test"]
         }
       },
       true,
@@ -793,10 +663,7 @@ mod test {
     "next": {
       "asdf": [
         true,
-        [
-          true,
-          "value"
-        ]
+        [true, "value"]
       ]
     }
   }
@@ -881,7 +748,7 @@ mod test {
 
   #[track_caller]
   fn run_config_change_test(file_text: &str, changes: &[ConfigChange], expected_text: &str, diagnostics: &[&str]) {
-    let result = apply_config_changes(file_text.to_string(), "plugin", changes);
+    let result = apply_config_changes(file_text, "plugin", changes);
     assert_eq!(result.diagnostics, diagnostics);
     assert_eq!(result.new_text, expected_text);
   }
diff --git a/crates/dprint/src/configuration/resolve_config.rs b/crates/dprint/src/configuration/resolve_config.rs
index 8c0d09d21..fe13d790a 100644
--- a/crates/dprint/src/configuration/resolve_config.rs
+++ b/crates/dprint/src/configuration/resolve_config.rs
@@ -1064,7 +1064,7 @@ mod tests {
       assert_eq!(
         result.to_string(),
         concat!(
-          "Error deserializing. Expected a colon after the string or word in an object property on line 2 column 21.\n",
+          "Error deserializing. Expected colon after the string or word in object property on line 2 column 21\n",
           "    at https://dprint.dev/dir/test.json"
         )
       );


Test patch: