在Haskell中解析可打印的文本文件

时间:2013-12-12 16:14:44

标签: parsing haskell text

我正试图弄清楚在Haskell中解析特定文本文件的“正确”方法。

在F#中,我遍历每一行,根据正则表达式对其进行测试以确定它是否是我要解析的行,然后如果是,我使用正则表达式解析它。否则,我忽略了这条线。

该文件是可打印的报告,每页都有标题。每条记录是一行,每个字段由两个或多个空格分隔。这是一个例子:

                                                    MY COMPANY'S NAME
                                                     PROGRAM LISTING
                                             STATE:  OK     PRODUCT: ProductName
                                                 (DESCRIPTION OF REPORT)
                                                    DATE:   11/03/2013

  This is the first line of a a two-line description of the contents of this report. The description, as noted,
  spans two lines. This is more text. I'm running out of things to write. Blah.

          DIVISION CODE: 3     XYZ CODE: FAA3   AGENT CODE: 0007                                       PAGE NO:  1

 AGENT    TARGET NAME                      ST   UD   TARGET#   XYZ#   X-DATE       YEAR    CO          ENCODING
 -----    ------------------------------   --   --   -------   ----   ----------   ----    ----------  ----------

 0007     SMITH, JOHN                      43   3    1234567   001    12/06/2013   2004    ABC         SIZE XL
 0007     SMITH, JANE                      43   3    2345678   001    12/07/2013   2005    ACME        YELLOW
 0007     DOE, JOHN                        43   3    3456789   004    12/09/2013   2008    MICROSOFT   GREEN
 0007     DOE, JANE                        43   3    4567890   002    12/09/2013   2007    MICROSOFT   BLUE
 0007     BORGES, JORGE LUIS               43   3    5678901   001    12/09/2013   2008    DUFEMSCHM   Y1500
 0007     DEWEY, JOHN &                    43   3    6789012   003    12/11/2013   2013    ERTZEVILI   X1500
 0007     NIETZSCHE, FRIEDRICH             43   3    7890123   004    12/11/2013   2006    NCORPORAT   X7

我首先构建了解析器来测试每一行以查看它是否是记录。如果它是一个记录,我只是根据我自己的子串函数的字符位置来切割线。这很好用。

然后我发现我确实在我的Haskell安装中有一个正则表达式库,所以我决定尝试像在F#中那样使用正则表达式。由于图书馆拒绝完全有效的正则表达式,因此失败了。

然后我想,Parsec怎么样?但是使用它的学习曲线越高越好,我发现自己想知道它是否是解析此报告这样简单任务的正确工具。

所以我想我会问一些Haskell专家:你会如何解析这种报告?我不是要求代码,但如果你有代码,我很乐意看到它。我真的要求技术或技术。

谢谢!

P.S。输出只是一个冒号分隔的文件,文件顶部有一行字段名,后面只有记录,可以导入最终用户的Excel。

修改

非常感谢你们的评论和答案!

因为我最初没有说清楚:示例的前十四行重复每页(打印)输出,每页的记录数从零变为整页(看起来像45条记录) 。我为之前没有说清楚而道歉,因为它可能会影响已经提供的一些答案。

我的Haskell系统目前仅限于Parsec(它没有attoparsec)和Text.Regex.Base以及Text.Regex.Posix。我将不得不看到安装attoparsec和/或其他Regex库。但就目前而言,你已经说服我继续学习Parsec。感谢您提供非常有用的代码示例!

3 个答案:

答案 0 :(得分:4)

这绝对是一个值得解析库的工作。我的主要目标通常是(即,我打算使用超过一次或两次的任何东西)尽快将数据转换为非文本形式,如

module ReportParser where

import Prelude hiding (takeWhile)
import Data.Text hiding (takeWhile)

import Control.Applicative
import Data.Attoparsec.Text

data ReportHeaderData = Company Text
                      | Program Text
                      | State Text
--                    ...
                      | FieldNames [Text]

data ReportData = ReportData Int Text Int Int Int Int Date Int Text Text

data Date = Date Int Int Int

我们可以说,为了论证,报告是

data Report = Report [ReportHeaderData] [ReportData]

现在,我通常创建一个解析器,它是一个与数据类型相同的函数

-- Ending condition for a field
doubleSpace :: Parser Char
doubleSpace = space >> space

-- Clears leading spaces
clearSpaces :: Parser Text
clearSpaces = takeWhile (== ' ') -- Naively assumes no tabs

-- Throws away everything up to and including a newline character (naively assumes unix line endings)
clearNewline :: Parser ()
clearNewline = (anyChar `manyTill` char '\n') *> pure ()

