Home

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

Vamos nos encontrar em São Paulo em 25 de Janeiro de 2016. Marque sua presença e comente se não puder vir.

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 palavras ABCD, AWD e LCD.

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 Chars 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.

Há um milestone no GitHub com tarefas esperando por você..

Share Comente no Twitter