← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/msa/formats/msf.ts

5.82 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import { normalizeGapSymbol } from "../residue-alphabet";
import type { MsaFormatDraft, MsaParseWarning } from "../types";

interface MsfBlockRow {
  id: string;
  line: number;
  sequence: string;
}

interface MsfDeclaredRow {
  id: string;
  length: number;
}

export function parseMsf(contents: string): MsaFormatDraft {
  const lines = contents.split(/\r?\n/);
  const warnings: Array<MsaParseWarning> = [];
  const expectedColumns = Number(/\bMSF:\s*(\d+)/i.exec(contents)?.[1]);
  const declaredRows = parseDeclaredRows(lines, warnings);
  const startIndex = lines.findIndex((line) => line.trim() === "//");
  if (startIndex === -1) {
    warnings.push({
      code: "msf-sequence-divider-missing",
      message: "The MSF sequence divider line (//) was not found.",
      severity: "error",
    });
  }

  const blocks = parseBlocks(
    lines.slice(startIndex === -1 ? 0 : startIndex + 1),
    startIndex === -1 ? 1 : startIndex + 2,
    warnings,
  );
  const order = unique([
    ...declaredRows.map(({ id }) => id),
    ...blocks.flatMap((block) => block.map(({ id }) => id)),
  ]);
  const dataIds = new Set(blocks.flatMap((block) => block.map(({ id }) => id)));
  const fragmentsById = new Map(order.map((id) => [id, [] as Array<string>]));

  for (const block of blocks) {
    const blockWidth = Math.max(
      0,
      ...block.map(({ sequence }) => sequence.length),
    );
    const rowsInBlock = new Map<string, MsfBlockRow>();
    for (const row of block) {
      if (rowsInBlock.has(row.id)) {
        warnings.push({
          code: "msf-duplicate-block-row",
          line: row.line,
          message: `MSF row ${row.id} appears more than once in one alignment block.`,
          severity: "error",
        });
        continue;
      }
      rowsInBlock.set(row.id, row);
    }
    for (const id of order) {
      const fragment = rowsInBlock.get(id)?.sequence ?? "";
      fragmentsById
        .get(id)
        ?.push(normalizeMsfSequence(fragment).padEnd(blockWidth, "-"));
    }
  }

  const rows = order.map((id) => ({
    alignedSequence: fragmentsById.get(id)?.join("") ?? "",
    id,
    label: id,
  }));
  const observedColumns = rows[0]?.alignedSequence.length ?? 0;
  if (!Number.isInteger(expectedColumns) || expectedColumns <= 0) {
    warnings.push({
      code: "msf-width-missing",
      message: "The MSF header does not declare a valid alignment width.",
      severity: "error",
    });
  } else if (observedColumns !== expectedColumns) {
    warnings.push({
      code: "msf-width-mismatch",
      message: `The MSF header declares ${expectedColumns} columns, but the alignment data contains ${observedColumns}. The observed alignment width was used.`,
    });
  }

  for (const declared of declaredRows) {
    const row = rows.find(({ id }) => id === declared.id);
    if (row == null || !dataIds.has(declared.id)) {
      warnings.push({
        code: "msf-declared-row-missing",
        message: `MSF declaration for ${declared.id} has no sequence data.`,
        severity: "error",
      });
      continue;
    }
    const ungappedLength = Array.from(row.alignedSequence).filter(
      (symbol) => symbol !== "-",
    ).length;
    if (
      declared.length !== row.alignedSequence.length &&
      declared.length !== ungappedLength
    ) {
      warnings.push({
        code: "msf-declared-row-mismatch",
        message: `MSF declaration for ${declared.id} says Len ${declared.length}, but its aligned and ungapped lengths are ${row.alignedSequence.length} and ${ungappedLength}.`,
      });
    }
  }

  return {
    format: "msf",
    metadata: {
      columns: String(observedColumns),
      declaredColumns:
        Number.isInteger(expectedColumns) && expectedColumns > 0
          ? String(expectedColumns)
          : "unknown",
    },
    rows,
    warnings,
  };
}

function parseDeclaredRows(
  lines: Array<string>,
  warnings: Array<MsaParseWarning>,
): Array<MsfDeclaredRow> {
  const rows: Array<MsfDeclaredRow> = [];
  const seen = new Set<string>();
  lines.forEach((line, index) => {
    const match = /^\s*Name:\s*(\S+)\s+Len:\s*(\d+)/i.exec(line);
    if (match == null) return;
    const id = match[1] ?? "";
    if (seen.has(id)) {
      warnings.push({
        code: "msf-duplicate-declaration",
        line: index + 1,
        message: `MSF row ${id} is declared more than once.`,
        severity: "error",
      });
      return;
    }
    seen.add(id);
    rows.push({ id, length: Number(match[2]) });
  });
  return rows;
}

function parseBlocks(
  lines: Array<string>,
  firstLineNumber: number,
  warnings: Array<MsaParseWarning>,
): Array<Array<MsfBlockRow>> {
  const blocks: Array<Array<MsfBlockRow>> = [];
  let block: Array<MsfBlockRow> = [];
  const flushBlock = (): void => {
    if (block.length > 0) blocks.push(block);
    block = [];
  };

  lines.forEach((line, index) => {
    const trimmed = line.trim();
    if (trimmed.length === 0) {
      flushBlock();
      return;
    }
    if (trimmed.startsWith("#") || /^\d+(?:\s+\d+)*$/.test(trimmed)) {
      return;
    }
    const fields = trimmed.split(/\s+/);
    const id = fields[0];
    const sequence = fields
      .slice(1)
      .filter((field) => /^[A-Za-z*?._~-]+$/.test(field))
      .join("");
    if (id == null || sequence.length === 0) {
      warnings.push({
        code: "msf-row-skipped",
        line: firstLineNumber + index,
        message: "Skipped a malformed MSF alignment row.",
      });
      return;
    }
    block.push({ id, line: firstLineNumber + index, sequence });
  });
  flushBlock();
  return blocks;
}

function normalizeMsfSequence(sequence: string): string {
  return Array.from(sequence)
    .map((symbol) => normalizeGapSymbol(symbol))
    .join("");
}

function unique(values: Array<string>): Array<string> {
  const seen = new Set<string>();
  return values.filter((value) => {
    if (seen.has(value)) return false;
    seen.add(value);
    return true;
  });
}

SHA-256: ecbc3c76dbf277b08b74d92cb834f70baa8d6d084e9f4c2ea32d64322fb845fc