import { findLastIndex, unique } from "./arrays"; import { HexclaveAssertionError } from "./errors"; import { filterUndefined } from "./objects"; export type Join = T extends [] ? "" : T extends [infer U extends string, ...infer Rest extends string[]] ? `${U}${Rest extends [any, ...any[]] ? `${Separator}${Join}` : ""}` : ""; export function typedJoin(strings: T, separator: Separator): Join { return strings.join(separator) as Join; } export function typedToLowercase(s: S): Lowercase { if (typeof s !== "string") throw new HexclaveAssertionError("Expected a string for typedToLowercase", { s }); return s.toLowerCase() as Lowercase; } import.meta.vitest?.test("typedToLowercase", ({ expect }) => { expect(typedToLowercase("")).toBe(""); expect(typedToLowercase("HELLO")).toBe("hello"); expect(typedToLowercase("Hello World")).toBe("hello world"); expect(typedToLowercase("hello")).toBe("hello"); expect(typedToLowercase("123")).toBe("123"); expect(typedToLowercase("MIXED123case")).toBe("mixed123case"); expect(typedToLowercase("Special@Chars!")).toBe("special@chars!"); expect(() => typedToLowercase(123 as any)).toThrow("Expected a string for typedToLowercase"); }); export function typedToUppercase(s: S): Uppercase { if (typeof s !== "string") throw new HexclaveAssertionError("Expected a string for typedToUppercase", { s }); return s.toUpperCase() as Uppercase; } import.meta.vitest?.test("typedToUppercase", ({ expect }) => { expect(typedToUppercase("")).toBe(""); expect(typedToUppercase("hello")).toBe("HELLO"); expect(typedToUppercase("Hello World")).toBe("HELLO WORLD"); expect(typedToUppercase("HELLO")).toBe("HELLO"); expect(typedToUppercase("123")).toBe("123"); expect(typedToUppercase("mixed123Case")).toBe("MIXED123CASE"); expect(typedToUppercase("special@chars!")).toBe("SPECIAL@CHARS!"); expect(() => typedToUppercase(123 as any)).toThrow("Expected a string for typedToUppercase"); }); export function typedCapitalize(s: S): Capitalize { return s.charAt(0).toUpperCase() + s.slice(1) as Capitalize; } import.meta.vitest?.test("typedCapitalize", ({ expect }) => { expect(typedCapitalize("")).toBe(""); expect(typedCapitalize("hello")).toBe("Hello"); expect(typedCapitalize("hello world")).toBe("Hello world"); expect(typedCapitalize("HELLO")).toBe("HELLO"); expect(typedCapitalize("123test")).toBe("123test"); expect(typedCapitalize("already Capitalized")).toBe("Already Capitalized"); expect(typedCapitalize("h")).toBe("H"); }); /** * Compares two strings in a way that is not dependent on the current locale. */ export function stringCompare(a: string, b: string): number { if (typeof a !== "string" || typeof b !== "string") throw new HexclaveAssertionError(`Expected two strings for stringCompare, found ${typeof a} and ${typeof b}`, { a, b }); const cmp = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; return cmp(a.toUpperCase(), b.toUpperCase()) || cmp(b, a); } import.meta.vitest?.test("stringCompare", ({ expect }) => { // Equal strings expect(stringCompare("a", "a")).toBe(0); expect(stringCompare("", "")).toBe(0); // Case comparison - note that this function is NOT case-insensitive // It compares uppercase versions first, then original strings expect(stringCompare("a", "A")).toBe(-1); // lowercase comes after uppercase expect(stringCompare("A", "a")).toBe(1); // uppercase comes before lowercase expect(stringCompare("abc", "ABC")).toBe(-1); expect(stringCompare("ABC", "abc")).toBe(1); // Different strings expect(stringCompare("a", "b")).toBe(-1); expect(stringCompare("b", "a")).toBe(1); // Strings with different lengths expect(stringCompare("abc", "abcd")).toBe(-1); expect(stringCompare("abcd", "abc")).toBe(1); // Strings with numbers expect(stringCompare("a1", "a2")).toBe(-1); expect(stringCompare("a10", "a2")).toBe(-1); // Strings with special characters expect(stringCompare("a", "a!")).toBe(-1); expect(stringCompare("a!", "a")).toBe(1); }); /** * Returns all whitespace character at the start of the string. * * Uses the same definition for whitespace as `String.prototype.trim()`. */ export function getWhitespacePrefix(s: string): string { return s.substring(0, s.length - s.trimStart().length); } import.meta.vitest?.test("getWhitespacePrefix", ({ expect }) => { expect(getWhitespacePrefix("")).toBe(""); expect(getWhitespacePrefix("hello")).toBe(""); expect(getWhitespacePrefix(" hello")).toBe(" "); expect(getWhitespacePrefix(" hello")).toBe(" "); expect(getWhitespacePrefix("\thello")).toBe("\t"); expect(getWhitespacePrefix("\n hello")).toBe("\n "); expect(getWhitespacePrefix(" ")).toBe(" "); expect(getWhitespacePrefix(" \t\n\r")).toBe(" \t\n\r"); }); /** * Returns all whitespace character at the end of the string. * * Uses the same definition for whitespace as `String.prototype.trim()`. */ export function getWhitespaceSuffix(s: string): string { return s.substring(s.trimEnd().length); } import.meta.vitest?.test("getWhitespaceSuffix", ({ expect }) => { expect(getWhitespaceSuffix("")).toBe(""); expect(getWhitespaceSuffix("hello")).toBe(""); expect(getWhitespaceSuffix("hello ")).toBe(" "); expect(getWhitespaceSuffix("hello ")).toBe(" "); expect(getWhitespaceSuffix("hello\t")).toBe("\t"); expect(getWhitespaceSuffix("hello \n")).toBe(" \n"); expect(getWhitespaceSuffix(" ")).toBe(" "); expect(getWhitespaceSuffix(" \t\n\r")).toBe(" \t\n\r"); }); /** * Returns a string with all empty or whitespace-only lines at the start removed. * * Uses the same definition for whitespace as `String.prototype.trim()`. */ export function trimEmptyLinesStart(s: string): string { const lines = s.split("\n"); const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim() !== ""); // If all lines are empty or whitespace-only, return an empty string if (firstNonEmptyLineIndex === -1) return ""; return lines.slice(firstNonEmptyLineIndex).join("\n"); } import.meta.vitest?.test("trimEmptyLinesStart", ({ expect }) => { expect(trimEmptyLinesStart("")).toBe(""); expect(trimEmptyLinesStart("hello")).toBe("hello"); expect(trimEmptyLinesStart("\nhello")).toBe("hello"); expect(trimEmptyLinesStart("\n\nhello")).toBe("hello"); expect(trimEmptyLinesStart(" \n\t\nhello")).toBe("hello"); expect(trimEmptyLinesStart("\n\nhello\nworld")).toBe("hello\nworld"); expect(trimEmptyLinesStart("hello\n\nworld")).toBe("hello\n\nworld"); expect(trimEmptyLinesStart("hello\nworld\n")).toBe("hello\nworld\n"); expect(trimEmptyLinesStart("\n \n\nhello\n \nworld")).toBe("hello\n \nworld"); // Edge case: all lines are empty expect(trimEmptyLinesStart("\n\n \n\t")).toBe(""); }); /** * Returns a string with all empty or whitespace-only lines at the end removed. * * Uses the same definition for whitespace as `String.prototype.trim()`. */ export function trimEmptyLinesEnd(s: string): string { const lines = s.split("\n"); const lastNonEmptyLineIndex = findLastIndex(lines, (line) => line.trim() !== ""); return lines.slice(0, lastNonEmptyLineIndex + 1).join("\n"); } import.meta.vitest?.test("trimEmptyLinesEnd", ({ expect }) => { expect(trimEmptyLinesEnd("")).toBe(""); expect(trimEmptyLinesEnd("hello")).toBe("hello"); expect(trimEmptyLinesEnd("hello\n")).toBe("hello"); expect(trimEmptyLinesEnd("hello\n\n")).toBe("hello"); expect(trimEmptyLinesEnd("hello\n \n\t")).toBe("hello"); expect(trimEmptyLinesEnd("hello\nworld\n\n")).toBe("hello\nworld"); expect(trimEmptyLinesEnd("hello\n\nworld")).toBe("hello\n\nworld"); expect(trimEmptyLinesEnd("\nhello\nworld")).toBe("\nhello\nworld"); expect(trimEmptyLinesEnd("hello\n \nworld\n\n ")).toBe("hello\n \nworld"); // Edge case: all lines are empty expect(trimEmptyLinesEnd("\n\n \n\t")).toBe(""); }); /** * Returns a string with all empty or whitespace-only lines trimmed at the start and end. * * Uses the same definition for whitespace as `String.prototype.trim()`. */ export function trimLines(s: string): string { return trimEmptyLinesEnd(trimEmptyLinesStart(s)); } import.meta.vitest?.test("trimLines", ({ expect }) => { expect(trimLines("")).toBe(""); expect(trimLines(" ")).toBe(""); expect(trimLines(" \n ")).toBe(""); expect(trimLines(" abc ")).toBe(" abc "); expect(trimLines("\n \nLine1\nLine2\n \n")).toBe("Line1\nLine2"); expect(trimLines("Line1\n \nLine2")).toBe("Line1\n \nLine2"); expect(trimLines(" \n \n\t")).toBe(""); expect(trimLines(" Hello World")).toBe(" Hello World"); expect(trimLines("\n")).toBe(""); expect(trimLines("\t \n\t\tLine1 \n \nLine2\t\t\n\t ")).toBe("\t\tLine1 \n \nLine2\t\t"); }); /** * A template literal tag that returns the same string as the template literal without a tag. * * Useful for implementing your own template literal tags. */ export function templateIdentity(strings: TemplateStringsArray | readonly string[], ...values: string[]): string { if (values.length !== strings.length - 1) throw new HexclaveAssertionError("Invalid number of values; must be one less than strings", { strings, values }); return strings.reduce((result, str, i) => result + str + (values[i] ?? ''), ''); } import.meta.vitest?.test("templateIdentity", ({ expect }) => { expect(templateIdentity`Hello World`).toBe("Hello World"); expect(templateIdentity`${"Hello"}`).toBe("Hello"); const greeting = "Hello"; const subject = "World"; expect(templateIdentity`${greeting}, ${subject}!`).toBe("Hello, World!"); expect(templateIdentity`${"A"}${"B"}${"C"}`).toBe("ABC"); expect(templateIdentity`Start${""}Middle${""}End`).toBe("StartMiddleEnd"); expect(templateIdentity``).toBe(""); expect(templateIdentity`Line1 Line2`).toBe("Line1\nLine2"); expect(templateIdentity(["a ", " scientific ", "gun"], "certain", "rail")).toBe("a certain scientific railgun"); expect(templateIdentity(["only one part"])).toBe("only one part"); expect(() => templateIdentity(["a ", "b", "c"], "only one")).toThrow("Invalid number of values"); expect(() => templateIdentity(["a", "b"], "x", "y")).toThrow("Invalid number of values"); }); export function deindent(code: string): string; export function deindent(strings: TemplateStringsArray | readonly string[], ...values: any[]): string; export function deindent(strings: string | readonly string[], ...values: any[]): string { if (typeof strings === "string") return deindent([strings]); return templateIdentity(...deindentTemplate(strings, ...values)); } export function deindentTemplate(strings: TemplateStringsArray | readonly string[], ...values: any[]): [string[], ...string[]] { if (values.length !== strings.length - 1) throw new HexclaveAssertionError("Invalid number of values; must be one less than strings", { strings, values }); const trimmedStrings = [...strings]; trimmedStrings[0] = trimEmptyLinesStart(trimmedStrings[0] + "+").slice(0, -1); trimmedStrings[trimmedStrings.length - 1] = trimEmptyLinesEnd("+" + trimmedStrings[trimmedStrings.length - 1]).slice(1); const indentation = trimmedStrings .join("${SOME_VALUE}") .split("\n") .filter((line) => line.trim() !== "") .map((line) => getWhitespacePrefix(line).length) .reduce((min, current) => Math.min(min, current), Infinity); const deindentedStrings = trimmedStrings .map((string, stringIndex) => { return string .split("\n") .map((line, lineIndex) => stringIndex !== 0 && lineIndex === 0 ? line : line.substring(indentation)) .join("\n"); }); const indentedValues = values.map((value, i) => { const firstLineIndentation = getWhitespacePrefix(deindentedStrings[i].split("\n").at(-1)!); return `${value}`.replaceAll("\n", `\n${firstLineIndentation}`); }); return [deindentedStrings, ...indentedValues]; } import.meta.vitest?.test("deindent", ({ expect }) => { // Test with string input expect(deindent(" hello")).toBe("hello"); expect(deindent(" hello\n world")).toBe("hello\nworld"); expect(deindent(" hello\n world")).toBe("hello\n world"); expect(deindent("\n hello\n world\n")).toBe("hello\nworld"); // Test with empty input expect(deindent("")).toBe(""); // Test with template literal expect(deindent` hello world `).toBe("hello\nworld"); expect(deindent` hello world `).toBe("hello\n world"); // Test with values const value = "test"; expect(deindent` hello ${value} world `).toBe(`hello ${value}\nworld`); // Test with multiline values expect(deindent` hello to ${"line1\n line2"} world `).toBe(`hello\n to line1\n line2\nworld`); // Leading whitespace values expect(deindent` ${" "}A ${" "}B ${" "}C `).toBe(` A\n B\n C`); // Trailing whitespaces (note: there are two whitespaces each after A and after C) expect(deindent` A B ${" "} C `).toBe(`A \nB \nC `); // Test with mixed indentation expect(deindent` hello world ! `).toBe("hello\n world\n !"); // Test error cases expect(() => deindent(["a", "b", "c"], "too", "many", "values")).toThrow("Invalid number of values"); }); export function extractScopes(scope: string, removeDuplicates=true): string[] { // TODO what is this for? can we move this into the OAuth code in the backend? const trimmedString = scope.trim(); const scopesArray = trimmedString.split(/\s+/); const filtered = scopesArray.filter(scope => scope.length > 0); return removeDuplicates ? [...new Set(filtered)] : filtered; } import.meta.vitest?.test("extractScopes", ({ expect }) => { // Test with empty string expect(extractScopes("")).toEqual([]); // Test with single scope expect(extractScopes("read")).toEqual(["read"]); // Test with multiple scopes expect(extractScopes("read write")).toEqual(["read", "write"]); // Test with extra whitespace expect(extractScopes(" read write ")).toEqual(["read", "write"]); // Test with newlines and tabs expect(extractScopes("read\nwrite\tdelete")).toEqual(["read", "write", "delete"]); // Test with duplicates (default behavior) expect(extractScopes("read write read")).toEqual(["read", "write"]); // Test with duplicates (explicitly set to remove) expect(extractScopes("read write read", true)).toEqual(["read", "write"]); // Test with duplicates (explicitly set to keep) expect(extractScopes("read write read", false)).toEqual(["read", "write", "read"]); }); export function mergeScopeStrings(...scopes: string[]): string { // TODO what is this for? can we move this into the OAuth code in the backend? const allScope = scopes.map((s) => extractScopes(s)).flat().join(" "); return extractScopes(allScope).join(" "); } import.meta.vitest?.test("mergeScopeStrings", ({ expect }) => { // Test with empty input expect(mergeScopeStrings()).toBe(""); // Test with single scope string expect(mergeScopeStrings("read write")).toBe("read write"); // Test with multiple scope strings expect(mergeScopeStrings("read", "write")).toBe("read write"); // Test with overlapping scopes expect(mergeScopeStrings("read write", "write delete")).toBe("read write delete"); // Test with extra whitespace expect(mergeScopeStrings(" read write ", " delete ")).toBe("read write delete"); // Test with duplicates across strings expect(mergeScopeStrings("read write", "write delete", "read")).toBe("read write delete"); // Test with empty strings expect(mergeScopeStrings("read write", "", "delete")).toBe("read write delete"); }); export function escapeTemplateLiteral(s: string): string { return s.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${"); } import.meta.vitest?.test("escapeTemplateLiteral", ({ expect }) => { // Test with empty string expect(escapeTemplateLiteral("")).toBe(""); // Test with normal string (no special characters) expect(escapeTemplateLiteral("hello world")).toBe("hello world"); // Test with backtick const input1 = "hello `world`"; const output1 = escapeTemplateLiteral(input1); // Verify backticks are escaped expect(output1).toBe("hello \\`world\\`"); // Test with backslash const input2 = "hello \\world"; const output2 = escapeTemplateLiteral(input2); // Verify backslashes are escaped expect(output2).toBe("hello \\\\world"); // Test with dollar sign const input3 = "hello $world"; const output3 = escapeTemplateLiteral(input3); // Verify dollar signs are escaped expect(output3).toBe("hello $world"); // Test with dollar sign in interpolation const input4 = "hello ${$world"; const output4 = escapeTemplateLiteral(input4); // Verify dollar signs are escaped expect(output4).toBe("hello \\${$world"); // Test with multiple special characters const input5 = "`hello` ${world\\"; const output5 = escapeTemplateLiteral(input5); // Verify all special characters are escaped expect(output5).toBe("\\`hello\\` \\${world\\\\"); // Test with already escaped characters const input6 = "\\`hello\\`"; const output6 = escapeTemplateLiteral(input6); expect(output6).toBe("\\\\\\`hello\\\\\\`"); }); /** * Some classes have different constructor names in different environments (eg. `Headers` is sometimes called `_Headers`, * so we create an object of overrides to handle these cases. */ const nicifiableClassNameOverrides = new Map(Object.entries({ Headers, } as Record).map(([k, v]) => [v, k])); export type Nicifiable = { getNicifiableKeys?(): PropertyKey[], getNicifiedObjectExtraLines?(): string[], }; export type NicifyOptions = { maxDepth: number, currentIndent: string, lineIndent: string, multiline: boolean, refs: Map, path: string, parent: null | { options: NicifyOptions, value: unknown, }, keyInParent: PropertyKey | null, hideFields: PropertyKey[], overrides: (...args: Parameters) => string | null, }; export function nicify( value: unknown, options: Partial = {}, ): string { const fullOptions: NicifyOptions = { maxDepth: 5, currentIndent: "", lineIndent: " ", multiline: true, refs: new Map(), path: "value", parent: null, overrides: () => null, keyInParent: null, hideFields: [], ...filterUndefined(options), }; const { maxDepth, currentIndent, lineIndent, multiline, refs, path, overrides, hideFields, } = fullOptions; const nl = `\n${currentIndent}`; const overrideResult = overrides(value, options); if (overrideResult !== null) return overrideResult; if (["function", "object", "symbol"].includes(typeof value) && value !== null) { if (refs.has(value)) { return `Ref<${refs.get(value)}>`; } refs.set(value, path); } const newOptions: NicifyOptions = { maxDepth: maxDepth - 1, currentIndent, lineIndent, multiline, refs, path: path + "->[unknown property]", overrides, parent: { value, options: fullOptions }, keyInParent: null, hideFields: [], }; const nestedNicify = (newValue: unknown, newPath: string, keyInParent: PropertyKey | null, options: Partial = {}) => { return nicify(newValue, { ...newOptions, path: newPath, currentIndent: currentIndent + lineIndent, keyInParent, ...options, }); }; switch (typeof value) { case "boolean": case "number": { return JSON.stringify(value); } case "string": { const isDeindentable = (v: string) => deindent(v) === v && v.includes("\n"); const wrapInDeindent = (v: string) => deindent` deindent\` ${currentIndent + lineIndent}${escapeTemplateLiteral(v).replaceAll("\n", nl + lineIndent)} ${currentIndent}\` `; if (isDeindentable(value)) { return wrapInDeindent(value); } else if (value.endsWith("\n") && isDeindentable(value.slice(0, -1))) { return wrapInDeindent(value.slice(0, -1)) + ' + "\\n"'; } else { return JSON.stringify(value); } } case "undefined": { return "undefined"; } case "symbol": { return value.toString(); } case "bigint": { return `${value}n`; } case "function": { if (value.name) return `function ${value.name}(...) { ... }`; return `(...) => { ... }`; } case "object": { if (value === null) return "null"; if (Array.isArray(value)) { const extraLines = getNicifiedObjectExtraLines(value); const resValueLength = value.length + extraLines.length; if (resValueLength === 0) return "[]"; // early return in case maxDepth <= 0 if (maxDepth <= 0) return `[...]`; const resValues = value.map((v, i) => nestedNicify(v, `${path}[${i}]`, i)); resValues.push(...extraLines); if (resValues.length !== resValueLength) throw new HexclaveAssertionError("nicify of object: resValues.length !== resValueLength", { value, resValues, resValueLength }); const shouldIndent = resValues.length > 4 || resValues.some(x => (resValues.length > 1 && x.length > 4) || x.includes("\n")); if (shouldIndent) { return `[${nl}${resValues.map(x => `${lineIndent}${x},${nl}`).join("")}]`; } else { return `[${resValues.join(", ")}]`; } } if (value instanceof Date) { return `Date(${nestedNicify(value.toISOString(), `${path}.toISOString()`, null)})`; } if (value instanceof URL) { return `URL(${nestedNicify(value.toString(), `${path}.toString()`, null)})`; } if (ArrayBuffer.isView(value)) { return `${value.constructor.name}([${value.toString()}])`; } if (value instanceof ArrayBuffer) { return `ArrayBuffer [${new Uint8Array(value).toString()}]`; } if (value instanceof Error) { let stack = value.stack ?? ""; const toString = value.toString(); if (!stack.startsWith(toString)) stack = `${toString}\n${stack}`; // some browsers don't include the error message in the stack, some do stack = stack.trimEnd(); stack = stack.replace(/\n\s+/g, `\n${lineIndent}${lineIndent}`); stack = stack.replace("\n", `\n${lineIndent}Stack:\n`); if (Object.keys(value).length > 0) { stack += `\n${lineIndent}Extra properties: ${nestedNicify(Object.fromEntries(Object.entries(value)), path, null)}`; } if (value.cause) { stack += `\n${lineIndent}Cause:\n${lineIndent}${lineIndent}${nestedNicify(value.cause, path, null, { currentIndent: currentIndent + lineIndent + lineIndent })}`; } stack = stack.replaceAll("\n", `\n${currentIndent}`); return stack; } const constructorName = [null, Object.prototype].includes(Object.getPrototypeOf(value)) ? null : (nicifiableClassNameOverrides.get(value.constructor) ?? value.constructor.name); const constructorString = constructorName ? `${constructorName} ` : ""; const entries = getNicifiableEntries(value).filter(([k]) => !hideFields.includes(k)); const extraLines = [ ...getNicifiedObjectExtraLines(value), ...hideFields.length > 0 ? [`