Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bbb085d
perf(holidays): read holiday days from the memo instead of rebuilding…
hyanmandian Sep 29, 2026
e985eb4
perf(lookups): index the lookup tables lazily instead of scanning the…
hyanmandian Sep 29, 2026
a06da80
perf(capitalize): cache the default word sets
hyanmandian Sep 29, 2026
781914c
perf(pix): checksum with a lookup table and parse TLV without regexes
hyanmandian Sep 29, 2026
2b0d22b
perf(cpf): read the states of a fiscal region from a cache
hyanmandian Sep 29, 2026
8d174bd
perf(business-days): reuse one Date per walk in eachLocalDay
hyanmandian Sep 29, 2026
d3edbf7
perf(cities): build the cached list of every city with toSorted
hyanmandian Sep 29, 2026
966aae6
refactor(business-days): move the walk and holiday memo helpers out o…
hyanmandian Sep 29, 2026
ee6f635
docs(each-local-day): describe the scratch date of eachLocalDay
hyanmandian Sep 29, 2026
5cb51f6
refactor(is-valid-nfe-key): drop the upper casing sanitizeToAlphanume…
hyanmandian Sep 29, 2026
fb793a1
refactor(municipalities): build the sorted list of every municipality…
hyanmandian Sep 29, 2026
12b1d58
refactor(cid10): move the category offsets of getCid10 into their own…
hyanmandian Sep 29, 2026
200ebe9
test(get-cpf-info): check a changed result leaves the cached states a…
hyanmandian Sep 29, 2026
780c339
test(mutation): kill the surviving mutants of the hot path code
hyanmandian Sep 29, 2026
10d0724
test(mutation): read the lookup caches without directives that hid ki…
hyanmandian Sep 29, 2026
2b562e0
docs(bundle): update the size figures for the perf changes
hyanmandian Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,18 @@ A few utils embed an official dataset and weigh far more than everything else co

