The raise of minimum-churn style in Ruby

Augusts Bautra - Oct 8 '21 - - Dev Community

TL;DR

  • Add trailing commas everywhere
  • Use consistent indentation
  • Disallow whitespace for formatting/aligning
  • Consider leading dot syntax, YMMW

The story

For me it all started with the announcement of version 1.0 release of the standard gem sometime back in March, 2021. Here was a library by the accomplished @searls that pre-configured Rubocop for the benefit of all mankind, and I was eager to imbibe. Perusing the readme, I immediately noticed the "Leading dots on multi-line method chains" point and opened the thread that lead to that decision.

The first argument for adopting the particular approach was that it would reduce the lines highlighted by git as changed if a new call was added to the end of a chain:

# trailing dot, noisy
  upcase.
  chars.
- drop(1)
+ drop(1).
+ reverse

# leading dot, quiet
  .upcase
  .chars
  .drop(1)
+ .reverse
Enter fullscreen mode Exit fullscreen mode

I had never really appreciated how last lines in chains like this, and, similarly, last lines in hashes and arrays are made different by the style used.
I had been burned by the occasional missing comma in a hash or array, but always figured aesthetics demand no comma at the end; now there was a good argument, even two, for breaking with aesthetics and instead adopting an approach that favours consistency and results in fewer mistakes and less noise!

# no final comma, inconsistent, very noisy
 {
   name: "Bob",
   faves: [
     :apples,
-    :beer
+    :beer,
+    :candy    
-   ]
+   ],
+  dislikes: [:radishes]  
 }

# final comma, very quiet
 {
   name: "Bob",
   faves: [
     :apples,
     :beer,    
+    :candy, 
   ],
+  dislikes: [:radishes],  
 }
Enter fullscreen mode Exit fullscreen mode

The same goes for multiline argument lists. Basically in any multiline list where individual entries would be comma-separated, keep adding trailing comma.

 data.dig(
   :properties,
   0,
   :rooms,
   -1,
-  :size
+  :size,
+  :square_meters
 )

 data.dig(
   :properties,
   0,
   :rooms,
   -1,
   :size,
+  :square_meters,
 )
Enter fullscreen mode Exit fullscreen mode

Now I was sold. Looking to minimise diff noise was a valid and powerful way to drive style decisions!
I looked through some of my recent commits to see what else produces noise and discovered a surprising fact - whitespace was the number one culprit for noise. It results from whitespace being used for alignment, thus tying several lines together, and sometimes change in one line demands changes in all. Extreme noise ensues.

An example with often-seen style where arbitrary leading whitespace is allowed (this sometimes even produces odd-numbered space indent, yuck!). Only the class name is changed, but it's impossible to easily tell. Good style setup should constrain any line to being at most one level deeper than the previous line.

- SomeClass.where(vip: true)
-          .where(local: false)
-          .where(name: "Jack", 
-                 title: "mr",)   
+ SomeOtherClass.where(vip: true)
+               .where(local: false)
+               .where(name: "Jack", 
+                      title: "mr",) 

# contrast with consistent indentation approach
- SomeClass
+ SomeOtherClass
   .where(vip: true)
   .where(local: false)
   .where(
     name: "Jack", 
     title: "mr",
    )   
Enter fullscreen mode Exit fullscreen mode

An example with often-seen arbitrary options hash alignment. Having consistent indentation and requiring that options hash starts on its own line also improves readability as to where positional args end and kwargs/options begin.

 FactoryBot.build_stubbed(
-  :some_model, :full_build, vip: true
-                            best: true
+  :some_model, :no_build, vip: true
+                          best: true
 )

# contrast with consistent indentation
 FactoryBot.build_stubbed(
-  :some_model, :full_build,
+  :some_model, :no_build,
   vip: true
   best: true
 )
Enter fullscreen mode Exit fullscreen mode

And an example with aligned hashes, what got changed? (hint, a new longest key appeared)

 {
-   the_longest_key: "value",
-   middle_key:      "value",
-   mini_key:        "value",
+   the_longeest_key: "value",
+   the_longest_key:  "value",
+   middle_key:       "value",
+   mini_key:         "value",
 }

# contrast with disallowed whitespace for alignment
 {
+  the_longeest_key: "value",
   the_longest_key: "value",
   middle_key: "value",
   mini_key: "value",  
 }
Enter fullscreen mode Exit fullscreen mode

The cops

This is not an exhaustive list, created with Rubocop v1.22 in mind. The settings provided should reduce whitespace abuses and add commas everywhere.

Layout/ArgumentAlignment:
  EnforcedStyle: with_fixed_indentation

Layout/ArrayAlignment:
  EnforcedStyle: with_fixed_indentation

Layout/ExtraSpacing:  
  Enabled: true  
  AllowForAlignment: false  
  AllowBeforeTrailingComments: true 
  ForceEqualSignAlignment: false

Layout/ParameterAlignment:
  EnforcedStyle: with_fixed_indentation

Layout/SpaceAroundOperators:
  AllowForAlignment: false
  EnforcedStyleForExponentOperator: no_space  

Style/TrailingCommaInArguments:  
  EnforcedStyleForMultiline: consistent_comma

Style/TrailingCommaInArrayLiteral: 
  EnforcedStyleForMultiline: consistent_comma

Style/TrailingCommaInHashLiteral:  
  EnforcedStyleForMultiline: consistent_comma

Layout/DotPosition:
  EnforcedStyle: leading  
Enter fullscreen mode Exit fullscreen mode

Addendum

You may also look at Layout/First* cops. Using them with EnforcedStyle: consistent complements alignment cops.

# with only alignment
some_method(:arg1,
  :arg2,
  :arg3,
)

other_method(arg, key1: :value1,
  key2: :value2,
)

# with First* cops
some_method(
  :arg1,
  :arg2,
  :arg3,
)

other_method(
  arg,
  key1: :value1,
  key2: :value2,
)
Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .