24 dias de Hackage, 2015 - dia 14 - Earley: Uma biblioteca de parsers promisora para Haskell
por Franklin Chen
traduzido por Pedro Yamada
Esse é um artigo escrito por Franklin Chen e traduzido para o português. Ler original.
Índice de toda a série
O índice de toda a série está no topo do artigo para o dia 1.
Encontro HaskellBR São Paulo
Dia 14
No
dia 10,
mostrei como usar s-expressions para evitar ter de escrever um parser
customizado. Mas escrever parsers não é nada mal em Haskell; ou será que é?
A popular biblioteca parsec
tem muitos problemas,
porque ela requere backtracking hackeado à mão que causa mensagens de erro
estranhas e dificuldade de pensar sobre a sua gramática. Há um fork melhorado
do parsec
chamado
megaparsec
,
mas ainda é o mesmo tipo de tecnologia. Que tal algo completamente diferente?
O recente pacote Earley
é
intrigante e eu comecei a o usar para novos projetos onde eu não preciso do
poder monádico de algo como o parsec
mas me contento com uma API de
applicatives e não preciso da performance de algo como o attoparsec
. Além
de boas mensagens de erro, ele lida bem com parsing inline e ambiguidades.
Hoje vou dar dois exemplos pequenos de usar o Earley
.
Instalação
Já que o LTS Stackage está um pouco atrasado agora, e o Earley
não para de
mudar, decidi usar a última versão do Earley
modificando nosso stack.yaml
:
- Earley-0.10.1.0
Parsing para a AST do dia 10
Vamos voltar para o problema de diferenciação simbólica do dia 10 e criar uma sintaxe infixa de tipo matemático para “parsear”.
Testes
Aqui estão alguns testes do HSpec/QuickCheck para ilustrar o que nós queremos
quando “parseamos” uma Exp
de uma string.
Imports
Text.Earley
é o módulo principal do pacote Earley
; Report
é usado para
retornar um relátorio do progresso do “parse”.
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase #-}
module SymbolicDifferentiation.EarleySpec where
import SymbolicDifferentiation.AlphaSyntax (Exp(N, V, Plus, Times))
import qualified SymbolicDifferentiation.Earley as Earley
import Text.Earley (Report(..))
import Test.Hspec (Spec, hspec, describe, it, shouldBe, shouldSatisfy)
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck (NonNegative(..))
import Data.String.Here (i)
Testes do QuickCheck
Alguns testes do QuickCheck que mostram que expressões como
x*a + y*b * (z+c)
“parseiam” para a AST esperada:
spec :: Spec
spec =
describe "Custom syntax for expression parsed by Earley" $ do
-- For simplicity, don't support negative numeric literals now.
prop "x + a" $ \(NonNegative (a :: Int)) ->
fst (Earley.parses [i|x + ${a}|]) `shouldBe`
[Plus (V "x") (N a)]
prop "x*a + y*b * (z+c)" $
\(NonNegative (a :: Int))
(NonNegative (b :: Int))
(NonNegative (c :: Int)) ->
fst (Earley.parses [i|x*${a} + y*${b} * (z+${c})|]) `shouldBe`
[Plus (Times (V "x") (N a))
(Times (Times (V "y") (N b))
(Plus (V "z") (N c)))]
Erros de parse esperados
Finalmente, um exemplo de como checar por erros de parse esperados. Os tokens de erro são definidos pelo o usuário e adicionados à construção de gramáticas, como veremos.
it "x + y * + 5" $
Earley.parses "x + y * + 5" `shouldSatisfy`
\case
([], Report { position = 8
, expected = ["number", "identifier", "("]
, unconsumed = "+ 5"
}) -> True
_ -> False
Implementação
A implementação envolve idiomas de Applicative
que vão ser familiares se você
já usou o parsec
.
Imports
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecursiveDo #-}
{-# LANGUAGE FlexibleContexts #-}
module SymbolicDifferentiation.Earley where
import SymbolicDifferentiation.AlphaSyntax (Exp(N, V, Plus, Times))
import qualified Text.Earley as E
import Text.Earley ((<?>))
import Control.Applicative (many, some, (<|>))
import qualified Data.Char as Char
import Control.Monad.ST (ST)
import Data.ListLike (ListLike)
-- | What to report for something expected.
type Expected = String
O operador <?>
é usado para adicionar uma expectativa (que nós decidimos
especificar como uma string, com o sinônimo de tipo Expected
) para um
production.
Drivers
O que queremos para nosso problema em particular é um parser que recebe uma string como input e espera que possa fazer parse completo dela. Construimos ele a partir de um parser mais genérico que vem de processar nossa gramática.
-- | Return a list of all possible `Exp` parses, and also a status report
-- regardless of how many successes.
parses :: String -> ([Exp], E.Report Expected String)
parses = E.fullParses expParser
-- | Parser created from the grammar.
expParser :: ListLike input Char =>
ST state (input -> ST state (E.Result state Expected input Exp))
expParser = E.parser grammar
Gramática
Nossa gramática é bem direta. Earley
usa uma mônada para manter seu estado
interno e nós usamos a extensão do GHC RecursiveDo
(coberta em um
dia de extensões do GHC de 2014)
para conseguirmos nos referir para uma regra da gramática recursivamente. Note
que a recursão da esquerda para a direita não tem problemas no Earley
.
Prod
é o
construtor de tipo para uma production
e você constrói productions usando combinadores como satisfy
e symbol
.
-- | Basically taken from <https://github.com/ollef/Earley/blob/master/examples/Expr2.hs Earley example expression parser>
grammar :: forall r. E.Grammar r (E.Prod r Expected Char Exp)
grammar = mdo
whitespace <- E.rule $
many $ E.satisfy Char.isSpace
let token :: E.Prod r Expected Char a -> E.Prod r Expected Char a
token p = whitespace *> p
sym x = token $ E.symbol x <?> [x]
ident = token $ (:) <$> E.satisfy Char.isAlpha
<*> many (E.satisfy Char.isAlphaNum)
<?> "identifier"
num = token $ some (E.satisfy Char.isDigit) <?> "number"
-- For now, just handle unsigned numeric literals.
atom <- E.rule $
(N . read) <$> num
<|> V <$> ident
<|> sym '(' *> term <* sym ')'
factor <- E.rule $
Times <$> factor <* sym '*' <*> atom
<|> atom
term <- E.rule $
Plus <$> term <* sym '+' <*> factor
<|> factor
return $ term <* whitespace
Para mais exemplos de gramáticas veja o
diretório de exemplos no repositório do GitHub do Earley
.
(Update of 2015-12-17)
O inverso de parsing é pretty-printing, coberto no dia 17.
N.T. Não traduzido; link vai para o original
Só por diversão: resolvendo o problema “number words”
A habilidade de lidar com ambiguidades e retornar todos os parses possível é algo útil em muitas situações. Aqui, mostro uma solução para o problema “number word”. No passado, eu lidei com ambiguidades usando o suporte GLR do Happy, mas não gosto de escrever parsers com ele.
O problema “number word”:
Dado um inteiro positivo, retorne todas as formas que um inteiro pode ser representado por letras usando o mapa
1 -> A, 2 -> B, ..., 26 -> Z
. Por exemplo, o número 1234 pode ser representado pelas palavrasABCD
,AWD
eLCD
.
Essa é uma versão de brincadeira de um problema sério, a segmentação em linguagens naturais.
O teste
O teste reflete o enunciado do problema:
module EarleyExampleSpec where
import EarleyExample (grammar, NumberWord, Expected)
import qualified Text.Earley as E
import qualified Data.List.NonEmpty as NonEmpty
import Test.Hspec (Spec, hspec, describe, it, shouldMatchList)
spec :: Spec
spec =
describe "EarleyExample" $ do
it "returns all possible parses of number words" $ do
let (result, _) = parseNumberWord 1234
map NonEmpty.toList result `shouldMatchList`
["ABCD", "AWD", "LCD"]
parseNumberWord :: Integer -> ([NumberWord], E.Report Expected String)
parseNumberWord = E.fullParses (E.parser grammar) . show
Note que estou usando uma lista NonEmpty
de Char
s porque uma string vazia
não é uma solução válida para o problema. (Cobri NonEmpty
no
dia 7).
Solução
A solução é só escrever uma gramática que tente escolher dígitos consecutivos
para formar uma letra. Nós criamos uma production para cada letra que nos
importa, usando o numberLetterFor
, combinamos todas elas por meio de
alternação usando asum
e conseguimos uma production composta, que é a
gramática.
{-# LANGUAGE RecursiveDo #-}
module EarleyExample where
import qualified Text.Earley as E
import Text.Earley ((<?>))
import Control.Applicative ((<|>))
import qualified Data.Foldable as Foldable
import qualified Data.Char as Char
import qualified Data.List.NonEmpty as NonEmpty
import Data.List.NonEmpty (NonEmpty((:|)))
-- | Result wanted.
type NumberWord = NonEmpty NumberLetter
-- | 'A' to 'Z'.
type NumberLetter = Char
-- | What to report for something expected.
type Expected = String
grammar :: E.Grammar r (E.Prod r Expected Char NumberWord)
grammar = mdo
numberWord <- E.rule $
NonEmpty.cons <$> numberLetter <*> numberWord
<|> (:| []) <$> numberLetter
return numberWord
numberLetter :: E.Prod r Expected Char NumberLetter
numberLetter = (Foldable.asum . map numberLetterFor) ['A'..'Z'] <?> "number"
-- | Return a production for a given letter.
--
-- 1 is 'A', 2 is 'B', .. 26 is 'Z'.
numberLetterFor :: NumberLetter -> E.Prod r Expected Char NumberLetter
numberLetterFor c = c <$ E.word (show (toNumber c)) <?> [c]
-- | 'A' is 1, ... 'Z' is 26
toNumber :: NumberLetter -> Int
toNumber c = (Char.ord c - Char.ord 'A') + 1
Conclusão
Só recentemente descobri a biblioteca de parsers Earley
e comecei a
usá-la. Eu estou animado com o quão amigável é.
Todo o código
Todo o código para a série estará nesse repositório do GitHub.
Nota do tradutor
Se você quer ajudar com esse tipo de coisa, agora é a hora. Entre no
Slack ou no
IRC da HaskellBR e
contribua. Esse blog e outros projetos associados estão na
organização haskellbr
no GitHub e em
haskellbr.com/git.