pub const VERBOSE_LOG_FILTER: &str = "info";
pub const QUIET_LOG_FILTER: &str = "warn";
use flate2::read::MultiGzDecoder;
use rayon::prelude::*;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use tempfile::tempdir;
#[cfg(test)]
mod tests;
pub enum Delimiter {
Str(String),
Chars(Vec<char>),
}
impl From<&str> for Delimiter {
fn from(s: &str) -> Self {
Delimiter::Str(s.to_string())
}
}
impl From<Vec<char>> for Delimiter {
fn from(chars: Vec<char>) -> Self {
Delimiter::Chars(chars)
}
}
impl From<&[char]> for Delimiter {
fn from(chars: &[char]) -> Self {
Delimiter::Chars(chars.to_vec())
}
}
impl<const N: usize> From<&[char; N]> for Delimiter {
fn from(chars: &[char; N]) -> Self {
Delimiter::Chars(chars.to_vec())
}
}
pub fn read_lines(input_file_path: &str) -> anyhow::Result<Vec<Box<str>>> {
let buf: Box<dyn BufRead> = open_buf_reader(input_file_path)?;
let mut lines = vec![];
for x in buf.lines() {
lines.push(x?.into_boxed_str());
}
Ok(lines)
}
pub fn write_lines(lines: &Vec<Box<str>>, output_file_path: &str) -> anyhow::Result<()> {
write_types(lines, output_file_path)
}
pub fn write_types<T>(lines: &Vec<T>, output_file_path: &str) -> anyhow::Result<()>
where
T: std::fmt::Display,
{
let mut buf = open_buf_writer(output_file_path)?;
for line in lines {
if let Err(e) = writeln!(buf, "{}", line) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
return Ok(());
} else {
return Err(anyhow::anyhow!("unexpected error: {}", e));
}
}
}
buf.flush()?;
Ok(())
}
pub struct ReadLinesOut<T: Send> {
pub lines: Vec<Vec<T>>,
pub header: Vec<Box<str>>,
}
pub fn read_lines_of_words_generic<T>(
input_file: &str,
hdr_line: i64,
parse_header_fn: impl Fn(&str) -> Vec<Box<str>> + Sync,
parse_fn: impl Fn(&str) -> Vec<T> + Sync,
) -> anyhow::Result<ReadLinesOut<T>>
where
T: Send,
{
let buf_reader: Box<dyn BufRead> = open_buf_reader(input_file)?;
fn is_not_comment_line(line: &str) -> bool {
if line.starts_with('#') || line.starts_with('%') {
return false;
}
true
}
let lines_raw: Vec<Box<str>> = buf_reader
.lines()
.map_while(Result::ok)
.map(|x| x.into_boxed_str())
.filter(|x| is_not_comment_line(x.as_ref()))
.collect();
let mut header = vec![];
let mut lines: Vec<(usize, Vec<T>)> = if hdr_line < 0 {
lines_raw
.iter()
.enumerate()
.par_bridge()
.map(|(i, s)| (i, parse_fn(s)))
.collect()
} else {
let n_skip = hdr_line as usize;
if lines_raw.len() < (n_skip + 1) {
return Err(anyhow::anyhow!("not enough data"));
}
header.extend(parse_header_fn(&lines_raw[n_skip]));
lines_raw[(n_skip + 1)..]
.iter()
.enumerate()
.par_bridge()
.map(|(i, s)| (i, parse_fn(s)))
.collect()
};
if lines.len() > 100_000 {
lines.par_sort_by_key(|&(i, _)| i);
} else {
lines.sort_by_key(|&(i, _)| i);
}
let lines = lines.into_iter().map(|(_, x)| x).collect();
Ok(ReadLinesOut { lines, header })
}
pub fn read_lines_of_types<T>(
input_file: &str,
delim: impl Into<Delimiter>,
hdr_line: i64,
) -> anyhow::Result<ReadLinesOut<T>>
where
T: Send + std::str::FromStr + std::fmt::Display,
<T as std::str::FromStr>::Err: std::fmt::Debug,
{
let delim = delim.into();
let parse_fn = move |line: &str| -> Vec<T> {
match &delim {
Delimiter::Str(s) => line
.split(s.as_str())
.map(|x| x.parse::<T>().expect("failed to parse"))
.collect(),
Delimiter::Chars(chars) => line
.split(chars.as_slice())
.map(|x| x.parse::<T>().expect("failed to parse"))
.collect(),
}
};
let parse_header_fn = |line: &str| -> Vec<Box<str>> {
line.split_whitespace()
.map(|x| x.to_owned().into_boxed_str())
.collect()
};
read_lines_of_words_generic(input_file, hdr_line, parse_header_fn, parse_fn)
}
pub fn read_lines_of_words(
input_file: &str,
hdr_line: i64,
) -> anyhow::Result<ReadLinesOut<Box<str>>> {
let parse_fn = |line: &str| -> Vec<Box<str>> {
line.split_whitespace()
.map(|x| x.to_owned().into_boxed_str())
.collect()
};
read_lines_of_words_generic(input_file, hdr_line, parse_fn, parse_fn)
}
pub fn unquote_field(x: &str) -> &str {
let t = x.trim();
for q in ['"', '\''] {
if t.len() >= 2 && t.starts_with(q) && t.ends_with(q) {
return &t[q.len_utf8()..t.len() - q.len_utf8()];
}
}
t
}
pub fn read_lines_of_words_delim(
input_file: &str,
delim: impl Into<Delimiter>,
hdr_line: i64,
) -> anyhow::Result<ReadLinesOut<Box<str>>> {
let delim = delim.into();
let parse_fn = |line: &str| -> Vec<Box<str>> {
match &delim {
Delimiter::Str(s) => line
.split(s.as_str())
.map(|x| unquote_field(x).to_owned().into_boxed_str())
.collect(),
Delimiter::Chars(chars) => line
.split(chars.as_slice())
.map(|x| unquote_field(x).to_owned().into_boxed_str())
.collect(),
}
};
read_lines_of_words_generic(input_file, hdr_line, parse_fn, parse_fn)
}
const NAME_LIST_HEADERS: [&str; 12] = [
"gene",
"genes",
"gene_name",
"gene_names",
"gene_id",
"gene_symbol",
"feature",
"features",
"feature_name",
"symbol",
"name",
"id",
];
fn name_list_column(header: &[Box<str>]) -> Option<usize> {
header.iter().position(|h| {
let h = h.trim().trim_matches('"').to_ascii_lowercase();
NAME_LIST_HEADERS.contains(&h.as_str())
})
}
pub fn read_name_list(file_path: &str) -> anyhow::Result<Vec<Box<str>>> {
let is_parquet = Path::new(file_path)
.extension()
.and_then(OsStr::to_str)
.is_some_and(|e| e.eq_ignore_ascii_case("parquet"));
let names: Vec<Box<str>> = if is_parquet {
let header = crate::matrix::parquet::peek_parquet_field_names(file_path)?;
let col = name_list_column(&header).unwrap_or(0);
crate::matrix::parquet::read_parquet_string_column(file_path, col)?
} else {
let raw = read_lines(file_path)?;
let data_lines: Vec<&str> = raw
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('%'))
.collect();
let delim: &[char] = match data_lines.first() {
Some(first) if first.contains('\t') => &['\t'],
Some(first) if first.contains(',') => &[','],
_ => &[' ', '\t'],
};
let rows: Vec<Vec<&str>> = data_lines
.iter()
.map(|line| {
line.split(delim)
.map(|w| w.trim().trim_matches('"'))
.filter(|w| !w.is_empty())
.collect::<Vec<&str>>()
})
.filter(|row| !row.is_empty())
.collect();
let header: Vec<Box<str>> = rows
.first()
.map(|row| row.iter().map(|w| (*w).into()).collect())
.unwrap_or_default();
let (col, skip) = match name_list_column(&header) {
Some(col) => (col, 1),
None => (0, 0),
};
rows.iter()
.skip(skip)
.filter_map(|row| row.get(col).map(|w| (*w).into()))
.collect()
};
let mut seen: std::collections::HashSet<Box<str>> = std::collections::HashSet::new();
let names: Vec<Box<str>> = names
.into_iter()
.filter(|n| !n.is_empty())
.filter(|n| seen.insert(n.clone()))
.collect();
if names.is_empty() {
return Err(anyhow::anyhow!("no names found in {file_path}"));
}
Ok(names)
}
pub fn open_buf_reader(input_file: &str) -> anyhow::Result<Box<dyn BufRead>> {
let ext = Path::new(input_file).extension().and_then(|x| x.to_str());
match ext {
Some("gz") | Some("bgz") | Some("bgzf") => {
let input_file = File::open(input_file)?;
let decoder = MultiGzDecoder::new(input_file);
Ok(Box::new(BufReader::with_capacity(1 << 20, decoder)))
}
_ => {
let input_file = File::open(input_file)?;
Ok(Box::new(BufReader::new(input_file)))
}
}
}
pub fn first_line_fields(path: &str, delimiters: &[char]) -> anyhow::Result<Vec<Box<str>>> {
let mut first = String::new();
open_buf_reader(path)?.read_line(&mut first)?;
Ok(first
.trim_end_matches(['\n', '\r'])
.split(delimiters)
.map(|f| unquote_field(f).to_string().into_boxed_str())
.filter(|f| !f.is_empty())
.collect())
}
pub fn detect_header_row_numeric(file_path: &str, delimiters: &[char]) -> Option<usize> {
let mut first = String::new();
open_buf_reader(file_path)
.ok()?
.read_line(&mut first)
.ok()?;
let fields: Vec<&str> = first
.trim_end_matches(['\n', '\r'])
.split(delimiters)
.map(unquote_field)
.collect();
let any_non_numeric_after_col0 = fields
.iter()
.skip(1)
.any(|t| !t.is_empty() && !is_numeric_or_missing(t));
let preview = fields
.iter()
.take(6)
.copied()
.collect::<Vec<_>>()
.join(", ");
if any_non_numeric_after_col0 {
log::info!("{file_path}: first line treated as a header (non-numeric): [{preview}]");
Some(0)
} else {
log::info!(
"{file_path}: first line treated as data (all numeric after column 0): [{preview}]"
);
None
}
}
fn is_numeric_or_missing(t: &str) -> bool {
t.parse::<f64>().is_ok() || matches!(t, "NA" | "N/A" | "na" | "n/a")
}
pub fn open_buf_writer(output_file: &str) -> anyhow::Result<Box<dyn std::io::Write>> {
if output_file.eq_ignore_ascii_case("stdout") {
return Ok(Box::new(std::io::BufWriter::new(std::io::stdout())));
}
if output_file.eq_ignore_ascii_case("stderr") {
return Ok(Box::new(std::io::BufWriter::new(std::io::stderr())));
}
let output_file = Path::new(output_file);
let ext = output_file.extension().and_then(|x| x.to_str());
match ext {
Some("gz") => {
let output_file = File::create(output_file)?;
let encoder =
flate2::write::GzEncoder::new(output_file, flate2::Compression::default());
Ok(Box::new(BufWriter::new(encoder)))
}
_ => {
let output_file = File::create(output_file)?;
Ok(Box::new(BufWriter::new(output_file)))
}
}
}
pub fn mkdir(file: &str) -> anyhow::Result<()> {
let path = Path::new(file);
std::fs::create_dir_all(path)?;
Ok(())
}
pub fn mkdir_parent(path: &str) -> anyhow::Result<()> {
if let Some(parent) = Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
Ok(())
}
pub trait PathOsToStr {
#[allow(clippy::wrong_self_convention)]
fn into_boxed_str(&self) -> Box<str>;
}
impl PathOsToStr for Path {
fn into_boxed_str(&self) -> Box<str> {
self.to_str()
.expect("failed to convert to string")
.to_string()
.into_boxed_str()
}
}
impl PathOsToStr for OsStr {
fn into_boxed_str(&self) -> Box<str> {
self.to_str()
.expect("failed to convert to string")
.to_string()
.into_boxed_str()
}
}
pub fn recursive_copy(src_path: &str, dst_path: &str) -> anyhow::Result<()> {
let src = Path::new(src_path);
let dst = Path::new(dst_path);
if src.is_dir() {
mkdir(dst_path)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
if let (Some(src_path), Some(dst_path)) =
(entry.path().to_str(), dst.join(entry.file_name()).to_str())
{
let file_type = entry.file_type()?;
if file_type.is_dir() {
recursive_copy(src_path, dst_path)?;
} else if file_type.is_file() {
std::fs::copy(src_path, dst_path)?;
}
}
}
} else if src.is_file() {
if let Some(dir) = dirname(dst_path).as_deref() {
mkdir(dir)?;
}
std::fs::copy(src, dst)?;
} else if src.is_symlink() {
if let Ok(abs_src) = std::fs::read_link(src) {
if let Some(abs_src_path) = abs_src.to_str() {
recursive_copy(abs_src_path, dst_path)?;
}
}
}
Ok(())
}
pub fn unzip_dir(zip_path: &str, extract_path: Option<&str>) -> anyhow::Result<Box<str>> {
let zip_file = std::fs::File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(zip_file)?;
let extract_path = extract_path
.map(std::path::PathBuf::from)
.unwrap_or(std::env::current_dir()?);
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let out_path = extract_path.join(file.name());
if file.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut outfile = std::fs::File::create(&out_path)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
Ok(extract_path.into_boxed_str())
}
pub fn zip_dir(source_dir: &str, zip_path: &str) -> anyhow::Result<()> {
zip_dir_as(source_dir, zip_path, None)
}
pub fn zip_dir_as(
source_dir: &str,
zip_path: &str,
entry_root: Option<&str>,
) -> anyhow::Result<()> {
use std::io::Write;
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
let file = std::fs::File::create(zip_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let source = Path::new(source_dir);
fn collect_entries(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
out.push(path.clone());
if entry.file_type()?.is_dir() {
collect_entries(&path, out)?;
}
}
Ok(())
}
let mut entries = vec![];
collect_entries(source, &mut entries)?;
entries.sort();
let root_name =
entry_root.unwrap_or_else(|| source.file_name().and_then(|s| s.to_str()).unwrap_or(""));
for path in &entries {
let rel_under_source = path.strip_prefix(source).unwrap_or(path);
let rel = if root_name.is_empty() {
rel_under_source.to_path_buf()
} else {
Path::new(root_name).join(rel_under_source)
};
if path.symlink_metadata()?.is_dir() {
zip.add_directory(format!("{}/", rel.display()), options)?;
} else {
zip.start_file(rel.display().to_string(), options)?;
let data = std::fs::read(path)?;
zip.write_all(&data)?;
}
}
zip.finish()?;
Ok(())
}
pub fn dirname(file_path: &str) -> Option<Box<str>> {
Path::new(file_path).parent().map(|x| x.into_boxed_str())
}
pub fn dir_base_ext(file_path: &str) -> anyhow::Result<(Box<str>, Box<str>, Box<str>)> {
let path = Path::new(file_path);
let dir = path
.parent()
.map_or(".".to_string().into_boxed_str(), |x| x.into_boxed_str());
let ext = path
.extension()
.map_or("".to_string().into_boxed_str(), |x| x.into_boxed_str());
let base = path
.file_stem()
.and_then(|x| x.to_str())
.map(|x| strip_data_ext(x).to_string().into_boxed_str())
.ok_or(anyhow::anyhow!("failed to find base here: {}", file_path))?;
Ok((dir, base, ext))
}
pub fn basename(file: &str) -> anyhow::Result<Box<str>> {
let path = Path::new(file);
if let Some(base) = path.file_stem().and_then(|s| s.to_str()) {
Ok(strip_data_ext(base).to_string().into_boxed_str())
} else {
Err(anyhow::anyhow!("no file stem"))
}
}
fn strip_data_ext(stem: &str) -> &str {
for sfx in [".zarr", ".h5ad", ".h5"] {
if let Some(s) = stem.strip_suffix(sfx) {
return s;
}
}
stem
}
pub fn file_ext(file: &str) -> anyhow::Result<Box<str>> {
let path = Path::new(file);
if let Some(ext) = path.extension() {
Ok(ext.into_boxed_str())
} else {
Err(anyhow::anyhow!("failed to extract extension"))
}
}
pub fn create_temp_dir_file(suffix: &str) -> anyhow::Result<std::path::PathBuf> {
let temp_dir = tempdir()?.path().to_path_buf();
std::fs::create_dir_all(&temp_dir)?;
let temp_file = tempfile::Builder::new()
.suffix(suffix)
.tempfile_in(temp_dir)?
.path()
.to_owned();
Ok(temp_file)
}
pub fn remove_file(file: &str) -> anyhow::Result<()> {
let path = Path::new(file);
if path.exists() {
if path.is_file() {
std::fs::remove_file(path)?;
} else {
std::fs::remove_dir_all(path)?;
}
}
Ok(())
}
pub fn remove_all_files(files: &Vec<Box<str>>) -> anyhow::Result<()> {
for file in files {
remove_file(file)?;
}
Ok(())
}
pub const DATA_FILE_EXTENSIONS: &[&str] = &[
"gz", "bgz", "bz2", "zst", "tsv", "csv", "txt", "tab", "gaf", "gmt", "obo", "bed", "vcf",
"parquet", "pq",
];
pub fn file_stem(path: &str) -> String {
let mut stem = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
loop {
let Some((base, ext)) = stem.rsplit_once('.') else {
break;
};
if base.is_empty() || !DATA_FILE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
break;
}
stem.truncate(base.len());
}
stem
}