@amritk/helpers
Advanced tools
| /** | ||
| * Emits `text` as a double-quoted JS string literal for generated code. | ||
| * | ||
| * This is the one escape-or-quote decision for schema-controlled text | ||
| * (property names, patterns, enum values) embedded in generated source — a key | ||
| * like `it's` or a pattern with a quote or newline must never break out of the | ||
| * literal or inject code, so anything carrying such a character goes through | ||
| * `JSON.stringify`. The common all-plain string skips the full escaper: code | ||
| * generators emit one literal per assertion/message, and the stringify calls | ||
| * were a measurable slice of generation time. Centralized so the next | ||
| * generator wanting the fast path cannot get the security-sensitive regex | ||
| * subtly wrong. | ||
| */ | ||
| export declare const quoteJsString: (text: string) => string; |
| // Characters that force the JSON.stringify escaping path below: quote, | ||
| // backslash, C0 controls, and the JS line separators. Anything else sits | ||
| // verbatim inside a double-quoted literal. | ||
| // biome-ignore lint/suspicious/noControlCharactersInRegex: the C0 range is exactly the set that must never appear raw in a generated string literal | ||
| const NEEDS_ESCAPING = /["\\\u0000-\u001f\u2028\u2029]/; | ||
| /** | ||
| * Emits `text` as a double-quoted JS string literal for generated code. | ||
| * | ||
| * This is the one escape-or-quote decision for schema-controlled text | ||
| * (property names, patterns, enum values) embedded in generated source — a key | ||
| * like `it's` or a pattern with a quote or newline must never break out of the | ||
| * literal or inject code, so anything carrying such a character goes through | ||
| * `JSON.stringify`. The common all-plain string skips the full escaper: code | ||
| * generators emit one literal per assertion/message, and the stringify calls | ||
| * were a measurable slice of generation time. Centralized so the next | ||
| * generator wanting the fast path cannot get the security-sensitive regex | ||
| * subtly wrong. | ||
| */ | ||
| export const quoteJsString = (text) => (NEEDS_ESCAPING.test(text) ? JSON.stringify(text) : `"${text}"`); |
| // Characters that force the JSON.stringify escaping path below: quote, | ||
| // backslash, C0 controls, and the JS line separators. Anything else sits | ||
| // verbatim inside a double-quoted literal. | ||
| // biome-ignore lint/suspicious/noControlCharactersInRegex: the C0 range is exactly the set that must never appear raw in a generated string literal | ||
| const NEEDS_ESCAPING = /["\\\u0000-\u001f\u2028\u2029]/ | ||
| /** | ||
| * Emits `text` as a double-quoted JS string literal for generated code. | ||
| * | ||
| * This is the one escape-or-quote decision for schema-controlled text | ||
| * (property names, patterns, enum values) embedded in generated source — a key | ||
| * like `it's` or a pattern with a quote or newline must never break out of the | ||
| * literal or inject code, so anything carrying such a character goes through | ||
| * `JSON.stringify`. The common all-plain string skips the full escaper: code | ||
| * generators emit one literal per assertion/message, and the stringify calls | ||
| * were a measurable slice of generation time. Centralized so the next | ||
| * generator wanting the fast path cannot get the security-sensitive regex | ||
| * subtly wrong. | ||
| */ | ||
| export const quoteJsString = (text: string): string => (NEEDS_ESCAPING.test(text) ? JSON.stringify(text) : `"${text}"`) |
@@ -1,26 +0,1 @@ | ||
| /** | ||
| * Escapes a JSON Schema `pattern` so it can be embedded between the slashes of | ||
| * a generated regex literal (`/…/`), and validates that the pattern is a legal | ||
| * regex at generation time. | ||
| * | ||
| * A `pattern` is an ECMA-262 regex *body*, and the generated text goes into a | ||
| * regex literal — not a string literal — so backslashes are regex syntax and | ||
| * must be left exactly as-is (doubling `\d` to `\\d` would change it from "a | ||
| * digit" to "a literal backslash followed by d"). Two things would otherwise | ||
| * corrupt the surrounding literal: | ||
| * - an *unescaped* `/`, which would close the literal early; and | ||
| * - a raw line terminator (LF, CR, U+2028, U+2029), which is not allowed | ||
| * inside a regex literal and would split the emitted `/…/` across lines (a | ||
| * syntax error). Each is rewritten to its backslash escape, which matches | ||
| * the identical character, so the regex's meaning is unchanged. | ||
| * | ||
| * Additionally, an *invalid* pattern (e.g. `([`) would be emitted verbatim as | ||
| * `/([/…` and produce output that does not parse. We compile the pattern with | ||
| * `new RegExp` here and throw a clear generator-time error instead, so a bad | ||
| * schema fails loudly during generation rather than emitting broken code. | ||
| * | ||
| * @example | ||
| * escapeRegexPattern('\\d{4}/\\d{2}') // → '\\d{4}\\/\\d{2}' (i.e. \d{4}\/\d{2}) | ||
| * @throws if `pattern` is not a valid regular expression. | ||
| */ | ||
| export declare const escapeRegexPattern: (pattern: string) => string; |
@@ -26,3 +26,20 @@ /** | ||
| */ | ||
| // Line terminators, keyed by code point, and the backslash escape each maps to. | ||
| // These are the four characters disallowed *raw* inside a regex literal. | ||
| const lineTerminatorEscapes = { | ||
| 10: '\\n', // LF | ||
| 13: '\\r', // CR | ||
| 8232: '\\u2028', // LINE SEPARATOR | ||
| 8233: '\\u2029', // PARAGRAPH SEPARATOR | ||
| }; | ||
| // Memoized results: the same schema pattern is escaped once per parser, per | ||
| // validator, and per assertion context, on every generation — and the dominant | ||
| // cost is the validating `new RegExp` compile. Schemas carry a bounded set of | ||
| // patterns; the cap only guards a pathological long-lived process. | ||
| const escapeCache = new Map(); | ||
| const ESCAPE_CACHE_LIMIT = 1000; | ||
| export const escapeRegexPattern = (pattern) => { | ||
| const cached = escapeCache.get(pattern); | ||
| if (cached !== undefined) | ||
| return cached; | ||
| // Validate at generation time — an invalid pattern must fail here, not emit | ||
@@ -36,10 +53,2 @@ // a `/([/` literal that breaks the generated file. | ||
| } | ||
| // Line terminators, keyed by code point, and the backslash escape each maps to. | ||
| // These are the four characters disallowed *raw* inside a regex literal. | ||
| const lineTerminatorEscapes = { | ||
| 10: '\\n', // LF | ||
| 13: '\\r', // CR | ||
| 8232: '\\u2028', // LINE SEPARATOR | ||
| 8233: '\\u2029', // PARAGRAPH SEPARATOR | ||
| }; | ||
| // Match either an escape sequence (`\` + any char, kept verbatim) or a single | ||
@@ -51,3 +60,3 @@ // character that would corrupt the literal (a bare `/` or a line terminator). | ||
| // double-escaped. | ||
| return pattern.replace(/\\[\s\S]|[/\n\r\u2028\u2029]/g, (match) => { | ||
| const escaped = pattern.replace(/\\[\s\S]|[/\n\r\u2028\u2029]/g, (match) => { | ||
| if (match === '/') | ||
@@ -61,2 +70,10 @@ return '\\/'; | ||
| }); | ||
| if (escapeCache.size >= ESCAPE_CACHE_LIMIT) { | ||
| // Evict the single oldest entry (Maps iterate in insertion order) instead | ||
| // of clearing wholesale — a corpus slightly over the limit keeps its hot | ||
| // set instead of collapsing to a 0% hit rate every pass. | ||
| escapeCache.delete(escapeCache.keys().next().value); | ||
| } | ||
| escapeCache.set(pattern, escaped); | ||
| return escaped; | ||
| }; |
@@ -14,2 +14,9 @@ /** A generated file: its name (with extension) and TypeScript source. */ | ||
| readonly typesOnly?: boolean; | ||
| /** | ||
| * Extension used on every relative re-export specifier. `'js'` (default) is | ||
| * the standard TS NodeNext form (`./x.js` resolving to a sibling `x.ts`); | ||
| * `'ts'` emits the literal on-disk path so the output runs directly under | ||
| * Node's type stripping. | ||
| */ | ||
| readonly importExt?: 'js' | 'ts'; | ||
| }; | ||
@@ -16,0 +23,0 @@ /** |
@@ -1,6 +0,56 @@ | ||
| // Generated files declare their public surface with these two forms, so we can | ||
| // recover the export names from the source text without parsing it. | ||
| const TYPE_EXPORT_RE = /^export type (\w+)/gm; | ||
| const CONST_EXPORT_RE = /^export const (\w+)/gm; | ||
| // Generated files declare their public surface with `export type <Name>` / | ||
| // `export const <Name>` at line starts, so the names can be recovered from the | ||
| // source text without parsing it. | ||
| /** Reads the identifier following `prefix` when `content` starts with it at `at`. */ | ||
| const exportNameAt = (content, at, prefix) => { | ||
| if (!content.startsWith(prefix, at)) | ||
| return null; | ||
| let end = at + prefix.length; | ||
| while (end < content.length) { | ||
| const code = content.charCodeAt(end); | ||
| const isWord = (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95; | ||
| if (!isWord) | ||
| break; | ||
| end++; | ||
| } | ||
| return end > at + prefix.length ? content.slice(at + prefix.length, end) : null; | ||
| }; | ||
| /** True for every JS LineTerminator code unit — the same set the old `/m` regexes anchored after. */ | ||
| const isLineTerminator = (code) => code === 10 || code === 13 || code === 8232 || code === 8233; | ||
| /** | ||
| * Collects `export type` / `export const` names with a single line-start walk. | ||
| * A multiline-anchored regex scan (`/^export .../gm`) does the same job but | ||
| * showed up at several percent of total generation time in CPU profiles — the | ||
| * regex engine re-anchors at every line of every generated file on every build. | ||
| */ | ||
| const collectExportNames = (content, typeNames, constNames) => { | ||
| let lineStart = 0; | ||
| while (lineStart < content.length) { | ||
| // charCode prefilter: almost every line starts with whitespace, a brace, or | ||
| // a keyword other than `export` — one integer compare skips the substring | ||
| // comparison for all of them (101 === 'e'). | ||
| if (content.charCodeAt(lineStart) === 101 && content.startsWith('export ', lineStart)) { | ||
| const typeName = exportNameAt(content, lineStart, 'export type '); | ||
| if (typeName !== null) { | ||
| typeNames.push(typeName); | ||
| } | ||
| else { | ||
| const constName = exportNameAt(content, lineStart, 'export const '); | ||
| if (constName !== null) | ||
| constNames.push(constName); | ||
| } | ||
| } | ||
| // Advance past the next line break of ANY JS flavor (LF, CR, CRLF, | ||
| // U+2028, U+2029) — matching the multiline regexes this walk replaced, | ||
| // which treated all of them as line starts. | ||
| let next = lineStart; | ||
| while (next < content.length && !isLineTerminator(content.charCodeAt(next))) | ||
| next++; | ||
| if (next >= content.length) | ||
| break; | ||
| // \r\n counts as one break. | ||
| lineStart = content.charCodeAt(next) === 13 && content.charCodeAt(next + 1) === 10 ? next + 2 : next + 1; | ||
| } | ||
| }; | ||
| /** | ||
| * Builds the `index.ts` barrel that re-exports every generated module. This is | ||
@@ -20,2 +70,3 @@ * the shared version of the near-identical barrel each generator used to build | ||
| const typesOnly = options.typesOnly ?? false; | ||
| const importExt = options.importExt ?? 'js'; | ||
| const sortedFiles = files | ||
@@ -29,15 +80,12 @@ .filter((file) => !file.filename.startsWith('_helpers/')) | ||
| const constNames = []; | ||
| for (const match of file.content.matchAll(TYPE_EXPORT_RE)) | ||
| typeNames.push(match[1]); | ||
| for (const match of file.content.matchAll(CONST_EXPORT_RE)) | ||
| constNames.push(match[1]); | ||
| collectExportNames(file.content, typeNames, constNames); | ||
| if (typeNames.length === 0 && constNames.length === 0) | ||
| continue; | ||
| // `.js` extension so the barrel resolves under Node ESM, not only Bun. | ||
| // An explicit extension so the barrel resolves under Node ESM, not only Bun. | ||
| if (typesOnly) { | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}.js';\n`; | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}.${importExt}';\n`; | ||
| } | ||
| else { | ||
| const typeExports = typeNames.map((name) => `type ${name}`); | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}.js';\n`; | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}.${importExt}';\n`; | ||
| } | ||
@@ -44,0 +92,0 @@ } |
@@ -1,2 +0,13 @@ | ||
| /** Parses the items of an array with a parser function */ | ||
| /** | ||
| * Parses the items of an array with a parser function. | ||
| * | ||
| * When every element parses to the very same reference — the parser found it | ||
| * already valid and handed it back, as the generated item sub-parsers do for | ||
| * clean values — the input array itself is returned and nothing is allocated. | ||
| * The result array is only materialized (lazily, on the first element the | ||
| * parser actually replaced) when something was coerced or stripped, so the | ||
| * common all-clean parse costs no allocation at all. Callers already share | ||
| * element references either way; sharing the container mirrors the generated | ||
| * fast paths, which return the input array by reference too. | ||
| */ | ||
| export declare const validateArray: (input: unknown, parser: (input: unknown) => unknown) => any[]; |
@@ -1,2 +0,13 @@ | ||
| /** Parses the items of an array with a parser function */ | ||
| /** | ||
| * Parses the items of an array with a parser function. | ||
| * | ||
| * When every element parses to the very same reference — the parser found it | ||
| * already valid and handed it back, as the generated item sub-parsers do for | ||
| * clean values — the input array itself is returned and nothing is allocated. | ||
| * The result array is only materialized (lazily, on the first element the | ||
| * parser actually replaced) when something was coerced or stripped, so the | ||
| * common all-clean parse costs no allocation at all. Callers already share | ||
| * element references either way; sharing the container mirrors the generated | ||
| * fast paths, which return the input array by reference too. | ||
| */ | ||
| export const validateArray = (input, parser) => { | ||
@@ -6,9 +17,18 @@ if (!Array.isArray(input)) { | ||
| } | ||
| // Pre-allocate the result array for better performance than push() | ||
| const len = input.length; | ||
| const result = new Array(len); | ||
| let result = null; | ||
| for (let i = 0; i < len; i++) { | ||
| result[i] = parser(input[i]); | ||
| const parsed = parser(input[i]); | ||
| if (result !== null) { | ||
| result[i] = parsed; | ||
| } | ||
| else if (parsed !== input[i]) { | ||
| // First replaced element: materialize the copy and backfill the prefix. | ||
| result = new Array(len); | ||
| for (let j = 0; j < i; j++) | ||
| result[j] = input[j]; | ||
| result[i] = parsed; | ||
| } | ||
| } | ||
| return result; | ||
| return result ?? input; | ||
| }; |
+1
-1
| { | ||
| "name": "@amritk/helpers", | ||
| "version": "0.10.3", | ||
| "version": "0.11.0", | ||
| "description": "Shared utilities for the mjst code generation ecosystem.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
@@ -26,3 +26,22 @@ /** | ||
| */ | ||
| // Line terminators, keyed by code point, and the backslash escape each maps to. | ||
| // These are the four characters disallowed *raw* inside a regex literal. | ||
| const lineTerminatorEscapes: Record<number, string> = { | ||
| 10: '\\n', // LF | ||
| 13: '\\r', // CR | ||
| 8232: '\\u2028', // LINE SEPARATOR | ||
| 8233: '\\u2029', // PARAGRAPH SEPARATOR | ||
| } | ||
| // Memoized results: the same schema pattern is escaped once per parser, per | ||
| // validator, and per assertion context, on every generation — and the dominant | ||
| // cost is the validating `new RegExp` compile. Schemas carry a bounded set of | ||
| // patterns; the cap only guards a pathological long-lived process. | ||
| const escapeCache = new Map<string, string>() | ||
| const ESCAPE_CACHE_LIMIT = 1000 | ||
| export const escapeRegexPattern = (pattern: string): string => { | ||
| const cached = escapeCache.get(pattern) | ||
| if (cached !== undefined) return cached | ||
| // Validate at generation time — an invalid pattern must fail here, not emit | ||
@@ -38,11 +57,2 @@ // a `/([/` literal that breaks the generated file. | ||
| // Line terminators, keyed by code point, and the backslash escape each maps to. | ||
| // These are the four characters disallowed *raw* inside a regex literal. | ||
| const lineTerminatorEscapes: Record<number, string> = { | ||
| 10: '\\n', // LF | ||
| 13: '\\r', // CR | ||
| 8232: '\\u2028', // LINE SEPARATOR | ||
| 8233: '\\u2029', // PARAGRAPH SEPARATOR | ||
| } | ||
| // Match either an escape sequence (`\` + any char, kept verbatim) or a single | ||
@@ -54,3 +64,3 @@ // character that would corrupt the literal (a bare `/` or a line terminator). | ||
| // double-escaped. | ||
| return pattern.replace(/\\[\s\S]|[/\n\r\u2028\u2029]/g, (match) => { | ||
| const escaped = pattern.replace(/\\[\s\S]|[/\n\r\u2028\u2029]/g, (match) => { | ||
| if (match === '/') return '\\/' | ||
@@ -62,2 +72,11 @@ const terminatorEscape = lineTerminatorEscapes[match.charCodeAt(0)] | ||
| }) | ||
| if (escapeCache.size >= ESCAPE_CACHE_LIMIT) { | ||
| // Evict the single oldest entry (Maps iterate in insertion order) instead | ||
| // of clearing wholesale — a corpus slightly over the limit keeps its hot | ||
| // set instead of collapsing to a 0% hit rate every pass. | ||
| escapeCache.delete(escapeCache.keys().next().value as string) | ||
| } | ||
| escapeCache.set(pattern, escaped) | ||
| return escaped | ||
| } |
@@ -15,10 +15,65 @@ /** A generated file: its name (with extension) and TypeScript source. */ | ||
| readonly typesOnly?: boolean | ||
| /** | ||
| * Extension used on every relative re-export specifier. `'js'` (default) is | ||
| * the standard TS NodeNext form (`./x.js` resolving to a sibling `x.ts`); | ||
| * `'ts'` emits the literal on-disk path so the output runs directly under | ||
| * Node's type stripping. | ||
| */ | ||
| readonly importExt?: 'js' | 'ts' | ||
| } | ||
| // Generated files declare their public surface with these two forms, so we can | ||
| // recover the export names from the source text without parsing it. | ||
| const TYPE_EXPORT_RE = /^export type (\w+)/gm | ||
| const CONST_EXPORT_RE = /^export const (\w+)/gm | ||
| // Generated files declare their public surface with `export type <Name>` / | ||
| // `export const <Name>` at line starts, so the names can be recovered from the | ||
| // source text without parsing it. | ||
| /** Reads the identifier following `prefix` when `content` starts with it at `at`. */ | ||
| const exportNameAt = (content: string, at: number, prefix: string): string | null => { | ||
| if (!content.startsWith(prefix, at)) return null | ||
| let end = at + prefix.length | ||
| while (end < content.length) { | ||
| const code = content.charCodeAt(end) | ||
| const isWord = | ||
| (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95 | ||
| if (!isWord) break | ||
| end++ | ||
| } | ||
| return end > at + prefix.length ? content.slice(at + prefix.length, end) : null | ||
| } | ||
| /** True for every JS LineTerminator code unit — the same set the old `/m` regexes anchored after. */ | ||
| const isLineTerminator = (code: number): boolean => code === 10 || code === 13 || code === 8232 || code === 8233 | ||
| /** | ||
| * Collects `export type` / `export const` names with a single line-start walk. | ||
| * A multiline-anchored regex scan (`/^export .../gm`) does the same job but | ||
| * showed up at several percent of total generation time in CPU profiles — the | ||
| * regex engine re-anchors at every line of every generated file on every build. | ||
| */ | ||
| const collectExportNames = (content: string, typeNames: string[], constNames: string[]): void => { | ||
| let lineStart = 0 | ||
| while (lineStart < content.length) { | ||
| // charCode prefilter: almost every line starts with whitespace, a brace, or | ||
| // a keyword other than `export` — one integer compare skips the substring | ||
| // comparison for all of them (101 === 'e'). | ||
| if (content.charCodeAt(lineStart) === 101 && content.startsWith('export ', lineStart)) { | ||
| const typeName = exportNameAt(content, lineStart, 'export type ') | ||
| if (typeName !== null) { | ||
| typeNames.push(typeName) | ||
| } else { | ||
| const constName = exportNameAt(content, lineStart, 'export const ') | ||
| if (constName !== null) constNames.push(constName) | ||
| } | ||
| } | ||
| // Advance past the next line break of ANY JS flavor (LF, CR, CRLF, | ||
| // U+2028, U+2029) — matching the multiline regexes this walk replaced, | ||
| // which treated all of them as line starts. | ||
| let next = lineStart | ||
| while (next < content.length && !isLineTerminator(content.charCodeAt(next))) next++ | ||
| if (next >= content.length) break | ||
| // \r\n counts as one break. | ||
| lineStart = content.charCodeAt(next) === 13 && content.charCodeAt(next + 1) === 10 ? next + 2 : next + 1 | ||
| } | ||
| } | ||
| /** | ||
| * Builds the `index.ts` barrel that re-exports every generated module. This is | ||
@@ -38,2 +93,3 @@ * the shared version of the near-identical barrel each generator used to build | ||
| const typesOnly = options.typesOnly ?? false | ||
| const importExt = options.importExt ?? 'js' | ||
@@ -50,13 +106,12 @@ const sortedFiles = files | ||
| for (const match of file.content.matchAll(TYPE_EXPORT_RE)) typeNames.push(match[1] as string) | ||
| for (const match of file.content.matchAll(CONST_EXPORT_RE)) constNames.push(match[1] as string) | ||
| collectExportNames(file.content, typeNames, constNames) | ||
| if (typeNames.length === 0 && constNames.length === 0) continue | ||
| // `.js` extension so the barrel resolves under Node ESM, not only Bun. | ||
| // An explicit extension so the barrel resolves under Node ESM, not only Bun. | ||
| if (typesOnly) { | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}.js';\n` | ||
| indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}.${importExt}';\n` | ||
| } else { | ||
| const typeExports = typeNames.map((name) => `type ${name}`) | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}.js';\n` | ||
| indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}.${importExt}';\n` | ||
| } | ||
@@ -63,0 +118,0 @@ } |
@@ -1,2 +0,13 @@ | ||
| /** Parses the items of an array with a parser function */ | ||
| /** | ||
| * Parses the items of an array with a parser function. | ||
| * | ||
| * When every element parses to the very same reference — the parser found it | ||
| * already valid and handed it back, as the generated item sub-parsers do for | ||
| * clean values — the input array itself is returned and nothing is allocated. | ||
| * The result array is only materialized (lazily, on the first element the | ||
| * parser actually replaced) when something was coerced or stripped, so the | ||
| * common all-clean parse costs no allocation at all. Callers already share | ||
| * element references either way; sharing the container mirrors the generated | ||
| * fast paths, which return the input array by reference too. | ||
| */ | ||
| export const validateArray = (input: unknown, parser: (input: unknown) => unknown) => { | ||
@@ -7,10 +18,17 @@ if (!Array.isArray(input)) { | ||
| // Pre-allocate the result array for better performance than push() | ||
| const len = input.length | ||
| const result = new Array(len) | ||
| let result: unknown[] | null = null | ||
| for (let i = 0; i < len; i++) { | ||
| result[i] = parser(input[i]) | ||
| const parsed = parser(input[i]) | ||
| if (result !== null) { | ||
| result[i] = parsed | ||
| } else if (parsed !== input[i]) { | ||
| // First replaced element: materialize the copy and backfill the prefix. | ||
| result = new Array(len) | ||
| for (let j = 0; j < i; j++) result[j] = input[j] | ||
| result[i] = parsed | ||
| } | ||
| } | ||
| return result | ||
| return result ?? input | ||
| } |
221228
5.32%74
4.23%4822
4.69%