From b84c71d6bfc69cced899fd9a8abfd41147de441b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kh=E1=BA=A3i?= Date: Thu, 14 Aug 2025 15:28:49 +0700 Subject: [PATCH] feat(cli/config): get/set objects and property paths (#9811) close #9797 --- .changeset/empty-ducks-stare.md | 6 + .changeset/fancy-stars-punch.md | 6 + .changeset/free-buttons-fly.md | 5 + .changeset/full-birds-cheer.md | 6 + config/plugin-commands-config/package.json | 4 + .../plugin-commands-config/src/configGet.ts | 30 ++++- .../plugin-commands-config/src/configSet.ts | 57 +++++++- .../src/isStrictlyKebabCase.ts | 10 ++ .../src/parseConfigPropertyPath.ts | 17 +++ .../test/configGet.test.ts | 119 +++++++++++++++++ .../test/configSet.test.ts | 78 +++++++++++ .../test/isStrictlyKebabCase.test.ts | 43 ++++++ .../test/managingAuthSettings.test.ts | 79 +++++++++++- config/plugin-commands-config/tsconfig.json | 3 + object/property-path/README.md | 17 +++ object/property-path/package.json | 46 +++++++ object/property-path/src/get.ts | 29 +++++ object/property-path/src/index.ts | 3 + object/property-path/src/parse.ts | 122 ++++++++++++++++++ object/property-path/src/token/ExactToken.ts | 14 ++ object/property-path/src/token/Identifier.ts | 24 ++++ .../property-path/src/token/NumericLiteral.ts | 44 +++++++ .../property-path/src/token/ParseErrorBase.ts | 7 + .../property-path/src/token/StringLiteral.ts | 79 ++++++++++++ object/property-path/src/token/Whitespace.ts | 12 ++ object/property-path/src/token/combine.ts | 9 ++ object/property-path/src/token/index.ts | 10 ++ object/property-path/src/token/tokenize.ts | 55 ++++++++ object/property-path/src/token/types.ts | 10 ++ object/property-path/test/get.test.ts | 82 ++++++++++++ object/property-path/test/parse.test.ts | 118 +++++++++++++++++ .../test/token/Identifier.test.ts | 90 +++++++++++++ .../test/token/NumericLiteral.test.ts | 55 ++++++++ .../test/token/StringLiteral.test.ts | 85 ++++++++++++ .../property-path/test/token/tokenize.test.ts | 51 ++++++++ object/property-path/test/tsconfig.json | 17 +++ object/property-path/tsconfig.json | 16 +++ object/property-path/tsconfig.lint.json | 8 ++ pnpm-lock.yaml | 13 ++ 39 files changed, 1467 insertions(+), 12 deletions(-) create mode 100644 .changeset/empty-ducks-stare.md create mode 100644 .changeset/fancy-stars-punch.md create mode 100644 .changeset/free-buttons-fly.md create mode 100644 .changeset/full-birds-cheer.md create mode 100644 config/plugin-commands-config/src/isStrictlyKebabCase.ts create mode 100644 config/plugin-commands-config/src/parseConfigPropertyPath.ts create mode 100644 config/plugin-commands-config/test/isStrictlyKebabCase.test.ts create mode 100644 object/property-path/README.md create mode 100644 object/property-path/package.json create mode 100644 object/property-path/src/get.ts create mode 100644 object/property-path/src/index.ts create mode 100644 object/property-path/src/parse.ts create mode 100644 object/property-path/src/token/ExactToken.ts create mode 100644 object/property-path/src/token/Identifier.ts create mode 100644 object/property-path/src/token/NumericLiteral.ts create mode 100644 object/property-path/src/token/ParseErrorBase.ts create mode 100644 object/property-path/src/token/StringLiteral.ts create mode 100644 object/property-path/src/token/Whitespace.ts create mode 100644 object/property-path/src/token/combine.ts create mode 100644 object/property-path/src/token/index.ts create mode 100644 object/property-path/src/token/tokenize.ts create mode 100644 object/property-path/src/token/types.ts create mode 100644 object/property-path/test/get.test.ts create mode 100644 object/property-path/test/parse.test.ts create mode 100644 object/property-path/test/token/Identifier.test.ts create mode 100644 object/property-path/test/token/NumericLiteral.test.ts create mode 100644 object/property-path/test/token/StringLiteral.test.ts create mode 100644 object/property-path/test/token/tokenize.test.ts create mode 100644 object/property-path/test/tsconfig.json create mode 100644 object/property-path/tsconfig.json create mode 100644 object/property-path/tsconfig.lint.json diff --git a/.changeset/empty-ducks-stare.md b/.changeset/empty-ducks-stare.md new file mode 100644 index 0000000000..f1abb92e87 --- /dev/null +++ b/.changeset/empty-ducks-stare.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-config": minor +"pnpm": minor +--- + +`pnpm config get` now prints an INI string for an object value [#9797](https://github.com/pnpm/pnpm/issues/9797). diff --git a/.changeset/fancy-stars-punch.md b/.changeset/fancy-stars-punch.md new file mode 100644 index 0000000000..e99d55c9fe --- /dev/null +++ b/.changeset/fancy-stars-punch.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-config": minor +"pnpm": minor +--- + +`pnpm config get` now accepts property paths (e.g. `pnpm config get catalog.react`, `pnpm config get .catalog.react`, `pnpm config get 'packageExtensions["@babel/parser"].peerDependencies["@babel/types"]'`), and `pnpm config set` now accepts dot-leading or subscripted keys (e.g. `pnpm config set .ignoreScripts true`). diff --git a/.changeset/free-buttons-fly.md b/.changeset/free-buttons-fly.md new file mode 100644 index 0000000000..16317a923b --- /dev/null +++ b/.changeset/free-buttons-fly.md @@ -0,0 +1,5 @@ +--- +"@pnpm/object.property-path": major +--- + +Initial Release. diff --git a/.changeset/full-birds-cheer.md b/.changeset/full-birds-cheer.md new file mode 100644 index 0000000000..c1c859ded4 --- /dev/null +++ b/.changeset/full-birds-cheer.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-config": minor +"pnpm": minor +--- + +`pnpm config get --json` now prints a JSON serialization of config value, and `pnpm config set --json` now parses the input value as JSON. diff --git a/config/plugin-commands-config/package.json b/config/plugin-commands-config/package.json index 431d4cd966..abec98c12b 100644 --- a/config/plugin-commands-config/package.json +++ b/config/plugin-commands-config/package.json @@ -36,6 +36,7 @@ "@pnpm/config": "workspace:*", "@pnpm/error": "workspace:*", "@pnpm/object.key-sorting": "workspace:*", + "@pnpm/object.property-path": "workspace:*", "@pnpm/run-npm": "workspace:*", "@pnpm/workspace.manifest-writer": "workspace:*", "camelcase": "catalog:", @@ -45,6 +46,9 @@ "render-help": "catalog:", "write-ini-file": "catalog:" }, + "peerDependencies": { + "@pnpm/logger": "catalog:" + }, "devDependencies": { "@pnpm/logger": "workspace:*", "@pnpm/plugin-commands-config": "workspace:*", diff --git a/config/plugin-commands-config/src/configGet.ts b/config/plugin-commands-config/src/configGet.ts index 7815e16379..1d6381c9bc 100644 --- a/config/plugin-commands-config/src/configGet.ts +++ b/config/plugin-commands-config/src/configGet.ts @@ -1,6 +1,11 @@ import kebabCase from 'lodash.kebabcase' +import { encode } from 'ini' +import { globalWarn } from '@pnpm/logger' +import { getObjectValueByPropertyPath } from '@pnpm/object.property-path' import { runNpm } from '@pnpm/run-npm' import { type ConfigCommandOptions } from './ConfigCommandOptions' +import { isStrictlyKebabCase } from './isStrictlyKebabCase' +import { parseConfigPropertyPath } from './parseConfigPropertyPath' import { settingShouldFallBackToNpm } from './settingShouldFallBackToNpm' export function configGet (opts: ConfigCommandOptions, key: string): { output: string, exitCode: number } { @@ -8,6 +13,27 @@ export function configGet (opts: ConfigCommandOptions, key: string): { output: s const { status: exitCode } = runNpm(opts.npmPath, ['config', 'get', key]) return { output: '', exitCode: exitCode ?? 0 } } - const config = opts.rawConfig[kebabCase(key)] - return { output: Array.isArray(config) ? config.join(',') : String(config), exitCode: 0 } + const config = isStrictlyKebabCase(key) + ? opts.rawConfig[kebabCase(key)] // we don't parse kebab-case keys as property paths because it's not a valid JS syntax + : getConfigByPropertyPath(opts.rawConfig, key) + const output = displayConfig(config, opts) + return { output, exitCode: 0 } +} + +function getConfigByPropertyPath (rawConfig: Record, propertyPath: string): unknown { + return getObjectValueByPropertyPath(rawConfig, parseConfigPropertyPath(propertyPath)) +} + +type DisplayConfigOptions = Pick + +function displayConfig (config: unknown, opts: DisplayConfigOptions): string { + if (opts.json) return JSON.stringify(config, undefined, 2) + if (Array.isArray(config)) { + globalWarn('`pnpm config get` would display an array as comma-separated list due to legacy implementation, use `--json` to print them as json') + return config.join(',') // TODO: change this in the next major version + } + if (typeof config === 'object' && config != null) { + return encode(config) + } + return String(config) } diff --git a/config/plugin-commands-config/src/configSet.ts b/config/plugin-commands-config/src/configSet.ts index 52810fe15c..33443efd96 100644 --- a/config/plugin-commands-config/src/configSet.ts +++ b/config/plugin-commands-config/src/configSet.ts @@ -2,6 +2,8 @@ import fs from 'fs' import path from 'path' import util from 'util' import { types } from '@pnpm/config' +import { PnpmError } from '@pnpm/error' +import { parsePropertyPath } from '@pnpm/object.property-path' import { runNpm } from '@pnpm/run-npm' import { updateWorkspaceManifest } from '@pnpm/workspace.manifest-writer' import camelCase from 'camelcase' @@ -9,17 +11,30 @@ import kebabCase from 'lodash.kebabcase' import { readIniFile } from 'read-ini-file' import { writeIniFile } from 'write-ini-file' import { type ConfigCommandOptions } from './ConfigCommandOptions' +import { isStrictlyKebabCase } from './isStrictlyKebabCase' import { settingShouldFallBackToNpm } from './settingShouldFallBackToNpm' -export async function configSet (opts: ConfigCommandOptions, key: string, value: string | null): Promise { +export async function configSet (opts: ConfigCommandOptions, key: string, valueParam: string | null): Promise { + let shouldFallbackToNpm = settingShouldFallBackToNpm(key) + if (!shouldFallbackToNpm) { + key = validateSimpleKey(key) + shouldFallbackToNpm = settingShouldFallBackToNpm(key) + } + let value: unknown = valueParam + if (valueParam != null && opts.json) { + value = JSON.parse(valueParam) + } if (opts.global && settingShouldFallBackToNpm(key)) { const _runNpm = runNpm.bind(null, opts.npmPath) if (value == null) { _runNpm(['config', 'delete', key]) - } else { - _runNpm(['config', 'set', `${key}=${value}`]) + return } - return + if (typeof value === 'string') { + _runNpm(['config', 'set', `${key}=${value}`]) + return + } + throw new PnpmError('CONFIG_SET_AUTH_NON_STRING', `Cannot set ${key} to a non-string value (${JSON.stringify(value)})`) } if (opts.global === true || fs.existsSync(path.join(opts.dir, '.npmrc'))) { const configPath = opts.global ? path.join(opts.configDir, 'rc') : path.join(opts.dir, '.npmrc') @@ -75,6 +90,40 @@ function castField (value: unknown, key: string) { return value } +export class ConfigSetKeyEmptyKeyError extends PnpmError { + constructor () { + super('CONFIG_SET_EMPTY_KEY', 'Cannot set config with an empty key') + } +} + +export class ConfigSetDeepKeyError extends PnpmError { + constructor () { + // it shouldn't be supported until there is a mechanism to validate the config value + super('CONFIG_SET_DEEP_KEY', 'Setting deep property path is not supported') + } +} + +/** + * Validate if {@link key} is a simple key or a property path. + * + * If it is an empty property path or a property path longer than 1, throw an error. + * + * If it is a simple key (or a property path with length of 1), return it. + */ +function validateSimpleKey (key: string): string { + if (isStrictlyKebabCase(key)) return key + + const iter = parsePropertyPath(key) + + const first = iter.next() + if (first.done) throw new ConfigSetKeyEmptyKeyError() + + const second = iter.next() + if (!second.done) throw new ConfigSetDeepKeyError() + + return first.value.toString() +} + async function safeReadIniFile (configPath: string): Promise> { try { return await readIniFile(configPath) as Record diff --git a/config/plugin-commands-config/src/isStrictlyKebabCase.ts b/config/plugin-commands-config/src/isStrictlyKebabCase.ts new file mode 100644 index 0000000000..0f622ddb93 --- /dev/null +++ b/config/plugin-commands-config/src/isStrictlyKebabCase.ts @@ -0,0 +1,10 @@ +/** + * Check if a name is strictly kebab-case. + * + * "Strictly kebab-case" means that the name is kebab-case and has at least 2 words. + */ +export function isStrictlyKebabCase (name: string): boolean { + const segments = name.split('-') + if (segments.length < 2) return false + return segments.every(segment => /^[a-z][a-z0-9]*$/.test(segment)) +} diff --git a/config/plugin-commands-config/src/parseConfigPropertyPath.ts b/config/plugin-commands-config/src/parseConfigPropertyPath.ts new file mode 100644 index 0000000000..2d30ad6a85 --- /dev/null +++ b/config/plugin-commands-config/src/parseConfigPropertyPath.ts @@ -0,0 +1,17 @@ +import kebabCase from 'lodash.kebabcase' +import { parsePropertyPath } from '@pnpm/object.property-path' + +/** + * Just like {@link parsePropertyPath} but the first element is converted into kebab-case. + */ +export function * parseConfigPropertyPath (propertyPath: string): Generator { + const iter = parsePropertyPath(propertyPath) + + const first = iter.next() + if (first.done) return + yield typeof first.value === 'string' + ? kebabCase(first.value) + : first.value + + yield * iter +} diff --git a/config/plugin-commands-config/test/configGet.test.ts b/config/plugin-commands-config/test/configGet.test.ts index 09cfcbacf8..7ed59e3661 100644 --- a/config/plugin-commands-config/test/configGet.test.ts +++ b/config/plugin-commands-config/test/configGet.test.ts @@ -1,5 +1,20 @@ +import * as ini from 'ini' import { config } from '@pnpm/plugin-commands-config' +/** + * Recursively clone an object and give every object inside the clone a null prototype. + * Making it possible to compare it to the result of `ini.decode` with `toStrictEqual`. + */ +function deepNullProto (value: Value): Value { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return value + + const result: Value = Object.create(null) + for (const key in value) { + result[key] = deepNullProto(value[key]) + } + return result +} + test('config get', async () => { const getResult = await config.handler({ dir: process.cwd(), @@ -59,6 +74,22 @@ test('config get on array should return a comma-separated list', async () => { expect(typeof getResult === 'object' && 'output' in getResult && getResult.output).toBe('*eslint*,*prettier*') }) +test('config get on object should return an ini string', async () => { + const getResult = await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir: process.cwd(), + global: true, + rawConfig: { + catalog: { + react: '^19.0.0', + }, + }, + }, ['get', 'catalog']) + + expect(typeof getResult === 'object' && 'output' in getResult && ini.decode(getResult.output)).toStrictEqual(deepNullProto({ react: '^19.0.0' })) +}) + test('config get without key show list all settings ', async () => { const rawConfig = { 'store-dir': '~/store', @@ -81,3 +112,91 @@ test('config get without key show list all settings ', async () => { expect(getOutput).toEqual(listOutput) }) + +describe('config get with a property path', () => { + function getOutputString (result: config.ConfigHandlerResult): string { + if (result == null) throw new Error('output is null or undefined') + if (typeof result === 'string') return result + if (typeof result === 'object') return result.output + const _typeGuard: never = result // eslint-disable-line @typescript-eslint/no-unused-vars + throw new Error('unreachable') + } + + const rawConfig = { + // rawConfig keys are always kebab-case + 'package-extensions': { + '@babel/parser': { + peerDependencies: { + '@babel/types': '*', + }, + }, + 'jest-circus': { + dependencies: { + slash: '3', + }, + }, + }, + } + + describe('anything with --json', () => { + test.each([ + ['', rawConfig], + ['packageExtensions', rawConfig['package-extensions']], + ['packageExtensions["@babel/parser"]', rawConfig['package-extensions']['@babel/parser']], + ['packageExtensions["@babel/parser"].peerDependencies', rawConfig['package-extensions']['@babel/parser'].peerDependencies], + ['packageExtensions["@babel/parser"].peerDependencies["@babel/types"]', rawConfig['package-extensions']['@babel/parser'].peerDependencies['@babel/types']], + ['packageExtensions["jest-circus"]', rawConfig['package-extensions']['jest-circus']], + ['packageExtensions["jest-circus"].dependencies', rawConfig['package-extensions']['jest-circus'].dependencies], + ['packageExtensions["jest-circus"].dependencies.slash', rawConfig['package-extensions']['jest-circus'].dependencies.slash], + ] as Array<[string, unknown]>)('%s', async (propertyPath, expected) => { + const getResult = await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir: process.cwd(), + global: true, + json: true, + rawConfig, + }, ['get', propertyPath]) + + expect(JSON.parse(getOutputString(getResult))).toStrictEqual(expected) + }) + }) + + describe('object without --json', () => { + test.each([ + ['', rawConfig], + ['packageExtensions', rawConfig['package-extensions']], + ['packageExtensions["@babel/parser"]', rawConfig['package-extensions']['@babel/parser']], + ['packageExtensions["@babel/parser"].peerDependencies', rawConfig['package-extensions']['@babel/parser'].peerDependencies], + ['packageExtensions["jest-circus"]', rawConfig['package-extensions']['jest-circus']], + ['packageExtensions["jest-circus"].dependencies', rawConfig['package-extensions']['jest-circus'].dependencies], + ] as Array<[string, unknown]>)('%s', async (propertyPath, expected) => { + const getResult = await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir: process.cwd(), + global: true, + rawConfig, + }, ['get', propertyPath]) + + expect(ini.decode(getOutputString(getResult))).toStrictEqual(deepNullProto(expected)) + }) + }) + + describe('string without --json', () => { + test.each([ + ['packageExtensions["@babel/parser"].peerDependencies["@babel/types"]', rawConfig['package-extensions']['@babel/parser'].peerDependencies['@babel/types']], + ['packageExtensions["jest-circus"].dependencies.slash', rawConfig['package-extensions']['jest-circus'].dependencies.slash], + ] as Array<[string, string]>)('%s', async (propertyPath, expected) => { + const getResult = await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir: process.cwd(), + global: true, + rawConfig, + }, ['get', propertyPath]) + + expect(getOutputString(getResult)).toStrictEqual(expected) + }) + }) +}) diff --git a/config/plugin-commands-config/test/configSet.test.ts b/config/plugin-commands-config/test/configSet.test.ts index 5a27068db7..4087df1320 100644 --- a/config/plugin-commands-config/test/configSet.test.ts +++ b/config/plugin-commands-config/test/configSet.test.ts @@ -212,3 +212,81 @@ test('config set or delete throws missing params error', async () => { rawConfig: {}, }, ['delete'])).rejects.toThrow(new PnpmError('CONFIG_NO_PARAMS', '`pnpm config delete` requires the config key')) }) + +test('config set with dot leading key', async () => { + const tmp = tempDir() + const configDir = path.join(tmp, 'global-config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store') + + await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir, + global: true, + rawConfig: {}, + }, ['set', '.fetchRetries', '1']) + + expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({ + 'store-dir': '~/store', + 'fetch-retries': '1', + }) +}) + +test('config set with subscripted key', async () => { + const tmp = tempDir() + const configDir = path.join(tmp, 'global-config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store') + + await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir, + global: true, + rawConfig: {}, + }, ['set', '["fetch-retries"]', '1']) + + expect(readIniFileSync(path.join(configDir, 'rc'))).toEqual({ + 'store-dir': '~/store', + 'fetch-retries': '1', + }) +}) + +test('config set rejects complex property path', async () => { + const tmp = tempDir() + const configDir = path.join(tmp, 'global-config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'rc'), 'store-dir=~/store') + + await expect(config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir, + global: true, + rawConfig: {}, + }, ['set', '.catalog.react', '19'])).rejects.toMatchObject({ + code: 'ERR_PNPM_CONFIG_SET_DEEP_KEY', + }) +}) + +test('config set with location=project and json=true', async () => { + const tmp = tempDir() + const configDir = path.join(tmp, 'global-config') + fs.mkdirSync(configDir, { recursive: true }) + + await config.handler({ + dir: process.cwd(), + cliOptions: {}, + configDir, + location: 'project', + json: true, + rawConfig: {}, + }, ['set', 'catalog', '{ "react": "19" }']) + + expect(readYamlFile(path.join(tmp, 'pnpm-workspace.yaml'))).toStrictEqual({ + catalog: { + react: '19', + }, + }) +}) diff --git a/config/plugin-commands-config/test/isStrictlyKebabCase.test.ts b/config/plugin-commands-config/test/isStrictlyKebabCase.test.ts new file mode 100644 index 0000000000..828f7dcd56 --- /dev/null +++ b/config/plugin-commands-config/test/isStrictlyKebabCase.test.ts @@ -0,0 +1,43 @@ +import { isStrictlyKebabCase } from '../src/isStrictlyKebabCase' + +test('kebab-case names with more than 1 words should satisfy', () => { + expect(isStrictlyKebabCase('foo-bar')).toBe(true) + expect(isStrictlyKebabCase('foo-bar123')).toBe(true) + expect(isStrictlyKebabCase('a123-foo')).toBe(true) +}) + +test('names with uppercase letters should not satisfy', () => { + expect(isStrictlyKebabCase('foo-Bar')).toBe(false) + expect(isStrictlyKebabCase('Foo-Bar')).toBe(false) + expect(isStrictlyKebabCase('Foo-bar')).toBe(false) +}) + +test('names with underscores should not satisfy', () => { + expect(isStrictlyKebabCase('foo_bar')).toBe(false) + expect(isStrictlyKebabCase('foo-bar_baz')).toBe(false) + expect(isStrictlyKebabCase('_foo-bar')).toBe(false) +}) + +test('names with only 1 word should not satisfy', () => { + expect(isStrictlyKebabCase('foo')).toBe(false) + expect(isStrictlyKebabCase('bar')).toBe(false) + expect(isStrictlyKebabCase('a123')).toBe(false) +}) + +test('names that start with a number should not satisfy', () => { + expect(isStrictlyKebabCase('123a')).toBe(false) +}) + +test('names with two or more dashes next to each other should not satisfy', () => { + expect(isStrictlyKebabCase('foo--bar')).toBe(false) + expect(isStrictlyKebabCase('foo-bar--baz')).toBe(false) +}) + +test('names that start or end with a dash should not satisfy', () => { + expect(isStrictlyKebabCase('-foo-bar')).toBe(false) + expect(isStrictlyKebabCase('foo-bar-')).toBe(false) +}) + +test('names with special characters should not satisfy', () => { + expect(isStrictlyKebabCase('foo@bar')).toBe(false) +}) diff --git a/config/plugin-commands-config/test/managingAuthSettings.test.ts b/config/plugin-commands-config/test/managingAuthSettings.test.ts index d8b9b49c3a..cdecfef222 100644 --- a/config/plugin-commands-config/test/managingAuthSettings.test.ts +++ b/config/plugin-commands-config/test/managingAuthSettings.test.ts @@ -16,18 +16,85 @@ describe.each( '//registry.npmjs.org/:_authToken', ] )('settings related to auth are handled by npm CLI', (key) => { + describe('without --json', () => { + const configOpts = { + dir: process.cwd(), + cliOptions: {}, + configDir: __dirname, // this doesn't matter, it won't be used + rawConfig: {}, + } + it(`should set ${key}`, async () => { + await config.handler(configOpts, ['set', `${key}=123`]) + expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'set', `${key}=123`]) + }) + it(`should delete ${key}`, async () => { + await config.handler(configOpts, ['delete', key]) + expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'delete', key]) + }) + }) + + describe('with --json', () => { + const configOpts = { + json: true, + dir: process.cwd(), + cliOptions: {}, + configDir: __dirname, // this doesn't matter, it won't be used + rawConfig: {}, + } + it(`should set ${key}`, async () => { + await config.handler(configOpts, ['set', key, '"123"']) + expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'set', `${key}=123`]) + }) + it(`should delete ${key}`, async () => { + await config.handler(configOpts, ['delete', key]) + expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'delete', key]) + }) + }) +}) + +describe.each( + [ + '_auth', + '_authToken', + '_password', + 'username', + 'registry', + '@foo:registry', + '//registry.npmjs.org/:_authToken', + ] +)('non-string values should be rejected', (key) => { + const configOpts = { + json: true, + dir: process.cwd(), + cliOptions: {}, + configDir: __dirname, // this doesn't matter, it won't be used + rawConfig: {}, + } + it(`${key} should reject a non-string value`, async () => { + await expect(config.handler(configOpts, ['set', key, '{}'])).rejects.toMatchObject({ + code: 'ERR_PNPM_CONFIG_SET_AUTH_NON_STRING', + }) + }) +}) + +describe.each( + [ + '._auth', + "['_auth']", + ] +)('%p is handled by npm CLI', (propertyPath) => { const configOpts = { dir: process.cwd(), cliOptions: {}, configDir: __dirname, // this doesn't matter, it won't be used rawConfig: {}, } - it(`should set ${key}`, async () => { - await config.handler(configOpts, ['set', `${key}=123`]) - expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'set', `${key}=123`]) + it('should set _auth', async () => { + await config.handler(configOpts, ['set', propertyPath, '123']) + expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'set', '_auth=123']) }) - it(`should delete ${key}`, async () => { - await config.handler(configOpts, ['delete', key]) - expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'delete', key]) + it('should delete _auth', async () => { + await config.handler(configOpts, ['delete', propertyPath]) + expect(runNpm).toHaveBeenCalledWith(undefined, ['config', 'delete', '_auth']) }) }) diff --git a/config/plugin-commands-config/tsconfig.json b/config/plugin-commands-config/tsconfig.json index 6db43d3691..1adb250676 100644 --- a/config/plugin-commands-config/tsconfig.json +++ b/config/plugin-commands-config/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../object/key-sorting" }, + { + "path": "../../object/property-path" + }, { "path": "../../packages/error" }, diff --git a/object/property-path/README.md b/object/property-path/README.md new file mode 100644 index 0000000000..5f4074a833 --- /dev/null +++ b/object/property-path/README.md @@ -0,0 +1,17 @@ +# @pnpm/object.property-path + +> Basic library to manipulate object property path which includes dots and subscriptions + + +[![npm version](https://img.shields.io/npm/v/@pnpm/object.property-path.svg)](https://www.npmjs.com/package/@pnpm/object.property-path) + + +## Installation + +```sh +pnpm add @pnpm/object.property-path +``` + +## License + +MIT diff --git a/object/property-path/package.json b/object/property-path/package.json new file mode 100644 index 0000000000..416df5aab7 --- /dev/null +++ b/object/property-path/package.json @@ -0,0 +1,46 @@ +{ + "name": "@pnpm/object.property-path", + "version": "1000.0.0-0", + "description": "Basic library to manipulate object property path which includes dots and subscriptions", + "keywords": [ + "pnpm", + "pnpm10", + "object.property-path" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/pnpm/blob/main/object/property-path", + "homepage": "https://github.com/pnpm/pnpm/blob/main/object/property-path#readme", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "type": "commonjs", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "test": "pnpm run compile && pnpm run _test", + "prepublishOnly": "pnpm run compile", + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "compile": "tsc --build && pnpm run lint --fix", + "_test": "jest" + }, + "dependencies": { + "@pnpm/error": "workspace:*" + }, + "devDependencies": { + "@pnpm/object.property-path": "workspace:*" + }, + "engines": { + "node": ">=18.12" + }, + "jest": { + "preset": "@pnpm/jest-config" + } +} diff --git a/object/property-path/src/get.ts b/object/property-path/src/get.ts new file mode 100644 index 0000000000..811d87660a --- /dev/null +++ b/object/property-path/src/get.ts @@ -0,0 +1,29 @@ +import { parsePropertyPath } from './parse' + +/** + * Get the value of a property path in a nested object. + * + * This function returns `undefined` if it meets non-object at some point. + */ +export function getObjectValueByPropertyPath (object: unknown, propertyPath: Iterable): unknown { + for (const name of propertyPath) { + if ( + typeof object !== 'object' || + object == null || + !Object.hasOwn(object, name) || + (Array.isArray(object) && typeof name !== 'number') + ) return undefined + + object = (object as Record)[name] + } + + return object +} + +/** + * Get the value of a property path in a nested object. + * + * This function returns `undefined` if it meets non-object at some point. + */ +export const getObjectValueByPropertyPathString = + (object: unknown, propertyPath: string): unknown => getObjectValueByPropertyPath(object, parsePropertyPath(propertyPath)) diff --git a/object/property-path/src/index.ts b/object/property-path/src/index.ts new file mode 100644 index 0000000000..40b8d55eb1 --- /dev/null +++ b/object/property-path/src/index.ts @@ -0,0 +1,3 @@ +export * from './token' +export * from './parse' +export * from './get' diff --git a/object/property-path/src/parse.ts b/object/property-path/src/parse.ts new file mode 100644 index 0000000000..22359d78b7 --- /dev/null +++ b/object/property-path/src/parse.ts @@ -0,0 +1,122 @@ +import assert from 'assert/strict' +import { PnpmError } from '@pnpm/error' +import { + type ExactToken, + type Identifier, + type NumericLiteral, + type StringLiteral, + type UnexpectedToken, + tokenize, +} from './token' + +export class UnexpectedTokenError | UnexpectedToken> extends PnpmError { + readonly token: Token + constructor (token: Token) { + super('UNEXPECTED_TOKEN_IN_PROPERTY_PATH', `Unexpected token ${JSON.stringify(token.content)} in property path`) + this.token = token + } +} + +export class UnexpectedIdentifierError extends PnpmError { + readonly token: Identifier + constructor (token: Identifier) { + super('UNEXPECTED_IDENTIFIER_IN_PROPERTY_PATH', `Unexpected identifier ${token.content} in property path`) + this.token = token + } +} + +export class UnexpectedLiteralError extends PnpmError { + readonly token: NumericLiteral | StringLiteral + constructor (token: NumericLiteral | StringLiteral) { + super('UNEXPECTED_LITERAL_IN_PROPERTY_PATH', `Unexpected literal ${JSON.stringify(token.content)} in property path`) + this.token = token + } +} + +export class UnexpectedEndOfInputError extends PnpmError { + constructor () { + super('UNEXPECTED_END_OF_PROPERTY_PATH', 'The property path does not end properly') + } +} + +/** + * Parse a string of property path. + * + * @example + * parsePropertyPath('foo.bar.baz') + * parsePropertyPath('.foo.bar.baz') + * parsePropertyPath('foo.bar["baz"]') + * parsePropertyPath("foo['bar'].baz") + * parsePropertyPath('["foo"].bar.baz') + * parsePropertyPath(`["foo"]['bar'].baz`) + * parsePropertyPath('foo[123]') + * + * @param propertyPath The string of property path to parse. + * @returns The parsed path in the form of an array. + */ +export function * parsePropertyPath (propertyPath: string): Generator { + type Stack = + | ExactToken<'.'> + | ExactToken<'['> + | [ExactToken<'['>, NumericLiteral | StringLiteral] + let stack: Stack | undefined + + for (const token of tokenize(propertyPath)) { + if (token.type === 'exact' && token.content === '.') { + if (!stack) { + stack = token + continue + } + + throw new UnexpectedTokenError(token) + } + + if (token.type === 'exact' && token.content === '[') { + if (!stack) { + stack = token + continue + } + + throw new UnexpectedTokenError(token) + } + + if (token.type === 'exact' && token.content === ']') { + if (!Array.isArray(stack)) throw new UnexpectedTokenError(token) + + const [openBracket, literal] = stack + assert.equal(openBracket.type, 'exact') + assert.equal(openBracket.content, '[') + assert(literal.type === 'numeric-literal' || literal.type === 'string-literal') + + yield literal.content + stack = undefined + continue + } + + if (token.type === 'identifier') { + if (!stack || ('type' in stack && stack.type === 'exact' && stack.content === '.')) { + stack = undefined + yield token.content + continue + } + + throw new UnexpectedIdentifierError(token) + } + + if (token.type === 'numeric-literal' || token.type === 'string-literal') { + if (stack && 'type' in stack && stack.type === 'exact' && stack.content === '[') { + stack = [stack, token] + continue + } + + throw new UnexpectedLiteralError(token) + } + + if (token.type === 'whitespace') continue + if (token.type === 'unexpected') throw new UnexpectedTokenError(token) + + const _typeGuard: never = token // eslint-disable-line @typescript-eslint/no-unused-vars + } + + if (stack) throw new UnexpectedEndOfInputError() +} diff --git a/object/property-path/src/token/ExactToken.ts b/object/property-path/src/token/ExactToken.ts new file mode 100644 index 0000000000..a66944a4e6 --- /dev/null +++ b/object/property-path/src/token/ExactToken.ts @@ -0,0 +1,14 @@ +import { type TokenBase, type Tokenize } from './types' + +export interface ExactToken extends TokenBase { + type: 'exact' + content: Content +} + +const createExactTokenParser = + (content: Content): Tokenize> => + source => source.startsWith(content) ? [{ type: 'exact', content }, source.slice(content.length)] : undefined + +export const parseDotOperator = createExactTokenParser('.') +export const parseOpenBracket = createExactTokenParser('[') +export const parseCloseBracket = createExactTokenParser(']') diff --git a/object/property-path/src/token/Identifier.ts b/object/property-path/src/token/Identifier.ts new file mode 100644 index 0000000000..a305a25dc3 --- /dev/null +++ b/object/property-path/src/token/Identifier.ts @@ -0,0 +1,24 @@ +import { type TokenBase, type Tokenize } from './types' + +export interface Identifier extends TokenBase { + type: 'identifier' + content: string +} + +export const parseIdentifier: Tokenize = source => { + if (source === '') return undefined + + const firstChar = source[0] + if (!/[a-z_]/i.test(firstChar)) return undefined + + let content = firstChar + source = source.slice(1) + while (source !== '') { + const char = source[0] + if (!/\w/.test(char)) break + source = source.slice(1) + content += char + } + + return [{ type: 'identifier', content }, source] +} diff --git a/object/property-path/src/token/NumericLiteral.ts b/object/property-path/src/token/NumericLiteral.ts new file mode 100644 index 0000000000..a5d2587d12 --- /dev/null +++ b/object/property-path/src/token/NumericLiteral.ts @@ -0,0 +1,44 @@ +import { ParseErrorBase } from './ParseErrorBase' +import { type TokenBase, type Tokenize } from './types' + +export interface NumericLiteral extends TokenBase { + type: 'numeric-literal' + content: number +} + +export class UnsupportedNumericSuffix extends ParseErrorBase { + readonly suffix: string + constructor (suffix: string) { + super('UNSUPPORTED_NUMERIC_LITERAL_SUFFIX', `Numeric suffix ${JSON.stringify(suffix)} is not supported`) + this.suffix = suffix + } +} + +export const parseNumericLiteral: Tokenize = source => { + if (source === '') return undefined + + const firstChar = source[0] + if (firstChar < '0' || firstChar > '9') return undefined + + let numberString = firstChar + source = source.slice(1) + + while (source !== '') { + const char = source[0] + + if (/[0-9.]/.test(char)) { + numberString += char + source = source.slice(1) + continue + } + + // We forbid things like `0x1A2E`, `1e20`, or `123n` for now. + if (/[a-z]/i.test(char)) { + throw new UnsupportedNumericSuffix(char) + } + + break + } + + return [{ type: 'numeric-literal', content: Number(numberString) }, source] +} diff --git a/object/property-path/src/token/ParseErrorBase.ts b/object/property-path/src/token/ParseErrorBase.ts new file mode 100644 index 0000000000..c5b80fd521 --- /dev/null +++ b/object/property-path/src/token/ParseErrorBase.ts @@ -0,0 +1,7 @@ +import { PnpmError } from '@pnpm/error' + +/** + * Base class for all parser errors. + * This allows consumer code to detect a parser error by simply checking `instanceof`. + */ +export abstract class ParseErrorBase extends PnpmError {} diff --git a/object/property-path/src/token/StringLiteral.ts b/object/property-path/src/token/StringLiteral.ts new file mode 100644 index 0000000000..db1f0dd2b9 --- /dev/null +++ b/object/property-path/src/token/StringLiteral.ts @@ -0,0 +1,79 @@ +import { ParseErrorBase } from './ParseErrorBase' +import { type TokenBase, type Tokenize } from './types' + +export type StringLiteralQuote = '"' | "'" + +export interface StringLiteral extends TokenBase { + type: 'string-literal' + quote: StringLiteralQuote + content: string +} + +const STRING_LITERAL_ESCAPES: Record = { + '\\': '\\', + "'": "'", + '"': '"', + b: '\b', + n: '\n', + r: '\r', + t: '\t', +} + +export class UnsupportedEscapeSequenceError extends ParseErrorBase { + readonly sequence: string + constructor (sequence: string) { + super('UNSUPPORTED_STRING_LITERAL_ESCAPE_SEQUENCE', `pnpm's string literal doesn't support ${JSON.stringify('\\' + sequence)}`) + this.sequence = sequence + } +} + +export class IncompleteStringLiteralError extends ParseErrorBase { + readonly expectedQuote: StringLiteralQuote + constructor (expectedQuote: StringLiteralQuote) { + super('INCOMPLETE_STRING_LITERAL', `Input ends without closing quote (${expectedQuote})`) + this.expectedQuote = expectedQuote + } +} + +export const parseStringLiteral: Tokenize = source => { + let quote: StringLiteralQuote + if (source.startsWith('"')) { + quote = '"' + } else if (source.startsWith("'")) { + quote = "'" + } else { + return undefined + } + + source = source.slice(1) + let content = '' + let escaped = false + + while (source !== '') { + const char = source[0] + source = source.slice(1) + + if (escaped) { + escaped = false + const realChar = STRING_LITERAL_ESCAPES[char] + if (!realChar) { + throw new UnsupportedEscapeSequenceError(char) + } + content += realChar + continue + } + + if (char === quote) { + return [{ type: 'string-literal', quote, content }, source] + } + + if (char === '\\') { + escaped = true + continue + } + + content += char + } + + throw new IncompleteStringLiteralError(quote) +} diff --git a/object/property-path/src/token/Whitespace.ts b/object/property-path/src/token/Whitespace.ts new file mode 100644 index 0000000000..793993bb23 --- /dev/null +++ b/object/property-path/src/token/Whitespace.ts @@ -0,0 +1,12 @@ +import { type TokenBase, type Tokenize } from './types' + +export interface Whitespace extends TokenBase { + type: 'whitespace' +} + +const WHITESPACE: Whitespace = { type: 'whitespace' } + +export const parseWhitespace: Tokenize = source => { + const remaining = source.trimStart() + return remaining === source ? undefined : [WHITESPACE, remaining] +} diff --git a/object/property-path/src/token/combine.ts b/object/property-path/src/token/combine.ts new file mode 100644 index 0000000000..012cf80f54 --- /dev/null +++ b/object/property-path/src/token/combine.ts @@ -0,0 +1,9 @@ +import { type TokenBase, type Tokenize } from './types' + +export const combineParsers = (parsers: Iterable>): Tokenize => source => { + for (const parse of parsers) { + const parseResult = parse(source) + if (parseResult) return parseResult + } + return undefined +} diff --git a/object/property-path/src/token/index.ts b/object/property-path/src/token/index.ts new file mode 100644 index 0000000000..2009de2c33 --- /dev/null +++ b/object/property-path/src/token/index.ts @@ -0,0 +1,10 @@ +export * from './ExactToken' +export * from './Identifier' +export * from './NumericLiteral' +export * from './StringLiteral' +export * from './Whitespace' + +export * from './ParseErrorBase' +export * from './combine' +export * from './tokenize' +export * from './types' diff --git a/object/property-path/src/token/tokenize.ts b/object/property-path/src/token/tokenize.ts new file mode 100644 index 0000000000..2d523239f0 --- /dev/null +++ b/object/property-path/src/token/tokenize.ts @@ -0,0 +1,55 @@ +import { type ExactToken, parseCloseBracket, parseDotOperator, parseOpenBracket } from './ExactToken' +import { type Identifier, parseIdentifier } from './Identifier' +import { type NumericLiteral, parseNumericLiteral } from './NumericLiteral' +import { type StringLiteral, parseStringLiteral } from './StringLiteral' +import { type Whitespace, parseWhitespace } from './Whitespace' +import { combineParsers } from './combine' +import { type TokenBase, type Tokenize } from './types' + +export type ExpectedToken = + | ExactToken<'.'> + | ExactToken<'['> + | ExactToken<']'> + | Identifier + | NumericLiteral + | StringLiteral + | Whitespace + +export const parseExpectedToken: Tokenize = combineParsers([ + parseDotOperator, + parseOpenBracket, + parseCloseBracket, + parseIdentifier, + parseNumericLiteral, + parseStringLiteral, + parseWhitespace, +]) + +export interface UnexpectedToken extends TokenBase { + type: 'unexpected' + content: string +} + +const parseUnexpectedToken: Tokenize = source => + [{ type: 'unexpected', content: source.slice(0, 1) }, source.slice(1)] + +export type Token = ExpectedToken | UnexpectedToken +export const parseToken = combineParsers([parseExpectedToken, parseUnexpectedToken]) + +/** Generate all tokens from a source text. */ +export function * tokenize (source: string): Generator { + while (source !== '') { + const parseResult = parseToken(source) + if (!parseResult) break + + const [token, remaining] = parseResult + yield token + + // guard against programmer error + if (source.length <= remaining.length) { + throw new Error(`Something went wrong! the remaining string (${remaining}) is supposed to be less than the source string (${source})`) + } + + source = remaining + } +} diff --git a/object/property-path/src/token/types.ts b/object/property-path/src/token/types.ts new file mode 100644 index 0000000000..a221ea6cc4 --- /dev/null +++ b/object/property-path/src/token/types.ts @@ -0,0 +1,10 @@ +export interface TokenBase { + type: string +} + +/** +* Extract a token from a source. +* @param source The source string. +* @returns The token and the remaining unparsed string. +*/ +export type Tokenize = (source: string) => [Token, string] | undefined diff --git a/object/property-path/test/get.test.ts b/object/property-path/test/get.test.ts new file mode 100644 index 0000000000..348cbd2d6b --- /dev/null +++ b/object/property-path/test/get.test.ts @@ -0,0 +1,82 @@ +import { getObjectValueByPropertyPathString } from '../src' + +const OBJECT = { + packages: [ + 'foo', + 'bar', + ], + + catalogs: { + default: { + 'is-positive': '^1.0.0', + 'is-negative': '^1.0.0', + }, + }, + + packageExtensions: { + '@babel/parser': { + peerDependencies: { + unified: '*', + }, + }, + }, + + updateConfig: { + ignoreDependencies: [ + 'boxen', + 'camelcase', + 'find-up', + ], + }, +} as const + +test('path exists', () => { + expect(getObjectValueByPropertyPathString(OBJECT, '')).toBe(OBJECT) + expect(getObjectValueByPropertyPathString(OBJECT, 'packages')).toBe(OBJECT.packages) + expect(getObjectValueByPropertyPathString(OBJECT, '.packages')).toBe(OBJECT.packages) + expect(getObjectValueByPropertyPathString(OBJECT, '["packages"]')).toBe(OBJECT.packages) + expect(getObjectValueByPropertyPathString(OBJECT, 'packages[0]')).toBe(OBJECT.packages[0]) + expect(getObjectValueByPropertyPathString(OBJECT, '.packages[0]')).toBe(OBJECT.packages[0]) + expect(getObjectValueByPropertyPathString(OBJECT, 'packages[1]')).toBe(OBJECT.packages[1]) + expect(getObjectValueByPropertyPathString(OBJECT, '.packages[1]')).toBe(OBJECT.packages[1]) + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs')).toBe(OBJECT.catalogs) + expect(getObjectValueByPropertyPathString(OBJECT, '.catalogs')).toBe(OBJECT.catalogs) + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs.default')).toBe(OBJECT.catalogs.default) + expect(getObjectValueByPropertyPathString(OBJECT, '.catalogs.default')).toBe(OBJECT.catalogs.default) + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs.default["is-positive"]')).toBe(OBJECT.catalogs.default['is-positive']) + expect(getObjectValueByPropertyPathString(OBJECT, '.catalogs.default["is-positive"]')).toBe(OBJECT.catalogs.default['is-positive']) +}) + +test('path does not exist', () => { + expect(getObjectValueByPropertyPathString(OBJECT, 'notExist')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, '.notExist')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs.notExist')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, '.notExist.catalogs')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs.default.notExist')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, '.catalogs.notExist.default')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'packages[99]')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'packages[0].foo')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs.default["not-exist"]')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'catalogs.default["is-positive"].foo')).toBeUndefined() +}) + +test('does not leak JavaScript-specific properties', () => { + expect(getObjectValueByPropertyPathString({}, 'constructor')).toBeUndefined() + expect(getObjectValueByPropertyPathString([], 'length')).toBeUndefined() + expect(getObjectValueByPropertyPathString('foo', 'length')).toBeUndefined() + expect(getObjectValueByPropertyPathString(0, 'valueOf')).toBeUndefined() + expect(getObjectValueByPropertyPathString(class {}, 'prototype')).toBeUndefined() // eslint-disable-line @typescript-eslint/no-extraneous-class + expect(getObjectValueByPropertyPathString(OBJECT, 'constructor')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'packages.length')).toBeUndefined() + expect(getObjectValueByPropertyPathString(OBJECT, 'packages[0].length')).toBeUndefined() +}) + +test('non-objects', () => { + expect(getObjectValueByPropertyPathString(0, '')).toBe(0) + expect(getObjectValueByPropertyPathString('foo', '')).toBe('foo') +}) + +test('does not allow accessing specific character in a string', () => { + expect(getObjectValueByPropertyPathString('foo', '[0]')).toBeUndefined() + expect(getObjectValueByPropertyPathString('foo', '["0"]')).toBeUndefined() +}) diff --git a/object/property-path/test/parse.test.ts b/object/property-path/test/parse.test.ts new file mode 100644 index 0000000000..ccf2292de3 --- /dev/null +++ b/object/property-path/test/parse.test.ts @@ -0,0 +1,118 @@ +import { + type ExactToken, + type UnexpectedEndOfInputError, + type UnexpectedIdentifierError, + type UnexpectedLiteralError, + type UnexpectedToken, + type UnexpectedTokenError, + parsePropertyPath, +} from '../src' + +test('valid property path', () => { + expect(Array.from(parsePropertyPath(''))).toStrictEqual([]) + expect(Array.from(parsePropertyPath('foo'))).toStrictEqual(['foo']) + expect(Array.from(parsePropertyPath('.foo'))).toStrictEqual(['foo']) + expect(Array.from(parsePropertyPath('["foo"]'))).toStrictEqual(['foo']) + expect(Array.from(parsePropertyPath("['foo']"))).toStrictEqual(['foo']) + expect(Array.from(parsePropertyPath('[ "foo" ]'))).toStrictEqual(['foo']) + expect(Array.from(parsePropertyPath("[ 'foo' ]"))).toStrictEqual(['foo']) + expect(Array.from(parsePropertyPath('foo.bar[0]'))).toStrictEqual(['foo', 'bar', 0]) + expect(Array.from(parsePropertyPath('.foo.bar[0]'))).toStrictEqual(['foo', 'bar', 0]) + expect(Array.from(parsePropertyPath('foo["bar"][0]'))).toStrictEqual(['foo', 'bar', 0]) + expect(Array.from(parsePropertyPath(".foo['bar'][0]"))).toStrictEqual(['foo', 'bar', 0]) + expect(Array.from(parsePropertyPath('foo.bar["0"]'))).toStrictEqual(['foo', 'bar', '0']) + expect(Array.from(parsePropertyPath('a.b.c.d'))).toStrictEqual(['a', 'b', 'c', 'd']) + expect(Array.from(parsePropertyPath('.a.b.c.d'))).toStrictEqual(['a', 'b', 'c', 'd']) + expect(Array.from(parsePropertyPath('a .b .c .d'))).toStrictEqual(['a', 'b', 'c', 'd']) + expect(Array.from(parsePropertyPath('.a .b .c .d'))).toStrictEqual(['a', 'b', 'c', 'd']) +}) + +test('invalid property path', () => { + expect(() => Array.from(parsePropertyPath('foo.bar.0'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_LITERAL_IN_PROPERTY_PATH', + token: { + type: 'numeric-literal', + content: 0, + }, + } as Partial)) + expect(() => Array.from(parsePropertyPath('foo.bar."baz"'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_LITERAL_IN_PROPERTY_PATH', + token: { + type: 'string-literal', + quote: '"', + content: 'baz', + }, + } as Partial)) + expect(() => Array.from(parsePropertyPath('foo.bar"baz"'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_LITERAL_IN_PROPERTY_PATH', + token: { + type: 'string-literal', + quote: '"', + content: 'baz', + }, + } as Partial)) + expect(() => Array.from(parsePropertyPath('foo.bar "baz"'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_LITERAL_IN_PROPERTY_PATH', + token: { + type: 'string-literal', + quote: '"', + content: 'baz', + }, + } as Partial)) + expect(() => Array.from(parsePropertyPath('foo.bar[baz]'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_IDENTIFIER_IN_PROPERTY_PATH', + token: { + type: 'identifier', + content: 'baz', + }, + } as Partial)) + expect(() => Array.from(parsePropertyPath('foo.bar..baz'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH', + token: { + type: 'exact', + content: '.', + }, + } as Partial>>)) + expect(() => Array.from(parsePropertyPath('foo.bar[[0]]'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH', + token: { + type: 'exact', + content: '[', + }, + } as Partial>>)) + expect(() => Array.from(parsePropertyPath('foo.bar[0]]'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH', + token: { + type: 'exact', + content: ']', + }, + } as Partial>>)) + expect(() => Array.from(parsePropertyPath('foo.bar?.baz'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH', + token: { + type: 'unexpected', + content: '?', + }, + } as Partial>)) + expect(() => Array.from(parsePropertyPath('foo.bar.baz.'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_END_OF_PROPERTY_PATH', + } as Partial)) + expect(() => Array.from(parsePropertyPath('foo.bar.baz[0'))).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_END_OF_PROPERTY_PATH', + } as Partial)) +}) + +test('partial parse', () => { + const iter = parsePropertyPath('.foo.bar[123]?.baz') + expect(iter.next()).toStrictEqual({ done: false, value: 'foo' }) + expect(iter.next()).toStrictEqual({ done: false, value: 'bar' }) + expect(iter.next()).toStrictEqual({ done: false, value: 123 }) + expect(() => iter.next()).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH', + token: { + type: 'unexpected', + content: '?', + }, + } as Partial>)) + expect(iter.next()).toStrictEqual({ done: true, value: undefined }) +}) diff --git a/object/property-path/test/token/Identifier.test.ts b/object/property-path/test/token/Identifier.test.ts new file mode 100644 index 0000000000..ad1c901ab6 --- /dev/null +++ b/object/property-path/test/token/Identifier.test.ts @@ -0,0 +1,90 @@ +import { type Identifier, parseIdentifier } from '../../src' + +test('not an identifier', () => { + expect(parseIdentifier('')).toBeUndefined() + expect(parseIdentifier('-')).toBeUndefined() + expect(parseIdentifier('+a')).toBeUndefined() + expect(parseIdentifier('7z')).toBeUndefined() +}) + +test('identifier only', () => { + expect(parseIdentifier('_')).toStrictEqual([{ + type: 'identifier', + content: '_', + } as Identifier, '']) + expect(parseIdentifier('a')).toStrictEqual([{ + type: 'identifier', + content: 'a', + } as Identifier, '']) + expect(parseIdentifier('abc')).toStrictEqual([{ + type: 'identifier', + content: 'abc', + } as Identifier, '']) + expect(parseIdentifier('helloWorld')).toStrictEqual([{ + type: 'identifier', + content: 'helloWorld', + } as Identifier, '']) + expect(parseIdentifier('HelloWorld')).toStrictEqual([{ + type: 'identifier', + content: 'HelloWorld', + } as Identifier, '']) + expect(parseIdentifier('a123')).toStrictEqual([{ + type: 'identifier', + content: 'a123', + } as Identifier, '']) + expect(parseIdentifier('abc123')).toStrictEqual([{ + type: 'identifier', + content: 'abc123', + } as Identifier, '']) + expect(parseIdentifier('helloWorld123')).toStrictEqual([{ + type: 'identifier', + content: 'helloWorld123', + } as Identifier, '']) + expect(parseIdentifier('HelloWorld123')).toStrictEqual([{ + type: 'identifier', + content: 'HelloWorld123', + } as Identifier, '']) + expect(parseIdentifier('hello_world_123')).toStrictEqual([{ + type: 'identifier', + content: 'hello_world_123', + } as Identifier, '']) + expect(parseIdentifier('__abc_123__')).toStrictEqual([{ + type: 'identifier', + content: '__abc_123__', + } as Identifier, '']) + expect(parseIdentifier('_0')).toStrictEqual([{ + type: 'identifier', + content: '_0', + } as Identifier, '']) + expect(parseIdentifier('_foo')).toStrictEqual([{ + type: 'identifier', + content: '_foo', + } as Identifier, '']) +}) + +test('identifier and tail', () => { + expect(parseIdentifier('a+b')).toStrictEqual([{ + type: 'identifier', + content: 'a', + } as Identifier, '+b']) + expect(parseIdentifier('abc.def')).toStrictEqual([{ + type: 'identifier', + content: 'abc', + } as Identifier, '.def']) + expect(parseIdentifier('helloWorld123-456')).toStrictEqual([{ + type: 'identifier', + content: 'helloWorld123', + } as Identifier, '-456']) + expect(parseIdentifier('HelloWorld123 456')).toStrictEqual([{ + type: 'identifier', + content: 'HelloWorld123', + } as Identifier, ' 456']) + expect(parseIdentifier('hello_world_123 456')).toStrictEqual([{ + type: 'identifier', + content: 'hello_world_123', + } as Identifier, ' 456']) + expect(parseIdentifier('__abc_123__++__def_456__')).toStrictEqual([{ + type: 'identifier', + content: '__abc_123__', + } as Identifier, '++__def_456__']) +}) diff --git a/object/property-path/test/token/NumericLiteral.test.ts b/object/property-path/test/token/NumericLiteral.test.ts new file mode 100644 index 0000000000..4eeb8aadd1 --- /dev/null +++ b/object/property-path/test/token/NumericLiteral.test.ts @@ -0,0 +1,55 @@ +import { type NumericLiteral, parseNumericLiteral } from '../../src' + +test('not a numeric literal', () => { + expect(parseNumericLiteral('')).toBeUndefined() + expect(parseNumericLiteral('abcdef')).toBeUndefined() + expect(parseNumericLiteral('"hello world"')).toBeUndefined() + expect(parseNumericLiteral('.123')).toBeUndefined() + expect(parseNumericLiteral('NaN')).toBeUndefined() +}) + +test('simple numbers', () => { + expect(parseNumericLiteral('0')).toStrictEqual([{ + type: 'numeric-literal', + content: 0, + } as NumericLiteral, '']) + expect(parseNumericLiteral('3')).toStrictEqual([{ + type: 'numeric-literal', + content: 3, + } as NumericLiteral, '']) + expect(parseNumericLiteral('123')).toStrictEqual([{ + type: 'numeric-literal', + content: 123, + } as NumericLiteral, '']) + expect(parseNumericLiteral('123.4')).toStrictEqual([{ + type: 'numeric-literal', + content: 123.4, + } as NumericLiteral, '']) + expect(parseNumericLiteral('0123')).toStrictEqual([{ + type: 'numeric-literal', + content: 123, + } as NumericLiteral, '']) + expect(parseNumericLiteral('123,456')).toStrictEqual([{ + type: 'numeric-literal', + content: 123, + } as NumericLiteral, ',456']) +}) + +test('unsupported syntax', () => { + expect(() => parseNumericLiteral('0x12AB')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNSUPPORTED_NUMERIC_LITERAL_SUFFIX', + suffix: 'x', + })) + expect(() => parseNumericLiteral('1e23')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNSUPPORTED_NUMERIC_LITERAL_SUFFIX', + suffix: 'e', + })) + expect(() => parseNumericLiteral('123n')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNSUPPORTED_NUMERIC_LITERAL_SUFFIX', + suffix: 'n', + })) + expect(() => parseNumericLiteral('123ABC')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNSUPPORTED_NUMERIC_LITERAL_SUFFIX', + suffix: 'A', + })) +}) diff --git a/object/property-path/test/token/StringLiteral.test.ts b/object/property-path/test/token/StringLiteral.test.ts new file mode 100644 index 0000000000..47cbe4462e --- /dev/null +++ b/object/property-path/test/token/StringLiteral.test.ts @@ -0,0 +1,85 @@ +import { type StringLiteral, parseStringLiteral } from '../../src' + +test('not a string literal', () => { + expect(parseStringLiteral('')).toBeUndefined() + expect(parseStringLiteral('not a string')).toBeUndefined() + expect(parseStringLiteral('not a string again "this string would be ignored"')).toBeUndefined() + expect(parseStringLiteral('0123')).toBeUndefined() +}) + +test('simple string literal', () => { + expect(parseStringLiteral('""')).toStrictEqual([{ + type: 'string-literal', + quote: '"', + content: '', + } as StringLiteral, '']) + expect(parseStringLiteral("''")).toStrictEqual([{ + type: 'string-literal', + quote: "'", + content: '', + } as StringLiteral, '']) + expect(parseStringLiteral('"hello world"')).toStrictEqual([{ + type: 'string-literal', + quote: '"', + content: 'hello world', + } as StringLiteral, '']) + expect(parseStringLiteral("'hello world'")).toStrictEqual([{ + type: 'string-literal', + quote: "'", + content: 'hello world', + } as StringLiteral, '']) + expect(parseStringLiteral('"hello world".length')).toStrictEqual([{ + type: 'string-literal', + quote: '"', + content: 'hello world', + } as StringLiteral, '.length']) + expect(parseStringLiteral("'hello world'.length")).toStrictEqual([{ + type: 'string-literal', + quote: "'", + content: 'hello world', + } as StringLiteral, '.length']) +}) + +test('escape sequences', () => { + expect(parseStringLiteral('"hello \\"world\\"".length')).toStrictEqual([{ + type: 'string-literal', + quote: '"', + content: 'hello "world"', + } as StringLiteral, '.length']) + expect(parseStringLiteral('"hello\\nworld".length')).toStrictEqual([{ + type: 'string-literal', + quote: '"', + content: 'hello\nworld', + } as StringLiteral, '.length']) + expect(parseStringLiteral('"C:\\\\hello\\\\world\\\\".length')).toStrictEqual([{ + type: 'string-literal', + quote: '"', + content: 'C:\\hello\\world\\', + } as StringLiteral, '.length']) +}) + +test('unsupported escape sequences', () => { + expect(() => parseStringLiteral('"hello \\x22world\\x22"')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_UNSUPPORTED_STRING_LITERAL_ESCAPE_SEQUENCE', + sequence: 'x', + })) +}) + +test('no closing quote', () => { + expect(() => parseStringLiteral('"hello world')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INCOMPLETE_STRING_LITERAL', + expectedQuote: '"', + })) + expect(() => parseStringLiteral("'hello world")).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INCOMPLETE_STRING_LITERAL', + expectedQuote: "'", + })) + expect(() => parseStringLiteral('"hello world\\"')).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INCOMPLETE_STRING_LITERAL', + expectedQuote: '"', + })) + expect(() => parseStringLiteral("'hello world\\'")).toThrow(expect.objectContaining({ + code: 'ERR_PNPM_INCOMPLETE_STRING_LITERAL', + expectedQuote: "'", + })) +}) diff --git a/object/property-path/test/token/tokenize.test.ts b/object/property-path/test/token/tokenize.test.ts new file mode 100644 index 0000000000..e6b075b1a6 --- /dev/null +++ b/object/property-path/test/token/tokenize.test.ts @@ -0,0 +1,51 @@ +import { type Token, tokenize } from '../../src' + +test('valid tokens', () => { + expect(Array.from(tokenize(''))).toStrictEqual([] as Token[]) + expect(Array.from(tokenize( + 'packageExtensions.react.dependencies["@types/node"]' + ))).toStrictEqual([ + { type: 'identifier', content: 'packageExtensions' }, + { type: 'exact', content: '.' }, + { type: 'identifier', content: 'react' }, + { type: 'exact', content: '.' }, + { type: 'identifier', content: 'dependencies' }, + { type: 'exact', content: '[' }, + { type: 'string-literal', quote: '"', content: '@types/node' }, + { type: 'exact', content: ']' }, + ] as Token[]) + expect(Array.from(tokenize( + 'packageExtensions .react\n.dependencies[ "@types/node" ]' + ))).toStrictEqual([ + { type: 'identifier', content: 'packageExtensions' }, + { type: 'whitespace' }, + { type: 'exact', content: '.' }, + { type: 'identifier', content: 'react' }, + { type: 'whitespace' }, + { type: 'exact', content: '.' }, + { type: 'identifier', content: 'dependencies' }, + { type: 'exact', content: '[' }, + { type: 'whitespace' }, + { type: 'string-literal', quote: '"', content: '@types/node' }, + { type: 'whitespace' }, + { type: 'exact', content: ']' }, + ] as Token[]) +}) + +test('unexpected tokens', () => { + expect(Array.from(tokenize('@'))).toStrictEqual([{ type: 'unexpected', content: '@' }] as Token[]) + expect(Array.from(tokenize( + 'packageExtensions.react.@!dependencies["@types/node"]' + ))).toStrictEqual([ + { type: 'identifier', content: 'packageExtensions' }, + { type: 'exact', content: '.' }, + { type: 'identifier', content: 'react' }, + { type: 'exact', content: '.' }, + { type: 'unexpected', content: '@' }, + { type: 'unexpected', content: '!' }, + { type: 'identifier', content: 'dependencies' }, + { type: 'exact', content: '[' }, + { type: 'string-literal', quote: '"', content: '@types/node' }, + { type: 'exact', content: ']' }, + ] as Token[]) +}) diff --git a/object/property-path/test/tsconfig.json b/object/property-path/test/tsconfig.json new file mode 100644 index 0000000000..74036126c6 --- /dev/null +++ b/object/property-path/test/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "../test.lib", + "rootDir": "." + }, + "include": [ + "**/*.ts", + "../../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": ".." + } + ] +} diff --git a/object/property-path/tsconfig.json b/object/property-path/tsconfig.json new file mode 100644 index 0000000000..019cba19e7 --- /dev/null +++ b/object/property-path/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": "../../packages/error" + } + ] +} diff --git a/object/property-path/tsconfig.lint.json b/object/property-path/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/object/property-path/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27c812e571..a7dccf88a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1785,6 +1785,9 @@ importers: '@pnpm/object.key-sorting': specifier: workspace:* version: link:../../object/key-sorting + '@pnpm/object.property-path': + specifier: workspace:* + version: link:../../object/property-path '@pnpm/run-npm': specifier: workspace:* version: link:../../exec/run-npm @@ -4041,6 +4044,16 @@ importers: specifier: workspace:* version: 'link:' + object/property-path: + dependencies: + '@pnpm/error': + specifier: workspace:* + version: link:../../packages/error + devDependencies: + '@pnpm/object.property-path': + specifier: workspace:* + version: 'link:' + packages/calc-dep-state: dependencies: '@pnpm/constants':