← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/msa/formats/clustal.ts
4.67 KB · Sep 30, 2026 · 23:01 UTC
import { normalizeGapSymbol } from "../residue-alphabet";
import type {
MsaAnnotationTrack,
MsaFormatDraft,
MsaParseWarning,
} from "../types";
const structureLikePattern = /^[()[\]{}<>A-Za-z._~, -]+$/;
const consensusLikePattern = /^[*:. ]+$/;
export function parseClustal(contents: string): MsaFormatDraft {
const rowsById = new Map<
string,
{ fragments: Array<string>; label: string }
>();
const order: Array<string> = [];
const warnings: Array<MsaParseWarning> = [];
const blocks: Array<{
consensus?: string;
structure?: string;
width: number;
}> = [];
const lines = contents.split(/\r?\n/);
const firstContentLine = lines.find((line) => line.trim().length > 0)?.trim();
if (!/^(?:CLUSTAL|MUSCLE)\b/i.test(firstContentLine ?? "")) {
warnings.push({
code: "clustal-header-missing",
message: "The CLUSTAL or MUSCLE alignment header was not found.",
severity: "error",
});
}
let currentBlock:
| {
consensus?: string;
fragmentStart: number;
rowIds: Set<string>;
structure?: string;
width: number;
}
| undefined;
const finalizeBlock = () => {
if (currentBlock == null || currentBlock.rowIds.size === 0) return;
blocks.push({
consensus: currentBlock.consensus,
structure: currentBlock.structure,
width: currentBlock.width,
});
currentBlock = undefined;
};
lines.forEach((line, index) => {
const lineNumber = index + 1;
const trimmed = line.trimEnd();
if (line.trim().length === 0) {
// An all-space consensus row is indistinguishable from a block separator
// after trailing whitespace is stripped. Finalize the block and pad its
// annotation later if another block establishes that the track exists.
finalizeBlock();
return;
}
if (/^CLUSTAL/i.test(trimmed.trim()) || /^MUSCLE/i.test(trimmed.trim())) {
return;
}
if (/^\s/.test(line)) {
if (currentBlock == null) {
warnings.push({
code: "clustal-annotation-skipped",
line: lineNumber,
message: "Skipped a CLUSTAL annotation row outside a sequence block.",
});
return;
}
const annotation = line
.slice(
currentBlock.fragmentStart,
currentBlock.fragmentStart + currentBlock.width,
)
.padEnd(currentBlock.width, " ");
if (consensusLikePattern.test(annotation)) {
currentBlock.consensus = annotation;
} else if (structureLikePattern.test(annotation)) {
currentBlock.structure = annotation;
} else {
warnings.push({
code: "clustal-annotation-skipped",
line: lineNumber,
message: "Skipped an unrecognized CLUSTAL annotation row.",
});
}
return;
}
const rowMatch = /^(\S+)(\s+)(\S+)(?:\s+\d+)?\s*$/.exec(trimmed);
const label = rowMatch?.[1];
const fragment = rowMatch?.[3];
if (label == null || fragment == null) {
warnings.push({
code: "clustal-row-skipped",
line: lineNumber,
message: "Skipped a malformed CLUSTAL sequence row.",
});
return;
}
if (currentBlock?.rowIds.has(label)) finalizeBlock();
currentBlock ??= {
fragmentStart:
(rowMatch?.[1]?.length ?? 0) + (rowMatch?.[2]?.length ?? 0),
rowIds: new Set(),
width: fragment.length,
};
currentBlock.rowIds.add(label);
const existing = rowsById.get(label);
const normalizedFragment = Array.from(fragment)
.map((symbol) => normalizeGapSymbol(symbol))
.join("");
if (existing == null) {
order.push(label);
rowsById.set(label, { fragments: [normalizedFragment], label });
} else {
existing.fragments.push(normalizedFragment);
}
});
finalizeBlock();
const annotations: Array<MsaAnnotationTrack> = [];
if (blocks.some(({ consensus }) => consensus != null)) {
annotations.push({
id: "clustal-consensus",
kind: "conservation",
label: "CLUSTAL consensus",
values: blocks
.map(({ consensus, width }) => consensus ?? " ".repeat(width))
.join(""),
});
}
if (blocks.some(({ structure }) => structure != null)) {
annotations.push({
id: "clustal-structure",
kind: "rna-secondary-structure",
label: "RNA structure",
values: blocks
.map(({ structure, width }) => structure ?? " ".repeat(width))
.join(""),
});
}
return {
annotations,
format: "clustal",
rows: order.map((id) => {
const row = rowsById.get(id);
return {
alignedSequence: row?.fragments.join("") ?? "",
id,
label: row?.label ?? id,
};
}),
warnings,
};
}
SHA-256: b7dfc760e71d6aa308d3bd9ad89644e9ca3439415426572b1f224ca56334bf44