justcsv 0.2.0

Simple CSV-file reader/writer
Documentation
use nom::{Parser, bytes::complete::tag, multi::separated_list1};

pub type ParseResult<'a, T> = nom::IResult<&'a str, T>;

fn textdata<F>(stop: F) -> impl FnMut(&str) -> ParseResult<&str>
where
    F: Fn(char) -> bool,
{
    move |src| {
        for (i, c) in src.char_indices() {
            if stop(c) {
                return Ok((&src[i..], &src[..i]));
            }
        }
        Ok(("", src))
    }
}

fn escaped(comma: char, dquote: char) -> impl FnMut(&str) -> ParseResult<&str> {
    move |src| {
        let trimmed = src.trim_start();
        let (rest, _) = tag(format!("{}", dquote).as_str())(trimmed)?;
        println!("Escaped: {:?}; trimmed={:?}; rest={:?}", src, trimmed, rest);
        let mut char_indices = rest.char_indices().peekable();
        while let Some((i, c)) = char_indices.next() {
            if c == dquote {
                match char_indices.peek().copied() {
                    Some((j, c)) if c != dquote => {
                        let remainder = rest[j..].trim_start();
                        let next_byte = remainder.as_bytes().first().copied().unwrap_or_default();
                        if remainder.starts_with(comma) || next_byte < 0x20 {
                            return Ok((remainder, &rest[..i]));
                        } else {
                            return Err(nom::Err::Failure(nom::error::make_error(
                                src,
                                nom::error::ErrorKind::Fail,
                            )));
                        }
                    }
                    None => return Ok(("", &rest[..i])),
                    _ => {
                        let _ = char_indices.next();
                    }
                }
            }
        }
        Err(nom::Err::Incomplete(nom::Needed::Unknown))
    }
}

fn field<'a>(
    comma: char,
    dquote: char,
) -> impl Parser<&'a str, Output = String, Error = nom::error::Error<&'a str>> {
    let stop = move |c| (c < ' ' || c == comma || c == dquote);
    nom::combinator::map(
        nom::branch::alt((escaped(comma, dquote), textdata(stop))),
        move |field| {
            field.replace(
                format!("{}{}", dquote, dquote).as_str(),
                format!("{}", dquote).as_str(),
            )
        },
    )
}

pub fn record(src: &str, comma: char, dquote: char) -> ParseResult<Vec<String>> {
    separated_list1(tag(format!("{}", comma).as_str()), field(comma, dquote)).parse(src)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_just_record() {
        let line = "мама,мыла,раму\r\n";
        assert_eq!(
            vec!["мама", "мыла", "раму"],
            record(line, ',', '"').unwrap().1
        );
    }

    #[test]
    fn parse_with_escaped() {
        let line = "мама, \"мыла\",раму";
        assert_eq!(
            vec!["мама", "мыла", "раму"],
            record(line, ',', '"').unwrap().1
        );
    }

    #[test]
    fn parse_multiline() {
        let line = "мама, \"мыла\ntwo times\"\t\t,раму";
        assert_eq!(
            vec!["мама", "мыла\ntwo times", "раму"],
            record(line, ',', '"').unwrap().1
        );
    }

    #[test]
    fn fail_after_dquote() {
        let comma = ',';
        let dquote = '"';
        println!("{:?}", record("мама,мыла, \"раму\"abc", comma, dquote));
        assert!(record("мама,мыла, \"раму\"abc", comma, dquote).is_err());
        assert_eq!(
            vec!["мама", "мыла", "раму"],
            record("мама,\"мыла\", \"раму\" ", comma, dquote).unwrap().1
        );
    }

    #[test]
    fn escaped_dquote() {
        let line = "мама, \"мыла\n\"\"two times\"\"\"\t\t,раму";
        assert_eq!(
            vec!["мама", "мыла\n\"two times\"", "раму"],
            record(line, ',', '"').unwrap().1
        );
    }
}