1use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::error::{Error, Result};
11use crate::table::{TableAlign, TableData, convert_table_pages};
12
13const MAX_GFF_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_GFF_LINES: usize = 5_000_000;
15const MAX_GFF_LINE_BYTES: usize = 1 << 20;
16const MAX_GFF_FEATURES: usize = 100_000;
17const MAX_GFF_ATTRIBUTES: usize = 256;
18const MAX_GFF_ATTRIBUTE_BYTES: usize = 64 * 1024;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub(crate) enum FeatureFormat {
22 Gff3,
23 Gtf,
24}
25
26pub(crate) fn looks_like_gff3_prefix(prefix: &[u8]) -> bool {
27 let Ok(text) = std::str::from_utf8(prefix) else {
28 return false;
29 };
30 text.lines()
31 .map(str::trim)
32 .find(|line| !line.is_empty())
33 .is_some_and(|line| line.to_ascii_lowercase().starts_with("##gff-version"))
34}
35
36pub(crate) fn looks_like_gtf_prefix(prefix: &[u8]) -> bool {
37 looks_like_feature_prefix(prefix, true)
38}
39
40pub(crate) fn looks_like_feature_prefix(prefix: &[u8], gtf: bool) -> bool {
41 let Ok(text) = std::str::from_utf8(prefix) else {
42 return false;
43 };
44 for line in text
45 .lines()
46 .map(str::trim)
47 .filter(|line| !line.is_empty() && !line.starts_with('#'))
48 {
49 let fields = line.split('\t').collect::<Vec<_>>();
50 if fields.len() != 9 {
51 continue;
52 }
53 if fields[0].is_empty() || fields[2].is_empty() || fields[8].is_empty() {
54 continue;
55 }
56 if fields[3].parse::<usize>().is_err() || fields[4].parse::<usize>().is_err() {
57 continue;
58 }
59 if gtf {
60 return fields[8].contains("gene_id") || fields[8].contains("transcript_id");
61 }
62 return true;
63 }
64 false
65}
66
67pub(crate) fn convert(
68 path: &Path,
69 options: &ConvertOptions,
70 sink: &mut dyn PageConsumer,
71 format: FeatureFormat,
72) -> Result<Vec<String>> {
73 let bytes = read_limited_file(
74 path,
75 options.max_input_bytes.min(MAX_GFF_BYTES),
76 "GFF/GTF input",
77 )?;
78 let text = String::from_utf8(bytes).map_err(|error| {
79 Error::InvalidInput(format!("GFF/GTF input must be UTF-8/ASCII: {error}"))
80 })?;
81 let (mut table, warnings) = parse(&text, format)?;
82 let format_name = match format {
83 FeatureFormat::Gff3 => "gff3",
84 FeatureFormat::Gtf => "gtf",
85 };
86 let mut page_sink = FeaturePageSink {
87 inner: sink,
88 format_name,
89 warnings: &warnings,
90 };
91 convert_table_pages(&mut table, format_name, options, &mut page_sink)?;
92 Ok(warnings)
93}
94
95struct FeaturePageSink<'a> {
96 inner: &'a mut dyn PageConsumer,
97 format_name: &'static str,
98 warnings: &'a [String],
99}
100
101impl PageConsumer for FeaturePageSink<'_> {
102 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
103 page.source_format = self.format_name.into();
104 page.title = format!(
105 "{} feature annotations",
106 self.format_name.to_ascii_uppercase()
107 );
108 page.description = "Genome feature coordinates and attributes are displayed inertly".into();
109 for warning in self.warnings {
110 page.warn(warning.clone());
111 }
112 self.inner.consume(page)
113 }
114}
115
116fn parse(text: &str, format: FeatureFormat) -> Result<(TableData, Vec<String>)> {
117 if text.len() as u64 > MAX_GFF_BYTES {
118 return Err(Error::LimitExceeded(format!(
119 "GFF/GTF input exceeds {MAX_GFF_BYTES} bytes"
120 )));
121 }
122 let mut rows = Vec::new();
123 let mut warnings = Vec::new();
124 let mut directives = false;
125 let mut embedded_fasta = false;
126 let mut seen_gff_version = false;
127 let mut features = 0usize;
128 for (line_number, original) in text.lines().enumerate() {
129 if line_number >= MAX_GFF_LINES {
130 return Err(Error::LimitExceeded(format!(
131 "GFF/GTF exceeds {MAX_GFF_LINES} lines"
132 )));
133 }
134 if original.len() > MAX_GFF_LINE_BYTES {
135 return Err(Error::LimitExceeded(format!(
136 "GFF/GTF line {} exceeds {MAX_GFF_LINE_BYTES} bytes",
137 line_number + 1
138 )));
139 }
140 let line = original.trim_end_matches('\r');
141 if line.trim().is_empty() {
142 continue;
143 }
144 if line.starts_with("##FASTA") {
145 embedded_fasta = true;
146 break;
147 }
148 if line.starts_with('#') {
149 directives = true;
150 if line.to_ascii_lowercase().starts_with("##gff-version") {
151 seen_gff_version = true;
152 }
153 continue;
154 }
155 let columns = line.split('\t').collect::<Vec<_>>();
156 if columns.len() != 9 {
157 return Err(Error::InvalidInput(format!(
158 "GFF/GTF feature line {} must contain 9 tab-separated columns",
159 line_number + 1
160 )));
161 }
162 if features >= MAX_GFF_FEATURES {
163 return Err(Error::LimitExceeded(format!(
164 "GFF/GTF exceeds {MAX_GFF_FEATURES} features"
165 )));
166 }
167 let seqid = columns[0];
168 let source = columns[1];
169 let feature_type = columns[2];
170 if seqid.is_empty() || source.is_empty() || feature_type.is_empty() {
171 return Err(Error::InvalidInput(format!(
172 "GFF/GTF feature line {} has an empty identity column",
173 line_number + 1
174 )));
175 }
176 let start = parse_coordinate(columns[3], "start", line_number + 1)?;
177 let end = parse_coordinate(columns[4], "end", line_number + 1)?;
178 if start == 0 || end == 0 || start > end {
179 return Err(Error::InvalidInput(format!(
180 "GFF/GTF feature line {} has invalid coordinate range",
181 line_number + 1
182 )));
183 }
184 let score = if columns[5] == "." {
185 ".".to_owned()
186 } else {
187 let value = columns[5].parse::<f64>().map_err(|_| {
188 Error::InvalidInput(format!(
189 "GFF/GTF feature line {} has invalid score",
190 line_number + 1
191 ))
192 })?;
193 if !value.is_finite() {
194 return Err(Error::InvalidInput("GFF/GTF score is non-finite".into()));
195 }
196 columns[5].to_owned()
197 };
198 if !matches!(columns[6], "+" | "-" | "." | "?") {
199 return Err(Error::InvalidInput(format!(
200 "GFF/GTF feature line {} has invalid strand",
201 line_number + 1
202 )));
203 }
204 if columns[7] != "." && !matches!(columns[7], "0" | "1" | "2") {
205 return Err(Error::InvalidInput(format!(
206 "GFF/GTF feature line {} has invalid phase",
207 line_number + 1
208 )));
209 }
210 let attributes = validate_attributes(columns[8], format)?;
211 rows.push(vec![
212 seqid.to_owned(),
213 source.to_owned(),
214 feature_type.to_owned(),
215 start.to_string(),
216 end.to_string(),
217 score,
218 columns[6].to_owned(),
219 columns[7].to_owned(),
220 attributes,
221 ]);
222 features += 1;
223 }
224 if features == 0 {
225 return Err(Error::InvalidInput(
226 "GFF/GTF contains no feature rows".into(),
227 ));
228 }
229 if directives {
230 warnings.push("GFF/GTF directives and comments were ignored".into());
231 }
232 if embedded_fasta {
233 warnings.push(
234 "embedded ##FASTA sequence data was omitted; only feature rows were rendered".into(),
235 );
236 }
237 if format == FeatureFormat::Gff3 && !seen_gff_version {
238 warnings.push(
239 "GFF3 version directive was missing; nine-column feature rows were accepted".into(),
240 );
241 }
242 let headers = [
243 "seqid",
244 "source",
245 "type",
246 "start",
247 "end",
248 "score",
249 "strand",
250 "phase",
251 "attributes",
252 ]
253 .into_iter()
254 .map(str::to_owned)
255 .collect();
256 let alignments = vec![
257 TableAlign::Left,
258 TableAlign::Left,
259 TableAlign::Left,
260 TableAlign::Right,
261 TableAlign::Right,
262 TableAlign::Right,
263 TableAlign::Center,
264 TableAlign::Center,
265 TableAlign::Left,
266 ];
267 Ok((
268 TableData {
269 headers,
270 rows,
271 alignments,
272 raw_source: String::new(),
273 },
274 warnings,
275 ))
276}
277
278fn parse_coordinate(value: &str, name: &str, line: usize) -> Result<usize> {
279 value.parse::<usize>().map_err(|_| {
280 Error::InvalidInput(format!("GFF/GTF line {line} has invalid {name} coordinate"))
281 })
282}
283
284fn validate_attributes(value: &str, format: FeatureFormat) -> Result<String> {
285 if value.len() > MAX_GFF_ATTRIBUTE_BYTES {
286 return Err(Error::LimitExceeded(format!(
287 "GFF/GTF attributes exceed {MAX_GFF_ATTRIBUTE_BYTES} bytes"
288 )));
289 }
290 if value == "." {
291 return Ok(String::new());
292 }
293 let pieces = value
294 .split(';')
295 .filter(|piece| !piece.trim().is_empty())
296 .collect::<Vec<_>>();
297 if pieces.len() > MAX_GFF_ATTRIBUTES {
298 return Err(Error::LimitExceeded(format!(
299 "GFF/GTF attributes exceed {MAX_GFF_ATTRIBUTES} entries"
300 )));
301 }
302 for piece in &pieces {
303 let valid = match format {
304 FeatureFormat::Gff3 => piece.contains('='),
305 FeatureFormat::Gtf => piece.split_whitespace().next().is_some_and(|key| {
306 let trimmed = piece.trim_start();
307 trimmed[key.len()..].trim_start().starts_with('"')
308 || trimmed[key.len()..].trim_start().starts_with("'")
309 }),
310 };
311 if !valid {
312 return Err(Error::InvalidInput(
313 "GFF/GTF attribute entry is malformed".into(),
314 ));
315 }
316 if piece
317 .chars()
318 .any(|character| character.is_control() && character != '\t')
319 {
320 return Err(Error::InvalidInput(
321 "GFF/GTF attributes contain a control character".into(),
322 ));
323 }
324 }
325 Ok(value.to_owned())
326}