Skip to content

Commit

Permalink
fix(parser): Parse event name when using an identifier (KatChaotic#1)
Browse files Browse the repository at this point in the history
- Add utils functions to help parse identifiers:
- Fix potential bugs:
  - utils.value was mutating the argument
  - parseEventDeclaration could attempt to read property of 'undefined'
- Add/Update tests (3) for event name parsing in parser v2 and v3
- Add unit tests (3) for new utils function
  • Loading branch information
soft-decay committed Jan 17, 2021
1 parent e1b80a0 commit 807e662
Show file tree
Hide file tree
Showing 8 changed files with 306 additions and 35 deletions.
28 changes: 20 additions & 8 deletions lib/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -459,15 +459,17 @@ class Parser extends EventEmitter {
const token = tokens[i];

if (token.type === 'Identifier' && token.value === 'fire') {
if (!tokens[i + 2]) {
const nextIndex = i + 2;

if (!tokens[nextIndex]) {
break;
}

if (!this.features.includes('events')) {
continue;
}

const next = tokens[i + 2];
const next = tokens[nextIndex];
const event = {
name: null,
parent: null,
Expand Down Expand Up @@ -524,20 +526,30 @@ class Parser extends EventEmitter {
break;

case 'Identifier':
event.name = utils.getIdentifierValue(
tokens, next.value, next.range[0]);
if (next.value in this.identifiers) {
const startingAtFirstArg = tokens.slice(nextIndex);

const chain = utils.buildPropertyAccessorChainFromTokens(startingAtFirstArg);

event.name = utils.getValueForPropertyAccessorChain(this.identifiers, chain);
}

if (!event.name) {
event.name = utils.getIdentifierValue(
tokens, next.value, next.range[0]);

if (typeof event.name === 'object') {
event.name = utils.getIdentifierValueFromStart(
this.ast.tokens, event.name.notFoundIdentifier);
if (typeof event.name === 'object') {
event.name = utils.getIdentifierValueFromStart(
this.ast.tokens, event.name.notFoundIdentifier);
}
}

break;
}
}

if (!event.name) {
event.name = '****unhandled-event-name****';
event.name = utils.UNHANDLED_EVENT_NAME;
} else {
if (hasOwnProperty(this.eventsEmitted, event.name)) {
const emitedEvent = this.eventsEmitted[event.name];
Expand Down
148 changes: 140 additions & 8 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const RE_VISIBILITY = new RegExp(`^(${VISIBILITIES.join('|')})$`);
const RE_KEYWORDS = /@\**\s*([a-z0-9_-]+)(\s+(-\s+)?([\wÀ-ÿ\s*{}[\]()='"`_^$#&²~|\\£¤€%µ,?;.:/!§<>+¨-]+))?/ig;

const DEFAULT_VISIBILITY = 'public';
const UNHANDLED_EVENT_NAME = '****unhandled-event-name****';

const isVisibilitySupported = (v) => RE_VISIBILITY.test(v);

Expand Down Expand Up @@ -112,30 +113,30 @@ class NodeFunction {
}

const value = (property) => {
if (property.key.type === 'Literal') {
property.key.name = property.key.value;
}
const keyName = property.key.type === 'Literal'
? property.key.value
: property.key.name;

switch (property.value.type) {
case 'Literal':
return { [property.key.name]: property.value.value };
return { [keyName]: property.value.value };

case 'Identifier':
return {
[property.key.name]: property.value.name === 'undefined'
[keyName]: property.value.name === 'undefined'
? undefined
: property.value.name
};

case 'ObjectExpression':
return { [property.key.name]: values(property) };
return { [keyName]: values(property) };

case 'FunctionExpression':
case 'ArrowFunctionExpression':
return { [property.key.name]: new NodeFunction(property.value) };
return { [keyName]: new NodeFunction(property.value) };
}

return { [property.key.name]: property.value };
return { [keyName]: property.value };
};

const values = (entry) => {
Expand All @@ -154,6 +155,132 @@ const values = (entry) => {
return values;
};

/**
*
* @param {{ type: string; value: string }[]} tokens
*/
const buildPropertyAccessorChainFromTokens = (tokens) => {
const next = tokens[0];

if (!next) {
return [];
}

if (!next.type === 'Identifier') {
return [];
}

const chain = [next.value];

let punctIndex = 1;
let isChained = tokens[punctIndex] && tokens[punctIndex].value === '.';
let chained = tokens[punctIndex + 1];

while (isChained && chained && chained.type === 'Identifier') {
chain.push(chained.value);
punctIndex += 2;
isChained = tokens[punctIndex] && tokens[punctIndex].value === '.';
chained = tokens[punctIndex + 1];
}

return chain;
};

/**
* Builds an array of property names from a 'MemberExpression' node.
* - Supports nested 'MemberExpression', 'Identifier', and 'Literal' nodes
* - Does not support bracket notation (computed === true).
*
* @example
* dispatch(PLAIN.NESTED.INNER);
* // Parsing the 'MemberExpression' node
* // corresponding to 'PLAIN.NESTED.INNER'
* // would yield the array ['PLAIN', 'NESTED', 'INNER']
*
* @param {{ type: string; object: any, property: any, computed: boolean }} node
*/
const buildPropertyAccessorChainFromAst = (node) => {
if (node.type === 'Identifier') {
return [node.name];
}

if (node.type === 'Literal') {
return [node.value];
}

if (node.type !== 'MemberExpression') {
return [undefined];
}

const chain = [];

if (!node.computed) {
// Dot notation
chain.push(...buildPropertyAccessorChainFromAst(node.object));
chain.push(node.property.name);
} else {
// TODO: Support bracket notation
chain.push(undefined);
}

return chain.includes(undefined) ? [undefined] : chain;
};

/**
* Builds an object expression (i.e. { ... }) from an 'ObjectExpression' node.
* Supports a limited range of property types:
* - 'ObjectExpression' (nested)
* - 'Literal' (string, int, boolean, etc)
*
* @param {{ type: 'ObjectExpression'; properties: any[] }} node
*/
const buildObjectFromObjectExpression = (node) => {
if (node.type !== 'ObjectExpression') {
throw new TypeError("Node must be of type 'ObjectExpression' but is", node.type);
}

const obj = {};

node.properties.forEach((property) => {
if (property.value.type === 'ObjectExpression') {
obj[property.key.name] = buildObjectFromObjectExpression(property.value);
} else if (property.value.type === 'Literal') {
obj[property.key.name] = property.value.value;
}
});

return obj;
};

/**
*
* @param {Record<string, { type: string; value?: any }>} record
* @param {string[]} chain
*/
const getValueForPropertyAccessorChain = (record, chain) => {
const rootExpression = record[chain[0]];

if (rootExpression.type === 'Literal') {
return rootExpression.value;
}

if (rootExpression.type !== 'ObjectExpression') {
return UNHANDLED_EVENT_NAME;
}

let current = buildObjectFromObjectExpression(rootExpression);

for (const identifier of chain.slice(1)) {
current = current[identifier];

if (!current) {
return UNHANDLED_EVENT_NAME;
}
}

return current;
};

const tokensInterval = (tokens, range) => {
return tokens.filter((item) => {
return item.range[0] > range[0] && item.range[1] < range[1];
Expand Down Expand Up @@ -299,13 +426,18 @@ const hasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj,

module.exports.VISIBILITIES = VISIBILITIES;
module.exports.DEFAULT_VISIBILITY = DEFAULT_VISIBILITY;
module.exports.UNHANDLED_EVENT_NAME = UNHANDLED_EVENT_NAME;
module.exports.isVisibilitySupported = isVisibilitySupported;
module.exports.getVisibility = getVisibility;
module.exports.parseComment = parseComment;
module.exports.getCommentFromSourceCode = getCommentFromSourceCode;
module.exports.NodeFunction = NodeFunction;
module.exports.value = value;
module.exports.values = values;
module.exports.buildObjectFromObjectExpression = buildObjectFromObjectExpression;
module.exports.buildPropertyAccessorChainFromAst = buildPropertyAccessorChainFromAst;
module.exports.buildPropertyAccessorChainFromTokens = buildPropertyAccessorChainFromTokens;
module.exports.getValueForPropertyAccessorChain = getValueForPropertyAccessorChain;
module.exports.tokensInterval = tokensInterval;
module.exports.getIdentifierValue = getIdentifierValue;
module.exports.getIdentifierValueFromStart = getIdentifierValueFromStart;
Expand Down
25 changes: 21 additions & 4 deletions lib/v3/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class Parser extends EventEmitter {
// Internal properties
this.componentName = null;
this.eventsEmitted = {};
this.identifiers = {};
this.imports = {};
this.dispatcherConstructorNames = [];
this.dispatcherNames = [];
Expand Down Expand Up @@ -325,8 +326,14 @@ class Parser extends EventEmitter {
}

if (variable.declarator.init) {
const idNode = variable.declarator.id;
const initNode = variable.declarator.init;

// Store top level variables in 'identifiers'
if (level === 0 && idNode.type === 'Identifier') {
this.identifiers[idNode.name] = variable.declarator.init;
}

if (initNode.type === 'CallExpression') {
const callee = initNode.callee;

Expand Down Expand Up @@ -608,16 +615,26 @@ class Parser extends EventEmitter {

const args = node.arguments;

if (!args && args.length < 1) {
if (!args || !args.length) {
return null;
}

const nameNode = args[0];

return {
name: nameNode.type === 'Literal'
let name;

try {
const chain = utils.buildPropertyAccessorChainFromAst(nameNode);

name = utils.getValueForPropertyAccessorChain(this.identifiers, chain);
} catch (error) {
name = nameNode.type === 'Literal'
? nameNode.value
: undefined,
: undefined;
}

return {
name: name,
node: node,
location: {
start: nameNode.start,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
<script>
const ComponentEventNames = {
Click: 'click'
Click: 'click',
Alternate: {
Press: 'press'
},
};
export default {
methods: {
handleButtonClick(event) {
/**
* Event fired when user clicked on button.
*/
this.fire(ComponentEventNames.Click, event);
},
handleButtonPress(event) {
/**
* Event fired when user pressed on button.
*/
this.fire(ComponentEventNames.Alternate.Press, event);
}
}
}
Expand Down
12 changes: 10 additions & 2 deletions test/svelte2/integration/events/events.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('SvelteDoc - Events', () => {
});
});

xit('Fired events with identifier event name in component methods should be parsed', (done) => {
it('Fired events with identifier event name in component methods should be parsed', (done) => {
parser.parse({
version: 2,
filename: path.resolve(__dirname, 'event.method.fire.identifier.svelte'),
Expand All @@ -65,7 +65,7 @@ describe('SvelteDoc - Events', () => {
expect(doc, 'Document should be provided').to.exist;
expect(doc.events, 'Document events should be parsed').to.exist;

expect(doc.events.length).to.equal(1);
expect(doc.events.length).to.equal(2);
const event = doc.events[0];

expect(event, 'Event should be a valid entity').to.exist;
Expand All @@ -74,6 +74,14 @@ describe('SvelteDoc - Events', () => {
expect(event.parent).to.be.null;
expect(event.description).to.equal('Event fired when user clicked on button.');

const event2 = doc.events[1];

expect(event2, 'Event should be a valid entity').to.exist;
expect(event2.name).to.equal('press');
expect(event2.visibility).to.equal('public');
expect(event2.parent).to.be.null;
expect(event2.description).to.equal('Event fired when user pressed on button.');

done();
}).catch(e => {
done(e);
Expand Down
11 changes: 11 additions & 0 deletions test/svelte3/integration/events/event.dispatcher.identifier.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const EVENT = {
SIGNAL: {
NOTIFY: 'notify'
}
};
dispatch(EVENT.SIGNAL.NOTIFY);
</script>
Loading

0 comments on commit 807e662

Please sign in to comment.