Skip to content
This repository was archived by the owner on Jan 6, 2025. It is now read-only.

Commit c41b27c

Browse files
hgwoodnlepage
authored andcommitted
Refactoring of the string path syntax parser (#115)
* recursive toPath * 🔨 smaller functions * 🔨 simpler parseQuotedBracketNotation Ain't RegExp cool? * 🔨 parseBareBracketNotation using regexp * 👌 🔨 use functions more aligned with intent * 💡 🎨 jsdoc and split long lines * 🔨 moar regexes? * 🔨 only regexes! 😈 * 🔨 better lisibility (?) using a helper match function * ✨ path syntax: leading dot now ignored * 🔨 renamed vars to avoid shadowing * 🔨 path syntax parser: match function spreads results into downstream functions * 🚨 fix lint * 💡 fix jsdoc * 👌 🔨 explicit regexp parser creation * 👌 🔨 parser combinators * 🔨 adapting to new code organization * ⏪ reverting merge mistakes * 💡 fix jsdoc
1 parent 132d1ff commit c41b27c

File tree

5 files changed

+159
-168
lines changed

5 files changed

+159
-168
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @typedef {function(string): T | null} Parser<T>
3+
* @memberof core
4+
* @private
5+
* @since 1.0.0
6+
*/
7+
8+
const maybeMap = (maybe, fn) => maybe === null ? maybe : fn(maybe)
9+
10+
/**
11+
* Creates a parser from a regular expression by matching the input string with
12+
* the regular expression, returning the resulting match object.
13+
* @function
14+
* @memberof core
15+
* @param {RegExp} regexp the regular expression
16+
* @return {core.Parser<string[]>} the resulting parser
17+
* @private
18+
* @since 1.0.0
19+
*/
20+
export const regexp = regexp => str => maybeMap(str.match(regexp), match => match.slice(1))
21+
22+
/**
23+
* Returns a new parser that will return <code>null</code> if a predicate about
24+
* the result of another parser does not hold. If the predicate holds then
25+
* the new parser returns the result of the other parser unchanged.
26+
* @function
27+
* @memberof core
28+
* @param {core.Parser<T>} parser parser to filter
29+
* @param {function(*): boolean} predicate predicate to use
30+
* @return {core.Parser<T>} resulting parser
31+
* @private
32+
* @since 1.0.0
33+
*/
34+
export const filter = (parser, predicate) => str => maybeMap(parser(str), parsed => predicate(parsed) ? parsed : null)
35+
36+
/**
37+
* Returns a new parser which will post-process the result of another parser.
38+
* @function
39+
* @memberof core
40+
* @param {core.Parser<T>} parser parser for which to process the result
41+
* @param {function(T): R} mapper function to transform the result of the parser
42+
* @return {core.Parser<R>} resulting parser
43+
* @private
44+
* @since 1.0.0
45+
*/
46+
export const map = (parser, mapper) => str => maybeMap(parser(str), mapper)
47+
48+
/**
49+
* Returns a new parser that attempts parsing with a first parser then falls
50+
* back to a second parser if the first returns <code>null</code>.
51+
* @function
52+
* @memberof core
53+
* @param {core.Parser<A>} parser the first parser
54+
* @param {core.Parser<B>} other the second parser
55+
* @return {core.Parser<A | B>} resulting parser
56+
* @private
57+
* @since 1.0.0
58+
*/
59+
export const fallback = (parser, other) => str => {
60+
const parsed = parser(str)
61+
if (parsed !== null) return parsed
62+
return other(str)
63+
}
64+
65+
/**
66+
* Chains a list of parsers together using <code>fallback</code>.
67+
* @function
68+
* @memberof core
69+
* @param {Array<core.Parser<*>>} parsers a list of parsers to try in order
70+
* @return {core.Parser<*>} resulting parser
71+
* @private
72+
* @since 1.0.0
73+
*/
74+
export const race = parsers => parsers.reduce((chainedParser, parser) => fallback(chainedParser, parser))

packages/immutadot/src/core/path.utils.js

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const getSliceBound = (value, defaultValue, length) => {
1010

1111
/**
1212
* Get the actual bounds of a slice.
13+
* @function
14+
* @memberof core
1315
* @param {Array<number>} bounds The bounds of the slice
1416
* @param {number} length The length of the actual array
1517
* @returns {Array<number>} The actual bounds of the slice

packages/immutadot/src/core/toPath.js

+79-166
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import {
2+
filter,
3+
map,
4+
race,
5+
regexp,
6+
} from './parser.utils'
7+
18
import {
29
isSymbol,
310
toString,
@@ -24,44 +31,18 @@ const toKey = arg => {
2431
return toString(arg)
2532
}
2633

27-
const quotes = ['"', '\'']
28-
2934
/**
30-
* Tests whether <code>index</code>th char of <code>str</code> is a quote.<br />
31-
* Quotes are <code>"</code> and <code>'</code>.
35+
* Strip slashes preceding occurences of <code>quote</code> from <code>str</code><br />
36+
* Possible quotes are <code>"</code> and <code>'</code>.
3237
* @function
3338
* @param {string} str The string
34-
* @param {number} index Index of the char to test
35-
* @return {{ quoted: boolean, quote: string }} A boolean <code>quoted</code>, true if <code>str.charAt(index)</code> is a quote and the <code>quote</code>.
39+
* @param {string} quote The quote to unescape
40+
* @return {string} The unescaped string
3641
* @memberof core
3742
* @private
3843
* @since 1.0.0
3944
*/
40-
const isQuoteChar = (str, index) => {
41-
const char = str.charAt(index)
42-
const quote = quotes.find(c => c === char)
43-
return {
44-
quoted: Boolean(quote),
45-
quote,
46-
}
47-
}
48-
49-
const escapedQuotesRegexps = {}
50-
for (const quote of quotes)
51-
escapedQuotesRegexps[quote] = new RegExp(`\\\\${quote}`, 'g')
52-
53-
/**
54-
* Strip slashes preceding occurences of <code>quote</code> from <code>str</code><br />
55-
* Possible quotes are <code>"</code> and <code>'</code>.
56-
* @function
57-
* @param {string} str The string
58-
* @param {string} quote The quote to unescape
59-
* @return {string} The unescaped string
60-
* @memberof core
61-
* @private
62-
* @since 1.0.0
63-
*/
64-
const unescapeQuotes = (str, quote) => str.replace(escapedQuotesRegexps[quote], quote)
45+
const unescapeQuotes = (str, quote) => str.replace(new RegExp(`\\\\${quote}`, 'g'), quote)
6546

6647
/**
6748
* Converts <code>str</code> to a slice index.
@@ -77,13 +58,25 @@ const toSliceIndex = str => str === '' ? undefined : Number(str)
7758
/**
7859
* Tests whether <code>arg</code> is a valid slice index, that is <code>undefined</code> or a valid int.
7960
* @function
61+
* @memberof core
8062
* @param {*} arg The value to test
8163
* @return {boolean} True if <code>arg</code> is a valid slice index, false otherwise.
8264
* @private
8365
* @since 1.0.0
8466
*/
8567
const isSliceIndex = arg => arg === undefined || Number.isSafeInteger(arg)
8668

69+
/**
70+
* Tests whether <code>arg</code> is a valid slice index once converted to a number.
71+
* @function
72+
* @memberof core
73+
* @param {*} arg The value to test
74+
* @return {boolean} True if <code>arg</code> is a valid slice index once converted to a number, false otherwise.
75+
* @private
76+
* @since 1.0.0
77+
*/
78+
const isSliceIndexString = arg => isSliceIndex(arg ? Number(arg) : undefined)
79+
8780
/**
8881
* Wraps <code>fn</code> allowing to call it with an array instead of a string.<br />
8982
* The returned function behaviour is :<br />
@@ -102,6 +95,50 @@ const allowingArrays = fn => arg => {
10295
return fn(toString(arg))
10396
}
10497

98+
const emptyStringParser = str => str.length === 0 ? [] : null
99+
100+
const quotedBracketNotationParser = map(
101+
regexp(/^\[(['"])(.*?[^\\])\1\]?\.?(.*)$/),
102+
([quote, property, rest]) => [unescapeQuotes(property, quote), ...stringToPath(rest)],
103+
)
104+
105+
const incompleteQuotedBracketNotationParser = map(
106+
regexp(/^\[["'](.*)$/),
107+
([rest]) => rest ? [rest] : [],
108+
)
109+
110+
const bareBracketNotationParser = map(
111+
regexp(/^\[([^\]]*)\]\.?(.*)$/),
112+
([property, rest]) => {
113+
return isIndex(Number(property))
114+
? [Number(property), ...stringToPath(rest)]
115+
: [property, ...stringToPath(rest)]
116+
},
117+
)
118+
119+
const incompleteBareBracketNotationParser = map(
120+
regexp(/^\[(.*)$/),
121+
([rest]) => rest ? [rest] : [],
122+
)
123+
124+
const sliceNotationParser = map(
125+
filter(
126+
regexp(/^\[([^:\]]*):([^:\]]*)\]\.?(.*)$/),
127+
([sliceStart, sliceEnd]) => isSliceIndexString(sliceStart) && isSliceIndexString(sliceEnd),
128+
),
129+
([sliceStart, sliceEnd, rest]) => [[toSliceIndex(sliceStart), toSliceIndex(sliceEnd)], ...stringToPath(rest)],
130+
)
131+
132+
const pathSegmentEndedByDotParser = map(
133+
regexp(/^([^.[]*?)\.(.*)$/),
134+
([beforeDot, afterDot]) => [beforeDot, ...stringToPath(afterDot)],
135+
)
136+
137+
const pathSegmentEndedByBracketParser = map(
138+
regexp(/^([^.[]*?)(\[.*)$/),
139+
([beforeBracket, atBracket]) => [beforeBracket, ...stringToPath(atBracket)],
140+
)
141+
105142
/**
106143
* Converts <code>str</code> to a path represented as an array of keys.
107144
* @function
@@ -111,141 +148,17 @@ const allowingArrays = fn => arg => {
111148
* @private
112149
* @since 1.0.0
113150
*/
114-
const stringToPath = str => {
115-
const path = []
116-
let index = 0
117-
118-
while (true) { // eslint-disable-line no-constant-condition
119-
// Look for new dot or opening square bracket
120-
const nextPointIndex = str.indexOf('.', index)
121-
const nextBracketIndex = str.indexOf('[', index)
122-
123-
// If neither one is found add the end of str to the path and stop
124-
if (nextPointIndex === -1 && nextBracketIndex === -1) {
125-
path.push(str.substring(index))
126-
break
127-
}
128-
129-
let isArrayNotation = false
130-
131-
// If a dot is found before an opening square bracket
132-
if (nextPointIndex !== -1 && (nextBracketIndex === -1 || nextPointIndex < nextBracketIndex)) {
133-
// Add the text preceding the dot to the path and move index after the dot
134-
path.push(str.substring(index, nextPointIndex))
135-
index = nextPointIndex + 1
136-
137-
// If an opening square bracket follows the dot,
138-
// enable array notation and move index after the bracket
139-
if (nextBracketIndex === nextPointIndex + 1) {
140-
isArrayNotation = true
141-
index = nextBracketIndex + 1
142-
}
143-
144-
// If an opening square bracket is found before a dot
145-
} else if (nextBracketIndex !== -1) {
146-
// Enable array notation
147-
isArrayNotation = true
148-
149-
// If any text precedes the bracket, add it to the path
150-
if (nextBracketIndex !== index)
151-
path.push(str.substring(index, nextBracketIndex))
152-
153-
// Move index after the bracket
154-
index = nextBracketIndex + 1
155-
}
156-
157-
// If array notation is enabled
158-
if (isArrayNotation) {
159-
// Check if next character is a string quote
160-
const { quoted, quote } = isQuoteChar(str, index)
161-
162-
// If array index is a quoted string
163-
if (quoted) {
164-
// Move index after the string quote
165-
index++
166-
167-
// Look for the next unescaped matching string quote
168-
let endQuoteIndex, quotedIndex = index
169-
do {
170-
endQuoteIndex = str.indexOf(quote, quotedIndex)
171-
quotedIndex = endQuoteIndex + 1
172-
} while (endQuoteIndex !== -1 && str.charAt(endQuoteIndex - 1) === '\\')
173-
174-
// If no end quote found, stop if end of str is reached, or continue to next iteration
175-
if (endQuoteIndex === -1) {
176-
if (index !== str.length) path.push(str.substring(index))
177-
break
178-
}
179-
180-
// Add the content of quotes to the path, unescaping escaped quotes
181-
path.push(unescapeQuotes(str.substring(index, endQuoteIndex), quote))
182-
183-
// Move index after end quote
184-
index = endQuoteIndex + 1
185-
186-
// If next character is a closing square bracket, move index after it
187-
if (str.charAt(index) === ']') index++
188-
189-
// Stop if end of str has been reached
190-
if (index === str.length) break
191-
192-
// If next character is a dot, move index after it (skip it)
193-
if (str.charAt(index) === '.') index++
194-
195-
} else { // If array index is not a quoted string
196-
197-
// Look for the closing square bracket
198-
const closingBracketIndex = str.indexOf(']', index)
199-
200-
// If no closing bracket found, stop if end of str is reached, or continue to next iteration
201-
if (closingBracketIndex === -1) {
202-
if (index !== str.length) path.push(str.substring(index))
203-
break
204-
}
205-
206-
// Fetch the content of brackets and move index after closing bracket
207-
const arrayIndexValue = str.substring(index, closingBracketIndex)
208-
index = closingBracketIndex + 1
209-
210-
// If next character is a dot, move index after it (skip it)
211-
if (str.charAt(index) === '.') index++
212-
213-
// Shorthand: if array index is the whole slice add it to path
214-
if (arrayIndexValue === ':') {
215-
path.push([undefined, undefined])
216-
} else {
217-
218-
// Look for a slice quote
219-
const sliceDelimIndex = arrayIndexValue.indexOf(':')
220-
221-
// If no slice quote found
222-
if (sliceDelimIndex === -1) {
223-
// Parse array index as a number
224-
const nArrayIndexValue = Number(arrayIndexValue)
225-
226-
// Add array index to path, either as a valid index (positive int), or as a string
227-
path.push(isIndex(nArrayIndexValue) ? nArrayIndexValue : arrayIndexValue)
228-
229-
} else { // If a slice quote is found
230-
231-
// Fetch slice start and end, and parse them as slice indexes (empty or valid int)
232-
const sliceStart = arrayIndexValue.substring(0, sliceDelimIndex), sliceEnd = arrayIndexValue.substring(sliceDelimIndex + 1)
233-
const nSliceStart = toSliceIndex(sliceStart), nSliceEnd = toSliceIndex(sliceEnd)
234-
235-
// Add array index to path, as a slice if both slice indexes are valid (undefined or int), or as a string
236-
path.push(isSliceIndex(nSliceStart) && isSliceIndex(nSliceEnd) ? [nSliceStart, nSliceEnd] : arrayIndexValue)
237-
}
238-
}
239-
240-
// Stop if end of string has been reached
241-
if (index === str.length) break
242-
}
243-
}
244-
245-
}
246-
247-
return path
248-
}
151+
const stringToPath = race([
152+
emptyStringParser,
153+
quotedBracketNotationParser,
154+
incompleteQuotedBracketNotationParser,
155+
sliceNotationParser,
156+
bareBracketNotationParser,
157+
incompleteBareBracketNotationParser,
158+
pathSegmentEndedByDotParser,
159+
pathSegmentEndedByBracketParser,
160+
str => [str],
161+
])
249162

250163
const MAX_CACHE_SIZE = 1000
251164
const cache = new Map()

packages/immutadot/src/core/toPath.spec.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ describe('ToPath', () => {
66
it('should convert basic path', () => {
77
expect(toPath('a.22.ccc')).toEqual(['a', '22', 'ccc'])
88
// Empty properties should be kept
9-
expect(toPath('.')).toEqual(['', ''])
10-
expect(toPath('..')).toEqual(['', '', ''])
9+
expect(toPath('.')).toEqual([''])
10+
expect(toPath('..')).toEqual(['', ''])
1111
// If no separators, path should be interpreted as one property
1212
expect(toPath('\']"\\')).toEqual(['\']"\\'])
1313
})

packages/immutadot/src/util/lang.js

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const isSymbol = arg => typeof arg === 'symbol'
3333

3434
/**
3535
* Returns the length of <code>arg</code>.
36+
* @function
37+
* @memberof util
3638
* @param {*} arg The value of which length must be returned
3739
* @returns {number} The length of <code>arg</code>
3840
* @private

0 commit comments

Comments
 (0)