use crate::attributes::Attributes;
use crate::model::{Record, Strand};
use std::io::BufRead;
#[derive(Debug)]
pub enum ParseError {
Io(std::io::Error),
BadColumnCount {
line_no: usize,
found: usize,
},
BadCoordinate {
line_no: usize,
field: &'static str,
},
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Io(e) => write!(f, "I/O error: {e}"),
ParseError::BadColumnCount { line_no, found } => {
write!(f, "line {line_no}: expected 9 columns, found {found}")
}
ParseError::BadCoordinate { line_no, field } => {
write!(f, "line {line_no}: invalid {field} coordinate")
}
}
}
}
impl std::error::Error for ParseError {}
impl From<std::io::Error> for ParseError {
fn from(e: std::io::Error) -> Self {
ParseError::Io(e)
}
}
pub struct GffReader<R: BufRead> {
inner: R,
line_no: usize,
buf: Vec<u8>,
in_fasta: bool,
}
impl<R: BufRead> GffReader<R> {
pub fn new(inner: R) -> Self {
Self {
inner,
line_no: 0,
buf: Vec::new(),
in_fasta: false,
}
}
pub fn next_record(&mut self) -> Result<Option<Record>, ParseError> {
loop {
self.buf.clear();
let n = self.inner.read_until(b'\n', &mut self.buf)?;
if n == 0 {
return Ok(None);
}
self.line_no += 1;
let raw = String::from_utf8_lossy(&self.buf);
let line = raw.trim_end_matches(['\n', '\r']);
if self.in_fasta {
continue;
}
if line.is_empty() {
continue;
}
if line.starts_with('#') {
if line.trim_end() == "##FASTA" {
self.in_fasta = true;
}
continue;
}
if line.starts_with('>') {
self.in_fasta = true;
continue;
}
return Ok(Some(self.parse_line(line)?));
}
}
fn parse_line(&self, line: &str) -> Result<Record, ParseError> {
let cols: Vec<&str> = line.splitn(9, '\t').collect();
if cols.len() < 9 {
return Err(ParseError::BadColumnCount {
line_no: self.line_no,
found: cols.len(),
});
}
let start = cols[3]
.parse::<u64>()
.map_err(|_| ParseError::BadCoordinate {
line_no: self.line_no,
field: "start",
})?;
let end = cols[4]
.parse::<u64>()
.map_err(|_| ParseError::BadCoordinate {
line_no: self.line_no,
field: "end",
})?;
Ok(Record {
seqid: cols[0].to_string(),
source: cols[1].to_string(),
feature_type: cols[2].to_string(),
start,
end,
score: cols[5].to_string(),
strand: Strand::parse(cols[6]),
phase: cols[7].to_string(),
attributes: Attributes::parse_gff3(cols[8]),
})
}
}
pub fn read_all<R: BufRead>(reader: R) -> Result<Vec<Record>, ParseError> {
let mut r = GffReader::new(reader);
let mut out = Vec::new();
while let Some(rec) = r.next_record()? {
out.push(rec);
}
Ok(out)
}
pub fn read_all_sanitize<R, F>(
reader: R,
mut on_skip: F,
) -> Result<(Vec<Record>, usize), ParseError>
where
R: BufRead,
F: FnMut(&ParseError),
{
let mut r = GffReader::new(reader);
let mut out = Vec::new();
let mut skipped = 0;
loop {
match r.next_record() {
Ok(Some(rec)) => out.push(rec),
Ok(None) => return Ok((out, skipped)),
Err(ParseError::Io(e)) => return Err(ParseError::Io(e)),
Err(e) => {
skipped += 1;
on_skip(&e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn reads_records_skips_comments_and_fasta() {
let data = "\
##gff-version 3
chr1\tEnsembl\tgene\t1\t100\t.\t+\t.\tID=g1
chr1\tEnsembl\tmRNA\t1\t100\t.\t+\t.\tID=t1;Parent=g1
##FASTA
>chr1
ACGT
";
let recs = read_all(Cursor::new(data)).unwrap();
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].id(), Some("g1"));
assert_eq!(recs[1].parent(), Some("g1"));
}
#[test]
fn tolerates_non_utf8_bytes() {
let mut data: Vec<u8> = b"chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=g1;Note=caf".to_vec();
data.push(0xE9);
data.push(b'\n');
let recs = read_all(Cursor::new(data)).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].id(), Some("g1"));
}
#[test]
fn too_few_columns_reports_line_and_count() {
let data = b"chr1\tsrc\tgene\t1\t100\n".to_vec();
match read_all(Cursor::new(data)) {
Err(ParseError::BadColumnCount { line_no, found }) => {
assert_eq!(line_no, 1);
assert_eq!(found, 5);
}
other => panic!("expected BadColumnCount, got {other:?}"),
}
}
#[test]
fn sanitize_skips_bad_records_and_continues() {
let data = b"\
chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=g1
bad\ttoo\tfew
chr1\tsrc\tmRNA\t1\t100\t.\t+\t.\tID=t1;Parent=g1
"
.to_vec();
let mut diagnostics = Vec::new();
let (recs, skipped) =
read_all_sanitize(Cursor::new(data), |e| diagnostics.push(e.to_string())).unwrap();
assert_eq!(skipped, 1);
assert_eq!(recs.len(), 2);
assert!(diagnostics[0].contains("line 2: expected 9 columns"));
}
}