我正试图弄清楚在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。感谢您提供非常有用的代码示例!
答案 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将字符串文字解释为String
和ByteString
)以及来自{{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