Используйте комбинатор парсера Scala для анализа файлов CSV

Я пытаюсь написать парсер CSV с использованием комбинаторов парсера Scala. Грамматика основана на RFC4180. Я придумал следующий код. Это почти работает, но я не могу правильно разделить разные записи. Что я пропустил?

object CSV extends RegexParsers {
  def COMMA   = ","
  def DQUOTE  = "\""
  def DQUOTE2 = "\"\"" ^^ { case _ => "\"" }
  def CR      = "\r"
  def LF      = "\n"
  def CRLF    = "\r\n"
  def TXT     = "[^\",\r\n]".r

  def file: Parser[List[List[String]]] = ((record~((CRLF~>record)*))<~(CRLF?)) ^^ { 
    case r~rs => r::rs
  }
  def record: Parser[List[String]] = (field~((COMMA~>field)*)) ^^ {
    case f~fs => f::fs
  }
  def field: Parser[String] = escaped|nonescaped
  def escaped: Parser[String] = (DQUOTE~>((TXT|COMMA|CR|LF|DQUOTE2)*)<~DQUOTE) ^^ { case ls => ls.mkString("")}
  def nonescaped: Parser[String] = (TXT*) ^^ { case ls => ls.mkString("") }

  def parse(s: String) = parseAll(file, s) match {
    case Success(res, _) => res
    case _ => List[List[String]]()
  }
}


println(CSV.parse(""" "foo", "bar", 123""" + "\r\n" + 
  "hello, world, 456" + "\r\n" +
  """ spam, 789, egg"""))

// Output: List(List(foo, bar, 123hello, world, 456spam, 789, egg)) 
// Expected: List(List(foo, bar, 123), List(hello, world, 456), List(spam, 789, egg))

Обновление: проблема решена

По умолчанию RegexParsers игнорируют пробелы, включая пробелы, табуляцию, возврат каретки и разрывы строк, используя регулярное выражение [\s]+. Проблема с указанным выше парсером, неспособным разделять записи, связана с этим. Нам нужно отключить режим skipWhitespace. Замена определения whiteSpace на просто [ \t]} не решает проблему, потому что при этом игнорируются все пробелы в полях (таким образом, «foo bar» в CSV становится «foobar»), что нежелательно. Таким образом, обновленный источник парсера

import scala.util.parsing.combinator._

// A CSV parser based on RFC4180
// http://tools.ietf.org/html/rfc4180

object CSV extends RegexParsers {
  override val skipWhitespace = false   // meaningful spaces in CSV

  def COMMA   = ","
  def DQUOTE  = "\""
  def DQUOTE2 = "\"\"" ^^ { case _ => "\"" }  // combine 2 dquotes into 1
  def CRLF    = "\r\n" | "\n"
  def TXT     = "[^\",\r\n]".r
  def SPACES  = "[ \t]+".r

  def file: Parser[List[List[String]]] = repsep(record, CRLF) <~ (CRLF?)

  def record: Parser[List[String]] = repsep(field, COMMA)

  def field: Parser[String] = escaped|nonescaped


  def escaped: Parser[String] = {
    ((SPACES?)~>DQUOTE~>((TXT|COMMA|CRLF|DQUOTE2)*)<~DQUOTE<~(SPACES?)) ^^ { 
      case ls => ls.mkString("")
    }
  }

  def nonescaped: Parser[String] = (TXT*) ^^ { case ls => ls.mkString("") }



  def parse(s: String) = parseAll(file, s) match {
    case Success(res, _) => res
    case e => throw new Exception(e.toString)
  }
}

person Rio    schedule 21.02.2011    source источник
comment
Почему константы определяются с помощью def, а не с val? Есть ли в этом польза?   -  person Sebastian N.    schedule 30.12.2012
comment
Проверьте это. tl; dr def использует меньше памяти, val быстрее.   -  person rancidfishbreath    schedule 08.02.2013
comment
Для констант времени компиляции действительно небольшая разница - val инициализирует поле этой константой в конструкторе, а затем создаст метод, который возвращает его значение, а def просто вернет константу - а для констант времени компиляции это фактически бесплатно .   -  person Score_Under    schedule 02.05.2013
comment
@rancidfishbreath - это объект, поэтому существует только 1 экземпляр, поэтому, если он сохраняет какую-либо память, это будет незначительно   -  person herman    schedule 24.12.2013


Ответы (3)


Вы пропустили пробелы. Я добавил пару бонусных улучшений.

import scala.util.parsing.combinator._

object CSV extends RegexParsers {
  override protected val whiteSpace = """[ \t]""".r

