Como criar e manter padrões de código em seus projetos Ruby com Rubocop

Como criar e manter padrões de código em seus projetos Ruby com Rubocop

Se você desenvolve código Ruby, talvez esteja familiarizado com as boas práticas do Ruby Style Guide. Recomendo fortemente conferir caso não esteja. Existem softwares criados para nos ajudar a reforçar as regras desejadas dentro de nossos repositórios, estes são chamados de linters.

Lint, ou um linter, é uma ferramenta de análise estática de código usada para sinalizar erros de programação, bugs, erros de estilo e construções suspeitas. O termo se origina de um utilitário Unix que examinou o código fonte da linguagem C.[1] - Traduzido do Wikipedia

Quando falamos de linters para Ruby, existe um que é amplamente usado e disseminado, o Rubocop. Daqui em diante eu irei contar um pouco sobre o ecossistema do Rubocop, como ele funciona, como utilizar em seus projetos e como contribuir.

Caso você já esteja familiarizado com o Rubocop pode ir direto para "Como funciona?" ou "Como contribuir?"

Rubocop

O Rubocop é um linter feito em Ruby e para Ruby.

Pontos interessantes do Rubocop:

  • Existe desde 2012.
  • Documentação completa e de alta fidelidade.
  • Comunidade ativa e receptiva.
  • Possui pacotes de regras para o Ruby on Rails além das regras para Ruby.
  • Expandiu a biblioteca padrão de AST para facilitar a criação de regras mais elaboradas.

Como utilizar o Rubocop?

Para utilizar o Rubocop é bem simples e os comandos podem ser encontrados no repositório. Nada diferente para quem está acostumado a usar o Ruby Gems e o Bundler.

Config file

Você pode usar um arquivo .rubocop.yml para personalizar as regras utilizadas ao fazer o lint do diretório onde o arquivos está localizado.

$ rubocop --init # gera um arquivo .rubocop.yml no diretório

Caso você queira adicionar o Rubocop em um projeto existente que contenha muitas ofensas (violações do estilo do código) de acordo com as regras padrão do rubocop, utilize a opção abaixo:

$ rubocop --auto-gen-config

--auto-gen-config irá gerar um arquivo de configuração contendo exceções para todas as ofensas contidas no projeto, assim você pode introduzir as regras ao projeto aos poucos sem precisar fazer grandes e demoradas mudanças na sua base de código.

$ rubocop [options] [file, file2, ...]

$ rubocop -l # executa apenas regras de lint (sem performance, segurança etc)
$ rubocop --safe # executa apenas regras seguras - considerada estáveis e determinísticas
$ rubocop -F # OU --fail-fast interrompe a execução na primeira ofensa
$ rubocop -a # OU --auto-correct corrige ofensas quando seguras
$ rubocop -A # OU --auto-correct-all corrige todas as ofensas mesmo não seguras

Como funciona?

O Rubocop funciona através de regras chamadas de cops. Cops são representados por classes que funcionam de modo semelhante a um Service object. Cada classe é responsável por:

  • Uma regra
  • Parâmetros de configuração
  • Mensagens de erros

AST & node matcher

AST significa Abstract Syntax Tree e é modo como o Rubocop desconstrói o código para fazer buscas por padrões de sintaxe - de código Ruby neste caso. Originalmente o Rubocop utilizava diretamente a gem parser, mas com o tempo sentiram falta de várias ferramentas e criaram o Rubocop AST que extende a gem parser.

Na documentação do parser você pode conferir os tipos de sintaxes que ele é capaz de identificar: de sintaxes básicas como atribuições e tipos até sintaxes mais elaboradas como argumentos ocultos de blocos. Já na documentação do Rubocop AST podemos ver como ele extende o parser e implementa ferramentas muito semelhantes às utilizadas em expressões regulares.

Com isso já podemos capturar grupos e identificar sintaxes independente da ordem e da quantidade de nódulos subsequentes. Essas ferramentas nos ajudam muito na hora de trabalharmos com regras que envolvem o uso de argumentos específicos nas chamadas de métodos - mais comum no Rubocop Rails, mas também presente no Rubocop.

