use anyhow::Result;
use polars::prelude::*;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
pub enum FileFormat {
Csv,
Tsv,
Text,
Parquet,
JsonLines,
Unknown,
}
pub fn detect_format(path: &Path) -> FileFormat {
match path.extension().and_then(|e| e.to_str()) {
Some("csv") => FileFormat::Csv,
Some("tsv" | "tab") => FileFormat::Tsv,
Some("txt") => FileFormat::Text,
Some("parquet") => FileFormat::Parquet,
Some("jsonl" | "ndjson") => FileFormat::JsonLines,
_ => FileFormat::Unknown,
}
}
pub fn load(path: &Path) -> Result<LazyFrame> {
let pl_path = PlRefPath::try_from_path(path)?;
match detect_format(path) {
FileFormat::Csv | FileFormat::Tsv | FileFormat::Text => {
let separator = separator(path)?.expect("delimited text has a separator");
let skip = preamble(path, separator)?.lines;
Ok(delimited(pl_path, separator, skip)?)
}
FileFormat::Parquet => Ok(LazyFrame::scan_parquet(pl_path, Default::default())?),
FileFormat::JsonLines => anyhow::bail!("jsonl is not read through polars"),
FileFormat::Unknown => anyhow::bail!(
"unsupported file format (use .csv, .tsv, .tab, .txt, .jsonl or .parquet)"
),
}
}
pub fn separator(path: &Path) -> Result<Option<u8>> {
match detect_format(path) {
FileFormat::Csv => Ok(Some(b',')),
FileFormat::Tsv => Ok(Some(b'\t')),
FileFormat::Text => Ok(Some(sniff_delimiter(&read_sample(path)?))),
FileFormat::Parquet | FileFormat::JsonLines | FileFormat::Unknown => Ok(None),
}
}
fn delimited(path: PlRefPath, separator: u8, skip_lines: usize) -> PolarsResult<LazyFrame> {
LazyCsvReader::new(path)
.with_separator(separator)
.with_skip_lines(skip_lines)
.finish()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Preamble {
pub lines: usize,
pub bytes: u64,
}
pub fn preamble(path: &Path, separator: u8) -> Result<Preamble> {
let file = std::fs::File::open(path)?;
Ok(preamble_of(BufReader::new(file), separator)?)
}
fn preamble_of(mut reader: impl BufRead, separator: u8) -> std::io::Result<Preamble> {
let mut found = Preamble::default();
let mut last = Vec::new();
let mut line = Vec::new();
loop {
line.clear();
let read = reader.read_until(b'\n', &mut line)?;
if read == 0 {
return Ok(Preamble::default());
}
if line.starts_with(b"#") {
found.lines += 1;
found.bytes += read as u64;
std::mem::swap(&mut last, &mut line);
continue;
}
if found.lines > 0 {
let fields = count_unquoted(trim_eol(&line), separator);
if fields > 0 && count_unquoted(trim_eol(&last), separator) == fields {
found.lines -= 1;
found.bytes -= last.len() as u64;
}
}
return Ok(found);
}
}
fn trim_eol(line: &[u8]) -> &[u8] {
let line = line.strip_suffix(b"\n").unwrap_or(line);
line.strip_suffix(b"\r").unwrap_or(line)
}
const SAMPLE_BYTES: u64 = 64 * 1024;
const DELIMITERS: [u8; 4] = *b"\t,;|";
const SAMPLE_LINES: usize = 5;
fn read_sample(path: &Path) -> Result<Vec<u8>> {
let mut sample = Vec::new();
std::fs::File::open(path)?
.take(SAMPLE_BYTES)
.read_to_end(&mut sample)?;
Ok(sample)
}
fn sniff_delimiter(sample: &[u8]) -> u8 {
let lines: Vec<&[u8]> = sample
.split(|&b| b == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| !line.is_empty() && !line.starts_with(b"#"))
.take(SAMPLE_LINES)
.collect();
DELIMITERS
.into_iter()
.find(|&delim| {
let mut counts = lines.iter().map(|line| count_unquoted(line, delim));
match counts.next() {
Some(first) => first > 0 && counts.all(|n| n == first),
None => false,
}
})
.unwrap_or(b'\t')
}
fn count_unquoted(line: &[u8], delim: u8) -> usize {
let mut quoted = false;
line.iter()
.filter(|&&b| {
if b == b'"' {
quoted = !quoted;
}
!quoted && b == delim
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_separated_extensions_split_on_tabs() {
for ext in ["tsv", "tab", "txt"] {
let df = load(&write_temp(ext, "name\tcount\na\t1\nb\t2\n"))
.unwrap()
.collect()
.unwrap();
assert_eq!(df.shape(), (2, 2), "{ext}");
assert_eq!(df.get_column_names(), ["name", "count"], "{ext}");
}
}
#[test]
fn comma_separated_txt_is_sniffed() {
let df = load(&write_temp("txt", "name,count\na,1\nb,2\n"))
.unwrap()
.collect()
.unwrap();
assert_eq!(df.shape(), (2, 2));
assert_eq!(df.get_column_names(), ["name", "count"]);
}
#[test]
fn sniffs_each_candidate() {
assert_eq!(sniff_delimiter(b"a\tb\n1\t2\n"), b'\t');
assert_eq!(sniff_delimiter(b"a,b\n1,2\n"), b',');
assert_eq!(sniff_delimiter(b"a;b\n1;2\n"), b';');
assert_eq!(sniff_delimiter(b"a|b\n1|2\n"), b'|');
}
#[test]
fn ignores_delimiters_inside_quotes() {
let sample = b"name;note\n\"a,b\";\"x,y,z\"\n\"c,d\";\"p,q,r\"\n";
assert_eq!(sniff_delimiter(sample), b';');
}
#[test]
fn prose_and_empty_samples_fall_back_to_tab() {
assert_eq!(sniff_delimiter(b"one, two, three\nfour five\n"), b'\t');
assert_eq!(sniff_delimiter(b""), b'\t');
}
#[test]
fn windows_line_endings_do_not_break_counting() {
assert_eq!(sniff_delimiter(b"a,b\r\n1,2\r\n"), b',');
}
#[test]
fn leading_comment_lines_are_skipped() {
let tsv = "# what this is\n# and why\n#\nname\tcount\na\t1\nb\t2\n";
for ext in ["tsv", "txt"] {
let df = load(&write_temp(ext, tsv)).unwrap().collect().unwrap();
assert_eq!(df.shape(), (2, 2), "{ext}");
assert_eq!(df.get_column_names(), ["name", "count"], "{ext}");
}
}
#[test]
fn the_preamble_is_the_leading_run_of_comments() {
let of = |text: &str| preamble_of(text.as_bytes(), b',').unwrap();
assert_eq!(of("a,b\n1,2\n"), Preamble::default(), "none");
assert_eq!(of("# note\na,b\n"), Preamble { lines: 1, bytes: 7 });
assert_eq!(
of("# one\r\n# two\r\na,b\r\n"),
Preamble {
lines: 2,
bytes: 14
},
"crlf counted in bytes"
);
assert_eq!(
of("a,b\n# 1,2\n"),
Preamble::default(),
"a # line after the header is a record"
);
}
#[test]
fn a_header_written_with_a_hash_stays_the_header() {
let of = |text: &str| preamble_of(text.as_bytes(), b'\t').unwrap();
assert_eq!(
of("## format 4\n#chrom\tpos\nchr1\t10\n"),
Preamble {
lines: 1,
bytes: 12
}
);
assert_eq!(of("#chrom\tpos\nchr1\t10\n"), Preamble::default());
assert_eq!(
of("# just\n# comments\n"),
Preamble::default(),
"nothing left"
);
}
#[test]
fn comments_do_not_sway_the_sniffer() {
assert_eq!(sniff_delimiter(b"# a, b, c\n# d\na;b\n1;2\n"), b';');
}
fn write_temp(ext: &str, contents: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join("plv-loader-tests");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("sample-{ext}-{:x}.{ext}", contents.len()));
std::fs::write(&path, contents).unwrap();
path
}
}