UNPKG

neo-jsdoc-x

Version:

Parser for outputting a customized Javascript object from documented code via JSDoc's explain (-X) command.

421 lines (369 loc) 15 kB
/* eslint-disable complexity */ 'use strict'; // core modules const path = require('path'); // dep modules const fs = require('fs-extra'); const _ = require('lodash'); const Promise = require('bluebird'); const glob = Promise.promisify(require('glob')); // own modules const helper = require('./lib/helper'); const utils = require('./lib/utils'); const sorter = require('./lib/sorter'); const ERR_SOURCE = 'Cannot process missing or invalid input files, or source code.'; /** * Parser for outputting a Javascript object from documented code via JSDoc's * explain (-X) command. * @namespace */ const jsdocx = {}; // if the dependent project has jsdoc dependency, ours will not be // installed. and since we don't use the jsdoc module via require but a // specific file (jsdoc.js), we cannot be sure of its location. this method // will try to find it if exists. const jsdocjs = 'neo-jsdoc/jsdoc.js'; try { const local = path.join('..', 'node_modules', jsdocjs); if (fs.pathExistsSync(local)) { jsdocx.path = local; } else { // Use the internal require() machinery to look up the location // of a module, but rather than loading the module, just return // the resolved filename. jsdocx.path = require.resolve(jsdocjs); // jsdoc module does not expose a main file. so instead of // require.resolve('jsdoc') - which will fail; we'll look for // 'jsdoc/jsdoc.js'. // for example, if the parent project has it, we'll get: // /path/to/parent-project/node_modules/jsdoc/jsdoc.js } } catch (e) { throw new Error('Could not find jsdoc module.', e); } // --------------------------- // HELPERS // --------------------------- function identity(o) { return o; } // http://usejsdoc.org/about-commandline.html // http://usejsdoc.org/about-configuring-jsdoc.html function buildArgs(options) { // access, private, readme, template, etc.. These CLI options do not // have affect when run with --explain (-X) flag. They are for // jsdoc-genrated docs. access is handled within the filter method. const opts = _.defaults(options, { // files: ... // non-jsdoc option encoding: 'utf8', package: null, // path recurse: false, pedantic: false, query: null // debug: false // verbose: false }); let args = ['-X']; args = args.concat(helper.ensureArray(opts.files)); args.push('-e', opts.encoding); if (_.isString(opts.package)) { args.push('-P', opts.package); } if (opts.recurse) args.push('-r'); if (opts.pedantic) args.push('--pedantic'); if (opts.query) args.push('-q', opts.query); // if (opts.debug) args.push('--debug'); // if (opts.verbose) args.push('--verbose'); return args; } // Builds the configuration object for JSDoc. JSDoc takes a filepath for // configuration (-c command). So we need to create a temp JSON file. This // function creates the conf object to be written to that temp file before // passing to jsdoc as a command line argument. function buildConf(options) { const opts = options || {}; const config = _.isPlainObject(opts) ? opts.config : {}; // updating default JSDoc configuration. // see http://usejsdoc.org/about-configuring-jsdoc.html return _.defaultsDeep(config, { tags: { allowUnknownTags: _.isBoolean(opts.allowUnknownTags) ? opts.allowUnknownTags : true, dictionaries: _.isArray(opts.dictionaries) ? opts.dictionaries : ['jsdoc', 'closure'] }, source: { includePattern: _.isString(opts.includePattern) ? opts.includePattern : '.+\\.js(doc|x)?$', excludePattern: _.isString(opts.excludePattern) ? opts.excludePattern : '(^|\\/|\\\\)_' }, templates: { cleverLinks: false, monospaceLinks: false }, plugins: _.isArray(opts.plugins) ? opts.plugins : [] }); } function relativePath(symbol, rPath) { if (!symbol || !rPath) return; var p = symbol.meta && helper.getStr(symbol.meta.path); if (p) { symbol.meta.path = helper.normalizePath(path.relative(p, rPath)); } } function normalizeAccess(access) { // ['public', 'protected', 'private', 'package'] if (access === 'all') return access; // 'all' if (!_.isString(access) && !_.isArray(access)) { return ['public', 'protected']; } return helper.ensureArray(access); } // sorts documentation symbols and properties of each symbol, if any. function sortDocs(docs, sortType) { if (!sortType) return; const fnSorter = sorter.getSymbolsComparer(sortType, '$longname'); const fnPropSorter = sorter.getSymbolsComparer(sortType, 'name'); docs.sort(fnSorter); docs.forEach(symbol => { if (symbol && Array.isArray(symbol.properties)) { symbol.properties.sort(fnPropSorter); } }); } function hierarchy(docs, sortType) { let parent; const fnSorter = sorter.getSymbolsComparer(sortType, '$longname'); // properties should be sorted alphabetically since they don't have kind or // scope. (they only have types.) const fnPropSorter = sorter.getSymbolsComparer('alphabetic', 'name'); _.eachRight(docs, (symbol, index) => { // Move constructor (method definition) to class declaration symbol if (utils.isConstructor(symbol)) { parent = _.find(docs, o => { return o.$longname === symbol.$longname && utils.isClass(o); }); if (parent) { parent.$constructor = symbol; docs.splice(index, 1); } // otherwise, move symbols with memberof property to corresponding parent member. } else if (symbol.memberof && symbol.longname !== 'module.exports') { // first check and sort if it has properties if (fnPropSorter && Array.isArray(symbol.properties)) { symbol.properties.sort(fnPropSorter); } parent = _.find(docs, sym => { return utils._cleanName(sym.longname) === utils._cleanName(symbol.memberof); }); // parent cannot be constructor if (parent && !utils.isConstructor(parent)) { parent.$members = parent.$members || []; parent.$members.push(symbol); if (fnSorter) { parent.$members.sort(fnSorter); } else { // reverse bec. we used eachRight parent.$members.reverse(); } docs.splice(index, 1); } } }); if (fnSorter) docs.sort(fnSorter); return docs; } function promiseGlobFiles(globs) { globs = helper.ensureArray(globs); return Promise.reduce(globs, (memo, pattern) => { return glob(pattern).then(paths => memo.concat(paths)); }, []); } function isSymbolComparable(symbol) { return typeof symbol.name === 'string' && typeof symbol.longname === 'string' && _.isPlainObject(symbol.meta) && _.isPlainObject(symbol.meta.code) && typeof symbol.meta.code.name === 'string'; } // jsdoc (core) version ^3.6.3 produces some duplicate symbols especially when // some ES2015 exporting is used within the code. We'll find these duplicates // and remove them in .filter() method. We'll only mark the symbol with longer // `code.name` or `longname` as duplicate. (e.g. "exports.Code" vs "Code") function isDuplicateSymbol(docs, symbol) { if (!isSymbolComparable(symbol)) return false; const found = _.find(docs, s => { if (!isSymbolComparable(s)) return false; const meta = s.meta; const symMeta = symbol.meta; const isDup = s.name === symbol.name && meta.path === symMeta.path && meta.filename === symMeta.filename && meta.lineno === symMeta.lineno && meta.code.type === symMeta.code.type; if (!isDup) return false; if (meta.code.name.length < symMeta.code.name.length) return true; if (meta.code.name.length === symMeta.code.name.length) { return s.longname.length < symbol.longname.length; } return false; }); return Boolean(found); } // --------------------------- // PUBLIC METHODS // --------------------------- /** * Filters the parsed documentation output array. * * @param {Array} docs - Documentation output array. * @param {Object|Function} [options] - Filter options. * @param {Function|String} [predicate] - The function invoked per iteration. * Returning a falsy value will remove the symbol from the output. Returning * true will keep the original symbol. To keep the symbol and alter its * contents, simply return an altered symbol object. If a RegExp string is * passed, it will be executed on symbol's long name. * * @returns {Array} - Filtered documentation array. */ jsdocx.filter = (docs, options, predicate) => { if (!options && !predicate) return docs; if (!predicate && _.isFunction(options)) { predicate = options; options = {}; } if (_.isString(predicate)) { const re = new RegExp(String(predicate)); predicate = symbol => re.test(symbol.longname); } else if (!_.isFunction(predicate)) { predicate = identity; } docs = helper.ensureArray(docs); options = _.defaults(options, { access: undefined, package: true, module: true, undocumented: true, undescribed: true, ignored: true, hierarchy: false, sort: false, // (true|"alphabetic")|"grouped"|false relativePath: null }); const access = normalizeAccess(options.access); let isCon, isDup, undoc, undesc, pkg, mdl, acc, ignored, o; if (!options.undocumented) { docs = docs.filter(symbol => { return !symbol.undocumented || utils.isConstructor(symbol); }); } docs = _.reduce(docs, (memo, symbol) => { // console.log(symbol.longname, symbol.kind, symbol.access, symbol.meta ? symbol.meta.code.type : ''); // JSDoc overwrites the `longname` and `name` of the symbol, if it // has an alias. See https://github.com/jsdoc3/jsdoc/issues/1217 and // documentation of jsdocx.utils.getFullName() symbol.$longname = utils.getLongName(symbol); // i.e.JSDoc generates a constructor's kind as `"class"`. This will // set it as `"constructor"`. See getKind() method. symbol.$kind = utils.getKind(symbol); undoc = options.undocumented || symbol.undocumented !== true; undesc = options.undescribed || utils.hasDescription(symbol); pkg = options.package || symbol.kind !== 'package'; mdl = options.module || symbol.longname !== 'module.exports'; ignored = options.ignored || symbol.ignore !== true; // access might not be explicitly set for the symbol. // in this case, we'll include the symbol. acc = access === 'all' || !symbol.access || access.indexOf(symbol.access) >= 0; // is symbol is duplicate? isDup = isDuplicateSymbol(docs, symbol); // if (isDup) console.info('>>> duplicate', symbol.longname, symbol.meta.code.name); // constructor symbol is undocumented=true even if it's documented isCon = acc && !isDup && utils.isConstructor(symbol); if (isCon || (undoc && undesc && pkg && mdl && acc && ignored && !isDup)) { relativePath(symbol, options.relativePath); o = predicate(symbol); if (_.isPlainObject(o)) { memo.push(o); // filtered symbol pushed } else if (o) { // boolean check memo.push(symbol); // original symbol pushed } } return memo; }, []); if (options.hierarchy) { docs = hierarchy(docs, options.sort); } else if (options.sort) { sortDocs(docs, options.sort); } return docs; }; /** * Executes the `jsdoc` command and parses the output into a Javascript * object/array; with the specified options. * * @param {Object|String|Array} options - Either an options object or one * or more source files to be processed. * @param {Function} [callback] - Callback function to be executed * in the following signature: function (err, array) { ... }` * Omit this callback to return a `Promise`. * * @returns {void|Promise} - Returns a `Promise` if `callback` is omitted. */ jsdocx.parse = (options, callback) => { if (!options) throw new Error(ERR_SOURCE); const opts = !_.isPlainObject(options) ? { files: options } : options; opts.files = opts.files || opts.file; const debug = opts.debug === undefined ? true : opts.debug; let args, conf; const hasFiles = _.isString(opts.files) || (_.isArray(opts.files) && opts.files.length > 0); const hasSource = _.isString(opts.source); if (!hasFiles && !hasSource) throw new Error(ERR_SOURCE); // let cleanupTemp; return Promise.resolve() .then(() => { if (hasFiles) { // expand glob patterns in opts.files array, if any return promiseGlobFiles(opts.files) .then(files => { opts.files = files; return opts; }); } if (hasSource) { return helper.createTempFile(opts.source) .then(file => { opts.files = [file.path]; // cleanupTemp = file.cleanup; return opts; }); } }) .then(opts => { args = buildArgs(opts); conf = buildConf(opts); // return helper.exec(jsdocx.path, args); return helper.execJSDoc(jsdocx.path, args, conf); }) .then(json => { var docs = helper.safeJsonParse(json); if (!docs) throw new Error('Output: ' + json); docs = jsdocx.filter(docs, opts, opts.predicate || opts.filter); if (options.output) { return helper.writeJSON(options.output, docs); } return docs; }) .catch(err => { if (debug) { // jsdoc err might not be very useful when some arguments are // invalid. so, we'll prepend the full command, in case of an // error and re-throw. const cmd = 'jsdoc ' + args.join(' '); err.message = err.message + ' \nExecuted JSDoc Command: ' + cmd + '\n' + 'with JSON configuration: ' + JSON.stringify(conf || {}); } throw err; }) .nodeify(callback); }; jsdocx.utils = utils; module.exports = jsdocx;