De maneira bem objetiva, isso já cobre o funcionamento do Rubocop que precisamos entender para podermos contribuir. Existem cops que procuram padrões de sintaxe Ruby através do AST. Ao identificar estes padrões, os cops aplicam ofensas àquele pedaço de código. Não precisamos entender a arquitetura do projeto ou API do comando do terminal para podermos contribuir com novas regras que podem ser úteis para nós e para a comunidade.

Como contribuir?

3 dicas/avisos antes de começarmos a contribuir:

  1. Agora que sabemos como tudo funciona, vou deixar claro que existe uma curva de aprendizado entre entender o AST e saber de fato trabalhar com ele de maneira eficiente, assim como existe com Regex. Então não se fruste, mas também não pense que será rápido criar um novo cop dependendo do que vocês está buscando fazer.
  2. Cave o projeto e procure por exemplos de uso destas regras em outros cops para ter ideias de como utilizar. Não pare no primeiro cop, olhe o máximo de cops que puder. É um projeto open source e seu primeiro exemplo provavelmente não será o melhor exemplo.
  3. O projeto possui testes feitos em rspec para todos os cops e contribuir para ele quer dizer que você será responsável por testar seu código também. Esteja familiarizado ou leve em consideração o tempo de aprender a utilizar.

Contribuindo para os cops

Vamos tentar fazer uma mudança simples, mas tentando entender como as coisas funcionam no caminho.

Uma convenção do Ruby Style Guide é a Nomeação de predicados. Essa regra nos diz que devemos evitar certos prefixos para métodos que retornam booleanas e força a utilização da interrogação (?) no final do nome, como vemos nos exemplos abaixo:

# bad
def is_even(value)
end

def is_even?(value)
end

# good
def even?(value)
end

# bad
def has_value
end

def has_value?
end

# good
def value?
end

# Tirado da documentação em Abril de 2022

Os prefixos proibidos por padrão são is_, has_, have_ e vamos supor que temos visto muitos casos de should_ e queremos propor essa mudança para o Rubocop ao invés de configurar em todos os projetos que constribuímos.

A regra na doc oficial se chama Naming/PredicateName e uma rápida busca no projeto nos mostra o seguinte cop existente:

rubocop/lib/rubocop/cop/naming/predicate_name.rb

# frozen_string_literal: true

