Functional Weave
Code in Rust

validation.uk-modulus-table@1.0.0

impl/typescript.ts

4,104 bytes · the TypeScript implementation · view raw

import {
  type UkModulusAlgorithm,
  type UkModulusRow,
  type UkModulusTable,
  type UkSortCodeSubstitution,
} from "./validation_uk_modulus_table_types.ts";

const SORT_CODE = /^[0-9]{6}$/;
const WEIGHT = /^-?[0-9]{1,4}$/;
const EXCEPTION = /^[0-9]{1,2}$/;
const ALGORITHMS: readonly string[] = ["MOD10", "MOD11", "DBLAL"];

/**
 * The fields of each non-blank line. Only ASCII spaces and tabs separate
 * fields, and a trailing carriage return is dropped, so a file saved with
 * Windows line endings reads the same and every language splits alike.
 */
function lines(text: string): { number: number; fields: string[] }[] {
  const out: { number: number; fields: string[] }[] = [];
  const body = text.startsWith("") ? text.slice(1) : text;
  body.split("\n").forEach((raw, i) => {
    const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
    const fields = line.split(/[ \t]+/).filter((f) => f !== "");
    if (fields.length > 0) out.push({ number: i + 1, fields });
  });
  return out;
}

function sortCode(file: string, line: number, value: string): string {
  if (!SORT_CODE.test(value)) throw new RangeError(`${file} line ${line}: "${value}" is not a six-digit sort code`);
  return value;
}

function parseRow(line: number, fields: string[]): UkModulusRow {
  const where = `VALACDOS.txt line ${line}`;
  if (fields.length !== 17 && fields.length !== 18) {
    throw new RangeError(
      `${where}: expected 17 or 18 fields (start, end, algorithm, 14 weights, optional exception), found ${fields.length}`,
    );
  }
  const start = sortCode("VALACDOS.txt", line, fields[0]);
  const end = sortCode("VALACDOS.txt", line, fields[1]);
  if (end < start) throw new RangeError(`${where}: range ${start} to ${end} ends before it starts`);
  const algorithm = fields[2];
  if (!ALGORITHMS.includes(algorithm)) {
    throw new RangeError(`${where}: unknown algorithm "${algorithm}"; expected MOD10, MOD11 or DBLAL`);
  }
  const weights = fields.slice(3, 17).map((w) => {
    if (!WEIGHT.test(w)) throw new RangeError(`${where}: weight "${w}" is not a whole number`);
    const n = Number(w);
    return n === 0 ? 0 : n; // "-0" is 0, not JavaScript's negative zero
  });
  // A digit sum of a negative product means different things in different
  // languages' remainder rules; the specification never needs one.
  if (algorithm === "DBLAL" && weights.some((w) => w < 0)) {
    throw new RangeError(`${where}: a DBLAL row cannot have a negative weight`);
  }
  let exception: number | null = null;
  if (fields.length === 18) {
    const e = fields[17];
    if (!EXCEPTION.test(e) || Number(e) < 1 || Number(e) > 14) {
      throw new RangeError(`${where}: exception "${e}" is not a number from 1 to 14`);
    }
    exception = Number(e);
  }
  return { start, end, algorithm: algorithm as UkModulusAlgorithm, weights, exception };
}

function parseSubstitution(line: number, fields: string[]): UkSortCodeSubstitution {
  if (fields.length !== 2) {
    throw new RangeError(`SCSUBTAB.txt line ${line}: expected two sort codes, found ${fields.length} fields`);
  }
  return {
    original: sortCode("SCSUBTAB.txt", line, fields[0]),
    substitute: sortCode("SCSUBTAB.txt", line, fields[1]),
  };
}

/**
 * Parse the text of Vocalink's VALACDOS.txt (the modulus weight table) and
 * SCSUBTAB.txt (exception 5's sort code substitutions) into the table
 * `validateUkSortCodeAccount` checks against.
 *
 * Rows keep their file order, which matters: where a sort code falls in two
 * rows, the first is the first check. Blank lines are skipped; anything else
 * malformed is an error naming the file and the line, because a silently
 * dropped row turns a checkable sort code into an unchecked one.
 */
export function parseUkModulusTable(valacdos: string, scsubtab: string): UkModulusTable {
  const rows = lines(valacdos).map(({ number, fields }) => parseRow(number, fields));
  if (rows.length === 0) throw new RangeError("VALACDOS.txt has no rows");
  const substitutions = lines(scsubtab).map(({ number, fields }) => parseSubstitution(number, fields));
  return { rows, substitutions };
}