← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/msa/formats/nexus.ts
8.29 KB · Oct 5, 2026 · 18:18 UTC
import { normalizeGapSymbol } from "../residue-alphabet";
import type { MsaFormatDraft, MsaParseWarning } from "../types";
function cleanNexusLine(line: string): string {
return line.trim();
}
export function parseNexus(contents: string): MsaFormatDraft {
const warnings: Array<MsaParseWarning> = [];
const strippedComments = stripNexusComments(contents);
const lines = strippedComments.contents.split(/\r?\n/);
if (strippedComments.unterminated) {
warnings.push({
code: "nexus-comment-unterminated",
message: "A NEXUS comment was not closed before the end of the file.",
severity: "error",
});
}
const flattenedDirectives = lines.map(cleanNexusLine).join(" ");
const dimensions =
/\bdimensions\b([^;]*);/i.exec(flattenedDirectives)?.[1] ?? "";
const expectedRows = Number(/\bntax\s*=\s*(\d+)/i.exec(dimensions)?.[1]);
const expectedColumns = Number(/\bnchar\s*=\s*(\d+)/i.exec(dimensions)?.[1]);
const formatBody = /\bformat\b([^;]*);/i.exec(flattenedDirectives)?.[1] ?? "";
const gapToken = formatBody.match(/\bgap\s*=\s*(\S)/i)?.[1] ?? "-";
const missingToken = formatBody.match(/\bmissing\s*=\s*(\S)/i)?.[1] ?? "?";
const matchToken = formatBody.match(/\bmatchchar\s*=\s*(\S)/i)?.[1];
const interleaved = /\binterleave(?:d)?\s*=\s*(?:yes|true)\b/i.test(
formatBody,
);
const matrixStart = lines.findIndex((line) =>
/\bmatrix\b/i.test(cleanNexusLine(line)),
);
if (matrixStart === -1) {
return {
format: "nexus",
rows: [],
warnings: [
...warnings,
{
code: "nexus-matrix-missing",
message: "The NEXUS MATRIX block was not found.",
severity: "error",
},
],
};
}
const rowsById = new Map<string, Array<string>>();
const order: Array<string> = [];
let continuationCursor = 0;
let reachedTerminator = false;
const matrixMarker = /\bmatrix\b/i.exec(lines[matrixStart] ?? "");
const matrixLines = [
(lines[matrixStart] ?? "").slice(
(matrixMarker?.index ?? 0) + (matrixMarker?.[0].length ?? 0),
),
...lines.slice(matrixStart + 1),
];
for (let index = 0; index < matrixLines.length; index += 1) {
let line = cleanNexusLine(matrixLines[index] ?? "");
if (line.length === 0) continue;
const terminatorIndex = findNexusTerminator(line);
if (terminatorIndex !== -1) {
reachedTerminator = true;
line = line.slice(0, terminatorIndex).trim();
if (line.length === 0) break;
}
const matrixRow = parseNexusMatrixRow(line);
if (matrixRow == null) {
if (interleaved && order.length > 0 && /^\S+$/.test(line)) {
const id = order[continuationCursor % order.length];
continuationCursor += 1;
if (id != null) {
rowsById
.get(id)
?.push(
normalizeNexusSequence(line, gapToken, missingToken, matchToken),
);
}
} else {
warnings.push({
code: "nexus-row-skipped",
line: matrixStart + index + 1,
message: "Skipped a malformed NEXUS MATRIX row.",
preserved: "ignored",
severity: "error",
});
}
} else {
const id = matrixRow.id;
const sequence = normalizeNexusSequence(
matrixRow.sequence,
gapToken,
missingToken,
matchToken,
);
if (!rowsById.has(id)) {
order.push(id);
rowsById.set(id, []);
}
rowsById.get(id)?.push(sequence);
}
if (reachedTerminator) break;
}
if (!reachedTerminator) {
warnings.push({
code: "nexus-matrix-terminator-missing",
message: "The NEXUS MATRIX block did not end with a semicolon.",
severity: "error",
});
}
let rows = order.map((id) => ({
alignedSequence: rowsById.get(id)?.join("") ?? "",
id,
label: id,
}));
if (matchToken != null && rows.length > 0) {
const reference = rows[0]?.alignedSequence ?? "";
rows = rows.map((row, rowIndex) => ({
...row,
alignedSequence: Array.from(row.alignedSequence)
.map((symbol, columnIndex) => {
if (symbol !== matchToken) return symbol;
if (rowIndex === 0) {
warnings.push({
code: "nexus-reference-matchchar",
message:
"The first NEXUS row cannot use MATCHCHAR because no reference residue exists.",
severity: "error",
});
return "?";
}
return reference[columnIndex] ?? "?";
})
.join(""),
}));
}
if (
Number.isInteger(expectedRows) &&
expectedRows > 0 &&
rows.length !== expectedRows
) {
warnings.push({
code: "nexus-ntax-mismatch",
message: `NEXUS declares NTAX=${expectedRows}, but ${rows.length} rows were parsed.`,
severity: "error",
});
}
if (Number.isInteger(expectedColumns) && expectedColumns > 0) {
for (const row of rows) {
if (row.alignedSequence.length !== expectedColumns) {
warnings.push({
code: "nexus-nchar-mismatch",
message: `NEXUS row ${row.id} has ${row.alignedSequence.length} columns; NCHAR=${expectedColumns}.`,
severity: "error",
});
}
}
}
return {
format: "nexus",
metadata: {
gap: gapToken,
interleaved: String(interleaved),
matchchar: matchToken ?? "none",
missing: missingToken,
},
rows,
warnings,
};
}
function findNexusTerminator(line: string): number {
let quote: "'" | '"' | null = null;
for (let index = 0; index < line.length; index += 1) {
const character = line[index] ?? "";
if (quote != null) {
if (character === quote && line[index + 1] === quote) {
index += 1;
} else if (character === quote) {
quote = null;
}
continue;
}
if (character === "'" || character === '"') {
quote = character;
} else if (character === ";") {
return index;
}
}
return -1;
}
function parseNexusMatrixRow(
line: string,
): { id: string; sequence: string } | null {
const quote = line[0] === "'" || line[0] === '"' ? line[0] : null;
if (quote == null) {
const separator = line.search(/\s/);
if (separator <= 0) return null;
const id = line.slice(0, separator).replaceAll("_", " ");
const sequence = line.slice(separator).trim();
return sequence.length === 0 ? null : { id, sequence };
}
let id = "";
let index = 1;
let closed = false;
while (index < line.length) {
const character = line[index] ?? "";
if (character === quote && line[index + 1] === quote) {
id += quote;
index += 2;
continue;
}
if (character === quote) {
closed = true;
index += 1;
break;
}
id += character;
index += 1;
}
if (!closed || !/\s/.test(line[index] ?? "")) return null;
const sequence = line.slice(index).trim();
return sequence.length === 0 ? null : { id, sequence };
}
function normalizeNexusSequence(
sequence: string,
gapToken: string,
missingToken: string,
matchToken?: string,
): string {
return Array.from(sequence.replaceAll(/\s+/g, ""))
.map((symbol) => {
if (matchToken != null && symbol === matchToken) return symbol;
if (symbol === gapToken) return "-";
if (symbol === missingToken) return "?";
return normalizeGapSymbol(symbol);
})
.join("");
}
function stripNexusComments(contents: string): {
contents: string;
unterminated: boolean;
} {
let depth = 0;
let result = "";
let quote: "'" | '"' | null = null;
for (let index = 0; index < contents.length; index += 1) {
const character = contents[index] ?? "";
if (depth === 0 && quote != null) {
result += character;
if (character === quote && contents[index + 1] === quote) {
result += contents[index + 1];
index += 1;
} else if (character === quote) {
quote = null;
}
continue;
}
if (depth === 0 && (character === "'" || character === '"')) {
quote = character;
result += character;
continue;
}
if (character === "[") {
depth += 1;
result += " ";
} else if (character === "]" && depth > 0) {
depth -= 1;
result += " ";
} else if (depth > 0) {
result += character === "\n" ? "\n" : " ";
} else {
result += character;
}
}
return { contents: result, unterminated: depth > 0 };
}
SHA-256: 9dc4b7e40da81f01548c88a9ce5f98367ca00009e9baf83224f38a548fa161a6