【问题标题】:Parsing Printable Text File in Haskell在 Haskell 中解析可打印的文本文件
【发布时间】:2026-02-03 11:55:01
【问题描述】:

我正在尝试找出在 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。

编辑:

非常感谢大家提供出色的 cmets 和答案!

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

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

【问题讨论】:

  • 我肯定会选择 Parsec 或更好的 attoparsec。你有什么特别的问题吗?
  • 关于您的正则表达式拒绝,您是否尝试过Text.RegexText.Regex.PCREText.RegexText.Regex.Posix 的影子包,它可能不支持您习惯使用的功能。 PCRE 是 perl 式的正则表达式,并且提供了很多扩展的功能。
  • 正则表达式库的比较见haskell.org/haskellwiki/Regular_expressions
  • 输入头是固定大小的吗?你能忽略像drop 14 . lines 这样的前几行吗?可以说字段是“双空格”分隔的吗?

标签: parsing haskell text


【解决方案1】:

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

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,如果您最终得到Partial 结果),您应该得到一个@ 列表987654330@s。然后您可以使用您创建的某些函数将它们转换为 CSV。

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

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

请注意,我更喜欢Applicative 形式,但如果您愿意,也可以使用一元形式(我在doubleSpace 中使用过)。 Data.Alternative 也很有用,其名称暗示了原因。

为了玩这个,我强烈推荐 GHCI 和 parseTest 函数。 GHCI 总体上很方便,是测试单个解析器的好方法,而 parseTest 接受解析器和输入字符串,并输出运行状态、已解析字符串和任何未解析的剩余字符串。当您不太确定发生了什么时非常有用。

【讨论】:

  • 感谢您的出色回答。不幸的是,我无法让它工作。我不断收到关于 Char、ByteString、[Char] 和 String 的类型转换的抱怨。但我最终能够编译代码,从我标记为答案的更简单的答案开始。再次感谢您!
【解决方案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”然后将它们链接在一起作为行列表。

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

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

【讨论】:

  • 我将此标记为答案,因为这是我开始的解决方案,我实际上能够开始工作。我得到了单子形式的工作,然后我将应用风格应用于它,如其他答案中所见,使其更短更漂亮。谢谢!
【解决方案3】:

假设一些事情——标题是固定的并且每行的字段是“双空格”分隔的——在 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 类型”,您基本上是正确的。 (&lt;*&gt;) 运算符(读作“apply”)对解析进行排序并将其结果收集到我们的 YearMonthDay 构造函数中。

不幸的是,正如我在类型中指出的那样,它有点错误。需要指出的是,我们目前忽略了'/' 字符,这些字符分隔了YearMonthDay 中的数字。我们通过使用“sequence and throw away”操作符(&lt;*) 来解决这个问题。这是(&lt;*&gt;) 的视觉双关语,当我们想要执行解析操作时使用它......但我们不想保留结果。

我们使用(&lt;*) 使用内置的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 解析器。然而,有一个障碍。我们想解析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,它占用两个或多个空格。我们将使用MonadPlusParser 的接口在内置解析器space 之上构建它,方法是(1)解析some spaces 和(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 的类型转换的抱怨。但我最终能够编译代码,从我标记为答案的更简单的答案开始。再次感谢您!
  • 如果您遇到编译器混淆ByteString[Char]String 的问题,很可能是OverloadedStrings 杂注。