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:
(11) 1234-5678
11 1234-5678
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!