module RuboCop
  module Cop
    module Naming
      class PredicateName < Base
        include AllowedMethods

        # @!method dynamic_method_define(node)
        def_node_matcher :dynamic_method_define, <<~PATTERN
          (send nil? #method_definition_macros
            (sym $_)
            ...)
        PATTERN

        def on_send(node)
          dynamic_method_define(node) do |method_name|
            predicate_prefixes.each do |prefix|
              next if allowed_method_name?(method_name.to_s, prefix)

              add_offense(
                node.first_argument.loc.expression,
                message: message(method_name, expected_name(method_name.to_s, prefix))
              )
            end
          end
        end

        def on_def(node)
          predicate_prefixes.each do |prefix|
            method_name = node.method_name.to_s

            next if allowed_method_name?(method_name, prefix)

            add_offense(
              node.loc.name,
              message: message(method_name, expected_name(method_name, prefix))
            )
          end
        end
        alias on_defs on_def

        private

        def allowed_method_name?(method_name, prefix)
          !(method_name.start_with?(prefix) && # cheap check to avoid allocating Regexp
              method_name.match?(/^#{prefix}[^0-9]/)) ||
            method_name == expected_name(method_name, prefix) ||
            method_name.end_with?('=') ||
            allowed_method?(method_name)
        end

        def expected_name(method_name, prefix)
          new_name = if forbidden_prefixes.include?(prefix)
                       method_name.sub(prefix, '')
                     else
                       method_name.dup
                     end
          new_name << '?' unless method_name.end_with?('?')
          new_name
        end

        def message(method_name, new_name)
          "Rename `#{method_name}` to `#{new_name}`."
        end

        def forbidden_prefixes
          cop_config['ForbiddenPrefixes']
        end

        def predicate_prefixes
          cop_config['NamePrefix']
        end

        def method_definition_macros(macro_name)
          cop_config['MethodDefinitionMacros'].include?(macro_name.to_s)
        end
      end
    end
  end
end

Vamos quebrar o que está acontecendo aqui:

  1. A classe herda Base e inclui AllowedMethods
  • Base possui diversos métodos compartilhados entre todos os cops. Poderia passar despercebido, mas olhe bem: a documentação no cabeçalho do Base nos conta como usar essa classe para criar cops. Como eu disse antes, esse projeto é muito bem documentado 😉
  • AllowedMethods parace nos passar regras sobre métodos que são sempre permitidos e não serão analisados por esse cop

2. Chamada do método def_node_matcher recebendo 2 parâmetros: um symbol :dynamic_method_define e um padrão AST declarado em um heredoc

  • A documentação do método nos diz que este symbol é o nome do método node_matcher que está sendo definido.
  • Pelo nome dynamic_method_define podemos imaginar que ele é usado para encontrar métodos sendo definidos dinamicamente através de define_singleton_method ou define_method no Ruby.

3. Se você leu a documentação do Base sabe o motivo de termos os métodos on_def e on_send neste cop: executar as regras do cop durante os nós (nodes) com ocorrências de definição de métodos.

  • Vamos analisar essas regras...

Agora sabemos que os prefixos que precisamos alterar estão no método predicate_prefixes, mas ele possui apenas o seguinte código:

def predicate_prefixes
  cop_config['NamePrefix']
end

Buscando NamePrefix no projeto encontramos uma ocorrência em rubocop/config/default.yml. Ou seja, configurações padrões! Então para adicionar um novo prefixo em:

ForbiddenPrefixes:
  - is_
  - has_
  - have_
  - should_

Agora falta só abrir o Pull Request

Existe um guia claro de como contribuir, que pode mudar a partir da data que escrevi este artigo, mas falando de maneira genérica, devemos sempre:

  • Abrir PRs com um objetivo único e claro
  • Garantir que adicionamos testes e os existentes continuem passando
  • Rodar o bundle exec rubocop para fazer lint do próprio código
  • Adicionar as devidas alterações na documentação
  • Adicionar seu nick do github entre os contribuídores e atualizar o changelog
  • Fazer squad dos commits

Este é o template para novos PRs em Abril de 2022:

Before submitting the PR make sure the following are checked:

* [ ] The PR relates to *only* one subject with a clear title and description in grammatically correct, complete sentences.
* [ ] Wrote [good commit messages][1].
* [ ] Commit message starts with `[Fix #issue-number]` (if the related issue exists).
* [ ] Feature branch is up-to-date with `master` (if not - rebase it).
* [ ] Squashed related commits together.
* [ ] Added tests.
* [ ] Ran `bundle exec rake default`. It executes all tests and runs RuboCop on its own code.
* [ ] Added an entry (file) to the [changelog folder](https://github.com/rubocop/rubocop/blob/master/changelog/) named `{change_type}_{change_description}.md` if the new code introduces user-observable changes. See [changelog entry format](https://github.com/rubocop/rubocop/blob/master/CONTRIBUTING.md#changelog-entry-format) for details.

[1]: https://chris.beams.io/posts/git-commit/

Repassando

Aprendemos:

  • Como encontrar cops e o funcionamento básico deles
  • Como cops estão estruturados e o que herdam
  • A documentação do projeto acontece dentro do próprio código
  • A existência de arquvios de configuração
  • Vimos um exemplo básico de padrão AST que podemos estudar mais a fundo 😉

Agora você pode usar o Rubocop para garantir a saúde e fácil compreensão de seus projetos Ruby, além de propor novas regras ou alterações. Lembre-se que o Rubocop é um projeto de código aberto e isso quer dizer que as decisões tomadas pelo projeto irão sempre favorecer mudanças que sejam positivas para a maioria dos usuários e não para cada caso, mas algo que afeta você pode ser exatamente algo que afeta muitos outros usuários também. Um bom jeito de começar pode ser ajudando a resolver alguma issue aberta.