diff --git a/examples/Alert.json b/examples/Alert.json index 40e4ee8..35500c4 100644 --- a/examples/Alert.json +++ b/examples/Alert.json @@ -32,6 +32,20 @@ "type": "any" } }, + { + "visibility": "private", + "description": null, + "keywords": [], + "name": "EVENT", + "kind": "const", + "static": false, + "readonly": true, + "type": { + "kind": "type", + "text": "any", + "type": "any" + } + }, { "visibility": "public", "description": null, diff --git a/examples/Alert.svelte b/examples/Alert.svelte index 3fce1a6..25f159d 100644 --- a/examples/Alert.svelte +++ b/examples/Alert.svelte @@ -11,7 +11,11 @@ */ import { createEventDispatcher } from 'svelte'; + const dispatch = createEventDispatcher(); + const EVENT = { + CLOSE: "close" + } export let closable = false; @@ -27,7 +31,7 @@ The `close` event fired when user click to X button in the panel. @event CloseEvent#close --> - + {/if} \ No newline at end of file diff --git a/lib/parser.js b/lib/parser.js index b578f93..e40bda2 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -459,7 +459,9 @@ 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; } @@ -467,7 +469,7 @@ class Parser extends EventEmitter { continue; } - const next = tokens[i + 2]; + const next = tokens[nextIndex]; const event = { name: null, parent: null, @@ -524,12 +526,22 @@ 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; @@ -537,7 +549,7 @@ class Parser extends EventEmitter { } 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]; diff --git a/lib/utils.js b/lib/utils.js index 19f6f91..88af7ce 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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); @@ -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) => { @@ -153,6 +154,173 @@ const values = (entry) => { return values; }; +/** + * @param {{ type: string; value: string }} token the Node token that needs to be tested + * @param {string} which a punctuator value to compare the token's value against + * @returns true if token is a punctuator with the correct value (if provided) + */ +const isPunctuatorToken = (token, which = undefined) => { + if (!token) { + return false; + } + + const isPunctuator = token.type === 'Punctuator'; + const isSpecific = which === undefined || token.value === which; + + return isPunctuator && isSpecific; +}; + +/** + * The array of tokens provided must start at the first identifier of the + * chain, but it can go beyond the last identifier. Only the chained + * identifiers will be parsed. + * + * See {@link buildPropertyAccessorChainFromAst} examples for + * expected returned 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 = isPunctuatorToken(tokens[punctIndex], '.'); + let chained = tokens[punctIndex + 1]; + + while (isChained && chained && chained.type === 'Identifier') { + chain.push(chained.value); + punctIndex += 2; + isChained = isPunctuatorToken(tokens[punctIndex], '.'); + chained = tokens[punctIndex + 1]; + } + + return chain; +}; + +/** + * Builds an array of property names from a 'MemberExpression' node. + * - Supports nested 'MemberExpression' and 'Identifier' nodes + * - Does not support bracket notation (computed === true). + * + * If the ast contains unsupported nodes, an empty array is returned. + * + * @example + * dispatch(PLAIN.NESTED.INNER); + * // Parsing the 'MemberExpression' node + * // corresponding to 'PLAIN.NESTED.INNER' + * // would return: + * ['PLAIN', 'NESTED', 'INNER'] + * + * @example + * dispatch(PLAIN['NESTED'].INNER); + * // Parsing the 'MemberExpression' node + * // corresponding to 'PLAIN['NESTED'].INNER' + * // would return: + * [] + * + * @param {{ type: string; object: any, property: any, computed: boolean }} node + * @returns an array of property names built from the ast + */ +const buildPropertyAccessorChainFromAst = (node) => { + if (node.type === 'Identifier') { + return [node.name]; + } + + if (node.type !== 'MemberExpression') { + return []; + } + + 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) ? [] : 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; +}; + +/** + * Supports a limited range of property types: + * - 'ObjectExpression' (nested) + * - 'Literal' (string, int, boolean, etc) + * + * If the `chain` visits an unsupported node type or tries to access a + * non-existing node, a default value is returned instead. + * + * @throws TypeError when argument `chain` is not an array of strings + * @param {Record} record identifier keys mapped to ast node values + * @param {string[]} chain an array of string used to access a value in `record` + * @returns the value found in `record` for the provided accessor `chain` + */ +const getValueForPropertyAccessorChain = (record, chain) => { + if (!chain.every(s => typeof s === 'string')) { + throw new TypeError('Unsupported PropertyAccessorChain:' + + `Expected 'chain' to be an array of strings but it was ${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) => { @@ -299,6 +467,7 @@ 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; @@ -306,6 +475,10 @@ 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; diff --git a/lib/v3/parser.js b/lib/v3/parser.js index ddc3615..962aa37 100644 --- a/lib/v3/parser.js +++ b/lib/v3/parser.js @@ -56,6 +56,7 @@ class Parser extends EventEmitter { // Internal properties this.componentName = null; this.eventsEmitted = {}; + this.identifiers = {}; this.imports = {}; this.dispatcherConstructorNames = []; this.dispatcherNames = []; @@ -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; @@ -608,16 +615,27 @@ 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); + + // This function can throw if chain is not valid + 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, @@ -768,6 +786,7 @@ class Parser extends EventEmitter { this.eventsEmitted[baseEvent.name] = baseEvent; this.parseKeywords(comment.keywords, baseEvent); + this.emit('event', baseEvent); } diff --git a/test/svelte2/integration/events/event.method.fire.identifier.svelte b/test/svelte2/integration/events/event.method.fire.identifier.svelte index 98cd529..d955cc2 100644 --- a/test/svelte2/integration/events/event.method.fire.identifier.svelte +++ b/test/svelte2/integration/events/event.method.fire.identifier.svelte @@ -7,9 +7,13 @@ \ No newline at end of file diff --git a/test/svelte3/integration/events/events.spec.js b/test/svelte3/integration/events/events.spec.js index 6eb194b..f144290 100644 --- a/test/svelte3/integration/events/events.spec.js +++ b/test/svelte3/integration/events/events.spec.js @@ -323,4 +323,27 @@ describe('SvelteDoc v3 - Events', () => { done(e); }); }); + + it('Dispatch event from code should be found when using an identifier', (done) => { + parser.parse({ + version: 3, + filename: path.resolve(__dirname, 'event.dispatcher.identifier.svelte'), + features: ['events'], + ignoredVisibilities: [] + }).then((doc) => { + 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); + + const event = doc.events[0]; + + expect(event, 'Event should be a valid entity').to.exist; + expect(event.name).to.equal('notify'); + expect(event.visibility).to.equal('public'); + + done(); + }).catch(e => { + done(e); + }); + }); }); diff --git a/test/unit/helpers/utils.spec.js b/test/unit/helpers/utils.spec.js index a6fd618..2caa11b 100644 --- a/test/unit/helpers/utils.spec.js +++ b/test/unit/helpers/utils.spec.js @@ -1,34 +1,184 @@ const utils = require('../../../lib/utils'); + +const espree = require('espree'); const { expect } = require('chai'); describe('"utils.js" module', () => { describe('"buildCamelCase" method', () => { - it('when input is already camel cased then should return same value', done => { + it('when input is already camel cased then should return same value', () => { const result = utils.buildCamelCase('CamelCasedTestMethodName12'); - expect(result).be.equal('CamelCasedTestMethodName12'); - done(); + expect(result).to.equal('CamelCasedTestMethodName12'); }); - it('when spaces used in name then should remove them and make next char uppercased', done => { + it('when spaces used in name then should remove them and make next char uppercased', () => { const result = utils.buildCamelCase('Spaces In the name'); - expect(result).be.equal('SpacesInTheName'); - done(); + expect(result).to.equal('SpacesInTheName'); }); - it('when first letter is lowercased then should be changed to upper case', done => { + it('when first letter is lowercased then should be changed to upper case', () => { const result = utils.buildCamelCase('lowercasedFirstLetter'); - expect(result).be.equal('LowercasedFirstLetter'); - done(); + expect(result).to.equal('LowercasedFirstLetter'); }); - it('when illegal chars in name then should remove then and make next char uppercased', done => { + it('when illegal chars in name then should remove then and make next char uppercased', () => { const result = utils.buildCamelCase('Illegal-chars-In-the-name'); - expect(result).to.be.equal('IllegalCharsInTheName'); - done(); + expect(result).to.equal('IllegalCharsInTheName'); + }); + }); + + describe('buildPropertyAccessorChainFromTokens', () => { + it('should correctly parse a single identifier', () => { + const expectedChain = ['NOTIFY']; + const script = ` + callee(${expectedChain.join('.')}); + `; + const tokens = espree.tokenize(script); + const identifierTokens = tokens.slice(2); + + const chain = utils.buildPropertyAccessorChainFromTokens(identifierTokens); + + expect(chain).to.deep.equal(expectedChain); + }); + + it('should correctly parse chained identifiers', () => { + const expectedChain = ['EVENT', 'SIGNAL', 'NOTIFY']; + const script = ` + callee(${expectedChain.join('.')}); + `; + const tokens = espree.tokenize(script); + const identifierTokens = tokens.slice(2); + + const chain = utils.buildPropertyAccessorChainFromTokens(identifierTokens); + + expect(chain).to.deep.equal(expectedChain); + }); + }); + + describe('buildPropertyAccessorChainFromAst', () => { + describe('should build an array when', () => { + it('AST is a single identifier', () => { + const expectedChain = ['NOTIFY']; + const script = ` + callee(${expectedChain.join('.')}); + `; + const ast = espree.parse(script); + const node = ast.body[0].expression.arguments[0]; + const chain = utils.buildPropertyAccessorChainFromAst(node); + + expect(chain).to.deep.equal(expectedChain); + }); + + it('AST has a nested "MemberExpression" node', () => { + const expectedChain = ['EVENT', 'SIGNAL', 'NOTIFY']; + const script = ` + callee(${expectedChain.join('.')}); + `; + const ast = espree.parse(script); + const node = ast.body[0].expression.arguments[0]; + const chain = utils.buildPropertyAccessorChainFromAst(node); + + expect(chain).to.deep.equal(expectedChain); + }); + }); + }); + + describe('buildObjectFromObjectExpression', () => { + it('should build an object from an AST containing a nested "ObjectExpression" node', () => { + const expectedObject = { + SIGNAL: { + NOTIFY: 'notify' + } + }; + const script = ` + var EVENT = { + SIGNAL: { + NOTIFY: 'notify' + } + }`; + const ast = espree.parse(script); + const node = ast.body[0].declarations[0].init; + const object = utils.buildObjectFromObjectExpression(node); + + expect(object).to.deep.equal(expectedObject); + }); + + it('should ignore unsupported node types', () => { + const expectedObject = { + SIGNAL: { + NOTIFY: 'notify' + }, + LITERAL: true, + }; + const script = ` + var EVENT = { + SIGNAL: { + NOTIFY: 'notify' + }, + OTHER: ['notify'], + LITERAL: true, + }`; + const ast = espree.parse(script); + const node = ast.body[0].declarations[0].init; + const object = utils.buildObjectFromObjectExpression(node); + + expect(object).to.deep.equal(expectedObject); + }); + }); + + describe('getValueForPropertyAccessorChain', () => { + it('should return the default value when value is unreachable', () => { + const script = ` + var EVENT = { + SIGNAL: {} + }`; + const ast = espree.parse(script); + const node = ast.body[0].declarations[0].init; + const container = { + EVENT: node + }; + const chain = ['EVENT', 'SIGNAL', 'NOTIFY']; + const value = utils.getValueForPropertyAccessorChain(container, chain); + + expect(value).to.equal(utils.UNHANDLED_EVENT_NAME); + }); + + it('should return the default value when visiting an unsupported node', () => { + const script = ` + var EVENT = { + SIGNAL: ['notify'] + }`; + const ast = espree.parse(script); + const node = ast.body[0].declarations[0].init; + const container = { + EVENT: node + }; + const chain = ['EVENT', 'SIGNAL', '0']; + const value = utils.getValueForPropertyAccessorChain(container, chain); + + expect(value).to.equal(utils.UNHANDLED_EVENT_NAME); + }); + + it('should return the correct value when searching an object', () => { + const expectedValue = 'notify'; + const script = ` + var EVENT = { + SIGNAL: { + NOTIFY: 'notify' + } + }`; + const ast = espree.parse(script); + const node = ast.body[0].declarations[0].init; + const container = { + EVENT: node + }; + const chain = ['EVENT', 'SIGNAL', 'NOTIFY']; + const value = utils.getValueForPropertyAccessorChain(container, chain); + + expect(value).to.equal(expectedValue); }); }); });