diff --git a/packages/compiler/src/frontend/npm-static-type-exports-host.test.ts b/packages/compiler/src/frontend/npm-static-type-exports-host.test.ts new file mode 100644 index 000000000..e37283fe6 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports-host.test.ts @@ -0,0 +1,59 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; +import { npmStaticTypeExportPlan } from "./npm-static-type-exports-host.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +/** A program directory with one package whose .d.ts is `declarations`; + * returns the program's entry path. */ +function fixture(declarations: string): string { + const dir = mkdtempSync(join(tmpdir(), "scriptc-type-exports-")); + dirs.push(dir); + const lib = join(dir, "node_modules", "pkg", "lib"); + mkdirSync(lib, { recursive: true }); + writeFileSync( + join(dir, "node_modules", "pkg", "package.json"), + JSON.stringify({ name: "pkg", main: "./lib/index.js", types: "./lib/index.d.ts" }), + ); + writeFileSync(join(lib, "index.d.ts"), declarations); + writeFileSync(join(lib, "index.js"), "exports.grow = function grow(into) {};\n"); + writeFileSync(join(dir, "main.ts"), 'import { grow } from "pkg";\n'); + return join(dir, "main.ts"); +} + +/** The type names the runtime entry's typedefs re-export. */ +function reexported(entry: string): string[] { + const plan = npmStaticTypeExportPlan(nodeFrontendServices(), entry, ["pkg"]); + const block = [...plan.typedefs.values()].join("\n"); + return [...block.matchAll(/@typedef \{[^}]*\} (\w+)/g)].map((m) => m[1]!); +} + +test("only data shapes are re-exported", () => { + const entry = fixture(`export interface Point { x: number; y: number } +export interface Handle { close(): void } +export type Label = { text: string; at: Point }; +export type Callback = (point: Point) => void; +export interface Holder { handle: Handle } +export declare function grow(into: Point[]): void; +`); + expect(reexported(entry)).toEqual(["Point", "Label"]); +}); + +test("mutually recursive types are data shapes only when every type they reach is", () => { + // Branch is checked first and reaches Tree, which refers back to Branch: + // Tree is a data shape only if Branch is, and Branch holds a Handle. + const entry = fixture(`export interface Branch { child: Tree; handle: Handle } +export interface Tree { name: string; parent: Branch | null } +export interface Handle { close(): void } +export interface Node { value: number; next: List } +export interface List { head: Node | null } +export declare function grow(into: Tree[]): void; +`); + expect(reexported(entry)).toEqual(["Node", "List"]); +}); diff --git a/packages/compiler/src/frontend/npm-static-type-exports-host.ts b/packages/compiler/src/frontend/npm-static-type-exports-host.ts new file mode 100644 index 000000000..8a61ed62b --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports-host.ts @@ -0,0 +1,238 @@ +/** Filesystem side of npm-static-type-exports.ts: finds each opted-in + * package's declaration entry and runtime entry, copies the declaration + * files the entry reaches, and builds the runtime entry's typedefs. */ +import { dirname, relative, resolve as resolvePath } from "node:path"; +import { trackedFileExists, trackedReadFile } from "./input-tracker.js"; +import { resolveBareModule } from "./resolve.js"; +import { isJsSourceFileName } from "./tsc-codes.js"; +import { NPM_STATIC_TYPES_SUFFIX } from "./npm-static.js"; +import type { FrontendServices } from "./services.js"; +import * as ts from "./ts7/syntax.js"; +import { + npmStaticDeclarationCopy, + npmStaticDeclarationExports, + npmStaticDeclarationSpecifiers, + npmStaticTypedefBlock, + type NpmStaticDeclarationExports, + type NpmStaticTypeDeclaration, +} from "./npm-static-type-exports.js"; + +export interface NpmStaticTypeExportPlan { + /** Copies of the declaration files, by copy path. */ + copies: Map; + /** The typedef block to append, by runtime entry (forward slashes). */ + typedefs: Map; +} + +/** The copy path of a declaration file. */ +function copyPathOf(declarationFile: string): string { + return declarationFile.replace(/\.d\.ts$/, NPM_STATIC_TYPES_SUFFIX); +} + +/** The declaration file a relative specifier names from `fromFile`, or null. */ +function declarationTargetOf(fromFile: string, specifier: string): string | null { + const base = resolvePath(dirname(fromFile), specifier); + const candidates = [ + `${base}.d.ts`, + `${base.replace(/\.(c|m)?js$/, "")}.d.ts`, + resolvePath(base, "index.d.ts"), + ]; + for (const candidate of candidates) if (trackedFileExists(candidate)) return candidate; + return null; +} + +/** A specifier for `target` from `fromFile`'s directory, without `.d.ts`. */ +function specifierFor(fromFile: string, target: string): string { + const rel = relative(dirname(fromFile), target) + .split("\\") + .join("/") + .replace(/\.d\.ts$/, ""); + return rel.startsWith(".") ? rel : `./${rel}`; +} + +/** The names the runtime JavaScript declares as types itself: classes, + * JSDoc typedefs and callbacks, and `@enum` constants. */ +function runtimeTypeNames(services: FrontendServices, file: string, text: string): Set { + const names = new Set(); + for (const m of text.matchAll(/@(?:typedef|callback)\s+(?:\{[^}]*\}\s*)?([A-Za-z_$][\w$]*)/g)) + names.add(m[1]!); + // `/** @enum {number} */ const Kind = { ... }`: the constant names a type too. + for (const m of text.matchAll( + /@enum\b[^\n]*\n(?:\s*\*[^\n]*\n)*?\s*(?:\*\/\s*)?(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g, + )) + names.add(m[1]!); + const sf = services.parse(file, text, "js"); + for (const stmt of sf.statements.nodes) + if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) names.add(stmt.name.text); + return names; +} + +export function npmStaticTypeExportPlan( + services: FrontendServices, + entryPath: string, + packages: readonly string[], +): NpmStaticTypeExportPlan { + const copies = new Map(); + const typedefs = new Map(); + const parsed = new Map(); + const parse = (file: string): ts.SourceFile | null => { + let sf = parsed.get(file); + if (sf === undefined) { + const text = trackedReadFile(file); + sf = text === null ? null : services.parse(file, text, "ts"); + parsed.set(file, sf); + } + return sf; + }; + // Copies every declaration file reachable from `file` through relative + // specifiers, each specifier respelled to its target's copy. + const copy = (file: string): void => { + const copyPath = copyPathOf(file); + if (copies.has(copyPath)) return; + const sf = parse(file); + if (sf === null) return; + copies.set(copyPath, ""); + const targets = new Map(); + for (const specifier of npmStaticDeclarationSpecifiers(sf)) { + const target = declarationTargetOf(file, specifier); + if (target !== null) targets.set(specifier, target); + } + copies.set( + copyPath, + npmStaticDeclarationCopy(sf, (specifier) => { + const target = targets.get(specifier); + return target === undefined ? null : specifierFor(copyPath, copyPathOf(target)); + }), + ); + for (const target of targets.values()) copy(target); + }; + const exportsOf = new Map(); + const declarationExports = (file: string): NpmStaticDeclarationExports | null => { + let ex = exportsOf.get(file); + if (ex === undefined) { + const sf = parse(file); + ex = sf === null ? null : npmStaticDeclarationExports(sf); + exportsOf.set(file, ex); + } + return ex; + }; + // The exported type-only declarations of `file`, through its re-exports, + // each with the file that declares it. + type Found = { decl: NpmStaticTypeDeclaration; file: string }; + const typesOf = ( + file: string, + seen: Set, + ): { types: Map; values: Set } => { + const result = { types: new Map(), values: new Set() }; + if (seen.has(file)) return result; + seen.add(file); + const own = declarationExports(file); + if (own === null) return result; + for (const t of own.types) result.types.set(t.name, { decl: t, file }); + for (const v of own.values) result.values.add(v); + for (const n of own.named) { + const target = n.specifier === null ? null : declarationTargetOf(file, n.specifier); + if (target === null) continue; + const inner = typesOf(target, new Set(seen)); + const t = inner.types.get(n.local); + if (t !== undefined) + result.types.set(n.exported, { ...t, decl: { ...t.decl, name: n.exported } }); + else if (inner.values.has(n.local)) result.values.add(n.exported); + } + for (const specifier of own.stars) { + const target = declarationTargetOf(file, specifier); + if (target === null) continue; + const inner = typesOf(target, new Set(seen)); + for (const [name, t] of inner.types) if (!result.types.has(name)) result.types.set(name, t); + for (const v of inner.values) result.values.add(v); + } + return result; + }; + // Whether `name`, as `file` sees it, is a data shape: an interface, type + // alias or enum whose syntax describes no behavior (npm-static-type- + // exports.ts) and whose referenced types are data shapes too. A name the + // package's declaration files do not declare is a global (`Array`, + // `Record`, `Date`); a class, a value or an import from another package + // is not a data shape. + // + // `referencesOf` answers one name: null when it is not a data shape by + // itself, otherwise the names it refers to. + type Ref = { file: string; name: string }; + const referencesOf = (file: string, name: string): Ref[] | null => { + const ex = declarationExports(file); + if (ex === null) return null; + const local = ex.locals.get(name); + if (local !== undefined) + return local.behavior ? null : local.references.map((r) => ({ file, name: r })); + if (ex.valueNames.has(name)) return null; + const from = ex.imports.get(name); + if (from !== undefined) { + const target = declarationTargetOf(file, from.specifier); + return target === null ? null : [{ file: target, name: from.local }]; + } + const reexport = typesOf(file, new Set()).types.get(name); + if (reexport !== undefined) return [{ file: reexport.file, name: reexport.decl.declared }]; + return []; + }; + // A name is a data shape when every name reachable from it is one by + // itself. `isData` walks everything reachable before it records any name + // as a data shape, so a type in a reference cycle is not recorded while + // another type in the cycle is still unchecked. + const dataMemo = new Map(); + const isData = (file: string, name: string): boolean => { + const keyOf = (ref: Ref): string => `${ref.file}\0${ref.name}`; + // Each reached name, by key, with the key of the name it was reached from. + const reachedFrom = new Map([[keyOf({ file, name }), null]]); + const queue: Ref[] = [{ file, name }]; + for (let i = 0; i < queue.length; i++) { + const ref = queue[i]!; + const key = keyOf(ref); + const memo = dataMemo.get(key); + if (memo === true) continue; + const next = memo === false ? null : referencesOf(ref.file, ref.name); + if (next === null) { + // Every name on the path to this one reaches a non-data type. + for (let k: string | null = key; k !== null; k = reachedFrom.get(k) ?? null) + dataMemo.set(k, false); + return false; + } + for (const target of next) { + const targetKey = keyOf(target); + if (reachedFrom.has(targetKey)) continue; + reachedFrom.set(targetKey, key); + queue.push(target); + } + } + for (const key of reachedFrom.keys()) dataMemo.set(key, true); + return true; + }; + for (const pkg of packages) { + const declared = resolveBareModule(entryPath, pkg, "types-only"); + if (declared === null || declared.packageName !== pkg || !declared.typesFile.endsWith(".d.ts")) + continue; + const runtime = resolveBareModule(entryPath, pkg, "js-only"); + if (runtime === null || !isJsSourceFileName(runtime.typesFile)) continue; + const runtimeText = trackedReadFile(runtime.typesFile); + if (runtimeText === null) continue; + const { types, values } = typesOf(declared.typesFile, new Set()); + const declaredByRuntime = runtimeTypeNames(services, runtime.typesFile, runtimeText); + // Only data shapes: a type with methods describes the package's + // JavaScript objects, which stay as the JavaScript types them. + const exported = [...types.values()] + .filter( + ({ decl, file }) => + !values.has(decl.name) && + !declaredByRuntime.has(decl.name) && + isData(file, decl.declared), + ) + .map(({ decl }) => decl); + if (exported.length === 0) continue; + copy(declared.typesFile); + const specifier = specifierFor(runtime.typesFile, copyPathOf(declared.typesFile)); + typedefs.set( + runtime.typesFile.split("\\").join("/"), + npmStaticTypedefBlock(exported, specifier), + ); + } + return { copies, typedefs }; +} diff --git a/packages/compiler/src/frontend/npm-static-type-exports.test.ts b/packages/compiler/src/frontend/npm-static-type-exports.test.ts new file mode 100644 index 000000000..89adf5bbc --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; +import { + npmStaticDeclarationCopy, + npmStaticDeclarationExports, + npmStaticTypedefBlock, +} from "./npm-static-type-exports.js"; + +function parse(text: string) { + return nodeFrontendServices().parse("/pkg/lib/index.d.ts", text, "ts"); +} + +test("type-only exports are told from values and re-exports", () => { + const exports = npmStaticDeclarationExports( + parse(`import type { Page } from "./paging"; +export * from "./paging"; +export { Entry as Item, Kind } from "./entries"; +export interface Options { value: T; label: U } +export type Mode = "a" | "b"; +export declare const enum Level { Low = 1 } +export declare class Client { url: string } +export declare function make(options: Options): Client; +export declare const version: string; +interface Hidden { x: number } +export { Hidden as Visible }; +declare function helper(): void; +export { helper };`), + ); + expect(exports.types.map((t) => [t.name, t.params, t.templates])).toEqual([ + ["Options", ["T", "U"], ["{object} [T={}]", "[U=string]"]], + ["Mode", [], []], + ["Level", [], []], + ["Visible", [], []], + ]); + expect(exports.values).toEqual(["Client", "make", "version", "helper"]); + expect(exports.named).toEqual([ + { exported: "Item", local: "Entry", specifier: "./entries" }, + { exported: "Kind", local: "Kind", specifier: "./entries" }, + ]); + expect(exports.stars).toEqual(["./paging"]); +}); + +test("a local export of an imported name resolves like a re-export", () => { + const exports = npmStaticDeclarationExports( + parse(`import type { Kind } from "./kinds"; +import { type Shape as Form, helper } from "./kinds"; +export { Kind, Form as Outline, helper };`), + ); + expect(exports.types).toEqual([]); + expect(exports.named).toEqual([ + { exported: "Kind", local: "Kind", specifier: "./kinds" }, + { exported: "Outline", local: "Shape", specifier: "./kinds" }, + { exported: "helper", local: "helper", specifier: "./kinds" }, + ]); +}); + +test("each declaration records whether it describes behavior and what it references", () => { + const { locals } = npmStaticDeclarationExports( + parse(`interface Row { key: string; value: T; next?: Row; tags: Tag[] } +type Tag = "a" | "b"; +interface Counter { add(value: number): void } +type Callback = (row: Row) => void; +type Keys = keyof Row; +interface Named extends Base { label: Label }`), + ); + const shape = (name: string) => { + const d = locals.get(name)!; + return [d.behavior, [...d.references].sort()]; + }; + expect(shape("Row")).toEqual([false, ["Row", "Tag"]]); + expect(shape("Tag")).toEqual([false, []]); + expect(shape("Counter")).toEqual([true, []]); + expect(shape("Callback")[0]).toBe(true); + expect(shape("Keys")[0]).toBe(true); + expect(shape("Named")).toEqual([false, ["Base", "Label"]]); +}); + +test("a copy respells the relative specifiers it is told", () => { + const sf = parse(`import type { Page } from "./paging"; +export * from './entries'; +export type Box = import("./paging").Page; +export { readFile } from "fs"; +`); + const copy = npmStaticDeclarationCopy(sf, (s) => + s === "./paging" ? "./paging.scriptc-types" : null, + ); + expect(copy).toBe(`import type { Page } from "./paging.scriptc-types"; +export * from './entries'; +export type Box = import("./paging.scriptc-types").Page; +export { readFile } from "fs"; +`); +}); + +test("the typedef block re-exports each type with its parameters", () => { + expect( + npmStaticTypedefBlock( + [ + { + name: "Mode", + declared: "Mode", + params: [], + templates: [], + behavior: false, + references: [], + }, + { + name: "Options", + declared: "Options", + params: ["T", "U"], + templates: ["{object} [T={}]", "[U=string]"], + behavior: false, + references: [], + }, + ], + "./index.scriptc-types", + ), + ).toBe(`/** + * @typedef {import("./index.scriptc-types").Mode} Mode + */ +/** + * @template {object} [T={}] + * @template [U=string] + * @typedef {import("./index.scriptc-types").Options} Options + */`); +}); diff --git a/packages/compiler/src/frontend/npm-static-type-exports.ts b/packages/compiler/src/frontend/npm-static-type-exports.ts new file mode 100644 index 000000000..3b74b8aef --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-type-exports.ts @@ -0,0 +1,287 @@ +/* The type-only exports of an opted-in package's declaration files. + * + * An opted-in package compiles from its JavaScript, and the checker types + * the package from it: the package's shipped .d.ts is hidden from + * resolution (npmStaticFsShadow). Values keep their JavaScript types, but a + * type that exists only in the .d.ts (an interface, a type alias, an enum + * used as a type) has no declaration in the JavaScript, so a program's + * `import type { Options } from "pkg"` resolved to `any`. TypeScript and + * Node see the .d.ts types. + * + * The package's declaration files stay available as copies under another + * name (`.scriptc-types.d.ts`, relative specifiers respelled to the + * copies), and the runtime entry gains a JSDoc typedef per type-only export + * that names the declaration in the copy: + * + * / ** @template T @typedef {import("./main.scriptc-types").Box} Box * / + * + * Classes and values keep the JavaScript's own declarations. */ +import * as ts from "./ts7/syntax.js"; +import { tokenStartOfNode } from "./ts7/node-functions.js"; + +/** A type-only declaration: its exported name, its name where it is + * declared, its type parameters' names and their JSDoc @template tag + * bodies, and what npmStaticDataShape needs to know. */ +export interface NpmStaticTypeDeclaration { + name: string; + declared: string; + params: readonly string[]; + templates: readonly string[]; + /** The declaration's own syntax describes behavior or a computed type: + * a method, call or construct signature, a function or constructor + * type, an accessor, `typeof`, `import("...")`, a conditional, mapped, + * indexed-access or `keyof` type, or a qualified type name. */ + behavior: boolean; + /** The type names it refers to, its own type parameters excepted. */ + references: readonly string[]; +} + +/** What a declaration file declares and re-exports, by exported name. */ +export interface NpmStaticDeclarationExports { + /** Exported interfaces, type aliases and enums, with their parameters. */ + types: readonly NpmStaticTypeDeclaration[]; + /** Exported classes, functions, variables and namespaces: names the + * JavaScript declares itself, or that are not type-only. */ + values: readonly string[]; + /** `export { local as exported } from "specifier"`, including a local + * `export { a }` of an imported name. */ + named: readonly { exported: string; local: string; specifier: string | null }[]; + /** `export * from "specifier"`. */ + stars: readonly string[]; + /** Every interface, type alias and enum the file declares, by name. */ + locals: ReadonlyMap; + /** The file's named imports: local name to imported name and specifier. */ + imports: ReadonlyMap; + /** Every class, function, namespace and variable the file declares. */ + valueNames: ReadonlySet; +} + +function isExported(node: ts.Node): boolean { + const modifiers = (node as { modifiers?: { nodes: readonly ts.Node[] } }).modifiers?.nodes ?? []; + return modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); +} + +function templatesOf(sf: ts.SourceFile, params: readonly ts.TypeParameterDeclaration[]): string[] { + const text = (n: ts.Node): string => sf.text.slice(tokenStartOfNode(n), n.end); + return params.map((p) => { + const name = p.name.text; + const constraint = p.constraint === undefined ? "" : `{${text(p.constraint)}} `; + return p.defaultType === undefined + ? `${constraint}${name}` + : `${constraint}[${name}=${text(p.defaultType)}]`; + }); +} + +/** Syntax kinds of a type that describes behavior or is computed. */ +const BEHAVIOR_KINDS: ReadonlySet = new Set([ + ts.SyntaxKind.MethodSignature, + ts.SyntaxKind.CallSignature, + ts.SyntaxKind.ConstructSignature, + ts.SyntaxKind.FunctionType, + ts.SyntaxKind.ConstructorType, + ts.SyntaxKind.GetAccessor, + ts.SyntaxKind.SetAccessor, + ts.SyntaxKind.TypeQuery, + ts.SyntaxKind.ImportType, + ts.SyntaxKind.ConditionalType, + ts.SyntaxKind.MappedType, + ts.SyntaxKind.InferType, + ts.SyntaxKind.IndexedAccessType, + ts.SyntaxKind.ThisType, + ts.SyntaxKind.TypePredicate, + ts.SyntaxKind.TemplateLiteralType, +]); + +/** The behavior flag and type references of an interface or type alias. */ +function shapeOf( + decl: ts.InterfaceDeclaration | ts.TypeAliasDeclaration, + params: readonly string[], +): { behavior: boolean; references: string[] } { + let behavior = false; + const references = new Set(); + const refer = (name: ts.Node): void => { + if (ts.isIdentifier(name)) { + if (!params.includes(name.text)) references.add(name.text); + } else behavior = true; // a qualified name: a namespace member + }; + ts.walkPreorder(decl, (n) => { + if (behavior) return "stop"; + if (BEHAVIOR_KINDS.has(n.kind)) behavior = true; + else if (ts.isTypeOperatorNode(n) && n.operator === ts.SyntaxKind.KeyOfKeyword) behavior = true; + else if (ts.isTypeReferenceNode(n)) refer(n.typeName); + else if (ts.isExpressionWithTypeArguments(n)) refer(n.expression); + return undefined; + }); + return { behavior, references: [...references] }; +} + +/** The exports of one declaration file. */ +export function npmStaticDeclarationExports(sf: ts.SourceFile): NpmStaticDeclarationExports { + const types: NpmStaticTypeDeclaration[] = []; + const values: string[] = []; + const named: { exported: string; local: string; specifier: string | null }[] = []; + const stars: string[] = []; + const localTypes = new Map(); + const localValues = new Set(); + // `import { A as B } from "./x"`: local name B is A of "./x". + const imported = new Map(); + for (const stmt of sf.statements.nodes) { + const exported = isExported(stmt); + if (ts.isImportDeclaration(stmt)) { + const bindings = stmt.importClause?.namedBindings; + if (ts.isStringLiteral(stmt.moduleSpecifier) && bindings && ts.isNamedImports(bindings)) { + for (const e of bindings.elements.nodes) { + imported.set(e.name.text, { + local: (e.propertyName ?? e.name).text, + specifier: stmt.moduleSpecifier.text, + }); + } + } + } else if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) { + const params = stmt.typeParameters?.nodes ?? []; + const paramNames = params.map((p) => p.name.text); + const shape = shapeOf(stmt, paramNames); + const merged = localTypes.get(stmt.name.text); // interface merging + const decl: NpmStaticTypeDeclaration = { + name: stmt.name.text, + declared: stmt.name.text, + params: paramNames, + templates: templatesOf(sf, params), + behavior: shape.behavior || merged?.behavior === true, + references: [...shape.references, ...(merged?.references ?? [])], + }; + localTypes.set(decl.name, decl); + if (exported && merged === undefined) types.push(decl); + } else if (ts.isEnumDeclaration(stmt)) { + const decl: NpmStaticTypeDeclaration = { + name: stmt.name.text, + declared: stmt.name.text, + params: [], + templates: [], + behavior: false, + references: [], + }; + localTypes.set(decl.name, decl); + if (exported) types.push(decl); + } else if ( + ts.isClassDeclaration(stmt) || + ts.isFunctionDeclaration(stmt) || + ts.isModuleDeclaration(stmt) + ) { + const name = stmt.name !== undefined && ts.isIdentifier(stmt.name) ? stmt.name.text : null; + if (name !== null) { + localValues.add(name); + if (exported) values.push(name); + } + } else if (ts.isVariableStatement(stmt)) { + for (const d of stmt.declarationList.declarations.nodes) { + if (!ts.isIdentifier(d.name)) continue; + localValues.add(d.name.text); + if (exported) values.push(d.name.text); + } + } else if (ts.isExportDeclaration(stmt)) { + const specifier = + stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier) + ? stmt.moduleSpecifier.text + : null; + const clause = stmt.exportClause; + if (clause === undefined) { + if (specifier !== null) stars.push(specifier); + } else if (ts.isNamedExports(clause)) { + for (const e of clause.elements.nodes) { + const exportedName = e.name.text; + const local = (e.propertyName ?? e.name).text; + named.push({ exported: exportedName, local, specifier }); + } + } + } + } + // Local `export { a as b }`: of the file's own declarations, or of a + // name it imports (`import type { A } from "./a"; export { A }`), which + // resolves like `export { A } from "./a"`. + const resolvedNamed: typeof named = []; + for (const n of named) { + if (n.specifier !== null) { + resolvedNamed.push(n); + continue; + } + const local = localTypes.get(n.local); + const from = imported.get(n.local); + if (local !== undefined) types.push({ ...local, name: n.exported }); + else if (localValues.has(n.local)) values.push(n.exported); + else if (from !== undefined) resolvedNamed.push({ exported: n.exported, ...from }); + } + // A merged interface's exported entry takes the merged flags. + const exportedTypes: NpmStaticTypeDeclaration[] = []; + for (const t of types) { + const local = localTypes.get(t.declared); + if (local === undefined) exportedTypes.push(t); + else exportedTypes.push({ ...local, name: t.name }); + } + return { + types: exportedTypes, + values, + named: resolvedNamed, + stars, + locals: localTypes, + imports: imported, + valueNames: localValues, + }; +} + +/** The relative module specifiers of a declaration file's imports, exports + * and `import("...")` types, with their spans. */ +function relativeSpecifiers(sf: ts.SourceFile): { start: number; end: number; text: string }[] { + const found: { start: number; end: number; text: string }[] = []; + const add = (literal: ts.Node | undefined): void => { + if (literal === undefined || !ts.isStringLiteral(literal)) return; + if (!literal.text.startsWith("./") && !literal.text.startsWith("../")) return; + found.push({ start: tokenStartOfNode(literal), end: literal.end, text: literal.text }); + }; + ts.walkPreorder(sf, (n) => { + if (ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) add(n.moduleSpecifier); + else if (ts.isImportTypeNode(n) && ts.isLiteralTypeNode(n.argument)) add(n.argument.literal); + return undefined; + }); + return found; +} + +/** The relative specifiers a declaration file names. */ +export function npmStaticDeclarationSpecifiers(sf: ts.SourceFile): string[] { + return relativeSpecifiers(sf).map((s) => s.text); +} + +/** The declaration file's text with each relative specifier `rename` + * answers respelled (quoted with the original's quote). */ +export function npmStaticDeclarationCopy( + sf: ts.SourceFile, + rename: (specifier: string) => string | null, +): string { + const spans = relativeSpecifiers(sf).sort((a, b) => a.start - b.start); + let text = ""; + let at = 0; + for (const span of spans) { + const renamed = rename(span.text); + if (renamed === null) continue; + const quote = sf.text[span.start] ?? '"'; + text += sf.text.slice(at, span.start) + quote + renamed + quote; + at = span.end; + } + return text + sf.text.slice(at); +} + +/** The JSDoc typedefs that re-export `types` from the copy at `specifier`. */ +export function npmStaticTypedefBlock( + types: readonly NpmStaticTypeDeclaration[], + specifier: string, +): string { + const lines: string[] = []; + for (const t of types) { + const args = t.params.length === 0 ? "" : `<${t.params.join(", ")}>`; + lines.push("/**"); + for (const tag of t.templates) lines.push(` * @template ${tag}`); + lines.push(` * @typedef {import(${JSON.stringify(specifier)}).${t.name}${args}} ${t.name}`); + lines.push(" */"); + } + return lines.join("\n"); +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 56edafc48..800d02eff 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -16,6 +16,7 @@ import { } from "./workspace-registry.js"; import { clearVirtualSources, + isVirtualSource, setVirtualSource, trackedExists, trackedReadFile, @@ -53,10 +54,43 @@ export function npmStaticLexerVisibleNames(path: string): readonly string[] { return lexerNames.get(path.split("\\").join("/")) ?? []; } +/** The suffix that replaces `.d.ts` in the name of a copy of an opted-in + * package's declaration file (npm-static-type-exports.ts). Defined here so + * this module does not depend on the syntax modules. */ +export const NPM_STATIC_TYPES_SUFFIX = ".scriptc-types.d.ts"; + +/** Per runtime entry (forward slashes), the JSDoc typedefs that re-export + * its package's type-only declarations (npm-static-type-exports.ts). */ +let typeExportTypedefs = new Map(); + +/** Registers the copies of the opted-in packages' declaration files and the + * typedefs their runtime entries gain (after setNpmStaticPackages, which + * clears virtual sources). */ +export function setNpmStaticTypeExports( + copies: ReadonlyMap, + typedefs: ReadonlyMap, +): void { + for (const [path, text] of copies) setVirtualSource(path, text); + typeExportTypedefs = new Map(typedefs); + rewriteCache.clear(); +} + +/** `text` (or the file's own text when null) with the typedefs of a + * package runtime entry appended: the entry re-exports the type-only + * declarations of the package's .d.ts, after every respelling, so + * positions stay. Null when `path` is no runtime entry and `text` is null. */ +function withTypeExportTypedefs(path: string, text: string | null): string | null { + const typedefs = typeExportTypedefs.get(path.split("\\").join("/")); + if (typedefs === undefined) return text; + const source = text ?? trackedReadFile(path); + return source === null ? text : `${source}\n${typedefs}\n`; +} + export function setNpmStaticPackages>(packages: T): void { // npm packages served as builtins (graceful-fs as fs) never compile // their JavaScript, even when named. activePackages = new Set([...packages].filter((name) => npmBuiltinAliasOf(name) === null)); + typeExportTypedefs = new Map(); declarationOverloads = new Map(); declarationProperties = new Map(); offenders.clear(); @@ -524,12 +558,17 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow } catch { rewritten = null; // unreadable/unparseable: fall through untouched } + rewritten = withTypeExportTypedefs(path, rewritten); rewriteCache.set(path, rewritten); return rewritten ?? undefined; } - return undefined; + // Other runtime entries (`.mjs`) keep their text and gain only the + // typedefs. + return withTypeExportTypedefs(path, null) ?? undefined; }, hideFile: (path) => { + // The copies of the package's declaration files the typedefs name. + if (path.endsWith(NPM_STATIC_TYPES_SUFFIX) && isVirtualSource(path)) return false; const target = shadowTargetOf(path); if (!target) return false; if (isBundledShimForwarder(path)) return false; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index db43b7749..e398e59df 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -76,8 +76,10 @@ import { reportNpmStaticOffender, setNpmStaticDeclarationOverloads, setNpmStaticPackages, + setNpmStaticTypeExports, } from "./npm-static.js"; import { isPrunedNpmReexport, planNpmStaticReexports } from "./npm-static-prune.js"; +import { npmStaticTypeExportPlan } from "./npm-static-type-exports-host.js"; import { isNpmStaticSubclassArgument } from "./npm-static-subtyping.js"; import { npmStaticDeclarationReexports, @@ -1032,6 +1034,10 @@ export function loadProgram( } setNpmStaticPackages(npmStaticPackages); setNpmStaticDeclarationOverloads(declarationOverloads, declarationProperties); + // Types only the packages' .d.ts declares keep their declarations + // (npm-static-type-exports.ts). + const typeExports = npmStaticTypeExportPlan(services, entryPath, npmStaticPackages); + setNpmStaticTypeExports(typeExports.copies, typeExports.typedefs); // Workspace-package registrations reset per load (same discipline as the // npm-static set), then the opted-in names are probed UP FRONT: a // workspace-linked opt-in resolves to files whose realpaths carry no diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json new file mode 100644 index 000000000..c5aa8bb42 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7001-package-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7001-package-declared-types/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json new file mode 100644 index 000000000..927b902d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7004-package-declared-type-reexports/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7004-package-declared-type-reexports/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7001-package-declared-types/main.ts b/tests/corpus/7001-package-declared-types/main.ts new file mode 100644 index 000000000..c5194d33d --- /dev/null +++ b/tests/corpus/7001-package-declared-types/main.ts @@ -0,0 +1,19 @@ +// A package that compiles from its JavaScript, whose interfaces, type +// aliases and enums exist only in its .d.ts. A program that imports its +// values compiles the JavaScript, and the type-only names it imports keep +// the declarations TypeScript reads from the .d.ts. +import { Severity, collect, firstOf, label, type Issue, type Page, type Report } from "issue-list"; + +function report(text: string): Report { + const issues: Issue[] = []; + const count = collect(text, issues); + return { count, issues }; +} + +const result = report("a?b!c?"); +console.log(result.count, result.issues.length); +const first = result.issues[0]!; +console.log(label(first.severity), first.offset, first.severity === Severity.Warning); +const page: Page = { items: result.issues.slice(1), next: null }; +console.log(page.items.map((issue) => `${label(issue.severity)}@${issue.offset}`).join(","), page.next); +console.log(firstOf(page)?.offset); diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts new file mode 100644 index 000000000..5ba9d04ac --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.d.ts @@ -0,0 +1,17 @@ +import type { Page } from "./paging"; +export * from "./paging"; +export declare enum Severity { + Warning = 1, + Error = 2 +} +export interface Issue { + severity: Severity; + offset: number; +} +export type Report = { + count: number; + issues: Issue[]; +}; +export declare function collect(text: string, issues: Issue[]): number; +export declare function label(severity: Severity): string; +export declare function firstOf(page: Page): T | undefined; diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js new file mode 100644 index 000000000..516d2fa30 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/index.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Severity = void 0; +exports.collect = collect; +exports.label = label; +exports.firstOf = firstOf; +var Severity; +(function (Severity) { + Severity[Severity["Warning"] = 1] = "Warning"; + Severity[Severity["Error"] = 2] = "Error"; +})(Severity || (exports.Severity = Severity = {})); +function collect(text, issues) { + for (let i = 0; i < text.length; i++) { + if (text[i] === "?") issues.push({ severity: Severity.Warning, offset: i }); + else if (text[i] === "!") issues.push({ severity: Severity.Error, offset: i }); + } + return issues.length; +} +function label(severity) { + return severity === Severity.Warning ? "warning" : "error"; +} +function firstOf(page) { + return page.items[0]; +} diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts new file mode 100644 index 000000000..c427d5ce0 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.d.ts @@ -0,0 +1,4 @@ +export interface Page { + items: T[]; + next: string | null; +} diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js new file mode 100644 index 000000000..c8ad2e549 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/lib/paging.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json b/tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json new file mode 100644 index 000000000..dcb8f20c3 --- /dev/null +++ b/tests/corpus/7001-package-declared-types/node_modules/issue-list/package.json @@ -0,0 +1 @@ +{ "name": "issue-list", "version": "1.0.0", "main": "./lib/index.js", "types": "./lib/index.d.ts" } diff --git a/tests/corpus/7004-package-declared-type-reexports/main.ts b/tests/corpus/7004-package-declared-type-reexports/main.ts new file mode 100644 index 000000000..52e45e7d4 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/main.ts @@ -0,0 +1,19 @@ +// Two packages that compile from their JavaScript, with types only their +// .d.ts declares: one re-exports types its entry imports +// (`import type { Kind } from "./kinds"; export { Kind }`), the other's +// runtime entry is an ES module (`.mjs`). Declarations using those types +// keep TypeScript's types: the empty arrays the packages fill hold the +// declared rows. +import { addShapes, type Form, type Kind } from "shape-kinds"; +import { readRows, type Row } from "row-module"; + +const forms: Form[] = []; +addShapes("c2 s3", forms); +const kinds: Kind[] = forms.map((form) => form.kind); +const first = forms[0]!; +console.log(kinds.join(","), first.size + forms[1]!.size, first.kind === "circle"); + +const rows: Row[] = []; +const count = readRows("a=1;b=22", rows); +const last = rows[count - 1]!; +console.log(count, last.key, last.value.length); diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts new file mode 100644 index 000000000..1f732f996 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.d.ts @@ -0,0 +1,5 @@ +export interface Row { + key: string; + value: string; +} +export declare function readRows(text: string, into: Row[]): number; diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs new file mode 100644 index 000000000..33d110065 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/lib/index.mjs @@ -0,0 +1,7 @@ +export function readRows(text, into) { + for (const part of text.split(";")) { + const at = part.indexOf("="); + into.push({ key: part.slice(0, at), value: part.slice(at + 1) }); + } + return into.length; +} diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json new file mode 100644 index 000000000..240a3f816 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/row-module/package.json @@ -0,0 +1 @@ +{ "name": "row-module", "version": "1.0.0", "main": "./lib/index.mjs", "types": "./lib/index.d.ts" } diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts new file mode 100644 index 000000000..ada1748d8 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.d.ts @@ -0,0 +1,4 @@ +import type { Kind } from "./kinds"; +import { type Shape as Form } from "./kinds"; +export { Kind, Form }; +export declare function addShapes(text: string, into: Form[]): void; diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js new file mode 100644 index 000000000..d6165c4b9 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/index.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addShapes = addShapes; +function addShapes(text, into) { + for (const part of text.split(" ")) { + into.push({ kind: part.charAt(0) === "c" ? "circle" : "square", size: Number(part.slice(1)) }); + } +} diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts new file mode 100644 index 000000000..79c90322c --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.d.ts @@ -0,0 +1,5 @@ +export type Kind = "circle" | "square"; +export interface Shape { + kind: Kind; + size: number; +} diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js new file mode 100644 index 000000000..c8ad2e549 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/lib/kinds.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json new file mode 100644 index 000000000..e428fc315 --- /dev/null +++ b/tests/corpus/7004-package-declared-type-reexports/node_modules/shape-kinds/package.json @@ -0,0 +1 @@ +{ "name": "shape-kinds", "version": "1.0.0", "main": "./lib/index.js", "types": "./lib/index.d.ts" } diff --git a/tests/harness/__snapshots__/coverage-npm-packages.txt b/tests/harness/__snapshots__/coverage-npm-packages.txt index 475b10e45..6150b08c2 100644 --- a/tests/harness/__snapshots__/coverage-npm-packages.txt +++ b/tests/harness/__snapshots__/coverage-npm-packages.txt @@ -2,7 +2,7 @@ scriptc coverage tests/corpus/native-npm-imports/main.ts statements analyzed 14 compile statically 14 (100%) - in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) npm package compilation: mathkit static diff --git a/tests/harness/corpus-costs/7001-package-declared-types/main.ts.json b/tests/harness/corpus-costs/7001-package-declared-types/main.ts.json new file mode 100644 index 000000000..d34ce5c69 --- /dev/null +++ b/tests/harness/corpus-costs/7001-package-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2683, "san": 1176 } diff --git a/tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json b/tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json new file mode 100644 index 000000000..e9e0bf6d2 --- /dev/null +++ b/tests/harness/corpus-costs/7004-package-declared-type-reexports/main.ts.json @@ -0,0 +1 @@ +{ "plain": 1015, "san": 2516 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 57ac7187c..86d202b46 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -506,6 +506,19 @@ test("JavaScript builtin aliases keep their deferred value fence", async () => { ); }); +test("types only a package's .d.ts declares stay static through re-exports and .mjs entries", () => { + // shape-kinds re-exports types its .d.ts entry imports; row-module's + // runtime entry is an .mjs file. Without their declared types the + // program's uses of Form, Kind and Row run in the dynamic tier (12 + // statements); with them only the packages' own JavaScript and one + // arithmetic on a JavaScript return value do (4). + const { coverage } = analyze( + join(repoRoot, "tests/corpus/7004-package-declared-type-reexports/main.ts"), + ); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.stats.statementsDynamic).toBe(4); +}); + test("any-typed checked-dynamic operators compile natively", () => { const file = join(repoRoot, "tests/corpus/2856-dynamic-any-local-operators.ts"); const staticCoverage = analyze(file).coverage;