diff --git a/.babelrc b/.babelrc index 4cbd460..4517434 100644 --- a/.babelrc +++ b/.babelrc @@ -2,5 +2,6 @@ "presets": ["env"], "plugins": [ ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + "transform-object-rest-spread", ], } diff --git a/.eslintrc b/.eslintrc index 02cde01..e725eb7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,6 @@ { - extends: "airbnb-base" + extends: "airbnb-base", + rules: { + no-use-before-define: ["error", { functions: false }] + } } diff --git a/__tests__/helper.js b/__tests__/helper.js index 37785d1..dd00aa5 100644 --- a/__tests__/helper.js +++ b/__tests__/helper.js @@ -36,7 +36,7 @@ function parse(code) { } if (parserName === 'babel') { try { - return babelParser.parse(code, { plugins }); + return babelParser.parse(code, { plugins, sourceFilename: 'test.js' }); } catch (_) { // eslint-disable-next-line no-console console.warn(`Failed to parse with ${fallbackToBabylon ? 'babylon' : 'Babel'} parser.`); diff --git a/__tests__/src/getProp-parser-test.js b/__tests__/src/getProp-parser-test.js new file mode 100644 index 0000000..9ffb50e --- /dev/null +++ b/__tests__/src/getProp-parser-test.js @@ -0,0 +1,136 @@ +/* eslint-env mocha */ +import assert from 'assert'; +import entries from 'object.entries'; +import fromEntries from 'object.fromentries'; +import { getOpeningElement, setParserName, fallbackToBabylon } from '../helper'; +import getProp from '../../src/getProp'; + +const literal = { + source: '
', + target: '
', + offset: { keyOffset: -6, valueOffset: -7 }, +}; + +const expression1 = { + source: '
', + target: '
', + offset: { keyOffset: -6, valueOffset: -2 }, +}; + +const expression2 = { + source: '
', // eslint-disable-line no-template-curly-in-string + target: '
', // eslint-disable-line no-template-curly-in-string + offset: { keyOffset: -6, valueOffset: -6 }, +}; + +describe('getProp', () => { + it('should create the correct AST for literal with flow parser', () => { + actualTest('flow', literal); + }); + it('should create the correct AST for literal with babel parser', () => { + actualTest('babel', literal); + }); + it('should create the correct AST for expression with flow parser (1)', () => { + actualTest('flow', expression1); + }); + it('should create the correct AST for expression with babel parser (1)', () => { + actualTest('babel', expression1); + }); + it('should create the correct AST for expression with flow parser (2)', () => { + actualTest('flow', expression2); + }); + it('should create the correct AST for expression with babel parser (2)', () => { + actualTest('babel', expression2); + }); +}); + +function actualTest(parserName, test) { + setParserName(parserName); + const { source, target, offset } = test; + const sourceProps = stripConstructors(getOpeningElement(source).attributes); + const targetProps = stripConstructors(getOpeningElement(target).attributes); + const prop = 'id'; + const sourceResult = getProp(sourceProps, prop); + const targetResult = getProp(targetProps, prop); + + if (fallbackToBabylon && parserName === 'babel' && test === literal) { + // Babylon (node < 6) adds an `extra: null` prop to a literal if it is parsed from a + // JSXAttribute, other literals don't get this. + sourceResult.value.extra = null; + } + + assert.deepStrictEqual( + adjustLocations(sourceResult, offset), + targetResult, + ); +} + +function stripConstructors(value) { + return JSON.parse(JSON.stringify(value)); +} + +function adjustLocations(node, { keyOffset, valueOffset }) { + const hasExpression = !!node.value.expression; + return { + ...adjustNodeLocations(node, { + startOffset: keyOffset, + endOffset: valueOffset + (hasExpression ? 1 : 0), + }), + name: adjustNodeLocations(node.name, { startOffset: keyOffset, endOffset: keyOffset }), + value: { + ...adjustNodeLocations(node.value, { + startOffset: valueOffset - (hasExpression ? 1 : 0), + endOffset: valueOffset + (hasExpression ? 1 : 0), + }), + ...(hasExpression + ? { + expression: adjustLocationsRecursively( + node.value.expression, + { startOffset: valueOffset, endOffset: valueOffset }, + ), + } + : {} + ), + }, + }; +} + +function adjustNodeLocations(node, { startOffset, endOffset }) { + if (!node.loc) return node; + const [start, end] = node.range || []; + return { + ...node, + ...(node.start !== undefined ? { start: node.start + startOffset } : {}), + ...(node.end !== undefined ? { end: node.end + endOffset } : {}), + loc: { + ...node.loc, + start: { + ...node.loc.start, + column: node.loc.start.column + startOffset, + }, + end: { + ...node.loc.end, + column: node.loc.end.column + endOffset, + }, + }, + ...(node.range !== undefined ? { range: [start + startOffset, end + endOffset] } : {}), + }; +} + +function adjustLocationsRecursively(node, { startOffset, endOffset }) { + if (Array.isArray(node)) { + return node.map(x => adjustLocationsRecursively(x, { startOffset, endOffset })); + } + if (node && typeof node === 'object') { + return adjustNodeLocations( + mapValues(node, x => adjustLocationsRecursively(x, { startOffset, endOffset })), + { startOffset, endOffset }, + ); + } + + return node; +} + +function mapValues(o, f) { + return fromEntries(entries(o).map(([k, v]) => [k, f(v)])); +} diff --git a/__tests__/src/getProp-test.js b/__tests__/src/getProp-test.js index 248a2f6..82410c4 100644 --- a/__tests__/src/getProp-test.js +++ b/__tests__/src/getProp-test.js @@ -45,6 +45,72 @@ describe('getProp', () => { assert.equal(expected, actual); }); + it('should return the correct attribute if the attribute exists in spread', () => { + const code = '
'; + const node = getOpeningElement(code); + const { attributes: props } = node; + const prop = 'ID'; + + const expected = 'id'; + const actual = getProp(props, prop).name.name; + + assert.equal(expected, actual); + }); + + it('should return the correct attribute if the attribute exists in spread as an expression', () => { + const code = '
'; + const node = getOpeningElement(code); + const { attributes: props } = node; + const prop = 'id'; + + const expected = 'id'; + const actual = getProp(props, prop); + const actualName = actual.name.name; + const actualValue = actual.value.expression.name; + + assert.equal(expected, actualName); + assert.equal(expected, actualValue); + }); + + it('should return the correct attribute if the attribute exists in spread (case sensitive)', () => { + const code = '
'; + const node = getOpeningElement(code); + const { attributes: props } = node; + const prop = 'id'; + const options = { ignoreCase: false }; + + const expected = 'id'; + const actual = getProp(props, prop, options).name.name; + + assert.equal(expected, actual); + }); + + it('should return undefined if the attribute does not exist in spread (case sensitive)', () => { + const code = '
'; + const node = getOpeningElement(code); + const { attributes: props } = node; + const prop = 'ID'; + const options = { ignoreCase: false }; + + const expected = undefined; + const actual = getProp(props, prop, options); + + assert.equal(expected, actual); + }); + + it('should return undefined for key in spread', () => { + // https://github.com/reactjs/rfcs/pull/107 + const code = '
'; + const node = getOpeningElement(code); + const { attributes: props } = node; + const prop = 'key'; + + const expected = undefined; + const actual = getProp(props, prop); + + assert.equal(expected, actual); + }); + it('should return undefined if the attribute may exist in spread', () => { const code = '
'; const node = getOpeningElement(code); diff --git a/package.json b/package.json index 2c1d524..36bd524 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "babel-core": "^6.26.3", "babel-eslint": "^10.0.2", "babel-jest": "^20.0.3", + "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-replace-object-assign": "^1.0.0", "babel-polyfill": "^6.26.0", "babel-preset-env": "^1.7.0", @@ -32,6 +33,8 @@ "in-publish": "^2.0.0", "jest": "^20.0.4", "jest-cli": "^20.0.4", + "object.entries": "^1.1.0", + "object.fromentries": "^2.0.1", "rimraf": "^2.6.3" }, "engines": { diff --git a/src/getProp.js b/src/getProp.js index 4652c38..40d4fb9 100644 --- a/src/getProp.js +++ b/src/getProp.js @@ -10,18 +10,66 @@ const DEFAULT_OPTIONS = { * */ export default function getProp(props = [], prop = '', options = DEFAULT_OPTIONS) { - const propToFind = options.ignoreCase ? prop.toUpperCase() : prop; + function getName(name) { return options.ignoreCase ? name.toUpperCase() : name; } + const propToFind = getName(prop); + function isPropToFind(property) { + return property.key.type === 'Identifier' && propToFind === getName(property.key.name); + } - return props.find((attribute) => { - // If the props contain a spread prop, then skip. + const foundAttribute = props.find((attribute) => { + // If the props contain a spread prop, try to find the property in the object expression. if (attribute.type === 'JSXSpreadAttribute') { - return false; + return attribute.argument.type === 'ObjectExpression' + && propToFind !== getName('key') // https://github.com/reactjs/rfcs/pull/107 + && attribute.argument.properties.some(isPropToFind); } - const currentProp = options.ignoreCase - ? propName(attribute).toUpperCase() - : propName(attribute); - - return propToFind === currentProp; + return propToFind === getName(propName(attribute)); }); + + if (foundAttribute && foundAttribute.type === 'JSXSpreadAttribute') { + return propertyToJSXAttribute(foundAttribute.argument.properties.find(isPropToFind)); + } + + return foundAttribute; +} + +function propertyToJSXAttribute(node) { + const { key, value } = node; + return { + type: 'JSXAttribute', + name: { type: 'JSXIdentifier', name: key.name, ...getBaseProps(key) }, + value: value.type === 'Literal' + ? value + : { type: 'JSXExpressionContainer', expression: value, ...getBaseProps(value) }, + ...getBaseProps(node), + }; +} + +function getBaseProps({ + start, + end, + loc, + range, +}) { + return { + loc: getBaseLocation(loc), + ...(start !== undefined ? { start } : {}), + ...(end !== undefined ? { end } : {}), + ...(range !== undefined ? { range } : {}), + }; +} + +function getBaseLocation({ + start, + end, + source, + filename, +}) { + return { + start, + end, + ...(source !== undefined ? { source } : {}), + ...(filename !== undefined ? { filename } : {}), + }; }