mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-10 18:18:56 -04:00
feat(cli/config): get/set objects and property paths (#9811)
close #9797
This commit is contained in:
6
.changeset/empty-ducks-stare.md
Normal file
6
.changeset/empty-ducks-stare.md
Normal file
@@ -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).
|
||||
6
.changeset/fancy-stars-punch.md
Normal file
6
.changeset/fancy-stars-punch.md
Normal file
@@ -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`).
|
||||
5
.changeset/free-buttons-fly.md
Normal file
5
.changeset/free-buttons-fly.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@pnpm/object.property-path": major
|
||||
---
|
||||
|
||||
Initial Release.
|
||||
6
.changeset/full-birds-cheer.md
Normal file
6
.changeset/full-birds-cheer.md
Normal file
@@ -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.
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<string, unknown>, propertyPath: string): unknown {
|
||||
return getObjectValueByPropertyPath(rawConfig, parseConfigPropertyPath(propertyPath))
|
||||
}
|
||||
|
||||
type DisplayConfigOptions = Pick<ConfigCommandOptions, 'json'>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
export async function configSet (opts: ConfigCommandOptions, key: string, valueParam: string | null): Promise<void> {
|
||||
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<Record<string, unknown>> {
|
||||
try {
|
||||
return await readIniFile(configPath) as Record<string, unknown>
|
||||
|
||||
10
config/plugin-commands-config/src/isStrictlyKebabCase.ts
Normal file
10
config/plugin-commands-config/src/isStrictlyKebabCase.ts
Normal file
@@ -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))
|
||||
}
|
||||
17
config/plugin-commands-config/src/parseConfigPropertyPath.ts
Normal file
17
config/plugin-commands-config/src/parseConfigPropertyPath.ts
Normal file
@@ -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<string | number, void, void> {
|
||||
const iter = parsePropertyPath(propertyPath)
|
||||
|
||||
const first = iter.next()
|
||||
if (first.done) return
|
||||
yield typeof first.value === 'string'
|
||||
? kebabCase(first.value)
|
||||
: first.value
|
||||
|
||||
yield * iter
|
||||
}
|
||||
@@ -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): 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../object/key-sorting"
|
||||
},
|
||||
{
|
||||
"path": "../../object/property-path"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
},
|
||||
|
||||
17
object/property-path/README.md
Normal file
17
object/property-path/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# @pnpm/object.property-path
|
||||
|
||||
> Basic library to manipulate object property path which includes dots and subscriptions
|
||||
|
||||
<!--@shields('npm')-->
|
||||
[](https://www.npmjs.com/package/@pnpm/object.property-path)
|
||||
<!--/@-->
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @pnpm/object.property-path
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
46
object/property-path/package.json
Normal file
46
object/property-path/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
29
object/property-path/src/get.ts
Normal file
29
object/property-path/src/get.ts
Normal file
@@ -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<string | number>): 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<string | number, unknown>)[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))
|
||||
3
object/property-path/src/index.ts
Normal file
3
object/property-path/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './token'
|
||||
export * from './parse'
|
||||
export * from './get'
|
||||
122
object/property-path/src/parse.ts
Normal file
122
object/property-path/src/parse.ts
Normal file
@@ -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<Token extends ExactToken<string> | 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<string | number, void, void> {
|
||||
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()
|
||||
}
|
||||
14
object/property-path/src/token/ExactToken.ts
Normal file
14
object/property-path/src/token/ExactToken.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type TokenBase, type Tokenize } from './types'
|
||||
|
||||
export interface ExactToken<Content extends string> extends TokenBase {
|
||||
type: 'exact'
|
||||
content: Content
|
||||
}
|
||||
|
||||
const createExactTokenParser =
|
||||
<Content extends string>(content: Content): Tokenize<ExactToken<Content>> =>
|
||||
source => source.startsWith(content) ? [{ type: 'exact', content }, source.slice(content.length)] : undefined
|
||||
|
||||
export const parseDotOperator = createExactTokenParser('.')
|
||||
export const parseOpenBracket = createExactTokenParser('[')
|
||||
export const parseCloseBracket = createExactTokenParser(']')
|
||||
24
object/property-path/src/token/Identifier.ts
Normal file
24
object/property-path/src/token/Identifier.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type TokenBase, type Tokenize } from './types'
|
||||
|
||||
export interface Identifier extends TokenBase {
|
||||
type: 'identifier'
|
||||
content: string
|
||||
}
|
||||
|
||||
export const parseIdentifier: Tokenize<Identifier> = 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]
|
||||
}
|
||||
44
object/property-path/src/token/NumericLiteral.ts
Normal file
44
object/property-path/src/token/NumericLiteral.ts
Normal file
@@ -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<NumericLiteral> = 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]
|
||||
}
|
||||
7
object/property-path/src/token/ParseErrorBase.ts
Normal file
7
object/property-path/src/token/ParseErrorBase.ts
Normal file
@@ -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 {}
|
||||
79
object/property-path/src/token/StringLiteral.ts
Normal file
79
object/property-path/src/token/StringLiteral.ts
Normal file
@@ -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<string, string | undefined> = {
|
||||
'\\': '\\',
|
||||
"'": "'",
|
||||
'"': '"',
|
||||
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<StringLiteral> = 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)
|
||||
}
|
||||
12
object/property-path/src/token/Whitespace.ts
Normal file
12
object/property-path/src/token/Whitespace.ts
Normal file
@@ -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<Whitespace> = source => {
|
||||
const remaining = source.trimStart()
|
||||
return remaining === source ? undefined : [WHITESPACE, remaining]
|
||||
}
|
||||
9
object/property-path/src/token/combine.ts
Normal file
9
object/property-path/src/token/combine.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type TokenBase, type Tokenize } from './types'
|
||||
|
||||
export const combineParsers = <Token extends TokenBase> (parsers: Iterable<Tokenize<Token>>): Tokenize<Token> => source => {
|
||||
for (const parse of parsers) {
|
||||
const parseResult = parse(source)
|
||||
if (parseResult) return parseResult
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
10
object/property-path/src/token/index.ts
Normal file
10
object/property-path/src/token/index.ts
Normal file
@@ -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'
|
||||
55
object/property-path/src/token/tokenize.ts
Normal file
55
object/property-path/src/token/tokenize.ts
Normal file
@@ -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<ExpectedToken> = combineParsers<ExpectedToken>([
|
||||
parseDotOperator,
|
||||
parseOpenBracket,
|
||||
parseCloseBracket,
|
||||
parseIdentifier,
|
||||
parseNumericLiteral,
|
||||
parseStringLiteral,
|
||||
parseWhitespace,
|
||||
])
|
||||
|
||||
export interface UnexpectedToken extends TokenBase {
|
||||
type: 'unexpected'
|
||||
content: string
|
||||
}
|
||||
|
||||
const parseUnexpectedToken: Tokenize<UnexpectedToken> = source =>
|
||||
[{ type: 'unexpected', content: source.slice(0, 1) }, source.slice(1)]
|
||||
|
||||
export type Token = ExpectedToken | UnexpectedToken
|
||||
export const parseToken = combineParsers<Token>([parseExpectedToken, parseUnexpectedToken])
|
||||
|
||||
/** Generate all tokens from a source text. */
|
||||
export function * tokenize (source: string): Generator<Token, void, void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
10
object/property-path/src/token/types.ts
Normal file
10
object/property-path/src/token/types.ts
Normal file
@@ -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<Token extends TokenBase> = (source: string) => [Token, string] | undefined
|
||||
82
object/property-path/test/get.test.ts
Normal file
82
object/property-path/test/get.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
118
object/property-path/test/parse.test.ts
Normal file
118
object/property-path/test/parse.test.ts
Normal file
@@ -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<UnexpectedLiteralError>))
|
||||
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<UnexpectedLiteralError>))
|
||||
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<UnexpectedLiteralError>))
|
||||
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<UnexpectedLiteralError>))
|
||||
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<UnexpectedIdentifierError>))
|
||||
expect(() => Array.from(parsePropertyPath('foo.bar..baz'))).toThrow(expect.objectContaining({
|
||||
code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH',
|
||||
token: {
|
||||
type: 'exact',
|
||||
content: '.',
|
||||
},
|
||||
} as Partial<UnexpectedTokenError<ExactToken<'.'>>>))
|
||||
expect(() => Array.from(parsePropertyPath('foo.bar[[0]]'))).toThrow(expect.objectContaining({
|
||||
code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH',
|
||||
token: {
|
||||
type: 'exact',
|
||||
content: '[',
|
||||
},
|
||||
} as Partial<UnexpectedTokenError<ExactToken<'['>>>))
|
||||
expect(() => Array.from(parsePropertyPath('foo.bar[0]]'))).toThrow(expect.objectContaining({
|
||||
code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH',
|
||||
token: {
|
||||
type: 'exact',
|
||||
content: ']',
|
||||
},
|
||||
} as Partial<UnexpectedTokenError<ExactToken<']'>>>))
|
||||
expect(() => Array.from(parsePropertyPath('foo.bar?.baz'))).toThrow(expect.objectContaining({
|
||||
code: 'ERR_PNPM_UNEXPECTED_TOKEN_IN_PROPERTY_PATH',
|
||||
token: {
|
||||
type: 'unexpected',
|
||||
content: '?',
|
||||
},
|
||||
} as Partial<UnexpectedTokenError<UnexpectedToken>>))
|
||||
expect(() => Array.from(parsePropertyPath('foo.bar.baz.'))).toThrow(expect.objectContaining({
|
||||
code: 'ERR_PNPM_UNEXPECTED_END_OF_PROPERTY_PATH',
|
||||
} as Partial<UnexpectedEndOfInputError>))
|
||||
expect(() => Array.from(parsePropertyPath('foo.bar.baz[0'))).toThrow(expect.objectContaining({
|
||||
code: 'ERR_PNPM_UNEXPECTED_END_OF_PROPERTY_PATH',
|
||||
} as Partial<UnexpectedEndOfInputError>))
|
||||
})
|
||||
|
||||
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<UnexpectedTokenError<UnexpectedToken>>))
|
||||
expect(iter.next()).toStrictEqual({ done: true, value: undefined })
|
||||
})
|
||||
90
object/property-path/test/token/Identifier.test.ts
Normal file
90
object/property-path/test/token/Identifier.test.ts
Normal file
@@ -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__'])
|
||||
})
|
||||
55
object/property-path/test/token/NumericLiteral.test.ts
Normal file
55
object/property-path/test/token/NumericLiteral.test.ts
Normal file
@@ -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',
|
||||
}))
|
||||
})
|
||||
85
object/property-path/test/token/StringLiteral.test.ts
Normal file
85
object/property-path/test/token/StringLiteral.test.ts
Normal file
@@ -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: "'",
|
||||
}))
|
||||
})
|
||||
51
object/property-path/test/token/tokenize.test.ts
Normal file
51
object/property-path/test/token/tokenize.test.ts
Normal file
@@ -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[])
|
||||
})
|
||||
17
object/property-path/test/tsconfig.json
Normal file
17
object/property-path/test/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "../test.lib",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"../../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
16
object/property-path/tsconfig.json
Normal file
16
object/property-path/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@pnpm/tsconfig",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
object/property-path/tsconfig.lint.json
Normal file
8
object/property-path/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user