UNPKG

@gmod/vcf

Version:

High performance streaming Variant Call Format (VCF) parser in pure JavaScript

288 lines 10.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const parseGenotypesOnly_ts_1 = require("./parseGenotypesOnly.js"); const parseMetaString_ts_1 = require("./parseMetaString.js"); const vcfReserved_ts_1 = __importDefault(require("./vcfReserved.js")); function decodeURIComponentNoThrow(uri) { try { return decodeURIComponent(uri); } catch (_e) { // avoid throwing exception on a failure to decode URI component return uri; } } /** * Class representing a VCF parser, instantiated with the VCF header. * * @param {object} args * * @param {string} args.header - The VCF header. Supports both LF and CRLF * newlines. * * @param {boolean} args.strict - Whether to parse in strict mode or not * (default true) */ class VCFParser { constructor({ header = '', strict = true, }) { if (!header.length) { throw new Error('empty header received'); } const headerLines = header.split(/[\r\n]+/).filter(Boolean); if (!headerLines.length) { throw new Error('no non-empty header lines specified'); } this.strict = strict; this.metadata = JSON.parse(JSON.stringify({ INFO: vcfReserved_ts_1.default.InfoFields, FORMAT: vcfReserved_ts_1.default.GenotypeFields, ALT: vcfReserved_ts_1.default.AltTypes, FILTER: vcfReserved_ts_1.default.FilterTypes, })); let lastLine; headerLines.forEach(line => { if (!line.startsWith('#')) { throw new Error(`Bad line in header:\n${line}`); } else if (line.startsWith('##')) { this.parseMetadata(line); } else { lastLine = line; } }); if (!lastLine) { throw new Error('No format line found in header'); } const fields = lastLine.trim().split('\t'); const thisHeader = fields.slice(0, 8); const correctHeader = [ '#CHROM', 'POS', 'ID', 'REF', 'ALT', 'QUAL', 'FILTER', 'INFO', ]; if (fields.length < 8) { throw new Error(`VCF header missing columns:\n${lastLine}`); } else if (thisHeader.length !== correctHeader.length || !thisHeader.every((value, index) => value === correctHeader[index])) { throw new Error(`VCF column headers not correct:\n${lastLine}`); } this.samples = fields.slice(9); } parseSamples(format, prerest) { const genotypes = {}; if (format) { const rest = prerest.split('\t'); const formatKeys = format.split(':'); const isNumberType = formatKeys.map(key => { const r = this.getMetadata('FORMAT', key, 'Type'); return r === 'Integer' || r === 'Float'; }); for (let i = 0; i < this.samples.length; i++) { const sample = this.samples[i]; genotypes[sample] = {}; const columns = rest[i].split(':'); for (let j = 0; j < columns.length; j++) { const val = columns[j]; genotypes[sample][formatKeys[j]] = val === '' || val === '.' ? undefined : val .split(',') .map(ent => ent === '.' ? undefined : isNumberType[j] ? +ent : ent); } } } return genotypes; } /** * Parse a VCF metadata line (i.e. a line that starts with "##") and add its * properties to the object. * * @param {string} line - A line from the VCF. Supports both LF and CRLF * newlines. */ parseMetadata(line) { const match = /^##(.+?)=(.*)/.exec(line.trim()); if (!match) { throw new Error(`Line is not a valid metadata line: ${line}`); } const [metaKey, metaVal] = match.slice(1, 3); const r = metaKey; if (metaVal?.startsWith('<')) { if (!(r in this.metadata)) { this.metadata[r] = {}; } const [id, keyVals] = this.parseStructuredMetaVal(metaVal); if (id) { // if there is an ID field in the <> metadata // e.g. ##INFO=<ID=AF_ESP,...> ; this.metadata[r][id] = keyVals; } else { // if there is not an ID field in the <> metadata // e.g. ##ID=<Description="ClinVar Variation ID"> this.metadata[r] = keyVals; } } else { this.metadata[r] = metaVal; } } /** * Parse a VCF header structured meta string (i.e. a meta value that starts * with "<ID=...") * * @param {string} metaVal - The VCF metadata value * * @returns {Array} - Array with two entries, 1) a string of the metadata ID * and 2) an object with the other key-value pairs in the metadata */ parseStructuredMetaVal(metaVal) { const keyVals = (0, parseMetaString_ts_1.parseMetaString)(metaVal); const id = keyVals.ID; delete keyVals.ID; if ('Number' in keyVals) { if (!Number.isNaN(Number(keyVals.Number))) { keyVals.Number = Number(keyVals.Number); } } return [id, keyVals]; } /** * Get metadata filtered by the elements in args. For example, can pass * ('INFO', 'DP') to only get info on an metadata tag that was like * "##INFO=<ID=DP,...>" * * @param {...string} args - List of metadata filter strings. * * @returns {any} An object, string, or number, depending on the filtering */ getMetadata(...args) { let filteredMetadata = this.metadata; for (const arg of args) { filteredMetadata = filteredMetadata[arg]; if (!filteredMetadata) { return filteredMetadata; } } return filteredMetadata; } /** * Parse a VCF line into an object like * * ```typescript * { * CHROM: 'contigA', * POS: 3000, * ID: ['rs17883296'], * REF: 'G', * ALT: ['T', 'A'], * QUAL: 100, * FILTER: 'PASS', * INFO: { * NS: [3], * DP: [14], * AF: [0.5], * DB: true, * XYZ: ['5'], * }, * SAMPLES: () => ({ * HG00096: { * GT: ['0|0'], * AP: ['0.000', '0.000'], * } * }), * GENOTYPES: () => ({ * HG00096: '0|0' * }) * } * ``` * * SAMPLES and GENOTYPES methods are functions instead of static data fields * because it avoids parsing the potentially long list of samples from e.g. * 1000 genotypes data unless requested. * * The SAMPLES function gives all info about the samples * * The GENOTYPES function only extracts the raw GT string if it exists, for * potentially optimized parsing by programs that need it * * @param {string} line - A string of a line from a VCF */ parseLine(line) { let currChar = 0; for (let currField = 0; currChar < line.length; currChar += 1) { if (line[currChar] === '\t') { currField += 1; } if (currField === 9) { // reached genotypes, rest of fields are evaluated lazily break; } } const fields = line.slice(0, currChar).split('\t'); const rest = line.slice(currChar + 1); const [CHROM, POS, ID, REF, ALT, QUAL, FILTER] = fields; const chrom = CHROM; const pos = +POS; const id = ID === '.' ? undefined : ID.split(';'); const ref = REF; const alt = ALT === '.' ? undefined : ALT.split(','); const qual = QUAL === '.' ? undefined : +QUAL; const filter = FILTER === '.' ? undefined : FILTER.split(';'); const format = fields[8]; if (this.strict && !fields[7]) { throw new Error("no INFO field specified, must contain at least a '.' (turn off strict mode to allow)"); } const hasDecode = fields[7]?.includes('%'); const info = fields[7] === undefined || fields[7] === '.' ? {} : Object.fromEntries(fields[7].split(';').map(r => { const [key, val] = r.split('='); const items = val ?.split(',') .map(val => (val === '.' ? undefined : val)) .map(f => (f && hasDecode ? decodeURIComponentNoThrow(f) : f)); const itemType = this.getMetadata('INFO', key, 'Type'); if (itemType === 'Integer' || itemType === 'Float') { return [ key, items?.map(val => val === undefined ? undefined : Number(val)), ]; } else if (itemType === 'Flag') { return [key, true]; } else { // ?? true interpret as flag if undefined return [key, items ?? true]; } })); return { CHROM: chrom, POS: pos, ALT: alt, INFO: info, REF: ref, FILTER: filter && filter.length === 1 && filter[0] === 'PASS' ? 'PASS' : filter, ID: id, QUAL: qual, FORMAT: format, SAMPLES: () => this.parseSamples(fields[8] ?? '', rest), GENOTYPES: () => (0, parseGenotypesOnly_ts_1.parseGenotypesOnly)(fields[8] ?? '', rest, this.samples), }; } } exports.default = VCFParser; //# sourceMappingURL=parse.js.map