use flate2::read::MultiGzDecoder;
use memmap2::Mmap;
use rayon::prelude::*;
use std::{
fmt,
fmt::Debug,
fs::File,
io::{self, BufWriter, Read, Write},
path::Path,
};
const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
const TWOBIT_MAGIC: [u8; 4] = [0x1a, 0x41, 0x27, 0x43];
const TWOBIT_MAGIC_REV: [u8; 4] = [0x43, 0x27, 0x41, 0x1a];
#[derive(Debug)]
pub enum ChromsizeError {
Io(io::Error),
EmptyInput,
InvalidInput(String),
InvalidFasta(String),
}
impl fmt::Display for ChromsizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChromsizeError::Io(err) => write!(f, "I/O error: {}", err),
ChromsizeError::EmptyInput => write!(f, "Input is empty"),
ChromsizeError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
ChromsizeError::InvalidFasta(msg) => write!(f, "Invalid FASTA: {}", msg),
}
}
}
impl std::error::Error for ChromsizeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ChromsizeError::Io(err) => Some(err),
_ => None,
}
}
}
impl From<io::Error> for ChromsizeError {
fn from(err: io::Error) -> Self {
ChromsizeError::Io(err)
}
}
enum InputData {
Mmap(Mmap),
Owned(Vec<u8>),
}
enum InputFormat {
Fasta,
TwoBit,
}
impl AsRef<[u8]> for InputData {
fn as_ref(&self) -> &[u8] {
match self {
InputData::Mmap(m) => m.as_ref(),
InputData::Owned(b) => b.as_slice(),
}
}
}
pub fn get_sizes<T: AsRef<Path> + Debug>(
sequence: T,
) -> Result<Vec<(String, u64)>, ChromsizeError> {
let path = sequence.as_ref();
let data = if is_stdin(path) {
from_stdin()?
} else {
from_file(path)?
};
sizes_from_bytes(data.as_ref())
}
fn is_stdin(path: &Path) -> bool {
path == Path::new("-")
}
fn from_stdin() -> Result<InputData, ChromsizeError> {
let mut buffer = Vec::with_capacity(1024 * 1024);
let mut handle = io::stdin().lock();
handle.read_to_end(&mut buffer)?;
if buffer.is_empty() {
return Err(ChromsizeError::EmptyInput);
}
if is_gzip(&buffer) {
let decompressed = decompress_gzip(&buffer)?;
if decompressed.is_empty() {
return Err(ChromsizeError::EmptyInput);
}
Ok(InputData::Owned(decompressed))
} else {
Ok(InputData::Owned(buffer))
}
}
fn from_file(path: &Path) -> Result<InputData, ChromsizeError> {
let file = File::open(path)?;
let mmap = unsafe { Mmap::map(&file)? };
if mmap.is_empty() {
return Err(ChromsizeError::EmptyInput);
}
if is_gzip(&mmap) {
let decompressed = decompress_gzip(&mmap)?;
if decompressed.is_empty() {
return Err(ChromsizeError::EmptyInput);
}
Ok(InputData::Owned(decompressed))
} else {
Ok(InputData::Mmap(mmap))
}
}
fn from_2bit(twobit: &[u8]) -> Result<Vec<(String, u64)>, ChromsizeError> {
let genome = twobit::TwoBitFile::from_buf(twobit)
.map_err(|e| ChromsizeError::InvalidInput(format!("Invalid 2bit data: {e}")))?;
let cs = genome
.chrom_names()
.into_iter()
.zip(genome.chrom_sizes())
.map(|(chr, size)| (chr, size as u64))
.collect();
Ok(cs)
}
fn sizes_from_bytes(data: &[u8]) -> Result<Vec<(String, u64)>, ChromsizeError> {
match sniff_format(data) {
Some(InputFormat::TwoBit) => from_2bit(data),
Some(InputFormat::Fasta) => chromsize(data),
None => Err(ChromsizeError::InvalidInput(
"Input format not recognized (expected FASTA or 2bit)".to_string(),
)),
}
}
fn sniff_format(data: &[u8]) -> Option<InputFormat> {
if data.len() >= TWOBIT_MAGIC.len()
&& (data[..TWOBIT_MAGIC.len()] == TWOBIT_MAGIC
|| data[..TWOBIT_MAGIC_REV.len()] == TWOBIT_MAGIC_REV)
{
return Some(InputFormat::TwoBit);
}
if data.first() == Some(&b'>') {
return Some(InputFormat::Fasta);
}
None
}
fn is_gzip(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == GZIP_MAGIC[0] && bytes[1] == GZIP_MAGIC[1]
}
fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>, ChromsizeError> {
let mut decoder = MultiGzDecoder::new(data);
let mut buffer = Vec::with_capacity(8 * 1024 * 1024);
decoder.read_to_end(&mut buffer)?;
Ok(buffer)
}
fn chromsize(data: &[u8]) -> Result<Vec<(String, u64)>, ChromsizeError> {
if data.is_empty() {
return Err(ChromsizeError::EmptyInput);
}
if data[0] != b'>' {
return Err(ChromsizeError::InvalidFasta(
"Input does not start with '>'".to_string(),
));
}
data.par_split(|&c| c == b'>')
.filter(|chunk| !chunk.is_empty())
.map(process_record)
.collect()
}
fn process_record(chunk: &[u8]) -> Result<(String, u64), ChromsizeError> {
let Some(stop) = memchr::memchr(b'\n', chunk) else {
return Err(ChromsizeError::InvalidFasta(
"Record header is not terminated by a newline".to_string(),
));
};
let header = std::str::from_utf8(&chunk[..stop])
.map_err(|_| ChromsizeError::InvalidFasta("Record header is not UTF-8".to_string()))?
.trim();
if header.is_empty() {
return Err(ChromsizeError::InvalidFasta(
"Record has an empty header".to_string(),
));
}
let data = &chunk[stop + 1..];
if memchr::memchr2(b' ', b'\t', data).is_some() {
return Err(ChromsizeError::InvalidFasta(
"Record contains whitespace inside sequence data".to_string(),
));
}
if memchr::memchr(b'>', data).is_some() {
return Err(ChromsizeError::InvalidFasta(
"Record contains '>' inside sequence data".to_string(),
));
}
if data.iter().any(|&b| match b {
b'\n' | b'\r' => false,
0x00..=0x08 | 0x0b | 0x0c | 0x0e..=0x1f => true,
0x20..=0x7e => false,
_ => true,
}) {
return Err(ChromsizeError::InvalidFasta(
"Record contains invalid control or non-ASCII characters".to_string(),
));
}
let count_newlines = bytecount::count(data, b'\n') as u64;
let count_cr = bytecount::count(data, b'\r') as u64;
let totals = data.len() as u64 - count_newlines - count_cr;
Ok((header.to_string(), totals))
}
pub fn writer<T>(sizes: &[(String, u64)], outdir: T, prefix: String) -> Result<(), ChromsizeError>
where
T: AsRef<Path> + Debug,
{
std::fs::create_dir_all(&outdir)?;
let file = File::create(outdir.as_ref().join(prefix))?;
let mut writer = BufWriter::with_capacity(64 * 1024, file);
for (k, v) in sizes.iter() {
writeln!(writer, "{}\t{}", k, v)?;
}
Ok(())
}