///|
/// CSV parsing error type
pub(all) enum CsvError {
UnexpectedEof(Int, Int)
UnterminatedQuote(Int, Int)
InvalidQuoteEscape(Int, Int)
} derive(Show, Eq, ToJson)
///|
/// CSV parser configuration
pub(all) struct CsvConfig {
delimiter : Char // Field delimiter (default: ',')
quote : Char // Quote character (default: '"')
skip_empty_lines : Bool // Skip empty lines (default: true)
} derive(Show, Eq)
///|
/// Default CSV configuration (comma-delimited, double-quote, skip empty lines)
pub fn CsvConfig::default() -> CsvConfig {
{ delimiter: ',', quote: '"', skip_empty_lines: true }
}
///|
/// Create a CSV configuration with custom delimiter
pub fn CsvConfig::with_delimiter(delimiter : Char) -> CsvConfig {
{ delimiter, quote: '"', skip_empty_lines: true }
}
///|
/// A parsed CSV row
pub type Row = Array[String]
///|
/// A parsed CSV document (array of rows)
pub type Document = Array[Row]
///|
/// Internal parser state
priv struct Parser {
input : String
mut pos : Int // Current position in input
mut row : Int // Current row number (1-indexed)
mut col : Int // Current column number (1-indexed)
config : CsvConfig
}
///|
/// Create a new parser
fn Parser::new(input : String, config : CsvConfig) -> Parser {
{ input, pos: 0, row: 1, col: 1, config }
}
///|
/// Peek at current character without consuming
fn Parser::peek(self : Parser) -> Char? {
if self.pos < self.input.length() {
self.input.get_char(self.pos)
} else {
None
}
}
///|
/// Consume and return current character
fn Parser::next(self : Parser) -> Char? {
match self.peek() {
Some(ch) => {
self.pos += 1
if ch == '\n' {
self.row += 1
self.col = 1
} else {
self.col += 1
}
Some(ch)
}
None => None
}
}
///|
/// Check if we're at end of input
fn Parser::is_eof(self : Parser) -> Bool {
self.pos >= self.input.length()
}
///|
/// Parse a quoted field
fn Parser::parse_quoted_field(self : Parser) -> Result[String, CsvError] {
let quote = self.config.quote
let buffer = @buffer.new()
// Consume opening quote
ignore(self.next())
while true {
match self.next() {
None => return Err(CsvError::UnterminatedQuote(self.row, self.col))
Some(ch) =>
if ch == quote {
// Check if this is an escaped quote (two quotes in a row)
match self.peek() {
Some(next_ch) if next_ch == quote => {
buffer.write_char(quote)
ignore(self.next()) // Consume second quote
continue
}
_ => return Ok(buffer.to_string()) // End of quoted field
}
} else {
buffer.write_char(ch)
continue
}
}
}
// Unreachable, but needed for type checking
Err(CsvError::UnterminatedQuote(self.row, self.col))
}
///|
/// Parse an unquoted field
fn Parser::parse_unquoted_field(self : Parser) -> String {
let buffer = @buffer.new()
let delimiter = self.config.delimiter
while true {
match self.peek() {
None | Some('\n') | Some('\r') => break
Some(ch) if ch == delimiter => break
Some(ch) => {
buffer.write_char(ch)
ignore(self.next())
}
}
}
buffer.to_string()
}
///|
/// Parse a single field
fn Parser::parse_field(self : Parser) -> Result[String, CsvError] {
match self.peek() {
Some(ch) if ch == self.config.quote => self.parse_quoted_field()
_ => Ok(self.parse_unquoted_field())
}
}
///|
/// Parse a single row
fn Parser::parse_row(self : Parser) -> Result[Row, CsvError] {
let fields : Array[String] = []
while true {
// Parse field
let field = self.parse_field()
match field {
Err(e) => return Err(e)
Ok(f) => fields.push(f)
}
// Check what comes next
match self.peek() {
None => return Ok(fields) // End of input
Some('\n') => {
ignore(self.next())
return Ok(fields) // End of row
}
Some('\r') => {
ignore(self.next())
if self.peek() is Some('\n') {
ignore(self.next())
}
return Ok(fields) // End of row (CRLF)
}
Some(ch) if ch == self.config.delimiter => {
ignore(self.next()) // Consume delimiter
continue // Parse next field
}
Some(_ch) =>
// Unexpected character after field
return Err(CsvError::InvalidQuoteEscape(self.row, self.col))
}
}
// Unreachable, but needed for type checking
Ok(fields)
}
///|
/// Skip empty lines
fn Parser::skip_empty_lines(self : Parser) -> Unit {
while true {
match self.peek() {
Some('\n') => ignore(self.next())
Some('\r') => {
ignore(self.next())
if self.peek() is Some('\n') {
ignore(self.next())
}
}
_ => break
}
}
}
///|
/// Parse entire CSV document
fn Parser::parse(self : Parser) -> Result[Document, CsvError] {
let rows : Array[Row] = []
while not(self.is_eof()) {
// Skip empty lines if configured
if self.config.skip_empty_lines {
self.skip_empty_lines()
if self.is_eof() {
break
}
}
// Parse row
let row = self.parse_row()
match row {
Err(e) => return Err(e)
Ok(r) =>
// Only add non-empty rows if skip_empty_lines is true
if not(self.config.skip_empty_lines) || r.length() > 0 {
rows.push(r)
}
}
}
Ok(rows)
}
///|
/// Parse CSV string with default configuration
pub fn parse(input : String) -> Result[Document, CsvError] {
let parser = Parser::new(input, CsvConfig::default())
parser.parse()
}
///|
/// Parse CSV string with custom configuration
pub fn parse_with_config(
input : String,
config : CsvConfig,
) -> Result[Document, CsvError] {
let parser = Parser::new(input, config)
parser.parse()
}
///|
/// Parse CSV from comma-separated values
pub fn parse_csv(input : String) -> Result[Document, CsvError] {
parse(input)
}
///|
/// Parse TSV (tab-separated values)
pub fn parse_tsv(input : String) -> Result[Document, CsvError] {
parse_with_config(input, CsvConfig::with_delimiter('\t'))
}
///|
/// Convert a row to a CSV string
pub fn row_to_string(row : Row, config? : CsvConfig) -> String {
let config = if config is Some(c) { c } else { CsvConfig::default() }
let buffer = @buffer.new()
for i = 0; i < row.length(); i = i + 1 {
if i > 0 {
buffer.write_char(config.delimiter)
}
let field = row[i]
let needs_quote = field.contains(config.delimiter.to_string()) ||
field.contains(config.quote.to_string()) ||
field.contains("\n") ||
field.contains("\r")
if needs_quote {
buffer.write_char(config.quote)
for ch in field {
if ch == config.quote {
buffer.write_char(config.quote)
buffer.write_char(config.quote)
} else {
buffer.write_char(ch)
}
}
buffer.write_char(config.quote)
} else {
buffer.write_string(field)
}
}
buffer.to_string()
}
///|
/// Convert a document to a CSV string
pub fn document_to_string(doc : Document, config? : CsvConfig) -> String {
let config = if config is Some(c) { c } else { CsvConfig::default() }
let buffer = @buffer.new()
for i = 0; i < doc.length(); i = i + 1 {
if i > 0 {
buffer.write_char('\n')
}
buffer.write_string(row_to_string(doc[i], config~))
}
buffer.to_string()
}