use std::collections::HashMap;
use std::sync::Arc;
use bytes::Bytes;
use parking_lot::Mutex;
use crate::error::{Error, Result};
use crate::source::ByteSource;
const MIN_WINDOW: i64 = 1 << 20;
const WINDOWS: usize = 3;
#[derive(Debug, Clone, Copy)]
struct FaiEntry {
length: i64,
offset: u64,
line_bases: i64,
line_width: i64,
}
#[derive(Debug, Default)]
struct Gzi {
blocks: Vec<(u64, u64)>,
}
impl Gzi {
fn parse(data: &[u8], path: &str) -> Result<Self> {
if data.len() < 8 {
return Err(Error::format(
path,
"a gzi index too short to hold its count",
));
}
let count = u64::from_le_bytes(data[..8].try_into().expect("eight bytes"));
let wanted = usize::try_from(count)
.ok()
.and_then(|count| count.checked_mul(16))
.and_then(|bytes| bytes.checked_add(8));
let Some(wanted) = wanted.filter(|wanted| *wanted <= data.len()) else {
return Err(Error::format(
path,
format!("a gzi index naming {count} blocks in {} bytes", data.len()),
));
};
let count = count as usize;
let mut blocks = Vec::with_capacity(count + 1);
blocks.push((0u64, 0u64));
for chunk in data[8..wanted].chunks_exact(16) {
let compressed = u64::from_le_bytes(chunk[..8].try_into().expect("eight bytes"));
let uncompressed = u64::from_le_bytes(chunk[8..].try_into().expect("eight bytes"));
blocks.push((compressed, uncompressed));
}
Ok(Self { blocks })
}
fn locate(&self, uncompressed: u64) -> (u64, u64) {
let index = self
.blocks
.partition_point(|(_, start)| *start <= uncompressed)
.saturating_sub(1);
let (compressed_start, uncompressed_start) = self.blocks[index];
(compressed_start, uncompressed - uncompressed_start)
}
}
#[derive(Debug)]
pub struct Fasta {
source: Arc<dyn ByteSource>,
path: String,
index: HashMap<String, FaiEntry>,
gzi: Option<Gzi>,
}
impl Fasta {
pub fn open(path: &str) -> Result<Self> {
let source = crate::source::open(path, None, None)?;
let index_path = format!("{path}.fai");
let index_source = crate::source::open(&index_path, None, None)?;
let index = parse_fai(&index_source.read_to_end(0)?, &index_path)?;
let head = source.read_at(0, 2)?;
let gzi = if crate::source::is_gzipped(&head) {
let gzi_path = format!("{path}.gzi");
let gzi_source = crate::source::open(&gzi_path, None, None).map_err(|_| {
Error::format(
path,
format!(
"this reference is compressed and {gzi_path} is not there, so its \
middle cannot be reached; `bgzip -r` writes one"
),
)
})?;
Some(Gzi::parse(&gzi_source.read_to_end(0)?, &gzi_path)?)
} else {
None
};
Ok(Self {
source,
path: path.to_string(),
index,
gzi,
})
}
pub fn has(&self, name: &str) -> bool {
self.resolve_name(name).is_some()
}
pub fn resolve_name(&self, name: &str) -> Option<&str> {
if let Some((key, _)) = self.index.get_key_value(name) {
return Some(key);
}
let toggled = match name.get(..3) {
Some(prefix) if prefix.eq_ignore_ascii_case("chr") => name[3..].to_string(),
_ => format!("chr{name}"),
};
self.index
.get_key_value(&toggled)
.map(|(key, _)| key.as_str())
}
pub fn length(&self, name: &str) -> Option<i64> {
let key = self.resolve_name(name)?;
self.index.get(key).map(|entry| entry.length)
}
pub fn read(&self, name: &str, start: i64, end: i64) -> Result<Vec<u8>> {
let name = self.resolve_name(name).unwrap_or(name);
let entry = *self.index.get(name).ok_or_else(|| {
Error::format(
&self.path,
format!("this reference has no sequence named {name}"),
)
})?;
let start = start.clamp(0, entry.length);
let end = end.clamp(start, entry.length);
if entry.line_bases <= 0 || entry.line_width < entry.line_bases {
return Err(Error::format(
&self.path,
format!(
"the index for {name} says {} bases on a line of {} bytes, which describes \
no layout",
entry.line_bases, entry.line_width
),
));
}
if start == end {
return Ok(Vec::new());
}
let byte_of = |base: i64| -> u64 {
entry.offset
+ (base / entry.line_bases * entry.line_width + base % entry.line_bases) as u64
};
let first = byte_of(start);
let last = byte_of(end - 1) + 1;
let raw = self.read_raw(first, (last - first) as usize)?;
let mut out = Vec::with_capacity((end - start) as usize);
for byte in raw.iter() {
if !byte.is_ascii_whitespace() {
out.push(byte.to_ascii_uppercase());
}
}
out.truncate((end - start) as usize);
Ok(out)
}
fn read_raw(&self, offset: u64, len: usize) -> Result<Bytes> {
let Some(gzi) = &self.gzi else {
return self.source.read_at(offset, len);
};
use crate::bam::bgzf::{Chunk, VirtualOffset};
let (begin_block, begin_within) = gzi.locate(offset);
let (end_block, end_within) = gzi.locate(offset + len as u64);
let chunk = Chunk {
begin: VirtualOffset::new(begin_block, begin_within as u16),
end: VirtualOffset::new(end_block, end_within as u16),
};
crate::bam::bgzf::decompress_chunk(self.source.as_ref(), chunk, &self.path)
}
}
fn parse_fai(data: &[u8], path: &str) -> Result<HashMap<String, FaiEntry>> {
let mut out = HashMap::new();
for (number, line) in data.split(|b| *b == b'\n').enumerate() {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if line.is_empty() {
continue;
}
let mut fields = line.split(|b| *b == b'\t');
let mut next = |what: &str| -> Result<&[u8]> {
fields.next().ok_or_else(|| {
Error::format(
path,
format!("line {} of this index has no {what}", number + 1),
)
})
};
let name = String::from_utf8_lossy(next("name")?).into_owned();
let number_at = |field: &[u8], what: &str| -> Result<i64> {
std::str::from_utf8(field)
.ok()
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| {
Error::format(
path,
format!("line {} of this index has an unreadable {what}", number + 1),
)
})
};
let length = number_at(next("length")?, "length")?;
let offset = number_at(next("offset")?, "offset")?;
let line_bases = number_at(next("line length")?, "line length")?;
let line_width = number_at(next("line width")?, "line width")?;
out.insert(
name,
FaiEntry {
length,
offset: offset.max(0) as u64,
line_bases,
line_width,
},
);
}
if out.is_empty() {
return Err(Error::format(path, "this index names no sequences"));
}
Ok(out)
}
#[derive(Debug, Default)]
struct Window {
name: String,
start: i64,
end: i64,
bases: Arc<Vec<u8>>,
}
#[derive(Debug)]
pub struct RefCache {
root: String,
checksums: HashMap<String, String>,
}
impl RefCache {
pub fn open(root: &str, sequences: &[(String, Option<String>, Option<String>)]) -> Self {
Self {
root: root.to_string(),
checksums: sequences
.iter()
.filter_map(|(name, _, m5)| Some((name.clone(), m5.clone()?)))
.collect(),
}
}
fn file(&self, name: &str) -> Option<String> {
let m5 = self.checksums.get(name)?;
let path = format!("{}/{m5}", self.root);
std::path::Path::new(&path).exists().then_some(path)
}
pub fn has(&self, name: &str) -> bool {
self.file(name).is_some()
}
pub fn length(&self, name: &str) -> Option<i64> {
let path = self.file(name)?;
std::fs::metadata(path).ok().map(|m| m.len() as i64)
}
pub fn read(&self, name: &str, start: i64, end: i64) -> Result<Vec<u8>> {
let Some(path) = self.file(name) else {
return Ok(Vec::new());
};
let source = crate::source::open(&path, None, None)?;
let length = source.len()? as i64;
let start = start.clamp(0, length);
let end = end.clamp(start, length);
if start == end {
return Ok(Vec::new());
}
let bytes = source.read_at(start as u64, (end - start) as usize)?;
Ok(bytes.iter().map(|b| b.to_ascii_uppercase()).collect())
}
}
#[derive(Debug)]
enum Backend {
Fasta(Fasta),
Cache(RefCache),
}
#[derive(Debug)]
pub struct ReferenceSource {
backend: Backend,
windows: Mutex<Vec<Window>>,
slots: usize,
}
impl ReferenceSource {
pub fn open(path: &str) -> Result<Self> {
Ok(Self {
backend: Backend::Fasta(Fasta::open(path)?),
windows: Mutex::new(Vec::new()),
slots: WINDOWS,
})
}
pub fn open_cache(root: &str, sequences: &[(String, Option<String>, Option<String>)]) -> Self {
Self {
backend: Backend::Cache(RefCache::open(root, sequences)),
windows: Mutex::new(Vec::new()),
slots: WINDOWS,
}
}
pub fn with_slots(mut self, slots: usize) -> Self {
self.slots = self.slots.max(slots);
self
}
pub fn has(&self, name: &str) -> bool {
match &self.backend {
Backend::Fasta(fasta) => fasta.has(name),
Backend::Cache(cache) => cache.has(name),
}
}
pub fn length(&self, name: &str) -> Option<i64> {
match &self.backend {
Backend::Fasta(fasta) => fasta.length(name),
Backend::Cache(cache) => cache.length(name),
}
}
fn read(&self, name: &str, start: i64, end: i64) -> Result<Vec<u8>> {
match &self.backend {
Backend::Fasta(fasta) => fasta.read(name, start, end),
Backend::Cache(cache) => cache.read(name, start, end),
}
}
pub fn window(&self, name: &str, start: i64, end: i64) -> Result<(Arc<Vec<u8>>, i64)> {
let end = match self.length(name) {
Some(length) => end.min(length),
None => end,
};
{
let mut held = self.windows.lock();
if let Some(position) = held
.iter()
.position(|w| w.name == name && w.start <= start && w.end >= end)
{
let hit = held.remove(position);
let out = (hit.bases.clone(), hit.start);
held.push(hit);
return Ok(out);
}
}
let from = start.max(0);
let to = end.max(from + MIN_WINDOW);
let bases = Arc::new(self.read(name, from, to)?);
let mut held = self.windows.lock();
held.push(Window {
name: name.to_string(),
start: from,
end: from + bases.len() as i64,
bases: bases.clone(),
});
while held.len() > self.slots {
held.remove(0);
}
Ok((bases, from))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reference {
Given(String),
FromHeader(String),
FromCache(String),
None(String),
}
impl Reference {
pub fn path(&self) -> Option<&str> {
match self {
Self::Given(path) | Self::FromHeader(path) | Self::FromCache(path) => Some(path),
Self::None(_) => None,
}
}
}
pub fn resolve(
given: Option<&str>,
sequences: &[(String, Option<String>, Option<String>)],
) -> Reference {
if let Some(path) = given {
return Reference::Given(path.to_string());
}
for (_, uri, _) in sequences {
if let Some(uri) = uri {
let path = uri
.strip_prefix("file://")
.or_else(|| uri.strip_prefix("file:"))
.unwrap_or(uri);
if crate::source::is_url(path) || std::path::Path::new(path).exists() {
return Reference::FromHeader(path.to_string());
}
}
}
let checksums: Vec<&str> = sequences
.iter()
.filter_map(|(_, _, m5)| m5.as_deref())
.collect();
if !checksums.is_empty() {
for variable in ["REF_CACHE", "REF_PATH"] {
let Ok(value) = std::env::var(variable) else {
continue;
};
for root in value.split(':') {
let root = root.trim_end_matches("/%s").trim_end_matches('/');
if root.is_empty() || root.contains('%') {
continue;
}
if checksums
.iter()
.all(|m5| std::path::Path::new(&format!("{root}/{m5}")).exists())
{
return Reference::FromCache(root.to_string());
}
}
}
}
let has_uri = sequences.iter().any(|(_, uri, _)| uri.is_some());
Reference::None(if sequences.is_empty() {
"this file's header names no reference sequences".to_string()
} else if has_uri {
let named: Vec<&str> = sequences
.iter()
.filter_map(|(_, uri, _)| uri.as_deref())
.take(1)
.collect();
format!(
"no reference was given, and the one this file's header names ({}) is not there",
named.first().copied().unwrap_or("")
)
} else {
"no reference was given, and this file's header names none (no UR field on its @SQ lines)"
.to_string()
})
}
#[cfg(test)]
pub(crate) fn parse_fai_for_fuzz(data: &[u8], path: &str) -> Result<()> {
parse_fai(data, path).map(|_| ())
}
#[cfg(test)]
pub(crate) fn parse_gzi_for_fuzz(data: &[u8], path: &str) -> Result<()> {
Gzi::parse(data, path).map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
const FAI: &[u8] = b"chr1\t10\t6\t5\t6\nchr2\t4\t24\t4\t5\n";
#[test]
fn a_fai_parses_into_its_five_columns() {
let index = parse_fai(FAI, "test.fai").expect("an index");
let chr1 = index.get("chr1").expect("chr1");
assert_eq!(chr1.length, 10);
assert_eq!(chr1.offset, 6);
assert_eq!(chr1.line_bases, 5);
assert_eq!(chr1.line_width, 6);
assert!(index.contains_key("chr2"));
}
#[test]
fn an_unreadable_fai_is_refused_rather_than_half_read() {
assert!(parse_fai(b"", "test.fai").is_err());
assert!(parse_fai(b"chr1\tten\t6\t5\t6\n", "test.fai").is_err());
assert!(parse_fai(b"chr1\t10\t6\n", "test.fai").is_err());
}
#[test]
fn bases_are_read_across_the_lines_they_are_wrapped_onto() {
let fasta = b">chr1\nACGTA\nCGTAC\n>chr2\nTTTT\n";
let index = parse_fai(FAI, "test.fai").expect("an index");
let source = crate::source::testing::MemorySource::new(fasta.to_vec());
let reader = Fasta {
source: Arc::new(source),
path: "test.fa".to_string(),
index,
gzi: None,
};
assert_eq!(reader.read("chr1", 0, 10).expect("bases"), b"ACGTACGTAC");
assert_eq!(reader.read("chr1", 0, 5).expect("bases"), b"ACGTA");
assert_eq!(reader.read("chr1", 3, 7).expect("bases"), b"TACG");
assert_eq!(reader.read("chr1", 5, 10).expect("bases"), b"CGTAC");
assert_eq!(reader.read("chr1", 9, 10).expect("bases"), b"C");
assert_eq!(reader.read("chr2", 0, 4).expect("bases"), b"TTTT");
}
#[test]
fn a_range_past_the_end_of_a_sequence_is_clipped_rather_than_refused() {
let index = parse_fai(FAI, "test.fai").expect("an index");
let source = crate::source::testing::MemorySource::new(
b">chr1\nACGTA\nCGTAC\n>chr2\nTTTT\n".to_vec(),
);
let reader = Fasta {
source: Arc::new(source),
path: "test.fa".to_string(),
index,
gzi: None,
};
assert_eq!(reader.read("chr1", 8, 100).expect("bases"), b"AC");
assert_eq!(reader.read("chr1", 50, 60).expect("bases"), b"");
assert!(reader.read("chrX", 0, 1).is_err());
}
#[test]
fn a_gzi_locates_an_offset_in_the_block_that_holds_it() {
let mut data = Vec::new();
data.extend_from_slice(&2u64.to_le_bytes());
data.extend_from_slice(&40u64.to_le_bytes());
data.extend_from_slice(&100u64.to_le_bytes());
data.extend_from_slice(&90u64.to_le_bytes());
data.extend_from_slice(&250u64.to_le_bytes());
let gzi = Gzi::parse(&data, "test.gzi").expect("a gzi");
assert_eq!(gzi.locate(0), (0, 0));
assert_eq!(gzi.locate(99), (0, 99));
assert_eq!(gzi.locate(100), (40, 0));
assert_eq!(gzi.locate(249), (40, 149));
assert_eq!(gzi.locate(250), (90, 0));
assert_eq!(gzi.locate(1000), (90, 750));
}
#[test]
fn a_truncated_gzi_is_refused() {
assert!(Gzi::parse(&[0u8; 4], "test.gzi").is_err());
let mut data = Vec::new();
data.extend_from_slice(&5u64.to_le_bytes());
data.extend_from_slice(&[0u8; 16]);
assert!(Gzi::parse(&data, "test.gzi").is_err());
}
#[test]
fn a_ref_cache_reads_the_bare_sequence_its_checksum_names() {
let dir = std::env::temp_dir().join(format!("gwseq-refcache-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("cache dir");
let m5 = "d41d8cd98f00b204e9800998ecf8427e";
std::fs::write(dir.join(m5), b"acgtACGTnnnn").expect("cache file");
let sequences = vec![("chr1".to_string(), None, Some(m5.to_string()))];
let source = ReferenceSource::open_cache(&dir.to_string_lossy(), &sequences);
assert!(source.has("chr1"));
assert_eq!(source.length("chr1"), Some(12));
let (bases, from) = source.window("chr1", 4, 8).expect("a window");
assert_eq!(from, 4);
assert_eq!(&bases[..4], b"ACGT");
assert!(!source.has("chr2"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_fai_line_layout_that_describes_nothing_is_refused() {
let dir = std::env::temp_dir().join(format!("gwseq-badfai-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
let fasta = dir.join("r.fa");
std::fs::write(&fasta, b">chr1\nACGT\n").expect("fasta");
std::fs::write(dir.join("r.fa.fai"), b"chr1\t4\t6\t4\t2\n").expect("fai");
let fasta = Fasta::open(&fasta.to_string_lossy()).expect("opens");
let error = fasta.read("chr1", 0, 4).expect_err("no layout");
assert!(error.to_string().contains("no layout"), "{error}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_single_slash_file_uri_is_a_local_path() {
let dir = std::env::temp_dir().join(format!("gwseq-uri-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
let fasta = dir.join("r.fa");
std::fs::write(&fasta, b">chr1\nACGT\n").expect("fasta");
let uri = format!("file:{}", fasta.to_string_lossy());
let sequences = vec![("chr1".to_string(), Some(uri), None)];
assert_eq!(
resolve(None, &sequences),
Reference::FromHeader(fasta.to_string_lossy().to_string())
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicitly_given_reference_wins_over_everything_else() {
let sequences = vec![(
"chr1".to_string(),
Some("/does/not/exist.fa".to_string()),
Some("abc".to_string()),
)];
assert_eq!(
resolve(Some("/given.fa"), &sequences),
Reference::Given("/given.fa".to_string())
);
}
#[test]
fn a_header_with_no_uri_says_so_rather_than_failing_vaguely() {
let sequences = vec![("chr1".to_string(), None, None)];
match resolve(None, &sequences) {
Reference::None(why) => assert!(why.contains("UR"), "{why}"),
other => panic!("{other:?}"),
}
match resolve(None, &[]) {
Reference::None(why) => assert!(why.contains("names no reference"), "{why}"),
other => panic!("{other:?}"),
}
}
#[test]
fn a_header_naming_a_missing_reference_names_it_in_the_reason() {
let sequences = vec![(
"chr1".to_string(),
Some("/nowhere/genome.fa".to_string()),
None,
)];
match resolve(None, &sequences) {
Reference::None(why) => assert!(why.contains("/nowhere/genome.fa"), "{why}"),
other => panic!("{other:?}"),
}
}
}