Go to English Blog

Parsing com a biblioteca Parslet

Leia em 4 minutos

Sempre que preciso fazer parsing de alguma string, a primeira coisa que me vem à cabeça é usar expressões regulares. Embora elas funcionem bem para grande parte das situações, é bom entender quais são as alternativas.

Com o Parslet é possível criar parsers usando Parsing Expression Grammar, que permite definir um conjunto de regras que detectam padrões de strings.

O primeiro passo é instalar o Parslet. Se você está usando o Bundler, basta adicionar a gem parslet ao seu arquivo Gemfile.

gem install parslet

Para este artigo, iremos fazer parsing de uma string que representa um número telefônico. As seguintes strings devem ser aceitas:

Para começar, vamos ter que criar um parser. Isso pode ser feito com a classe Parslet::Parser.

module PhoneNumber
  class Parser < Parslet::Parser
  end
end

As regras de nosso parser devem ser definidas diretamente no contexto da classe PhoneNumber::Parser. No Parslet, cada regra é chamada de átomo.

Todo parser deve ter um átomo raíz, que determina o modo como o parser funcionará. No exemplo à seguir, estamos definindo a raíz para um átomo chamado phone_number, que ainda não foi definido.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)
  end
end

Agora podemos definir os àtomos que compõem um número telefônico. Primeiro, temos um parêntese, que é opcional. Então, neste caso, vamos definir dois átomos: um para o parêntese obrigatório e outro para o parêntese opcional.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:phone_number) {
      paren?
    }
  end
end

Por enquanto, o átomo phone_number agrega apenas o átomo lparen?, mas logo iremos adicionar outros.

Na sequência, podemos definir o DDD, que é composto por dois números e que vai de 11 a 99.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:phone_number) {
      lparen? >> ddd
    }
  end
end

O método match permite definir alguns intervalos, como [:alnum:] (letras e números) e \\n (quebras de linha). Você pode criar matchers alternativos com o método |.

Agora teremos que definir o fechamento do parêntese.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:rparen) { str(")") }
    rule(:rparen?) { rparen.maybe }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:phone_number) {
      lparen? >> ddd >> rparen?
    }
  end
end

À seguir, um espaço.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:space) { str(" ") }

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:rparen) { str(")") }
    rule(:rparen?) { rparen.maybe }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:phone_number) {
      lparen? >> ddd >> rparen? >> space
    }
  end
end

A primeira parte do número telefônico consiste de 4 a 5 números. Até hoje não vi números de telefone que começam com zero, então vamos limitar no intervalo 1-9. Para definir a repetição do demais números, vamos usar o método repeat.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:space) { str(" ") }

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:rparen) { str(")") }
    rule(:rparen?) { rparen.maybe }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:first_block) {
      match("[1-9]") >> match("[0-9]").repeat(3,4)
    }

    rule(:phone_number) {
      lparen? >>
      ddd >>
      rparen? >>
      space >>
      first_block
    }
  end
end

Agora vem o hífen.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:space) { str(" ") }

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:rparen) { str(")") }
    rule(:rparen?) { rparen.maybe }

    rule(:hyphen) { str("-") }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:first_block) {
      match("[1-9]") >> match("[0-9]").repeat(3,4)
    }

    rule(:phone_number) {
      lparen? >>
      ddd >>
      rparen? >>
      space >>
      first_block >>
      hyphen
    }
  end
end

Finalmente, precisaremos definir o segundo bloco de números.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:space) { str(" ") }

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:rparen) { str(")") }
    rule(:rparen?) { rparen.maybe }

    rule(:hyphen) { str("-") }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:first_block) {
      match("[1-9]") >> match("[0-9]").repeat(3,4)
    }

    rule(:last_block) {
      match("[0-9]").repeat(4)
    }

    rule(:phone_number) {
      lparen? >>
      ddd >>
      rparen? >>
      space >>
      first_block >>
      hyphen >>
      last_block
    }
  end
end

Para construir a àrvore de análise sintática, vamos precisar instanciar a classe PhoneNumber::Parser.

parser = PhoneNumber::Parser.new
parser.parse("(11) 1234-5678")

O resultado será algo como isto:

"(11) 1234-5678"@0

Isso significa que nosso parser funcionou. Se passarmos algo como [11] 1234-5678, você verá que uma exceção será lançada.

