Repository: dprint/dprint-plugin-typescript (rust)

Issue:
Add prettier/eslint-like "consistent" option for quoteProps
In TS, we have enabled the eslint rule that says "try not to use quotes, but if one prop needs quotes, quote all of them". prettier also has this setting; it'd be great if dprint also had this variant so we could remove a lint rule when we switch over.

- https://eslint.org/docs/latest/rules/quote-props#consistent-as-needed
- https://prettier.io/docs/en/options.html#quote-props

Note that "consistent" in both do different things; consistent-as-needed is the right one.

Pull request:
feat: add "quoteProps": "consistent"
Closes #542

Patch:
diff --git a/deployment/schema.json b/deployment/schema.json
index 1d410a7..b5847f4 100644
--- a/deployment/schema.json
+++ b/deployment/schema.json
@@ -65,11 +65,14 @@
       "type": "string",
       "default": "preserve",
       "oneOf": [{
-        "const": "preserve",
-        "description": "Preserve quotes around property names."
-      }, {
         "const": "asNeeded",
         "description": "Remove unnecessary quotes around property names."
+      }, {
+        "const": "consistent",
+        "description": "Same as 'asNeeded', but if one property requires quotes then quote them all."
+      }, {
+        "const": "preserve",
+        "description": "Preserve quotes around property names."
       }]
     },
     "jsx.multiLineParens": {
diff --git a/dprint.json b/dprint.json
index 65bd1b8..4f851f9 100644
--- a/dprint.json
+++ b/dprint.json
@@ -1,9 +1,10 @@
 {
   "exec": {
-    "associations": "**/*.rs",
-    "rustfmt": "rustfmt"
+    "commands": [{
+      "command": "rustfmt",
+      "exts": ["rs"]
+    }]
   },
-  "includes": ["**/*.{ts,tsx,js,jsx,cjs,mjs,json,md,toml,rs}"],
   "excludes": [
     "**/node_modules",
     "**/*-lock.json",
@@ -15,6 +16,6 @@
     "https://plugins.dprint.dev/json-0.16.0.wasm",
     "https://plugins.dprint.dev/markdown-0.14.3.wasm",
     "https://plugins.dprint.dev/toml-0.5.4.wasm",
-    "https://plugins.dprint.dev/exec-0.3.2.json@8efbbb3fcfbdf84142c3c438fbdeaf1637152a020032127c837b2b14e23261c3"
+    "https://plugins.dprint.dev/exec-0.4.3.json@42343548b8022c99b1d750be6b894fe6b6c7ee25f72ae9f9082226dd2e515072"
   ]
 }
diff --git a/src/configuration/types.rs b/src/configuration/types.rs
index 4d1aa8f..6e51339 100644
--- a/src/configuration/types.rs
+++ b/src/configuration/types.rs
@@ -225,13 +225,15 @@ generate_str_to_from![JsxQuoteStyle, [PreferDouble, "preferDouble"], [PreferSing
 #[derive(Clone, PartialEq, Copy, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub enum QuoteProps {
-  /// Preserve quotes around property names.
-  Preserve,
   /// Remove unnecessary quotes around property names.
   AsNeeded,
+  /// Same as `AsNeeded`, but if one property requires quotes then quote them all.
+  Consistent,
+  /// Preserve quotes around property names.
+  Preserve,
 }
 
-generate_str_to_from![QuoteProps, [Preserve, "preserve"], [AsNeeded, "asNeeded"]];
+generate_str_to_from![QuoteProps, [AsNeeded, "asNeeded"], [Consistent, "consistent"], [Preserve, "preserve"]];
 
 /// Whether to surround a JSX element or fragment with parentheses
 /// when it's the top JSX node and it spans multiple lines.
diff --git a/src/generation/context.rs b/src/generation/context.rs
index 8b5fac9..7b86945 100644
--- a/src/generation/context.rs
+++ b/src/generation/context.rs
@@ -26,6 +26,8 @@ pub struct Context<'a> {
   pub token_finder: TokenFinder<'a>,
   pub current_node: Node<'a>,
   pub parent_stack: Stack<Node<'a>>,
+  /// Stores whether the parent requires all properties have consistent quoting.
+  consistent_quote_props_stack: Stack<bool>,
   handled_comments: FxHashSet<SourcePos>,
   stored_ln_ranges: FxHashMap<(SourcePos, SourcePos), (LineNumber, LineNumber)>,
   stored_lsil: FxHashMap<(SourcePos, SourcePos), LineStartIndentLevel>,
@@ -49,14 +51,15 @@ impl<'a> Context<'a> {
       comments: CommentTracker::new(program, tokens),
       token_finder: TokenFinder::new(program),
       current_node,
-      parent_stack: Stack::new(),
+      parent_stack: Default::default(),
+      consistent_quote_props_stack: Default::default(),
       handled_comments: FxHashSet::default(),
       stored_ln_ranges: FxHashMap::default(),
       stored_lsil: FxHashMap::default(),
       stored_ln: FxHashMap::default(),
       stored_il: FxHashMap::default(),
-      end_statement_or_member_lns: Stack::new(),
-      before_comments_start_info_stack: Stack::new(),
+      end_statement_or_member_lns: Default::default(),
+      before_comments_start_info_stack: Default::default(),
       if_stmt_last_brace_condition_ref: None,
       expr_stmt_single_line_parent_brace_ref: None,
       #[cfg(debug_assertions)]
@@ -158,6 +161,26 @@ impl<'a> Context<'a> {
     }
   }
 
+  pub fn use_consistent_quote_props(&self) -> Option<bool> {
+    self.consistent_quote_props_stack.peek().copied()
+  }
+
+  pub fn with_maybe_consistent_props<TState, TReturn>(
+    &mut self,
+    state: TState,
+    use_consistent_quotes: impl FnOnce(&TState) -> bool,
+    action: impl FnOnce(&mut Self, TState) -> TReturn,
+  ) -> TReturn {
+    if self.config.quote_props == QuoteProps::Consistent {
+      self.consistent_quote_props_stack.push((use_consistent_quotes)(&state));
+    }
+    let is_consistent = self.consistent_quote_props_stack.peek().copied().unwrap_or(true);
+    self.consistent_quote_props_stack.push(is_consistent);
+    let result = action(self, state);
+    self.consistent_quote_props_stack.pop();
+    result
+  }
+
   // do any assertions for how the state of this context should be at the end of the file
   #[cfg(debug_assertions)]
   pub fn assert_end_of_file_state(&self) {
diff --git a/src/generation/generate.rs b/src/generation/generate.rs
index 9edb95e..e44eebe 100644
--- a/src/generation/generate.rs
+++ b/src/generation/generate.rs
@@ -1,6 +1,5 @@
 use deno_ast::swc::common::comments::Comment;
 use deno_ast::swc::common::comments::CommentKind;
-use deno_ast::swc::parser::lexer::util::CharExt;
 use deno_ast::swc::parser::token::BinOpToken;
 use deno_ast::swc::parser::token::Token;
 use deno_ast::swc::parser::token::TokenAndSpan;
@@ -895,16 +894,23 @@ fn gen_class_decl_or_expr<'a>(node: ClassDeclOrExpr<'a>, context: &mut Context<'
     },
     context,
   ));
