Skip to content

Commit

Permalink
✨ toPath without _ (#102)
Browse files Browse the repository at this point in the history
  • Loading branch information
nlepage committed Sep 17, 2017
1 parent b11f68a commit 7dc4eba
Show file tree
Hide file tree
Showing 11 changed files with 443 additions and 21 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"comma-style": "error",
"computed-property-spacing": "error",
"consistent-return": 2,
"curly": ["error", "multi"],
"curly": ["error", "multi-or-nest", "consistent"],
"dot-location": ["error", "property"],
"dot-notation": "error",
"eol-last": "error",
Expand Down
5 changes: 3 additions & 2 deletions misc/test.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,20 @@ expect.extend({
const diff = (this.isNot ? intersectionWith : xorWith)(refs, receivedRefs, (ref, receivedRef) => ref[0] === receivedRef[0] && ref[1] === receivedRef[1])
const pass = Boolean(this.isNot ^ !diff.length)
let message
if (pass)
if (pass) {
message = () => {
const formattedDiff = map(diff, ([path, ref]) => ` "${path}": ${this.utils.printReceived(ref)}`).join('\n')
return `${this.utils.matcherHint('.not.toBeDeep')} expected values not to have the same deep references, same references at paths :\n${formattedDiff}`
}
else
} else {
message = () => {
const formattedDiff = map(
groupBy(diff, ref => ref[0]),
([[, ref], [, receivedRef] = []], path) => ` "${path}": ${this.utils.printExpected(ref)} !== ${this.utils.printReceived(receivedRef)}`,
).join('\n')
return `${this.utils.matcherHint('.toBeDeep')} expected values to have the same deep references, different references at paths :\n${formattedDiff}`
}
}

return {
message,
Expand Down
10 changes: 10 additions & 0 deletions src/core/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { toPath } from './toPath'

/**
* Core functions.
* @namespace core
* @since 0.4.0
*/
export {
toPath,
}
287 changes: 287 additions & 0 deletions src/core/toPath.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import {
isIndex,
isSymbol,
toString,
} from 'util/lang'

/**
* Converts a value to a valid path key.<br />
* Returns <code>arg</code> if arg is a positive integer or a Symbol, <code>toString(arg)</code> otherwise.
* @param {*} arg The value to convert
* @return {string} A valid path key
* @memberof core
* @private
* @since 0.4.0
*/
const toKey = arg => {
if (isIndex(arg)) return arg
if (isSymbol(arg)) return arg
if (Array.isArray(arg) && arg.length === 2 && isSliceIndex(arg[0]) && isSliceIndex(arg[0])) return arg
return toString(arg)
}

const quotes = ['"', '\'']

/**
* Tests whether <code>index</code>th char of <code>str</code> is a quote.<br />
* Quotes are <code>"</code> and <code>'</code>.
* @param {string} str The string
* @param {number} index Index of the char to test
* @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>.
* @memberof core
* @private
* @since 0.4.0
*/
const isQuoteChar = (str, index) => {
const char = str.charAt(index)
const quote = quotes.find(c => c === char)
return {
quoted: Boolean(quote),
quote,
}
}

const escapedQuotesRegexps = {}
for (const quote of quotes)
escapedQuotesRegexps[quote] = new RegExp(`\\\\${quote}`, 'g')

/**
* Strip slashes preceding occurences of <code>quote</code> from <code>str</code><br />
* Possible quotes are <code>"</code> and <code>'</code>.
* @param {string} str The string
* @param {string} quote The quote to unescape
* @return {string} The unescaped string
* @memberof core
* @private
* @since 0.4.0
*/
const unescapeQuotes = (str, quote) => str.replace(escapedQuotesRegexps[quote], quote)

/**
* Converts <code>str</code> to a slice index.
* @param {string} str The string to convert
* @return {number} <code>undefined</code> if <code>str</code> is empty, otherwise an int (may be NaN)
* @memberof core
* @private
* @since 0.4.0
*/
const toSliceIndex = str => str === '' ? undefined : Number(str)

/**
* Tests whether <code>arg</code> is a valid slice index, that is <code>undefined</code> or a valid int.
* @param {*} arg The value to test
* @return {boolean} True if <code>arg</code> is a valid slice index, false otherwise.
* @private
* @since 0.4.0
*/
const isSliceIndex = arg => arg === undefined || Number.isSafeInteger(arg)

/**
* Wraps <code>fn</code> allowing to call it with an array instead of a string.<br />
* The returned function behaviour is :<br />
* - If called with an array, returns a copy of the array with values converted to path keys<br />
* - Otherwise, calls <code>fn</code> with the string representation of its argument
* @param {function} fn The function to wrap
* @return {function} The wrapper function
* @memberof core
* @private
* @since 0.4.0
*/
const allowingArrays = fn => arg => {
if (Array.isArray(arg)) return arg.map(toKey)

return fn(toString(arg))
}

/**
* Converts <code>str</code> to a path represented as an array of keys.
* @param {string} str The string to convert
* @return {(string|number)[]} The path represented as an array of keys
* @memberof core
* @private
* @since 0.4.0
*/
const stringToPath = str => {
const path = []
let index = 0

while (true) { // eslint-disable-line no-constant-condition
// Look for new dot or opening square bracket
const nextPointIndex = str.indexOf('.', index)
const nextBracketIndex = str.indexOf('[', index)

// If neither one is found add the end of str to the path and stop
if (nextPointIndex === -1 && nextBracketIndex === -1) {
path.push(str.substring(index))
break
}

let isArrayNotation = false

// If a dot is found before an opening square bracket
if (nextPointIndex !== -1 && (nextBracketIndex === -1 || nextPointIndex < nextBracketIndex)) {
// Add the text preceding the dot to the path and move index after the dot
path.push(str.substring(index, nextPointIndex))
index = nextPointIndex + 1

// If an opening square bracket follows the dot,
// enable array notation and move index after the bracket
if (nextBracketIndex === nextPointIndex + 1) {
isArrayNotation = true
index = nextBracketIndex + 1
}

// If an opening square bracket is found before a dot
} else if (nextBracketIndex !== -1) {
// Enable array notation
isArrayNotation = true

// If any text precedes the bracket, add it to the path
if (nextBracketIndex !== index)
path.push(str.substring(index, nextBracketIndex))

// Move index after the bracket
index = nextBracketIndex + 1
}

// If array notation is enabled
if (isArrayNotation) {
// Check if next character is a string quote
const { quoted, quote } = isQuoteChar(str, index)

// If array index is a quoted string
if (quoted) {
// Move index after the string quote
index++

// Look for the next unescaped matching string quote
let endQuoteIndex, quotedIndex = index
do {
endQuoteIndex = str.indexOf(quote, quotedIndex)
quotedIndex = endQuoteIndex + 1
} while (endQuoteIndex !== -1 && str.charAt(endQuoteIndex - 1) === '\\')

// If no end quote found, stop if end of str is reached, or continue to next iteration
if (endQuoteIndex === -1) {
if (index !== str.length) path.push(str.substring(index))
break
}

// Add the content of quotes to the path, unescaping escaped quotes
path.push(unescapeQuotes(str.substring(index, endQuoteIndex), quote))

// Move index after end quote
index = endQuoteIndex + 1

// If next character is a closing square bracket, move index after it
if (str.charAt(index) === ']') index++

// Stop if end of str has been reached
if (index === str.length) break

// If next character is a dot, move index after it (skip it)
if (str.charAt(index) === '.') index++

} else { // If array index is not a quoted string

// Look for the closing square bracket
const closingBracketIndex = str.indexOf(']', index)

// If no closing bracket found, stop if end of str is reached, or continue to next iteration
if (closingBracketIndex === -1) {
if (index !== str.length) path.push(str.substring(index))
break
}

// Fetch the content of brackets and move index after closing bracket
const arrayIndexValue = str.substring(index, closingBracketIndex)
index = closingBracketIndex + 1

// If next character is a dot, move index after it (skip it)
if (str.charAt(index) === '.') index++

// Shorthand: if array index is the whole slice add it to path
if (arrayIndexValue === ':') {
path.push([undefined, undefined])
} else {

// Look for a slice quote
const sliceDelimIndex = arrayIndexValue.indexOf(':')

// If no slice quote found
if (sliceDelimIndex === -1) {
// Parse array index as a number
const nArrayIndexValue = Number(arrayIndexValue)

// Add array index to path, either as a valid index (positive int), or as a string
path.push(isIndex(nArrayIndexValue) ? nArrayIndexValue : arrayIndexValue)

} else { // If a slice quote is found

// Fetch slice start and end, and parse them as slice indexes (empty or valid int)
const sliceStart = arrayIndexValue.substring(0, sliceDelimIndex), sliceEnd = arrayIndexValue.substring(sliceDelimIndex + 1)
const nSliceStart = toSliceIndex(sliceStart), nSliceEnd = toSliceIndex(sliceEnd)

// Add array index to path, as a slice if both slice indexes are valid (undefined or int), or as a string
path.push(isSliceIndex(nSliceStart) && isSliceIndex(nSliceEnd) ? [nSliceStart, nSliceEnd] : arrayIndexValue)
}
}

// Stop if end of string has been reached
if (index === str.length) break
}
}

}

return path
}

const MAX_CACHE_SIZE = 1000
const cache = new Map()

/**
* Memoized version of {@link core.stringToPath}.<br />
* The cache has a maximum size of 1000, when overflowing the cache is cleared.
* @param {string} str The string to convert
* @return {(string|number)[]} The path represented as an array of keys
* @memberof core
* @private
* @since 0.4.0
*/
const memoizedStringToPath = str => {
if (cache.has(str)) return cache.get(str)

const path = stringToPath(str)

if (cache.size === MAX_CACHE_SIZE) cache.clear()
cache.set(str, path)

return path
}

/**
* Converts <code>arg</code> to a path represented as an array of keys.<br />
* <code>arg</code> may be a string, in which case it will be parsed.<br />
* It may also be an Array, in which case a copy of the array with values converted to path keys will be returned.<br />
* If <code>arg</code> is neither a string nor an Array, its string representation will be parsed.<br />
* This function is failsafe, it will never throw an error.
* @param {string|Array|*} arg The value to convert
* @return {(string|number)[]} The path represented as an array of keys
* @memberof core
* @since 0.4.0
* @example toPath('a.b[1]["."][1:-1]') // => ['a', 'b', 1, '.', [1, -1]]
*/
const toPath = allowingArrays(arg => [...memoizedStringToPath(arg)])

/**
* This method is like {@link core.toPath} except it returns memoized arrays which must not be mutated.
* @param {string|Array|*} arg The value to convert
* @return {(string|number)[]} The path represented as an array of keys
* @memberof core
* @since 0.4.0
* @private
*/
const unsafeToPath = allowingArrays(memoizedStringToPath)

export { toPath, unsafeToPath }
65 changes: 65 additions & 0 deletions src/core/toPath.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-env jest */
import { toPath } from 'core'

describe('ToPath', () => {

it('should convert basic path', () => {
expect(toPath('a.22.ccc')).toEqual(['a', '22', 'ccc'])
// Empty properties should be kept
expect(toPath('.')).toEqual(['', ''])
expect(toPath('..')).toEqual(['', '', ''])
// If no separators, path should be interpreted as one property
expect(toPath('\']"\\')).toEqual(['\']"\\'])
})

it('should convert array notation path', () => {
expect(toPath('[0]["1.2"][\'[1.2]\']["[\\"1.2\\"]"][1a][1[2]')).toEqual([0, '1.2', '[1.2]', '["1.2"]', '1a', '1[2'])
// Empty unterminated array notation should be discarded
expect(toPath('[0][')).toEqual([0])
expect(toPath('[0]["')).toEqual([0])
// Unterminated array notation should run to end of path as string
expect(toPath('[0][123')).toEqual([0, '123'])
expect(toPath('[0][1.a[2')).toEqual([0, '1.a[2'])
// Unterminated quoted array notation should run to end of path
expect(toPath('[0]["1[2].a')).toEqual([0, '1[2].a'])
})

it('should convert slice notation path', () => {
expect(toPath('[:][1:][:-2][3:4]')).toEqual([
[undefined, undefined],
[1, undefined],
[undefined, -2],
[3, 4],
])
expect(toPath('[1:2:3][1:a][1:2')).toEqual(['1:2:3', '1:a', '1:2'])
})

it('should convert mixed path', () => {
expect(toPath('a[0]["b.c"].666[1:]')).toEqual(['a', 0, 'b.c', '666', [1, undefined]])
expect(toPath('a.[0].["b.c"]666[1:2:3]')).toEqual(['a', 0, 'b.c', '666', '1:2:3'])
})

it('should not convert array path', () => {
expect(toPath([
666,
-666,
Symbol.for('🍺'),
true,
'test',
[1, undefined],
[0, -2],
[1, 2, 3],
['1', 2],
])).toEqual([
666,
'-666',
Symbol.for('🍺'),
'true',
'test',
[1, undefined],
[0, -2],
'1,2,3',
'1,2',
])
})
})

0 comments on commit 7dc4eba

Please sign in to comment.