/* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const doctrine = require('doctrine'); const esprima = require('esprima'); const acorn = require('acorn'); /** * Processes a single Javascript file (.js extension) * * @param {string} file - the file to process * @param {Object} fileProcessor - the processor instance to use to generate code * @private * @class * @memberof module:composer-common */ class JavaScriptParser { /** * Create a JavaScriptParser. * * @param {string} fileContents - the text of the JS file to parse * @param {boolean} includePrivates - if true methods tagged as private are also returned */ constructor(fileContents, includePrivates) { let comments = [], tokens = []; let ast = acorn.parse(fileContents, { // collect ranges for each node ranges: true, // collect comments in Esprima's format onComment: comments, // collect token ranges onToken: tokens }); this.includes = []; this.classes = []; this.functions = []; for (let n = 0; n < ast.body.length; n++) { let statement = ast.body[n]; if (statement.type === 'VariableDeclaration') { let variableDeclarations = statement.declarations; for (let n = 0; n < variableDeclarations.length; n++) { let variableDeclaration = variableDeclarations[n]; if (variableDeclaration.init && variableDeclaration.init.type === 'CallExpression' && variableDeclaration.init.callee.name === 'require') { let requireName = variableDeclaration.init.arguments[0].value; // we only care about the code we require with a relative path if (requireName.startsWith('.')) { this.includes.push(variableDeclaration.init.arguments[0].value); } } } } else if (statement.type === 'FunctionDeclaration') { //console.log(JSON.stringify(statement)); let closestComment = findCommentBefore(statement.start, statement.end, comments); let returnType = ''; let visibility = '+'; let parameterTypes = []; let parameterNames = []; let decorators = []; let throws = ''; let example = ''; if(closestComment >= 0) { let comment = comments[closestComment].value; //console.log('Found comment: ' + comment ); returnType = getReturnType(comment); visibility = getVisibility(comment); parameterTypes = getMethodArguments(comment); throws = getThrows(comment); decorators = getDecorators(comment); example = getExample(comment); } if(visibility === '+' || includePrivates) { for(let n=0; n < statement.params.length; n++) { parameterNames.push(statement.params[n].name); } const func = { visibility: visibility, returnType: returnType, name: statement.id.name, parameterTypes: parameterTypes, parameterNames: parameterNames, throws: throws, decorators: decorators, functionText : getText(statement.start, statement.end, fileContents), example: example }; //console.log('Function: ' + JSON.stringify(func)); this.functions.push(func); } } else if (statement.type === 'ClassDeclaration') { let closestComment = findCommentBefore(statement.start, statement.end, comments); let privateClass = false; if(closestComment >= 0) { let comment = comments[closestComment].value; privateClass = getVisibility(comment) === '-'; } if(privateClass === false || includePrivates) { const clazz = { name: statement.id.name}; clazz.methods = []; for(let n=0; n < statement.body.body.length; n++) { let thing = statement.body.body[n]; if (thing.type === 'MethodDefinition') { let closestComment = findCommentBefore(thing.key.start, thing.key.end, comments); let returnType = ''; let visibility = '+'; let methodArgs = []; let throws = ''; let decorators = []; let example = ''; if(closestComment >= 0) { let comment = comments[closestComment].value; returnType = getReturnType(comment); visibility = getVisibility(comment); methodArgs = getMethodArguments(comment); decorators = getDecorators(comment); throws = getThrows(comment); example = getExample(comment); } if(visibility === '+' || includePrivates) { const method = { visibility: visibility, returnType: returnType, name: thing.key.name, methodArgs: methodArgs, decorators: decorators, throws: throws, example: example }; clazz.methods.push(method); } } } if (statement.superClass) { clazz.superClass = statement.superClass.name; } this.classes.push(clazz); } } } } /** * Return the includes that were extracted from the JS file. * * @return {Object[]} information about each include */ getIncludes() { return this.includes; } /** * Return the classes that were extracted from the JS file. * * @return {Object[]} information about each class */ getClasses() { return this.classes; } /** * Return the methods that were extracted from the JS file. * * @return {Object[]} information about each method */ getFunctions() { return this.functions; } } /** * Grab the text between a range§ * * @param {integer} rangeStart - the start of the range * @param {integer} rangeEnd - the end of the range * @param {string} source - the source text * @return {string} the text between start and end * @private */ function getText(rangeStart, rangeEnd, source) { return source.substring(rangeStart, rangeEnd); } /** * Find the comments that is above and closest to the start of the range. * * @param {integer} rangeStart - the start of the range * @param {integer} rangeEnd - the end of the range * @param {string[]} comments - the end of the range * @return {integer} the comment index or -1 if there are no comments * @private */ function findCommentBefore(rangeStart, rangeEnd, comments) { let foundIndex = -1; let distance = -1; for(let n=0; n < comments.length; n++) { let comment = comments[n]; let endComment = comment.end; if(rangeStart > endComment ) { if(distance === -1 || rangeStart - endComment < distance) { distance = rangeStart - endComment; foundIndex = n; } } } return foundIndex; } /** * Grabs all the @ prefixed decorators from a comment block. * @param {string} comment - the comment block * @return {string[]} the @ prefixed decorators within the comment block * @private */ function getDecorators(comment) { const re = /(?:^|\W)@(\w+)/g; let match; const matches = []; match = re.exec(comment); while (match) { matches.push(match[1]); match = re.exec(comment); } return matches; } /** * Extracts the visibilty from a comment block * @param {string} comment - the comment block * @return {string} the return visibility (either + for public, or - for private) * @private */ function getVisibility(comment) { const PRIVATE = 'private'; let parsedComment = doctrine.parse(comment, {unwrap: true, sloppy: true, tags: [PRIVATE]}); const tags = parsedComment.tags; if (tags.length > 0) { return '-'; } return '+'; } /** * Extracts the return type from a comment block. * @param {string} comment - the comment block * @return {string} the return type of the comment * @private */ function getReturnType(comment) { const RETURN = 'return'; const RETURNS = 'returns'; let result = 'void'; let parsedComment = doctrine.parse(comment, {unwrap: true, sloppy: true, tags: [RETURN, RETURNS]}); const tags = parsedComment.tags; if (tags.length > 1) { throw new Error('Malformed JSDoc comment. More than one returns: ' + comment ); } tags.forEach((tag) => { if (!tag.type.name && !tag.type) { throw new Error('Malformed JSDoc comment. ' + comment ); } if (tag.type.name) { result = tag.type.name; } else if (tag.type.applications){ result = tag.type.applications[0].name + '[]'; } else if (tag.type.expression) { result = tag.type.expression.name; } }); return result; } /** * Extracts the return type from a comment block. * @param {string} comment - the comment block * @return {string} the return type of the comment * @private */ function getThrows(comment) { const THROWS = 'throws'; const EXCEPTION = 'exception'; let result = ''; let parsedComment = doctrine.parse(comment, {unwrap: true, sloppy: true, tags: [THROWS, EXCEPTION]}); const tags = parsedComment.tags; if (tags.length > 1) { throw new Error('Malformed JSDoc comment. More than one throws/exception: ' + comment ); } tags.forEach((tag) => { if (!tag.type.type || !tag.type.name) { throw new Error('Malformed JSDoc comment. ' + comment ); } result = tag.type.name; }); return result; } /** * Extracts the method arguments from a comment block. * @param {string} comment - the comment block * @return {string} the the argument types * @private */ function getMethodArguments(comment) { const TAG = 'param'; let paramTypes = []; let parsedComment = doctrine.parse(comment, {unwrap: true, sloppy: true, tags: [TAG]}); const tags = parsedComment.tags; // param is mentined but not picked up by parser if (comment.indexOf('@'+TAG) !== -1 && tags.length === 0) { throw new Error('Malformed JSDoc comment: ' + comment ); } tags.forEach((tag) => { //If description starts with } if (tag.description.trim().indexOf('}') === 0 || !tag.type || !tag.name ) { throw new Error('Malformed JSDoc comment: ' + comment ); } else if(tag.type.name) { if (tag.type.name.indexOf(' ') !== -1) { throw new Error('Malformed JSDoc comment: ' + comment ); } } if (tag.type.name) { paramTypes.push(tag.type.name); } else if (tag.type.applications){ paramTypes.push(tag.type.applications[0].name + '[]'); } else if (tag.type.expression) { paramTypes.push(tag.type.expression.name); } }); return paramTypes; } /** * Extracts the example tag from a comment block. * @param {string} comment - the comment block * @return {string} the the argument types * @private */ function getExample(comment) { const EXAMPLE = 'example'; let result = ''; let parsedComment = doctrine.parse(comment, {unwrap: true, sloppy: true, tags: [EXAMPLE]}); const tags = parsedComment.tags; if (tags.length > 0) { result = tags[0].description; } try { // Pass as a function so that return statements are valid let program = 'function testSyntax() {' + result + '}'; esprima.parse(program); } catch (e) { throw Error('Malformed JSDoc Comment. Invalid @example tag: ' + comment); } return result; } module.exports = JavaScriptParser;