Skip to content

Instantly share code, notes, and snippets.

@edwardloveall
Last active May 13, 2022 20:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save edwardloveall/33ec528c34aee8a4e1235860891f8296 to your computer and use it in GitHub Desktop.
Save edwardloveall/33ec528c34aee8a4e1235860891f8296 to your computer and use it in GitHub Desktop.
Custom cop for detecting abbreviations in variables/class names/etc

This is a custom Rubocop cop to detect abbreviations in the names of classes, methods, variables, and constants. It does this by taking each definition, splitting it up into individual words, and running it through a spellchecker. The english spellchecker is from ffi-aspell and the ruby and html dictionaries are from spellr. As a nice side-effect, this helps us to avoid misspellings in our names.

Note that spellr and ffi-aspell are required for this, but could be swapped out or removed if desired. Small editing will be needed to remove them from abbreviations.rb.

Note that this doesn't care about using the abbreviations, only defining them. So for example, these will receive a warning:

  • abc_thing = ...
  • class AbcThing
  • ABC_CONST = ...
  • def abc_code

but these will not:

  • abc_thing(code: abc)
  • AbcCode.call
  • list.include?(ABC_CONST)

Most repos use a bunch of words that are not in any dictionary but are nonetheless valid. These words can be added in the configuration for the cop which looks like:

require:
  - ./path/to/abbreviations.rb

CustomCops/Abbreviations:
  AllowedWords:
    - cancellable
  QuestionableWords:
    - aad
  AllowedFileWords:
    - path: Gemfile
      words:
        - repo

AllowedWords

These words are we expect to not be in the dictionary but are valid words. Example: arel, stubber, dnd

QuestionableWords

These are words that should likely be removed, fixed, or that are actually valid but you might not know. The goal would be to remove this section entirely over time. It's here now because it allows for increment fixes. Examples: foo, pll, authoized (misspelling of authorized),

AllowedFileWords

In some cases, we want to only allow the word in a single file because they are required by some external API or for whatever reason they're okay in one place but not another. Examples: config in this custom cop because cop_config is a RuboCop value, repo in the Gemfile.

How to try this out

You can run this cop on the whole codebase or a single file:

bin/rubocop --only CustomCops/Abbreviations path/to/abbreviations.rb

References used:

require "active_support"
require_relative "../../config/initializers/inflections"
require "ffi/aspell"
require "spellr"
module CustomCops
class Abbreviations < RuboCop::Cop::Base
NON_LETTERS = /[^a-z]+/
ABLE_SUFFIX = /able$/
def check_token(token, node)
offenses = collect_offenses(token.to_s)
if offenses.any?
message = "Please avoid the abbreviation(s): #{offenses.join(", ")}"
add_offense(node.loc.name, message: message)
end
end
def on_def(node)
check_token(node.method_name, node)
end
def on_class(node)
check_token(node.children.first.const_name, node)
end
def on_casgn(node)
check_token(node.name, node)
end
def on_lvasgn(node)
check_token(node.name, node)
end
def on_investigation_end
speller.close
end
private
def collect_offenses(token)
words_from_token(token).select do |word|
!defined_word?(word) && !allowed?(word, processed_source.file_path)
end
end
def words_from_token(token)
token.titleize.downcase.split(NON_LETTERS)
end
def defined_word?(word)
speller.correct?(word) ||
speller.correct?(word.sub(ABLE_SUFFIX, "")) ||
ruby_words.include?(word).present? ||
html_words.include?(word).present?
end
def allowed?(word, path)
allowed_word?(word) ||
questionable_word?(word) ||
allowed_file_word?(word, path) ||
active_support_abbreviation?(word)
end
def allowed_word?(word)
case_insensitive_include?(cop_config["AllowedWords"], word)
end
def questionable_word?(word)
case_insensitive_include?(cop_config["QuestionableWords"], word)
end
def allowed_file_word?(word, path)
relative_path = Pathname.new(path).relative_path_from(Dir.pwd).to_s
file_config = cop_config["AllowedFileWords"]
.find { |allow| allow["path"] == relative_path }
file_config.present? &&
case_insensitive_include?(file_config["words"], word)
end
def active_support_abbreviation?(word)
@acronyms ||= ActiveSupport::Inflector.inflections.acronyms.keys
case_insensitive_include?(@acronyms, word)
end
def speller
@speller ||= FFI::Aspell::Speller.new("en_US")
end
def ruby_words
@ruby_words ||= begin
ruby_list = get_spellr_wordlist("ruby")
Spellr::Wordlist.new(ruby_list)
end
end
def html_words
@html_words ||= begin
html_list = get_spellr_wordlist("html")
Spellr::Wordlist.new(html_list)
end
end
def get_spellr_wordlist(list_name)
spellr_path = Gem::Specification.find_by_name("spellr").full_gem_path
Pathname.new(spellr_path).join("wordlists", "#{list_name}.txt")
end
def case_insensitive_include?(array, string)
array.any? { |s| s.casecmp(string).zero? }
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment