From 64fd88ca7ca256f3916880da27d157d44f22490f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 18 Nov 2024 11:08:57 +0100 Subject: [PATCH] Replace lodash --- package-lock.json | 19 ++-- package.json | 3 +- src/cli/debug.ts | 13 ++- src/cli/run.ts | 3 +- src/configs/eslint/config.ts | 43 ++++---- src/configs/husky/config.ts | 4 +- src/configs/stylelint/config.ts | 37 +++---- src/lib/choices.ts | 10 +- src/lib/helpers.spec.ts | 56 ++++++++++ src/lib/helpers.ts | 39 +++++++ src/lib/logger.ts | 4 +- src/lib/type-check.spec.ts | 176 ++++++++++++++++++++++++++++++++ src/lib/type-check.ts | 37 +++++++ 13 files changed, 371 insertions(+), 73 deletions(-) create mode 100644 src/lib/helpers.spec.ts create mode 100644 src/lib/helpers.ts create mode 100644 src/lib/type-check.spec.ts create mode 100644 src/lib/type-check.ts diff --git a/package-lock.json b/package-lock.json index 6ed6da93..3d2ee92e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "chalk": "^4.1.2", "cross-spawn": "^7.0.5", "dedent": "^0.7.0", + "deepmerge-ts": "^7.1.3", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -36,7 +37,6 @@ "lint-staged": "15.2.10", "listr": "^0.14.3", "listr-inquirer": "^0.1.0", - "lodash": "^4.17.21", "prettier": "^3.3.3", "read-pkg-up": "^7.0.1", "semver": "^7.6.3", @@ -58,7 +58,6 @@ "@types/inquirer": "^8.2.10", "@types/is-ci": "^3.0.4", "@types/listr": "^0.14.9", - "@types/lodash": "^4.17.13", "@types/node": "^18.19.0", "@types/prettier": "^3.0.0", "@types/semver": "^7.5.8", @@ -2069,13 +2068,6 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, - "node_modules/@types/lodash": { - "version": "4.17.13", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", - "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -3805,6 +3797,15 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "node_modules/deepmerge-ts": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.3.tgz", + "integrity": "sha512-qCSH6I0INPxd9Y1VtAiLpnYvz5O//6rCfJXKk0z66Up9/VOSr+1yS8XSKA5IWRxjocFGlzPyaZYe+jxq7OOLtQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", diff --git a/package.json b/package.json index 07ef4ed8..68ec11de 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "chalk": "^4.1.2", "cross-spawn": "^7.0.5", "dedent": "^0.7.0", + "deepmerge-ts": "^7.1.3", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -70,7 +71,6 @@ "lint-staged": "15.2.10", "listr": "^0.14.3", "listr-inquirer": "^0.1.0", - "lodash": "^4.17.21", "prettier": "^3.3.3", "read-pkg-up": "^7.0.1", "semver": "^7.6.3", @@ -89,7 +89,6 @@ "@types/inquirer": "^8.2.10", "@types/is-ci": "^3.0.4", "@types/listr": "^0.14.9", - "@types/lodash": "^4.17.13", "@types/node": "^18.19.0", "@types/prettier": "^3.0.0", "@types/semver": "^7.5.8", diff --git a/src/cli/debug.ts b/src/cli/debug.ts index d4a9b950..189d03bc 100644 --- a/src/cli/debug.ts +++ b/src/cli/debug.ts @@ -13,17 +13,20 @@ * limitations under the License. */ -import { isArray, isEmpty, mapValues } from 'lodash/fp'; - import { getOptions } from '../lib/options'; import * as logger from '../lib/logger'; +import { isArray } from '../lib/type-check'; +import { isEmpty } from '../lib/helpers'; export function debug(): void { const options = getOptions(); - const stringifiedOptions = mapValues( - (value) => (isArray(value) && !isEmpty(value) ? value.join(', ') : value), - options, + const stringifiedOptions = Object.entries(options).reduce( + (acc, [key, value]) => { + acc[key] = isArray(value) && !isEmpty(value) ? value.join(', ') : value; + return acc; + }, + {} as Record, ); logger.empty(); diff --git a/src/cli/run.ts b/src/cli/run.ts index 24a06eb2..455a8d8f 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -17,11 +17,10 @@ import { dirname, resolve, join, relative } from 'node:path'; import { access, readFile } from 'node:fs'; import { promisify } from 'node:util'; -import { isString } from 'lodash/fp'; - import type { PackageJson } from '../types/shared'; import { spawn } from '../lib/spawn'; import * as logger from '../lib/logger'; +import { isString } from '../lib/type-check'; const readFileAsync = promisify(readFile); const accessAsync = promisify(access); diff --git a/src/configs/eslint/config.ts b/src/configs/eslint/config.ts index a15cfb2a..be5b6b1f 100644 --- a/src/configs/eslint/config.ts +++ b/src/configs/eslint/config.ts @@ -16,7 +16,7 @@ import { cwd } from 'node:process'; import path from 'node:path'; -import { flow, mergeWith, isArray, isObject, isEmpty, uniq } from 'lodash/fp'; +import { deepmergeCustom } from 'deepmerge-ts'; import { Language, @@ -27,30 +27,25 @@ import { } from '../../types/shared'; import * as logger from '../../lib/logger'; import { getOptions } from '../../lib/options'; +import { isEmpty, flow, uniq } from '../../lib/helpers'; // NOTE: Using the Linter.Config interface from ESLint causes errors // and I couldn't figure out how to fix them. — @connor_baer type ESLintConfig = unknown; -export const customizeConfig = mergeWith(customizer); - -function isArrayTypeGuard(array: unknown): array is unknown[] { - return isArray(array); -} - -function customizer( - objValue: unknown, - srcValue: unknown, - key: string, -): unknown { - if (isArrayTypeGuard(objValue) && isArrayTypeGuard(srcValue)) { - return uniq([...objValue, ...srcValue]); - } - if (isObject(objValue) && isObject(srcValue)) { - return key === 'rules' ? { ...objValue, ...srcValue } : undefined; - } - return undefined; -} +export const customizeConfig = deepmergeCustom({ + mergeArrays: (values) => { + const [baseValue, sourceValue] = values; + return uniq([...baseValue, ...sourceValue]); + }, + mergeRecords: (values, utils, meta) => { + const [baseValue, sourceValue] = values; + if (meta?.key === 'rules') { + return { ...baseValue, ...sourceValue }; + } + return utils.actions.defaultMerge; + }, +}); export function getFileGlobsForWorkspaces( workspaces: Workspaces, @@ -452,7 +447,7 @@ function customizeLanguage(language: Language, useBiome: boolean) { return config; } const overrides = languageMap[language]; - return customizeConfig(config, overrides); + return overrides ? customizeConfig(config, overrides) : config; }; } @@ -516,7 +511,7 @@ function customizeEnvironments(environments: Environment[]) { } return environments.reduce((acc, environment: Environment) => { const overrides = environmentMap[environment]; - return customizeConfig(acc, overrides); + return overrides ? customizeConfig(acc, overrides) : acc; }, config); }; } @@ -614,7 +609,7 @@ function customizeFramework(frameworks: Framework[]) { return frameworks.reduce((acc, framework: Framework) => { const overrides = frameworkMap[framework]; - return customizeConfig(acc, overrides); + return overrides ? customizeConfig(acc, overrides) : acc; }, config); }; } @@ -709,7 +704,7 @@ function customizePlugin(plugins: Plugin[], workspaces: Workspaces) { return plugins.reduce((acc, plugin: Plugin) => { const overrides = pluginMap[plugin]; - return customizeConfig(acc, overrides); + return overrides ? customizeConfig(acc, overrides) : acc; }, config); }; } diff --git a/src/configs/husky/config.ts b/src/configs/husky/config.ts index 0b606634..4d172fc3 100644 --- a/src/configs/husky/config.ts +++ b/src/configs/husky/config.ts @@ -13,7 +13,7 @@ * limitations under the License. */ -import { merge } from 'lodash/fp'; +import { deepmerge } from 'deepmerge-ts'; interface HuskyConfig { skipCI?: boolean; @@ -27,5 +27,5 @@ export const base: HuskyConfig = { }; export function config(overrides: HuskyConfig = {}): HuskyConfig { - return merge(base, overrides); + return deepmerge(base, overrides); } diff --git a/src/configs/stylelint/config.ts b/src/configs/stylelint/config.ts index 94abfeb4..671eeb08 100644 --- a/src/configs/stylelint/config.ts +++ b/src/configs/stylelint/config.ts @@ -14,30 +14,25 @@ */ import type { Config as StylelintConfig } from 'stylelint'; -import { flow, mergeWith, isArray, isObject, uniq, isEmpty } from 'lodash/fp'; +import { deepmergeCustom } from 'deepmerge-ts'; import { getOptions } from '../../lib/options'; +import { isEmpty, flow, uniq } from '../../lib/helpers'; import { Plugin } from '../../types/shared'; -export const customizeConfig = mergeWith(customizer); - -function isArrayTypeGuard(array: unknown): array is unknown[] { - return isArray(array); -} - -function customizer( - objValue: unknown, - srcValue: unknown, - key: string, -): unknown { - if (isArrayTypeGuard(objValue) && isArrayTypeGuard(srcValue)) { - return uniq([...objValue, ...srcValue]); - } - if (isObject(objValue) && isObject(srcValue)) { - return key === 'rules' ? { ...objValue, ...srcValue } : undefined; - } - return undefined; -} +export const customizeConfig = deepmergeCustom({ + mergeArrays: (values) => { + const [baseValue, sourceValue] = values; + return uniq([...baseValue, ...sourceValue]); + }, + mergeRecords: (values, utils, meta) => { + const [baseValue, sourceValue] = values; + if (meta?.key === 'rules') { + return { ...baseValue, ...sourceValue }; + } + return utils.actions.defaultMerge; + }, +}); const base: StylelintConfig = { extends: ['stylelint-config-standard', 'stylelint-config-recess-order'], @@ -87,7 +82,7 @@ function customizePlugin(plugins: Plugin[]) { return plugins.reduce((acc, plugin: Plugin) => { const overrides = pluginMap[plugin]; - return customizeConfig(acc, overrides); + return overrides ? customizeConfig(acc, overrides) : config; }, config); }; } diff --git a/src/lib/choices.ts b/src/lib/choices.ts index 867930e6..5f31ab3d 100644 --- a/src/lib/choices.ts +++ b/src/lib/choices.ts @@ -13,28 +13,24 @@ * limitations under the License. */ -import { isArray } from 'lodash/fp'; +import { isArray } from './type-check'; type Enum = { [key: string]: string }; type Choices = { [key: string]: Enum | Enum[] }; type Combination = { [key: string]: string | string[] }; -function isArrayTypeGuard(array: unknown): array is unknown[] { - return isArray(array); -} - export function getAllChoiceCombinations( possibleChoices: Choices, ): Combination[] { return Object.entries(possibleChoices).reduce( (acc, [optionName, choices]) => { - const choiceEnum = isArrayTypeGuard(choices) ? choices[0] : choices; + const choiceEnum = isArray(choices) ? choices[0] : choices; const choicesForOption = Object.values(choiceEnum); const allCombinations: Combination[] = []; acc.forEach((combination: Combination) => { choicesForOption.forEach((value) => { - const choice = isArrayTypeGuard(choices) ? [value] : value; + const choice = isArray(choices) ? [value] : value; allCombinations.push({ ...combination, [optionName]: choice, diff --git a/src/lib/helpers.spec.ts b/src/lib/helpers.spec.ts new file mode 100644 index 00000000..8df9b848 --- /dev/null +++ b/src/lib/helpers.spec.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { flow, uniq } from './helpers.js'; + +describe('helpers', () => { + describe('flow', () => { + it('should call each function', () => { + const fn1 = vi.fn((value: string) => value); + const fn2 = vi.fn((value: string) => value); + const fn3 = vi.fn((value: string) => value); + const value = 'foo'; + const actual = flow(fn1, fn2, fn3)(value); + + expect(fn1).toHaveBeenCalledWith(value); + expect(fn2).toHaveBeenCalledWith(value); + expect(fn3).toHaveBeenCalledWith(value); + expect(actual).toBe(value); + }); + + it('should call the next function with the previous return value', () => { + const fn1 = vi.fn((value: number) => value + 1); + const fn2 = vi.fn((value: number) => value + 2); + const fn3 = vi.fn((value: number) => value + 3); + const value = 0; + const actual = flow(fn1, fn2, fn3)(value); + + expect(fn1).toHaveBeenCalledWith(0); + expect(fn2).toHaveBeenCalledWith(1); + expect(fn3).toHaveBeenCalledWith(3); + expect(actual).toBe(6); + }); + }); + + describe('uniq', () => { + it('should return an array of unique values', () => { + const values = [1, 3, 3, 4, 5, 1, 'foo', 'bar', 'foo']; + const actual = uniq(values); + expect(actual).toEqual([1, 3, 4, 5, 'foo', 'bar']); + }); + }); +}); diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts new file mode 100644 index 00000000..5723999d --- /dev/null +++ b/src/lib/helpers.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isArray, isObject, isString } from './type-check'; + +export function isEmpty(value: unknown): boolean { + if (!value) { + return true; + } + if (isString(value) || isArray(value)) { + return !value.length; + } + if (isObject(value)) { + return !Object.keys(value).length; + } + return false; +} + +export function flow(...fns: ((value: T) => T)[]) { + return (value: T) => + fns.reduce((currentValue, fn) => fn(currentValue), value); +} + +export function uniq(values: T[]): T[] { + const set = new Set(values); + return Array.from(set); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index a78e9312..f5906a7d 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -16,13 +16,15 @@ /* eslint-disable no-console */ import chalk from 'chalk'; +import { isArray } from './type-check'; + type LogMessage = string | string[]; const IS_DEBUG = process.argv.includes('--debug') || process.env.NODE_ENV === 'DEBUG'; const getMessage = (arg: LogMessage): string => { - const message = Array.isArray(arg) ? arg.join('\n') : arg; + const message = isArray(arg) ? arg.join('\n') : arg; return message; }; diff --git a/src/lib/type-check.spec.ts b/src/lib/type-check.spec.ts new file mode 100644 index 00000000..07b42b4b --- /dev/null +++ b/src/lib/type-check.spec.ts @@ -0,0 +1,176 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + isArray, + isFunction, + isNil, + isObject, + isString, +} from './type-check.js'; + +describe('type check', () => { + describe('isFunction', () => { + it('should return true for a function', () => { + const actual = isFunction(vi.fn()); + expect(actual).toBeTruthy(); + }); + + it('should return false for an object', () => { + const actual = isFunction({}); + expect(actual).toBeFalsy(); + }); + + it('should return false for an array', () => { + const actual = isFunction([]); + expect(actual).toBeFalsy(); + }); + + it('should return false for a date', () => { + const actual = isFunction(new Date()); + expect(actual).toBeFalsy(); + }); + + it('should return false for null', () => { + const actual = isFunction(null); + expect(actual).toBeFalsy(); + }); + + it('should return false for undefined', () => { + const actual = isFunction(undefined); + expect(actual).toBeFalsy(); + }); + }); + + describe('isString', () => { + it('should return true for a string', () => { + const actual = isString(''); + expect(actual).toBeTruthy(); + }); + + it('should return false for an integer', () => { + const actual = isString(1); + expect(actual).toBeFalsy(); + }); + + it('should return false for an object', () => { + const actual = isString({ foo: 'bar' }); + expect(actual).toBeFalsy(); + }); + + it('should return false for an array of strings', () => { + const actual = isString(['foo', 'bar']); + expect(actual).toBeFalsy(); + }); + + it('should return false for null', () => { + const actual = isString(null); + expect(actual).toBeFalsy(); + }); + + it('should return false for undefined', () => { + const actual = isString(undefined); + expect(actual).toBeFalsy(); + }); + }); + + describe('isArray', () => { + it('should return true for an array', () => { + const actual = isArray([]); + expect(actual).toBeTruthy(); + }); + + it('should return false for an object', () => { + const actual = isArray({ foo: 'bar' }); + expect(actual).toBeFalsy(); + }); + + it('should return false for a function', () => { + const actual = isArray(vi.fn()); + expect(actual).toBeFalsy(); + }); + + it('should return false for null', () => { + const actual = isArray(null); + expect(actual).toBeFalsy(); + }); + + it('should return false for undefined', () => { + const actual = isArray(undefined); + expect(actual).toBeFalsy(); + }); + }); + + describe('isObject', () => { + it('should return true for an object', () => { + const actual = isObject({}); + expect(actual).toBeTruthy(); + }); + + it('should return false for an array', () => { + const actual = isObject([]); + expect(actual).toBeFalsy(); + }); + + it('should return false for a function', () => { + const actual = isObject(vi.fn()); + expect(actual).toBeFalsy(); + }); + + it('should return false for null', () => { + const actual = isObject(null); + expect(actual).toBeFalsy(); + }); + + it('should return false for undefined', () => { + const actual = isObject(undefined); + expect(actual).toBeFalsy(); + }); + }); + + describe('isNil', () => { + it('should return true for null', () => { + const actual = isNil(null); + expect(actual).toBeTruthy(); + }); + + it('should return true for undefined', () => { + const actual = isNil(undefined); + expect(actual).toBeTruthy(); + }); + + it('should return false for a boolean', () => { + const actual = isNil(true); + expect(actual).toBeFalsy(); + }); + + it('should return false for a function', () => { + const actual = isNil(vi.fn()); + expect(actual).toBeFalsy(); + }); + + it('should return false for an integer', () => { + const actual = isNil(1); + expect(actual).toBeFalsy(); + }); + + it('should return false for NaN', () => { + const actual = isNil(Number.NaN); + expect(actual).toBeFalsy(); + }); + }); +}); diff --git a/src/lib/type-check.ts b/src/lib/type-check.ts new file mode 100644 index 00000000..ca51965a --- /dev/null +++ b/src/lib/type-check.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// biome-ignore lint/complexity/noBannedTypes: There is no better type for this type guard +export function isFunction(value?: unknown): value is Function { + return typeof value === 'function'; +} + +export function isString(value?: unknown): value is string { + return typeof value === 'string'; +} + +export function isArray(value?: unknown): value is T[] { + return Array.isArray(value); +} + +export function isObject>( + value: unknown, +): value is T { + return value === Object(value) && !isArray(value) && !isFunction(value); +} + +export function isNil(value?: unknown): value is null | undefined { + return value === undefined || value === null; +}