| Util | Dataset | Minified | Gzipped |
| --- | --- | --- | --- |
| `getCid10` | CID-10 V2008 categories and subcategories plus the SIM `U07` codes, with the DATASUS descriptions | 721.5 KB | 113.5 KB |
| `getCid10` | CID-10 V2008 categories and subcategories plus the SIM `U07` codes, with the DATASUS descriptions | 721.6 KB | 113.5 KB |
| `getCest` | CEST descriptions and segments (Convênio ICMS 142/18) | 102.3 KB | 24.1 KB |
| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 IBGE municipalities, with the DDD of each (Anatel) | 23.6 - 90.3 KB | 5.6 - 33.0 KB |
| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 IBGE municipalities, with the DDD of each (Anatel) | 23.6 - 90.4 KB | 5.6 - 33.0 KB |
| `getCbo` | CBO 2002 occupation titles | 80.6 KB | 24.1 KB |
| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 76.0 - 76.9 KB | 29.4 - 29.9 KB |
| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 76.3 - 77.2 KB | 29.5 - 30.1 KB |
| `getCnae` | CNAE-Subclasses 2.3 | 66.8 KB | 17.7 KB |
| `getCities` | 5571 IBGE municipality names | 64.2 KB | 26.4 KB |
| `getCfop` | CFOP operation descriptions | 56.9 KB | 5.1 KB |
| `getNbs` | NBS 2.0 (Nomenclatura Brasileira de Serviços) descriptions | 51.1 KB | 11.3 KB |
| `getClassTrib` | cClassTrib (IBS/CBS) names and descriptions | 50.0 KB | 9.0 KB |
| `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 29.6 KB | 9.0 KB |
| `getBanks` · `getBankByCode` · `getBankByIspb` | Banco Central STR participants (COMPE + ISPB) | 26.2 - 26.5 KB | 7.6 - 7.8 KB |
| `getBanks` · `getBankByCode` · `getBankByIspb` | Banco Central STR participants (COMPE + ISPB) | 26.2 - 26.6 KB | 7.6 - 7.9 KB |
| `getServiceItem` | Service list of the Lei Complementar 116/2003 | 25.5 KB | 8.2 KB |
| `getIsbnInfo` · `formatIsbn` | ISBN ranges of the International ISBN Agency (RangeMessage) | 15.1 - 15.3 KB | 5.4 - 5.5 KB |
| `isValidCbo` | CBO 2002 occupation codes, without the titles | 6.7 KB | 1.7 KB |
Expand Down
8 changes: 4 additions & 4 deletions docs/pt-br/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,18 @@ Alguns utilitários embutem uma base de dados oficial e pesam muito mais que tod

| Utilitário | Base de dados | Minificado | Gzip |
| --- | --- | --- | --- |
| `getCid10` | categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, com as descrições do DATASUS | 721,5 KB | 113,5 KB |
| `getCid10` | categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, com as descrições do DATASUS | 721,6 KB | 113,5 KB |
| `getCest` | descrições e segmentos do CEST (Convênio ICMS 142/18) | 102,3 KB | 24,1 KB |
| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 municípios do IBGE, com o DDD de cada um (Anatel) | 23,6 - 90,3 KB | 5,6 - 33,0 KB |
| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 municípios do IBGE, com o DDD de cada um (Anatel) | 23,6 - 90,4 KB | 5,6 - 33,0 KB |
| `getCbo` | títulos das ocupações da CBO 2002 | 80,6 KB | 24,1 KB |
| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 76,0 - 76,9 KB | 29,4 - 29,9 KB |
| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 76,3 - 77,2 KB | 29,5 - 30,1 KB |
| `getCnae` | CNAE-Subclasses 2.3 | 66,8 KB | 17,7 KB |
| `getCities` | nomes dos 5571 municípios do IBGE | 64,2 KB | 26,4 KB |
| `getCfop` | descrições das operações do CFOP | 56,9 KB | 5,1 KB |
| `getNbs` | descrições da NBS 2.0 (Nomenclatura Brasileira de Serviços) | 51,1 KB | 11,3 KB |
| `getClassTrib` | nomes e descrições do cClassTrib (IBS/CBS) | 50,0 KB | 9,0 KB |
| `isValidNcm` | códigos NCM (Nomenclatura Comum do Mercosul) | 29,6 KB | 9,0 KB |
| `getBanks` · `getBankByCode` · `getBankByIspb` | participantes do STR do Banco Central (COMPE + ISPB) | 26,2 - 26,5 KB | 7,6 - 7,8 KB |
| `getBanks` · `getBankByCode` · `getBankByIspb` | participantes do STR do Banco Central (COMPE + ISPB) | 26,2 - 26,6 KB | 7,6 - 7,9 KB |
| `getServiceItem` | lista de serviços da Lei Complementar 116/2003 | 25,5 KB | 8,2 KB |
| `getIsbnInfo` · `formatIsbn` | faixas do ISBN da Agência Internacional do ISBN (RangeMessage) | 15,1 - 15,3 KB | 5,4 - 5,5 KB |
| `isValidCbo` | códigos das ocupações da CBO 2002, sem os títulos | 6,7 KB | 1,7 KB |
Expand Down
26 changes: 26 additions & 0 deletions src/_internals/crc16-ccitt/crc16-ccitt.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { describe, expect, test } from "../test/runtime";
import { crc16Ccitt } from "./crc16-ccitt";

const reference = (value: string): string => {
let crc = 0xff_ff;

for (const byte of new TextEncoder().encode(value)) {
crc ^= byte << 8;

for (let bit = 0; bit < 8; bit++) {
crc = (crc & 0x80_00) === 0 ? (crc << 1) & 0xff_ff : ((crc << 1) ^ 0x10_21) & 0xff_ff;
}
}

return crc.toString(16).toUpperCase().padStart(4, "0");
};

describe("crc16Ccitt", () => {
test("should match the CRC-16/CCITT-FALSE check value", () => {
expect(crc16Ccitt("123456789")).toBe("29B1");
});

test("should checksum the UTF-8 bytes of a character from U+0080 up, not its code", () => {
expect(crc16Ccitt("\u0080")).toBe("FCB1");
expect(crc16Ccitt("a\u0080b")).toBe("B44D");
expect(crc16Ccitt("\u007F")).toBe("6E88");
});

test("should return the initial value for an empty string", () => {
expect(crc16Ccitt("")).toBe("FFFF");
});
Expand Down Expand Up @@ -40,6 +60,12 @@ describe("crc16Ccitt", () => {
).toBe("AD38");
});

test("should checksum the UTF-8 bytes of a value with non-ASCII characters", () => {
for (const value of ["é", "abc é", "日本語", "a😀b", "\uD800", "São Paulo"]) {
expect(crc16Ccitt(value)).toBe(reference(value));
}
});

test("should change when the payload changes", () => {
expect(crc16Ccitt("A")).not.toBe(crc16Ccitt("B"));
});
Expand Down
47 changes: 40 additions & 7 deletions src/_internals/crc16-ccitt/crc16-ccitt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ const MASK = 0xff_ff;

const HEX_LENGTH = 4;

const BYTE_VALUES = 256;

const ASCII_LIMIT = 0x80;

let table: Uint16Array | undefined;

const buildTable = (): Uint16Array =>
Uint16Array.from({ length: BYTE_VALUES }, (_, byte) => {
let crc = byte << 8;

for (let bit = 0; bit < 8; bit++) {
crc = (crc & 0x80_00) === 0 ? (crc << 1) & MASK : ((crc << 1) ^ POLYNOMIAL) & MASK;
}

return crc;
});

const update = (crc: number, byte: number, entries: Uint16Array): number =>
((crc << 8) & MASK) ^ entries[(crc >> 8) ^ byte];

const toHex = (crc: number): string => crc.toString(16).toUpperCase().padStart(HEX_LENGTH, "0");

const checksumUtf8 = (value: string, entries: Uint16Array): string => {
let crc = INITIAL_VALUE;

for (const byte of new TextEncoder().encode(value)) {
crc = update(crc, byte, entries);
}

return toHex(crc);
};

/**
* Calculates the CRC-16/CCITT-FALSE checksum of a string and returns it as four uppercase
* hexadecimal digits.
Expand All @@ -24,17 +56,18 @@ const HEX_LENGTH = 4;
* ```
*/
export const crc16Ccitt = (value: string): string => {
const bytes = new TextEncoder().encode(value);
table ??= buildTable();

let crc = INITIAL_VALUE;

for (const byte of bytes) {
crc ^= byte << 8;
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);

for (let bit = 0; bit < 8; bit++) {
crc = (crc & 0x80_00) === 0 ? (crc << 1) & MASK : ((crc << 1) ^ POLYNOMIAL) & MASK;
}
// Stryker disable next-line ConditionalExpression: leaving for the UTF-8 pass at the first character gives the checksum the ASCII pass gives for a string of ASCII characters.
if (code >= ASCII_LIMIT) return checksumUtf8(value, table);

crc = update(crc, code, table);
}

