diff --git a/packages/@jsii/spec/lib/assembly.ts b/packages/@jsii/spec/lib/assembly.ts index a60a88d807..7fc1a165d7 100644 --- a/packages/@jsii/spec/lib/assembly.ts +++ b/packages/@jsii/spec/lib/assembly.ts @@ -204,7 +204,10 @@ export interface ReadMe { * The difference between a top-level module (the assembly) and a submodule is * that the submodule is annotated with its location in the repository. */ -export type Submodule = ReadMeContainer & SourceLocatable & Targetable; +export type Submodule = ReadMeContainer & + SourceLocatable & + Targetable & + TypeScriptLocatable; /** * Versions of the JSII Assembly Specification. @@ -440,6 +443,26 @@ export interface SourceLocatable { locationInModule?: SourceLocation; } +/** + * Indicates that a jsii entity's origin can be traced to TypeScript code + * + * This is interface is not the same as `SourceLocatable`. SourceLocatable + * identifies lines in source files in a source repository (in a `.ts` file, + * with respect to a git root). + * + * On the other hand, `TypeScriptLocatable` identifies a symbol name inside a + * potentially distributed TypeScript file (in either a `.d.ts` or `.ts` + * file, with respect to the package root). + */ +export interface TypeScriptLocatable { + /** + * Unique string representation of the corresponding Typescript symbol + * + * Used to map from TypeScript code back into the assembly. + */ + symbolId?: string; +} + /** * Kinds of collections. */ @@ -779,7 +802,10 @@ export type Type = TypeBase & (ClassType | EnumType | InterfaceType); /** * Common attributes of a type definition. */ -export interface TypeBase extends Documentable, SourceLocatable { +export interface TypeBase + extends Documentable, + SourceLocatable, + TypeScriptLocatable { /** * The fully qualified name of the type (``..``) * @@ -823,11 +849,6 @@ export interface TypeBase extends Documentable, SourceLocatable { * The kind of the type. */ kind: TypeKind; - - /** - * Unique string representation of the corresponding Typescript symbol - */ - symbolId?: string; } /** diff --git a/packages/@scope/jsii-calc-lib/test/assembly.jsii b/packages/@scope/jsii-calc-lib/test/assembly.jsii index 131d74d634..b932b84ed4 100644 --- a/packages/@scope/jsii-calc-lib/test/assembly.jsii +++ b/packages/@scope/jsii-calc-lib/test/assembly.jsii @@ -98,6 +98,7 @@ "readme": { "markdown": "# Submodule Readme\n\nThis is a submodule readme.\n" }, + "symbolId": "lib/submodule/index:", "targets": { "dotnet": { "namespace": "Amazon.JSII.Tests.CustomSubmoduleName" @@ -951,5 +952,5 @@ } }, "version": "0.0.0", - "fingerprint": "fCLOsQQLslSg+W8rn7XDZ1RSenH5QeaoTna+j7o+URc=" + "fingerprint": "BJ528BPiptJdqvxyoh4Ax2l2Knaz2KcuNYBYhVpyTw8=" } diff --git a/packages/jsii-calc/test/assembly.jsii b/packages/jsii-calc/test/assembly.jsii index 597752aca0..5d53eb9849 100644 --- a/packages/jsii-calc/test/assembly.jsii +++ b/packages/jsii-calc/test/assembly.jsii @@ -181,145 +181,169 @@ "locationInModule": { "filename": "lib/compliance.ts", "line": 325 - } + }, + "symbolId": "lib/compliance:DerivedClassHasNoProperties" }, "jsii-calc.InterfaceInNamespaceIncludesClasses": { "locationInModule": { "filename": "lib/compliance.ts", "line": 1206 - } + }, + "symbolId": "lib/compliance:InterfaceInNamespaceIncludesClasses" }, "jsii-calc.InterfaceInNamespaceOnlyInterface": { "locationInModule": { "filename": "lib/compliance.ts", "line": 1199 - } + }, + "symbolId": "lib/compliance:InterfaceInNamespaceOnlyInterface" }, "jsii-calc.PythonSelf": { "locationInModule": { "filename": "lib/compliance.ts", "line": 1090 - } + }, + "symbolId": "lib/compliance:PythonSelf" }, "jsii-calc.cdk16625": { "locationInModule": { "filename": "lib/index.ts", "line": 23 - } + }, + "symbolId": "lib/cdk16625/index:" }, "jsii-calc.cdk16625.donotimport": { "locationInModule": { "filename": "lib/cdk16625/index.ts", "line": 6 - } + }, + "symbolId": "lib/cdk16625/donotimport/index:" }, "jsii-calc.composition": { "locationInModule": { "filename": "lib/calculator.ts", "line": 143 - } + }, + "symbolId": "lib/calculator:composition" }, "jsii-calc.module2530": { "locationInModule": { "filename": "lib/index.ts", "line": 20 - } + }, + "symbolId": "lib/module2530/index:" }, "jsii-calc.module2617": { "locationInModule": { "filename": "lib/index.ts", "line": 16 - } + }, + "symbolId": "lib/module2617/index:" }, "jsii-calc.module2647": { "locationInModule": { "filename": "lib/index.ts", "line": 15 - } + }, + "symbolId": "lib/module2647/index:" }, "jsii-calc.module2689": { "locationInModule": { "filename": "lib/index.ts", "line": 17 - } + }, + "symbolId": "lib/module2689/index:" }, "jsii-calc.module2689.methods": { "locationInModule": { "filename": "lib/module2689/index.ts", "line": 8 - } + }, + "symbolId": "lib/module2689/methods/index:" }, "jsii-calc.module2689.props": { "locationInModule": { "filename": "lib/module2689/index.ts", "line": 9 - } + }, + "symbolId": "lib/module2689/props/index:" }, "jsii-calc.module2689.retval": { "locationInModule": { "filename": "lib/module2689/index.ts", "line": 10 - } + }, + "symbolId": "lib/module2689/retval/index:" }, "jsii-calc.module2689.structs": { "locationInModule": { "filename": "lib/module2689/index.ts", "line": 7 - } + }, + "symbolId": "lib/module2689/structs/index:" }, "jsii-calc.module2692": { "locationInModule": { "filename": "lib/index.ts", "line": 19 - } + }, + "symbolId": "lib/module2692/index:" }, "jsii-calc.module2692.submodule1": { "locationInModule": { "filename": "lib/module2692/index.ts", "line": 1 - } + }, + "symbolId": "lib/module2692/submodule1/index:" }, "jsii-calc.module2692.submodule2": { "locationInModule": { "filename": "lib/module2692/index.ts", "line": 2 - } + }, + "symbolId": "lib/module2692/submodule2/index:" }, "jsii-calc.module2700": { "locationInModule": { "filename": "lib/index.ts", "line": 21 - } + }, + "symbolId": "lib/module2700/index:" }, "jsii-calc.module2702": { "locationInModule": { "filename": "lib/index.ts", "line": 18 - } + }, + "symbolId": "lib/module2702/index:" }, "jsii-calc.nodirect": { "locationInModule": { "filename": "lib/index.ts", "line": 14 - } + }, + "symbolId": "lib/no-direct-types/index:" }, "jsii-calc.nodirect.sub1": { "locationInModule": { "filename": "lib/no-direct-types/index.ts", "line": 3 - } + }, + "symbolId": "lib/no-direct-types/sub1/index:" }, "jsii-calc.nodirect.sub2": { "locationInModule": { "filename": "lib/no-direct-types/index.ts", "line": 4 - } + }, + "symbolId": "lib/no-direct-types/sub2/index:" }, "jsii-calc.onlystatic": { "locationInModule": { "filename": "lib/index.ts", "line": 13 - } + }, + "symbolId": "lib/only-static/index:" }, "jsii-calc.submodule": { "locationInModule": { @@ -328,19 +352,22 @@ }, "readme": { "markdown": "Read you, read me\n=================\n\nThis is the readme of the `jsii-calc.submodule` module.\n" - } + }, + "symbolId": "lib/submodule/index:" }, "jsii-calc.submodule.back_references": { "locationInModule": { "filename": "lib/submodule/index.ts", "line": 7 - } + }, + "symbolId": "lib/submodule/refers-to-parent/index:" }, "jsii-calc.submodule.child": { "locationInModule": { "filename": "lib/submodule/index.ts", "line": 1 - } + }, + "symbolId": "lib/submodule/child/index:" }, "jsii-calc.submodule.isolated": { "locationInModule": { @@ -349,31 +376,36 @@ }, "readme": { "markdown": "Read you, read me\n=================\n\nThis is the readme of the `jsii-calc.submodule.isolated` module.\n" - } + }, + "symbolId": "lib/submodule/isolated:" }, "jsii-calc.submodule.nested_submodule": { "locationInModule": { "filename": "lib/submodule/nested_submodule.ts", "line": 4 - } + }, + "symbolId": "lib/submodule/nested_submodule:nested_submodule" }, "jsii-calc.submodule.nested_submodule.deeplyNested": { "locationInModule": { "filename": "lib/submodule/nested_submodule.ts", "line": 6 - } + }, + "symbolId": "lib/submodule/nested_submodule:nested_submodule.deeplyNested" }, "jsii-calc.submodule.param": { "locationInModule": { "filename": "lib/submodule/index.ts", "line": 3 - } + }, + "symbolId": "lib/submodule/param/index:" }, "jsii-calc.submodule.returnsparam": { "locationInModule": { "filename": "lib/submodule/index.ts", "line": 4 - } + }, + "symbolId": "lib/submodule/returns-param/index:" } }, "targets": { @@ -16775,5 +16807,5 @@ } }, "version": "3.20.120", - "fingerprint": "WpcHNV2L+v3B7Ou+8xsRMzye4rf1U+SURQA97A3JT6g=" + "fingerprint": "wBGhJE4ZhvVb8R2qk5FWg49mt+ZKqW60TjlMMtT1Klo=" } diff --git a/packages/jsii-pacmak/test/generated-code/__snapshots__/target-java.test.ts.snap b/packages/jsii-pacmak/test/generated-code/__snapshots__/target-java.test.ts.snap index 313cb7c0e0..ad5c61affa 100644 --- a/packages/jsii-pacmak/test/generated-code/__snapshots__/target-java.test.ts.snap +++ b/packages/jsii-pacmak/test/generated-code/__snapshots__/target-java.test.ts.snap @@ -5626,7 +5626,7 @@ package software.amazon.jsii.tests.calculator; * Calculator calculator = new Calculator(); * calculator.add(5); * calculator.mul(3); - * System.out.println(calculator.expression.getValue()); + * System.out.println(calculator.getExpression().getValue()); * *

* I will repeat this example again, but in an @example tag. @@ -5637,7 +5637,7 @@ package software.amazon.jsii.tests.calculator; * Calculator calculator = new Calculator(); * calculator.add(5); * calculator.mul(3); - * System.out.println(calculator.expression.getValue()); + * System.out.println(calculator.getExpression().getValue()); * */ @javax.annotation.Generated(value = "jsii-pacmak") diff --git a/packages/jsii-rosetta/lib/jsii/assemblies.ts b/packages/jsii-rosetta/lib/jsii/assemblies.ts index 0f020a21ad..07b00993b0 100644 --- a/packages/jsii-rosetta/lib/jsii/assemblies.ts +++ b/packages/jsii-rosetta/lib/jsii/assemblies.ts @@ -13,7 +13,7 @@ import { ApiLocation, } from '../snippet'; import { enforcesStrictMode } from '../strict'; -import { mkDict } from '../util'; +import { mkDict, sortBy } from '../util'; // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports const sortJson = require('sort-json'); @@ -190,8 +190,9 @@ function _fingerprint(assembly: spec.Assembly): spec.Assembly { } export interface TypeLookupAssembly { + readonly packageJson: any; readonly assembly: spec.Assembly; - readonly assemblyFile: string; + readonly directory: string; readonly symbolIdMap: Record; } @@ -204,24 +205,23 @@ const ASM_CACHE: TypeLookupAssembly[] = []; * stored the assembly in memory. If not, we synchronously * load the assembly into memory. */ -export function findTypeLookupAssembly(directory: string): TypeLookupAssembly | undefined { - const pjLocation = findPackageJsonLocation(path.resolve(directory)); +export function findTypeLookupAssembly(startingDirectory: string): TypeLookupAssembly | undefined { + const pjLocation = findPackageJsonLocation(path.resolve(startingDirectory)); if (!pjLocation) { return undefined; } + const directory = path.dirname(pjLocation); - const assemblyFile = path.join(path.dirname(pjLocation), '.jsii'); - - const fromCache = ASM_CACHE.find((c) => c.assemblyFile === assemblyFile); + const fromCache = ASM_CACHE.find((c) => c.directory === directory); if (fromCache) { return fromCache; } - if (!fs.existsSync(assemblyFile)) { + const loaded = loadLookupAssembly(directory); + if (!loaded) { return undefined; } - const loaded = loadLookupAssembly(assemblyFile); while (ASM_CACHE.length >= MAX_ASM_CACHE) { ASM_CACHE.pop(); } @@ -229,15 +229,23 @@ export function findTypeLookupAssembly(directory: string): TypeLookupAssembly | return loaded; } -function loadLookupAssembly(assemblyFile: string): TypeLookupAssembly { +function loadLookupAssembly(directory: string): TypeLookupAssembly | undefined { + const assemblyFile = path.join(directory, '.jsii'); + if (!fs.pathExistsSync(assemblyFile)) { + return undefined; + } + + const packageJson = fs.readJSONSync(path.join(directory, 'package.json'), { encoding: 'utf-8' }); const assembly: spec.Assembly = fs.readJSONSync(assemblyFile, { encoding: 'utf-8' }); - const symbolIdMap = mkDict( - Object.values(assembly.types ?? {}).map((type) => [type.symbolId ?? '', type.fqn] as const), - ); + 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, - assemblyFile, + directory, symbolIdMap, }; } @@ -257,3 +265,19 @@ function findPackageJsonLocation(currentPath: string): string | 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; +} diff --git a/packages/jsii-rosetta/lib/jsii/jsii-types.ts b/packages/jsii-rosetta/lib/jsii/jsii-types.ts index a3345cc590..36302bdb7d 100644 --- a/packages/jsii-rosetta/lib/jsii/jsii-types.ts +++ b/packages/jsii-rosetta/lib/jsii/jsii-types.ts @@ -1,7 +1,7 @@ import * as ts from 'typescript'; import { inferredTypeOfExpression, BuiltInType, builtInTypeName, mapElementType } from '../typescript/types'; -import { hasAnyFlag, analyzeStructType } from './jsii-utils'; +import { hasAnyFlag, analyzeStructType, JsiiSymbol } from './jsii-utils'; // eslint-disable-next-line prettier/prettier export type JsiiType = @@ -69,11 +69,11 @@ export function determineJsiiType(typeChecker: ts.TypeChecker, type: ts.Type): J return { kind: 'unknown' }; } -export type ObjectLiteralAnalysis = - | { readonly kind: 'struct'; readonly type: ts.Type } - | { readonly kind: 'local-struct'; readonly type: ts.Type } - | { readonly kind: 'map' } - | { readonly kind: 'unknown' }; +export type ObjectLiteralAnalysis = ObjectLiteralStruct | { readonly kind: 'map' } | { readonly kind: 'unknown' }; + +export type ObjectLiteralStruct = + | { readonly kind: 'struct'; readonly type: ts.Type; readonly jsiiSym: JsiiSymbol } + | { readonly kind: 'local-struct'; readonly type: ts.Type }; export function analyzeObjectLiteral( typeChecker: ts.TypeChecker, @@ -100,9 +100,9 @@ export function analyzeObjectLiteral( // If the type is a union between a struct and something else, return the first possible struct const structCandidates = type.isUnion() ? type.types : [type]; for (const candidate of structCandidates) { - const structType = analyzeStructType(candidate); + const structType = analyzeStructType(typeChecker, candidate); if (structType) { - return { kind: structType, type: candidate }; + return structType; } } diff --git a/packages/jsii-rosetta/lib/jsii/jsii-utils.ts b/packages/jsii-rosetta/lib/jsii/jsii-utils.ts index d9a5d9b23b..e6f530ee8e 100644 --- a/packages/jsii-rosetta/lib/jsii/jsii-utils.ts +++ b/packages/jsii-rosetta/lib/jsii/jsii-utils.ts @@ -3,15 +3,16 @@ import * as ts from 'typescript'; import { AstRenderer } from '../renderer'; import { typeContainsUndefined } from '../typescript/types'; -import { findTypeLookupAssembly } from './assemblies'; -import { findPackageJson } from './packages'; +import { fmap } from '../util'; +import { findTypeLookupAssembly, TypeLookupAssembly } from './assemblies'; +import { ObjectLiteralStruct } from './jsii-types'; export function isNamedLikeStruct(name: string) { // Start with an I and another uppercase character return !/^I[A-Z]/.test(name); } -export function analyzeStructType(type: ts.Type): 'struct' | 'local-struct' | false { +export function analyzeStructType(typeChecker: ts.TypeChecker, type: ts.Type): ObjectLiteralStruct | false { if ( !type.isClassOrInterface() || !hasAllFlags(type.objectFlags, ts.ObjectFlags.Interface) || @@ -20,11 +21,12 @@ export function analyzeStructType(type: ts.Type): 'struct' | 'local-struct' | fa return false; } - if (refersToJsiiSymbol(type.symbol)) { - return 'struct'; + const jsiiSym = lookupJsiiSymbol(typeChecker, type.symbol); + if (jsiiSym) { + return { kind: 'struct', type, jsiiSym }; } - return 'local-struct'; + return { kind: 'local-struct', type }; } export function hasAllFlags(flags: A, test: A) { @@ -70,32 +72,32 @@ export function structPropertyAcceptsUndefined(prop: StructProperty): boolean { } /** - * Whether or not the given call expression seems to refer to a jsii symbol - * - * If it does, we treat it differently than if it's a class or symbol defined - * in the same example source. - * - * To do this, we look for whether it's defined in a directory that's compiled - * for jsii and has a jsii assembly. - * - * FIXME: Look up the actual symbol identifier when we finally have those. - * - * For tests, we also treat symbols in a file that has the string '/// fake-from-jsii' - * as coming from jsii. + * A TypeScript symbol resolved to its jsii type */ -export function refersToJsiiSymbol(symbol: ts.Symbol): boolean { - const declaration = symbol.declarations[0]; - if (!declaration) { - return false; - } +export interface JsiiSymbol { + /** + * FQN of the symbol + * + * Is either the FQN of a type (for a type). For a membr, the FQN looks like: + * 'type.fqn#memberName'. + */ + readonly fqn: string; - const declaringFile = declaration.getSourceFile(); - if (/^\/\/\/ fake-from-jsii/m.test(declaringFile.getFullText())) { - return true; - } + /** + * What kind of symbol this is + */ + readonly symbolType: 'module' | 'type' | 'member'; - const pj = findPackageJson(declaringFile.fileName); - return !!(pj && pj.jsii); + /** + * Assembly where the type was found + * + * Might be undefined if the type was FAKE from jsii (for tests) + */ + readonly sourceAssembly?: TypeLookupAssembly; +} + +export function lookupJsiiSymbolFromNode(typeChecker: ts.TypeChecker, node: ts.Node): JsiiSymbol | undefined { + return fmap(typeChecker.getSymbolAtLocation(node), (s) => lookupJsiiSymbol(typeChecker, s)); } /** @@ -107,13 +109,48 @@ export function refersToJsiiSymbol(symbol: ts.Symbol): boolean { * 1. The package name (extracted from the nearest `package.json`) * 2. The submodule name (...?? don't know how to get this yet) * 3. Any containing type names or namespace names. + * + * For tests, we also treat symbols in a file that has the string '/// fake-from-jsii' + * as coming from jsii. */ -export function jsiiFqnFromSymbol(typeChecker: ts.TypeChecker, sym: ts.Symbol): string | undefined { +export function lookupJsiiSymbol(typeChecker: ts.TypeChecker, sym: ts.Symbol): JsiiSymbol | undefined { + // Resolve alias, if it is one. This comes into play if the symbol refers to a module, + // we need to resolve the alias to find the ACTUAL module. + if (hasAnyFlag(sym.flags, ts.SymbolFlags.Alias)) { + sym = typeChecker.getAliasedSymbol(sym); + } + const decl: ts.Node | undefined = sym.declarations?.[0]; - if (!decl || !isDeclaration(decl)) { + if (!decl) { return undefined; } + if (ts.isSourceFile(decl)) { + // This is a module. + // FIXME: for now assume this is the assembly root. Handle the case where it isn't later. + const sourceAssembly = findTypeLookupAssembly(decl.fileName); + return fmap( + sourceAssembly, + (asm) => + ({ + fqn: + fmap(symbolIdentifier(typeChecker, sym), (symbolId) => sourceAssembly?.symbolIdMap[symbolId]) ?? + sourceAssembly?.assembly.name, + sourceAssembly: asm, + symbolType: 'module', + } as JsiiSymbol), + ); + } + + if (!isDeclaration(decl)) { + return undefined; + } + + const declaringFile = decl.getSourceFile(); + if (/^\/\/\/ fake-from-jsii/m.test(declaringFile.getFullText())) { + return { fqn: `fake_jsii.${sym.name}`, symbolType: 'type' }; + } + const declSym = getSymbolFromDeclaration(decl, typeChecker); if (!declSym) { return undefined; @@ -121,29 +158,49 @@ export function jsiiFqnFromSymbol(typeChecker: ts.TypeChecker, sym: ts.Symbol): const fileName = decl.getSourceFile().fileName; if (hasAnyFlag(declSym.flags, ts.SymbolFlags.Method | ts.SymbolFlags.Property | ts.SymbolFlags.EnumMember)) { - return fqnFromMemberSymbol(typeChecker, sym, fileName); + return lookupMemberSymbol(typeChecker, sym, fileName); } - return fqnFromTypeSymbol(typeChecker, sym, fileName); + return lookupTypeSymbol(typeChecker, sym, fileName); +} + +function isDeclaration(x: ts.Node): x is ts.Declaration { + return ( + ts.isClassDeclaration(x) || + ts.isNamespaceExportDeclaration(x) || + ts.isNamespaceExport(x) || + ts.isModuleDeclaration(x) || + ts.isEnumDeclaration(x) || + ts.isEnumMember(x) || + ts.isInterfaceDeclaration(x) || + ts.isMethodDeclaration(x) || + ts.isMethodSignature(x) || + ts.isPropertyDeclaration(x) || + ts.isPropertySignature(x) + ); } /** * Look up the jsii fqn for a given type symbol */ -function fqnFromTypeSymbol(typeChecker: ts.TypeChecker, typeSymbol: ts.Symbol, fileName: string): string | undefined { +function lookupTypeSymbol( + typeChecker: ts.TypeChecker, + typeSymbol: ts.Symbol, + fileName: string, +): JsiiSymbol | undefined { const symbolId = symbolIdentifier(typeChecker, typeSymbol); if (!symbolId) { return undefined; } - const assembly = findTypeLookupAssembly(fileName); - return assembly?.symbolIdMap[symbolId]; + const sourceAssembly = findTypeLookupAssembly(fileName); + return fmap(sourceAssembly?.symbolIdMap[symbolId], (fqn) => ({ fqn, sourceAssembly, symbolType: 'type' })); } -function fqnFromMemberSymbol( +function lookupMemberSymbol( typeChecker: ts.TypeChecker, memberSymbol: ts.Symbol, fileName: string, -): string | undefined { +): JsiiSymbol | undefined { const declParent = memberSymbol.declarations?.[0]?.parent; if (!declParent || !isDeclaration(declParent)) { return undefined; @@ -154,24 +211,28 @@ function fqnFromMemberSymbol( return undefined; } - const result = fqnFromTypeSymbol(typeChecker, declParentSym, fileName); - return result ? `${result}#${memberSymbol.name}` : undefined; + const result = lookupTypeSymbol(typeChecker, declParentSym, fileName); + return fmap(result, (result) => ({ ...result, fqn: `${result.fqn}#${memberSymbol.name}`, symbolType: 'member' })); } -function isDeclaration(x: ts.Node): x is ts.Declaration { - return ( - ts.isClassDeclaration(x) || - ts.isNamespaceExportDeclaration(x) || - ts.isNamespaceExport(x) || - ts.isModuleDeclaration(x) || - ts.isEnumDeclaration(x) || - ts.isEnumMember(x) || - ts.isInterfaceDeclaration(x) || - ts.isMethodDeclaration(x) || - ts.isMethodSignature(x) || - ts.isPropertyDeclaration(x) || - ts.isPropertySignature(x) - ); +/** + * If the given type is an enum literal, resolve to the enum type + */ +export function resolveEnumLiteral(typeChecker: ts.TypeChecker, type: ts.Type) { + if (!hasAnyFlag(type.flags, ts.TypeFlags.EnumLiteral)) { + return type; + } + + const parentDeclaration = type.symbol.declarations?.[0]?.parent; + return fmap(parentDeclaration, typeChecker.getTypeAtLocation) ?? type; +} + +export function resolvedSymbolAtLocation(typeChecker: ts.TypeChecker, node: ts.Node) { + let symbol = typeChecker.getSymbolAtLocation(node); + while (symbol && hasAnyFlag(symbol.flags, ts.SymbolFlags.Alias)) { + symbol = typeChecker.getAliasedSymbol(symbol); + } + return symbol; } function getSymbolFromDeclaration(decl: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol | undefined { @@ -182,3 +243,30 @@ function getSymbolFromDeclaration(decl: ts.Node, typeChecker: ts.TypeChecker): t const name = ts.getNameOfDeclaration(decl); return name ? typeChecker.getSymbolAtLocation(name) : undefined; } + +export function parentSymbol(sym: JsiiSymbol): JsiiSymbol | undefined { + const parts = sym.fqn.split('.'); + if (parts.length === 1) { + return undefined; + } + + return { + fqn: parts.slice(0, -1).join('.'), + symbolType: 'module', // Might not be true, but probably good enough + sourceAssembly: sym.sourceAssembly, + }; +} + +/** + * Get the last part of a dot-separated string + */ +export function simpleName(x: string) { + return x.split('.').slice(-1)[0]; +} + +/** + * Get all parts except the last of a dot-separated string + */ +export function namespaceName(x: string) { + return x.split('.').slice(0, -1).join('.'); +} diff --git a/packages/jsii-rosetta/lib/jsii/packages.ts b/packages/jsii-rosetta/lib/jsii/packages.ts index eb07c5475f..9aa5e2ec61 100644 --- a/packages/jsii-rosetta/lib/jsii/packages.ts +++ b/packages/jsii-rosetta/lib/jsii/packages.ts @@ -1,3 +1,4 @@ +import * as spec from '@jsii/spec'; import * as fs from 'fs'; import * as path from 'path'; @@ -40,11 +41,9 @@ export function findPackageJson(fileName: string) { } } -export function jsiiTargetParam(packageName: string, field: string) { - const pkgJson = resolvePackage(packageName); - - const path = ['jsii', 'targets', ...field.split('.')]; - let r = pkgJson; +export function jsiiTargetParameter(target: spec.Targetable, field: string) { + const path = field.split('.'); + let r: any = target.targets; while (path.length > 0 && typeof r === 'object' && r !== null) { r = r[path.splice(0, 1)[0]]; } diff --git a/packages/jsii-rosetta/lib/languages/csharp.ts b/packages/jsii-rosetta/lib/languages/csharp.ts index 813e3c1843..b31daf9d39 100644 --- a/packages/jsii-rosetta/lib/languages/csharp.ts +++ b/packages/jsii-rosetta/lib/languages/csharp.ts @@ -1,7 +1,8 @@ import * as ts from 'typescript'; -import { determineJsiiType, JsiiType } from '../jsii/jsii-types'; -import { jsiiTargetParam } from '../jsii/packages'; +import { determineJsiiType, JsiiType, ObjectLiteralStruct } from '../jsii/jsii-types'; +import { JsiiSymbol, simpleName, namespaceName } from '../jsii/jsii-utils'; +import { jsiiTargetParameter } from '../jsii/packages'; import { OTree, NO_SYNTAX } from '../o-tree'; import { AstRenderer, nimpl } from '../renderer'; import { @@ -21,7 +22,7 @@ import { inferMapElementType, determineReturnType, } from '../typescript/types'; -import { flat, partition, setExtend } from '../util'; +import { flat, fmap } from '../util'; import { DefaultVisitor } from './default'; import { TargetLanguage } from './target-language'; @@ -96,14 +97,21 @@ export class CSharpVisitor extends DefaultVisitor { * * If these are encountered in the LHS of a property access, they will be dropped. */ - private readonly importedModuleAliases = new Set(); + private readonly dropPropertyAccesses = new Set(); /** - * Elements imported into current namespace + * Already imported modules so we don't emit duplicate imports + */ + private readonly alreadyImportedNamespaces = new Set(); + + /** + * A map to undo import renames + * + * We will always reference the original name in the translation. * - * All namespace elements that can be imported need to be uppercased. + * Maps a local-name to a C# name. */ - private readonly importedModuleSymbols = new Set(); + private readonly renamedSymbols = new Map(); public mergeContext(old: CSharpLanguageContext, update: Partial): CSharpLanguageContext { return Object.assign({}, old, update); @@ -128,28 +136,42 @@ export class CSharpVisitor extends DefaultVisitor { } public importStatement(importStatement: ImportStatement, context: CSharpRenderer): OTree { - const namespace = this.lookupModuleNamespace(importStatement.packageName); + const guessedNamespace = guessDotnetNamespace(importStatement.packageName); + const namespace = fmap(importStatement.moduleSymbol, findDotnetName) ?? guessedNamespace; + if (importStatement.imports.import === 'full') { - this.importedModuleAliases.add(importStatement.imports.alias); + this.dropPropertyAccesses.add(importStatement.imports.alias); + this.alreadyImportedNamespaces.add(namespace); return new OTree([`using ${namespace};`], [], { canBreakLine: true }); } if (importStatement.imports.import === 'selective') { - const statements = []; - const [withoutAlias, withAlias] = partition(importStatement.imports.elements, (im) => im.alias === undefined); - - // If there's at least one import without an alias, emit a namespace import. - if (withoutAlias) { - statements.push(`using ${namespace};`); - setExtend( - this.importedModuleSymbols, - withoutAlias.map((w) => w.sourceName), - ); - } - - // For every aliased import, emit an aliasing 'using' statement - for (const aliasedImport of withAlias) { - statements.push(`using ${ucFirst(aliasedImport.alias!)} = ${namespace}.${ucFirst(aliasedImport.sourceName)};`); - this.importedModuleSymbols.add(aliasedImport.alias!); + const statements = new Array(); + + for (const el of importStatement.imports.elements) { + const dotnetNs = fmap(el.importedSymbol, findDotnetName) ?? `${guessedNamespace}.${ucFirst(el.sourceName)}`; + + // If this is an alias, we only honor it if it's NOT for sure a module + // (could be an alias import of a class or enum). + if (el.alias && el.importedSymbol?.symbolType !== 'module') { + this.renamedSymbols.set(el.alias, simpleName(dotnetNs)); + statements.push(`using ${ucFirst(el.alias)} = ${dotnetNs};`); + continue; + } + + // If we are importing a module directly, drop the occurrences of that + // identifier further down (turn `mod.MyClass` into `MyClass`). + if (el.importedSymbol?.symbolType === 'module') { + this.dropPropertyAccesses.add(el.alias ?? el.sourceName); + } + + // Output an import statement for the containing namespace + const importableNamespace = el.importedSymbol?.symbolType === 'module' ? dotnetNs : namespaceName(dotnetNs); + if (this.alreadyImportedNamespaces.has(importableNamespace)) { + continue; + } + + this.alreadyImportedNamespaces.add(importableNamespace); + statements.push(`using ${importableNamespace};`); } return new OTree([], statements, { canBreakLine: true, separator: '\n' }); @@ -316,7 +338,7 @@ export class CSharpVisitor extends DefaultVisitor { // Suppress the LHS of the dot operator if it's "this." (not necessary in C#) // or if it's an imported module reference (C# has namespace-wide imports). const objectExpression = - lhs === 'this' || this.importedModuleAliases.has(lhs) + lhs === 'this' || this.dropPropertyAccesses.has(lhs) ? [] : [renderer.updateContext({ propertyOrMethod: false }).convert(node.expression), '.']; @@ -426,11 +448,10 @@ export class CSharpVisitor extends DefaultVisitor { public knownStructObjectLiteralExpression( node: ts.ObjectLiteralExpression, - structType: ts.Type, - _definedInExample: boolean, + structType: ObjectLiteralStruct, renderer: CSharpRenderer, ): OTree { - return new OTree(['new ', structType.symbol.name, ' { '], renderer.convertAll(node.properties), { + return new OTree(['new ', structType.type.symbol.name, ' { '], renderer.convertAll(node.properties), { suffix: renderer.mirrorNewlineBefore(node.properties[0], '}', ' '), separator: ', ', indent: 4, @@ -600,21 +621,6 @@ export class CSharpVisitor extends DefaultVisitor { }); } - protected lookupModuleNamespace(ref: string) { - // Get the .NET namespace from the referenced package (if available) - const resolvedNamespace = jsiiTargetParam(ref, 'dotnet.namespace'); - - // Return that or some default-derived module name representation - return ( - resolvedNamespace || - ref - .split(/[^a-zA-Z0-9]+/g) - .filter((s) => s !== '') - .map(ucFirst) - .join('.') - ); - } - private renderTypeNode(typeNode: ts.TypeNode | undefined, questionMark: boolean, renderer: CSharpRenderer): string { if (!typeNode) { return 'void'; @@ -683,3 +689,38 @@ export class CSharpVisitor extends DefaultVisitor { function ucFirst(x: string) { return x.substr(0, 1).toUpperCase() + x.substr(1); } + +/** + * Find the Java name of a module or type + */ +function findDotnetName(jsiiSymbol: JsiiSymbol): string | undefined { + if (!jsiiSymbol.sourceAssembly?.assembly) { + // Don't have accurate info, just guess + return jsiiSymbol.symbolType !== 'module' ? simpleName(jsiiSymbol.fqn) : guessDotnetNamespace(jsiiSymbol.fqn); + } + + const asm = jsiiSymbol.sourceAssembly?.assembly; + return recurse(jsiiSymbol.fqn); + + function recurse(fqn: string): string { + if (fqn === asm.name) { + return jsiiTargetParameter(asm, 'dotnet.namespace') ?? guessDotnetNamespace(fqn); + } + if (asm.submodules?.[fqn]) { + const modName = jsiiTargetParameter(asm.submodules[fqn], 'dotnet.namespace'); + if (modName) { + return modName; + } + } + + return `${recurse(namespaceName(fqn))}.${simpleName(jsiiSymbol.fqn)}`; + } +} + +function guessDotnetNamespace(ref: string) { + return ref + .split(/[^a-zA-Z0-9]+/g) + .filter((s) => s !== '') + .map(ucFirst) + .join('.'); +} diff --git a/packages/jsii-rosetta/lib/languages/default.ts b/packages/jsii-rosetta/lib/languages/default.ts index 319c26a997..dc6a83fd2f 100644 --- a/packages/jsii-rosetta/lib/languages/default.ts +++ b/packages/jsii-rosetta/lib/languages/default.ts @@ -1,6 +1,6 @@ import * as ts from 'typescript'; -import { analyzeObjectLiteral } from '../jsii/jsii-types'; +import { analyzeObjectLiteral, ObjectLiteralStruct } from '../jsii/jsii-types'; import { isNamedLikeStruct } from '../jsii/jsii-utils'; import { OTree, NO_SYNTAX } from '../o-tree'; import { AstRenderer, AstHandler, nimpl, CommentSyntax } from '../renderer'; @@ -160,7 +160,7 @@ export abstract class DefaultVisitor implements AstHandler { return this.unknownTypeObjectLiteralExpression(node, context); case 'struct': case 'local-struct': - return this.knownStructObjectLiteralExpression(node, lit.type, lit.kind === 'local-struct', context); + return this.knownStructObjectLiteralExpression(node, lit, context); case 'map': return this.keyValueObjectLiteralExpression(node, context); } @@ -172,8 +172,7 @@ export abstract class DefaultVisitor implements AstHandler { public knownStructObjectLiteralExpression( node: ts.ObjectLiteralExpression, - _structType: ts.Type, - _definedInExample: boolean, + _structType: ObjectLiteralStruct, context: AstRenderer, ): OTree { return this.notImplemented(node, context); diff --git a/packages/jsii-rosetta/lib/languages/java.ts b/packages/jsii-rosetta/lib/languages/java.ts index 22ff74db03..97b4488980 100644 --- a/packages/jsii-rosetta/lib/languages/java.ts +++ b/packages/jsii-rosetta/lib/languages/java.ts @@ -1,23 +1,27 @@ import * as ts from 'typescript'; -import { determineJsiiType, JsiiType, analyzeObjectLiteral } from '../jsii/jsii-types'; -import { jsiiTargetParam } from '../jsii/packages'; +import { determineJsiiType, JsiiType, analyzeObjectLiteral, ObjectLiteralStruct } from '../jsii/jsii-types'; +import { JsiiSymbol, simpleName, namespaceName } from '../jsii/jsii-utils'; +import { jsiiTargetParameter } from '../jsii/packages'; import { TargetLanguage } from '../languages/target-language'; import { OTree, NO_SYNTAX } from '../o-tree'; import { AstRenderer } from '../renderer'; import { isReadOnly, matchAst, nodeOfType, quoteStringLiteral, visibility } from '../typescript/ast-utils'; import { ImportStatement } from '../typescript/imports'; import { isEnumAccess, isStaticReadonlyAccess, determineReturnType } from '../typescript/types'; +import { fmap, setExtend } from '../util'; import { DefaultVisitor } from './default'; interface JavaContext { /** * Whether to ignore the left-hand part of a property access expression. - * Used to strip out TypeScript namespace prefixes from 'extends' and 'new' clauses. + * + * Used to strip out TypeScript namespace prefixes from 'extends' and 'new' clauses, + * EVEN if the source doesn't compile. * * @default false */ - readonly ignorePropertyPrefix?: boolean; + readonly discardPropertyAccess?: boolean; /** * Whether a property access ('sth.b') should be substituted by a getter ('sth.getB()'). @@ -101,6 +105,13 @@ export class JavaVisitor extends DefaultVisitor { */ public static readonly VERSION = '1'; + /** + * Aliases for modules + * + * If these are encountered in the LHS of a property access, they will be dropped. + */ + private readonly dropPropertyAccesses = new Set(); + public readonly language = TargetLanguage.JAVA; public readonly defaultContext = {}; @@ -109,15 +120,27 @@ export class JavaVisitor extends DefaultVisitor { } public importStatement(importStatement: ImportStatement): OTree { - const namespace = this.lookupModuleNamespace(importStatement.packageName); + const guessedNamespace = guessJavaNamespaceName(importStatement.packageName); + if (importStatement.imports.import === 'full') { + this.dropPropertyAccesses.add(importStatement.imports.alias); + const namespace = fmap(importStatement.moduleSymbol, findJavaName) ?? guessedNamespace; + return new OTree([`import ${namespace}.*;`], [], { canBreakLine: true }); } - return new OTree( - [], - importStatement.imports.elements.map((importEl) => `import ${namespace}.${importEl.sourceName};`), - { canBreakLine: true, separator: '\n' }, - ); + + const imports = importStatement.imports.elements.map((e) => { + const fqn = fmap(e.importedSymbol, findJavaName) ?? `${guessedNamespace}.${e.sourceName}`; + + return e.importedSymbol?.symbolType === 'module' ? `import ${fqn}.*;` : `import ${fqn};`; + }); + + const localNames = importStatement.imports.elements + .filter((el) => el.importedSymbol?.symbolType === 'module') + .map((el) => el.alias ?? el.sourceName); + setExtend(this.dropPropertyAccesses, localNames); + + return new OTree([], imports, { canBreakLine: true, separator: '\n' }); } public classDeclaration(node: ts.ClassDeclaration, renderer: JavaRenderer): OTree { @@ -141,7 +164,7 @@ export class JavaVisitor extends DefaultVisitor { 'public ', 'interface ', renderer.convert(node.name), - ...this.typeHeritage(node, renderer.updateContext({ ignorePropertyPrefix: true })), + ...this.typeHeritage(node, renderer.updateContext({ discardPropertyAccess: true })), ' {', ], renderer @@ -445,7 +468,7 @@ export class JavaVisitor extends DefaultVisitor { const className = renderer .updateContext({ - ignorePropertyPrefix: true, + discardPropertyAccess: true, convertPropertyToGetter: false, }) .convert(node.expression); @@ -487,8 +510,7 @@ export class JavaVisitor extends DefaultVisitor { public knownStructObjectLiteralExpression( node: ts.ObjectLiteralExpression, - structType: ts.Type, - definedInExample: boolean, + structType: ObjectLiteralStruct, renderer: JavaRenderer, ): OTree { // Special case: we're rendering an object literal, but the containing constructor @@ -500,10 +522,10 @@ export class JavaVisitor extends DefaultVisitor { // Jsii-generated classes have builders, classes we generated in the course of // this example do not. - const hasBuilder = !definedInExample; + const hasBuilder = structType.kind !== 'local-struct'; return new OTree( - hasBuilder ? [structType.symbol.name, '.builder()'] : ['new ', structType.symbol.name, '()'], + hasBuilder ? [structType.type.symbol.name, '.builder()'] : ['new ', structType.type.symbol.name, '()'], [ ...renderer.convertAll(node.properties), new OTree([renderer.mirrorNewlineBefore(node.properties[0])], [hasBuilder ? '.build()' : '']), @@ -530,34 +552,30 @@ export class JavaVisitor extends DefaultVisitor { const rightHandSide = renderer.convert(node.name); let parts: Array; - if (renderer.currentContext.ignorePropertyPrefix) { - // ignore al prefixes when resolving properties - // only used for type names, in things like - // 'MyClass extends cdk.Construct' - // and 'new' expressions + const leftHandSide = renderer.textOf(node.expression); + + // Suppress the LHS of the dot operator if it matches an alias for a module import. + if (this.dropPropertyAccesses.has(leftHandSide) || renderer.currentContext.discardPropertyAccess) { parts = [rightHandSide]; + } else if (leftHandSide === 'this') { + // for 'this', assume this is a field, and access it directly + parts = ['this', '.', rightHandSide]; } else { - const leftHandSide = renderer.textOf(node.expression); - if (leftHandSide === 'this') { - // for 'this', assume this is a field, and access it directly - parts = ['this', '.', rightHandSide]; - } else { - let convertToGetter = renderer.currentContext.convertPropertyToGetter !== false; - - // See if we're not accessing an enum member or public static readonly property (const). - if (isEnumAccess(renderer.typeChecker, node)) { - convertToGetter = false; - } - if (isStaticReadonlyAccess(renderer.typeChecker, node)) { - convertToGetter = false; - } - - // add a 'get' prefix to the property name, and change the access to a method call, if required - const renderedRightHandSide = convertToGetter ? `get${capitalize(node.name.text)}()` : rightHandSide; - - // strip any trailing ! from the left-hand side, as they're not meaningful in Java - parts = [stripTrailingBang(leftHandSide), '.', renderedRightHandSide]; + let convertToGetter = renderer.currentContext.convertPropertyToGetter !== false; + + // See if we're not accessing an enum member or public static readonly property (const). + if (isEnumAccess(renderer.typeChecker, node)) { + convertToGetter = false; + } + if (isStaticReadonlyAccess(renderer.typeChecker, node)) { + convertToGetter = false; } + + // add a 'get' prefix to the property name, and change the access to a method call, if required + const renderedRightHandSide = convertToGetter ? `get${capitalize(node.name.text)}()` : rightHandSide; + + // strip any trailing ! from the left-hand side, as they're not meaningful in Java + parts = [renderer.convert(node.expression), '.', renderedRightHandSide]; } return new OTree(parts); @@ -634,27 +652,13 @@ export class JavaVisitor extends DefaultVisitor { ); } - private lookupModuleNamespace(packageName: string): string { - // get the Java package name from the referenced package (if available) - const resolvedNamespace = jsiiTargetParam(packageName, 'java.package'); - - // return that or some default-derived module name representation - return ( - resolvedNamespace || - packageName - .split(/[^a-zA-Z0-9]+/g) - .filter((s) => s !== '') - .join('.') - ); - } - private renderClassDeclaration(node: ts.ClassDeclaration | ts.InterfaceDeclaration, renderer: JavaRenderer) { return new OTree( [ 'public ', 'class ', renderer.convert(node.name), - ...this.typeHeritage(node, renderer.updateContext({ ignorePropertyPrefix: true })), + ...this.typeHeritage(node, renderer.updateContext({ discardPropertyAccess: true })), ' {', ], renderer.updateContext({ insideTypeDeclaration: { typeName: node.name } }).convertAll(node.members), @@ -825,10 +829,6 @@ export class JavaVisitor extends DefaultVisitor { } } -function stripTrailingBang(str: string): string { - return str.replace(/!+$/, ''); -} - function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } @@ -836,3 +836,37 @@ function capitalize(str: string): string { function lastElement(strings: string[]): string { return strings[strings.length - 1]; } + +/** + * Find the Java name of a module or type + */ +function findJavaName(jsiiSymbol: JsiiSymbol): string | undefined { + if (!jsiiSymbol.sourceAssembly?.assembly) { + // Don't have accurate info, just guess + return jsiiSymbol.symbolType !== 'module' ? simpleName(jsiiSymbol.fqn) : guessJavaNamespaceName(jsiiSymbol.fqn); + } + + const asm = jsiiSymbol.sourceAssembly?.assembly; + return recurse(jsiiSymbol.fqn); + + function recurse(fqn: string): string { + if (fqn === asm.name) { + return jsiiTargetParameter(asm, 'java.package') ?? guessJavaNamespaceName(fqn); + } + if (asm.submodules?.[fqn]) { + const modName = jsiiTargetParameter(asm.submodules[fqn], 'java.package'); + if (modName) { + return modName; + } + } + + return `${recurse(namespaceName(fqn))}.${simpleName(jsiiSymbol.fqn)}`; + } +} + +function guessJavaNamespaceName(packageName: string) { + return packageName + .split(/[^a-zA-Z0-9]+/g) + .filter((s) => s !== '') + .join('.'); +} diff --git a/packages/jsii-rosetta/lib/languages/python.ts b/packages/jsii-rosetta/lib/languages/python.ts index 08e90692ef..73b0c434dc 100644 --- a/packages/jsii-rosetta/lib/languages/python.ts +++ b/packages/jsii-rosetta/lib/languages/python.ts @@ -1,13 +1,16 @@ import * as ts from 'typescript'; -import { determineJsiiType, JsiiType } from '../jsii/jsii-types'; +import { determineJsiiType, JsiiType, ObjectLiteralStruct } from '../jsii/jsii-types'; import { propertiesOfStruct, StructProperty, structPropertyAcceptsUndefined, analyzeStructType, + JsiiSymbol, + simpleName, + namespaceName, } from '../jsii/jsii-utils'; -import { jsiiTargetParam } from '../jsii/packages'; +import { jsiiTargetParameter } from '../jsii/packages'; import { TargetLanguage } from '../languages/target-language'; import { NO_SYNTAX, OTree, renderTree } from '../o-tree'; import { AstRenderer, nimpl, CommentSyntax } from '../renderer'; @@ -20,7 +23,7 @@ import { } from '../typescript/ast-utils'; import { ImportStatement } from '../typescript/imports'; import { parameterAcceptsUndefined } from '../typescript/types'; -import { startsWithUppercase, flat } from '../util'; +import { startsWithUppercase, flat, sortBy, groupBy, fmap } from '../util'; import { DefaultVisitor } from './default'; interface StructVar { @@ -87,6 +90,11 @@ export interface PythonVisitorOptions { disclaimer?: string; } +interface ImportedModule { + readonly importedFqn: string; + readonly importName: string; +} + export class PythonVisitor extends DefaultVisitor { /** * Translation version @@ -99,6 +107,16 @@ export class PythonVisitor extends DefaultVisitor { public readonly language = TargetLanguage.PYTHON; public readonly defaultContext = {}; + /** + * Keep track of module imports we've seen, so that if we need to render a type we can pick from these modules + */ + private readonly imports = new Array(); + + /** + * Synthetic imports that need to be added as a final step + */ + private readonly syntheticImportsToAdd = new Array(); + protected statementTerminator = ''; public constructor(private readonly options: PythonVisitorOptions = {}) { @@ -125,26 +143,52 @@ export class PythonVisitor extends DefaultVisitor { } public sourceFile(node: ts.SourceFile, context: PythonVisitorContext): OTree { - const rendered = super.sourceFile(node, context); + let rendered = super.sourceFile(node, context); + + // Add synthetic imports + if (this.syntheticImportsToAdd.length > 0) { + rendered = new OTree([...this.renderSyntheticImports(), rendered]); + } + if (this.options.disclaimer) { - return new OTree([`# ${this.options.disclaimer}\n`, rendered]); + rendered = new OTree([`# ${this.options.disclaimer}\n`, rendered]); } return rendered; } public importStatement(node: ImportStatement, context: PythonVisitorContext): OTree { - const moduleName = this.convertModuleReference(node.packageName); if (node.imports.import === 'full') { + const moduleName = fmap(node.moduleSymbol, findPythonName) ?? guessPythonPackageName(node.packageName); + + this.addImport({ + importedFqn: node.moduleSymbol?.fqn ?? node.packageName, + importName: node.imports.alias, + }); + return new OTree([`import ${moduleName} as ${mangleIdentifier(node.imports.alias)}`], [], { canBreakLine: true, }); } if (node.imports.import === 'selective') { - const imports = node.imports.elements.map((im) => - im.alias - ? `${mangleIdentifier(im.sourceName)} as ${mangleIdentifier(im.alias)}` - : mangleIdentifier(im.sourceName), - ); + for (const im of node.imports.elements) { + if (im.importedSymbol) { + this.addImport({ + importName: im.alias ? im.alias : im.sourceName, + importedFqn: im.importedSymbol.fqn, + }); + } + } + + const imports = node.imports.elements.map((im) => { + const localName = im.alias ?? im.sourceName; + const originalName = fmap(fmap(im.importedSymbol, findPythonName), simpleName) ?? im.sourceName; + + return localName === originalName + ? mangleIdentifier(originalName) + : `${mangleIdentifier(originalName)} as ${mangleIdentifier(localName)}`; + }); + + const moduleName = fmap(node.moduleSymbol, findPythonName) ?? guessPythonPackageName(node.packageName); return new OTree([`from ${moduleName} import ${imports.join(', ')}`], [], { canBreakLine: true, @@ -293,7 +337,11 @@ export class PythonVisitor extends DefaultVisitor { public parameterDeclaration(node: ts.ParameterDeclaration, context: PythonVisitorContext): OTree { const type = node.type && context.typeOfType(node.type); - if (context.currentContext.tailPositionParameter && type && analyzeStructType(type) !== false) { + if ( + context.currentContext.tailPositionParameter && + type && + analyzeStructType(context.typeChecker, type) !== false + ) { // Return the parameter that we exploded so that we can use this information // while translating the body. if (context.currentContext.returnExplodedParameter) { @@ -351,15 +399,18 @@ export class PythonVisitor extends DefaultVisitor { public knownStructObjectLiteralExpression( node: ts.ObjectLiteralExpression, - structType: ts.Type, - _definedInExample: boolean, + structType: ObjectLiteralStruct, context: PythonVisitorContext, ): OTree { if (context.currentContext.tailPositionArgument) { // We know it's a struct we can DEFINITELY inline the args for return this.renderObjectLiteralExpression('', '', true, node, context); } - return this.renderObjectLiteralExpression(`${structType.symbol.name}(`, ')', true, node, context); + + const structName = + structType.kind === 'struct' ? this.importedNameForType(structType.jsiiSym) : structType.type.symbol.name; + + return this.renderObjectLiteralExpression(`${structName}(`, ')', true, node, context); } public keyValueObjectLiteralExpression(node: ts.ObjectLiteralExpression, context: PythonVisitorContext): OTree { @@ -590,15 +641,6 @@ export class PythonVisitor extends DefaultVisitor { return NO_SYNTAX; } - protected convertModuleReference(ref: string) { - // Get the Python target name from the referenced package (if available) - const resolvedPackage = jsiiTargetParam(ref, 'python.module'); - - // Return that or some default-derived module name representation - - return resolvedPackage || ref.replace(/^@/, '').replace(/\//g, '.').replace(/-/g, '_'); - } - /** * Convert parameters * @@ -700,6 +742,40 @@ export class PythonVisitor extends DefaultVisitor { } } } + + private addImport(x: ImportedModule) { + this.imports.push(x); + // Sort in reverse order of FQN length + sortBy(this.imports, (i) => [-i.importedFqn.length]); + } + + /** + * Find the import for the FQNs submodule, and return it and the rest of the name + */ + private importedNameForType(jsiiSym: JsiiSymbol) { + // Look for an existing import that contains this symbol + for (const imp of this.imports) { + if (jsiiSym.fqn.startsWith(`${imp.importedFqn}.`)) { + const remainder = jsiiSym.fqn.substring(imp.importedFqn.length + 1); + return `${imp.importName}.${remainder}`; + } + } + + // Otherwise look up the Python name of this symbol (but not for fake imports from tests) + const pythonName = findPythonName(jsiiSym); + if (!jsiiSym.fqn.startsWith('fake_jsii.') && pythonName) { + this.syntheticImportsToAdd.push(pythonName); + } + return simpleName(jsiiSym.fqn); + } + + private renderSyntheticImports(): string[] { + const grouped = groupBy(this.syntheticImportsToAdd, namespaceName); + return Object.entries(grouped).map(([namespaceFqn, fqns]) => { + const simpleNames = fqns.map(simpleName); + return `from ${namespaceFqn} import ${simpleNames.join(', ')}\n`; + }); + } } function mangleIdentifier(originalIdentifier: string) { @@ -729,3 +805,37 @@ const IDENTIFIER_KEYWORDS: string[] = ['lambda']; function last(xs: readonly A[]): A { return xs[xs.length - 1]; } + +/** + * Find the Python name of a module or type + */ +function findPythonName(jsiiSymbol: JsiiSymbol): string | undefined { + if (!jsiiSymbol.sourceAssembly?.assembly) { + // Don't have accurate info, just guess + return jsiiSymbol.symbolType !== 'module' ? simpleName(jsiiSymbol.fqn) : guessPythonPackageName(jsiiSymbol.fqn); + } + + const asm = jsiiSymbol.sourceAssembly?.assembly; + return recurse(jsiiSymbol.fqn); + + function recurse(fqn: string): string { + if (fqn === asm.name) { + return jsiiTargetParameter(asm, 'python.module') ?? guessPythonPackageName(fqn); + } + if (asm.submodules?.[fqn]) { + const modName = jsiiTargetParameter(asm.submodules[fqn], 'python.module'); + if (modName) { + return modName; + } + } + + return `${recurse(namespaceName(fqn))}.${simpleName(jsiiSymbol.fqn)}`; + } +} + +/** + * Pythonify an assembly name and hope it is correct + */ +function guessPythonPackageName(ref: string) { + return ref.replace(/^@/, '').replace(/\//g, '.').replace(/-/g, '_'); +} diff --git a/packages/jsii-rosetta/lib/languages/record-references.ts b/packages/jsii-rosetta/lib/languages/record-references.ts index db1027f14f..3b4b049a10 100644 --- a/packages/jsii-rosetta/lib/languages/record-references.ts +++ b/packages/jsii-rosetta/lib/languages/record-references.ts @@ -1,6 +1,6 @@ import * as ts from 'typescript'; -import { jsiiFqnFromSymbol } from '../jsii/jsii-utils'; +import { lookupJsiiSymbol } from '../jsii/jsii-utils'; import { TargetLanguage } from '../languages/target-language'; import { OTree, NO_SYNTAX } from '../o-tree'; import { AstRenderer } from '../renderer'; @@ -126,11 +126,11 @@ export class RecordReferencesVisitor extends DefaultVisitor): ImportStatement { @@ -32,6 +45,7 @@ export function analyzeImportEquals(node: ts.ImportEqualsDeclaration, context: A return { node, packageName: moduleName, + moduleSymbol: lookupJsiiSymbolFromNode(context.typeChecker, node.name), imports: { import: 'full', alias: context.textOf(node.name) }, }; } @@ -51,6 +65,7 @@ export function analyzeImportDeclaration(node: ts.ImportDeclaration, context: As return { node, packageName, + moduleSymbol: lookupJsiiSymbolFromNode(context.typeChecker, starBindings.namespace.name), imports: { import: 'full', alias: context.textOf(starBindings.namespace.name), @@ -72,17 +87,22 @@ export function analyzeImportDeclaration(node: ts.ImportDeclaration, context: As const elements: ImportBinding[] = []; if (namedBindings) { elements.push( - ...namedBindings.specifiers.map((spec) => + ...namedBindings.specifiers.map((spec) => { // regular import { name }, renamed import { propertyName, name } - spec.propertyName - ? { - sourceName: context.textOf(spec.propertyName), - alias: spec.name ? context.textOf(spec.name) : '???', - } - : { - sourceName: spec.name ? context.textOf(spec.name) : '???', - }, - ), + if (spec.propertyName) { + // Renamed import + return { + sourceName: context.textOf(spec.propertyName), + alias: context.textOf(spec.name), + importedSymbol: lookupJsiiSymbolFromNode(context.typeChecker, spec.propertyName), + } as ImportBinding; + } + + return { + sourceName: context.textOf(spec.name), + importedSymbol: lookupJsiiSymbolFromNode(context.typeChecker, spec.name), + }; + }), ); } @@ -90,5 +110,6 @@ export function analyzeImportDeclaration(node: ts.ImportDeclaration, context: As node, packageName, imports: { import: 'selective', elements }, + moduleSymbol: fmap(elements?.[0]?.importedSymbol, parentSymbol), }; } diff --git a/packages/jsii-rosetta/lib/typescript/types.ts b/packages/jsii-rosetta/lib/typescript/types.ts index 4ce2916221..006a3515f8 100644 --- a/packages/jsii-rosetta/lib/typescript/types.ts +++ b/packages/jsii-rosetta/lib/typescript/types.ts @@ -1,6 +1,6 @@ import * as ts from 'typescript'; -import { hasAllFlags, hasAnyFlag } from '../jsii/jsii-utils'; +import { hasAllFlags, hasAnyFlag, resolveEnumLiteral, resolvedSymbolAtLocation } from '../jsii/jsii-utils'; /** * Return the first non-undefined type from a union @@ -149,7 +149,8 @@ export function arrayElementType(type: ts.Type): ts.Type | undefined { } export function typeOfExpression(typeChecker: ts.TypeChecker, node: ts.Expression) { - return typeChecker.getContextualType(node) ?? typeChecker.getTypeAtLocation(node); + const t = typeChecker.getContextualType(node) ?? typeChecker.getTypeAtLocation(node); + return resolveEnumLiteral(typeChecker, t); } function isDefined(x: A): x is NonNullable { @@ -174,12 +175,12 @@ export function isNumber(x: any): x is number { } export function isEnumAccess(typeChecker: ts.TypeChecker, access: ts.PropertyAccessExpression) { - const symbol = typeChecker.getSymbolAtLocation(access.expression); + const symbol = resolvedSymbolAtLocation(typeChecker, access.expression); return symbol ? hasAnyFlag(symbol.flags, ts.SymbolFlags.Enum) : false; } export function isStaticReadonlyAccess(typeChecker: ts.TypeChecker, access: ts.PropertyAccessExpression) { - const symbol = typeChecker.getSymbolAtLocation(access); + const symbol = resolvedSymbolAtLocation(typeChecker, access); const decl = symbol?.getDeclarations(); if (decl && decl[0] && ts.isPropertyDeclaration(decl[0])) { const flags = ts.getCombinedModifierFlags(decl[0]); diff --git a/packages/jsii-rosetta/lib/util.ts b/packages/jsii-rosetta/lib/util.ts index 4d131ad254..979acfb36a 100644 --- a/packages/jsii-rosetta/lib/util.ts +++ b/packages/jsii-rosetta/lib/util.ts @@ -100,8 +100,30 @@ export function mkDict(xs: Array): Record< return ret; } -export function fmap(value: NonNullable, fn: (x: A) => B): B; -export function fmap(value: undefined, fn: (x: A) => B): undefined; +/** + * Apply a function to a value, as long as it's not `undefined` + * + * This is a companion helper to TypeScript's nice `??` and `?.` nullish + * operators. Those operators are helpful if you're calling methods: + * + * object?.method() <- returns 'undefined' if 'object' is nullish + * + * But are no help when you want to use free functions: + * + * func(object) <- but what if 'object' is nullish and func + * expects it not to be? + * + * Yes you can write `object ? func(object) : undefined` but the trailing + * `: undefined` clutters your code. Instead, you write: + * + * fmap(object, func) + * + * The name `fmap` is taken from Haskell: it's a "Functor-map" (although + * only for the `Maybe` Functor). + */ +export function fmap(value: NonNullable, fn: (x: NonNullable) => B): B; +export function fmap(value: undefined, fn: (x: NonNullable) => B): undefined; +export function fmap(value: A | undefined, fn: (x: A) => B): B | undefined; export function fmap(value: A, fn: (x: A) => B): B | undefined { if (value === undefined) { return undefined; @@ -116,3 +138,62 @@ export function mapValues(xs: Record, fn: (x: A) => B): Record< } return ret; } + +/** + * Sort an array by a key function. + * + * Instead of having to write your own comparators for your types any time you + * want to sort, you supply a function that maps a value to a compound sort key + * consisting of numbers or strings. The sorting will happen by that sort key + * instead. + */ +export function sortBy(xs: A[], keyFn: (x: A) => Array) { + return xs.sort((a, b) => { + const aKey = keyFn(a); + const bKey = keyFn(b); + + for (let i = 0; i < Math.min(aKey.length, bKey.length); i++) { + // Compare aKey[i] to bKey[i] + const av = aKey[i]; + const bv = bKey[i]; + + if (av === bv) { + continue; + } + + if (typeof av !== typeof bv) { + throw new Error(`Type of sort key ${JSON.stringify(aKey)} not same as ${JSON.stringify(bKey)}`); + } + + if (typeof av === 'number' && typeof bv === 'number') { + return av - bv; + } + + if (typeof av === 'string' && typeof bv === 'string') { + return av.localeCompare(bv); + } + } + + return aKey.length - bKey.length; + }); +} + +/** + * Group elements by a key + * + * Supply a function that maps each element to a key string. + * + * Returns a map of the key to the list of elements that map to that key. + */ +export function groupBy(xs: A[], keyFn: (x: A) => string): Record { + const ret: Record = {}; + for (const x of xs) { + const key = keyFn(x); + if (ret[key]) { + ret[key].push(x); + } else { + ret[key] = [x]; + } + } + return ret; +} diff --git a/packages/jsii-rosetta/package.json b/packages/jsii-rosetta/package.json index 87c8561a5f..d406679d12 100644 --- a/packages/jsii-rosetta/package.json +++ b/packages/jsii-rosetta/package.json @@ -24,8 +24,7 @@ "@types/workerpool": "^6.1.0", "eslint": "^7.32.0", "jest": "^27.2.4", - "jsii": "^0.0.0", - "jsii-build-tools": "^0.0.0", + "jsii-build-tools": "0.0.0", "memory-streams": "^0.1.3", "mock-fs": "^5.1.1", "prettier": "^2.4.1" diff --git a/packages/jsii-rosetta/test/commands/extract.test.ts b/packages/jsii-rosetta/test/commands/extract.test.ts index 0969adb715..3de081d7ed 100644 --- a/packages/jsii-rosetta/test/commands/extract.test.ts +++ b/packages/jsii-rosetta/test/commands/extract.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { LanguageTablet, RosettaTranslator, RosettaTranslatorOptions } from '../../lib'; import * as extract from '../../lib/commands/extract'; import { TARGET_LANGUAGES } from '../../lib/languages'; -import { TestJsiiModule, DUMMY_ASSEMBLY_TARGETS } from '../testutil'; +import { TestJsiiModule, DUMMY_JSII_CONFIG } from '../testutil'; const DUMMY_README = ` Here is an example of how to use ClassA: @@ -35,7 +35,7 @@ beforeAll(async () => { }, { name: 'my_assembly', - jsii: DUMMY_ASSEMBLY_TARGETS, + jsii: DUMMY_JSII_CONFIG, }, ); }); @@ -115,7 +115,7 @@ test('do not ignore example strings', async () => { }, { name: 'my_assembly', - jsii: DUMMY_ASSEMBLY_TARGETS, + jsii: DUMMY_JSII_CONFIG, }, ); try { diff --git a/packages/jsii-rosetta/test/commands/infuse.test.ts b/packages/jsii-rosetta/test/commands/infuse.test.ts index 4c60103f6e..a6c4a11413 100644 --- a/packages/jsii-rosetta/test/commands/infuse.test.ts +++ b/packages/jsii-rosetta/test/commands/infuse.test.ts @@ -5,7 +5,7 @@ import { LanguageTablet } from '../../lib'; import { extractSnippets } from '../../lib/commands/extract'; import { infuse, DEFAULT_INFUSION_RESULTS_NAME } from '../../lib/commands/infuse'; import { loadAssemblies } from '../../lib/jsii/assemblies'; -import { TestJsiiModule, DUMMY_ASSEMBLY_TARGETS } from '../testutil'; +import { TestJsiiModule, DUMMY_JSII_CONFIG } from '../testutil'; const DUMMY_README = ` Here is an example of how to use ClassA: @@ -41,7 +41,7 @@ beforeEach(async () => { }, { name: 'my_assembly', - jsii: DUMMY_ASSEMBLY_TARGETS, + jsii: DUMMY_JSII_CONFIG, }, ); diff --git a/packages/jsii-rosetta/test/commands/transliterate.test.ts b/packages/jsii-rosetta/test/commands/transliterate.test.ts index 86fbac3a16..98863eecce 100644 --- a/packages/jsii-rosetta/test/commands/transliterate.test.ts +++ b/packages/jsii-rosetta/test/commands/transliterate.test.ts @@ -425,10 +425,10 @@ export class ClassName implements IInterface { // See https://github.com/aws/jsii/issues/826 for more information. IInterface object = new ClassName(\\"this\\", 1337, new ClassNameProps().foo(\\"bar\\")); - object.getProperty() = EnumType.getOPTION_A(); + object.getProperty() = EnumType.OPTION_A; object.methodCall(); - ClassName.staticMethod(EnumType.getOPTION_B()); + ClassName.staticMethod(EnumType.OPTION_B); \`\`\`", }, "repository": Object { @@ -450,7 +450,7 @@ export class ClassName implements IInterface { "example": "// This example was automatically transliterated. // See https://github.com/aws/jsii/issues/826 for more information. - new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.getOPTION_B()));", + new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.OPTION_B));", "summary": "Create a new instance of ClassName.", }, "locationInModule": Object { @@ -589,7 +589,7 @@ export class ClassName implements IInterface { "example": "// This example was automatically transliterated. // See https://github.com/aws/jsii/issues/826 for more information. - new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.getOPTION_B()));", + new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.OPTION_B));", }, "fqn": "testpkg.EnumType", "kind": "enum", @@ -603,7 +603,7 @@ export class ClassName implements IInterface { "example": "// This example was automatically transliterated. // See https://github.com/aws/jsii/issues/826 for more information. - new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.getOPTION_A()));", + new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.OPTION_A));", }, "name": "OPTION_A", }, @@ -612,7 +612,7 @@ export class ClassName implements IInterface { "example": "// This example was automatically transliterated. // See https://github.com/aws/jsii/issues/826 for more information. - new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.getOPTION_B()));", + new ClassName(\\"this\\", 1337, new ClassNameProps().property(EnumType.OPTION_B));", }, "name": "OPTION_B", }, @@ -653,7 +653,7 @@ export class ClassName implements IInterface { "example": "// This example was automatically transliterated. // See https://github.com/aws/jsii/issues/826 for more information. - iface.getProperty() = EnumType.getOPTION_B();", + iface.getProperty() = EnumType.OPTION_B;", "summary": "A property value.", }, "locationInModule": Object { diff --git a/packages/jsii-rosetta/test/jsii-imports.test.ts b/packages/jsii-rosetta/test/jsii-imports.test.ts new file mode 100644 index 0000000000..67aec7f362 --- /dev/null +++ b/packages/jsii-rosetta/test/jsii-imports.test.ts @@ -0,0 +1,459 @@ +// Test translation of imports with actual jsii assemblies +// +// - For Python, there is a lot of variation in what imports get translated to (mirroring +// the style in TypeScript, occasionally adding extra imports as required). +// - For other languages, we'll mostly translate the same thing. + +import { TargetLanguage, TranslatedSnippet } from '../lib'; +import { MultipleSources, TestJsiiModule, DUMMY_JSII_CONFIG } from './testutil'; + +describe('no submodule', () => { + describe('top-level struct', () => { + let module: TestJsiiModule; + beforeAll(async () => { + module = await makeJsiiModule({ withModule: false, nestedStruct: false }); + }); + + afterAll(() => module.cleanup()); + + describe('package import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere(` + import * as masm from 'my_assembly'; + const obj = new masm.MyClass('value', { + myStruct: { + value: 'v', + }, + }); + `); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'import example_test_demo as masm', + 'obj = masm.MyClass("value",', + ' my_struct=masm.MyStruct(', + ' value="v"', + ' )', + ')', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.*;', + ...DEFAULT_JAVA_CODE, + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo;', + ...DEFAULT_CSHARP_CODE, + ]); + }); + }); + + describe('class import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere( + `import { MyClass } from 'my_assembly'; + const obj = new MyClass('value', { + myStruct: { + value: 'v', + }, + }); + `, + ); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'from example_test_demo import MyStruct', + 'from example_test_demo import MyClass', + 'obj = MyClass("value",', + ' my_struct=MyStruct(', + ' value="v"', + ' )', + ')', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.MyClass;', + ...DEFAULT_JAVA_CODE, + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo;', + ...DEFAULT_CSHARP_CODE, + ]); + }); + }); + }); + + describe('nested struct', () => { + let module: TestJsiiModule; + beforeAll(async () => { + module = await makeJsiiModule({ withModule: false, nestedStruct: true }); + }); + afterAll(() => module.cleanup()); + + describe('package import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere(` + import * as masm from 'my_assembly'; + const obj = new masm.MyClass('value', { + myStruct: { + value: 'v', + }, + }); + `); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'import example_test_demo as masm', + 'obj = masm.MyClass("value",', + ' my_struct=masm.MyClass.MyStruct(', + ' value="v"', + ' )', + ')', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.*;', + ...DEFAULT_JAVA_CODE, + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo;', + ...DEFAULT_CSHARP_CODE, + ]); + }); + }); + + describe('class import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere(` + import { MyClass } from 'my_assembly'; + const obj = new MyClass('value', { + myStruct: { + value: 'v', + }, + }); + `); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'from example_test_demo import MyClass', + 'obj = MyClass("value",', + ' my_struct=MyClass.MyStruct(', + ' value="v"', + ' )', + ')', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.MyClass;', + ...DEFAULT_JAVA_CODE, + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo;', + ...DEFAULT_CSHARP_CODE, + ]); + }); + }); + }); + + describe('enum', () => { + let module: TestJsiiModule; + beforeAll(async () => { + module = await TestJsiiModule.fromSource( + { + 'index.ts': `export enum MyEnum { OPTION_A = 'a', OPTION_B = 'b' }`, + }, + { + name: 'my_assembly', + jsii: DUMMY_JSII_CONFIG, + }, + ); + }); + + afterAll(() => module.cleanup()); + + describe('package import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere(` + import * as masm from 'my_assembly'; + const x = masm.MyEnum.OPTION_A; + `); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'import example_test_demo as masm', + 'x = masm.MyEnum.OPTION_A', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.*;', + 'MyEnum x = MyEnum.OPTION_A;', + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo;', + 'MyEnum x = MyEnum.OPTION_A;', + ]); + }); + }); + + describe('direct import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere( + `import { MyEnum } from 'my_assembly'; + const x = MyEnum.OPTION_A; + `, + ); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'from example_test_demo import MyEnum', + 'x = MyEnum.OPTION_A', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.MyEnum;', + 'MyEnum x = MyEnum.OPTION_A;', + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo;', + 'MyEnum x = MyEnum.OPTION_A;', + ]); + }); + }); + }); +}); + +describe('with submodule', () => { + describe('top-level struct', () => { + let module: TestJsiiModule; + beforeAll(async () => { + module = await makeJsiiModule({ withModule: true, nestedStruct: false }); + }); + + afterAll(() => module.cleanup()); + + describe('namespace import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere(` + import { submod as mod } from 'my_assembly'; + const obj = new mod.MyClass('value', { + myStruct: { + value: 'v', + }, + }); + `); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'from example_test_demo import boop as mod', + 'obj = mod.MyClass("value",', + ' my_struct=mod.MyStruct(', + ' value="v"', + ' )', + ')', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.boop.*;', + ...DEFAULT_JAVA_CODE, + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo.Boop;', + ...DEFAULT_CSHARP_CODE, + ]); + }); + }); + }); + + describe('nested struct', () => { + let module: TestJsiiModule; + beforeAll(async () => { + module = await makeJsiiModule({ withModule: true, nestedStruct: true }); + }); + + afterAll(() => module.cleanup()); + + describe('namespace import', () => { + let trans: TranslatedSnippet; + beforeAll(() => { + trans = module.translateHere(` + import { submod as mod } from 'my_assembly'; + const obj = new mod.MyClass('value', { + myStruct: { + value: 'v', + }, + }); + `); + }); + + test('to Python', () => { + expectTranslation(trans, TargetLanguage.PYTHON, [ + 'from example_test_demo import boop as mod', + 'obj = mod.MyClass("value",', + ' my_struct=mod.MyClass.MyStruct(', + ' value="v"', + ' )', + ')', + ]); + }); + + test('to Java', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.JAVA, [ + 'import example.test.demo.boop.*;', + ...DEFAULT_JAVA_CODE, + ]); + }); + + test('to C#', () => { + // eslint-disable-next-line prettier/prettier + expectTranslation(trans, TargetLanguage.CSHARP, [ + 'using Example.Test.Demo.Boop;', + ...DEFAULT_CSHARP_CODE, + ]); + }); + }); + }); +}); + +async function makeJsiiModule(options: { + readonly withModule: boolean; + readonly nestedStruct: boolean; +}): Promise { + const nsRef = options.nestedStruct ? 'MyClass.' : ''; + const nsDeclBegin = options.nestedStruct ? 'export namespace MyClass {\n' : ''; + const nsDeclEnd = options.nestedStruct ? '}' : ''; + + const payload = ` + export class MyClass { + constructor(value: string, props: ${nsRef}MyClassProps) { + Array.isArray(value); + Array.isArray(props); + } + } + + ${nsDeclBegin} + export interface MyClassProps { + readonly myStruct: MyStruct; + } + + export interface MyStruct { + readonly value: string; + } + ${nsDeclEnd} + `; + + const source: MultipleSources = options.withModule + ? { + 'index.ts': 'export * as submod from "./submodule/module";', + 'submodule/module.ts': payload, + 'submodule/.jsiirc.json': JSON.stringify({ + targets: { + python: { + module: 'example_test_demo.boop', + }, + java: { + package: 'example.test.demo.boop', + }, + dotnet: { + namespace: 'Example.Test.Demo.Boop', + }, + }, + }), + } + : { + 'index.ts': payload, + }; + + return TestJsiiModule.fromSource(source, { + name: 'my_assembly', + jsii: DUMMY_JSII_CONFIG, + }); +} + +// The implementation part of the Java code is always the same +const DEFAULT_JAVA_CODE = [ + 'MyClass obj = MyClass.Builder.create("value")', + ' .myStruct(MyStruct.builder()', + ' .value("v")', + ' .build())', + ' .build();', +]; + +// The implementation part of the CSharp code is always the same +const DEFAULT_CSHARP_CODE = [ + 'MyClass obj = new MyClass("value", new MyClassProps {', + ' MyStruct = new MyStruct {', + ' Value = "v"', + ' }', + '});', +]; + +/** + * Verify the Java output. All expected Java outputs look the same. + */ +function expectTranslation(trans: TranslatedSnippet, lang: TargetLanguage, expected: string[]) { + expect(trans.get(lang)?.source.split('\n')).toEqual(expected); +} diff --git a/packages/jsii-rosetta/test/jsii/assemblies.test.ts b/packages/jsii-rosetta/test/jsii/assemblies.test.ts index 76e1e772f8..97878d9a9f 100644 --- a/packages/jsii-rosetta/test/jsii/assemblies.test.ts +++ b/packages/jsii-rosetta/test/jsii/assemblies.test.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { allTypeScriptSnippets } from '../../lib/jsii/assemblies'; import { SnippetParameters } from '../../lib/snippet'; -import { TestJsiiModule, DUMMY_ASSEMBLY_TARGETS } from '../testutil'; +import { TestJsiiModule, DUMMY_JSII_CONFIG } from '../testutil'; import { fakeAssembly } from './fake-assembly'; test('Extract snippet from README', () => { @@ -248,7 +248,7 @@ test('rosetta fixture from submodule is preferred if it exists', async () => { }, { name: 'my_assembly', - jsii: DUMMY_ASSEMBLY_TARGETS, + jsii: DUMMY_JSII_CONFIG, }, ); try { diff --git a/packages/jsii-rosetta/test/record-references.test.ts b/packages/jsii-rosetta/test/record-references.test.ts index b4c8b7ed5b..72fd3d6bcc 100644 --- a/packages/jsii-rosetta/test/record-references.test.ts +++ b/packages/jsii-rosetta/test/record-references.test.ts @@ -1,4 +1,4 @@ -import { TestJsiiModule, DUMMY_ASSEMBLY_TARGETS } from './testutil'; +import { TestJsiiModule, DUMMY_JSII_CONFIG } from './testutil'; let assembly: TestJsiiModule; beforeAll(async () => { @@ -29,7 +29,7 @@ beforeAll(async () => { }, { name: 'my_assembly', - jsii: DUMMY_ASSEMBLY_TARGETS, + jsii: DUMMY_JSII_CONFIG, }, ); }); diff --git a/packages/jsii-rosetta/test/rosetta-translator.test.ts b/packages/jsii-rosetta/test/rosetta-translator.test.ts index 3397dc40d1..798990fb79 100644 --- a/packages/jsii-rosetta/test/rosetta-translator.test.ts +++ b/packages/jsii-rosetta/test/rosetta-translator.test.ts @@ -3,6 +3,8 @@ import { withTemporaryDirectory } from './testutil'; const location: SnippetLocation = { api: { api: 'file', fileName: 'test.ts' } }; +jest.setTimeout(60_000); + test('translator can translate', async () => { const translator = new RosettaTranslator({ includeCompilerDiagnostics: true, diff --git a/packages/jsii-rosetta/test/syntax-counter.test.ts b/packages/jsii-rosetta/test/syntax-counter.test.ts index 285f0d276d..2f3a424c26 100644 --- a/packages/jsii-rosetta/test/syntax-counter.test.ts +++ b/packages/jsii-rosetta/test/syntax-counter.test.ts @@ -1,4 +1,4 @@ -import { TestJsiiModule, DUMMY_ASSEMBLY_TARGETS } from './testutil'; +import { TestJsiiModule, DUMMY_JSII_CONFIG } from './testutil'; let assembly: TestJsiiModule; beforeAll(async () => { @@ -18,7 +18,7 @@ beforeAll(async () => { `, { name: 'my_assembly', - jsii: DUMMY_ASSEMBLY_TARGETS, + jsii: DUMMY_JSII_CONFIG, }, ); }); diff --git a/packages/jsii-rosetta/test/testutil.ts b/packages/jsii-rosetta/test/testutil.ts index ff39f98007..6708fc044e 100644 --- a/packages/jsii-rosetta/test/testutil.ts +++ b/packages/jsii-rosetta/test/testutil.ts @@ -5,11 +5,12 @@ import * as os from 'os'; import * as path from 'path'; import { - typeScriptSnippetFromSource, SnippetTranslator, SnippetParameters, rosettaDiagFromTypescript, SnippetLocation, + typeScriptSnippetFromCompleteSource, + Translator, } from '../lib'; export type MultipleSources = { [key: string]: string; 'index.ts': string }; @@ -43,6 +44,8 @@ export class TestJsiiModule { jsii: packageInfo.jsii, }); for (const [fileName, fileContents] of Object.entries(files)) { + // eslint-disable-next-line no-await-in-loop + await fs.ensureDir(path.dirname(path.join(modDir, fileName))); // eslint-disable-next-line no-await-in-loop await fs.writeFile(path.join(modDir, fileName), fileContents); } @@ -61,7 +64,7 @@ export class TestJsiiModule { */ public successfullyCompile(source: string) { const location = testSnippetLocation('testutil'); - const snippet = typeScriptSnippetFromSource(source, location, false, { + const snippet = typeScriptSnippetFromCompleteSource(source, location, false, { [SnippetParameters.$COMPILATION_DIRECTORY]: this.workspaceDirectory, }); const ret = new SnippetTranslator(snippet, { @@ -76,6 +79,23 @@ export class TestJsiiModule { return ret; } + public translateHere(source: string) { + const location = testSnippetLocation('testutil'); + const snip = typeScriptSnippetFromCompleteSource(source.trimLeft(), location, true, { + [SnippetParameters.$COMPILATION_DIRECTORY]: this.workspaceDirectory, + }); + + const trans = new Translator(true); + const ret = trans.translate(snip); + if (trans.diagnostics.length > 0) { + for (const diag of trans.diagnostics) { + console.error(diag.formattedMessage); + } + throw new Error('Compilation failures'); + } + return ret; + } + public async cleanup() { await fs.remove(this.moduleDirectory); } @@ -85,22 +105,24 @@ export function testSnippetLocation(fileName: string): SnippetLocation { return { api: { api: 'file', fileName }, field: { field: 'example' } }; } -export const DUMMY_ASSEMBLY_TARGETS = { - dotnet: { - namespace: 'Example.Test.Demo', - packageId: 'Example.Test.Demo', - }, - go: { moduleName: 'example.test/demo' }, - java: { - maven: { - groupId: 'example.test', - artifactId: 'demo', +export const DUMMY_JSII_CONFIG = { + targets: { + dotnet: { + namespace: 'Example.Test.Demo', + packageId: 'Example.Test.Demo', + }, + go: { moduleName: 'example.test/demo' }, + java: { + maven: { + groupId: 'example.test', + artifactId: 'demo', + }, + package: 'example.test.demo', + }, + python: { + distName: 'example-test.demo', + module: 'example_test_demo', }, - package: 'example.test.demo', - }, - python: { - distName: 'example-test.demo', - module: 'example_test_demo', }, }; diff --git a/packages/jsii-rosetta/test/translations.test.ts b/packages/jsii-rosetta/test/translations.test.ts index c37075ae60..86b45e5aca 100644 --- a/packages/jsii-rosetta/test/translations.test.ts +++ b/packages/jsii-rosetta/test/translations.test.ts @@ -1,10 +1,9 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { JavaVisitor, PythonVisitor, SnippetTranslator } from '../lib'; -import { CSharpVisitor } from '../lib/languages/csharp'; +import { SnippetTranslator } from '../lib'; +import { TARGET_LANGUAGES, TargetLanguage, VisitorFactory } from '../lib/languages'; import { VisualizeAstVisitor } from '../lib/languages/visualize'; -import { AstHandler } from '../lib/renderer'; import { testSnippetLocation } from './testutil'; // This iterates through all subdirectories of this directory, @@ -28,24 +27,24 @@ interface SupportedLanguage { readonly extension: string; - readonly visitor: AstHandler; + readonly visitorFactory: VisitorFactory; } -const SUPPORTED_LANGUAGES = new Array( +export const SUPPORTED_LANGUAGES = new Array( { name: 'Python', extension: '.py', - visitor: new PythonVisitor(), + visitorFactory: TARGET_LANGUAGES[TargetLanguage.PYTHON], }, { name: 'Java', extension: '.java', - visitor: new JavaVisitor(), + visitorFactory: TARGET_LANGUAGES[TargetLanguage.JAVA], }, { name: 'C#', extension: '.cs', - visitor: new CSharpVisitor(), + visitorFactory: TARGET_LANGUAGES[TargetLanguage.CSHARP], }, ); @@ -78,7 +77,7 @@ for (const typeScriptTest of typeScriptTests) { translator = undefined as any; // Need this to properly release memory }); - for (const { name, extension, visitor } of SUPPORTED_LANGUAGES) { + for (const { name, extension, visitorFactory } of SUPPORTED_LANGUAGES) { const languageFile = replaceExtension(typeScriptTest, extension); // Use 'test.skip' if the file doesn't exist so that we can clearly see it's missing. @@ -87,7 +86,7 @@ for (const typeScriptTest of typeScriptTests) { testConstructor(`to ${name}`, () => { const expected = fs.readFileSync(languageFile, { encoding: 'utf-8' }); try { - const translation = translator.renderUsing(visitor); + const translation = translator.renderUsing(visitorFactory.createVisitor()); expect(stripEmptyLines(translation)).toEqual(stripEmptyLines(stripCommonWhitespace(expected))); } catch (e) { anyFailed = true; diff --git a/packages/jsii/lib/assembler.ts b/packages/jsii/lib/assembler.ts index 200a3b1b50..ad69225cbc 100644 --- a/packages/jsii/lib/assembler.ts +++ b/packages/jsii/lib/assembler.ts @@ -628,6 +628,7 @@ export class Assembler implements Emitter { this._submodules.set(symbol, { fqn, fqnResolutionPrefix, + symbolId: symbolIdentifier(this._typeChecker, symbol), locationInModule: this.declarationLocation(declaration), }); await this._addToSubmodule(symbol, symbol, packageRoot); @@ -707,6 +708,7 @@ export class Assembler implements Emitter { fqnResolutionPrefix, targets, readme, + symbolId: symbolIdentifier(this._typeChecker, symbol), locationInModule: this.declarationLocation(declaration), }); await this._addToSubmodule(symbol, sourceModule, packageRoot); @@ -2811,6 +2813,11 @@ interface SubmoduleSpec { */ readonly locationInModule: spec.SourceLocation; + /** + * Symbol identifier of the root of the root file that represents this submodule + */ + readonly symbolId?: string; + /** * Any customized configuration for the currentl submodule. */ @@ -3150,6 +3157,7 @@ function toSubmoduleDeclarations( locationInModule: submodule.locationInModule, targets: submodule.targets, readme: submodule.readme, + symbolId: submodule.symbolId, }; } diff --git a/packages/jsii/lib/symbol-id.ts b/packages/jsii/lib/symbol-id.ts index c58deb827a..2c6b189e49 100644 --- a/packages/jsii/lib/symbol-id.ts +++ b/packages/jsii/lib/symbol-id.ts @@ -6,6 +6,11 @@ export function symbolIdentifier( typeChecker: ts.TypeChecker, sym: ts.Symbol, ): string | undefined { + // If this symbol happens to be an alias, resolve it first + while ((sym.flags & ts.SymbolFlags.Alias) !== 0) { + sym = typeChecker.getAliasedSymbol(sym); + } + const inFileNameParts: string[] = []; let decl: ts.Node | undefined = sym.declarations?.[0]; diff --git a/packages/jsii/test/symbol-identifiers.test.ts b/packages/jsii/test/symbol-identifiers.test.ts index 6c221e3b57..995b467305 100644 --- a/packages/jsii/test/symbol-identifiers.test.ts +++ b/packages/jsii/test/symbol-identifiers.test.ts @@ -51,3 +51,44 @@ test('Module declarations are included in symbolId', async () => { const types = result.assembly.types ?? {}; expect(types['testpkg.Foo.Bar'].symbolId).toEqual('index:Foo.Bar'); }); + +test('Submodules also have symbol identifiers', async () => { + const result = await compileJsiiForTest( + { + 'index.ts': `export * as submod from './submodule';`, + 'submodule.ts': ` + export class Foo { + constructor() { + } + } + `, + }, + undefined /* callback */, + { stripDeprecated: true }, + ); + + expect(result.assembly.submodules?.['testpkg.submod']?.symbolId).toEqual( + 'submodule:', + ); +}); + +test('Submodules also have symbol identifiers', async () => { + const result = await compileJsiiForTest( + { + 'index.ts': ` + export namespace cookie { + export class Foo { + constructor() { + } + } + } + `, + }, + undefined /* callback */, + { stripDeprecated: true }, + ); + + expect(result.assembly.submodules?.['testpkg.cookie']?.symbolId).toEqual( + 'index:cookie', + ); +});