fix: add type expression, not parsed type AST, to doclets

The AST was normally added as a non-enumerable property, `type.parsedType`, which caused many complications; most recently, I noticed that doclets don't retain this property when `DocletStore` proxies them. Better to just add the original type expression as `type.expression` and let templates parse it again as needed.
This commit is contained in:
Jeff Williams 2024-12-24 16:49:16 -08:00
parent bb62f69f51
commit 59d31d5176
No known key found for this signature in database
11 changed files with 56 additions and 125 deletions

View File

@ -106,6 +106,10 @@ export const TYPE_PROPERTY_SCHEMA = {
type: OBJECT, type: OBJECT,
additionalProperties: false, additionalProperties: false,
properties: { properties: {
// original type expression
expression: {
type: STRING,
},
names: { names: {
type: ARRAY, type: ARRAY,
minItems: 1, minItems: 1,
@ -113,11 +117,6 @@ export const TYPE_PROPERTY_SCHEMA = {
type: STRING, type: STRING,
}, },
}, },
// type parser output
parsedType: {
type: OBJECT,
additionalProperties: true,
},
}, },
}; };

View File

@ -56,17 +56,6 @@ function trim(text, opts, meta) {
return text; return text;
} }
function addHiddenProperty(obj, propName, propValue, env) {
const { options } = env;
Object.defineProperty(obj, propName, {
value: propValue,
writable: true,
enumerable: Boolean(options.debug),
configurable: true,
});
}
function parseType({ env, text, originalTitle }, { canHaveName, canHaveType }, meta) { function parseType({ env, text, originalTitle }, { canHaveName, canHaveType }, meta) {
let log; let log;
@ -90,7 +79,7 @@ function parseType({ env, text, originalTitle }, { canHaveName, canHaveType }, m
} }
} }
function processTagText(tagInstance, tagDef, meta, env) { function processTagText(tagInstance, tagDef, meta) {
let tagType; let tagType;
if (tagDef.onTagText) { if (tagDef.onTagText) {
@ -105,9 +94,9 @@ function processTagText(tagInstance, tagDef, meta, env) {
if (tagType.type) { if (tagType.type) {
if (tagType.type.length) { if (tagType.type.length) {
tagInstance.value.type = { tagInstance.value.type = {
expression: tagType.typeExpression,
names: tagType.type, names: tagType.type,
}; };
addHiddenProperty(tagInstance.value.type, 'parsedType', tagType.parsedType, env);
} }
['optional', 'nullable', 'variable', 'defaultvalue'].forEach((prop) => { ['optional', 'nullable', 'variable', 'defaultvalue'].forEach((prop) => {
@ -187,7 +176,7 @@ export class Tag {
this.text = trim(tagBody, trimOpts, meta); this.text = trim(tagBody, trimOpts, meta);
if (this.text) { if (this.text) {
processTagText(this, tagDef, meta, env); processTagText(this, tagDef, meta);
} }
tagValidator.validate(this, tagDef, meta); tagValidator.validate(this, tagDef, meta);

View File

@ -283,7 +283,6 @@ function parseTypeExpression(tagInfo) {
} }
tagInfo.type = tagInfo.type.concat(getTypeStrings(parsedType, true)); tagInfo.type = tagInfo.type.concat(getTypeStrings(parsedType, true));
tagInfo.parsedType = parsedType;
// Catharsis and JSDoc use the same names for 'optional' and 'nullable'... // Catharsis and JSDoc use the same names for 'optional' and 'nullable'...
['optional', 'nullable'].forEach((key) => { ['optional', 'nullable'].forEach((key) => {

View File

@ -52,31 +52,18 @@ describe('@jsdoc/tag/lib/tag', () => {
]; ];
const textExampleIndented = exampleIndentedRaw.join(''); const textExampleIndented = exampleIndentedRaw.join('');
let tagArg;
let tagExample;
let tagExampleIndented;
let tagParam;
let tagParamWithType;
let tagType;
function createTags() {
// Synonym for @param; space in the title // Synonym for @param; space in the title
tagArg = new Tag('arg ', text, meta, jsdoc.env); const tagArg = new Tag('arg ', text, meta, jsdoc.env);
// @param with no type, but with optional and defaultvalue // @param with no type, but with optional and defaultvalue
tagParam = new Tag('param', '[foo=1]', meta, jsdoc.env); const tagParam = new Tag('param', '[foo=1]', meta, jsdoc.env);
// @param with type and no type modifiers (such as optional) // @param with type and no type modifiers (such as optional)
tagParamWithType = new Tag('param', '{string} foo', meta, jsdoc.env); const tagParamWithType = new Tag('param', '{string} foo', meta, jsdoc.env);
// @example that does not need indentation to be removed // @example that does not need indentation to be removed
tagExample = new Tag('example', textExample, meta, jsdoc.env); const tagExample = new Tag('example', textExample, meta, jsdoc.env);
// @example that needs indentation to be removed // @example that needs indentation to be removed
tagExampleIndented = new Tag('example', textExampleIndented, meta, jsdoc.env); const tagExampleIndented = new Tag('example', textExampleIndented, meta, jsdoc.env);
// For testing that `onTagText` is called when necessary // For testing that `onTagText` is called when necessary
tagType = new Tag('type', 'MyType ', meta, jsdoc.env); const tagType = new Tag('type', 'MyType ', meta, jsdoc.env);
}
beforeEach(() => {
createTags();
});
describe('`originalTitle` property', () => { describe('`originalTitle` property', () => {
it('has an `originalTitle` property', () => { it('has an `originalTitle` property', () => {
@ -170,7 +157,6 @@ describe('@jsdoc/tag/lib/tag', () => {
function verifyTagType(tag) { function verifyTagType(tag) {
let def; let def;
let descriptor;
let info; let info;
def = jsdocDictionary.lookUp(tag.title); def = jsdocDictionary.lookUp(tag.title);
@ -185,27 +171,19 @@ describe('@jsdoc/tag/lib/tag', () => {
} }
}); });
if (info.type && info.type.length) { if (info.type?.length) {
expect(tag.value.type).toBeObject(); expect(tag.value.type).toBeObject();
expect(tag.value.type.names).toEqual(info.type); expect(tag.value.type.names).toEqual(info.type);
expect(tag.value.type.parsedType).toBeObject(); expect(tag.value.type.expression).toBeString();
descriptor = Object.getOwnPropertyDescriptor(tag.value.type, 'parsedType');
expect(descriptor.enumerable).toBe(Boolean(options.debug));
} }
} }
it('contains the type information for tags with types', () => { it('contains the type information for tags with types', () => {
[true, false].forEach((bool) => {
options.debug = bool;
createTags();
verifyTagType(tagType); verifyTagType(tagType);
verifyTagType(tagArg); verifyTagType(tagArg);
verifyTagType(tagParam); verifyTagType(tagParam);
}); });
});
it('contains any additional descriptive text', () => { it('contains any additional descriptive text', () => {
expect(tagType.value.description).toBeUndefined(); expect(tagType.value.description).toBeUndefined();
@ -222,6 +200,12 @@ describe('@jsdoc/tag/lib/tag', () => {
expect(Object.hasOwn(tagParamWithType.value, modifier)).toBeFalse(); expect(Object.hasOwn(tagParamWithType.value, modifier)).toBeFalse();
}); });
}); });
it('contains the original type expression', () => {
const paramWithTypeModifiers = new Tag('param', '{?Object} foo', meta, jsdoc.env);
expect(paramWithTypeModifiers.value.type.expression).toBe('?Object');
});
}); });
// Additional tests in validator.js. // Additional tests in validator.js.

View File

@ -244,7 +244,7 @@ function parseType(longname) {
let err; let err;
try { try {
return catharsis.parse(longname, { jsdoc: true }); return catharsis.parse(longname, { jsdoc: true, useCache: false });
} catch (e) { } catch (e) {
err = new Error(`unable to parse ${longname}: ${e.message}`); err = new Error(`unable to parse ${longname}: ${e.message}`);
console.error(err); console.error(err);
@ -253,7 +253,9 @@ function parseType(longname) {
} }
} }
function stringifyType(parsedType, cssClass, stringifyLinkMap) { function stringifyType(typeExpression, cssClass, stringifyLinkMap) {
const parsedType = parseType(typeExpression);
return catharsis.stringify(parsedType, { return catharsis.stringify(parsedType, {
cssClass: cssClass, cssClass: cssClass,
htmlSafe: true, htmlSafe: true,
@ -315,8 +317,6 @@ function buildLink(longname, linkText, options) {
let stripped; let stripped;
let text; let text;
let parsedType;
// handle cases like: // handle cases like:
// @see <http://example.org> // @see <http://example.org>
// @see http://example.org // @see http://example.org
@ -333,9 +333,7 @@ function buildLink(longname, linkText, options) {
/\{@.+\}/.test(longname) === false && /\{@.+\}/.test(longname) === false &&
/^<[\s\S]+>/.test(longname) === false /^<[\s\S]+>/.test(longname) === false
) { ) {
parsedType = parseType(longname); return stringifyType(longname, options.cssClass, options.linkMap);
return stringifyType(parsedType, options.cssClass, options.linkMap);
} else { } else {
fileUrl = Object.hasOwn(options.linkMap, longname) ? options.linkMap[longname] : ''; fileUrl = Object.hasOwn(options.linkMap, longname) ? options.linkMap[longname] : '';
text = linkText || (options.shortenName ? getShortName(longname) : longname); text = linkText || (options.shortenName ? getShortName(longname) : longname);

View File

@ -364,27 +364,29 @@ describe('@jsdoc/template-legacy/lib/templateHelper', () => {
it('works correctly with type applications if only the longname is specified', () => { it('works correctly with type applications if only the longname is specified', () => {
const link = helper.linkto('Array.<LinktoFakeClass>'); const link = helper.linkto('Array.<LinktoFakeClass>');
expect(link).toBe('Array.&lt;<a href="fakeclass.html">LinktoFakeClass</a>>'); expect(link).toBe('Array&lt;<a href="fakeclass.html">LinktoFakeClass</a>&gt;');
}); });
it('works correctly with type applications if a class is not specified', () => { it('works correctly with type applications if a class is not specified', () => {
const link = helper.linkto('Array.<LinktoFakeClass>', 'link text'); const link = helper.linkto('Array.<LinktoFakeClass>', 'link text');
expect(link).toBe('Array.&lt;<a href="fakeclass.html">LinktoFakeClass</a>>'); expect(link).toBe('Array&lt;<a href="fakeclass.html">LinktoFakeClass</a>&gt;');
}); });
it('works correctly with type applications if a class is specified', () => { it('works correctly with type applications if a class is specified', () => {
const link = helper.linkto('Array.<LinktoFakeClass>', 'link text', 'myclass'); const link = helper.linkto('Array.<LinktoFakeClass>', 'link text', 'myclass');
expect(link).toBe('Array.&lt;<a href="fakeclass.html" class="myclass">LinktoFakeClass</a>>'); expect(link).toBe(
'Array&lt;<a href="fakeclass.html" class="myclass">LinktoFakeClass</a>&gt;'
);
}); });
it('works correctly with type applications that include a type union', () => { it('works correctly with type applications that include a type union', () => {
const link = helper.linkto('Array.<(linktoTest|LinktoFakeClass)>', 'link text'); const link = helper.linkto('Array.<(linktoTest|LinktoFakeClass)>', 'link text');
expect(link).toBe( expect(link).toBe(
'Array.&lt;(<a href="test.html">linktoTest</a>|' + 'Array&lt;(<a href="test.html">linktoTest</a>|' +
'<a href="fakeclass.html">LinktoFakeClass</a>)>' '<a href="fakeclass.html">LinktoFakeClass</a>)&gt;'
); );
}); });
@ -857,7 +859,7 @@ describe('@jsdoc/template-legacy/lib/templateHelper', () => {
expect(types).toBeArrayOfSize(2); expect(types).toBeArrayOfSize(2);
expect(types).toContain('number'); expect(types).toContain('number');
expect(types).toContain(helper.htmlsafe('Array.<boolean>')); expect(types).toContain('Array&lt;boolean&gt;');
}); });
it('creates links for types if relevant', () => { it('creates links for types if relevant', () => {
@ -965,8 +967,8 @@ describe('@jsdoc/template-legacy/lib/templateHelper', () => {
}; };
const html = helper.getSignatureReturns(mockDoclet); const html = helper.getSignatureReturns(mockDoclet);
expect(html).not.toContain('Array.<string>'); expect(html).not.toContain('Array<string>');
expect(html).toContain('Array.&lt;string>'); expect(html).toContain('Array&lt;string&gt;');
}); });
it('returns an empty array if the doclet has no returns', () => { it('returns an empty array if the doclet has no returns', () => {

View File

@ -1,44 +0,0 @@
/*
Copyright 2020 the JSDoc Authors.
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
https://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.
*/
const { options } = jsdoc.env;
describe('multiple @param tags with the same type expression', () => {
const debug = Boolean(options.debug);
afterEach(() => {
options.debug = debug;
});
it('does not have circular references when type.parsedType is enumerable', () => {
let docSet;
let params;
let stringified;
// Force type.parsedType to be enumerable.
options.debug = true;
docSet = jsdoc.getDocSetFromFile('test/fixtures/paramtagsametype.js');
params = docSet.getByLongname('foo.bar.Baz').filter((d) => !d.undocumented)[0].params;
stringified = JSON.stringify(params);
expect(stringified).toContain('"parsedType":');
expect(stringified).not.toContain('<CircularRef>');
// Prevent the schema validator from complaining about `parsedType`. (The schema _should_
// allow that property, but for some reason, that doesn't work correctly.)
params.forEach((p) => delete p.type.parsedType);
});
});

View File

@ -13,6 +13,7 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
describe('@type tag containing a newline character', () => { describe('@type tag containing a newline character', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/typetagwithnewline.js'); const docSet = jsdoc.getDocSetFromFile('test/fixtures/typetagwithnewline.js');
const mini = docSet.getByLongname('Matryoshka.mini')[0]; const mini = docSet.getByLongname('Matryoshka.mini')[0];
@ -24,7 +25,7 @@ describe('@type tag containing a newline character', () => {
() => { () => {
expect(mini).toBeObject(); expect(mini).toBeObject();
expect(mini.type).toBeObject(); expect(mini.type).toBeObject();
expect(mini.type.names).toEqual(['!Array.<number>', '!Array.<!Array.<number>>']); expect(mini.type.names).toEqual(['!Array<number>', '!Array<!Array<number>>']);
} }
); );
@ -35,9 +36,9 @@ describe('@type tag containing a newline character', () => {
expect(mega).toBeObject(); expect(mega).toBeObject();
expect(mega.type).toBeObject(); expect(mega.type).toBeObject();
expect(mega.type.names).toEqual([ expect(mega.type.names).toEqual([
'!Array.<number>', '!Array<number>',
'!Array.<!Array.<number>>', '!Array<!Array<number>>',
'!Array.<!Array.<!Array.<number>>>', '!Array<!Array<!Array<number>>>',
]); ]);
} }
); );

View File

@ -13,6 +13,7 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
describe('@param tag', () => { describe('@param tag', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/paramtag.js'); const docSet = jsdoc.getDocSetFromFile('test/fixtures/paramtag.js');
const docSet2 = jsdoc.getDocSetFromFile('test/fixtures/paramtag2.js'); const docSet2 = jsdoc.getDocSetFromFile('test/fixtures/paramtag2.js');
@ -21,7 +22,7 @@ describe('@param tag', () => {
const find = docSet.getByLongname('find')[0]; const find = docSet.getByLongname('find')[0];
expect(find.params).toBeArrayOfSize(1); expect(find.params).toBeArrayOfSize(1);
expect(find.params[0].type.names.join(', ')).toBe('String, Array.<String>'); expect(find.params[0].type.names.join(', ')).toBe('String, Array<String>');
expect(find.params[0].name).toBe('targetName'); expect(find.params[0].name).toBe('targetName');
expect(find.params[0].description).toBe('The name (or names) of what to find.'); expect(find.params[0].description).toBe('The name (or names) of what to find.');
}); });

View File

@ -13,6 +13,7 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
describe('@returns tag', () => { describe('@returns tag', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/returnstag.js'); const docSet = jsdoc.getDocSetFromFile('test/fixtures/returnstag.js');
@ -20,7 +21,7 @@ describe('@returns tag', () => {
const find = docSet.getByLongname('find')[0]; const find = docSet.getByLongname('find')[0];
expect(find.returns).toBeArrayOfSize(1); expect(find.returns).toBeArrayOfSize(1);
expect(find.returns[0].type.names.join(', ')).toBe('string, Array.<string>'); expect(find.returns[0].type.names.join(', ')).toBe('string, Array<string>');
expect(find.returns[0].description).toBe('The names of the found item(s).'); expect(find.returns[0].description).toBe('The names of the found item(s).');
}); });

View File

@ -13,6 +13,7 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
describe('@type tag', () => { describe('@type tag', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/typetag.js'); const docSet = jsdoc.getDocSetFromFile('test/fixtures/typetag.js');
@ -21,7 +22,7 @@ describe('@type tag', () => {
expect(foo.type).toBeObject(); expect(foo.type).toBeObject();
expect(foo.type.names).toBeArrayOfStrings(); expect(foo.type.names).toBeArrayOfStrings();
expect(foo.type.names.join(', ')).toBe('string, Array.<string>'); expect(foo.type.names.join(', ')).toBe('string, Array<string>');
}); });
it("When a symbol has a @type tag set to a plain string, the doclet has a type property set to that value's type.", () => { it("When a symbol has a @type tag set to a plain string, the doclet has a type property set to that value's type.", () => {