return crc.toString(16).toUpperCase().padStart(HEX_LENGTH, "0");
return toHex(crc);
};
18 changes: 11 additions & 7 deletions src/_internals/each-local-day/each-local-day.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ const NOON = 12;
*
* Both ends are local calendar days written as the number `Date.UTC(year, month, day)` returns
* for them, so the walk is plain integer arithmetic on days: it always advances, it always stops
* after `Math.abs(until - from)` days, and no `Date` is ever mutated. That is what makes it safe
* in every time zone. A walk driven by `date.setDate(date.getDate() + 1)` is not: when the
* neighbouring local day does not exist (`Pacific/Apia` and `Pacific/Fakaofo` skipped 30 December 2011,
* `Pacific/Kiritimati` and `Pacific/Enderbury` 31 December 1994, `Pacific/Kwajalein` 21 August 1993, all of them crossing
* the date line) the runtime re-normalizes onto the same local day, the walk stops advancing and
* the loop never ends.
* after `Math.abs(until - from)` days, and no `Date` a caller sees is ever mutated. The walk
* reuses one private scratch `Date`, read only through its UTC getters, and hands out a new
* `Date` for each day. That is what makes it safe in every time zone. A walk driven by
* `date.setDate(date.getDate() + 1)` is not: when the neighbouring local day does not exist
* (`Pacific/Apia` and `Pacific/Fakaofo` skipped 30 December 2011, `Pacific/Kiritimati` and
* `Pacific/Enderbury` 31 December 1994, `Pacific/Kwajalein` 21 August 1993, all of them
* crossing the date line) the runtime re-normalizes onto the same local day, the walk stops
* advancing and the loop never ends.
*
* Each day is yielded at **noon**, not at midnight, because noon is a time of day every existing
* local calendar day has: a transition that moves the clock forward (Brazilian summer time always
Expand Down Expand Up @@ -50,8 +52,10 @@ export function* eachLocalDay({ from, until }: EachLocalDayParams): Generator<Da
const step = Math.sign(until - from) * DAY_IN_MS;
const length = Math.abs(until - from) / DAY_IN_MS;

const target = new Date(from);

for (let index = 0; index < length; index += 1) {
const target = new Date(from + index * step);
target.setTime(from + index * step);
const candidate = new Date(
target.getUTCFullYear(),
target.getUTCMonth(),
Expand Down
26 changes: 26 additions & 0 deletions src/_internals/find-bank/find-bank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readBanks } from "../read-banks/read-banks";
import { describe, expect, test } from "../test/runtime";
import { findBank } from "./find-bank";

describe("findBank", () => {
test("should find every bank of the table by its code and by its ISPB", () => {
for (const bank of readBanks()) {
expect(findBank("code", bank.code)).toBe(
readBanks().find((entry) => entry.code === bank.code),
);
expect(findBank("ispb", bank.ispb)).toBe(
readBanks().find((entry) => entry.ispb === bank.ispb),
);
}
});

test("should return undefined for a value no bank has", () => {
expect(findBank("code", "000")).toBeUndefined();
expect(findBank("ispb", "abc")).toBeUndefined();
});

test("should not match a prototype chain key", () => {
expect(findBank("code", "constructor")).toBeUndefined();
expect(findBank("ispb", "__proto__")).toBeUndefined();
});
});
31 changes: 31 additions & 0 deletions src/_internals/find-bank/find-bank.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type Bank } from "../constants/banks";
import { readBanks } from "../read-banks/read-banks";

type BankKey = "code" | "ispb";

const indexes: Partial<Record<BankKey, Map<string, Bank>>> = {};

const buildBankIndex = (key: BankKey): Map<string, Bank> => {
const index = new Map<string, Bank>();
const banks = readBanks();

for (let position = banks.length - 1; position >= 0; position--) {
index.set(banks[position][key], banks[position]);
}

return index;
};

/**
* Finds the first bank whose `code` or `ispb` equals a value, through an index built on the
* first lookup of each key.
*
* @param {BankKey} key - The bank field to match: `"code"` or `"ispb"`.
* @param {string} value - The zero-padded value to look for.
* @returns {Bank|undefined} The bank of the table itself, not a copy, or `undefined` when none matches.
*/
export const findBank = (key: BankKey, value: string): Bank | undefined => {
indexes[key] ??= buildBankIndex(key);

return indexes[key].get(value);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "../test/runtime";
import { isBusinessDayUnderRules } from "./is-business-day-under-rules";

const WEEKDAYS = { stateCode: undefined, includeOptional: true, includeSaturday: false };

describe("isBusinessDayUnderRules", () => {
test("should accept an ordinary weekday", () => {
expect(isBusinessDayUnderRules(new Date(2024, 0, 2), WEEKDAYS)).toBe(true);
});

test("should reject a national holiday", () => {
expect(isBusinessDayUnderRules(new Date(2024, 0, 1), WEEKDAYS)).toBe(false);
});

test("should reject Sunday, and Saturday unless it is included", () => {
expect(
isBusinessDayUnderRules(new Date(2024, 0, 7), { ...WEEKDAYS, includeSaturday: true }),
).toBe(false);
expect(isBusinessDayUnderRules(new Date(2024, 0, 6), WEEKDAYS)).toBe(false);
expect(
isBusinessDayUnderRules(new Date(2024, 0, 6), { ...WEEKDAYS, includeSaturday: true }),
).toBe(true);
});

test("should keep a holiday that falls on a Saturday a non-business day", () => {
expect(
isBusinessDayUnderRules(new Date(2024, 8, 7), { ...WEEKDAYS, includeSaturday: true }),
).toBe(false);
});

test("should count optional holidays only when asked to", () => {
expect(isBusinessDayUnderRules(new Date(2024, 1, 12), WEEKDAYS)).toBe(false);
expect(
isBusinessDayUnderRules(new Date(2024, 1, 12), { ...WEEKDAYS, includeOptional: false }),
).toBe(true);
});

test("should add the holidays of the state read", () => {
expect(isBusinessDayUnderRules(new Date(2024, 6, 9), { ...WEEKDAYS, stateCode: "SP" })).toBe(
false,
);
expect(isBusinessDayUnderRules(new Date(2024, 6, 9), WEEKDAYS)).toBe(true);
});

test("should reject a year outside the supported range", () => {
expect(isBusinessDayUnderRules(new Date(2100, 0, 4), WEEKDAYS)).toBe(false);
expect(isBusinessDayUnderRules(new Date(1899, 0, 3), WEEKDAYS)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { isSupportedHolidayYear } from "../is-supported-holiday-year/is-supported-holiday-year";
import { type BusinessDayRules } from "../read-business-day-rules/read-business-day-rules";
import { readMemoizedHolidays } from "../read-memoized-holidays/read-memoized-holidays";

const SUNDAY = 0;
const SATURDAY = 6;

/**
* Tells whether a valid date is a business day under rules already read.
*
* @param {Date} value - A valid date.
* @param {BusinessDayRules} rules - The rules read from the options.
* @returns {boolean} `true` when the date is a business day.
*/
export const isBusinessDayUnderRules = (value: Date, rules: BusinessDayRules): boolean => {
const year = value.getFullYear();

if (!isSupportedHolidayYear(year)) return false;

const day = value.getDay();

if (day === SUNDAY) return false;

if (day === SATURDAY && !rules.includeSaturday) return false;

const month = value.getMonth();
const date = value.getDate();
const { includeOptional } = rules;

return !readMemoizedHolidays(year, rules.stateCode).some((holiday) => {
if (!includeOptional && holiday.type === "optional") return false;

return holiday.month === month && holiday.day === date;
});
};
9 changes: 9 additions & 0 deletions src/_internals/parse-tlv/parse-tlv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ describe("parseTlv", () => {
expect(parseTlv("0A0201")).toBeNull();
});

test("when a character of an id is just outside the range of the digits", () => {
expect(parseTlv("/10201")).toBeNull();
expect(parseTlv(":10201")).toBeNull();
expect(parseTlv("1/0201")).toBeNull();
expect(parseTlv("1:0201")).toBeNull();
expect(parseTlv("A10201")).toBeNull();
expect(parseTlv("1A0201")).toBeNull();
});

test("when a length is not made of two digits", () => {
expect(parseTlv("00A201")).toBeNull();
});
Expand Down
20 changes: 12 additions & 8 deletions src/_internals/parse-tlv/parse-tlv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ export type TlvFields = Record<string, string | undefined>;

const SEGMENT_LENGTH = 2;

// Stryker disable next-line Regex: id and length are always sliced to at most SEGMENT_LENGTH (2) characters, so dropping either anchor cannot change whether this matches
const SEGMENT_REGEX = /^\d{2}$/;
const ZERO_CODE = 48;

const EMPTY_LENGTH = "00";
const readTwoDigits = (value: string, index: number): number => {
const tens = value.charCodeAt(index) - ZERO_CODE;
const units = value.charCodeAt(index + 1) - ZERO_CODE;

return tens >= 0 && tens <= 9 && units >= 0 && units <= 9 ? tens * 10 + units : -1;
};

/**
* Parses an EMV® style TLV (tag-length-value) string into its objects.
Expand Down Expand Up @@ -40,16 +44,16 @@ export const parseTlv = (value: string): TlvFields | null => {
let index = 0;

while (index < value.length) {
const id = value.slice(index, index + SEGMENT_LENGTH);
const length = value.slice(index + SEGMENT_LENGTH, index + SEGMENT_LENGTH * 2);
const length = readTwoDigits(value, index + SEGMENT_LENGTH);

if (!SEGMENT_REGEX.test(id) || !SEGMENT_REGEX.test(length) || length === EMPTY_LENGTH)
return null;
if (readTwoDigits(value, index) === -1 || length <= 0) return null;

const id = value.slice(index, index + SEGMENT_LENGTH);

if (Object.hasOwn(fields, id)) return null;

const start = index + SEGMENT_LENGTH * 2;
const end = start + Number(length);
const end = start + length;

if (end > value.length) return null;

Expand Down
Loading
Loading