  def COMMA   = ","
  def DQUOTE  = "\""
  def DQUOTE2 = "\"\"" ^^ { case _ => "\"" }
  def CR      = "\r"
  def LF      = "\n"
  def CRLF    = "\r\n"
  def TXT     = "[^\",\r\n]".r

  def file: Parser[List[List[String]]] = repsep(record, CRLF) <~ opt(CRLF)
  def record: Parser[List[String]] = rep1sep(field, COMMA)
  def field: Parser[String] = (escaped|nonescaped)
  def escaped: Parser[String] = (DQUOTE~>((TXT|COMMA|CR|LF|DQUOTE2)*)<~DQUOTE) ^^ { case ls => ls.mkString("")}
  def nonescaped: Parser[String] = (TXT*) ^^ { case ls => ls.mkString("") }

  def parse(s: String) = parseAll(file, s) match {
    case Success(res, _) => res
    case _ => List[List[String]]()
  }
}
person psp    schedule 21.02.2011
comment
Чем это отличается от protected val whiteSpace = """\s+""".r, который используется RegexParsers по умолчанию? - А, понял. Новая строка - это тоже пробел, поэтому ваше переопределение удалило его из рассмотрения. - person Daniel C. Sobral; 21.02.2011
comment
Большое спасибо за указание на проблему с пустым пространством! Ваше решение правильно разбирает разные записи. Однако он также игнорирует пробелы в полях. Пожалуйста, просмотрите мой обновленный вопрос, чтобы увидеть мое решение после принятия ваших изменений. - person Rio; 22.02.2011
comment
Измените CRLF в файле на CRLF | LF для них обоих, если вы хотите поддерживать переводы строк не в Windows (это просто \ n в Linux) - person djsumdog; 23.01.2014

С библиотекой Scala Parser Combinators вне стандартной библиотеки Scala, начиная с 2.11, нет веских причин не использовать гораздо более производительную библиотеку Parboiled2. Вот версия парсера CSV в DSL Parboiled2:

/*  based on comments in https://github.com/sirthias/parboiled2/issues/61 */
import org.parboiled2._
case class Parboiled2CsvParser(input: ParserInput, delimeter: String) extends Parser {
  def DQUOTE = '"'
  def DELIMITER_TOKEN = rule(capture(delimeter))
  def DQUOTE2 = rule("\"\"" ~ push("\""))
  def CRLF = rule(capture("\r\n" | "\n"))
  def NON_CAPTURING_CRLF = rule("\r\n" | "\n")

  val delims = s"$delimeter\r\n" + DQUOTE
  def TXT = rule(capture(!anyOf(delims) ~ ANY))
  val WHITESPACE = CharPredicate(" \t")
  def SPACES: Rule0 = rule(oneOrMore(WHITESPACE))

  def escaped = rule(optional(SPACES) ~
    DQUOTE ~ (zeroOrMore(DELIMITER_TOKEN | TXT | CRLF | DQUOTE2) ~ DQUOTE ~
    optional(SPACES)) ~> (_.mkString("")))
  def nonEscaped = rule(zeroOrMore(TXT | capture(DQUOTE)) ~> (_.mkString("")))

  def field = rule(escaped | nonEscaped)
  def row: Rule1[Seq[String]] = rule(oneOrMore(field).separatedBy(delimeter))
  def file = rule(zeroOrMore(row).separatedBy(NON_CAPTURING_CRLF))

  def parsed() : Try[Seq[Seq[String]]] = file.run()
}
person Maciej Biłas    schedule 11.07.2014
comment
Поскольку вы уже постарались написать такой хороший блог об этом, мы могли бы также разместить ссылку здесь :-) maciejb.me/2014/07/11/ - person harschware; 15.10.2014
comment
Разве CRLF = rule(capture("\n\r" | "\n")) не должно быть CRLF = rule(capture("\r\n" | "\n"))? и снова за NON_CAPTURING_CRLF? - person Toby; 08.04.2015
comment
@Toby конечно должен! Спасибо, что указали на это, я исправил ответ. - person Maciej Biłas; 13.04.2015
comment
Отличный материал. Разве он не должен поддерживать (двойные) кавычки из коробки? Мне кажется, что он должен, но он не разбирает его, как я ожидал. т.е. a, b, c - person Toby; 15.04.2015
comment
@Toby это обязательно должно! Я тоже исправил это. :-) - person Maciej Biłas; 16.04.2015

По умолчанию для парсеров RegexParsers используется пробел \s+, который включает новые строки. Таким образом, CR, LF и CRLF никогда не получат возможности обработать, поскольку он автоматически пропускается анализатором.

person Daniel C. Sobral    schedule 21.02.2011