diff --git a/packages/jsdoc-doclet/lib/doclet.js b/packages/jsdoc-doclet/lib/doclet.js index f7fc84c3..1cc7eca6 100644 --- a/packages/jsdoc-doclet/lib/doclet.js +++ b/packages/jsdoc-doclet/lib/doclet.js @@ -35,7 +35,10 @@ const { } = jsdocName; const { isFunction } = astNode; +const ACCESS_LEVELS = ['package', 'private', 'protected', 'public']; const DEFAULT_SCOPE = SCOPE.NAMES.STATIC; +// TODO: `class` should be on this list, right? What are the implications of adding it? +const GLOBAL_KINDS = ['constant', 'function', 'member', 'typedef']; function fakeMeta(node) { return { @@ -370,6 +373,9 @@ function copySpecificProperties(primary, secondary, target, include) { * @alias module:@jsdoc/doclet.Doclet */ export class Doclet { + #accessConfig; + #dictionary; + /** * Create a doclet. * @@ -381,6 +387,8 @@ export class Doclet { let newTags = []; meta = meta || {}; + this.#accessConfig = dependencies.get('config')?.opts?.access ?? []; + this.#dictionary = dependencies.get('tags'); /** The original text of the comment from the source code. */ this.comment = docletSrc; Object.defineProperty(this, 'dependencies', { @@ -441,8 +449,7 @@ export class Doclet { * @param {string} [text] - The text of the tag being added. */ addTag(title, text) { - const dictionary = this.dependencies.get('tags'); - const tagDef = dictionary.lookUp(title); + const tagDef = this.#dictionary.lookUp(title); const newTag = new Tag(title, text, this.meta, this.dependencies); if (tagDef && tagDef.onTagged) { @@ -455,6 +462,75 @@ export class Doclet { } } + /** + * Check whether the doclet represents a globally available symbol. + * + * @returns {boolean} `true` if the doclet represents a global; `false` otherwise. + */ + isGlobal() { + return this.scope === 'global' && GLOBAL_KINDS.includes(this.kind); + } + + /** + * Check whether the doclet should be used to generate output. + * + * @returns {boolean} `true` if the doclet should be used to generate output; `false` otherwise. + */ + isVisible() { + const accessConfig = this.#accessConfig; + + // By default, we don't use: + // + // + Doclets that explicitly declare that they should be ignored + // + Doclets that claim to belong to an anonymous scope + // + "Undocumented" doclets (usually code with no JSDoc comment; might also include some odd + // artifacts of the parsing process) + if (this.ignore === true || this.memberof === '' || this.undocumented === true) { + return false; + } + + // We also don't use private doclets by default, unless the user told us to use them. + if ( + (!accessConfig.length || + (!accessConfig.includes('all') && !accessConfig.includes('private'))) && + this.access === 'private' + ) { + return false; + } + + if (accessConfig.length && !accessConfig.includes('all')) { + // The string `undefined` needs special treatment. + if ( + !accessConfig.includes('undefined') && + (this.access === null || this.access === undefined) + ) { + return false; + } + // For other access levels, we can just check whether the user asked us to use that level. + if (ACCESS_LEVELS.some((level) => !accessConfig.includes(level) && this.access === level)) { + return false; + } + } + + return true; + } + + /** + * Set the doclet's `longname` property. + * + * @param {string} longname - The longname for the doclet. + */ + setLongname(longname) { + /** + * The fully resolved symbol name. + * @type {string} + */ + this.longname = removeGlobal(longname); + if (this.#dictionary.isNamespace(this.kind)) { + this.longname = applyNamespace(this.longname, this.kind); + } + } + /** * Set the doclet's `memberof` property. * @@ -470,24 +546,6 @@ export class Doclet { .replace(/\.prototype/g, SCOPE.PUNC.INSTANCE); } - /** - * Set the doclet's `longname` property. - * - * @param {string} longname - The longname for the doclet. - */ - setLongname(longname) { - const dictionary = this.dependencies.get('tags'); - - /** - * The fully resolved symbol name. - * @type {string} - */ - this.longname = removeGlobal(longname); - if (dictionary.isNamespace(this.kind)) { - this.longname = applyNamespace(this.longname, this.kind); - } - } - /** * Set the doclet's `scope` property. Must correspond to a scope name that is defined in * {@link module:@jsdoc/core.name.SCOPE.NAMES}. diff --git a/packages/jsdoc-doclet/test/specs/lib/doclet.js b/packages/jsdoc-doclet/test/specs/lib/doclet.js index b98da0ad..222b5b71 100644 --- a/packages/jsdoc-doclet/test/specs/lib/doclet.js +++ b/packages/jsdoc-doclet/test/specs/lib/doclet.js @@ -18,7 +18,9 @@ import { name } from '@jsdoc/core'; import _ from 'lodash'; import * as doclet from '../../../lib/doclet.js'; +import { DOCLET_SCHEMA } from '../../../lib/schema.js'; +const ACCESS_VALUES = DOCLET_SCHEMA.properties.access.enum.concat([undefined]); const { Doclet } = doclet; const { SCOPE } = name; @@ -36,7 +38,79 @@ describe('@jsdoc/doclet/lib/doclet', () => { expect(doclet.Doclet).toBeFunction(); }); + describe('combineDoclets', () => { + it('overrides most properties of the secondary doclet', () => { + const primaryDoclet = new Doclet( + '/** New and improved!\n@version 2.0.0 */', + null, + jsdoc.deps + ); + const secondaryDoclet = new Doclet('/** Hello!\n@version 1.0.0 */', null, jsdoc.deps); + const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + + Object.getOwnPropertyNames(newDoclet).forEach((property) => { + expect(newDoclet[property]).toEqual(primaryDoclet[property]); + }); + }); + + it('adds properties from the secondary doclet that are missing', () => { + const primaryDoclet = new Doclet('/** Hello!\n@version 2.0.0 */', null, jsdoc.deps); + const secondaryDoclet = new Doclet('/** Hello! */', null, jsdoc.deps); + const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + + expect(newDoclet.version).toBe('2.0.0'); + }); + + describe('params and properties', () => { + const properties = ['params', 'properties']; + + it('uses params and properties from the secondary doclet if the primary lacks them', () => { + const primaryDoclet = new Doclet('/** Hello! */', null, jsdoc.deps); + const secondaryComment = [ + '/**', + ' * @param {string} foo - The foo.', + ' * @property {number} bar - The bar.', + ' */', + ].join('\n'); + const secondaryDoclet = new Doclet(secondaryComment, null, jsdoc.deps); + const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + + properties.forEach((property) => { + expect(newDoclet[property]).toEqual(secondaryDoclet[property]); + }); + }); + + it('uses params and properties from the primary doclet, if present', () => { + const primaryComment = [ + '/**', + ' * @param {number} baz - The baz.', + ' * @property {string} qux - The qux.', + ' */', + ].join('\n'); + const primaryDoclet = new Doclet(primaryComment, null, jsdoc.deps); + const secondaryComment = [ + '/**', + ' * @param {string} foo - The foo.', + ' * @property {number} bar - The bar.', + ' */', + ].join('\n'); + const secondaryDoclet = new Doclet(secondaryComment, null, jsdoc.deps); + const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + + properties.forEach((property) => { + expect(newDoclet[property]).toEqual(primaryDoclet[property]); + }); + }); + }); + }); + describe('Doclet', () => { + function makeDoclet(tagStrings, deps) { + const comment = `/**\n${tagStrings.join('\n')}\n*/`; + + return new Doclet(comment, {}, deps || jsdoc.deps); + } + const docSet = jsdoc.getDocSetFromFile('test/fixtures/doclet.js'); const testDoclet = docSet.getByLongname('test2')[0]; @@ -51,15 +125,9 @@ describe('@jsdoc/doclet/lib/doclet', () => { expect(descriptor.enumerable).toBeFalse(); }); - // TODO: more tests (namespaces, modules, etc.) + // TODO: more tests (namespaces, modules, etc.); fold into `postProcess()` tests if that's + // really what we're testing here describe('name resolution', () => { - // TODO: Load fixtures instead of creating doclets manually - function makeDoclet(tagStrings) { - const comment = `/**\n${tagStrings.join('\n')}\n*/`; - - return new Doclet(comment, {}, jsdoc.deps); - } - describe('aliases', () => { // TODO: This comment implies that we _don't_ need to set doclet.name... // If `doclet.alias` is defined, `doclet.name` will be set to the same value by the @@ -219,93 +287,205 @@ describe('@jsdoc/doclet/lib/doclet', () => { }); }); }); - }); - describe('setScope', () => { - it('accepts the correct scope names', () => { - function setScope(scopeName) { - const newDoclet = new Doclet('/** Huzzah, a doclet! */', null, jsdoc.deps); + xdescribe('addTag', () => { + xit('TODO: write tests'); + }); - newDoclet.setScope(scopeName); - } + xdescribe('augment', () => { + xit('TODO: write tests'); + }); - _.values(SCOPE.NAMES).forEach((scopeName) => { - expect(setScope.bind(null, scopeName)).not.toThrow(); + xdescribe('borrow', () => { + xit('TODO: write tests'); + }); + + describe('isGlobal', () => { + it('identifies global constants', () => { + const newDoclet = makeDoclet(['@constant', '@global', '@name foo']); + + expect(newDoclet.isGlobal()).toBeTrue(); + }); + + it('identifies global functions', () => { + const newDoclet = makeDoclet(['@function', '@global', '@name foo']); + + expect(newDoclet.isGlobal()).toBeTrue(); + }); + + it('identifies global members', () => { + const newDoclet = makeDoclet(['@global', '@member', '@name foo']); + + expect(newDoclet.isGlobal()).toBeTrue(); + }); + + it('identifies global typedefs', () => { + const newDoclet = makeDoclet(['@global', '@name foo', '@typedef']); + + expect(newDoclet.isGlobal()).toBeTrue(); + }); + + it('does not say a doclet is global if its scope is not `global`', () => { + const newDoclet = makeDoclet(['@name foo', '@static', '@typedef']); + + expect(newDoclet.isGlobal()).toBeFalse(); + }); + + it('does not say a doclet is global if it has the wrong `kind`', () => { + const newDoclet = makeDoclet(['@name foo', '@param', '@static']); + + expect(newDoclet.isGlobal()).toBeFalse(); }); }); - it('throws an error for invalid scope names', () => { - function setScope() { - const newDoclet = new Doclet('/** Woe betide this doclet. */', null, jsdoc.deps); + describe('isVisible', () => { + function makeDeps(access) { + const config = _.cloneDeep(jsdoc.deps.get('config')); + const map = new Map(); - newDoclet.setScope('fiddlesticks'); + if (access) { + config.opts.access = access.slice(); + } + map.set('config', config); + map.set('tags', jsdoc.deps.get('tags')); + + return map; } - expect(setScope).toThrow(); - }); - }); + it('returns `false` for ignored doclets', () => { + const newDoclet = makeDoclet(['@ignore', '@name foo', '@function']); - describe('combineDoclets', () => { - it('overrides most properties of the secondary doclet', () => { - const primaryDoclet = new Doclet( - '/** New and improved!\n@version 2.0.0 */', - null, - jsdoc.deps - ); - const secondaryDoclet = new Doclet('/** Hello!\n@version 1.0.0 */', null, jsdoc.deps); - const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); - - Object.getOwnPropertyNames(newDoclet).forEach((property) => { - expect(newDoclet[property]).toEqual(primaryDoclet[property]); + expect(newDoclet.isVisible()).toBeFalse(); }); - }); - it('adds properties from the secondary doclet that are missing', () => { - const primaryDoclet = new Doclet('/** Hello!\n@version 2.0.0 */', null, jsdoc.deps); - const secondaryDoclet = new Doclet('/** Hello! */', null, jsdoc.deps); - const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + it('returns `false` for undocumented doclets', () => { + const newDoclet = makeDoclet(['@name foo', '@function']); - expect(newDoclet.version).toBe('2.0.0'); - }); + newDoclet.undocumented = true; - describe('params and properties', () => { - const properties = ['params', 'properties']; + expect(newDoclet.isVisible()).toBeFalse(); + }); - it('uses params and properties from the secondary doclet if the primary lacks them', () => { - const primaryDoclet = new Doclet('/** Hello! */', null, jsdoc.deps); - const secondaryComment = [ - '/**', - ' * @param {string} foo - The foo.', - ' * @property {number} bar - The bar.', - ' */', - ].join('\n'); - const secondaryDoclet = new Doclet(secondaryComment, null, jsdoc.deps); - const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + it('returns `false` for members of anonymous scopes', () => { + const newDoclet = makeDoclet(['@name foo', '@function']); - properties.forEach((property) => { - expect(newDoclet[property]).toEqual(secondaryDoclet[property]); + newDoclet.memberof = ''; + + expect(newDoclet.isVisible()).toBeFalse(); + }); + + describe('access', () => { + it('returns `false` for `private` doclets by default', () => { + const newDoclet = makeDoclet(['@name foo', '@function', '@private']); + + expect(newDoclet.isVisible()).toBeFalse(); + }); + + it('returns `true` with `access === undefined` by default', () => { + const newDoclet = makeDoclet(['@name foo', '@function']); + + // Just to be sure. + delete newDoclet.access; + + expect(newDoclet.isVisible()).toBeTrue(); + }); + + it('always returns `true` based on `doclet.access` when `access` config includes `all`', () => { + const fakeDeps = makeDeps(['all']); + const doclets = ACCESS_VALUES.map((value) => { + let newDoclet; + const tags = ['@function', '@name foo']; + + if (value) { + tags.push('@' + value); + } + newDoclet = makeDoclet(tags, fakeDeps); + // Just to be sure. + if (!value) { + delete newDoclet.access; + } + + return newDoclet; + }); + + doclets.forEach((d) => { + expect(d.isVisible()).toBeTrue(); + }); + }); + + it('returns `false` for `package` doclets when config omits `package`', () => { + const fakeDeps = makeDeps(['public']); + const newDoclet = makeDoclet(['@function', '@name foo', '@package'], fakeDeps); + + expect(newDoclet.isVisible()).toBeFalse(); + }); + + it('returns `false` for `protected` doclets when config omits `protected`', () => { + const fakeDeps = makeDeps(['public']); + const newDoclet = makeDoclet(['@function', '@name foo', '@protected'], fakeDeps); + + expect(newDoclet.isVisible()).toBeFalse(); + }); + + it('returns `false` for `public` doclets when config omits `public`', () => { + const fakeDeps = makeDeps(['private']); + const newDoclet = makeDoclet(['@function', '@name foo', '@public'], fakeDeps); + + expect(newDoclet.isVisible()).toBeFalse(); + }); + + it('returns `false` for undefined-access doclets when config omits `undefined`', () => { + const fakeDeps = makeDeps(['public']); + const newDoclet = makeDoclet(['@function', '@name foo'], fakeDeps); + + // Just to be sure. + delete newDoclet.access; + + expect(newDoclet.isVisible()).toBeFalse(); }); }); - it('uses params and properties from the primary doclet, if present', () => { - const primaryComment = [ - '/**', - ' * @param {number} baz - The baz.', - ' * @property {string} qux - The qux.', - ' */', - ].join('\n'); - const primaryDoclet = new Doclet(primaryComment, null, jsdoc.deps); - const secondaryComment = [ - '/**', - ' * @param {string} foo - The foo.', - ' * @property {number} bar - The bar.', - ' */', - ].join('\n'); - const secondaryDoclet = new Doclet(secondaryComment, null, jsdoc.deps); - const newDoclet = doclet.combineDoclets(primaryDoclet, secondaryDoclet); + xdescribe('mix', () => { + xit('TODO: write tests'); + }); - properties.forEach((property) => { - expect(newDoclet[property]).toEqual(primaryDoclet[property]); + xdescribe('postProcess', () => { + xit('TODO: write tests'); + }); + + xdescribe('setLongname', () => { + xit('TODO: write tests'); + }); + + xdescribe('setMemberof', () => { + xit('TODO: write tests'); + }); + + xdescribe('setMeta', () => { + xit('TODO: write tests'); + }); + + describe('setScope', () => { + it('accepts the correct scope names', () => { + function setScope(scopeName) { + const newDoclet = new Doclet('/** Huzzah, a doclet! */', null, jsdoc.deps); + + newDoclet.setScope(scopeName); + } + + _.values(SCOPE.NAMES).forEach((scopeName) => { + expect(setScope.bind(null, scopeName)).not.toThrow(); + }); + }); + + it('throws an error for invalid scope names', () => { + function setScope() { + const newDoclet = new Doclet('/** Woe betide this doclet. */', null, jsdoc.deps); + + newDoclet.setScope('fiddlesticks'); + } + + expect(setScope).toThrow(); }); }); });