feat(cli/config): get/set objects and property paths (#9811)

close #9797
This commit is contained in:
Khải
2025-08-14 15:28:49 +07:00
committed by GitHub
parent 9dbada8315
commit b84c71d6bf
39 changed files with 1467 additions and 12 deletions

View 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).

View 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`).

View File

@@ -0,0 +1,5 @@
---
"@pnpm/object.property-path": major
---
Initial Release.

View 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.

View File

@@ -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:*",

View File

@@ -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)
}

View File

@@ -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>

View 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))
}

View 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
}

View File

@@ -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)
})
})
})

View File

@@ -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',
},
})
})

View File

@@ -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)
})

View File

@@ -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'])
})
})

View File

@@ -21,6 +21,9 @@
{
"path": "../../object/key-sorting"
},
{
"path": "../../object/property-path"
},
{
"path": "../../packages/error"
},

View File

@@ -0,0 +1,17 @@
# @pnpm/object.property-path
> Basic library to manipulate object property path which includes dots and subscriptions
<!--@shields('npm')-->
[![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

View 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"
}
}

View 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))

View File

@@ -0,0 +1,3 @@
export * from './token'
export * from './parse'
export * from './get'

View 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()
}

View 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(']')

View 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]
}

View 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]
}

View 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 {}

View 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)
}

View 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]
}

View 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
}

View 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'

View 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
}
}

View 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

View 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()
})

View 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 })
})

View 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__'])
})

View 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',
}))
})

View 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: "'",
}))
})

View 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[])
})

View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../test.lib",
"rootDir": "."
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../packages/error"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

13
pnpm-lock.yaml generated
View File

@@ -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':