-  items.extend(gen_membered_body(
-    GenMemberedBodyOptions {
-      node: node.member_node,
-      members: node.members,
-      start_header_lsil: Some(start_header_lsil),
-      brace_position: node.brace_position,
-      should_use_blank_line: move |previous, next, context| node_helpers::has_separating_blank_line(&previous, &next, context.program),
-      separator: Separator::none(),
+
+  items.extend(context.with_maybe_consistent_props(
+    node.members,
+    |members| use_consistent_quotes_for_members(members.iter().copied()),
+    |context, members| {
+      gen_membered_body(
+        GenMemberedBodyOptions {
+          node: node.member_node,
+          members,
+          start_header_lsil: Some(start_header_lsil),
+          brace_position: node.brace_position,
+          should_use_blank_line: move |previous, next, context| node_helpers::has_separating_blank_line(&previous, &next, context.program),
+          separator: Separator::none(),
+        },
+        context,
+      )
     },
-    context,
   ));
 
   if node.is_class_expr {
@@ -2620,7 +2626,7 @@ fn gen_getter_prop<'a>(node: &GetterProp<'a>, context: &mut Context<'a>) -> Prin
 
 fn gen_key_value_prop<'a>(node: &KeyValueProp<'a>, context: &mut Context<'a>) -> PrintItems {
   let mut items = PrintItems::new();
-  items.extend(gen_node(node.key.into(), context));
+  items.extend(gen_quotable_prop(node.key.into(), context));
   items.extend(gen_assignment(node.value.into(), ":", context));
   items
 }
@@ -2680,20 +2686,26 @@ fn gen_non_null_expr<'a>(node: &TsNonNullExpr<'a>, context: &mut Context<'a>) ->
 }
 
 fn gen_object_lit<'a>(node: &ObjectLit<'a>, context: &mut Context<'a>) -> PrintItems {
-  let items = gen_object_like_node(
-    GenObjectLikeNodeOptions {
-      node: node.into(),
-      members: node.props.iter().map(|x| x.into()).collect(),
-      separator: context.config.object_expression_trailing_commas.into(),
-      prefer_hanging: context.config.object_expression_prefer_hanging,
-      prefer_single_line: context.config.object_expression_prefer_single_line,
-      force_single_line: false,
-      force_multi_line: is_node_definitely_above_line_width(node.range(), context),
-      surround_single_line_with_spaces: context.config.object_expression_space_surrounding_properties,
-      allow_blank_lines: true,
-      node_sorter: None,
+  let items = context.with_maybe_consistent_props(
+    node,
+    |node| use_consistent_quotes_for_members(node.props.iter().map(|p| p.into())),
+    |context, node| {
+      gen_object_like_node(
+        GenObjectLikeNodeOptions {
+          node: node.into(),
+          members: node.props.iter().map(|x| x.into()).collect(),
+          separator: context.config.object_expression_trailing_commas.into(),
+          prefer_hanging: context.config.object_expression_prefer_hanging,
+          prefer_single_line: context.config.object_expression_prefer_single_line,
+          force_single_line: false,
+          force_multi_line: is_node_definitely_above_line_width(node.range(), context),
+          surround_single_line_with_spaces: context.config.object_expression_space_surrounding_properties,
+          allow_blank_lines: true,
+          node_sorter: None,
+        },
+        context,
+      )
     },
-    context,
   );
 
   if should_add_parens_around_expr(node.into(), context) {
@@ -3309,7 +3321,7 @@ fn gen_method_signature_like<'a>(node: MethodSignatureLike<'a>, context: &mut Co
       context,
     )
   } else {
-    gen_node(node.key, context)
+    gen_quotable_prop(node.key, context)
   });
 
   if node.optional {
@@ -3362,7 +3374,7 @@ fn gen_property_signature<'a>(node: &TsPropertySignature<'a>, context: &mut Cont
       context,
     )
   } else {
-    gen_node(node.key.into(), context)
+    gen_quotable_prop(node.key.into(), context)
   });
 
   if node.optional() {
@@ -3377,19 +3389,51 @@ fn gen_property_signature<'a>(node: &TsPropertySignature<'a>, context: &mut Cont
   items
 }
 
