variant-linker
Version:
CLI for Ensembl VEP and Variant Recoder
569 lines (511 loc) • 23.9 kB
JavaScript
// src/vcfFormatter.js
;
/**
* @fileoverview Module for formatting annotation results into VCF format.
* Handles both VCF and non-VCF original inputs, preparing header sections,
* grouping variants by position, and formatting INFO fields with annotation data.
* @module vcfFormatter
*/
const debugOutput = require('debug')('variant-linker:vcf-formatter');
const { formatVcfCsqString } = require('./dataExtractor');
/**
* Prepares VCF header lines with necessary INFO definitions for VL_CSQ.
*
* @param {Array<string>} [originalHeaderLines] - Original VCF header lines, if available.
* @param {Array<string>} vlCsqFormatFields - Array defining fields for the VL_CSQ format.
* @returns {Array<string>} The prepared VCF header lines.
* @private
*/
function _prepareVcfHeader(originalHeaderLines, vlCsqFormatFields) {
// Use provided header or generate a default one if missing
let finalVcfHeaderLines =
originalHeaderLines && originalHeaderLines.length > 0
? [...originalHeaderLines] // Use a copy
: _generateDefaultVcfHeader();
// Ensure ##fileformat=VCFv4.2 is always the first line
const fileformatRegex = /^##fileformat=/i;
const hasFileformat =
finalVcfHeaderLines.length > 0 && fileformatRegex.test(finalVcfHeaderLines[0]);
if (!hasFileformat) {
// Remove any fileformat lines elsewhere (shouldn't happen, but KISS/DRY)
finalVcfHeaderLines = finalVcfHeaderLines.filter((line) => !fileformatRegex.test(line));
// Insert at the top
finalVcfHeaderLines.unshift('##fileformat=VCFv4.2');
}
// Check if INFO header already exists for VL_CSQ in the final header lines
const hasVlCsqHeader = finalVcfHeaderLines.some((line) => line.includes('ID=VL_CSQ'));
// Add VL_CSQ INFO header if not present
if (!hasVlCsqHeader) {
const infoFieldDescBase = '##INFO=<ID=VL_CSQ,Number=.,Type=String,Description=';
const infoFieldDescDesc = '"VariantLinker Consequence Annotation using VEP/Custom. ';
const infoFieldDescFormat = `Format: ${vlCsqFormatFields.join('|')}">`;
const infoFieldDescription = infoFieldDescBase + infoFieldDescDesc + infoFieldDescFormat;
const chromLineIdx = finalVcfHeaderLines.findIndex((line) => line.startsWith('#CHROM'));
if (chromLineIdx >= 0) {
finalVcfHeaderLines.splice(chromLineIdx, 0, infoFieldDescription);
} else {
// If #CHROM is missing (shouldn't happen with default header), add at the end before data
finalVcfHeaderLines.push(infoFieldDescription);
finalVcfHeaderLines.push('#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO'); // Add #CHROM line if missing
}
debugOutput('Added missing VL_CSQ header line.');
}
// Add headers for inheritance pattern fields
const dedInhHeader =
'##INFO=<ID=VL_DED_INH,Number=1,Type=String,Description="Deduced inheritance pattern (VariantLinker)">';
const compHetHeader =
'##INFO=<ID=VL_COMPHET,Number=.,Type=String,Description="Compound Het details (partner variant keys and gene)">';
// Check if headers already exist
const hasDedInhHeader = finalVcfHeaderLines.some((line) => line.includes('ID=VL_DED_INH'));
const hasCompHetHeader = finalVcfHeaderLines.some((line) => line.includes('ID=VL_COMPHET'));
// Add headers if not present
if (!hasDedInhHeader || !hasCompHetHeader) {
const chromLineIdx = finalVcfHeaderLines.findIndex((line) => line.startsWith('#CHROM'));
if (chromLineIdx >= 0) {
if (!hasDedInhHeader) {
finalVcfHeaderLines.splice(chromLineIdx, 0, dedInhHeader);
debugOutput('Added missing VL_DED_INH header line.');
}
if (!hasCompHetHeader) {
finalVcfHeaderLines.splice(chromLineIdx, 0, compHetHeader);
debugOutput('Added missing VL_COMPHET header line.');
}
} else {
// If #CHROM is missing (shouldn't happen with default header), add at the end before data
if (!hasDedInhHeader) {
finalVcfHeaderLines.push(dedInhHeader);
}
if (!hasCompHetHeader) {
finalVcfHeaderLines.push(compHetHeader);
}
}
}
return finalVcfHeaderLines;
}
/**
* Groups annotation data by genomic position for VCF output.
* Handles both VCF and non-VCF input sources appropriately.
*
* @param {Array<Object>} annotationData - Array of annotation results.
* @param {Map<string, Object>} [vcfRecordMap] - Optional map of variant keys to original VCF data.
* @returns {Map<string, Object>} Map keyed by position, containing grouped annotation data
* and variant details.
* @private
*/
function _groupAnnotationsByPosition(annotationData, vcfRecordMap) {
const positionGroups = new Map();
// Determine if the input was likely a VCF file based on presence of vcfRecordMap
const hasVcfInput = vcfRecordMap && vcfRecordMap.size > 0;
debugOutput(
`_groupAnnotationsByPosition: Input annotation count=${annotationData?.length}, hasVcfInput=${hasVcfInput}`
);
// Logic depends on whether original input was VCF
if (hasVcfInput) {
debugOutput('Processing results using original VCF record map.');
// Map results data to variant keys for faster lookup
// *** Annotation Lookup FIX: Use annotation.variantKey ***
const annotationsByKey = {};
for (const annotation of annotationData) {
if (annotation.variantKey) {
// Use the pre-assigned key
if (!annotationsByKey[annotation.variantKey]) {
annotationsByKey[annotation.variantKey] = [];
}
annotationsByKey[annotation.variantKey].push(annotation);
} else {
// Fallback using originalInput (less reliable now)
const lookupKey = annotation.originalInput || annotation.input;
if (lookupKey) {
if (!annotationsByKey[lookupKey]) {
annotationsByKey[lookupKey] = [];
}
annotationsByKey[lookupKey].push(annotation);
debugOutput(
`_groupAnnotationsByPosition: Warning - Used fallback key '${lookupKey}' for annotation lookup.`
);
} else {
debugOutput(
`_groupAnnotationsByPosition: Warning - Could not determine key for annotation: ${JSON.stringify(annotation)}`
);
}
}
}
debugOutput(
`_groupAnnotationsByPosition: Built annotationsByKey map with ${Object.keys(annotationsByKey).length} keys.`
);
// Process based on the original VCF structure preserved in vcfRecordMap
for (const [key, entry] of vcfRecordMap.entries()) {
// key is CHR-POS-REF-ALT
const { originalRecord, alt } = entry; // the specific ALT allele from the original VCF line
if (!originalRecord) {
debugOutput(`Warning: No original record found for VCF entry key: ${key}`);
continue;
}
const chrom = originalRecord.CHROM || '';
const pos = originalRecord.POS || '';
const ref = originalRecord.REF || '';
const id = originalRecord.ID && originalRecord.ID !== '.' ? originalRecord.ID : '.'; // Prefer original ID
const posKey = `${chrom}:${pos}:${ref}`;
// *** DEBUG POINT 15: Processing VCF Record Map Entry ***
debugOutput(
`_groupAnnotationsByPosition (VCF Path): Processing vcfRecordMap Key='${key}', PosKey='${posKey}', ALT='${alt}'`
);
if (!positionGroups.has(posKey)) {
positionGroups.set(posKey, {
chrom,
pos,
ref,
id, // Use ID from the first VCF record for this position
// Map<altAllele, { annotations[], info?, qual?, filter? }>
alts: new Map(),
});
}
const group = positionGroups.get(posKey);
// *** Use the key directly from vcfRecordMap for lookup ***
const matchingAnnotations = annotationsByKey[key] || [];
// *** DEBUG POINT 16: Annotations Found for VCF Key ***
debugOutput(` -> Found ${matchingAnnotations.length} annotation(s) for Key='${key}'`);
// Find the specific annotation that corresponds to this ALT allele
// If multiple annotations match the key (rare), we might just take the first
// (This assumes VEP results are appropriately associated upstream)
const annotationForAlt = matchingAnnotations.length > 0 ? matchingAnnotations[0] : null;
// *** DEBUG POINT 17: Annotation Matching ALT ***
debugOutput(
` -> Annotation found for ALT='${alt}': ${!!annotationForAlt}. Content: ${JSON.stringify(annotationForAlt)}`
);
if (!group.alts.has(alt)) {
group.alts.set(alt, {
annotations: annotationForAlt ? [annotationForAlt] : [], // Start with the found annotation or empty
originalInfo: originalRecord.INFO,
originalQual: originalRecord.QUAL,
originalFilter: originalRecord.FILTER,
});
} else {
// Add annotation if ALT allele exists and we found one
if (annotationForAlt) {
group.alts.get(alt).annotations.push(annotationForAlt);
}
}
debugOutput(
` -> Updated group for PosKey='${posKey}', ALT='${alt}'. Annotations count: ${group.alts.get(alt)?.annotations.length || 0}`
);
}
} else {
// Handle non-VCF input: Construct VCF fields primarily from annotationData.vcfString
debugOutput('Processing results assuming non-VCF input. Using annotation.vcfString.');
for (const annotation of annotationData) {
if (!annotation || !annotation.vcfString) {
debugOutput(
`Warning: Skipping annotation due to missing 'vcfString'. Input: ${annotation?.originalInput || annotation?.input}`
);
continue;
}
// Parse vcfString: CHROM-POS-REF-ALT
const parts = annotation.vcfString.split('-');
if (parts.length !== 4) {
debugOutput(
`Warning: Skipping due to unexpected vcfString format: '${annotation.vcfString}'. Expected CHROM-POS-REF-ALT. Input: ${annotation?.originalInput}`
);
continue;
}
const [chrom, posStr, ref, alt] = parts;
const pos = parseInt(posStr, 10);
if (isNaN(pos)) {
debugOutput(`Invalid POS in vcfString: '${posStr}'. Input: ${annotation?.originalInput}`);
continue;
}
if (!chrom || !ref || !alt) {
debugOutput(
`Warning: Missing CHROM/REF/ALT in vcfString: '${annotation.vcfString}'. Input: ${annotation?.originalInput}`
);
continue;
}
// Determine ID: Use original input (rsID, HGVS) or fallback
const id =
annotation.originalInput && annotation.originalInput !== annotation.vcfString
? annotation.originalInput
: '.';
const posKey = `${chrom}:${pos}:${ref}`;
const key = annotation.variantKey || `${chrom}-${pos}-${ref}-${alt}`; // Use assigned key if available
// *** DEBUG POINT 18: Processing Non-VCF Annotation ***
debugOutput(
`_groupAnnotationsByPosition (Non-VCF Path): Processing annotation Key='${key}', PosKey='${posKey}', ALT='${alt}'`
);
if (!positionGroups.has(posKey)) {
positionGroups.set(posKey, {
chrom,
pos,
ref,
id, // Use ID from the first annotation encountered for this position
alts: new Map(), // Map<altAllele, { annotations: [] }>
});
}
const group = positionGroups.get(posKey);
// Use the ID from the *first* annotation seen for this posKey, but allow others if '.'
if (group.id === '.' && id !== '.') {
group.id = id;
}
if (!group.alts.has(alt)) {
group.alts.set(alt, { annotations: [annotation] });
} else {
// If this ALT allele already exists for the position, add the annotation
// This could happen if the same variant was input multiple times (e.g., rsID and HGVS)
group.alts.get(alt).annotations.push(annotation);
}
debugOutput(
` -> Updated group for PosKey='${posKey}', ALT='${alt}'. Annotations count: ${group.alts.get(alt)?.annotations.length || 0}`
);
}
}
// *** DEBUG POINT 19: Final Position Groups ***
debugOutput(
`_groupAnnotationsByPosition: Final positionGroups size=${positionGroups.size}. Keys: ${JSON.stringify(Array.from(positionGroups.keys()))}`
);
return positionGroups;
}
/**
* Formats the INFO field for a VCF line using the grouped annotations.
*
* @param {Object} positionGroupData - Object containing data for a position and its ALTs.
* @param {Array<string>} vlCsqFormatFields - Array defining fields for the VL_CSQ format.
* @returns {string} The formatted INFO field string.
* @private
*/
function _formatVcfInfoField(positionGroupData, vlCsqFormatFields) {
const allCsqStringsForLine = [];
let firstAltData = positionGroupData.alts?.values().next().value; // For original INFO
let inheritanceInfoFound = false; // Flag to take inheritance only once per line
let dedInhPattern = null;
let compHetDetails = null;
// *** DEBUG POINT 20: Formatting INFO for Position ***
debugOutput(
`_formatVcfInfoField: Formatting INFO for PosKey='${positionGroupData.chrom}:${positionGroupData.pos}:${positionGroupData.ref}'`
);
// --- Collect CSQ and Inheritance Info ---
for (const [altAllele, altData] of positionGroupData.alts.entries()) {
// *** DEBUG POINT 21: Processing ALT within INFO ***
debugOutput(
` -> Processing ALT='${altAllele}'. Has ${altData.annotations?.length} annotation(s).`
);
// Capture firstAltData reliably if not already set
if (!firstAltData) firstAltData = altData;
if (altData.annotations && altData.annotations.length > 0) {
for (const annotation of altData.annotations) {
// *** DEBUG POINT 22: Formatting CSQ String ***
const csqString = formatVcfCsqString(annotation, vlCsqFormatFields, altAllele);
debugOutput(
` -> CSQ for Annotation (Input='${annotation.originalInput || annotation.input}'): '${csqString}'`
);
if (csqString) {
allCsqStringsForLine.push(csqString);
}
// *** DEBUG POINT 23: Extracting Inheritance Info ***
// Extract Inheritance Info ONCE per VCF line
if (!inheritanceInfoFound && annotation.deducedInheritancePattern) {
debugOutput(
` -> Found Inheritance data in annotation for Input='${annotation.originalInput || annotation.input}'`
);
if (typeof annotation.deducedInheritancePattern === 'object') {
dedInhPattern = annotation.deducedInheritancePattern.prioritizedPattern;
if (annotation.deducedInheritancePattern.compHetDetails) {
const details = annotation.deducedInheritancePattern.compHetDetails;
// Only add comphet tag if it's a confirmed or possible candidate with partners
if (
(details.isCandidate || details.isPossible) &&
details.partnerVariantKeys?.length > 0
) {
compHetDetails = {
partners: details.partnerVariantKeys.join(','),
gene: details.geneSymbol || '', // Ensure gene symbol exists
};
debugOutput(
` -> CompHet details found: Partners='${compHetDetails.partners}', Gene='${compHetDetails.gene}'`
);
} else {
debugOutput(
` -> CompHet details present but not a candidate/possible or no partners.`
);
}
} else {
debugOutput(` -> No CompHet details found in inheritance object.`);
}
} else {
// Backward compatibility or simple string pattern
dedInhPattern = annotation.deducedInheritancePattern;
debugOutput(` -> Found simple inheritance pattern string: '${dedInhPattern}'`);
}
inheritanceInfoFound = true; // Mark as found so we don't repeat for other ALTs/annotations on the same line
}
// --- End Inheritance Info Extraction ---
}
}
}
// --- Prepare INFO parts ---
const infoParts = [];
// Add original INFO fields first (excluding managed tags)
// *** DEBUG POINT 24: Original INFO Check ***
debugOutput(` -> Original INFO from first ALT: ${JSON.stringify(firstAltData?.originalInfo)}`);
if (firstAltData?.originalInfo) {
const originalInfoString = Object.entries(firstAltData.originalInfo)
.filter(([key]) => !['VL_CSQ', 'VL_DED_INH', 'VL_COMPHET'].includes(key))
.map(([key, value]) =>
value === true || value === 'true' || value === '' ? key : `${key}=${value}`
) // Handle flags correctly
.join(';');
if (originalInfoString) {
infoParts.push(originalInfoString);
}
}
// Add VL_CSQ tag if there were consequences
// *** DEBUG POINT 25: Adding CSQ and Inheritance Tags ***
debugOutput(
` -> Adding VL_CSQ: ${allCsqStringsForLine.length > 0 ? 'Yes' : 'No'}. Count: ${allCsqStringsForLine.length}`
);
if (allCsqStringsForLine.length > 0) {
infoParts.push(`VL_CSQ=${allCsqStringsForLine.join(',')}`);
}
// *** NOTE: If no CSQ strings, the tag is omitted entirely ***
// *** ADD INHERITANCE TAGS ***
// Add VL_DED_INH if available and meaningful
const ignorablePatterns = [
'unknown',
'reference',
'unknown_not_processed',
'error_analysis_failed',
// Add other patterns that shouldn't be outputted if necessary
];
debugOutput(
` -> Adding VL_DED_INH: ${dedInhPattern && !ignorablePatterns.includes(dedInhPattern) && !dedInhPattern?.startsWith('unknown_') && !dedInhPattern?.startsWith('error_') ? 'Yes (' + dedInhPattern + ')' : 'No'}`
);
if (
dedInhPattern &&
!ignorablePatterns.includes(dedInhPattern) &&
!dedInhPattern.startsWith('unknown_') &&
!dedInhPattern.startsWith('error_')
) {
// Ensure pattern is safe for VCF INFO field (basic check)
const safePattern = String(dedInhPattern).replace(/[;=,\s|]/g, '_'); // Replace problematic characters more broadly
infoParts.push(`VL_DED_INH=${safePattern}`);
}
// Add VL_COMPHET if details were extracted
debugOutput(` -> Adding VL_COMPHET: ${compHetDetails ? 'Yes' : 'No'}`);
if (compHetDetails) {
const safePartners = String(compHetDetails.partners).replace(/[;=,\s|]/g, '_');
const safeGene = String(compHetDetails.gene).replace(/[;=,\s|]/g, '_');
infoParts.push(`VL_COMPHET=${safePartners}|${safeGene}`); // Use pipe separator as per description
}
// *** END INHERITANCE TAGS ***
const finalInfoString = infoParts.length > 0 ? infoParts.join(';') : '.';
// *** DEBUG POINT 26: Final INFO String ***
debugOutput(` -> Final INFO string: '${finalInfoString}'`);
return finalInfoString;
}
/**
* Constructs a VCF data line from the position group data and INFO string.
*
* @param {Object} positionGroupData - Object containing data for a position and its ALTs.
* @param {string} infoString - The formatted INFO field string.
* @returns {string} The formatted VCF data line.
* @private
*/
function _constructVcfLine(positionGroupData, infoString) {
const alts = Array.from(positionGroupData.alts.keys());
const altAllelesString = alts.join(',');
// Determine QUAL and FILTER values
let firstAltData = null;
for (const altData of positionGroupData.alts.values()) {
if (!firstAltData) {
firstAltData = altData;
break;
}
}
// Get QUAL and FILTER from the first ALT data, if available
const qual =
firstAltData && firstAltData.originalQual !== undefined && firstAltData.originalQual !== null
? firstAltData.originalQual
: '.';
// Handle FILTER field according to VCF spec
let filter = 'PASS'; // Default to PASS for non-VCF input or undefined filter
if (firstAltData && Array.isArray(firstAltData.originalFilter)) {
const validFilters = firstAltData.originalFilter.filter((f) => f && f !== 'PASS' && f !== '.'); // Filter out empty, PASS, or '.'
if (validFilters.length > 0) {
filter = validFilters.join(';'); // Join only actual filter values
}
// else: keep default 'PASS' for empty array or array with only 'PASS'
}
// Construct the VCF data line
return [
positionGroupData.chrom,
positionGroupData.pos,
positionGroupData.id, // Use the determined ID for the position
positionGroupData.ref,
altAllelesString,
qual,
filter,
infoString,
].join('\t');
}
/**
* Generates default VCF header lines when original VCF data is not available.
*
* @returns {Array<string>} Array of default VCF header lines
* @private
*/
function _generateDefaultVcfHeader() {
// Create a minimal VCF header with variant linker specific fields
const headerLines = [
'##fileformat=VCFv4.2',
`##fileDate=${new Date().toISOString().slice(0, 10).replace(/-/g, '')}`,
'##source=variant-linker',
'##INFO=<ID=VL_CSQ,Number=.,Type=String,Description="Consequence annotations from variant-linker. Format: Allele|Consequence|IMPACT|SYMBOL|Gene|Feature_type|Feature|BIOTYPE|HGVSc|HGVSp|Protein_position|Amino_acids|Codons|SIFT|PolyPhen">',
'##INFO=<ID=VL_DED_INH,Number=1,Type=String,Description="Deduced inheritance pattern (VariantLinker)">',
'##INFO=<ID=VL_COMPHET,Number=.,Type=String,Description="Compound Het details (partner variant keys and gene)">',
'##INFO=<ID=VARIANT_LINKER,Number=0,Type=Flag,Description="Variant processed by variant-linker">',
'#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO',
];
return headerLines;
}
/**
* Formats annotation results into a complete VCF string.
*
* @param {Array<Object>} annotationData - Array of annotation results.
* @param {Map<string, Object>} [vcfRecordMap] - Optional map from VCF input.
* @param {Array<string>} [vcfHeaderLines] - Optional original VCF header lines.
* @param {Array<string>} vlCsqFormatFields - Fields for the VL_CSQ tag format.
* @returns {string} The complete VCF formatted content.
*/
function formatAnnotationsToVcf(annotationData, vcfRecordMap, vcfHeaderLines, vlCsqFormatFields) {
// *** DEBUG POINT 27: Starting VCF Formatting ***
debugOutput(
`formatAnnotationsToVcf: Starting formatting. Annotation count=${annotationData?.length}, vcfRecordMap size=${vcfRecordMap?.size}`
);
const finalHeaderLines = _prepareVcfHeader(vcfHeaderLines, vlCsqFormatFields);
if (!annotationData || !Array.isArray(annotationData) || annotationData.length === 0) {
debugOutput('No annotation data provided for VCF output. Returning header only.');
return finalHeaderLines.length > 0 ? finalHeaderLines.join('\n') + '\n' : '';
}
const positionGroups = _groupAnnotationsByPosition(annotationData, vcfRecordMap);
debugOutput(`Grouped annotations into ${positionGroups.size} positions.`);
const outputDataLines = [];
for (const [, groupData] of positionGroups.entries()) {
if (!groupData.alts || groupData.alts.size === 0) continue;
const infoString = _formatVcfInfoField(groupData, vlCsqFormatFields);
const vcfLine = _constructVcfLine(groupData, infoString);
outputDataLines.push(vcfLine);
}
// *** DEBUG POINT 28: Final VCF Output Lines ***
debugOutput(`formatAnnotationsToVcf: Generated ${outputDataLines.length} data lines.`);
const finalOutput = [...finalHeaderLines, ...outputDataLines];
// Final check: if only header lines are present, maybe something went wrong
if (finalOutput.length === finalHeaderLines.length && annotationData.length > 0) {
debugOutput(
'Warning: VCF output contains only header lines, but there were annotations to process. Check grouping or formatting logic.'
);
}
// Join lines and add a trailing newline for valid VCF format
return finalOutput.join('\n') + (finalOutput.length > 0 ? '\n' : '');
}
module.exports = {
formatAnnotationsToVcf,
// Do NOT export internal helpers
};