pokemon-showdown/lib/utils.ts
Guangcong Luo b5c25a84e2 Fix typo
2021-01-06 11:18:56 -08:00

349 lines
10 KiB
TypeScript

/**
* Utils library
*
* Miscellaneous utility functions that don't really have a better place.
*
* It'll always be a judgment call whether or not a function goes into a
* "catch-all" library like this, so here are some guidelines:
*
* - It must not have any dependencies
*
* - It must conceivably have a use in a wide variety of projects, not just
* Pokémon (if it's Pokémon-specific, Dex is probably a good place for it)
*
* - A lot of Chat functions are kind of iffy, but I'm going to say for now
* that if it's English-specific, it should be left out of here.
*/
type Comparable = number | string | boolean | Comparable[] | {reverse: Comparable};
export const Utils = new class {
/**
* Safely converts the passed variable into a string. Unlike '' + str,
* String(str), or str.toString(), Utils.getString is guaranteed not to
* crash.
*
* Specifically, the fear with untrusted JSON is an object like:
*
* let a = {"toString": "this is not a function"};
* console.log(`a is ${a}`);
*
* This will crash (because a.toString() is not a function). Instead,
* getString simply returns '' if the passed variable isn't a
* string or a number.
*/
getString(str: any): string {
return (typeof str === 'string' || typeof str === 'number') ? '' + str : '';
}
escapeRegex(str: string) {
return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
}
/**
* Escapes HTML in a string.
*/
escapeHTML(str: string) {
if (!str) return '';
return ('' + str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\//g, '&#x2f;');
}
/**
* Strips HTML from a string.
*/
stripHTML(htmlContent: string) {
if (!htmlContent) return '';
return htmlContent.replace(/<[^>]*>/g, '');
}
/**
* Visualizes eval output in a slightly more readable form
*/
visualize(value: any, depth = 0): string {
if (value === undefined) return `undefined`;
if (value === null) return `null`;
if (typeof value === 'number' || typeof value === 'boolean') {
return `${value}`;
}
if (typeof value === 'string') {
return `"${value}"`; // NOT ESCAPED
}
if (typeof value === 'symbol') {
return value.toString();
}
if (Array.isArray(value)) {
if (depth > 10) return `[array]`;
return `[` + value.map(elem => this.visualize(elem, depth + 1)).join(`, `) + `]`;
}
if (value instanceof RegExp || value instanceof Date || value instanceof Function) {
if (depth && value instanceof Function) return `Function`;
return `${value}`;
}
let constructor = '';
if (value.constructor && value.constructor.name && typeof value.constructor.name === 'string') {
constructor = value.constructor.name;
if (constructor === 'Object') constructor = '';
} else {
constructor = 'null';
}
// If it has a toString, try to grab the base class from there
// (This is for Map/Set subclasses like user.auth)
const baseClass = (value?.toString && /\[object (.*)\]/.exec(value.toString())?.[1]) || constructor;
switch (baseClass) {
case 'Map':
if (depth > 2) return `Map`;
const mapped = [...value.entries()].map(
val => `${this.visualize(val[0], depth + 1)} => ${this.visualize(val[1], depth + 1)}`
);
return `${constructor} (${value.size}) { ${mapped.join(', ')} }`;
case 'Set':
if (depth > 2) return `Set`;
return `${constructor} (${value.size}) { ${[...value].map(v => this.visualize(v), depth + 1).join(', ')} }`;
}
if (value.toString) {
try {
const stringValue = value.toString();
if (typeof stringValue === 'string' &&
stringValue !== '[object Object]' &&
stringValue !== `[object ${constructor}]`) {
return `${constructor}(${stringValue})`;
}
} catch (e) {}
}
let buf = '';
for (const key in value) {
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
if (depth > 2 || (depth && constructor)) {
buf = '...';
break;
}
if (buf) buf += `, `;
let displayedKey = key;
if (!/^[A-Za-z0-9_$]+$/.test(key)) displayedKey = JSON.stringify(key);
buf += `${displayedKey}: ` + this.visualize(value[key], depth + 1);
}
if (constructor && !buf && constructor !== 'null') return constructor;
return `${constructor}{${buf}}`;
}
/**
* Compares two variables; intended to be used as a smarter comparator.
* The two variables must be the same type (TypeScript will not check this).
*
* - Numbers are sorted low-to-high, use `-val` to reverse
* - Strings are sorted A to Z case-semi-insensitively, use `{reverse: val}` to reverse
* - Booleans are sorted true-first (REVERSE of casting to numbers), use `!val` to reverse
* - Arrays are sorted lexically in the order of their elements
*
* In other words: `[num, str]` will be sorted A to Z, `[num, {reverse: str}]` will be sorted Z to A.
*/
compare(a: Comparable, b: Comparable): number {
if (typeof a === 'number') {
return a - (b as number);
}
if (typeof a === 'string') {
return a.localeCompare(b as string);
}
if (typeof a === 'boolean') {
return (a ? 1 : 2) - (b ? 1 : 2);
}
if (Array.isArray(a)) {
for (let i = 0; i < a.length; i++) {
const comparison = this.compare(a[i], (b as Comparable[])[i]);
if (comparison) return comparison;
}
return 0;
}
if (a.reverse) {
return this.compare((b as {reverse: string}).reverse, a.reverse);
}
throw new Error(`Passed value ${a} is not comparable`);
}
/**
* Sorts an array according to the callback's output on its elements.
*
* The callback's output is compared according to `PSUtils.compare` (in
* particular, it supports arrays so you can sort by multiple things).
*/
sortBy<T>(array: T[], callback: (a: T) => Comparable): T[];
/**
* Sorts an array according to `PSUtils.compare`. (Correctly sorts numbers,
* unlike `array.sort`)
*/
sortBy<T extends Comparable>(array: T[]): T[];
sortBy<T>(array: T[], callback?: (a: T) => Comparable) {
if (!callback) return (array as any[]).sort(this.compare);
return array.sort((a, b) => this.compare(callback(a), callback(b)));
}
splitFirst(str: string, delimiter: string): [string, string];
splitFirst(str: string, delimiter: string, limit: 2): [string, string, string];
splitFirst(str: string, delimiter: string, limit: 3): [string, string, string, string];
splitFirst(str: string, delimiter: string, limit: number): string[];
/**
* Like string.split(delimiter), but only recognizes the first `limit`
* delimiters (default 1).
*
* `"1 2 3 4".split(" ", 2) => ["1", "2"]`
*
* `Utils.splitFirst("1 2 3 4", " ", 1) => ["1", "2 3 4"]`
*
* Returns an array of length exactly limit + 1.
*
*/
splitFirst(str: string, delimiter: string, limit = 1) {
const splitStr: string[] = [];
while (splitStr.length < limit) {
const delimiterIndex = str.indexOf(delimiter);
if (delimiterIndex >= 0) {
splitStr.push(str.slice(0, delimiterIndex));
str = str.slice(delimiterIndex + delimiter.length);
} else {
splitStr.push(str);
str = '';
}
}
splitStr.push(str);
return splitStr;
}
/**
* Template string tag function for escaping HTML
*/
html(strings: TemplateStringsArray, ...args: any) {
let buf = strings[0];
let i = 0;
while (i < args.length) {
buf += this.escapeHTML(args[i]);
buf += strings[++i];
}
return buf;
}
/**
* HTML doesn't support `word-wrap: break-word` in tables, but sometimes it
* would be really nice if it did. This emulates `word-wrap: break-word` by
* manually inserting U+200B (zero-width space, the force-wrap character) in long words.
*/
forceWrap(text: string) {
return text.replace(/[^\s]{30,}/g, word => {
let lastBreak = 0;
let brokenWord = '';
for (let i = 1; i < word.length; i++) {
if (i - lastBreak >= 10 || /[^a-zA-Z0-9([{][a-zA-Z0-9]/.test(word.slice(i - 1, i + 1))) {
brokenWord += word.slice(lastBreak, i) + '\u200B';
lastBreak = i;
}
}
brokenWord += word.slice(lastBreak);
return brokenWord;
});
}
shuffle<T>(arr: T[]): T[] {
// In-place shuffle by Fisher-Yates algorithm
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/** Forces num to be an integer (between min and max). */
clampIntRange(num: any, min?: number, max?: number): number {
if (typeof num !== 'number') num = 0;
num = Math.floor(num);
if (min !== undefined && num < min) num = min;
if (max !== undefined && num > max) num = max;
return num;
}
clearRequireCache(options: {exclude?: string[]} = {}) {
const excludes = options?.exclude || [];
excludes.push('/node_modules/');
for (const path in require.cache) {
let skip = false;
for (const exclude of excludes) {
if (path.includes(exclude)) {
skip = true;
break;
}
}
if (!skip) delete require.cache[path];
}
}
deepClone(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(prop => this.deepClone(prop));
const clone = Object.create(Object.getPrototypeOf(obj));
for (const key of Object.keys(obj)) {
clone[key] = this.deepClone(obj[key]);
}
return clone;
}
levenshtein(s: string, t: string, l: number): number {
// Original levenshtein distance function by James Westgate, turned out to be the fastest
const d: number[][] = [];
// Step 1
const n = s.length;
const m = t.length;
if (n === 0) return m;
if (m === 0) return n;
if (l && Math.abs(m - n) > l) return Math.abs(m - n);
// Create an array of arrays in javascript (a descending loop is quicker)
for (let i = n; i >= 0; i--) d[i] = [];
// Step 2
for (let i = n; i >= 0; i--) d[i][0] = i;
for (let j = m; j >= 0; j--) d[0][j] = j;
// Step 3
for (let i = 1; i <= n; i++) {
const si = s.charAt(i - 1);
// Step 4
for (let j = 1; j <= m; j++) {
// Check the jagged ld total so far
if (i === j && d[i][j] > 4) return n;
const tj = t.charAt(j - 1);
const cost = (si === tj) ? 0 : 1; // Step 5
// Calculate the minimum
let mi = d[i - 1][j] + 1;
const b = d[i][j - 1] + 1;
const c = d[i - 1][j - 1] + cost;
if (b < mi) mi = b;
if (c < mi) mi = c;
d[i][j] = mi; // Step 6
}
}
// Step 7
return d[n][m];
}
waitUntil(time: number): Promise<void> {
return new Promise(resolve => {
setTimeout(() => resolve(), time - Date.now());
});
}
};