From 26b7b19c429a592764302c5e2155e30f6e7e5ddf Mon Sep 17 00:00:00 2001 From: Zack Kitzmiller Date: Wed, 22 Jul 2026 11:05:33 -0500 Subject: [PATCH] fix: repair broken Prettier plugin in Ruby/Rails templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initium's package.json installed the unscoped `prettier-plugin-ruby` GitHub tarball while .prettierrc referenced the scoped `@prettier/plugin-ruby`. The ids never matched, so Prettier could not load the plugin — the reason Prettier is broken in nearly every generated Rails project. - package.json: pin `@prettier/plugin-ruby@^4.0.4` (scoped npm package); Rails additionally gets `@4az/prettier-plugin-html-erb@^0.0.7` - PrettierConfig: emit `rubySingleQuote: true` with `singleQuote: true` (was erroneously `single_quote = false`) - .rubocop.yml: disable cops that fight Prettier (StringLiterals, TrailingComma*, SpaceInsideArrayLiteralBrackets) instead of enforcing single_quotes - .prettierignore: populate with Rails-aware ignores (generic elsewhere) - justfile: add `fmt` / `fmt-check` recipes for Ruby templates - commands: print a post-generation "Next steps" message telling users to run `npm install`, `bundle add syntax_tree prettier_print`, and `bundle install` for the deps we inject (v4 needs the Syntax Tree gems) Update integration tests and add unit tests covering the scoped package, ERB plugin scoping, rubySingleQuote, and non-Ruby exclusion. --- src/commands.rs | 42 +++++++++++++++++ src/config.rs | 84 +++++++++++++++++++++------------ src/generators/basic.rs | 21 ++++++++- src/generators/ruby.rs | 96 ++++++++++++++++++++++++++++++++++++-- tests/integration_tests.rs | 15 ++++-- tests/unit_tests.rs | 83 ++++++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 41 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index a4de290..ca044f9 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -141,6 +141,8 @@ impl CommandHandler { .await?; println!("{}", "✅ Git hooks generated successfully!".green()); } + + self.print_ruby_next_steps(); } Ok(()) } @@ -265,6 +267,8 @@ impl CommandHandler { .await?; println!("{}", "✅ Git hooks generated successfully!".green()); } + + self.print_node_next_steps(); } Ok(()) } @@ -619,6 +623,7 @@ impl CommandHandler { "{}", "✅ Ruby configuration files generated successfully!".green() ); + self.print_ruby_next_steps(); } } ProjectType::Python => { @@ -659,6 +664,7 @@ impl CommandHandler { "{}", "✅ Node.js configuration files generated successfully!".green() ); + self.print_node_next_steps(); } } ProjectType::Go => { @@ -801,6 +807,42 @@ impl CommandHandler { Ok(()) } + /// Tell the user which commands install the dependencies we just injected. + /// + /// Initium writes `package.json` (and, for Ruby, expects Gemfile gems) but + /// never runs the installers itself, so the generated config is inert until + /// the user installs the tooling. Skipped on dry runs. + fn print_ruby_next_steps(&self) { + if self.dry_run { + return; + } + println!(); + println!( + "{}", + "📦 Next steps — install the tooling this config expects:".blue() + ); + println!(" • npm install # Prettier + @prettier/plugin-ruby"); + println!( + " • {}", + "bundle add syntax_tree prettier_print --group development,test".yellow() + ); + println!(" (required by @prettier/plugin-ruby to parse Ruby)"); + println!(" • bundle install"); + println!(" Then run {} to format.", "just fmt".green()); + } + + fn print_node_next_steps(&self) { + if self.dry_run { + return; + } + println!(); + println!( + "{}", + "📦 Next steps — install the tooling this config expects:".blue() + ); + println!(" • npm install"); + } + pub fn handle_list(&self) { println!("{}", "📋 Available configuration files:".blue()); println!(" • .editorconfig"); diff --git a/src/config.rs b/src/config.rs index 81be550..96d20e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,8 @@ pub struct PrettierConfig { pub trailing_comma: String, pub print_width: u8, pub plugins: Option>, + /// Emits `rubySingleQuote` when set. Only meaningful with `@prettier/plugin-ruby`. + pub ruby_single_quote: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -183,14 +185,26 @@ impl Default for PrettierConfig { trailing_comma: "es5".to_string(), print_width: 80, plugins: None, + ruby_single_quote: None, } } } impl PrettierConfig { - fn with_ruby_plugin(mut self) -> Self { - self.single_quote = false; - self.plugins = Some(vec!["@prettier/plugin-ruby".to_string()]); + /// Configure Prettier as the Ruby formatting source of truth. + /// + /// `@prettier/plugin-ruby` (npm) drives Ruby style; RuboCop cops that fight + /// it are disabled in `.rubocop.yml`. Both JS and Ruby use single quotes. + /// When `erb` is set, ERB templates are formatted via + /// `@4az/prettier-plugin-html-erb`. + fn with_ruby_plugin(mut self, erb: bool) -> Self { + self.single_quote = true; + self.ruby_single_quote = Some(true); + let mut plugins = vec!["@prettier/plugin-ruby".to_string()]; + if erb { + plugins.push("@4az/prettier-plugin-html-erb".to_string()); + } + self.plugins = Some(plugins); self } @@ -203,6 +217,7 @@ impl PrettierConfig { trailing_comma: "es5".to_string(), print_width: 80, plugins: None, + ruby_single_quote: None, }, "airbnb" => Self { semi: true, @@ -211,8 +226,11 @@ impl PrettierConfig { trailing_comma: "es5".to_string(), print_width: 100, plugins: None, + ruby_single_quote: None, }, - "rails" | "sinatra" | "gem" | "ruby" => Self::default().with_ruby_plugin(), + // Rails renders ERB views, so it also gets the HTML+ERB plugin. + "rails" => Self::default().with_ruby_plugin(true), + "sinatra" | "gem" | "ruby" => Self::default().with_ruby_plugin(false), _ => Self::default(), } } @@ -237,24 +255,41 @@ impl fmt::Display for PrettierConfig { write!(f, "]")?; } + if let Some(ruby_single_quote) = self.ruby_single_quote { + write!(f, r#", "rubySingleQuote": {}"#, ruby_single_quote)?; + } + write!(f, "}}") } } +/// Pinned npm version for `@prettier/plugin-ruby` (the Ruby formatter plugin). +const PRETTIER_PLUGIN_RUBY_VERSION: &str = "^4.0.4"; +/// Pinned npm version for `@4az/prettier-plugin-html-erb` (ERB view formatter). +const PRETTIER_PLUGIN_HTML_ERB_VERSION: &str = "^0.0.7"; + +/// Base Prettier dev-dependencies shared by every Ruby package.json. +/// +/// Uses the published, scoped `@prettier/plugin-ruby` package — NOT the +/// unscoped `prettier-plugin-ruby` GitHub tarball, which does not match the +/// `.prettierrc` plugin id and leaves Prettier unable to format Ruby. +fn ruby_prettier_deps() -> HashMap { + let mut deps = HashMap::new(); + deps.insert("prettier".to_string(), "^3.0.0".to_string()); + deps.insert( + "@prettier/plugin-ruby".to_string(), + PRETTIER_PLUGIN_RUBY_VERSION.to_string(), + ); + deps +} + impl Default for PackageJson { fn default() -> Self { - let mut dev_dependencies = HashMap::new(); - dev_dependencies.insert("prettier".to_string(), "^3.0.0".to_string()); - dev_dependencies.insert( - "prettier-plugin-ruby".to_string(), - "github:prettier/plugin-ruby".to_string(), - ); - Self { name: "project".to_string(), version: "0.1.0".to_string(), description: "A Ruby project".to_string(), - dev_dependencies, + dev_dependencies: ruby_prettier_deps(), } } } @@ -267,11 +302,11 @@ impl PackageJson { version: "0.1.0".to_string(), description: "A Rails web application".to_string(), dev_dependencies: { - let mut deps = HashMap::new(); - deps.insert("prettier".to_string(), "^3.0.0".to_string()); + let mut deps = ruby_prettier_deps(); + // Rails renders ERB views; format them with the HTML+ERB plugin. deps.insert( - "prettier-plugin-ruby".to_string(), - "github:prettier/plugin-ruby".to_string(), + "@4az/prettier-plugin-html-erb".to_string(), + PRETTIER_PLUGIN_HTML_ERB_VERSION.to_string(), ); deps.insert("eslint".to_string(), "^8.0.0".to_string()); deps @@ -281,27 +316,14 @@ impl PackageJson { name: "sinatra-project".to_string(), version: "0.1.0".to_string(), description: "A Sinatra web application".to_string(), - dev_dependencies: { - let mut deps = HashMap::new(); - deps.insert("prettier".to_string(), "^3.0.0".to_string()); - deps.insert( - "prettier-plugin-ruby".to_string(), - "github:prettier/plugin-ruby".to_string(), - ); - deps - }, + dev_dependencies: ruby_prettier_deps(), }, "gem" => Self { name: "ruby-gem".to_string(), version: "0.1.0".to_string(), description: "A Ruby gem".to_string(), dev_dependencies: { - let mut deps = HashMap::new(); - deps.insert("prettier".to_string(), "^3.0.0".to_string()); - deps.insert( - "prettier-plugin-ruby".to_string(), - "github:prettier/plugin-ruby".to_string(), - ); + let mut deps = ruby_prettier_deps(); deps.insert("rspec".to_string(), "^3.12.0".to_string()); deps }, diff --git a/src/generators/basic.rs b/src/generators/basic.rs index aed76fe..2f75b9b 100644 --- a/src/generators/basic.rs +++ b/src/generators/basic.rs @@ -1,6 +1,18 @@ use crate::config::{EditorConfig, PrettierConfig}; use crate::error::InitiumError; +/// Paths Prettier should skip. Ruby/Rails projects carry framework-specific +/// build and vendor directories, so they get a richer ignore list than the +/// generic default. +fn prettier_ignore_content(template: &str) -> &'static str { + match template { + "ruby" | "rails" | "sinatra" | "gem" => { + "node_modules\nvendor\ntmp\nlog\nstorage\ncoverage\npublic/assets\npublic/packs\n.gitignore\n*.min.js\n*.min.css\n" + } + _ => "node_modules\ndist\nbuild\ncoverage\n*.min.js\n*.min.css\n", + } +} + impl super::ConfigGenerator { pub async fn generate_basic(&self, fail_on_exists: bool) -> Result<(), InitiumError> { self.generate_basic_with_template(fail_on_exists, "default") @@ -19,8 +31,13 @@ impl super::ConfigGenerator { let prettier = PrettierConfig::from_template(template); self.emit_file(".prettierrc", &prettier.to_string(), fail_on_exists, false) .await?; - self.emit_file(".prettierignore", "", fail_on_exists, false) - .await?; + self.emit_file( + ".prettierignore", + prettier_ignore_content(template), + fail_on_exists, + false, + ) + .await?; let justfile_content = r#"# Basic project justfile # Add your project-specific commands here diff --git a/src/generators/ruby.rs b/src/generators/ruby.rs index 6de6093..cd06525 100644 --- a/src/generators/ruby.rs +++ b/src/generators/ruby.rs @@ -61,8 +61,20 @@ AllCops: Style/Documentation: Enabled: false +# Prettier (@prettier/plugin-ruby) is the formatter source of truth. +# Disable RuboCop cops that fight it — never bend Prettier to satisfy RuboCop. Style/StringLiterals: - EnforcedStyle: single_quotes + Enabled: false +Style/StringLiteralsInInterpolation: + Enabled: false +Style/TrailingCommaInArguments: + Enabled: false +Style/TrailingCommaInArrayLiteral: + Enabled: false +Style/TrailingCommaInHashLiteral: + Enabled: false +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: false Layout/LineLength: Max: 120 @@ -86,8 +98,20 @@ AllCops: Style/Documentation: Enabled: false +# Prettier (@prettier/plugin-ruby) is the formatter source of truth. +# Disable RuboCop cops that fight it — never bend Prettier to satisfy RuboCop. Style/StringLiterals: - EnforcedStyle: single_quotes + Enabled: false +Style/StringLiteralsInInterpolation: + Enabled: false +Style/TrailingCommaInArguments: + Enabled: false +Style/TrailingCommaInArrayLiteral: + Enabled: false +Style/TrailingCommaInHashLiteral: + Enabled: false +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: false Layout/LineLength: Max: 120 @@ -106,8 +130,20 @@ AllCops: Style/Documentation: Enabled: false +# Prettier (@prettier/plugin-ruby) is the formatter source of truth. +# Disable RuboCop cops that fight it — never bend Prettier to satisfy RuboCop. Style/StringLiterals: - EnforcedStyle: single_quotes + Enabled: false +Style/StringLiteralsInInterpolation: + Enabled: false +Style/TrailingCommaInArguments: + Enabled: false +Style/TrailingCommaInArrayLiteral: + Enabled: false +Style/TrailingCommaInHashLiteral: + Enabled: false +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: false Layout/LineLength: Max: 120 @@ -124,8 +160,20 @@ AllCops: Style/Documentation: Enabled: false +# Prettier (@prettier/plugin-ruby) is the formatter source of truth. +# Disable RuboCop cops that fight it — never bend Prettier to satisfy RuboCop. Style/StringLiterals: - EnforcedStyle: single_quotes + Enabled: false +Style/StringLiteralsInInterpolation: + Enabled: false +Style/TrailingCommaInArguments: + Enabled: false +Style/TrailingCommaInArrayLiteral: + Enabled: false +Style/TrailingCommaInHashLiteral: + Enabled: false +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: false Layout/LineLength: Max: 120 @@ -170,6 +218,16 @@ rubocop: @echo "Running RuboCop..." @bundle exec rubocop +# Format with Prettier (source of truth for style) +fmt: + @echo "Formatting with Prettier..." + @npx prettier --write . + +# Check formatting without writing +fmt-check: + @echo "Checking formatting with Prettier..." + @npx prettier --check . + # Database operations db:migrate: @echo "Running database migrations..." @@ -206,6 +264,16 @@ rubocop: @echo "Running RuboCop..." @bundle exec rubocop +# Format with Prettier (source of truth for style) +fmt: + @echo "Formatting with Prettier..." + @npx prettier --write . + +# Check formatting without writing +fmt-check: + @echo "Checking formatting with Prettier..." + @npx prettier --check . + # Install dependencies install: @echo "Installing Ruby dependencies..." @@ -238,6 +306,16 @@ rubocop: @echo "Running RuboCop..." @bundle exec rubocop +# Format with Prettier (source of truth for style) +fmt: + @echo "Formatting with Prettier..." + @npx prettier --write . + +# Check formatting without writing +fmt-check: + @echo "Checking formatting with Prettier..." + @npx prettier --check . + # Install dependencies install-deps: @echo "Installing Ruby dependencies..." @@ -260,6 +338,16 @@ rubocop: @echo "Running RuboCop..." @bundle exec rubocop +# Format with Prettier (source of truth for style) +fmt: + @echo "Formatting with Prettier..." + @npx prettier --write . + +# Check formatting without writing +fmt-check: + @echo "Checking formatting with Prettier..." + @npx prettier --check . + # Install dependencies install: @echo "Installing Ruby dependencies..." diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index f040446..321a8c4 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -32,7 +32,8 @@ async fn test_generate_basic_config() { let prettier_ignore = std::fs::read_to_string(temp_dir.child(".prettierignore").path()).unwrap(); - assert!(prettier_ignore.is_empty()); + assert!(prettier_ignore.contains("node_modules")); + assert!(prettier_ignore.contains("coverage")); } #[tokio::test] @@ -70,16 +71,22 @@ async fn test_generate_ruby_config() { assert_eq!(node_version.trim(), "25.9.0"); let package_json = std::fs::read_to_string(temp_dir.child("package.json").path()).unwrap(); - assert!(package_json.contains("prettier-plugin-ruby")); - assert!(package_json.contains("github:prettier/plugin-ruby")); + // Scoped npm package, not the unscoped GitHub tarball that fails to load. + assert!(package_json.contains("@prettier/plugin-ruby")); + assert!(package_json.contains("^4.0.4")); + assert!(!package_json.contains("github:prettier/plugin-ruby")); let prettier_config = std::fs::read_to_string(temp_dir.child(".prettierrc").path()).unwrap(); assert!(prettier_config.contains("@prettier/plugin-ruby")); - assert!(prettier_config.contains("\"singleQuote\": false")); + // Prettier is the source of truth: single quotes for both JS and Ruby. + assert!(prettier_config.contains("\"singleQuote\": true")); + assert!(prettier_config.contains("\"rubySingleQuote\": true")); let rubocop_config = std::fs::read_to_string(temp_dir.child(".rubocop.yml").path()).unwrap(); assert!(rubocop_config.contains("TargetRubyVersion: 3.3")); assert!(rubocop_config.contains("Max: 120")); + // Cops that fight Prettier must be disabled, not enforced. + assert!(rubocop_config.contains("Style/StringLiterals:\n Enabled: false")); } #[tokio::test] diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index 9033d06..ec810fb 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -1,5 +1,6 @@ use initium::ConfigGenerator; use initium::PackageJson; +use initium::PrettierConfig; use std::path::PathBuf; #[test] @@ -264,6 +265,88 @@ fn test_ruby_package_json_valid_json() { } } +#[test] +fn test_ruby_package_json_uses_scoped_prettier_plugin() { + // Every Ruby template must install the scoped npm package, never the + // unscoped GitHub tarball that fails to load under @prettier/plugin-ruby. + // The Ruby *default* comes from PackageJson::default(); "default" in + // from_template is the Node template. + let ruby_jsons = [ + PackageJson::default().to_string(), + PackageJson::from_template("rails").to_string(), + PackageJson::from_template("sinatra").to_string(), + PackageJson::from_template("gem").to_string(), + ]; + for json in ruby_jsons { + assert!( + json.contains(r#""@prettier/plugin-ruby":"^4.0.4""#), + "Ruby package.json must pin @prettier/plugin-ruby: {json}" + ); + assert!( + !json.contains("github:prettier/plugin-ruby"), + "Ruby package.json must not use the GitHub tarball: {json}" + ); + } +} + +#[test] +fn test_rails_package_json_includes_erb_plugin() { + let json = PackageJson::from_template("rails").to_string(); + assert!(json.contains("@4az/prettier-plugin-html-erb")); + + // Non-Rails Ruby templates should not pull in the ERB plugin. + for template in ["sinatra", "gem"] { + let json = PackageJson::from_template(template).to_string(); + assert!( + !json.contains("@4az/prettier-plugin-html-erb"), + "template '{template}' should not include the ERB plugin" + ); + } +} + +#[test] +fn test_ruby_prettier_config_is_source_of_truth() { + // Ruby prettier config: single quotes everywhere + the Ruby plugin. + for template in ["ruby", "rails", "sinatra", "gem"] { + let config = PrettierConfig::from_template(template).to_string(); + let parsed: serde_json::Value = serde_json::from_str(&config) + .unwrap_or_else(|e| panic!("template '{template}' invalid JSON: {e}: {config}")); + + assert_eq!(parsed["singleQuote"], true, "template '{template}'"); + assert_eq!(parsed["rubySingleQuote"], true, "template '{template}'"); + assert!( + config.contains("@prettier/plugin-ruby"), + "template '{template}' missing Ruby plugin" + ); + } +} + +#[test] +fn test_rails_prettier_config_includes_erb_plugin() { + let rails = PrettierConfig::from_template("rails").to_string(); + assert!(rails.contains("@4az/prettier-plugin-html-erb")); + + // Sinatra/gem/ruby drive no ERB views, so they skip the ERB plugin. + for template in ["sinatra", "gem", "ruby"] { + let config = PrettierConfig::from_template(template).to_string(); + assert!( + !config.contains("@4az/prettier-plugin-html-erb"), + "template '{template}' should not include the ERB plugin" + ); + } +} + +#[test] +fn test_non_ruby_prettier_config_has_no_ruby_single_quote() { + for template in ["default", "google", "airbnb"] { + let config = PrettierConfig::from_template(template).to_string(); + assert!( + !config.contains("rubySingleQuote"), + "template '{template}' should not emit rubySingleQuote" + ); + } +} + #[test] fn test_dart_pubspec_content_generation() { let temp_dir = PathBuf::from("/tmp");