Skip to content

Commit ab5c488

Browse files
authored
feat: add support for import tags (#252)
* feat: add support for import tags * feat: sort imports by src * feat: sort third party modules before local * feat: add option to pad single line imports * feat: add option to toggle import merging * feat: named import line splitting option * chore: add whitespace back in tests * feat: option to toggle import formatting * chore: add newline at end of test file
1 parent 2c6d363 commit ab5c488

8 files changed

Lines changed: 417 additions & 46 deletions

File tree

src/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,36 @@ const options = {
142142
default: undefined,
143143
description: "How many spaces will be used to separate tag elements.",
144144
},
145+
jsdocMergeImports: {
146+
name: "jsdocMergeImports",
147+
type: "boolean",
148+
category: "jsdoc",
149+
default: true,
150+
description:
151+
"Merge all imports tags in the same block from the same source into one tag",
152+
},
153+
jsdocNamedImportPadding: {
154+
name: "jsdocNamedImportPadding",
155+
type: "boolean",
156+
category: "jsdoc",
157+
default: false,
158+
description: "Whether or not to pad brackets for single line named imports",
159+
},
160+
jsdocNamedImportLineSplitting: {
161+
name: "jsdocNamedImportLineSplitting",
162+
type: "boolean",
163+
category: "jsdoc",
164+
default: true,
165+
description:
166+
"Split import tags with multiple named imports into multiple lines",
167+
},
168+
jsdocFormatImports: {
169+
name: "jsdocFormatImports",
170+
type: "boolean",
171+
category: "jsdoc",
172+
default: true,
173+
description: "Format import tags",
174+
},
145175
} as const satisfies Record<keyof JsdocOptions, SupportOption>;
146176

