src/jsii/assemblies.ts (278 lines of code) (raw):

import { promises as fsPromises } from 'node:fs'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { loadAssemblyFromFile, loadAssemblyFromPath, findAssemblyFile } from '@jsii/spec'; import * as spec from '@jsii/spec'; import { fixturize } from '../fixtures'; import { extractTypescriptSnippetsFromMarkdown } from '../markdown/extract-snippets'; import { TypeScriptSnippet, updateParameters, SnippetParameters, ApiLocation, parseMetadataLine, CompilationDependency, INITIALIZER_METHOD_NAME, typeScriptSnippetFromVisibleSource, } from '../snippet'; import { resolveDependenciesFromPackageJson } from '../snippet-dependencies'; import { enforcesStrictMode } from '../strict'; import { LanguageTablet, DEFAULT_TABLET_NAME, DEFAULT_TABLET_NAME_COMPRESSED } from '../tablets/tablets'; import { fmap, mkDict, pathExists, sortBy } from '../util'; /** * The JSDoc tag users can use to associate non-visible metadata with an example * * In a Markdown section, metadata goes after the code block fence, where it will * be attached to the example but invisible. * * ```ts metadata=goes here * * But in doc comments, '@example' already delineates the example, and any metadata * in there added by the '///' tags becomes part of the visible code (there is no * place to put hidden information). * * We introduce the '@exampleMetadata' tag to put that additional information. */ export const EXAMPLE_METADATA_JSDOCTAG = 'exampleMetadata'; interface RosettaPackageJson extends spec.PackageJson { readonly jsiiRosetta?: { readonly strict?: boolean; readonly exampleDependencies?: Record<string, string>; }; } export interface LoadedAssembly { readonly assembly: spec.Assembly; readonly directory: string; readonly packageJson?: RosettaPackageJson; } /** * Load assemblies by filename or directory */ export function loadAssemblies( assemblyLocations: readonly string[], validateAssemblies: boolean, ): readonly LoadedAssembly[] { return assemblyLocations.map(loadAssembly); function loadAssembly(location: string): LoadedAssembly { const stat = fs.statSync(location); if (stat.isDirectory()) { return loadAssembly(findAssemblyFile(location)); } const directory = path.dirname(location); const pjLocation = path.join(directory, 'package.json'); const assembly = loadAssemblyFromFile(location, validateAssemblies); const packageJson = fs.existsSync(pjLocation) ? JSON.parse(fs.readFileSync(pjLocation, 'utf-8')) : undefined; return { assembly, directory, packageJson }; } } /** * Load the default tablets for every assembly, if available * * Returns a map of { directory -> tablet }. */ export async function loadAllDefaultTablets(asms: readonly LoadedAssembly[]): Promise<Record<string, LanguageTablet>> { return mkDict( await Promise.all( asms.map( async (a) => [a.directory, await LanguageTablet.fromOptionalFile(guessTabletLocation(a.directory))] as const, ), ), ); } /** * Returns the location of the tablet file, either .jsii.tabl.json or .jsii.tabl.json.gz. * Assumes that a tablet exists in the directory and if not, the ensuing behavior is * handled by the caller of this function. */ export function guessTabletLocation(directory: string) { return compressedTabletExists(directory) ? path.join(directory, DEFAULT_TABLET_NAME_COMPRESSED) : path.join(directory, DEFAULT_TABLET_NAME); } export function compressedTabletExists(directory: string) { return fs.existsSync(path.join(directory, DEFAULT_TABLET_NAME_COMPRESSED)); } export type AssemblySnippetSource = | { type: 'markdown'; markdown: string; location: ApiLocation } | { type: 'example'; source: string; metadata?: { [key: string]: string }; location: ApiLocation }; /** * Return all markdown and example snippets from the given assembly */ export function allSnippetSources(assembly: spec.Assembly): AssemblySnippetSource[] { const ret: AssemblySnippetSource[] = []; if (assembly.readme) { ret.push({ type: 'markdown', markdown: assembly.readme.markdown, location: { api: 'moduleReadme', moduleFqn: assembly.name }, }); } for (const [submoduleFqn, submodule] of Object.entries(assembly.submodules ?? {})) { if (submodule.readme) { ret.push({ type: 'markdown', markdown: submodule.readme.markdown, location: { api: 'moduleReadme', moduleFqn: submoduleFqn }, }); } } if (assembly.types) { for (const type of Object.values(assembly.types)) { emitDocs(type.docs, { api: 'type', fqn: type.fqn }); if (spec.isEnumType(type)) { for (const m of type.members) emitDocs(m.docs, { api: 'member', fqn: type.fqn, memberName: m.name }); } if (spec.isClassType(type)) { emitDocsForCallable(type.initializer, type.fqn); } if (spec.isClassOrInterfaceType(type)) { for (const m of type.methods ?? []) emitDocsForCallable(m, type.fqn, m.name); for (const m of type.properties ?? []) emitDocs(m.docs, { api: 'member', fqn: type.fqn, memberName: m.name }); } } } return ret; function emitDocsForCallable(callable: spec.Callable | undefined, fqn: string, memberName?: string) { if (!callable) { return; } emitDocs(callable.docs, memberName ? { api: 'member', fqn, memberName } : { api: 'initializer', fqn }); for (const parameter of callable.parameters ?? []) { emitDocs(parameter.docs, { api: 'parameter', fqn: fqn, methodName: memberName ?? INITIALIZER_METHOD_NAME, parameterName: parameter.name, }); } } function emitDocs(docs: spec.Docs | undefined, location: ApiLocation) { if (!docs) { return; } if (docs.remarks) { ret.push({ type: 'markdown', markdown: docs.remarks, location, }); } if (docs.example) { ret.push({ type: 'example', source: docs.example, metadata: fmap(docs.custom?.[EXAMPLE_METADATA_JSDOCTAG], parseMetadataLine), location, }); } } } export async function allTypeScriptSnippets( assemblies: readonly LoadedAssembly[], loose = false, ): Promise<TypeScriptSnippet[]> { const sources = assemblies .flatMap((loaded) => allSnippetSources(loaded.assembly).map((source) => ({ source, loaded }))) .flatMap(({ source, loaded }) => { switch (source.type) { case 'example': return [ { snippet: updateParameters( typeScriptSnippetFromVisibleSource( source.source, { api: source.location, field: { field: 'example' } }, isStrict(loaded), ), source.metadata ?? {}, ), loaded, }, ]; case 'markdown': return extractTypescriptSnippetsFromMarkdown(source.markdown, source.location, isStrict(loaded)).map( (snippet) => ({ snippet, loaded }), ); } }); const fixtures = []; for (let { snippet, loaded } of sources) { const isInfused = snippet.parameters?.infused != null; // Ignore fixturization errors if requested on this command, or if the snippet was infused const ignoreFixtureErrors = loose || isInfused; // Also if the snippet was infused: switch off 'strict' mode if it was set if (isInfused) { snippet = { ...snippet, strict: false }; } snippet = await withDependencies(loaded, withProjectDirectory(loaded.directory, snippet)); fixtures.push(fixturize(snippet, ignoreFixtureErrors)); } return fixtures; } export interface TypeLookupAssembly { readonly packageJson: any; readonly assembly: spec.Assembly; readonly directory: string; readonly symbolIdMap: Record<string, string>; } const MAX_ASM_CACHE = 3; const ASM_CACHE: TypeLookupAssembly[] = []; /** * Recursively searches for a .jsii file in the directory. * When file is found, checks cache to see if we already * stored the assembly in memory. If not, we synchronously * load the assembly into memory. */ export function findTypeLookupAssembly(startingDirectory: string): TypeLookupAssembly | undefined { const pjLocation = findPackageJsonLocation(path.resolve(startingDirectory)); if (!pjLocation) { return undefined; } const directory = path.dirname(pjLocation); const fromCache = ASM_CACHE.find((c) => c.directory === directory); if (fromCache) { return fromCache; } const loaded = loadLookupAssembly(directory); if (!loaded) { return undefined; } while (ASM_CACHE.length >= MAX_ASM_CACHE) { ASM_CACHE.pop(); } ASM_CACHE.unshift(loaded); return loaded; } function loadLookupAssembly(directory: string): TypeLookupAssembly | undefined { try { const packageJson = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf-8')); const assembly: spec.Assembly = loadAssemblyFromPath(directory); const symbolIdMap = mkDict([ ...Object.values(assembly.types ?? {}).map((type) => [type.symbolId ?? '', type.fqn] as const), ...Object.entries(assembly.submodules ?? {}).map(([fqn, mod]) => [mod.symbolId ?? '', fqn] as const), ]); return { packageJson, assembly, directory, symbolIdMap, }; } catch { return undefined; } } function findPackageJsonLocation(currentPath: string): string | undefined { // eslint-disable-next-line no-constant-condition while (true) { const candidate = path.join(currentPath, 'package.json'); if (fs.existsSync(candidate)) { return candidate; } const parentPath = path.resolve(currentPath, '..'); if (parentPath === currentPath) { return undefined; } currentPath = parentPath; } } /** * Find the jsii [sub]module that contains the given FQN * * @returns `undefined` if the type is a member of the assembly root. */ export function findContainingSubmodule(assembly: spec.Assembly, fqn: string): string | undefined { const submoduleNames = Object.keys(assembly.submodules ?? {}); sortBy(submoduleNames, (s) => [-s.length]); // Longest first for (const s of submoduleNames) { if (fqn.startsWith(`${s}.`)) { return s; } } return undefined; } function withProjectDirectory(dir: string, snippet: TypeScriptSnippet) { return updateParameters(snippet, { [SnippetParameters.$PROJECT_DIRECTORY]: dir, }); } /** * Return a TypeScript snippet with dependencies added * * The dependencies will be taken from the package.json, and will consist of: * * - The package itself * - The package's dependencies and peerDependencies (but NOT devDependencies). Will * symlink to the files on disk. * - Any additional dependencies declared in `jsiiRosetta.exampleDependencies`. */ async function withDependencies(asm: LoadedAssembly, snippet: TypeScriptSnippet): Promise<TypeScriptSnippet> { const compilationDependencies: Record<string, CompilationDependency> = {}; if (await pathExists(path.join(asm.directory, 'package.json'))) { compilationDependencies[asm.assembly.name] = { type: 'concrete', resolvedDirectory: await fsPromises.realpath(asm.directory), }; } Object.assign(compilationDependencies, await resolveDependenciesFromPackageJson(asm.packageJson, asm.directory)); Object.assign( compilationDependencies, mkDict( Object.entries(asm.packageJson?.jsiiRosetta?.exampleDependencies ?? {}).map( ([name, versionRange]) => [name, { type: 'symbolic', versionRange }] as const, ), ), ); return { ...snippet, compilationDependencies, }; } /** * Whether samples in the assembly should be treated as strict * * True if the strict flag is found in the package.json (modern) or the assembly itself (legacy). */ function isStrict(loaded: LoadedAssembly) { return loaded.packageJson?.jsiiRosetta?.strict ?? enforcesStrictMode(loaded.assembly); }