diff --git a/lib/utils.js b/lib/utils.js index c6e251a..e32abdf 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -69,7 +69,8 @@ const parseComment = (text, defaultVisibility = DEFAULT_VISIBILITY) => { /** * @param {Node} node * @param {SourceCode} sourceCode - * @param {*} options + * @param {{ defaultVisibility: string, useFirst: boolean, useLeading: boolean, useTrailing: boolean }} options + * @return {import('../typings').IScopedCommentItem} */ const getCommentFromSourceCode = (node, sourceCode, { defaultVisibility = DEFAULT_VISIBILITY, @@ -481,6 +482,9 @@ const inferTypeFromVariableDeclaration = (variable) => { } }; +/** + * @param {import('../typings').IScopedCommentItem} comment + */ const isTopLevelComment = (comment) => { return comment.keywords.some((keyword) => keyword.name === 'component'); }; diff --git a/lib/v3/events.js b/lib/v3/events.js new file mode 100644 index 0000000..bc1cca1 --- /dev/null +++ b/lib/v3/events.js @@ -0,0 +1,53 @@ +const CommonEvent = Object.freeze({ + /** + * Emit the @see {SvelteDataItem} object. + */ + DATA: 'data', + /** + * Emit the @see {SvelteEventItem} object. + */ + EVENT: 'event', + /** + * Emit the global comment @see {IScopedCommentItem} object. + */ + GLOBAL_COMMENT: 'global-comment', +}); + +const TemplateEvent = Object.freeze({ + ...CommonEvent, + NAME: 'name', + REF: 'ref', + SLOT: 'slot', + EXPRESSION: 'expression', +}); + +const ScriptEvent = Object.freeze({ + ...CommonEvent, + METHOD: 'method', + COMPUTED: 'computed', + IMPORTED_COMPONENT: 'imported-component', +}); + +const ParserEvent = Object.freeze({ + NAME: 'name', + DESCRIPTION: 'description', + KEYWORDS: 'keywords', + + DATA: 'data', + EVENT: 'event', + REF: 'ref', + SLOT: 'slot', + METHOD: 'method', + COMPUTED: 'computed', + IMPORTED_COMPONENT: 'component', + + FAILURE: 'failure', + END: 'end', +}); + +module.exports = { + CommonEvent, + TemplateEvent, + ScriptEvent, + ParserEvent +}; diff --git a/lib/v3/parser.js b/lib/v3/parser.js index 962aa37..b881dae 100644 --- a/lib/v3/parser.js +++ b/lib/v3/parser.js @@ -1,20 +1,21 @@ const EventEmitter = require('events'); const path = require('path'); -const espree = require('espree'); -const eslint = require('eslint'); -const HtmlParser = require('htmlparser2-svelte').Parser; - const utils = require('./../utils'); -const jsdoc = require('./../jsdoc'); const { normalize: normalizeOptions, validateFeatures, - getAstDefaultOptions } = require('../options'); -const hasOwnProperty = utils.hasOwnProperty; +const TemplateParser = require('./template'); +const { TemplateEvent, ScriptEvent, ParserEvent } = require('./events'); + +const ScriptParser = require('./script'); +/** + * @typedef {import('../../typings').Svelte3Feature} Svelte3Feature + * @type {Svelte3Feature[]} +*/ const SUPPORTED_FEATURES = [ 'name', 'data', @@ -27,21 +28,11 @@ const SUPPORTED_FEATURES = [ 'slots', 'refs' ]; -const SCOPE_DEFAULT = 'default'; -const SCOPE_STATIC = 'static'; -const SCOPE_MARKUP = 'markup'; - -const PARAM_ALIASES = { - arg: true, - argument: true, - param: true -}; -const RETURN_ALIASES = { - return: true, - returns: true, -}; class Parser extends EventEmitter { + /** + * @param {import('../options').SvelteParserOptions} options + */ constructor(options) { super(); @@ -49,17 +40,16 @@ class Parser extends EventEmitter { // External options this.filename = options.filename; - this.structure = options.structure; this.features = options.features; this.includeSourceLocations = options.includeSourceLocations; + /** @type {import("../helpers").FileStructure} */ + this.structure = options.structure; // Internal properties this.componentName = null; - this.eventsEmitted = {}; - this.identifiers = {}; - this.imports = {}; - this.dispatcherConstructorNames = []; - this.dispatcherNames = []; + + this.scriptParser = null; + this.templateParser = null; } walk() { @@ -67,7 +57,7 @@ class Parser extends EventEmitter { try { this.__walk(); } catch (error) { - this.emit('failure', error); + this.emit(ParserEvent.FAILURE, error); } }); @@ -79,17 +69,15 @@ class Parser extends EventEmitter { this.parseComponentName(); } - if (this.structure.scripts) { - this.structure.scripts.forEach(scriptBlock => { - this.parseScriptBlock(scriptBlock); - }); + if (this.structure.scripts && this.structure.scripts.length) { + this.parseScriptBlocks(this.structure.scripts); } if (this.structure.template) { - this.parseTemplate(); + this.parseTemplate(this.structure.template); } - this.emit('end'); + this.emit(ParserEvent.END); } static getDefaultOptions() { @@ -119,845 +107,128 @@ class Parser extends EventEmitter { } if (this.componentName) { - this.emit('name', utils.buildCamelCase(this.componentName)); + this.emit(ParserEvent.NAME, utils.buildCamelCase(this.componentName)); } } - updateType(item) { - const typeKeyword = item.keywords.find(kw => kw.name === 'type'); - - if (typeKeyword) { - const parsedType = jsdoc.parseTypeKeyword(typeKeyword.description); + /** + * @param {import('../../typings').IScopedCommentItem} comment + */ + emitGlobalComment(comment) { + if (comment && utils.isTopLevelComment(comment)) { + if (this.features.includes('description')) { + this.emit(ParserEvent.DESCRIPTION, comment.description); + } - if (parsedType) { - item.type = parsedType; + if (this.features.includes('keywords')) { + this.emit(ParserEvent.KEYWORDS, comment.keywords); } } } - emitDataItem(variable, parseContext, defaultVisibility, parentComment) { - const comment = parentComment || utils.getCommentFromSourceCode(variable.node, parseContext.sourceCode, { defaultVisibility }); - - const item = Object.assign({}, comment, { - name: variable.name, - kind: variable.kind, - static: parseContext.scopeType === SCOPE_STATIC, - readonly: variable.kind === 'const', - type: utils.inferTypeFromVariableDeclaration(variable), - importPath: variable.importPath, - originalName: variable.originalName, - localName: variable.localName - }); - - if (variable.declarator && variable.declarator.init) { - item.defaultValue = variable.declarator.init.value; - } - - if (this.includeSourceLocations && variable.location) { - item.locations = [{ - start: variable.location.start + parseContext.offset, - end: variable.location.end + parseContext.offset, - }]; - } + parseScriptBlocks(scripts) { + const scriptParser = this.buildScriptParser(); - this.updateType(item); + scriptParser.parse(scripts); - this.emit('data', item); + return scriptParser; } - emitMethodItem(method, parseContext, defaultVisibility, parentComment) { - const comment = parentComment || utils.getCommentFromSourceCode(method.node, parseContext.sourceCode, { defaultVisibility }); - - this.parseKeywords(comment.keywords, method); + parseTemplateJavascriptExpression(expression) { + const scriptParser = this.buildScriptParser(); - const item = Object.assign({}, comment, { - name: method.name, - params: method.params, - return: method.return, - static: parseContext.scopeType === SCOPE_STATIC - }); - - if (this.includeSourceLocations && method.location) { - item.locations = [{ - start: method.location.start + parseContext.offset, - end: method.location.end + parseContext.offset - }]; - } + scriptParser.parseScriptExpression(expression); - this.emit('method', item); + return scriptParser; } - emitComputedItem(computed, parseContext, defaultVisibility) { - const item = Object.assign({}, utils.getCommentFromSourceCode(computed.node, parseContext.sourceCode, { defaultVisibility }), { - name: computed.name, - static: parseContext.scopeType === SCOPE_STATIC, - type: jsdoc.DEFAULT_TYPE - }); - - if (this.includeSourceLocations && computed.location) { - item.locations = [{ - start: computed.location.start + parseContext.offset, - end: computed.location.end + parseContext.offset - }]; - } + parseTemplate(template) { + const templateParser = this.buildTemplateParser(); - this.updateType(item); + templateParser.parse(template); - this.emit('computed', item); + return templateParser; } - emitEventItem(event, parseContext) { - const item = Object.assign({}, utils.getCommentFromSourceCode(event.node, parseContext.sourceCode, { defaultVisibility: 'public' }), { - name: event.name - }); - - if (this.includeSourceLocations && event.location) { - item.locations = [{ - start: event.location.start + parseContext.offset, - end: event.location.end + parseContext.offset - }]; + buildScriptParser() { + if (this.scriptParser) { + return this.scriptParser; } - this.emit('event', item); - } - - emitRefItem(ref) { - const item = Object.assign({}, ref, { - visibility: 'private' + this.scriptParser = new ScriptParser({ + features: this.features, + includeSourceLocations: this.includeSourceLocations }); - this.emit('ref', item); - } + this.subscribeToScriptParser(this.scriptParser); - emitImportedComponentItem(component, parseContext) { - const item = Object.assign({}, utils.getCommentFromSourceCode(component.node, parseContext.sourceCode, { defaultVisibility: 'private' }), { - name: component.name, - importPath: component.path, - }); - - if (this.includeSourceLocations && component.location) { - item.locations = [{ - start: component.location.start + parseContext.offset, - end: component.location.end + parseContext.offset - }]; - } - - this.emit('component', item); + return this.scriptParser; } - emitGlobalComment(comment) { - if (comment && utils.isTopLevelComment(comment)) { - if (this.features.includes('description')) { - this.emit('description', comment.description); - } - - this.emit('keywords', comment.keywords); + buildTemplateParser() { + if (this.templateParser) { + return this.templateParser; } - } - - parseBodyRecursively(rootNode, parseContext, level) { - const nodes = rootNode.body - ? rootNode.body - : (rootNode.length > 0 ? rootNode : [rootNode]); - - nodes.forEach((node, index) => { - if (index === 0 && level === 0) { - const firstComment = utils.getCommentFromSourceCode(node, parseContext.sourceCode, { useTrailing: false, useFirst: true }); - - this.emitGlobalComment(firstComment); - } - - if (node.type === 'BlockStatement') { - this.parseBodyRecursively(node.body, parseContext, level); - - return; - } - - if (node.type === 'ExpressionStatement') { - const expressionNode = node.expression; - - if (expressionNode.type === 'CallExpression') { - const callee = expressionNode.callee; - - if (expressionNode.arguments) { - this.parseBodyRecursively(expressionNode.arguments, parseContext, level + 1); - } - - if (callee.type === 'Identifier' && this.dispatcherNames.indexOf(callee.name) >= 0) { - const eventItem = this.parseEventDeclaration(expressionNode); - - this.emitEventItem(eventItem, parseContext); - - return; - } - } - if (expressionNode.type === 'ArrowFunctionExpression') { - if (expressionNode.body) { - this.parseBodyRecursively(expressionNode.body, parseContext, level + 1); - - return; - } - } - } - - if (node.type === 'CallExpression') { - const callee = node.callee; - - if (node.arguments) { - this.parseBodyRecursively(node.arguments, parseContext, level + 1); - } - - if (callee.type === 'Identifier' && this.dispatcherNames.includes(callee.name)) { - const eventItem = this.parseEventDeclaration(node); - - this.emitEventItem(eventItem, parseContext); - - return; - } - } - - if (node.type === 'VariableDeclaration' && parseContext.scopeType !== SCOPE_MARKUP) { - const variables = this.parseVariableDeclaration(node); - - variables.forEach(variable => { - if (level === 0) { - this.emitDataItem(variable, parseContext, 'private'); - } - - 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; - - if (initNode.arguments) { - this.parseBodyRecursively(initNode.arguments, parseContext, level + 1); - } - - if (callee.type === 'Identifier' && this.dispatcherConstructorNames.includes(callee.name)) { - this.dispatcherNames.push(variable.name); - } - } else if (initNode.type === 'ArrowFunctionExpression') { - if (initNode.body) { - this.parseBodyRecursively(initNode.body, parseContext, level + 1); - } - } - } - }); - - return; - } - - if (node.type === 'FunctionDeclaration') { - this.emitMethodItem(this.parseFunctionDeclaration(node), parseContext, 'private'); - - if (node.body) { - this.parseBodyRecursively(node.body, parseContext, level + 1); - } - - return; - } - - if (node.type === 'ExportNamedDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) { - const declaration = node.declaration; - const specifiers = node.specifiers; - - if (declaration) { - const exportNodeComment = utils.getCommentFromSourceCode(node, parseContext.sourceCode, { defaultVisibility: 'public', useLeading: true, useTrailing: false }); - - if (declaration.type === 'VariableDeclaration') { - const variables = this.parseVariableDeclaration(declaration); - - variables.forEach(variable => { - this.emitDataItem(variable, parseContext, 'public', exportNodeComment); - - if (variable.declarator.init) { - const initNode = variable.declarator.init; - - if (initNode.type === 'CallExpression') { - const callee = initNode.callee; - - if (initNode.arguments) { - this.parseBodyRecursively(initNode.arguments, parseContext, level + 1); - } - - if (callee.type === 'Identifier' && this.dispatcherConstructorNames.includes(callee.name)) { - this.dispatcherNames.push(variable.name); - } - } else if (initNode.type === 'ArrowFunctionExpression') { - if (initNode.body) { - this.parseBodyRecursively(initNode.body, parseContext, level + 1); - } - } - } - }); - - return; - } - - if (declaration.type === 'FunctionDeclaration') { - const func = this.parseFunctionDeclaration(declaration); - - this.emitMethodItem(func, parseContext, 'public', exportNodeComment); - - if (declaration.body) { - this.parseBodyRecursively(declaration.body, parseContext, level + 1); - } - - return; - } - } - - if (specifiers) { - specifiers.forEach(specifier => { - if (specifier.type === 'ExportSpecifier') { - const exportedOrLocalName = specifier.exported - ? specifier.exported.name - : specifier.local.name; - - this.emitDataItem({ - node: specifier, - name: exportedOrLocalName, - localName: specifier.local.name, - kind: 'const', - location: { - start: specifier.exported ? specifier.exported.start : specifier.local.start, - end: specifier.exported ? specifier.exported.end : specifier.local.end - } - }, parseContext, 'public'); - } - }); - } - } - - if (node.type === 'LabeledStatement' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) { - const idNode = node.label; - - if (idNode && idNode.type === 'Identifier' && idNode.name === '$') { - if (node.body && node.body.type === 'ExpressionStatement') { - const expression = node.body.expression; - - if (expression && expression.type === 'AssignmentExpression') { - const leftNode = expression.left; - - if (leftNode.type === 'Identifier') { - this.emitComputedItem({ - name: leftNode.name, - location: { - start: leftNode.start, - end: leftNode.end - }, - node: node - }, parseContext, 'private'); - - return; - } - } - } - } - } - - if (node.type === 'ImportDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) { - const specifier = node.specifiers[0]; - const source = node.source; - - if (source && source.type === 'Literal') { - const sourceFileName = source.value; - - if (specifier && specifier.type === 'ImportDefaultSpecifier') { - const importEntry = { - identifier: specifier.local.name, - sourceFilename: sourceFileName - }; - - if (!hasOwnProperty(this.imports, importEntry.identifier)) { - this.imports[importEntry.identifier] = importEntry; - - if (importEntry.identifier) { - if (importEntry.identifier[0] === importEntry.identifier[0].toUpperCase()) { - const component = { - node: node, - name: importEntry.identifier, - path: importEntry.sourceFilename, - location: { - start: specifier.local.start, - end: specifier.local.end - } - }; - - this.emitImportedComponentItem(component, parseContext); - - return; - } else { - const imported = specifier.imported - ? specifier.imported.name - : undefined; - - this.emitDataItem({ - node, - name: importEntry.identifier, - originalName: imported || importEntry.identifier, - importPath: importEntry.sourceFilename, - kind: 'const', - location: { - start: specifier.local.start, - end: specifier.local.end - } - }, parseContext, 'private'); - } - } - } - } else if (node.specifiers.length > 0) { - node.specifiers.forEach((specifier) => { - if (specifier.type === 'ImportSpecifier') { - this.emitDataItem({ - node: specifier, - name: specifier.local.name, - originalName: specifier.imported - ? specifier.imported.name - : specifier.local.name, - importPath: sourceFileName, - kind: 'const', - location: { - start: specifier.local.start, - end: specifier.local.end - } - }, parseContext, 'private'); - } - }); - } - - // Import svelte API functions - if (sourceFileName === 'svelte') { - // Dispatcher constructors - node.specifiers - .filter(specifier => specifier.imported.name === 'createEventDispatcher') - .forEach(specifier => { - this.dispatcherConstructorNames.push(specifier.local.name); - }); - } - } - } - - if (node.body) { - this.parseBodyRecursively(node.body, parseContext, level + 1); - } + this.templateParser = new TemplateParser({ + features: this.features, + includeSourceLocations: this.includeSourceLocations }); - } - - parseScriptBlock(scriptBlock) { - const ast = espree.parse( - scriptBlock.content, - getAstDefaultOptions() - ); - - const sourceCode = new eslint.SourceCode({ - text: scriptBlock.content, - ast: ast - }); - - const isStaticScope = /\sscope=('module'|"module")/gi.test(scriptBlock.attributes); - const scriptParseContext = { - scopeType: isStaticScope - ? SCOPE_STATIC - : SCOPE_DEFAULT, - offset: scriptBlock.offset, - sourceCode: sourceCode - }; + this.subscribeToTemplateParser(this.templateParser); - this.parseBodyRecursively(ast, scriptParseContext, 0); + return this.templateParser; } - parseMarkupExpressionBlock(expression, offset) { - // Add name for anonymous functions to prevent parser error - const regex = /^{?\s*function\s+\(/i; - - expression = expression.replace(regex, function (m) { - // When quotes in attributes used curcly braces are provided here, so we should handle it separatly - if (m.startsWith('{')) { - return '{function a('; - } - - return 'function a('; + subscribeToScriptParser(scriptParser) { + scriptParser.on(ScriptEvent.COMPUTED, item => { + this.emit(ParserEvent.COMPUTED, item); }); - - const ast = espree.parse( - expression, - getAstDefaultOptions() - ); - - const sourceCode = new eslint.SourceCode({ - text: expression, - ast: ast + scriptParser.on(ScriptEvent.DATA, item => { + this.emit(ParserEvent.DATA, item); }); - - const scriptParseContext = { - scope: SCOPE_MARKUP, - offset: offset, - sourceCode: sourceCode - }; - - this.parseBodyRecursively(ast, scriptParseContext, 0); - } - - parseEventDeclaration(node) { - if (node.type !== 'CallExpression') { - throw new Error('Node should have a CallExpressionType, but is ' + node.type); - } - - const args = node.arguments; - - if (!args || !args.length) { - return null; - } - - const nameNode = args[0]; - - 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; - } - - return { - name: name, - node: node, - location: { - start: nameNode.start, - end: nameNode.end - } - }; - } - - parseVariableDeclaration(node) { - if (node.type !== 'VariableDeclaration') { - throw new Error('Node should have a VariableDeclarationType, but is ' + node.type); - } - - const result = []; - - node.declarations.forEach(declarator => { - const idNode = declarator.id; - - if (idNode.type === 'Identifier') { - result.push({ - name: idNode.name, - kind: node.kind, - node: node, - declarator: declarator, - location: { - start: idNode.start, - end: idNode.end - } - }); - } else if (idNode.type === 'ObjectPattern') { - idNode.properties.forEach(propertyNode => { - const propertyIdNode = propertyNode.key; - - if (propertyIdNode.type === 'Identifier') { - result.push({ - name: propertyIdNode.name, - kind: node.kind, - node: node, - declarator: declarator, - locations: { - start: propertyIdNode.start, - end: propertyIdNode.end - } - }); - } - }); - } + scriptParser.on(ScriptEvent.EVENT, item => { + this.emit(ParserEvent.EVENT, item); }); - - return result; - } - - parseFunctionDeclaration(node) { - if (node.type !== 'FunctionDeclaration') { - throw new Error('Node should have a FunctionDeclarationType, but is ' + node.type); - } - - const params = []; - - node.params.forEach((param) => { - if (param.type === 'Identifier') { - params.push({ - name: param.name, - }); - } + scriptParser.on(ScriptEvent.IMPORTED_COMPONENT, item => { + this.emit(ParserEvent.IMPORTED_COMPONENT, item); }); - - const output = { - node: node, - name: node.id.name, - location: { - start: node.id.start, - end: node.id.end - }, - params: params, - }; - - return output; - } - - parseObjectProperty(node) { - if (node.type !== 'Property') { - throw new Error('Node should have a Property, but is ' + node.type); - } - - if (node.key && node.key.type !== 'Identifier') { - throw new Error('Wrong property declaration'); - } - - return { - name: node.key.name - }; - } - - parseTemplate() { - let rootElementIndex = 0; - let lastComment = null; - let lastAttributeIndex = 0; - let lastAttributeLocations = {}; - let lastTagName = null; - - const parser = new HtmlParser({ - oncomment: (data) => { - lastComment = data.trim(); - }, - ontext: (text) => { - if (text.trim()) { - lastComment = null; - } - }, - onattribute: (name, value) => { - if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) { - lastAttributeLocations[name] = { - start: lastAttributeIndex, - end: parser._tokenizer._index - }; - - lastAttributeIndex = parser._tokenizer._index; - } - - if (this.features.includes('events')) { - if (lastTagName !== 'slot') { - // Expose events that propogated from child events - // Handle event syntax like `````` - if (name.length > 3 && name.indexOf('on:') === 0 && !value) { - const nameWithModificators = name.substr(3).split('|'); - - const baseEvent = { - name: nameWithModificators[0], - parent: lastTagName, - modificators: nameWithModificators.slice(1), - locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name) - ? [lastAttributeLocations[name]] - : null - }; - - if (lastComment) { - lastComment = `/** ${lastComment} */`; - } - - const comment = utils.parseComment(lastComment || ''); - - baseEvent.visibility = comment.visibility; - baseEvent.description = comment.description || ''; - baseEvent.keywords = comment.keywords; - - if (!hasOwnProperty(this.eventsEmitted, baseEvent.name)) { - this.eventsEmitted[baseEvent.name] = baseEvent; - - this.parseKeywords(comment.keywords, baseEvent); - - this.emit('event', baseEvent); - } - - lastComment = null; - } - - // Parse event handlers - if (name.length > 3 && name.indexOf('on:') === 0 && value) { - this.parseMarkupExpressionBlock(value); - } - } - } - }, - onopentagname: (tagName) => { - lastTagName = tagName; - lastAttributeIndex = parser._tokenizer._index; - lastAttributeLocations = {}; - }, - onopentag: (tagName, attrs) => { - const isNotStyleOrScript = !['style', 'script'].includes(tagName); - const isTopLevelElement = parser._stack.length === 1; - - if (isTopLevelElement && isNotStyleOrScript) { - if (lastComment && rootElementIndex === 0) { - this.emitGlobalComment(utils.parseComment(lastComment)); - } - - rootElementIndex += 1; - } - - if (tagName === 'slot') { - if (this.features.includes('slots')) { - const exposedParameters = Object.keys(attrs) - .filter(name => name.length > 0 && name !== 'name') - .map(name => ({ - name: name, - visibility: 'public' - })); - - const slot = { - name: attrs.name || 'default', - description: lastComment, - visibility: 'public', - parameters: exposedParameters - }; - - if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) { - slot.loc = { - start: parser.startIndex, - end: parser.endIndex - }; - } - - this.emit('slot', slot); - } - } else { - if (tagName === 'svelte:options' && attrs.tag) { - if (this.features.includes('name')) { - this.emit('name', attrs.tag); - } - } - - if (this.features.includes('data')) { - const bindProperties = Object.keys(attrs) - .filter(name => name.length > 5 && name.indexOf('bind:') === 0) - .filter(name => name !== 'bind:this') - .map(name => { - const sourcePropertyName = name.substr(5); - - let targetPropertyName = sourcePropertyName; - - const attributeValue = attrs[name]; - - if (attributeValue && attributeValue.length > 0) { - targetPropertyName = attributeValue; - } - - return { - sourcePropertyName: sourcePropertyName, - targetPropertyName: targetPropertyName, - parent: tagName, - locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name) - ? [lastAttributeLocations[name]] - : null - }; - }); - - bindProperties.forEach(bindProperty => { - const dataItem = { - name: bindProperty.targetPropertyName, - kind: undefined, - bind: [{ - source: bindProperty.parent, - property: bindProperty.sourcePropertyName - }], - locations: bindProperty.locations, - visibility: 'private', - static: false, - readonly: false - }; - - this.emit('data', dataItem); - }); - } - - if (this.features.includes('refs')) { - if (hasOwnProperty(attrs, 'bind:this') && attrs['bind:this']) { - const bindedVariableName = attrs['bind:this']; - - this.emitRefItem({ - name: bindedVariableName, - parent: tagName, - locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, 'bind:this') - ? [lastAttributeLocations['bind:this']] - : null - }); - } - } - } - } - }, { - lowerCaseTags: false, - lowerCaseAttributeNames: false, - curlyBracesInAttributes: true + scriptParser.on(ScriptEvent.METHOD, item => { + this.emit(ParserEvent.METHOD, item); }); - parser.write(this.structure.template); - parser.end(); + // Special cases where more parsing is required + scriptParser.on(ScriptEvent.GLOBAL_COMMENT, (comment) => { + this.emitGlobalComment(comment); + }); } - /** - * Mutates event. - * @param {any[]} keywords - * @param {{ params?: any[] }} event - */ - parseKeywords(keywords = [], event) { - if (!event.params) { - event.params = []; - } + subscribeToTemplateParser(templateParser) { + // Forward emit of basic template events + templateParser.on(TemplateEvent.DATA, dataItem => { + this.emit(ParserEvent.DATA, dataItem); + }); + templateParser.on(TemplateEvent.EVENT, event => { + this.emit(ParserEvent.EVENT, event); + }); + templateParser.on(TemplateEvent.NAME, name => { + this.emit(ParserEvent.NAME, name); + }); + templateParser.on(TemplateEvent.REF, refItem => { + this.emit(ParserEvent.REF, refItem); + }); + templateParser.on(TemplateEvent.SLOT, slot => { + this.emit(ParserEvent.SLOT, slot); + }); - keywords.forEach(({ name, description }) => { - if (name in PARAM_ALIASES) { - const parsedParam = jsdoc.parseParamKeyword(description); - const pIndex = event.params.findIndex( - p => p.name === parsedParam.name - ); - - /* - * Replace the param if there is already one present with - * the same name. This will happen with parsed - * FunctionDeclaration because params will already be - * present from parsing the AST node. - */ - if (pIndex >= 0) { - event.params[pIndex] = parsedParam; - } else { - /* - * This means @param does not match an actual param - * in the FunctionDeclaration. - * TODO: Implement option to choose behaviour (keep, ignore, warn, throw) - */ - event.params.push(parsedParam); - } - } else if (name in RETURN_ALIASES) { - event.return = jsdoc.parseReturnKeyword(description); - } + // Special cases where more parsing is required + templateParser.on(TemplateEvent.EXPRESSION, (expression) => { + this.parseTemplateJavascriptExpression(expression); }); - if (event.params.length === 0) { - delete event.params; - } + templateParser.on(TemplateEvent.GLOBAL_COMMENT, (comment) => { + this.emitGlobalComment(comment); + }); } } diff --git a/lib/v3/script.js b/lib/v3/script.js new file mode 100644 index 0000000..ae49ddc --- /dev/null +++ b/lib/v3/script.js @@ -0,0 +1,563 @@ +const espree = require('espree'); +const eslint = require('eslint'); +const EventEmitter = require('events'); + +const { getAstDefaultOptions } = require('../options'); +const { + hasOwnProperty, + getCommentFromSourceCode, + buildPropertyAccessorChainFromAst, + getValueForPropertyAccessorChain, + inferTypeFromVariableDeclaration, +} = require('../utils'); + +const { + assertNodeType, + getInnermostBody, + parseFunctionDeclaration, + parseVariableDeclaration, + parseAndMergeKeywords, + updateType +} = require('./v3-utils'); + +const jsdoc = require('./../jsdoc'); + +const { ScriptEvent } = require('./events'); + +/** + * @typedef {import('./v3-utils').AstNode} AstNode + */ + +const RE_ANONYMOUS_FUNCTION = /^{?\s*function\s+\(/i; +const RE_STATIC_SCOPE = /\sscope=('module'|"module")/gi; + +/** @typedef {'default' | 'static' | 'markup'} ScopeType */ + +const SCOPE_DEFAULT = 'default'; +const SCOPE_STATIC = 'static'; +const SCOPE_MARKUP = 'markup'; + +/** + * @typedef ScriptParserOptions + * @property {Svelte3FeatureKeys[]} features + * @property {boolean} includeSourceLocations + */ + +class ScriptParser extends EventEmitter { + /** + * @param {ScriptParserOptions} options + */ + constructor(options) { + super(); + + this.includeSourceLocations = options.includeSourceLocations; + this.features = options.features; + + // Internal properties + this.identifiers = Object.create(null); // Empty Map + this.imports = Object.create(null); // Empty Map + this.dispatcherConstructorNames = []; + this.dispatcherNames = []; + } + + /** + * @sideEffect mutates `item.locations`. + * + * Attaches the node's location to the item if requested by the user. + * + * @param {import('../../typings').ISvelteItem} item the item to attach locations to + * @param {{ location?: { start: number, end: number } }} node the parsed node containing a location + * @param {{ offset: number }} context parse context containing an offset for locations + */ + attachLocationsIfRequired(item, node, context) { + if (this.includeSourceLocations && node.location) { + item.locations = [{ + start: node.location.start + context.offset, + end: node.location.end + context.offset, + }]; + } + } + + emitDataItem(variable, parseContext, defaultVisibility, parentComment) { + const comment = parentComment || getCommentFromSourceCode(variable.node, parseContext.sourceCode, { defaultVisibility }); + + /** @type {import('../../typings').SvelteDataItem} */ + const item = { + ...comment, + name: variable.name, + kind: variable.kind, + static: parseContext.scopeType === SCOPE_STATIC, + readonly: variable.kind === 'const', + type: inferTypeFromVariableDeclaration(variable), + importPath: variable.importPath, + originalName: variable.originalName, + localName: variable.localName, + }; + + if (variable.declarator && variable.declarator.init) { + item.defaultValue = variable.declarator.init.value; + } + + this.attachLocationsIfRequired(item, variable, parseContext); + + updateType(item); + + this.emit(ScriptEvent.DATA, item); + } + + emitMethodItem(method, parseContext, defaultVisibility, parentComment) { + const comment = parentComment || getCommentFromSourceCode(method.node, parseContext.sourceCode, { defaultVisibility }); + + parseAndMergeKeywords(comment.keywords, method); + + /** @type {import('../../typings').SvelteMethodItem} */ + const item = { + ...comment, + name: method.name, + params: method.params, + return: method.return, + static: parseContext.scopeType === SCOPE_STATIC + }; + + this.attachLocationsIfRequired(item, method, parseContext); + + this.emit(ScriptEvent.METHOD, item); + } + + emitComputedItem(computed, parseContext, defaultVisibility) { + const comment = getCommentFromSourceCode(computed.node, parseContext.sourceCode, { defaultVisibility }); + + /** @type {import('../../typings').SvelteComputedItem} */ + const item = { + ...comment, + name: computed.name, + static: parseContext.scopeType === SCOPE_STATIC, + type: jsdoc.DEFAULT_TYPE + }; + + this.attachLocationsIfRequired(item, computed, parseContext); + + updateType(item); + + this.emit(ScriptEvent.COMPUTED, item); + } + + emitEventItem(event, parseContext) { + const comment = getCommentFromSourceCode(event.node, parseContext.sourceCode, { defaultVisibility: 'public' }); + + /** @type {import('../../typings').SvelteEventItem} */ + const item = { + ...comment, + name: event.name + }; + + this.attachLocationsIfRequired(item, event, parseContext); + + this.emit(ScriptEvent.EVENT, item); + } + + emitImportedComponentItem(component, parseContext) { + const comment = getCommentFromSourceCode(component.node, parseContext.sourceCode, { defaultVisibility: 'private' }); + + /** @type {import('../../typings').SvelteComponentItem} */ + const item = { + ...comment, + name: component.name, + importPath: component.path, + }; + + this.attachLocationsIfRequired(item, component, parseContext); + + this.emit(ScriptEvent.IMPORTED_COMPONENT, item); + } + + /** + * @typedef {import("../helpers").HtmlBlock} HtmlBlock + * @param {HtmlBlock[]} scripts + */ + parse(scripts) { + scripts.forEach(script => { + this.parseScript(script); + }); + } + + /** + * @param {{ content: string; offset: number; attributes?: string }} script + * @param {ScopeType} scope if passed, overrides the scopeType used during parsing + */ + parseScript(script, scope) { + const ast = espree.parse( + script.content, + getAstDefaultOptions() + ); + const sourceCode = new eslint.SourceCode({ + text: script.content, + ast: ast + }); + + const isStaticScope = RE_STATIC_SCOPE.test(script.attributes); + const scriptParseContext = { + scopeType: scope || (isStaticScope ? SCOPE_STATIC : SCOPE_DEFAULT), + offset: script.offset, + sourceCode: sourceCode + }; + + this.parseBodyRecursively(ast, scriptParseContext, 0); + } + + /** + * Call this to parse javascript expressions found in the template. The + * content of the parsed scripts, such as dispatchers and identifiers, are + * available so they will be recognized when used in template javascript + * expressions. + * + * @param {string} expression javascript expression found in the template + */ + parseScriptExpression(expression, offset = 0) { + // Add name for anonymous functions to prevent parser error + expression = expression.replace(RE_ANONYMOUS_FUNCTION, function (m) { + // Preserve the curly brace if it appears in the quotes + return m.startsWith('{') ? '{function a(' : 'function a('; + }); + + const expressionWrapper = { + content: expression, + offset: offset, + }; + + this.parseScript(expressionWrapper, SCOPE_MARKUP); + } + + parseVariableDeclarations(declaration, context, level, visibility, comment = undefined) { + if (context.scopeType === SCOPE_MARKUP) { + return; + } + + const variables = parseVariableDeclaration(declaration); + + variables.forEach(variable => { + if (level === 0) { + this.emitDataItem(variable, context, visibility, comment); + } + + if (!variable.declarator.init) { + return; + } + + const id = variable.declarator.id; + const init = variable.declarator.init; + + // Store top level variables in 'identifiers' + if (level === 0 && id.type === 'Identifier') { + this.identifiers[id.name] = init; + } + + if (init.type === 'CallExpression') { + const callee = init.callee; + + if (init.arguments) { + this.parseBodyRecursively(init.arguments, context, level + 1); + } + + if (callee.type === 'Identifier' && this.dispatcherConstructorNames.includes(callee.name)) { + this.dispatcherNames.push(variable.name); + } + } else if (init.type === 'ArrowFunctionExpression') { + this.parseBodyRecursively(init, context, level + 1); + } + } + ); + } + + parseEventDeclaration(node) { + assertNodeType(node, 'CallExpression'); + + const args = node.arguments; + + if (!args || !args.length) { + return null; + } + + const nameNode = args[0]; + + let name; + + try { + const chain = buildPropertyAccessorChainFromAst(nameNode); + + // This function can throw if chain is not valid + name = getValueForPropertyAccessorChain(this.identifiers, chain); + } catch (error) { + name = nameNode.type === 'Literal' + ? nameNode.value + : undefined; + } + + return { + name: name, + node: node, + location: { + start: nameNode.start, + end: nameNode.end + } + }; + } + + /** + * + * @param {{ body: AstNode | AstNode[] } | AstNode[]} rootNode + * @param {*} parseContext + * @param {*} level + */ + parseBodyRecursively(rootNode, parseContext, level) { + if (!rootNode) { + throw TypeError('parseBodyRecursively was called without a node'); + } + + const body = getInnermostBody(rootNode); + const nodes = Array.isArray(body) ? body : [body]; + + if (nodes[0] && level === 0) { + this.emit(ScriptEvent.GLOBAL_COMMENT, getCommentFromSourceCode( + nodes[0], + parseContext.sourceCode, + { useTrailing: false, useFirst: true } + )); + } + + nodes.forEach((node) => { + if (node.type === 'BlockStatement') { + this.parseBodyRecursively(node, parseContext, level); + + return; + } + + if (node.type === 'ExpressionStatement') { + const expression = node.expression; + + if (expression.type === 'CallExpression') { + this.parseBodyRecursively(expression, parseContext, level); + } else if (expression.type === 'ArrowFunctionExpression') { + this.parseBodyRecursively(expression, parseContext, level + 1); + } + + return; + } + + if (node.type === 'CallExpression') { + const callee = node.callee; + + if (node.arguments) { + this.parseBodyRecursively(node.arguments, parseContext, level + 1); + } + + if (callee.type === 'Identifier' && this.dispatcherNames.includes(callee.name)) { + const eventItem = this.parseEventDeclaration(node); + + this.emitEventItem(eventItem, parseContext); + } + + return; + } + + if (node.type === 'VariableDeclaration' && parseContext.scopeType !== SCOPE_MARKUP) { + this.parseVariableDeclarations(node, parseContext, level, 'private'); + + return; + } + + if (node.type === 'FunctionDeclaration') { + const func = parseFunctionDeclaration(node); + + this.emitMethodItem(func, parseContext, 'private'); + this.parseBodyRecursively(node, parseContext, level + 1); + + return; + } + + if (node.type === 'ExportNamedDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) { + const declaration = node.declaration; + const specifiers = node.specifiers; + + if (declaration) { + const exportNodeComment = getCommentFromSourceCode( + node, + parseContext.sourceCode, + { defaultVisibility: 'public', useLeading: true, useTrailing: false } + ); + + if (declaration.type === 'VariableDeclaration') { + this.parseVariableDeclarations( + declaration, + parseContext, + level, + 'public', + exportNodeComment + ); + } + + if (declaration.type === 'FunctionDeclaration') { + const func = parseFunctionDeclaration(declaration); + + this.emitMethodItem(func, parseContext, 'public', exportNodeComment); + this.parseBodyRecursively(declaration, parseContext, level + 1); + } + } + + if (specifiers) { + specifiers.forEach(specifier => { + if (specifier.type === 'ExportSpecifier') { + const subNode = specifier.exported ? 'exported' : 'local'; + const dataItem = { + node: specifier, + name: specifier[subNode].name, + localName: specifier.local.name, + kind: 'const', + location: { + start: specifier[subNode].start, + end: specifier[subNode].end + } + }; + + this.emitDataItem(dataItem, parseContext, 'public'); + } + }); + } + + return; + } + + /** + * Special case for reactive declarations (computed) + * In this case, the body is not parsed recursively. + */ + if (node.type === 'LabeledStatement' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) { + const label = node.label; + + if (label && label.type === 'Identifier' && label.name === '$') { + if (node.body && node.body.type === 'ExpressionStatement') { + const expression = node.body.expression; + + if (expression && expression.type === 'AssignmentExpression') { + const leftNode = expression.left; + + if (leftNode.type === 'Identifier') { + const computedItem = { + name: leftNode.name, + location: { + start: leftNode.start, + end: leftNode.end + }, + node: node + }; + + this.emitComputedItem(computedItem, parseContext, 'private'); + } + } + } + } + + return; + } + + if (node.type === 'ImportDeclaration' && level === 0 && parseContext.scopeType !== SCOPE_MARKUP) { + const specifier = node.specifiers[0]; + const source = node.source; + + if (source && source.type === 'Literal') { + const sourceFileName = source.value; + + if (specifier && specifier.type === 'ImportDefaultSpecifier') { + const importEntry = { + identifier: specifier.local.name, + sourceFilename: sourceFileName + }; + + if (!hasOwnProperty(this.imports, importEntry.identifier)) { + this.imports[importEntry.identifier] = importEntry; + + if (importEntry.identifier) { + if (importEntry.identifier[0] === importEntry.identifier[0].toUpperCase()) { + const component = { + node: node, + name: importEntry.identifier, + path: importEntry.sourceFilename, + location: { + start: specifier.local.start, + end: specifier.local.end + } + }; + + this.emitImportedComponentItem(component, parseContext); + + return; + } else { + const imported = specifier.imported + ? specifier.imported.name + : undefined; + + const dataItem = { + node, + name: importEntry.identifier, + originalName: imported || importEntry.identifier, + importPath: importEntry.sourceFilename, + kind: 'const', + location: { + start: specifier.local.start, + end: specifier.local.end + } + }; + + this.emitDataItem(dataItem, parseContext, 'private'); + } + } + } + } else if (node.specifiers.length > 0) { + node.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportSpecifier') { + const dataItem = { + node: specifier, + name: specifier.local.name, + originalName: specifier.imported + ? specifier.imported.name + : specifier.local.name, + importPath: sourceFileName, + kind: 'const', + location: { + start: specifier.local.start, + end: specifier.local.end + } + }; + + this.emitDataItem(dataItem, parseContext, 'private'); + } + }); + } + + // Import svelte API functions + if (sourceFileName === 'svelte') { + // Dispatcher constructors + node.specifiers + .filter(specifier => specifier.imported.name === 'createEventDispatcher') + .forEach(specifier => { + this.dispatcherConstructorNames.push(specifier.local.name); + }); + } + } + + return; + } + + /** + * There must be a check for body presence because otherwise + * the parser gets stuck in an infinite loop on some nodes + */ + if (node.body) { + this.parseBodyRecursively(node.body, parseContext, level + 1); + } + }); + } +} + +module.exports = ScriptParser; +module.exports.SCOPE_STATIC = SCOPE_STATIC; diff --git a/lib/v3/template.js b/lib/v3/template.js new file mode 100644 index 0000000..7f9236b --- /dev/null +++ b/lib/v3/template.js @@ -0,0 +1,242 @@ +const EventEmitter = require('events'); +const { Parser: HtmlParser } = require('htmlparser2-svelte'); + +const { parseAndMergeKeywords } = require('./v3-utils'); +const { parseComment, hasOwnProperty } = require('../utils'); +const { TemplateEvent } = require('./events'); + +/** + * @typedef {import('../../typings').Svelte3FeatureKeys} Svelte3FeatureKeys + * + * @typedef TemplateParserOptions + * @property {Svelte3FeatureKeys[]} features + * @property {boolean} includeSourceLocations + */ + +class TemplateParser extends EventEmitter { + /** + * @param {TemplateParserOptions} options + */ + constructor(options) { + super(); + + this.features = options.features; + this.includeSourceLocations = options.includeSourceLocations; + + // Internal properties + /** + * Map of events already emitted. Check if an event exists in this map + * before emitting it again. + */ + this.eventsEmitted = Object.create(null); // Empty Map + } + + /** + * Parse the template markup and produce events with parsed data. + * @param {string} template The template markup to parse + */ + parse(template) { + const options = { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + curlyBracesInAttributes: true + }; + + const parser = new HtmlParser(this.getTemplateHandler(), options); + + parser.write(template); + parser.end(); + } + + getTemplateHandler() { + let rootElementIndex = 0; + let lastComment = null; + let lastAttributeIndex = 0; + let lastAttributeLocations = {}; + let lastTagName = null; + let parser = null; + + return { + onparserinit: (parserInstance) => { + parser = parserInstance; + }, + oncomment: (data) => { + lastComment = data.trim(); + }, + ontext: (text) => { + if (text.trim()) { + lastComment = null; + } + }, + onattribute: (name, value) => { + if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) { + lastAttributeLocations[name] = { + start: lastAttributeIndex, + end: parser._tokenizer._index + }; + + lastAttributeIndex = parser._tokenizer._index; + } + + if (this.features.includes('events')) { + if (lastTagName !== 'slot') { + // Expose events that propagated from child events + // Handle event syntax like `````` + if (name.length > 3 && name.indexOf('on:') === 0 && !value) { + const nameWithModificators = name.substr(3).split('|'); + + const baseEvent = { + name: nameWithModificators[0], + parent: lastTagName, + modificators: nameWithModificators.slice(1), + locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name) + ? [lastAttributeLocations[name]] + : null + }; + + if (lastComment) { + lastComment = `/** ${lastComment} */`; + } + + const comment = parseComment(lastComment || ''); + + baseEvent.visibility = comment.visibility; + baseEvent.description = comment.description || ''; + baseEvent.keywords = comment.keywords; + + if (!hasOwnProperty(this.eventsEmitted, baseEvent.name)) { + this.eventsEmitted[baseEvent.name] = baseEvent; + + parseAndMergeKeywords(comment.keywords, baseEvent); + + this.emit(TemplateEvent.EVENT, baseEvent); + } + + lastComment = null; + } + + // Parse event handlers + if (name.length > 3 && name.indexOf('on:') === 0 && value) { + this.emit(TemplateEvent.EXPRESSION, value); + } + } + } + }, + onopentagname: (tagName) => { + lastTagName = tagName; + lastAttributeIndex = parser._tokenizer._index; + lastAttributeLocations = {}; + }, + onopentag: (tagName, attrs) => { + const isNotStyleOrScript = !['style', 'script'].includes(tagName); + const isTopLevelElement = parser._stack.length === 1; + + if (isTopLevelElement && isNotStyleOrScript) { + if (lastComment && rootElementIndex === 0) { + const parsedComment = parseComment(lastComment); + + this.emit(TemplateEvent.GLOBAL_COMMENT, parsedComment); + } + + rootElementIndex += 1; + } + + if (tagName === 'slot') { + if (this.features.includes('slots')) { + const exposedParameters = Object.keys(attrs) + .filter(name => name.length > 0 && name !== 'name') + .map(name => ({ + name: name, + visibility: 'public' + })); + + const slot = { + name: attrs.name || 'default', + description: lastComment, + visibility: 'public', + parameters: exposedParameters + }; + + if (this.includeSourceLocations && parser.startIndex >= 0 && parser.endIndex >= parser.startIndex) { + slot.loc = { + start: parser.startIndex, + end: parser.endIndex + }; + } + + this.emit(TemplateEvent.SLOT, slot); + } + } else { + if (tagName === 'svelte:options' && attrs.tag) { + if (this.features.includes('name')) { + this.emit(TemplateEvent.NAME, attrs.tag); + } + } + + if (this.features.includes('data')) { + const bindProperties = Object.keys(attrs) + .filter(name => name.length > 5 && name.indexOf('bind:') === 0) + .filter(name => name !== 'bind:this') + .map(name => { + const sourcePropertyName = name.substr(5); + + let targetPropertyName = sourcePropertyName; + + const attributeValue = attrs[name]; + + if (attributeValue && attributeValue.length > 0) { + targetPropertyName = attributeValue; + } + + return { + sourcePropertyName: sourcePropertyName, + targetPropertyName: targetPropertyName, + parent: tagName, + locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, name) + ? [lastAttributeLocations[name]] + : null + }; + }); + + bindProperties.forEach(bindProperty => { + const dataItem = { + name: bindProperty.targetPropertyName, + kind: undefined, + bind: [{ + source: bindProperty.parent, + property: bindProperty.sourcePropertyName + }], + locations: bindProperty.locations, + visibility: 'private', + static: false, + readonly: false + }; + + this.emit(TemplateEvent.DATA, dataItem); + }); + } + + if (this.features.includes('refs')) { + if (hasOwnProperty(attrs, 'bind:this') && attrs['bind:this']) { + const bindedVariableName = attrs['bind:this']; + + const refItem = { + visibility: 'private', + name: bindedVariableName, + parent: tagName, + locations: this.includeSourceLocations && hasOwnProperty(lastAttributeLocations, 'bind:this') + ? [lastAttributeLocations['bind:this']] + : null + }; + + this.emit(TemplateEvent.REF, refItem); + } + } + } + } + }; + } +} + +module.exports = TemplateParser; +module.exports.TemplateEvent = TemplateEvent; diff --git a/lib/v3/v3-utils.js b/lib/v3/v3-utils.js new file mode 100644 index 0000000..273ea86 --- /dev/null +++ b/lib/v3/v3-utils.js @@ -0,0 +1,206 @@ +const { + parseParamKeyword, + parseReturnKeyword, + parseTypeKeyword +} = require('../jsdoc'); + +class InvalidNodeTypeError extends TypeError { + constructor(expected, actual = '', ...args) { + super(expected, actual, ...args); + this.message = `Node should be of type '${expected}', but it was ${actual}.`; + } +} + +/** + * @typedef {{ type: string }} AstNode + */ + +/** + * @throws InvalidNodeTypeError if the node is not of the correct type + * @param {AstNode} node + * @param {string} type + */ +function assertNodeType(node, type) { + if (node.type !== type) { + throw new InvalidNodeTypeError(type, node.type); + } +} + +/** All `@param` JSDoc aliases. */ +const PARAM_ALIASES = { + arg: true, + argument: true, + param: true +}; + +/** All `@returns` JSDoc aliases. */ +const RETURN_ALIASES = { + return: true, + returns: true, +}; + +/** + * Returns the innermost body prop from node. + * returns the same node if it does not have a body. + * @param {AstNode & { body?: AstNode | AstNode[] }} node an AST node + */ +function getInnermostBody(node) { + while (node && node.body) { + node = node.body; + } + + return node; +} + +/** + * @param {AstNode} node a 'FunctionDeclaration' AST node + */ +function parseFunctionDeclaration(node) { + assertNodeType(node, 'FunctionDeclaration'); + + const params = []; + + node.params.forEach((param) => { + if (param.type === 'Identifier') { + params.push({ + name: param.name, + }); + } + }); + + const output = { + node: node, + name: node.id.name, + location: { + start: node.id.start, + end: node.id.end + }, + params: params, + }; + + return output; +} + +/** + * @param {AstNode} node a 'VariableDeclaration' AST node + */ +function parseVariableDeclaration(node) { + assertNodeType(node, 'VariableDeclaration'); + + const result = []; + + node.declarations.forEach(declarator => { + const idNode = declarator.id; + + if (idNode.type === 'Identifier') { + result.push({ + name: idNode.name, + kind: node.kind, + node: node, + declarator: declarator, + location: { + start: idNode.start, + end: idNode.end + } + }); + } else if (idNode.type === 'ObjectPattern') { + idNode.properties.forEach(propertyNode => { + const propertyIdNode = propertyNode.key; + + if (propertyIdNode.type === 'Identifier') { + result.push({ + name: propertyIdNode.name, + kind: node.kind, + node: node, + declarator: declarator, + locations: { + start: propertyIdNode.start, + end: propertyIdNode.end + } + }); + } + }); + } + }); + + return result; +} + +/** + * @sideEffect Mutates `item.type`. + * + * Updates the item.type` from `@type` in `item.keywords`, if any. + * + * @param {{ name: string; description: string}[]} item + */ +function updateType(item) { + const typeKeyword = item.keywords.find(kw => kw.name === 'type'); + + if (typeKeyword) { + const parsedType = parseTypeKeyword(typeKeyword.description); + + if (parsedType) { + item.type = parsedType; + } + } +} + +/** + * @sideEffect Mutates `event.params` and `event.return`. + * + * Parses the `keywords` argument and merges the result in `event`. + * + * If a param exists as a JSDoc `@param`, it will overwrite the param of the + * same name already in event, if any. If a `@param` in JSDoc does + * not match the name of an actual param of the function, it is appended at + * end of the `event` params. + * + * @param {{ name: string; description: string}[]} keywords + * @param {{ params?: any[]; return?: any }} event + */ +function parseAndMergeKeywords(keywords = [], event) { + if (!event.params) { + event.params = []; + } + + keywords.forEach(({ name, description }) => { + if (name in PARAM_ALIASES) { + const parsedParam = parseParamKeyword(description); + const pIndex = event.params.findIndex( + p => p.name === parsedParam.name + ); + + /* + * Replace the param if there is already one present with + * the same name. This will happen with parsed + * FunctionDeclaration because params will already be + * present from parsing the AST node. + */ + if (pIndex >= 0) { + event.params[pIndex] = parsedParam; + } else { + /* + * This means @param does not match an actual param + * in the FunctionDeclaration. For now, we keep it. + * TODO: Implement option to choose behaviour (keep, ignore, warn, throw) + */ + event.params.push(parsedParam); + } + } else if (name in RETURN_ALIASES) { + event.return = parseReturnKeyword(description); + } + }); + + if (event.params.length === 0) { + delete event.params; + } +} + +module.exports = { + assertNodeType, + getInnermostBody, + parseFunctionDeclaration, + parseVariableDeclaration, + parseAndMergeKeywords, + updateType, +}; diff --git a/test/svelte3/integration/events/event.dispatcher.identifier.svelte b/test/svelte3/integration/events/event.dispatcher.identifier.svelte index 23f2f8c..5d54c74 100644 --- a/test/svelte3/integration/events/event.dispatcher.identifier.svelte +++ b/test/svelte3/integration/events/event.dispatcher.identifier.svelte @@ -7,10 +7,18 @@ NOTIFY: 'notify' } }; - const SIMPLE_EVENT = 'plain-notify'; dispatch(EVENT.SIGNAL.NOTIFY); - dispatch(SIMPLE_EVENT); + + export const EXPORTED_EVENT = { + SIGNAL: { + NOTIFY: 'exported-notify' + } + }; + export const EXPORTED_SIMPLE_EVENT = 'exported-plain-notify'; + + dispatch(EXPORTED_EVENT.SIGNAL.NOTIFY); + dispatch(EXPORTED_SIMPLE_EVENT); \ 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 02469e3..af268c0 100644 --- a/test/svelte3/integration/events/events.spec.js +++ b/test/svelte3/integration/events/events.spec.js @@ -333,7 +333,7 @@ describe('SvelteDoc v3 - Events', () => { }).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(2); + expect(doc.events.length).to.equal(4); let event = doc.events[0]; @@ -347,6 +347,18 @@ describe('SvelteDoc v3 - Events', () => { expect(event.name).to.equal('plain-notify'); expect(event.visibility).to.equal('public'); + event = doc.events[2]; + + expect(event, 'Event should be a valid entity').to.exist; + expect(event.name).to.equal('exported-notify'); + expect(event.visibility).to.equal('public'); + + event = doc.events[3]; + + expect(event, 'Event should be a valid entity').to.exist; + expect(event.name).to.equal('exported-plain-notify'); + expect(event.visibility).to.equal('public'); + done(); }).catch(e => { done(e); diff --git a/typings.d.ts b/typings.d.ts index aa9f23b..5724200 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -70,18 +70,7 @@ export type JSVisibilityScope = 'public' | 'protected' | 'private'; export type JSVariableDeclarationKind = 'var' | 'let' | 'const'; -export interface ISvelteItem { - /** - * The name of the item. - */ - name: string; - - /** - * The list of source code locations for this item. - * Provided only if requested by specific option parameter. - */ - locations?: SourceLocation[]; - +export interface IScopedCommentItem { /** * The description of the item, provided from related comment. */ @@ -96,6 +85,19 @@ export interface ISvelteItem { keywords?: JSDocKeyword[]; } +export interface ISvelteItem extends IScopedCommentItem { + /** + * The name of the item. + */ + name: string; + + /** + * The list of source code locations for this item. + * Provided only if requested by specific option parameter. + */ + locations?: SourceLocation[]; +} + export interface SvelteDataBindMapping { /** * The parent component name or DOM element from which are was binded.