parslet-1.5.0/lib/parslet/cause.rb:63:in `raise': Failed to match sequence (LPAREN? DDD RPAREN? SPACE FIRST_BLOCK HYPHEN LAST_BLOCK) at line 1 char 1. (Parslet::ParseFailed)
  from parslet-1.5.0/lib/parslet/atoms/base.rb:46:in `parse'
  from parser.rb:45:in `<main>'

Para transformar essa árvore em algo útil, vamos ter que identificar os átomos. Isso pode ser feito com o método as.

module PhoneNumber
  class Parser < Parslet::Parser
    root(:phone_number)

    rule(:space) { str(" ") }

    rule(:lparen) { str("(") }
    rule(:lparen?) { lparen.maybe }

    rule(:rparen) { str(")") }
    rule(:rparen?) { rparen.maybe }

    rule(:hyphen) { str("-") }

    rule(:ddd) {
      str("1") >> match("[1-9]") |
      match("[2-9]") >> match("[0-9]")
    }

    rule(:first_block) {
      match("[1-9]") >> match("[0-9]").repeat(3,4)
    }

    rule(:last_block) {
      match("[0-9]").repeat(4)
    }

    rule(:phone_number) {
      lparen? >>
      ddd.as(:ddd) >>
      rparen? >>
      space >>
      first_block.as(:block1) >>
      hyphen >>
      last_block.as(:block2)
    }
  end
end

Ao fazer o parsing novamente, teremos um hash contendo o valor dos átomos ddd, block1 e block2.

{:ddd=>"11"@1, :block1=>"1234"@5, :block2=>"5678"@10}

Também é possível modificar a árvore, gerando objetos específicos para cada um dos átomos. Para mais informações sobre esse processo, acesse a documentação da classe Parslet::Transformer.

Para facilitar o uso de nosso parser, podemos fazer um método utilitário que verifica se um número é valido ou não. No Parslet, a exceção Parslet::ParseFailed é lançada toda vez que o parsing falhar.

module PhoneNumber
  def self.valid?(number)
    parser = Parser.new
    parser.parse(number)
  rescue Parslet::ParseFailed
    return false
  end
end

Escrevendo testes

O Parslet vem com suporte para RSpec, com um matcher chamado parse. Para validar o nosso parser, poderíamos escrever os seguintes testes:

require "parslet/rig/rspec"
require "phone_number"

describe PhoneNumber::Parser do
  subject(:parser) { described_class.new }

  it "accepts number with parens" do
    expect(parser).to parse("(11) 1234-5678")
  end

  it "accepts number without parens" do
    expect(parser).to parse("11 1234-5678")
  end

  it "accepts cellphone numbers" do
    expect(parser).to parse("11 91234-5678")
  end

  it "rejects number starting with zeros" do
    expect(parser).not_to parse("11 01234-5678")
    expect(parser).not_to parse("11 0234-5678")
  end

  ("00".."10").each do |ddd|
    it "rejects DDD - #{ddd}" do
      expect(parser).not_to parse("#{ddd} 1234-5678")
    end
  end

  ("11".."99").each do |ddd|
    it "accepts DDD - #{ddd}" do
      expect(parser).to parse("#{ddd} 1234-5678")
    end
  end
end

Finalizando

O grande problema de artigos como este é que os exemplos sempre são simples e tudo parece over engineering. Mas deixe-me dar um caso de uso onde foi muito mais fácil usar o Parslet que expressões regulares.

No novo site do HOWTO as turmas abertas terão um calendário contendo todas as datas do curso. Em vez de criar um formulário e ter que adicionar cada data individualmente (ou algo nessa linha), criei uma biblioteca chamada date_interval. Com ela é possível definir intervalos de datas com expressões como 2014-01-01 - 2014-01-10, -weekends.

A gramática consiste de um ou mais intervalos de datas (date - date, date - date, ...) e uma combinação de filtros opcional. A biblioteca final ficou muito mais simples sem o uso de expressões regulares, e pode ser implementada em não mais que 398 linhas de código!

$ find lib -name '*.rb' | xargs wc -l
  37 lib/date_interval/date.rb
  22 lib/date_interval/filter/date.rb
  23 lib/date_interval/filter/holidays.rb
   9 lib/date_interval/filter/none.rb
  17 lib/date_interval/filter/operator.rb
  19 lib/date_interval/filter/weekday.rb
  15 lib/date_interval/filter/weekdays.rb
  15 lib/date_interval/filter/weekend.rb
  15 lib/date_interval/filter.rb
  96 lib/date_interval/parser.rb
  31 lib/date_interval/transformer.rb
   3 lib/date_interval/version.rb
  46 lib/date_interval.rb
 348 total

Da próxima vez que precisar fazer parsing de algum texto, experimente o Parslet: você pode se surpreender!