Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 153 additions & 46 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
findPluginByParser,
isDefaultTag,
} from "./utils.js";
import { DESCRIPTION, PARAM, RETURNS, EXAMPLE } from "./tags.js";
import { DESCRIPTION, PARAM, RETURNS, EXAMPLE, IMPORT } from "./tags.js";
import {
TAGS_DESCRIPTION_NEEDED,
TAGS_GROUP_HEAD,
Expand Down Expand Up @@ -255,61 +255,127 @@ function sortTags(
): Spec[] {
let canGroupNextTags = false;
let shouldSortAgain = false;
const importDetailsBySource: { [tag: string]: ImportDetails[] } = {};
const importSourceByDescription: { [description: string]: string } = {};

const tagGroups = tags.reduce<Spec[][]>((tagGroups, cur) => {
if (
tagGroups.length === 0 ||
(TAGS_GROUP_HEAD.includes(cur.tag) && canGroupNextTags)
) {
canGroupNextTags = false;
tagGroups.push([]);
}

tags = tags
.reduce<Spec[][]>((tagGroups, cur) => {
if (
tagGroups.length === 0 ||
(TAGS_GROUP_HEAD.includes(cur.tag) && canGroupNextTags)
) {
canGroupNextTags = false;
tagGroups.push([]);
}
if (TAGS_GROUP_CONDITION.includes(cur.tag)) {
canGroupNextTags = true;
}
tagGroups[tagGroups.length - 1].push(cur);

return tagGroups;
}, [])
.flatMap((tagGroup, index, array) => {
// sort tags within groups
tagGroup.sort((a, b) => {
if (
paramsOrder &&
paramsOrder.length > 1 &&
a.tag === PARAM &&
b.tag === PARAM
) {
const aIndex = paramsOrder.indexOf(a.name);
const bIndex = paramsOrder.indexOf(b.name);
if (aIndex > -1 && bIndex > -1) {
//sort params
return aIndex - bIndex;
}
return 0;
}
return (
getTagOrderWeight(a.tag, options) - getTagOrderWeight(b.tag, options)
);
});
if (TAGS_GROUP_CONDITION.includes(cur.tag)) {
canGroupNextTags = true;
}

// add an empty line between groups
if (array.length - 1 !== index) {
tagGroup.push(SPACE_TAG_DATA);
if (cur.tag === IMPORT) {
const importDetails = getImportDetails(cur);
if (importDetails) {
const existingImport = importDetailsBySource[importDetails.src];
if (existingImport) {
importDetailsBySource[importDetails.src].push(importDetails);
// do not add duplicate import tags to tagGroups
return tagGroups;
}
importDetailsBySource[importDetails.src] = [importDetails];
}
}

tagGroups[tagGroups.length - 1].push(cur);

return tagGroups;
}, []);

// Merge the import details for a given src into a printable tag description
Object.keys(importDetailsBySource).forEach((src) => {
const importDetails = importDetailsBySource[src];
// the first spec is the only one added to tagGroups
const firstImpSpec = importDetails[0].spec;
const { defaultImport, namedImports } = importDetails.reduce(
(prev, curr) => {
prev.namedImports.push(...curr.namedImports);
// NB: the last default import encountered will be the one used
if (curr.defaultImport) prev.defaultImport = curr.defaultImport;
return prev;
},
{ namedImports: [], defaultImport: undefined } as Pick<
ImportDetails,
"defaultImport" | "namedImports"
>,
);
// sort the import details
namedImports.sort((a, b) =>
(a.alias ?? a.name).localeCompare(b.alias ?? b.name),
);

// write the merged import details to the spec description
const importClauses = [];
if (defaultImport) importClauses.push(defaultImport);
if (namedImports.length > 0) {
const makeMultiLine = namedImports.length > 1;
const typeString = namedImports
.map((t) => {
const val = t.alias ? `${t.name} as ${t.alias}` : `${t.name}`;
return makeMultiLine ? ` ${val}` : val;
})
.join(",\n");
const namedImportClause = makeMultiLine
? `{\n${typeString}\n}`
: `{${typeString}}`;
importClauses.push(namedImportClause);
}
firstImpSpec.description = `${importClauses.join(", ")} from "${src}"`;
importSourceByDescription[firstImpSpec.description] = src;
});

tags = tagGroups.flatMap((tagGroup, index, array) => {
// sort tags within groups
tagGroup.sort((a, b) => {
if (
index > 0 &&
tagGroup[0]?.tag &&
!TAGS_GROUP_HEAD.includes(tagGroup[0].tag)
paramsOrder &&
paramsOrder.length > 1 &&
a.tag === PARAM &&
b.tag === PARAM
) {
shouldSortAgain = true;
const aIndex = paramsOrder.indexOf(a.name);
const bIndex = paramsOrder.indexOf(b.name);
if (aIndex > -1 && bIndex > -1) {
//sort params
return aIndex - bIndex;
}
return 0;
}

if (a.tag === IMPORT && b.tag === IMPORT) {
const aSrc = importSourceByDescription[a.description] ?? a.description;
const bSrc = importSourceByDescription[b.description] ?? a.description;
return aSrc.localeCompare(bSrc);
}

return tagGroup;
return (
getTagOrderWeight(a.tag, options) - getTagOrderWeight(b.tag, options)
);
});

// add an empty line between groups
if (array.length - 1 !== index) {
tagGroup.push(SPACE_TAG_DATA);
}

if (
index > 0 &&
tagGroup[0]?.tag &&
!TAGS_GROUP_HEAD.includes(tagGroup[0].tag)
) {
shouldSortAgain = true;
}

return tagGroup;
});

return shouldSortAgain ? sortTags(tags, paramsOrder, options) : tags;
}

Expand Down Expand Up @@ -615,3 +681,44 @@ function assignOptionalAndDefaultToName({
default: default_,
};
}

type ImportDetails = {
/** the spec associated with this import tag */
spec: Spec;
/** the source of the module that types were imported from */
src: string;
defaultImport?: string;
/** the types that were imported */
namedImports: {
name: string;
/** the alias assigned to the type (EX: alias of "B as B0" is "B0") */
alias?: string;
}[];
};

/**
* Extracts the defaultImports, namedImports, and src associated with a given import tag.
*/
function getImportDetails(spec: Spec): ImportDetails | null {
// step 1: capture the default import, named import clause, and src
const match = spec.description.match(
/([^\s\\,\\{\\}]+)?(?:[^\\{\\}]*)\{?([^\\{\\}]*)?\}?(?:\s+from\s+)[\\'\\"](\S+)[\\'\\"]/s,
);
if (!match) return null;

const defaultImport = match[1] || "";
const namedImportsClause = match[2] || "";
const src = match[3] || "";

// step 2: get all named imports from the named import section
const typeMatches = namedImportsClause.matchAll(
/([^\s\\,\\{\\}]+)(?:\s+as\s+)?([^\s\\,\\{\\}]+)?/g,
);

const namedImports = [];
for (const typeMatch of typeMatches) {
namedImports.push({ name: typeMatch[1], alias: typeMatch[2] });
}

return { spec, src, namedImports, defaultImport };
}
6 changes: 6 additions & 0 deletions src/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
FLOW,
FUNCTION,
IGNORE,
IMPORT,
LICENSE,
MEMBER,
MEMBEROF,
Expand Down Expand Up @@ -84,6 +85,7 @@ const TAGS_NAMELESS = [
EXAMPLE,
EXTENDS,
LICENSE,
IMPORT,
MODULE,
NAMESPACE,
OVERLOAD,
Expand All @@ -106,6 +108,7 @@ const TAGS_TYPELESS = [
DESCRIPTION,
EXAMPLE,
IGNORE,
IMPORT,
LICENSE,
MODULE,
NAMESPACE,
Expand All @@ -122,6 +125,7 @@ const TAGS_PEV_FORMATE_DESCRIPTION = [
/** @todo should be formate like jsdoc standard saw https://jsdoc.app/tags-borrows.html */
BORROWS,
...TAGS_DEFAULT,
IMPORT,
MEMBEROF,
MODULE,
SEE,
Expand All @@ -132,6 +136,7 @@ const TAGS_DESCRIPTION_NEEDED = [
CATEGORY,
DESCRIPTION,
EXAMPLE,
IMPORT,
PRIVATE_REMARKS,
REMARKS,
SINCE,
Expand Down Expand Up @@ -174,6 +179,7 @@ const TAGS_GROUP_CONDITION = [
];

const TAGS_ORDER = {
[IMPORT]: 0,
[REMARKS]: 1,
[PRIVATE_REMARKS]: 2,
[PROVIDES_MODULE]: 3,
Expand Down
2 changes: 1 addition & 1 deletion src/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const stringify = async (
if (useTagTitle) tagString += gap + " ".repeat(descGapAdj);
if (
TAGS_PEV_FORMATE_DESCRIPTION.includes(tag) ||
!TAGS_ORDER[tag as keyof typeof TAGS_ORDER]
TAGS_ORDER[tag as keyof typeof TAGS_ORDER] === undefined
) {
// Avoid wrapping
descriptionString = description;
Expand Down
2 changes: 2 additions & 0 deletions src/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const FIRES = "fires";
const FLOW = "flow";
const FUNCTION = "function";
const IGNORE = "ignore";
const IMPORT = "import";
const LICENSE = "license";
const MEMBER = "member";
const MEMBEROF = "memberof";
Expand Down Expand Up @@ -79,6 +80,7 @@ export {
FLOW,
FUNCTION,
IGNORE,
IMPORT,
LICENSE,
MEMBER,
MEMBEROF,
Expand Down
18 changes: 18 additions & 0 deletions tests/__snapshots__/typeScript.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,21 @@ exports[`max width challenge 1`] = `
}
"
`;

exports[`type imports 1`] = `
"/**
* @import {A} from "modulea"
* @import BMain, {
* B as B1,
* B2,
* B3,
* B4
* } from "moduleb"
* @typedef {Object} Foo
*/
/**
* @import BDefault, {B5} from "moduleb"
* @import C from "modulec"
*/
"
`;
26 changes: 23 additions & 3 deletions tests/typeScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ test("hoisted object", async () => {
}
} User
*/

`);

expect(result).toMatchSnapshot();
Expand Down Expand Up @@ -122,10 +122,10 @@ class test {
* @returns {StarkStringType & NativeString}
*/
testFunction(){

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this spaces need to be changed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, just an auto formatting thing from my editor. Tests are passing with the change, so figured it was ok to keep

@Didericis Didericis Oct 15, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added them back just in case there was any deliberate whitespace cleaning being tested 👍

}
}

this._value = this._value.replace(searchValue, replaceValue);
return this;
}
Expand Down Expand Up @@ -190,3 +190,23 @@ test("Long type Union types", async () => {

expect(result).toMatchSnapshot();
});

test("type imports", async () => {
const result = await subject(
`
/**
* @import BM, { B as B1,
* B2 , B4 } from 'moduleb'
* @typedef {Object} Foo
* @import BMain, {B3 } from "moduleb"
* @import {A} from 'modulea'
*/
/**
* @import BDefault, { B5 } from 'moduleb'
* @import C from "modulec"
*/
`,
);

expect(result).toMatchSnapshot();
});