@gmod/vcf
Version:
High performance streaming Variant Call Format (VCF) parser in pure JavaScript
316 lines (299 loc) • 9.19 kB
text/typescript
import { parseGenotypesOnly } from './parseGenotypesOnly.ts'
import { parseMetaString } from './parseMetaString.ts'
import vcfReserved from './vcfReserved.ts'
function decodeURIComponentNoThrow(uri: string) {
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)
*/
export default class VCFParser {
private metadata: Record<string, unknown>
public strict: boolean
public samples: string[]
constructor({
header = '',
strict = true,
}: {
header: string
strict?: boolean
}) {
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.InfoFields,
FORMAT: vcfReserved.GenotypeFields,
ALT: vcfReserved.AltTypes,
FILTER: vcfReserved.FilterTypes,
}),
)
let lastLine: string | undefined
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)
}
private parseSamples(format: string, prerest: string) {
const genotypes = {} as Record<
string,
Record<string, (string | number | undefined)[] | undefined>
>
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.
*/
private parseMetadata(line: string) {
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] as Record<string, unknown>)[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
*/
private parseStructuredMetaVal(metaVal: string) {
const keyVals = 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] as const
}
/**
* 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: string[]) {
let filteredMetadata: any = 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: string) {
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: () => parseGenotypesOnly(fields[8] ?? '', rest, this.samples),
}
}
}
export type Variant = ReturnType<VCFParser['parseLine']>