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,
additionalProperties: false,
properties: {
// original type expression
expression: {
type: STRING,
},
names: {
type: ARRAY,
minItems: 1,
@ -113,11 +117,6 @@ export const TYPE_PROPERTY_SCHEMA = {
type: STRING,
},
},
// type parser output
parsedType: {
type: OBJECT,
additionalProperties: true,
},
},
};

View File

@ -56,17 +56,6 @@ function trim(text, opts, meta) {
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) {
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;
if (tagDef.onTagText) {
@ -105,9 +94,9 @@ function processTagText(tagInstance, tagDef, meta, env) {
if (tagType.type) {
if (tagType.type.length) {
tagInstance.value.type = {
expression: tagType.typeExpression,
names: tagType.type,
};
addHiddenProperty(tagInstance.value.type, 'parsedType', tagType.parsedType, env);
}
['optional', 'nullable', 'variable', 'defaultvalue'].forEach((prop) => {
@ -187,7 +176,7 @@ export class Tag {
this.text = trim(tagBody, trimOpts, meta);
if (this.text) {
processTagText(this, tagDef, meta, env);
processTagText(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.parsedType = parsedType;
// Catharsis and JSDoc use the same names for 'optional' and 'nullable'...
['optional', 'nullable'].forEach((key) => {

View File

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

View File

@ -244,7 +244,7 @@ function parseType(longname) {
let err;
try {
return catharsis.parse(longname, { jsdoc: true });
return catharsis.parse(longname, { jsdoc: true, useCache: false });
} catch (e) {
err = new Error(`unable to parse ${longname}: ${e.message}`);
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, {
cssClass: cssClass,
htmlSafe: true,
@ -315,8 +317,6 @@ function buildLink(longname, linkText, options) {
let stripped;
let text;
let parsedType;
// handle cases like:
// @see <http://example.org>
// @see http://example.org
@ -333,9 +333,7 @@ function buildLink(longname, linkText, options) {
/\{@.+\}/.test(longname) === false &&
/^<[\s\S]+>/.test(longname) === false
) {
parsedType = parseType(longname);
return stringifyType(parsedType, options.cssClass, options.linkMap);
return stringifyType(longname, options.cssClass, options.linkMap);
} else {
fileUrl = Object.hasOwn(options.linkMap, longname) ? options.linkMap[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', () => {
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', () => {
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', () => {
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', () => {
const link = helper.linkto('Array.<(linktoTest|LinktoFakeClass)>', 'link text');
expect(link).toBe(
'Array.&lt;(<a href="test.html">linktoTest</a>|' +
'<a href="fakeclass.html">LinktoFakeClass</a>)>'
'Array&lt;(<a href="test.html">linktoTest</a>|' +
'<a href="fakeclass.html">LinktoFakeClass</a>)&gt;'
);
});
@ -857,7 +859,7 @@ describe('@jsdoc/template-legacy/lib/templateHelper', () => {
expect(types).toBeArrayOfSize(2);
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', () => {
@ -965,8 +967,8 @@ describe('@jsdoc/template-legacy/lib/templateHelper', () => {
};
const html = helper.getSignatureReturns(mockDoclet);
expect(html).not.toContain('Array.<string>');
expect(html).toContain('Array.&lt;string>');
expect(html).not.toContain('Array<string>');
expect(html).toContain('Array&lt;string&gt;');
});
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
limitations under the License.
*/
describe('@type tag containing a newline character', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/typetagwithnewline.js');
const mini = docSet.getByLongname('Matryoshka.mini')[0];
@ -24,7 +25,7 @@ describe('@type tag containing a newline character', () => {
() => {
expect(mini).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.type).toBeObject();
expect(mega.type.names).toEqual([
'!Array.<number>',
'!Array.<!Array.<number>>',
'!Array.<!Array.<!Array.<number>>>',
'!Array<number>',
'!Array<!Array<number>>',
'!Array<!Array<!Array<number>>>',
]);
}
);

View File

@ -13,6 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
describe('@param tag', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/paramtag.js');
const docSet2 = jsdoc.getDocSetFromFile('test/fixtures/paramtag2.js');
@ -21,7 +22,7 @@ describe('@param tag', () => {
const find = docSet.getByLongname('find')[0];
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].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
limitations under the License.
*/
describe('@returns tag', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/returnstag.js');
@ -20,7 +21,7 @@ describe('@returns tag', () => {
const find = docSet.getByLongname('find')[0];
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).');
});

View File

@ -13,6 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
describe('@type tag', () => {
const docSet = jsdoc.getDocSetFromFile('test/fixtures/typetag.js');
@ -21,7 +22,7 @@ describe('@type tag', () => {
expect(foo.type).toBeObject();
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.", () => {