-- Parse a date
date :: Parser Date
date = Date <$> decimal <*> (char '/' *> decimal) <*> (char '/' *> decimal)

-- Parse a report
reportData :: Parser ReportData
reportData = let f1 = decimal <* clearSpaces
                 f2 = (pack <$> manyTill anyChar doubleSpace) <* clearSpaces
                 f3 = decimal <* clearSpaces
                 f4 = decimal <* clearSpaces
                 f5 = decimal <* clearSpaces
                 f6 = decimal <* clearSpaces
                 f7 = date <* clearSpaces
                 f8 = decimal <* clearSpaces
                 f9 = (pack <$> manyTill anyChar doubleSpace) <* clearSpaces
                 f10 = (pack <$> manyTill anyChar doubleSpace) <* clearNewline
             in ReportData <$> f1 <*> f2 <*> f3 <*> f4 <*> f5 <*> f6 <*> f7 <*> f8 <*> f9 <*> f10

正确运行one of the parse functions并使用其中一个组合器(例如many(可能还有feed,如果你最终获得了部分结果),你应该结束最后列出ReportData s。然后您可以使用您创建的某些功能将它们转换为CSV。

请注意,我没有处理标题。编写代码来解析它并使用例如构建Report应该是相对微不足道的。

-- Not tested
parseReport = Report <$> (many reportHeader) <*> (many reportData)

请注意,我更喜欢Applicative表单,但如果您愿意,也可以使用monadic表单(我在doubleSpace中执行过)。由于名称隐含的原因,Data.Alternative也很有用。

为了玩这个,我强烈推荐GHCI和parseTest功能。 GHCI只是整体方便而且是测试单个解析器的好方法,而parseTest采用解析器和输入字符串并输出运行状态,解析后的字符串以及未解析的任何剩余字符串。当你不太确定发生了什么时非常有用。

答案 1 :(得分:2)

我建议使用解析器的语言非常简单(我过去使用正则表达式解析了很多像这样的文件),但是parsec使它变得如此简单 -

parseLine = do
  first <- count 4 anyChar
  second <- count 4 anyChar
  return (first, second)

parseFile = endBy parseLine (char '\n')

main = interact $ show . parse parseFile "-" 

函数“parseLine”通过将两个由固定长度组成的字段链接在一起来创建单个行的解析器(4个字符,任何字符都可以)。

函数“parseFile”然后将这些链接在一起作为行列表。

当然,您必须添加更多字段,并在数据中切断标题,但所有这些在parsec中都很容易。

这可以说比正则表达式更容易阅读....

答案 2 :(得分:1)

假设有一些事情 - 标题是固定的,每行的字段是“双空格”分隔 - 在Haskell中为这个文件实现解析器真的很容易。最终结果可能比你的正则表达式更长(并且如果符合你的愿望,Haskell中有正则表达式库)但它更易测试和可读。在我概述如何为这种文件格式构建一个时,我将演示其中的一些内容。

我将使用Attoparsec。我们还需要使用ByteString数据类型(以及OverloadedStrings PRAGMA,它允许Haskell将字符串文字解释为StringByteString)以及来自{{Control.Applicative的一些组合符1}}和Control.Monad

{-# LANGUAGE OverloadedStrings #-}

import           Data.Attoparsec.Char8
import           Control.Applicative
import           Control.Monad
import qualified Data.ByteString.Char8         as S

首先,我们将构建一个表示每条记录的数据类型。

data YearMonthDay =
  YearMonthDay { ymdYear  :: Int
               , ymdMonth :: Int
               , ymdDay   :: Int
               }
    deriving ( Show )

data Line =
  Line { agent     :: Int
       , name      :: S.ByteString
       , st        :: Int
       , ud        :: Int
       , targetNum :: Int
       , xyz       :: Int
       , xDate     :: YearMonthDay
       , year      :: Int
       , co        :: S.ByteString
       , encoding  :: S.ByteString
       }
    deriving ( Show )

如果需要,您可以为每个字段填写更多描述性类型,但这不是一个糟糕的开始。由于每行都可以独立解析,我会做到这一点。第一步是构建一个Parser Line类型---读取它作为解析器类型,如果成功则返回Line

为此,我们将使用其Line接口在“Parser”内部构建我们的Applicative类型。这听起来很复杂,但它很简单,看起来很漂亮。我们将从YearMonthDay类型开始作为热身

parseYMDWrong :: Parser YearMonthDay
parseYMDWrong =
  YearMonthDay <$> decimal
               <*> decimal
               <*> decimal

这里,decimal是一个内置的Attoparsec解析器,它解析像Int这样的整数类型。您可以将此解析器读作“解析三个十进制数并使用它们构建我的YearMonthDay类型”,您基本上是正确的。 (<*>)运算符(读作“apply”)对解析进行排序,并将结果收集到我们的YearMonthDay构造函数中。

不幸的是,正如我在类型中指出的那样,这有点不对劲。要指出,我们目前忽略了'/'字符,这些字符用于界定YearMonthDay内的数字。我们通过使用“sequence and throw away”运算符(<*)来解决这个问题。它是(<*>)上的视觉双关语,我们在想要执行解析操作时使用它...但我们不想保留结果。

我们使用(<*)使用内置的decimal解析器,使用以下'/'个字符来扩充前两个char8解析器。

parseYMD :: Parser YearMonthDay
parseYMD =
  YearMonthDay <$> (decimal <* char8 '/')
               <*> (decimal <* char8 '/')
               <*> decimal

我们可以使用Attoparsec的parseOnly函数测试这是一个有效的解析器

>>> parseOnly parseYMD "2013/12/12"
Right (YearMonthDay {ymdYear = 2013, ymdMonth = 12, ymdDay = 12})

我们现在想将此技术推广到整个Line解析器。然而,有一个障碍。我们想要解析可能包含空格的ByteString字段"SMITH, JOHN",同时也用Line分隔双空格的每个字段。这意味着我们需要一个特殊的ByteString解析器,它使用任何字符,包括单个空格......但是当它看到一行中有两个空格时就会退出。

我们可以使用scan组合器来构建它。 scan允许我们在解析时消耗字符时累积状态,并确定何时动态停止该解析。我们将保持一个布尔状态 - “这是一个空格的最后一个字符吗?” - 每当我们看到一个新空间时停止,同时知道前一个字符也是一个空格。

parseStringField :: Parser S.ByteString
parseStringField = scan False step where
  step :: Bool -> Char -> Maybe Bool
  step b ' ' | b         = Nothing
             | otherwise = Just True
  step _ _               = Just False

我们可以使用parseOnly再次测试这个小块。让我们尝试解析三个字符串字段。

>>> let p = (,,) <$> parseStringField <*> parseStringField <*> parseStringField
>>> parseOnly p "foo  bar  baz"
Right ("foo "," bar "," baz")
>>> parseOnly p "foo bar  baz quux  end"
Right ("foo bar "," baz quux "," end")
>>> parseOnly p "a sentence with no double space delimiters"
Right ("a sentence with no double space delimiters","","")

根据您的实际文件格式,这可能是完美的。值得注意的是,它留下了尾随空格(如果需要可以修剪它们)并且它允许一些空格分隔的字段为空。为了解决这些错误,很容易继续摆弄这一部分,但我现在就把它留下来。

我们现在可以构建我们的Line解析器了。与parseYMD一样,我们将使用定界解析器跟踪每个字段的解析器,someSpaces占用两个或更多空格。我们将使用MonadPlus接口Parser在内置解析器space上构建此接口,方法是:(1)解析some space s和(2)检查以确保我们至少有两个。

someSpaces :: Parser Int
someSpaces = do
  sps <- some space
  let count = length sps
  if count >= 2 then return count else mzero

>>> parseOnly someSpaces "  "
Right 2
>>> parseOnly someSpaces "    "
Right 4
>>> parseOnly someSpaces " "
Left "Failed reading: mzero"

现在我们可以构建行解析器

lineParser :: Parser Line
lineParser =
  Line <$> (decimal <* someSpaces)
       <*> (parseStringField <* someSpaces)
       <*> (decimal <* someSpaces)
       <*> (decimal <* someSpaces)
       <*> (decimal <* someSpaces)
       <*> (decimal <* someSpaces)
       <*> (parseYMD <* someSpaces)
       <*> (decimal <* someSpaces)
       <*> (parseStringField <* someSpaces)
       <*> (parseStringField <* some space)

>>> parseOnly lineParser "0007     SMITH, JOHN                      43   3    1234567   001    12/06/2013   2004    ABC         SIZE XL      "
Right (Line { agent = 7
            , name = "SMITH, JOHN "
            , st = 43
            , ud = 3
            , targetNum = 1234567
            , xyz = 1
            , xDate = YearMonthDay {ymdYear = 12, ymdMonth = 6, ymdDay = 2013}
            , year = 2004
            , co = "ABC "
            , encoding = "SIZE XL "
            })

然后我们可以切断标题并解析每一行。

parseFile :: S.ByteString -> [Either String Line]
parseFile = map (parseOnly parseLine) . drop 14 . lines