147177
const defaultOptions: JsdocOptions = {
@@ -162,6 +192,10 @@ const defaultOptions: JsdocOptions = {
162192
tsdoc: options.tsdoc.default,
163193
jsdocLineWrappingStyle: options.jsdocLineWrappingStyle.default,
164194
jsdocTagsOrder: options.jsdocTagsOrder.default,
195+
jsdocFormatImports: options.jsdocFormatImports.default,
196+
jsdocNamedImportPadding: options.jsdocNamedImportPadding.default,
197+
jsdocMergeImports: options.jsdocMergeImports.default,
198+
jsdocNamedImportLineSplitting: options.jsdocNamedImportLineSplitting.default,
165199
};
166200

167201
const parsers = {

src/parser.ts

Lines changed: 183 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
findPluginByParser,
88
isDefaultTag,
99
} from "./utils.js";
10-
import { DESCRIPTION, PARAM, RETURNS, EXAMPLE } from "./tags.js";
10+
import { DESCRIPTION, PARAM, RETURNS, EXAMPLE, IMPORT } from "./tags.js";
1111
import {
1212
TAGS_DESCRIPTION_NEEDED,
1313
TAGS_GROUP_HEAD,
@@ -255,61 +255,122 @@ function sortTags(
255255
): Spec[] {
256256
let canGroupNextTags = false;
257257
let shouldSortAgain = false;
258+
const importDetailsBySource: { [tag: string]: ImportDetails[] } = {};
259+
const importSourceByDescription: { [description: string]: string } = {};
260+
261+
const tagGroups = tags.reduce<Spec[][]>((tagGroups, cur) => {
262+
if (
263+
tagGroups.length === 0 ||
264+
(TAGS_GROUP_HEAD.includes(cur.tag) && canGroupNextTags)
265+
) {
266+
canGroupNextTags = false;
267+
tagGroups.push([]);
268+
}
258269

259-
tags = tags
260-
.reduce<Spec[][]>((tagGroups, cur) => {
261-
if (
262-
tagGroups.length === 0 ||
263-
(TAGS_GROUP_HEAD.includes(cur.tag) && canGroupNextTags)
264-
) {
265-
canGroupNextTags = false;
266-
tagGroups.push([]);
267-
}
268-
if (TAGS_GROUP_CONDITION.includes(cur.tag)) {
269-
canGroupNextTags = true;
270-
}
271-
tagGroups[tagGroups.length - 1].push(cur);
272-
273-
return tagGroups;
274-
}, [])
275-
.flatMap((tagGroup, index, array) => {
276-
// sort tags within groups
277-
tagGroup.sort((a, b) => {
278-
if (
279-
paramsOrder &&
280-
paramsOrder.length > 1 &&
281-
a.tag === PARAM &&
282-
b.tag === PARAM
283-
) {
284-
const aIndex = paramsOrder.indexOf(a.name);
285-
const bIndex = paramsOrder.indexOf(b.name);
286-
if (aIndex > -1 && bIndex > -1) {
287-
//sort params
288-
return aIndex - bIndex;
270+
if (TAGS_GROUP_CONDITION.includes(cur.tag)) {
271+
canGroupNextTags = true;
272+
}
273+
274+
if (options.jsdocFormatImports && cur.tag === IMPORT) {
275+
const importDetails = getImportDetails(cur);
276+
if (importDetails) {
277+
if (options.jsdocMergeImports) {
278+
const existingImport = importDetailsBySource[importDetails.src];
279+
if (existingImport) {
280+
importDetailsBySource[importDetails.src].push(importDetails);
281+
// do not add duplicate import tags to tagGroups
282+
return tagGroups;
289283
}
290-
return 0;
284+
importDetailsBySource[importDetails.src] = [importDetails];
285+
} else {
286+
writeImportDetailsToSpec(importDetails, options);
287+
importSourceByDescription[importDetails.spec.description] =
288+
importDetails.src;
291289
}
292-
return (
293-
getTagOrderWeight(a.tag, options) - getTagOrderWeight(b.tag, options)
294-
);
295-
});
296-
297-
// add an empty line between groups
298-
if (array.length - 1 !== index) {
299-
tagGroup.push(SPACE_TAG_DATA);
300290
}
291+
}
292+
293+
tagGroups[tagGroups.length - 1].push(cur);
294+
295+
return tagGroups;
296+
}, []);
297+
298+
// Merge the import details for a given src into a printable tag description
299+
if (options.jsdocFormatImports && options.jsdocMergeImports) {
300+
Object.keys(importDetailsBySource).forEach((src) => {
301+
const importDetails = importDetailsBySource[src];
302+
// the first spec is the only one added to tagGroups
303+
const firstImpSpec = importDetails[0].spec;
304+
const { defaultImport, namedImports } = importDetails.reduce(
305+
(prev, curr) => {
306+
prev.namedImports.push(...curr.namedImports);
307+
// NB: the last default import encountered will be the one used
308+
if (curr.defaultImport) prev.defaultImport = curr.defaultImport;
309+
return prev;
310+
},
311+
{ namedImports: [], defaultImport: undefined } as Pick<
312+
ImportDetails,
313+
"defaultImport" | "namedImports"
314+
>,
315+
);
301316

317+
writeImportDetailsToSpec(
318+
{ src, defaultImport, namedImports, spec: firstImpSpec },
319+
options,
320+
);
321+
importSourceByDescription[firstImpSpec.description] = src;
322+
});
323+
}
324+
325+
tags = tagGroups.flatMap((tagGroup, index, array) => {
326+
// sort tags within groups
327+
tagGroup.sort((a, b) => {
302328
if (
303-
index > 0 &&
304-
tagGroup[0]?.tag &&
305-
!TAGS_GROUP_HEAD.includes(tagGroup[0].tag)
329+
paramsOrder &&
330+
paramsOrder.length > 1 &&
331+
a.tag === PARAM &&
332+
b.tag === PARAM
306333
) {
307-
shouldSortAgain = true;
334+
const aIndex = paramsOrder.indexOf(a.name);
335+
const bIndex = paramsOrder.indexOf(b.name);
336+
if (aIndex > -1 && bIndex > -1) {
337+
//sort params
338+
return aIndex - bIndex;
339+
}
340+
return 0;
341+
}
342+
343+
// sorts imports by source and places third party modes at the top
344+
if (options.jsdocFormatImports && a.tag === IMPORT && b.tag === IMPORT) {
345+
const aSrc = importSourceByDescription[a.description] ?? a.description;
346+
const bSrc = importSourceByDescription[b.description] ?? a.description;
347+
const aVal = aSrc.startsWith(".") ? 1 : 0;
348+
const bVal = bSrc.startsWith(".") ? 1 : 0;
349+
if (aVal === bVal) return aSrc.localeCompare(bSrc);
350+
return aVal - bVal;
308351
}
309352

310-
return tagGroup;
353+
return (
354+
getTagOrderWeight(a.tag, options) - getTagOrderWeight(b.tag, options)
355+
);
311356
});
312357

358+
// add an empty line between groups
359+
if (array.length - 1 !== index) {
360+
tagGroup.push(SPACE_TAG_DATA);
361+
}
362+
363+
if (
364+
index > 0 &&
365+
tagGroup[0]?.tag &&
366+
!TAGS_GROUP_HEAD.includes(tagGroup[0].tag)
367+
) {
368+
shouldSortAgain = true;
369+
}
370+
371+
return tagGroup;
372+
});
373+
313374
return shouldSortAgain ? sortTags(tags, paramsOrder, options) : tags;
314375
}
315376

@@ -615,3 +676,80 @@ function assignOptionalAndDefaultToName({
615676
default: default_,
616677
};
617678
}
679+
680+
type ImportDetails = {
681+
/** the spec associated with this import tag */
682+
spec: Spec;
683+
/** the source of the module that types were imported from */
684+
src: string;
685+
defaultImport?: string;
686+
/** the types that were imported */
687+
namedImports: {
688+
name: string;
689+
/** the alias assigned to the type (EX: alias of "B as B0" is "B0") */
690+
alias?: string;
691+
}[];
692+
};
693+
694+
/**
695+
* Writes the import details given to the description field of the spec
696+
*/
697+
function writeImportDetailsToSpec(
698+
importDetails: ImportDetails,
699+
options: AllOptions,
700+
) {
701+
const { defaultImport, namedImports, src, spec } = importDetails;
702+
703+
// sort the import details
704+
namedImports.sort((a, b) =>
705+
(a.alias ?? a.name).localeCompare(b.alias ?? b.name),
706+
);
707+
708+
// write the merged import details to the spec description
709+
const importClauses = [];
710+
if (defaultImport) importClauses.push(defaultImport);
711+
if (namedImports.length > 0) {
712+
const makeMultiLine =
713+
options.jsdocNamedImportLineSplitting && namedImports.length > 1;
714+
const typeString = namedImports
715+
.map((t) => {
716+
const val = t.alias ? `${t.name} as ${t.alias}` : `${t.name}`;
717+
return makeMultiLine ? ` ${val}` : val;
718+
})
719+
.join(makeMultiLine ? ",\n" : ", ");
720+
const namedImportClause = makeMultiLine
721+
? `{\n${typeString}\n}`
722+
: options.jsdocNamedImportPadding
723+
? `{ ${typeString} }`
724+
: `{${typeString}}`;
725+
importClauses.push(namedImportClause);
726+
}
727+
spec.description = `${importClauses.join(", ")} from "${src}"`;
728+
}
729+
730+
/**
731+
* Extracts the defaultImports, namedImports, and src associated with a given import tag.
732+
*/
733+
function getImportDetails(spec: Spec): ImportDetails | null {
734+
// step 1: capture the default import, named import clause, and src
735+
const match = spec.description.match(
736+
/([^\s\\,\\{\\}]+)?(?:[^\\{\\}]*)\{?([^\\{\\}]*)?\}?(?:\s+from\s+)[\\'\\"](\S+)[\\'\\"]/s,
737+
);
738+
if (!match) return null;
739+
740+
const defaultImport = match[1] || "";
741+
const namedImportsClause = match[2] || "";
742+
const src = match[3] || "";
743+
744+
// step 2: get all named imports from the named import section
745+
const typeMatches = namedImportsClause.matchAll(
746+
/([^\s\\,\\{\\}]+)(?:\s+as\s+)?([^\s\\,\\{\\}]+)?/g,
747+
);
748+
749+
const namedImports = [];
750+
for (const typeMatch of typeMatches) {
751+
namedImports.push({ name: typeMatch[1], alias: typeMatch[2] });
752+
}
753+
754+
return { spec, src, namedImports, defaultImport };
755+
}

src/roles.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
FLOW,
2121
FUNCTION,
2222
IGNORE,
23+
IMPORT,
2324
LICENSE,
2425
MEMBER,
2526
MEMBEROF,
@@ -84,6 +85,7 @@ const TAGS_NAMELESS = [
8485
EXAMPLE,
8586
EXTENDS,
8687
LICENSE,
88+
IMPORT,
8789
MODULE,
8890
NAMESPACE,
8991
OVERLOAD,
@@ -106,6 +108,7 @@ const TAGS_TYPELESS = [
106108
DESCRIPTION,
107109
EXAMPLE,
108110
IGNORE,
111+
IMPORT,
109112
LICENSE,
110113
MODULE,
111114
NAMESPACE,
@@ -122,6 +125,7 @@ const TAGS_PEV_FORMATE_DESCRIPTION = [
122125
/** @todo should be formate like jsdoc standard saw https://jsdoc.app/tags-borrows.html */
123126
BORROWS,
124127
...TAGS_DEFAULT,
128+
IMPORT,
125129
MEMBEROF,
126130
MODULE,
127131
SEE,
@@ -132,6 +136,7 @@ const TAGS_DESCRIPTION_NEEDED = [
132136
CATEGORY,
133137
DESCRIPTION,
134138
EXAMPLE,
139+
IMPORT,
135140
PRIVATE_REMARKS,
136141
REMARKS,
137142
SINCE,
@@ -174,6 +179,7 @@ const TAGS_GROUP_CONDITION = [
174179
];
175180

176181
const TAGS_ORDER = {
182+
[IMPORT]: 0,
177183
[REMARKS]: 1,
178184
[PRIVATE_REMARKS]: 2,
179185
[PROVIDES_MODULE]: 3,

src/stringify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const stringify = async (
125125
if (useTagTitle) tagString += gap + " ".repeat(descGapAdj);
126126
if (
127127
TAGS_PEV_FORMATE_DESCRIPTION.includes(tag) ||
128-
!TAGS_ORDER[tag as keyof typeof TAGS_ORDER]
128+
TAGS_ORDER[tag as keyof typeof TAGS_ORDER] === undefined
129129
) {
130130
// Avoid wrapping
131131
descriptionString = description;

src/tags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const FIRES = "fires";
2121
const FLOW = "flow";
2222
const FUNCTION = "function";
2323
const IGNORE = "ignore";
24+
const IMPORT = "import";
2425
const LICENSE = "license";
2526
const MEMBER = "member";
2627
const MEMBEROF = "memberof";
@@ -79,6 +80,7 @@ export {
7980
FLOW,
8081
FUNCTION,
8182
IGNORE,
83+
IMPORT,
8284
LICENSE,
8385
MEMBER,
8486
MEMBEROF,

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export interface JsdocOptions {
2222
tsdoc: boolean;
2323
jsdocLineWrappingStyle: "greedy";
2424
jsdocTagsOrder?: Record<string, number>;
25+
jsdocFormatImports: boolean;
26+
jsdocNamedImportPadding: boolean;
27+
jsdocMergeImports: boolean;
28+
jsdocNamedImportLineSplitting: boolean;
2529
}
2630

2731
export interface AllOptions extends ParserOptions, JsdocOptions {}

0 commit comments

Comments
 (0)