From ea4f01aac21a3b86cd432dff9159ceb5362875b1 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Fri, 21 Aug 2026 12:43:39 -0700 Subject: [PATCH] chore: support import --- README.md | 15 ++++++++++++- format-file-header/index.js | 43 +++++++++++++++++++++++++++++++----- generate-types/index.js | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9281777..5d9ee32 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,23 @@ Ensures that the starting of all source files follows the following convention: const Import = require("./Import"); const SortedAlphabetically = require("./SortedAlphabetically"); -/** @typedef {import("../TypeImport")} TypeImport */ +/** @import TypeImport from "../TypeImport" */ /** @typedef {import("../SortedAlphabetically")} SortedAlphabetically */ ``` +Type imports may use either the `@import` tag or the legacy `@typedef {import("...")}` form. +Both are sorted together by the module they refer to. +`@import` tags may also be wrapped over multiple lines: + +```js +/** + * @import { + * LongTypeName, + * AnotherLongTypeName + * } from "../TypeImport" + */ +``` + ```text --source ./lib/**/*.js ``` diff --git a/format-file-header/index.js b/format-file-header/index.js index 19f99a9..5691008 100644 --- a/format-file-header/index.js +++ b/format-file-header/index.js @@ -18,19 +18,52 @@ const sortImport = (a, b) => { return 0; }; -const execToArray = (content, regexp) => { +const defaultKey = (match) => match[1] + match[2]; + +const execToArray = (content, regexp, getKey = defaultKey) => { const items = []; let match = regexp.exec(content); while (match) { items.push({ content: match[0], - key: match[1] + match[2], + key: getKey(match), }); match = regexp.exec(content); } return items; }; +// Anything that can appear inside a jsdoc comment, i. e. everything up to the +// closing `*/`. Used to match both single line and multi line jsdoc comments. +const JSDOC_CONTENT = String.raw`(?:[^*]|\*(?!\/))*?`; + +// Optional type arguments like `` or `` +const TYPE_ARGUMENTS = String.raw`(?:<(?:(?:\w\.)*\w+, )*(?:\w\.)*\w+>)?`; + +// Legacy type import: +// /** @typedef {import("./Module")} Module */ +// /** @template T @typedef {import("./Module").Item} Item */ +const TYPEDEF_TYPE_IMPORT = String.raw`\/\*\* (?:@template \w+ )*@typedef \{(?:typeof )?import\("(?[^"]+)"\)(?(?:\.\w+)*${TYPE_ARGUMENTS})\} \w+${TYPE_ARGUMENTS} \*\/\n`; + +// `@import` type import, single line or wrapped over multiple lines: +// /** @import Module from "./Module" */ +// /** @import { Item, Other as Alias } from "./Module" */ +// /** @import Module, { Item } from "./Module" */ +// /** +// * @import { +// * Item, +// * Other +// * } from "./Module" +// */ +const IMPORT_TYPE_IMPORT = String.raw`\/\*\*${JSDOC_CONTENT}@import\b${JSDOC_CONTENT}from "(?[^"]+)"\s*\*\/\n`; + +const TYPE_IMPORT = `(?:${TYPEDEF_TYPE_IMPORT}|${IMPORT_TYPE_IMPORT})`; + +const typeImportKey = ({ groups }) => + groups.importFrom === undefined + ? groups.typedefFrom + groups.typedefMember + : groups.importFrom; + /** * @typedef {Object} Schema * @property {string} title @@ -103,13 +136,13 @@ const schema = [ }, { title: "type imports", - regexp: - /(\/\*\* (?:@template \w+ )*@typedef \{(?:typeof )?import\("[^"]+"\)(\.\w+)*(?:<(?:(?:\w\.)*\w+, )*(?:\w\.)*\w+>)?\} \w+(?:<(?:(?:\w\.)*\w+, )*(?:\w\.)*\w+>)? \*\/\n)+\n/g, + regexp: new RegExp(`(?:${TYPE_IMPORT})+\\n`, "g"), updateMessage: "sort type imports alphabetically", update(content) { const items = execToArray( content, - /\/\*\* (?:@template \w+ )*@typedef \{(?:typeof )?import\("([^"]+)"\)((?:\.\w+)*(?:<(?:(?:\w\.)*\w+, )*(?:\w\.)*\w+>)?)\} \w+(?:<(?:(?:\w\.)*\w+, )*(?:\w\.)*\w+>)? \*\/\n/g, + new RegExp(TYPE_IMPORT, "g"), + typeImportKey, ); items.sort(sortImport); return items.map((item) => item.content).join("") + "\n"; diff --git a/generate-types/index.js b/generate-types/index.js index 1eecc4b..21a4583 100644 --- a/generate-types/index.js +++ b/generate-types/index.js @@ -15,6 +15,7 @@ const prettier = require("prettier"); process.exitCode = 1; let exitCode = 0; +let hasUnresolvableTypes = false; const AnonymousType = "__Type"; @@ -364,8 +365,26 @@ const printError = (diagnostic) => { return name; }; + /** + * @param {ts.Symbol} symbol symbol + * @returns {string} location of the symbol for error messages + */ + const getSymbolLocation = (symbol) => { + const decls = symbol.getDeclarations(); + const decl = decls && decls[0]; + if (!decl) return ""; + const source = decl.getSourceFile(); + const { line, character } = ts.getLineAndCharacterOfPosition( + source, + decl.getStart(), + ); + return `${source.fileName} (${line + 1},${character + 1})`; + }; + const getTypeOfSymbol = (symbol, isValue) => { let decl; + /** @type {ts.Type | undefined} */ + let errorType; const type = (() => { let type; if (!isValue) { @@ -373,6 +392,7 @@ const printError = (diagnostic) => { if (type && type.intrinsicName !== "error") { return type; } + errorType = errorType || type; } if (symbol.type) return symbol.type; const decls = symbol.getDeclarations(); @@ -382,12 +402,26 @@ const printError = (diagnostic) => { if (type && type.intrinsicName !== "error") { return type; } + errorType = errorType || type; type = checker.getTypeAtLocation(decl); if (type && type.intrinsicName !== "error") { return type; } + errorType = errorType || type; } })(); + if (!type) { + // Without a type there is nothing to generate, but reporting all + // unresolvable symbols at once is a lot more useful than failing on the + // first one, so continue with the error type and bail out before writing. + hasUnresolvableTypes = true; + console.error( + `${getSymbolLocation(symbol)}: Unable to resolve the type of "${ + symbol.name + }".`, + ); + return errorType || checker.getDeclaredTypeOfSymbol(symbol); + } if (type && decl) { // Learn about type nodes if ( @@ -2668,6 +2702,16 @@ const printError = (diagnostic) => { fn(); } + if (hasUnresolvableTypes) { + console.error( + "Some types can't be resolved, so no declarations are generated.", + ); + console.error( + "Note that a type imported with `@import` is only a local alias, it's not re-exported from the module like a `@typedef` is.", + ); + return; + } + const outputFilePath = path.resolve(root, outputFile); const sortedDeclarations = [...declarations].sort((a, b) => {