+fn gen_quotable_prop<'a>(node: Node<'a>, context: &mut Context<'a>) -> PrintItems {
+  match context.config.quote_props {
+    QuoteProps::AsNeeded => match node {
+      Node::Str(str_lit) if is_text_valid_identifier(str_lit.value()) => gen_from_raw_string(str_lit.value()),
+      _ => gen_node(node, context),
+    },
+    QuoteProps::Consistent => match context.use_consistent_quote_props() {
+      Some(true) => match node {
+        // add quotes
+        Node::Ident(ident) => string_literal::gen_non_jsx_text(ident.sym(), context),
+        _ => gen_node(node, context),
+      },
+      Some(false) => match node {
+        // remove quotes
+        Node::Str(str_lit) => gen_from_raw_string(str_lit.value()),
+        _ => gen_node(node, context),
+      },
+      None => {
+        debug_assert!(false, "should always have a value");
+        gen_node(node, context)
+      }
+    },
+    QuoteProps::Preserve => gen_node(node, context),
+  }
+}
+
 fn gen_interface_body<'a>(node: &TsInterfaceBody<'a>, context: &mut Context<'a>) -> PrintItems {
   let start_header_lsil = get_parent_lsil(node, context);
 
-  return gen_membered_body(
-    GenMemberedBodyOptions {
-      node: node.into(),
-      members: node.body.iter().map(|x| x.into()).collect(),
-      start_header_lsil,
-      brace_position: context.config.interface_declaration_brace_position,
-      should_use_blank_line: move |previous, next, context| node_helpers::has_separating_blank_line(&previous, &next, context.program),
-      separator: context.config.semi_colons.into(),
+  return context.with_maybe_consistent_props(
+    node,
+    |node| use_consistent_quotes_for_members(node.body.iter().map(|n| n.into())),
+    |context, node| {
+      gen_membered_body(
+        GenMemberedBodyOptions {
+          node: node.into(),
+          members: node.body.iter().map(|x| x.into()).collect(),
+          start_header_lsil,
+          brace_position: context.config.interface_declaration_brace_position,
+          should_use_blank_line: move |previous, next, context| node_helpers::has_separating_blank_line(&previous, &next, context.program),
+          separator: context.config.semi_colons.into(),
+        },
+        context,
+      )
     },
-    context,
   );
 
   fn get_parent_lsil(node: &TsInterfaceBody, context: &mut Context) -> Option<LineStartIndentLevel> {
@@ -3402,30 +3446,73 @@ fn gen_interface_body<'a>(node: &TsInterfaceBody<'a>, context: &mut Context<'a>)
   }
 }
 
+fn use_consistent_quotes_for_members<'a>(mut members: impl Iterator<Item = Node<'a>>) -> bool {
+  fn check_expr(expr: Expr) -> bool {
+    match expr {
+      Expr::Ident(ident) => !is_text_valid_identifier(ident.sym()),
+      Expr::Lit(Lit::Str(str)) => !is_text_valid_identifier(str.value()),
+      _ => false,
+    }
+  }
+
+  fn check_prop_name(name: PropName) -> bool {
+    match name {
+      PropName::Ident(ident) => !is_text_valid_identifier(ident.sym()),
+      PropName::Str(str) => !is_text_valid_identifier(str.value()),
+      _ => false,
+    }
+  }
+
+  members.any(|m| match m {
+    // class members
+    // Do not match class properties (Node::ClassProp)
+    // With `--strictPropertyInitialization`, TS treats properties
+    // with quoted names differently than unquoted ones.
+    // See https://github.com/microsoft/TypeScript/pull/20075
+    Node::ClassMethod(method) => check_prop_name(method.key),
+    // interface members
+    Node::TsPropertySignature(signature) => check_expr(signature.key),
+    Node::TsGetterSignature(signature) => check_expr(signature.key),
+    Node::TsSetterSignature(signature) => check_expr(signature.key),
+    // object members
+    Node::KeyValueProp(prop) => check_prop_name(prop.key),
+    Node::GetterProp(prop) => check_prop_name(prop.key),
+    Node::SetterProp(prop) => check_prop_name(prop.key),
+    Node::MethodProp(prop) => check_prop_name(prop.key),
+    _ => false,
+  })
+}
+
 fn gen_type_lit<'a>(node: &TsTypeLit<'a>, context: &mut Context<'a>) -> PrintItems {
