feat: add isGlobal() and isVisible() methods to Doclet

This commit is contained in:
Jeff Williams 2023-03-19 21:47:13 -07:00
parent e926a3f300
commit f5865d3e59
No known key found for this signature in database
2 changed files with 333 additions and 95 deletions

View File

@ -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 === '<anonymous>' || 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}.

View File

@ -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 = '<anonymous>';
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();
});
});
});