diff --git a/spec/bridge/README.md b/spec/bridge/README.md index cad288ba..964fc225 100644 --- a/spec/bridge/README.md +++ b/spec/bridge/README.md @@ -149,6 +149,7 @@ be handed it, so nothing is skipped for being inconvenient, and the count is vis | utility | what makes it worth porting | | ------------- | ----------------------------------------------------------------- | | `isValidCnpj` | regular expressions, check digits, JavaScript's own type coercion | +| `formatCnpj` | a shared mask helper, and three options that interact | ## Adding a utility diff --git a/spec/bridge/conformance/cases/format-cnpj.ts b/spec/bridge/conformance/cases/format-cnpj.ts new file mode 100644 index 00000000..6363e141 --- /dev/null +++ b/spec/bridge/conformance/cases/format-cnpj.ts @@ -0,0 +1,28 @@ +/** + * What `formatCnpj` is replayed with: the whole CNPJ corpus, under every option combination. + */ +import { type Recorder } from "../cases.ts"; +import { cnpjCorpus } from "./_cnpj-corpus.ts"; + +const OPTIONS = [ + undefined, + { version: 2 }, + { pad: true }, + { obfuscate: true }, + { pad: true, version: 2 }, + { obfuscate: true, version: 2 }, + { obfuscate: true, pad: true }, +]; + +export const recorder: Recorder = { + module: "format-cnpj", + inputs: (shipped) => { + const corpus = cnpjCorpus(shipped["generateCnpj"] as (version?: 1 | 2) => string); + const found: unknown[][] = []; + + for (const input of corpus) + for (const options of OPTIONS) found.push(options === undefined ? [input] : [input, options]); + + return found; + }, +}; diff --git a/spec/bridge/source/_internals/mask.ts b/spec/bridge/source/_internals/mask.ts new file mode 100644 index 00000000..092e94f1 --- /dev/null +++ b/spec/bridge/source/_internals/mask.ts @@ -0,0 +1,56 @@ +/** + * Laying a value over a mask, which is what every `format*` utility does. + * + * The mask is a string: `0` is a slot the value fills, `*` is a slot the value fills and the + * mask hides, and anything else is a separator, written only while there is still value left + * to write. + */ + +/** + * Lays a value over a pattern. + * + * @param {string} value - The sanitized value. + * @param {string} pattern - The mask. + * @param {boolean} pad - Whether to left pad the value with zeros up to the number of slots. + * @returns {string} The masked value, cut short where the value runs out. + */ +export const layout = (value: string, pattern: string, pad: boolean): string => { + let slots = 0; + + for (let index = 0; index < pattern.length; index++) { + if (pattern.charCodeAt(index) === 48 || pattern.charCodeAt(index) === 42) { + slots += 1; + } + } + + let padded = value; + + if (pad) { + padded = value.padStart(slots, "0"); + } + + let formatted = ""; + let cursor = 0; + + for (let index = 0; index < pattern.length; index++) { + const slot = pattern.charCodeAt(index); + + if (slot === 48 || slot === 42) { + if (cursor >= padded.length) { + return formatted; + } + + if (slot === 42) { + formatted += "*"; + } else { + formatted += padded.slice(cursor, cursor + 1); + } + + cursor += 1; + } else if (cursor < padded.length) { + formatted += pattern.slice(index, index + 1); + } + } + + return formatted; +}; diff --git a/spec/bridge/source/format-cnpj.ts b/spec/bridge/source/format-cnpj.ts new file mode 100644 index 00000000..104ee34a --- /dev/null +++ b/spec/bridge/source/format-cnpj.ts @@ -0,0 +1,63 @@ +/** + * `formatCnpj`, written once. + * + * The boundary guards are the handwritten package's, not a tidier version of them: a nullish + * value answers the empty string, and a value the mask outruns is cut short rather than + * padded, because that is what the JavaScript this repository ships already does. + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj + * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico + */ +import { NON_ALPHANUMERIC, NON_DIGIT } from "./_internals/cnpj.ts"; +import { layout } from "./_internals/mask.ts"; +import { asString, isTruthy } from "./_std.ts"; + +/** Options of `formatCnpj`. */ +export type FormatCnpjOptions = { + /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ + pad?: boolean; + /** Which CNPJ format to read: `1` numeric only, `2` alphanumeric (default: `1`). */ + version?: 1 | 2; + /** Whether to hide the first 2 digits and the 2 check digits with `*` (default: `false`). */ + obfuscate?: boolean; +}; + +const PATTERN = "00.000.000/0000-00"; +const OBFUSCATED_PATTERN = "**.000.000/0000-**"; + +/** + * Formats a given CNPJ (Cadastro Nacional da Pessoa Jurídica) value. + * + * @param {string} value - The CNPJ value to be formatted. + * @param {FormatCnpjOptions} [options] - Optional configuration for formatting the CNPJ. + * @returns {string} The formatted CNPJ string in the pattern "00.000.000/0000-00". + * + * @example + * ```typescript + * formatCnpj("12345678000195"); // "12.345.678/0001-95" + * formatCnpj("12345678", { pad: true }); // "00.000.012/3456-78" + * formatCnpj("q0SLFMBD7VX439", { version: 2 }); // "Q0.SLF.MBD/7VX4-39" + * formatCnpj("12345678000195", { obfuscate: true }); // "**.345.678/0001-**" + * ``` + */ +export const formatCnpj = (value: string | number, options?: FormatCnpjOptions): string => { + if (value === null || value === undefined) { + return ""; + } + + const text = asString(value); + + let cleaned = text.replaceAll(NON_DIGIT, ""); + + if (options?.version === 2) { + cleaned = text.replaceAll(NON_ALPHANUMERIC, "").toUpperCase(); + } + + let pattern = PATTERN; + + if (isTruthy(options?.obfuscate)) { + pattern = OBFUSCATED_PATTERN; + } + + return layout(cleaned, pattern, isTruthy(options?.pad)); +};