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

4
我正在尝试找出在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专家:您如何解析这种类型的报告?我不是要求代码,尽管如果您有一些代码,我很乐意看到它。我真的在询问技术或技术方面。
谢谢!
附注:输出只是一个带有文件顶部字段名称行的冒号分隔文件,后跟仅记录,可以导入Excel供最终用户使用。
编辑:
非常感谢大家的精彩评论和答案! 因为我最初没有表明:示例的前14行针对(打印)输出的每一页都会重复出现,每页的记录数量从零到整页(看起来像45条记录)不等。我很抱歉之前没有表明清楚,因为这可能会影响已经提供的一些答案。
我的Haskell系统目前仅限于Parsec(它没有attoparsec)和Text.Regex.Base以及Text.Regex.Posix。我将尝试安装attoparsec和/或其他正则表达式库。但是在此期间,您已经说服我继续学习Parsec。感谢您提供的非常有用的代码示例!

2
我肯定会选择Parsec或更好的attoparsec。你对它有什么特别的问题吗? - Niklas B.
2
关于您的正则表达式拒绝,您尝试过 Text.RegexText.Regex.PCRE 两种吗?Text.RegexText.Regex.Posix 的影子包,可能不支持您习惯使用的功能。PCRE 是类似 Perl 的正则表达式,并具有更广泛的功能提供。 - Elliot Robinson
1
有关正则表达式库的比较,请参见http://www.haskell.org/haskellwiki/Regular_expressions。 - Pedro Rodrigues
1
输入头是固定大小的吗?您可以像“drop 14. lines”一样忽略前几行吗?可以说这些字段是“双空格”分隔的吗? - J. Abrahamson
3个回答

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

通过正确运行解析函数之一并使用其中一个组合器(例如many(如果最终结果为部分结果,则可能需要feed)),您应该最终得到一系列ReportData。然后,您可以使用创建的某些函数将它们转换为CSV。

请注意,我没有处理标题。编写代码来解析它并构建Report应该相对简单。

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

请注意,我更喜欢应用形式Applicative,但如果您喜欢,也可以使用单子形式(我在doubleSpace中使用了它)。Data.Alternative也很有用,因为其名称所暗示的原因。
对于玩弄这个,我强烈推荐GHCI和parseTest函数。 GHCI总体上很方便,是测试单个解析器的好方法,而parseTest接受解析器和输入字符串,并输出运行状态,已解析的字符串以及任何未解析的剩余字符串。当您不太确定发生了什么时非常有用。

非常感谢您的出色回答。不幸的是,我无法使它工作。我一直收到有关类型转换从/到Char、ByteString、[Char]和String的投诉。但最终我还是能够让代码编译通过,从我标记为答案的更简单的答案开始。再次感谢! - Jeff Maner

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 中所有这些都很容易实现。

这种方式比正则表达式容易理解多了....


我将这个标记为答案,因为它是我从中开始并且实际上能够工作的解决方案。我让单子形式起作用,然后应用了其他答案中所看到的应用风格,使其更短更漂亮。谢谢! - Jeff Maner

1
假设一些事情——标题是固定的,每行的字段以“双空格”分隔——在Haskell中实现这个文件的解析器真的很容易。最终结果可能会比您的正则表达式更长(如果符合您的需求,Haskell中有正则表达式库),但它更加可测试和可读。我将演示其中一些内容,同时概述如何为此文件格式构建一个解析器。
我将使用Attoparsec。我们还需要使用ByteString数据类型(和OverloadedStrings PRAGMA,让Haskell将字符串字面量解释为StringByteString),以及来自Control.ApplicativeControl.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
为此,我们将在解析器中使用其Applicative接口构建我们的Line类型。听起来很复杂,但它很简单,并且看起来非常漂亮。我们将从YearMonthDay类型作为热身开始。
parseYMDWrong :: Parser YearMonthDay
parseYMDWrong =
  YearMonthDay <$> decimal
               <*> decimal
               <*> decimal

在这里,decimal是一个内置的Attoparsec解析器,用于解析像Int这样的整数类型。您可以将此解析器视为“解析三个十进制数并使用它们构建我的YearMonthDay类型”,您基本上是正确的。(<*>)运算符(读作“应用”)按顺序解析并收集其结果到我们的YearMonthDay构造函数中。
不幸的是,正如我在类型中指出的那样,它有点错误。具体而言,我们当前正在忽略YearMonthDay内部数字之间分隔的'/'字符。我们通过使用“序列和丢弃”运算符(<*)来修复此问题。它是对(<*>)的视觉双关语,并且当我们想执行解析操作时使用它...但我们不想保留结果。
我们使用内置的 char8 解析器,通过添加其后面的 '/' 字符来增强前两个 decimal 解析器,其中 (<*) 表示匹配任意数量(包括零个)的字符。
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”解析器。然而,有一个问题。我们希望解析像“SMITH,JOHN”这样可能包含空格的“ByteString”字段,同时通过双空格来分隔我们的“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”,它消耗两个或更多空格。我们将使用“Parser”的“MonadPlus”接口,在内置解析器“space”的基础上构建它,方法是(1)解析“一些空格”并(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

非常感谢您的出色回答。不幸的是,我无法使它工作。我一直收到有关从/到Char、ByteString、[Char]和String的类型转换的投诉。但最终我还是能够让代码编译通过,从我标记为答案的更简单的答案开始。再次感谢! - Jeff Maner
如果你在编译器混淆 ByteString[Char]String 时遇到问题,最有可能的原因是 OverloadedStrings pragma。 - J. Abrahamson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接