use rudb_common::{Error, Result};
pub const DELIMITERS: [u8; 4] = *b",|;\t";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dialect {
pub delimiter: u8,
pub quote: Option<u8>,
pub escape: Option<u8>,
pub header: bool,
}
impl Dialect {
#[must_use]
pub const fn comma_separated() -> Self {
Self { delimiter: b',', quote: None, escape: None, header: true }
}
#[must_use]
pub const fn quote_byte(self) -> u8 {
match self.quote {
Some(quote) => quote,
None => b'"',
}
}
#[must_use]
pub const fn escape_byte(self) -> u8 {
match self.escape {
Some(escape) => escape,
None => self.quote_byte(),
}
}
#[must_use]
pub fn shown(byte: Option<u8>) -> String {
match byte {
None => "(empty)".to_string(),
Some(b'\t') => "\\t".to_string(),
Some(byte) => (byte as char).to_string(),
}
}
}
pub fn delimiter(sample: &[u8], quote: Option<u8>) -> Result<u8> {
let mut best = (1usize, DELIMITERS[0]);
for candidate in DELIMITERS {
let dialect = Dialect { delimiter: candidate, quote, escape: quote, header: false };
let Some(width) = consistent_width(sample, dialect) else { continue };
if width > best.0 {
best = (width, candidate);
}
}
if sample.iter().all(|&byte| byte != b'\n' && byte != b'\r') && sample.is_empty() {
return Err(Error::io("the file is empty"));
}
Ok(best.1)
}
fn consistent_width(sample: &[u8], dialect: Dialect) -> Option<usize> {
let mut at = 0;
let mut fields = Vec::new();
let mut width = None;
let mut lines = 0;
while at < sample.len() {
let next = crate::scan::record(sample, at, dialect, true, &mut fields).ok()??;
at = next;
lines += 1;
match width {
None => width = Some(fields.len()),
Some(held) if held == fields.len() => {}
Some(_) => return None,
}
}
if lines == 0 { None } else { width }
}
#[must_use]
pub fn quote(sample: &[u8]) -> Option<u8> {
let mut at_field_start = true;
for &byte in sample {
if at_field_start && byte == b'"' {
return Some(b'"');
}
at_field_start = byte == b'\n' || byte == b'\r' || DELIMITERS.contains(&byte);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_delimiter_is_the_one_every_line_agrees_on() {
assert_eq!(delimiter(b"a,b,c\n1,2,3\n", None).unwrap(), b',');
assert_eq!(delimiter(b"a|b\n1|x\n", None).unwrap(), b'|');
assert_eq!(delimiter(b"a;b\n1;x\n", None).unwrap(), b';');
assert_eq!(delimiter(b"a\tb\n1\tx\n", None).unwrap(), b'\t');
}
#[test]
fn a_character_that_lands_in_some_lines_and_not_others_is_not_the_delimiter() {
let sample = b"a,b;c\n1,2\n";
assert_eq!(delimiter(sample, None).unwrap(), b',');
}
#[test]
fn the_delimiter_that_finds_more_columns_wins_among_the_ones_that_agree() {
assert_eq!(delimiter(b"a,b,c\nx,y,z\n", None).unwrap(), b',');
}
#[test]
fn a_file_with_nothing_to_split_on_is_comma_separated_and_one_column() {
assert_eq!(delimiter(b"a\nb\n", None).unwrap(), b',');
}
#[test]
fn a_delimiter_inside_a_quoted_field_does_not_count() {
let sample = b"a,b\n1,\"x,y\"\n";
assert_eq!(delimiter(sample, Some(b'"')).unwrap(), b',');
}
#[test]
fn a_quote_is_found_where_a_field_starts_and_not_in_the_middle_of_one() {
assert_eq!(quote(b"a,b\n1,\"x\"\n"), Some(b'"'));
assert_eq!(quote(b"a,b\n1,x\n"), None);
assert_eq!(quote(b"a,b\n1,he said \"hi\"\n"), None);
}
#[test]
fn the_block_duckdb_prints_writes_a_tab_as_two_characters() {
assert_eq!(Dialect::shown(None), "(empty)");
assert_eq!(Dialect::shown(Some(b'\t')), "\\t");
assert_eq!(Dialect::shown(Some(b',')), ",");
}
}