Skip to content

Commit 0dc5e8d

Browse files
committed
feat: parse property path (wip)
1 parent d54b847 commit 0dc5e8d

25 files changed

+1014
-210
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# @pnpm/object-property-path
2+
3+
> Basic library to manipulate object property path which includes dots and subscriptions
4+
5+
<!--@shields('npm')-->
6+
[![npm version](https://img.shields.io/npm/v/@pnpm/object-property-path.svg)](https://www.npmjs.com/package/@pnpm/object-property-path)
7+
<!--/@-->
8+
9+
## Installation
10+
11+
```sh
12+
pnpm add @pnpm/object-property-path
13+
```
14+
15+
## License
16+
17+
MIT
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@pnpm/object-property-path",
3+
"version": "1000.0.0",
4+
"description": "Basic library to manipulate object property path which includes dots and subscriptions",
5+
"keywords": [
6+
"pnpm",
7+
"pnpm10",
8+
"object-property-path"
9+
],
10+
"license": "MIT",
11+
"funding": "https://opencollective.com/pnpm",
12+
"repository": "https://github.com/pnpm/pnpm/blob/main/packages/object-property-path",
13+
"homepage": "https://github.com/pnpm/pnpm/blob/main/packages/object-property-path#readme",
14+
"bugs": {
15+
"url": "https://github.com/pnpm/pnpm/issues"
16+
},
17+
"type": "commonjs",
18+
"main": "lib/index.js",
19+
"types": "lib/index.d.ts",
20+
"exports": {
21+
".": "./lib/index.js"
22+
},
23+
"files": [
24+
"lib",
25+
"!*.map"
26+
],
27+
"scripts": {
28+
"test": "pnpm run compile && pnpm run _test",
29+
"prepublishOnly": "pnpm run compile",
30+
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
31+
"compile": "tsc --build && pnpm run lint --fix",
32+
"_test": "jest"
33+
},
34+
"dependencies": {
35+
"@pnpm/error": "workspace:*"
36+
},
37+
"devDependencies": {
38+
"@pnpm/object-property-path": "workspace:*"
39+
},
40+
"engines": {
41+
"node": ">=18.12"
42+
},
43+
"jest": {
44+
"preset": "@pnpm/jest-config"
45+
}
46+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { parsePropertyPath } from './parse'
2+
3+
/**
4+
* Get the value of a property path in a nested object.
5+
*
6+
* This function returns `undefined` if it meets non-object at some point.
7+
*/
8+
export function getObjectValueByPropertyPath (object: unknown, propertyPath: Iterable<string | number>): unknown {
9+
for (const name of propertyPath) {
10+
if (
11+
typeof object !== 'object' ||
12+
object == null ||
13+
!Object.hasOwn(object, name) ||
14+
(Array.isArray(object) && typeof name !== 'number')
15+
) return undefined
16+
17+
object = (object as Record<string | number, unknown>)[name]
18+
}
19+
20+
return object
21+
}
22+
23+
/**
24+
* Get the value of a property path in a nested object.
25+
*
26+
* This function returns `undefined` if it meets non-object at some point.
27+
*/
28+
export const getObjectValueByPropertyPathString =
29+
(object: unknown, propertyPath: string): unknown => getObjectValueByPropertyPath(object, parsePropertyPath(propertyPath))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './token'
2+
export * from './parse'
3+
export * from './get'
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import assert from 'assert/strict'
2+
import { PnpmError } from '@pnpm/error'
3+
import {
4+
type ExactToken,
5+
type Identifier,
6+
type NumericLiteral,
7+
type StringLiteral,
8+
type UnexpectedToken,
9+
tokenize,
10+
} from './token'
11+
12+
export class UnexpectedTokenError<Token extends ExactToken<string> | UnexpectedToken> extends PnpmError {
13+
readonly token: Token
14+
constructor (token: Token) {
15+
super('UNEXPECTED_TOKEN_IN_PROPERTY_PATH', `Unexpected token ${JSON.stringify(token.content)} in property path`)
16+
this.token = token
17+
}
18+
}
19+
20+
export class UnexpectedIdentifierError extends PnpmError {
21+
readonly token: Identifier
22+
constructor (token: Identifier) {
23+
super('UNEXPECTED_IDENTIFIER_IN_PROPERTY_PATH', `Unexpected identifier ${token.content} in property path`)
24+
this.token = token
25+
}
26+
}
27+
28+
export class UnexpectedLiteralError extends PnpmError {
29+
readonly token: NumericLiteral | StringLiteral
30+
constructor (token: NumericLiteral | StringLiteral) {
31+
super('UNEXPECTED_LITERAL_IN_PROPERTY_PATH', `Unexpected literal ${JSON.stringify(token.content)} in property path`)
32+
this.token = token
33+
}
34+
}
35+
36+
export class UnexpectedEndOfInputError extends PnpmError {
37+
constructor () {
38+
super('UNEXPECTED_END_OF_PROPERTY_PATH', 'The property path does not end properly')
39+
}
40+
}
41+
42+
/**
43+
* Parse a string of property path.
44+
*
45+
* @example
46+
* parsePropertyPath('foo.bar.baz')
47+
* parsePropertyPath('.foo.bar.baz')
48+
* parsePropertyPath('foo.bar["baz"]')
49+
* parsePropertyPath("foo['bar'].baz")
50+
* parsePropertyPath('["foo"].bar.baz')
51+
* parsePropertyPath(`["foo"]['bar'].baz`)
52+
* parsePropertyPath('foo[123]')
53+
*
54+
* @param propertyPath The string of property path to parse.
55+
* @returns The parsed path in the form of an array.
56+
*/
57+
export function * parsePropertyPath (propertyPath: string): Generator<string | number, void, void> {
58+
type Stack =
59+
| ExactToken<'.'>
60+
| ExactToken<'['>
61+
| [ExactToken<'['>, NumericLiteral | StringLiteral]
62+
let stack: Stack | undefined
63+
64+
for (const token of tokenize(propertyPath)) {
65+
if (token.type === 'exact' && token.content === '.') {
66+
if (!stack) {
67+
stack = token
68+
continue
69+
}
70+
71+
throw new UnexpectedTokenError(token)
72+
}
73+
74+
if (token.type === 'exact' && token.content === '[') {
75+
if (!stack) {
76+
stack = token
77+
continue
78+
}
79+
80+
throw new UnexpectedTokenError(token)
81+
}
82+
83+
if (token.type === 'exact' && token.content === ']') {
84+
if (!Array.isArray(stack)) throw new UnexpectedTokenError(token)
85+
86+
const [openBracket, literal] = stack
87+
assert.equal(openBracket.type, 'exact')
88+
assert.equal(openBracket.content, '[')
89+
assert(literal.type === 'numeric-literal' || literal.type === 'string-literal')
90+
91+
yield literal.content
92+
stack = undefined
93+
continue
94+
}
95+
96+
if (token.type === 'identifier') {
97+
if (!stack || ('type' in stack && stack.type === 'exact' && stack.content === '.')) {
98+
stack = undefined
99+
yield token.content
100+
continue
101+
}
102+
103+
throw new UnexpectedIdentifierError(token)
104+
}
105+
106+
if (token.type === 'numeric-literal' || token.type === 'string-literal') {
107+
if (stack && 'type' in stack && stack.type === 'exact' && stack.content === '[') {
108+
stack = [stack, token]
109+
continue
110+
}
111+
112+
throw new UnexpectedLiteralError(token)
113+
}
114+
115+
if (token.type === 'whitespace') continue
116+
if (token.type === 'unexpected') throw new UnexpectedTokenError(token)
117+
118+
const _typeGuard: never = token // eslint-disable-line @typescript-eslint/no-unused-vars
119+
}
120+
121+
if (stack) throw new UnexpectedEndOfInputError()
122+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type TokenBase, type Tokenize } from './types'
2+
3+
export interface ExactToken<Content extends string> extends TokenBase {
4+
type: 'exact'
5+
content: Content
6+
}
7+
8+
const createExactTokenParser =
9+
<Content extends string>(content: Content): Tokenize<ExactToken<Content>> =>
10+
source => source.startsWith(content) ? [{ type: 'exact', content }, source.slice(content.length)] : undefined
11+
12+
export const parseDotOperator = createExactTokenParser('.')
13+
export const parseOpenBracket = createExactTokenParser('[')
14+
export const parseCloseBracket = createExactTokenParser(']')
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { type TokenBase, type Tokenize } from './types'
2+
3+
export interface Identifier extends TokenBase {
4+
type: 'identifier'
5+
content: string
6+
}
7+
8+
export const parseIdentifier: Tokenize<Identifier> = source => {
9+
if (source === '') return undefined
10+
11+
const firstChar = source[0]
12+
if (!/[a-z_]/i.test(firstChar)) return undefined
13+
14+
let content = firstChar
15+
source = source.slice(1)
16+
while (source !== '') {
17+
const char = source[0]
18+
if (!/\w/.test(char)) break
19+
source = source.slice(1)
20+
content += char
21+
}
22+
23+
return [{ type: 'identifier', content }, source]
24+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ParseErrorBase } from './ParseErrorBase'
2+
import { type TokenBase, type Tokenize } from './types'
3+
4+
export interface NumericLiteral extends TokenBase {
5+
type: 'numeric-literal'
6+
content: number
7+
}
8+
9+
export class UnsupportedNumericSuffix extends ParseErrorBase {
10+
readonly suffix: string
11+
constructor (suffix: string) {
12+
super('UNSUPPORTED_NUMERIC_LITERAL_SUFFIX', `Numeric suffix ${JSON.stringify(suffix)} is not supported`)
13+
this.suffix = suffix
14+
}
15+
}
16+
17+
export const parseNumericLiteral: Tokenize<NumericLiteral> = source => {
18+
if (source === '') return undefined
19+
20+
const firstChar = source[0]
21+
if (firstChar < '0' || firstChar > '9') return undefined
22+
23+
let numberString = firstChar
24+
source = source.slice(1)
25+
26+
while (source !== '') {
27+
const char = source[0]
28+
29+
if (/[0-9.]/.test(char)) {
30+
numberString += char
31+
source = source.slice(1)
32+
continue
33+
}
34+
35+
// We forbid things like `0x1A2E`, `1e20`, or `123n` for now.
36+
if (/[a-z]/i.test(char)) {
37+
throw new UnsupportedNumericSuffix(char)
38+
}
39+
40+
break
41+
}
42+
43+
return [{ type: 'numeric-literal', content: Number(numberString) }, source]
44+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { PnpmError } from '@pnpm/error'
2+
3+
/**
4+
* Base class for all parser errors.
5+
* This allows consumer code to detect a parser error by simply checking `instanceof`.
6+
*/
7+
export abstract class ParseErrorBase extends PnpmError {}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ParseErrorBase } from './ParseErrorBase'
2+
import { type TokenBase, type Tokenize } from './types'
3+
4+
export type StringLiteralQuote = '"' | "'"
5+
6+
export interface StringLiteral extends TokenBase {
7+
type: 'string-literal'
8+
quote: StringLiteralQuote
9+
content: string
10+
}
11+
12+
const STRING_LITERAL_ESCAPES: Record<string, string | undefined> = {
13+
'\\': '\\',
14+
"'": "'",
15+
'"': '"',
16+
b: '\b',
17+
n: '\n',
18+
r: '\r',
19+
t: '\t',
20+
}
21+
22+
export class UnsupportedEscapeSequenceError extends ParseErrorBase {
23+
readonly sequence: string
24+
constructor (sequence: string) {
25+
super('UNSUPPORTED_STRING_LITERAL_ESCAPE_SEQUENCE', `pnpm's string literal doesn't support ${JSON.stringify('\\' + sequence)}`)
26+
this.sequence = sequence
27+
}
28+
}
29+
30+
export class IncompleteStringLiteralError extends ParseErrorBase {
31+
readonly expectedQuote: StringLiteralQuote
32+
constructor (expectedQuote: StringLiteralQuote) {
33+
super('INCOMPLETE_STRING_LITERAL', `Input ends without closing quote (${expectedQuote})`)
34+
this.expectedQuote = expectedQuote
35+
}
36+
}
37+
38+
export const parseStringLiteral: Tokenize<StringLiteral> = source => {
39+
let quote: StringLiteralQuote
40+
if (source.startsWith('"')) {
41+
quote = '"'
42+
} else if (source.startsWith("'")) {
43+
quote = "'"
44+
} else {
45+
return undefined
46+
}
47+
48+
source = source.slice(1)
49+
let content = ''
50+
let escaped = false
51+
52+
while (source !== '') {
53+
const char = source[0]
54+
source = source.slice(1)
55+
56+
if (escaped) {
57+
escaped = false
58+
const realChar = STRING_LITERAL_ESCAPES[char]
59+
if (!realChar) {
60+
throw new UnsupportedEscapeSequenceError(char)
61+
}
62+
content += realChar
63+
continue
64+
}
65+
66+
if (char === quote) {
67+
return [{ type: 'string-literal', quote, content }, source]
68+
}
69+
70+
if (char === '\\') {
71+
escaped = true
72+
continue
73+
}
74+
75+
content += char
76+
}
77+
78+
throw new IncompleteStringLiteralError(quote)
79+
}

0 commit comments

Comments
 (0)