-  return gen_object_like_node(
-    GenObjectLikeNodeOptions {
-      node: node.into(),
-      members: node.members.iter().map(|m| m.into()).collect(),
-      separator: Separator {
-        single_line: Some(semi_colon_or_comma_to_separator_value(
-          context.config.type_literal_separator_kind_single_line,
-          context,
-        )),
-        multi_line: Some(semi_colon_or_comma_to_separator_value(
-          context.config.type_literal_separator_kind_multi_line,
-          context,
-        )),
-      },
-      prefer_hanging: context.config.type_literal_prefer_hanging,
-      prefer_single_line: context.config.type_literal_prefer_single_line,
-      force_single_line: false,
-      force_multi_line: false,
-      surround_single_line_with_spaces: context.config.type_literal_space_surrounding_properties,
-      allow_blank_lines: true,
-      node_sorter: None,
+  return context.with_maybe_consistent_props(
+    node,
+    |node| use_consistent_quotes_for_members(node.members.iter().map(|n| n.into())),
+    |context, node| {
+      gen_object_like_node(
+        GenObjectLikeNodeOptions {
+          node: node.into(),
+          members: node.members.iter().map(|m| m.into()).collect(),
+          separator: Separator {
+            single_line: Some(semi_colon_or_comma_to_separator_value(
+              context.config.type_literal_separator_kind_single_line,
+              context,
+            )),
+            multi_line: Some(semi_colon_or_comma_to_separator_value(
+              context.config.type_literal_separator_kind_multi_line,
+              context,
+            )),
+          },
+          prefer_hanging: context.config.type_literal_prefer_hanging,
+          prefer_single_line: context.config.type_literal_prefer_single_line,
+          force_single_line: false,
+          force_multi_line: false,
+          surround_single_line_with_spaces: context.config.type_literal_space_surrounding_properties,
+          allow_blank_lines: true,
+          node_sorter: None,
+        },
+        context,
+      )
     },
-    context,
   );
 
   fn semi_colon_or_comma_to_separator_value(value: SemiColonOrComma, context: &mut Context) -> SeparatorValue {
@@ -3879,106 +3966,37 @@ fn gen_reg_exp_literal(node: &Regex, _: &mut Context) -> PrintItems {
 }
 
 fn gen_string_literal<'a>(node: &Str<'a>, context: &mut Context<'a>) -> PrintItems {
-  return gen_from_raw_string(&get_string_literal_text(
-    get_string_value(node, context),
-    node.parent().is::<JSXAttr>(),
-    context.config.quote_props == QuoteProps::AsNeeded && is_property_name(node),
-    context,
-  ));
-
-  fn is_property_name(node: &Str) -> bool {
-    let key = match node.parent() {
-      Node::KeyValueProp(parent) => parent.key.as_node(),
-      Node::GetterProp(parent) => parent.key.as_node(),
-      Node::SetterProp(parent) => parent.key.as_node(),
-      Node::MethodProp(parent) => parent.key.as_node(),
-      // Do not match class properties (Node::ClassProp)
-      // With `--strictPropertyInitialization`, TS treats properties with quoted names differently than unquoted ones.
-      // See https://github.com/microsoft/TypeScript/pull/20075
-      Node::ClassMethod(parent) => parent.key.as_node(),
-      Node::TsPropertySignature(parent) => parent.key.as_node(),
-      Node::TsGetterSignature(parent) => parent.key.as_node(),
-      Node::TsSetterSignature(parent) => parent.key.as_node(),
-      Node::TsMethodSignature(parent) => parent.key.as_node(),
-      _ => return false,
-    };
-
-    key.range() == node.range() && matches!(key, Node::Str(_))
+  let string_value = string_literal::get_value(node, context);
+  if node.parent().is::<JSXAttr>() {
+    string_literal::gen_jsx_text(&string_value, context)
+  } else {
+    string_literal::gen_non_jsx_text(&string_value, context)
   }
+}
 
-  fn get_string_literal_text(string_value: String, is_jsx_attribute: bool, should_remove_quotes_if_identifier: bool, context: &mut Context) -> String {
-    if should_remove_quotes_if_identifier && is_valid_identifier(&string_value) {
-      return string_value;
-    }
-    return if is_jsx_attribute {
-      // JSX attributes cannot contain escaped quotes so regardless of
-      // configuration, allow changing the quote style to single or
-      // double depending on if it contains the opposite quote
-      match context.config.jsx_quote_style {
-        JsxQuoteStyle::PreferDouble => handle_prefer_double(string_value),
-        JsxQuoteStyle::PreferSingle => handle_prefer_single(string_value),
-      }
-    } else {
-      match context.config.quote_style {
-        QuoteStyle::AlwaysDouble => format_with_double(string_value),
-        QuoteStyle::AlwaysSingle => format_with_single(string_value),
-        QuoteStyle::PreferDouble => handle_prefer_double(string_value),
-        QuoteStyle::PreferSingle => handle_prefer_single(string_value),
-      }
-    };
-
-    fn is_valid_identifier(string_value: &str) -> bool {
-      if string_value.is_empty() {
-        return false;
-      }
-      for (i, c) in string_value.chars().enumerate() {
-        if (i == 0 && !c.is_ident_start()) || !c.is_ident_part() {
-          return false;
-        }
-      }
-      true
-    }
-
-    fn handle_prefer_double(string_value: String) -> String {
-      if double_to_single(&string_value) <= 0 {
-        format_with_double(string_value)
-      } else {
-        format_with_single(string_value)
-      }
-    }
-
-    fn handle_prefer_single(string_value: String) -> String {
-      if double_to_single(&string_value) >= 0 {
-        format_with_single(string_value)
-      } else {
-        format_with_double(string_value)
-      }
-    }
-
-    fn format_with_double(string_value: String) -> String {
-      format!("\"{}\"", string_value.replace('"', "\\\""))
-    }
-
-    fn format_with_single(string_value: String) -> String {
-      format!("'{}'", string_value.replace('\'', "\\'"))
-    }
+mod string_literal {
+  use super::*;
 
-    fn double_to_single(string_value: &str) -> i32 {
-      let mut double_count = 0;
-      let mut single_count = 0;
-      for c in string_value.chars() {
-        match c {
-          '"' => double_count += 1,
-          '\'' => single_count += 1,
-          _ => {}
-        }
-      }
+  pub fn gen_non_jsx_text(string_value: &str, context: &mut Context) -> PrintItems {
+    gen_from_raw_string(&match context.config.quote_style {
+      QuoteStyle::AlwaysDouble => format_with_double(string_value),
+      QuoteStyle::AlwaysSingle => format_with_single(string_value),
+      QuoteStyle::PreferDouble => handle_prefer_double(string_value),
+      QuoteStyle::PreferSingle => handle_prefer_single(string_value),
+    })
+  }
 
-      double_count - single_count
-    }
+  pub fn gen_jsx_text(string_value: &str, context: &mut Context) -> PrintItems {
+    // JSX attributes cannot contain escaped quotes so regardless of
+    // configuration, allow changing the quote style to single or
+    // double depending on if it contains the opposite quote
+    gen_from_raw_string(&match context.config.jsx_quote_style {
+      JsxQuoteStyle::PreferDouble => handle_prefer_double(string_value),
+      JsxQuoteStyle::PreferSingle => handle_prefer_single(string_value),
+    })
   }
 
-  fn get_string_value(node: &Str, context: &mut Context) -> String {
+  pub fn get_value(node: &Str, context: &mut Context) -> String {
     let raw_string_text = node.text_fast(context.program);
     if raw_string_text.len() <= 2 {
       return String::new();
@@ -4016,6 +4034,44 @@ fn gen_string_literal<'a>(node: &Str<'a>, context: &mut Context<'a>) -> PrintIte
       new_string
     }
   }
+
+  fn handle_prefer_double(string_value: &str) -> String {
+    if double_to_single(string_value) <= 0 {
+      format_with_double(string_value)
+    } else {
+      format_with_single(string_value)
+    }
+  }
+
+  fn handle_prefer_single(string_value: &str) -> String {
+    if double_to_single(string_value) >= 0 {
+      format_with_single(string_value)
+    } else {
+      format_with_double(string_value)
+    }
+  }
+
+  fn format_with_double(string_value: &str) -> String {
+    format!("\"{}\"", string_value.replace('"', "\\\""))
+  }
+
+  fn format_with_single(string_value: &str) -> String {
+    format!("'{}'", string_value.replace('\'', "\\'"))
+  }
+
+  fn double_to_single(string_value: &str) -> i32 {
+    let mut double_count = 0;
+    let mut single_count = 0;
+    for c in string_value.chars() {
+      match c {
+        '"' => double_count += 1,
+        '\'' => single_count += 1,
+        _ => {}
+      }
+    }
+
+    double_count - single_count
+  }
 }
 
 /* top level */
@@ -4159,7 +4215,7 @@ fn gen_object_pat<'a>(node: &ObjectPat<'a>, context: &mut Context<'a>) -> PrintI
 /* properties */
 
 fn gen_method_prop<'a>(node: &MethodProp<'a>, context: &mut Context<'a>) -> PrintItems {
-  return gen_class_or_object_method(
+  gen_class_or_object_method(
     ClassOrObjectMethod {
       node: node.into(),
       parameters_range: node.get_parameters_range(context),
@@ -4179,7 +4235,7 @@ fn gen_method_prop<'a>(node: &MethodProp<'a>, context: &mut Context<'a>) -> Prin
       body: node.function.body.map(|x| x.into()),
     },
     context,
-  );
+  )
 }
 
 struct ClassOrObjectMethod<'a> {
@@ -4252,7 +4308,7 @@ fn gen_class_or_object_method<'a>(node: ClassOrObjectMethod<'a>, context: &mut C
   if node.is_generator {
     items.push_str("*");
   }
-  items.extend(gen_node(node.key, context));
+  items.extend(gen_quotable_prop(node.key, context));
   if node.is_optional {
     items.push_str("?");
   }
diff --git a/src/generation/swc/helpers.rs b/src/generation/swc/helpers.rs
index f40b8b7..cb3468c 100644
--- a/src/generation/swc/helpers.rs
+++ b/src/generation/swc/helpers.rs
@@ -1,4 +1,5 @@
 use deno_ast::swc::common::comments::CommentKind;
+use deno_ast::swc::parser::lexer::util::CharExt;
 use deno_ast::view::Node;
 use deno_ast::view::Program;
 use deno_ast::RootNode;
@@ -25,3 +26,15 @@ pub fn contains_line_or_multiline_comment<'a>(node: Node<'a>, program: Program<'
   }
   false
 }
+
+pub fn is_text_valid_identifier(string_value: &str) -> bool {
+  if string_value.is_empty() {
+    return false;
+  }
+  for (i, c) in string_value.chars().enumerate() {
+    if (i == 0 && !c.is_ident_start()) || !c.is_ident_part() {
+      return false;
+    }
+  }
+  true
+}
diff --git a/src/utils/stack.rs b/src/utils/stack.rs
index ccae115..d9ab16a 100644
--- a/src/utils/stack.rs
+++ b/src/utils/stack.rs
@@ -2,11 +2,13 @@ pub struct Stack<T> {
   items: Vec<T>,
 }
 
-impl<T> Stack<T> {
-  pub fn new() -> Stack<T> {
-    Stack { items: Vec::new() }
+impl<T> Default for Stack<T> {
+  fn default() -> Self {
+    Self { items: Default::default() }
   }
+}
 
+impl<T> Stack<T> {
   pub fn pop(&mut self) -> T {
     let result = self.items.pop();
     result.expect("Tried to pop, but the stack was empty. This indicates a bug where an item is being popped, but not pushed to the stack.")


Test patch:
diff --git a/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_AsNeeded.txt b/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_AsNeeded.txt
index a465a9a..67a7fcf 100644
--- a/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_AsNeeded.txt
+++ b/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_AsNeeded.txt
@@ -12,6 +12,7 @@ const objectLiteral = {
     get "2foo"() {},
     set "2foo"(v) {},
     async "3foo"() {},
+    other: 1,
 };
 class Class {
     "1foo"() {}
@@ -20,18 +21,21 @@ class Class {
     async "3foo"() {}
     "4foo": string;
     "1" = true;
+    other = 1;
 }
 interface Interface {
     "1foo": string;
     "2foo"(): string;
     get "3foo"(): string;
     set "3foo"(v: string);
+    other: number;
 }
 type Type = {
     "1foo": string;
     "2foo"(): string;
     get "3foo"(): string;
     set "3foo"(v: string);
+    other: number;
 };
 
 [expect]
@@ -41,12 +45,13 @@ const objectLiteral = {
     "1foo": true,
     "💩": true,
     "a💩": true,
-    "c\d": true,
+    cd: true,
     "1": true,
     "1foo"() {},
     get "2foo"() {},
     set "2foo"(v) {},
     async "3foo"() {},
+    other: 1,
 };
 class Class {
     "1foo"() {}
@@ -55,18 +60,21 @@ class Class {
     async "3foo"() {}
     "4foo": string;
     "1" = true;
+    other = 1;
 }
 interface Interface {
     "1foo": string;
     "2foo"(): string;
     get "3foo"(): string;
     set "3foo"(v: string);
+    other: number;
 }
 type Type = {
     "1foo": string;
     "2foo"(): string;
     get "3foo"(): string;
     set "3foo"(v: string);
+    other: number;
 };
 
 == should remove quotes when unnecessary ==
@@ -85,6 +93,7 @@ const objectLiteral = {
     set "foo2"(v) {},
     async "foo3"() {},
     "test": "literal",
+    "2notThis": false,
 };
 class Class {
     "foo"() {}
@@ -93,6 +102,7 @@ class Class {
     async "foo3"() {}
     "foo4": string;
     "test": "literal" = "literal";
+    "2notThis" = false;
 }
 interface Interface {
     "foo": string;
@@ -100,6 +110,7 @@ interface Interface {
     get "foo3"(): string;
     set "foo3"(v: string);
     "foo4": "literal";
+    "2notThis": false;
 }
 type Type = {
     "foo": string;
@@ -107,6 +118,7 @@ type Type = {
     get "foo3"(): string;
     set "foo3"(v: string);
     "foo4": "literal";
+    "2notThis": false;
 };
 
 [expect]
@@ -125,6 +137,7 @@ const objectLiteral = {
     set foo2(v) {},
     async foo3() {},
     test: "literal",
+    "2notThis": false,
 };
 class Class {
     foo() {}
@@ -133,6 +146,7 @@ class Class {
     async foo3() {}
     "foo4": string;
     "test": "literal" = "literal";
+    "2notThis" = false;
 }
 interface Interface {
     foo: string;
@@ -140,6 +154,7 @@ interface Interface {
     get foo3(): string;
     set foo3(v: string);
     foo4: "literal";
+    "2notThis": false;
 }
 type Type = {
     foo: string;
@@ -147,4 +162,15 @@ type Type = {
     get foo3(): string;
     set foo3(v: string);
     foo4: "literal";
+    "2notThis": false;
 };
+
+== should keep quotes around class properties due to --strictPropertyInitialization ==
+class Test {
+    "prop" = true;
+}
+
+[expect]
+class Test {
+    "prop" = true;
+}
diff --git a/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_Consistent.txt b/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_Consistent.txt
new file mode 100644
index 0000000..70f2448
--- /dev/null
+++ b/tests/specs/literals/StringLiteral/StringLiteral_QuoteProps_Consistent.txt
@@ -0,0 +1,160 @@
+~~ quoteProps: consistent ~~
+== should add quotes when necessary ==
+const objectLiteral = {
+    "foo bar": true,
+    "foo\nbar": true,
+    "1foo": true,
+    "💩": true,
+    "a💩": true,
+    "c\d": true,
+    "1": true,
+    "1foo"() {},
+    get "2foo"() {},
+    set "2foo"(v) {},
+    async "3foo"() {},
+    other: 1,
+};
+class Class {
+    "1foo"() {}
+    get "1foo"() {}
+    set "1foo"() {}
+    async "3foo"() {}
+    "4foo": string;
+    "1" = true;
+    other = 1;
+}
+interface Interface {
+    "1foo": string;
+    "2foo"(): string;
+    get "3foo"(): string;
+    set "3foo"(v: string);
+    other: number;
+}
+type Type = {
+    "1foo": string;
+    "2foo"(): string;
+    get "3foo"(): string;
+    set "3foo"(v: string);
+    other: number;
+};
+
+[expect]
+const objectLiteral = {
+    "foo bar": true,
+    "foo\nbar": true,
+    "1foo": true,
+    "💩": true,
+    "a💩": true,
+    "c\d": true,
+    "1": true,
+    "1foo"() {},
+    get "2foo"() {},
+    set "2foo"(v) {},
+    async "3foo"() {},
+    "other": 1,
+};
+class Class {
+    "1foo"() {}
+    get "1foo"() {}
+    set "1foo"() {}
+    async "3foo"() {}
+    "4foo": string;
+    "1" = true;
+    other = 1;
+}
+interface Interface {
+    "1foo": string;
+    "2foo"(): string;
+    get "3foo"(): string;
+    set "3foo"(v: string);
+    "other": number;
+}
+type Type = {
+    "1foo": string;
+    "2foo"(): string;
+    get "3foo"(): string;
+    set "3foo"(v: string);
+    "other": number;
+};
+
+== should remove quotes when none need them ==
+const objectLiteral = {
+    "foo": true,
+    "$": true,
+    "_": true,
+    "_1": true,
+    'foo': true,
+    '$': true,
+    '_': true,
+    '_1': true,
+    'é': true,
+    "foo"() {},
+    "c\d": true,
+    get "foo2"() {},
+    set "foo2"(v) {},
+    async "foo3"() {},
+    "test": "literal",
+};
+class Class {
+    "foo"() {}
+    get "foo2"() {}
+    set "foo2"() {}
+    async "foo3"() {}
+    "foo4": string;
+    "test": "literal" = "literal";
+}
+interface Interface {
+    "foo": string;
+    "foo2"(): string;
+    get "foo3"(): string;
+    set "foo3"(v: string);
+    "foo4": "literal";
+}
+type Type = {
+    "foo": string;
+    "foo2"(): string;
+    get "foo3"(): string;
+    set "foo3"(v: string);
+    "foo4": "literal";
+};
+
+[expect]
+const objectLiteral = {
+    foo: true,
+    $: true,
+    _: true,
+    _1: true,
+    foo: true,
+    $: true,
+    _: true,
+    _1: true,
+    é: true,
+    foo() {},
+    cd: true,
+    get foo2() {},
+    set foo2(v) {},
+    async foo3() {},
+    test: "literal",
+};
+class Class {
+    foo() {}
+    get foo2() {}
+    set foo2() {}
+    async foo3() {}
+    "foo4": string;
+    "test": "literal" = "literal";
+}
+interface Interface {
+    foo: string;
+    foo2(): string;
+    get foo3(): string;
+    set foo3(v: string);
+    foo4: "literal";
+}
+type Type = {
+    foo: string;
+    foo2(): string;
+    get foo3(): string;
+    set foo3(v: string);
+    foo4: "literal";
+};