Skip to main content

document_svg/document/
vcf.rs

1//! Bounded Variant Call Format (VCF) variant-table preview.
2//!
3//! VCF metadata, fixed variant columns and sample fields are displayed as
4//! inert text. Reference URLs, genotype interpretation, phasing and variant
5//! normalization are never performed.
6
7use 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_VCF_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_VCF_LINES: usize = 2_000_000;
15const MAX_VCF_LINE_BYTES: usize = 1 << 20;
16const MAX_VCF_VARIANTS: usize = 100_000;
17const MAX_VCF_COLUMNS: usize = 256;
18const MAX_VCF_VALUE_BYTES: usize = 64 * 1024;
19const MAX_VCF_CELLS: usize = 2_000_000;
20
21pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
22    let Ok(text) = std::str::from_utf8(prefix) else {
23        return false;
24    };
25    text.lines().any(|line| {
26        let line = line.trim_start();
27        line.to_ascii_lowercase().starts_with("##fileformat=vcf") || line.starts_with("#CHROM\t")
28    })
29}
30
31pub(crate) fn convert(
32    path: &Path,
33    options: &ConvertOptions,
34    sink: &mut dyn PageConsumer,
35) -> Result<Vec<String>> {
36    let bytes = read_limited_file(
37        path,
38        options.max_input_bytes.min(MAX_VCF_BYTES),
39        "VCF input",
40    )?;
41    let text = String::from_utf8(bytes)
42        .map_err(|error| Error::InvalidInput(format!("VCF input must be UTF-8/ASCII: {error}")))?;
43    let (mut table, warnings) = parse_vcf(&text)?;
44    let mut page_sink = VcfPageSink {
45        inner: sink,
46        warnings: &warnings,
47    };
48    convert_table_pages(&mut table, "vcf", options, &mut page_sink)?;
49    Ok(warnings)
50}
51
52struct VcfPageSink<'a> {
53    inner: &'a mut dyn PageConsumer,
54    warnings: &'a [String],
55}
56impl PageConsumer for VcfPageSink<'_> {
57    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
58        page.source_format = "vcf".into();
59        page.title = "VCF variant annotations".into();
60        page.description = "Variant and sample fields are displayed inertly; no reference or genotype analysis is performed".into();
61        for warning in self.warnings {
62            page.warn(warning.clone());
63        }
64        self.inner.consume(page)
65    }
66}
67
68fn parse_vcf(text: &str) -> Result<(TableData, Vec<String>)> {
69    if text.len() as u64 > MAX_VCF_BYTES {
70        return Err(Error::LimitExceeded(format!(
71            "VCF input exceeds {MAX_VCF_BYTES} bytes"
72        )));
73    }
74    let lines = text.lines().collect::<Vec<_>>();
75    if lines.len() > MAX_VCF_LINES {
76        return Err(Error::LimitExceeded(format!(
77            "VCF input exceeds {MAX_VCF_LINES} lines"
78        )));
79    }
80    let mut headers = None::<Vec<String>>;
81    let mut rows = Vec::new();
82    let mut warnings = Vec::new();
83    let mut metadata_seen = false;
84    let mut fileformat_seen = false;
85    let mut embedded_fasta = false;
86    let mut cells = 0usize;
87    for (line_number, original) in lines.iter().enumerate() {
88        if original.len() > MAX_VCF_LINE_BYTES {
89            return Err(Error::LimitExceeded(format!(
90                "VCF line {} exceeds {MAX_VCF_LINE_BYTES} bytes",
91                line_number + 1
92            )));
93        }
94        let line = original.trim_end_matches('\r');
95        if line.is_empty() {
96            continue;
97        }
98        if line.starts_with("##FASTA") {
99            embedded_fasta = true;
100            break;
101        }
102        if line.starts_with("##") {
103            metadata_seen = true;
104            if line.to_ascii_lowercase().starts_with("##fileformat=vcf") {
105                fileformat_seen = true;
106            }
107            continue;
108        }
109        if line.starts_with('#') {
110            if headers.is_some() {
111                return Err(Error::InvalidInput(format!(
112                    "VCF has duplicate column header at line {}",
113                    line_number + 1
114                )));
115            }
116            let fields = line.split('\t').collect::<Vec<_>>();
117            if fields.len() < 8
118                || fields.len() > MAX_VCF_COLUMNS
119                || fields[..8]
120                    != [
121                        "#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO",
122                    ]
123            {
124                return Err(Error::InvalidInput(format!(
125                    "VCF column header at line {} is invalid",
126                    line_number + 1
127                )));
128            }
129            headers = Some(fields.into_iter().map(str::to_owned).collect());
130            continue;
131        }
132        let header = headers
133            .as_ref()
134            .ok_or_else(|| Error::InvalidInput("VCF data appears before #CHROM header".into()))?;
135        let fields = line.split('\t').collect::<Vec<_>>();
136        if fields.len() != header.len() {
137            return Err(Error::InvalidInput(format!(
138                "VCF line {} has {} columns; expected {}",
139                line_number + 1,
140                fields.len(),
141                header.len()
142            )));
143        }
144        if rows.len() >= MAX_VCF_VARIANTS {
145            return Err(Error::LimitExceeded(format!(
146                "VCF exceeds {MAX_VCF_VARIANTS} variants"
147            )));
148        }
149        if fields[0].is_empty()
150            || fields[2].is_empty()
151            || fields[3].is_empty()
152            || fields[4].is_empty()
153        {
154            return Err(Error::InvalidInput(format!(
155                "VCF line {} has an empty required field",
156                line_number + 1
157            )));
158        }
159        let pos = fields[1].parse::<u64>().map_err(|_| {
160            Error::InvalidInput(format!("VCF line {} POS is invalid", line_number + 1))
161        })?;
162        if pos == 0 {
163            return Err(Error::InvalidInput(format!(
164                "VCF line {} POS must be 1-based",
165                line_number + 1
166            )));
167        }
168        if fields[5] != "." {
169            let qual = fields[5].parse::<f64>().map_err(|_| {
170                Error::InvalidInput(format!("VCF line {} QUAL is invalid", line_number + 1))
171            })?;
172            if !qual.is_finite() {
173                return Err(Error::InvalidInput("VCF QUAL is non-finite".into()));
174            }
175        }
176        for field in &fields {
177            if field.len() > MAX_VCF_VALUE_BYTES {
178                return Err(Error::LimitExceeded(format!(
179                    "VCF line {} field exceeds {MAX_VCF_VALUE_BYTES} bytes",
180                    line_number + 1
181                )));
182            }
183            if field.chars().any(char::is_control) {
184                return Err(Error::InvalidInput(format!(
185                    "VCF line {} contains a control character",
186                    line_number + 1
187                )));
188            }
189        }
190        cells = cells
191            .checked_add(fields.len())
192            .ok_or_else(|| Error::LimitExceeded("VCF cell count overflowed".into()))?;
193        if cells > MAX_VCF_CELLS {
194            return Err(Error::LimitExceeded(format!(
195                "VCF exceeds {MAX_VCF_CELLS} cells"
196            )));
197        }
198        rows.push(fields.into_iter().map(str::to_owned).collect());
199    }
200    let headers =
201        headers.ok_or_else(|| Error::InvalidInput("VCF #CHROM header is missing".into()))?;
202    if rows.is_empty() {
203        return Err(Error::InvalidInput("VCF contains no variant rows".into()));
204    }
205    if metadata_seen {
206        warnings.push(
207            "VCF metadata directives were ignored; reference URLs and descriptions were not loaded"
208                .into(),
209        );
210    }
211    if embedded_fasta {
212        warnings.push("embedded VCF ##FASTA sequence data was omitted".into());
213    }
214    if !fileformat_seen {
215        warnings
216            .push("VCF ##fileformat directive was missing; the tabular header was accepted".into());
217    }
218    let alignments = (0..headers.len())
219        .map(|index| {
220            if matches!(index, 1 | 5) {
221                TableAlign::Right
222            } else {
223                TableAlign::Left
224            }
225        })
226        .collect();
227    Ok((
228        TableData {
229            headers,
230            rows,
231            alignments,
232            raw_source: String::new(),
233        },
234        warnings,
235    ))
236}