From 9fe9d97c1dd3ef15da98060bcf49351906ba0368 Mon Sep 17 00:00:00 2001 From: Steve Rice Date: Tue, 30 Sep 2025 00:01:02 -0700 Subject: [PATCH] Proof of concept for Transformers --- packages/compiler/src/config/config-schema.ts | 25 ++ packages/compiler/src/config/types.ts | 9 + packages/compiler/src/core/library.ts | 12 + packages/compiler/src/core/options.ts | 5 +- packages/compiler/src/core/program.ts | 37 ++- packages/compiler/src/core/stats.ts | 6 + packages/compiler/src/core/transformer.ts | 240 +++++++++++++++ packages/compiler/src/core/types.ts | 50 +++ packages/compiler/src/index.ts | 7 +- packages/compiler/src/testing/index.ts | 1 + packages/compiler/src/testing/tester.ts | 94 +++++- packages/compiler/src/testing/types.ts | 35 ++- packages/compiler/test/core/linter.test.ts | 4 +- .../compiler/test/core/transformer.test.ts | 139 +++++++++ packages/compiler/test/testing/tester.test.ts | 37 +++ packages/graphql/package.json | 7 +- packages/graphql/src/index.ts | 1 + packages/graphql/src/lib/type-utils.ts | 285 ++++++++++++++++++ packages/graphql/src/transformer.ts | 13 + packages/graphql/src/transformers/index.ts | 0 .../transformers/rename-types.transform.ts | 30 ++ .../rename-types.transform.test.ts | 141 +++++++++ packages/graphql/tsconfig.json | 7 +- pnpm-lock.yaml | 7 +- 24 files changed, 1171 insertions(+), 21 deletions(-) create mode 100644 packages/compiler/src/core/transformer.ts create mode 100644 packages/compiler/test/core/transformer.test.ts create mode 100644 packages/graphql/src/lib/type-utils.ts create mode 100644 packages/graphql/src/transformer.ts create mode 100644 packages/graphql/src/transformers/index.ts create mode 100644 packages/graphql/src/transformers/rename-types.transform.ts create mode 100644 packages/graphql/test/transformers/rename-types.transform.test.ts diff --git a/packages/compiler/src/config/config-schema.ts b/packages/compiler/src/config/config-schema.ts index 0df1c8b9333..d1705f46947 100644 --- a/packages/compiler/src/config/config-schema.ts +++ b/packages/compiler/src/config/config-schema.ts @@ -103,5 +103,30 @@ export const TypeSpecConfigJsonSchema: JSONSchemaType = { }, }, } as any, // ajv type system doesn't like the string templates + transformer: { + type: "object", + nullable: true, + required: [], + additionalProperties: false, + properties: { + extends: { + type: "array", + nullable: true, + items: { type: "string" }, + }, + enable: { + type: "object", + required: [], + nullable: true, + additionalProperties: { type: "boolean" }, + }, + disable: { + type: "object", + required: [], + nullable: true, + additionalProperties: { type: "string" }, + }, + }, + } as any, // ajv type system doesn't like the string templates }, }; diff --git a/packages/compiler/src/config/types.ts b/packages/compiler/src/config/types.ts index f38d07a1173..66c6b5a4358 100644 --- a/packages/compiler/src/config/types.ts +++ b/packages/compiler/src/config/types.ts @@ -69,6 +69,8 @@ export interface TypeSpecConfig { options?: Record; linter?: LinterConfig; + + transformer?: TransformerConfig; } /** @@ -88,6 +90,7 @@ export interface TypeSpecRawConfig { options?: Record; linter?: LinterConfig; + transformer?: TransformerConfig; } export interface ConfigEnvironmentVariable { @@ -107,3 +110,9 @@ export interface LinterConfig { enable?: Record; disable?: Record; } + +export interface TransformerConfig { + extends?: RuleRef[]; + enable?: Record; + disable?: Record; +} diff --git a/packages/compiler/src/core/library.ts b/packages/compiler/src/core/library.ts index 24687c6f774..882400b8283 100644 --- a/packages/compiler/src/core/library.ts +++ b/packages/compiler/src/core/library.ts @@ -9,6 +9,8 @@ import { LinterRuleDefinition, PackageFlags, StateDef, + TransformDefinition, + TransformerDefinition, TypeSpecLibrary, TypeSpecLibraryDef, } from "./types.js"; @@ -116,6 +118,16 @@ export function createLinterRule(definition: TransformDefinition) { + compilerAssert(!definition.name.includes("/"), "Transform name cannot contain a '/'."); + return definition; +} + /** * Set the TypeSpec namespace for that function. * @param namespace Namespace string (e.g. "Foo.Bar") diff --git a/packages/compiler/src/core/options.ts b/packages/compiler/src/core/options.ts index 6f5d88140f1..7f40683855d 100644 --- a/packages/compiler/src/core/options.ts +++ b/packages/compiler/src/core/options.ts @@ -1,5 +1,5 @@ import { EmitterOptions, TypeSpecConfig } from "../config/types.js"; -import { LinterRuleSet, ParseOptions } from "./types.js"; +import { LinterRuleSet, ParseOptions, TransformSet } from "./types.js"; export interface CompilerOptions { miscOptions?: Record; @@ -68,6 +68,9 @@ export interface CompilerOptions { /** Ruleset to enable for linting. */ linterRuleSet?: LinterRuleSet; + /** Transform set to enable for transformation. */ + transformSet?: TransformSet; + /** @internal */ readonly configFile?: TypeSpecConfig; } diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 59fecec7ec0..eaa809b1cc4 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -43,6 +43,12 @@ import { } from "./source-loader.js"; import { createStateAccessors } from "./state-accessors.js"; import { ComplexityStats, RuntimeStats, Stats, startTimer, time, timeAsync } from "./stats.js"; +import { + builtInTransformerLibraryName, + createBuiltInTransformerLibrary, + createTransformer, + resolveTransformerDefinition, +} from "./transformer.js"; import { CompilerHost, Diagnostic, @@ -52,8 +58,8 @@ import { EmitterFunc, Entity, JsSourceFileNode, - LibraryInstance, LibraryMetadata, + LinterLibraryInstance, LiteralType, LocationContext, LogSink, @@ -68,6 +74,7 @@ import { SyntaxKind, TemplateInstanceTarget, Tracer, + TransformerLibraryInstance, Type, TypeSpecLibrary, TypeSpecScriptNode, @@ -133,13 +140,15 @@ export interface Program { readonly projectRoot: string; } +export interface TransformedProgram extends Program {} + interface EmitterRef { emitFunction: EmitterFunc; main: string; metadata: LibraryMetadata; emitterOutputDir: string; options: Record; - readonly library: LibraryInstance; + readonly library: LinterLibraryInstance & TransformerLibraryInstance; } interface Validator { @@ -204,7 +213,7 @@ async function createProgram( mainFile: string, options: CompilerOptions = {}, oldProgram?: Program, -): Promise<{ program: Program; shouldAbort: boolean }> { +): Promise<{ program: TransformedProgram; shouldAbort: boolean }> { const runtimeStats: Partial = {}; const validateCbs: Validator[] = []; const stateMaps = new Map>(); @@ -299,6 +308,16 @@ async function createProgram( program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); } + const transformer = createTransformer(program, (name) => loadLibrary(basedir, name)); + // Register built-in transformer library (currently empty placeholder) + transformer.registerTransformLibrary( + builtInTransformerLibraryName, + createBuiltInTransformerLibrary(), + ); + if (options.transformSet) { + program.reportDiagnostics(await transformer.extendTransformSet(options.transformSet)); + } + program.checker = createChecker(program, resolver); runtimeStats.checker = time(() => program.checker.checkProgram()); @@ -325,7 +344,12 @@ async function createProgram( runtimeStats.linter = lintResult.stats.runtime; program.reportDiagnostics(lintResult.diagnostics); - return { program, shouldAbort: false }; + // Transform stage + const transformResult = transformer.transform(); + runtimeStats.transformer = transformResult.stats.runtime; + program.reportDiagnostics(transformResult.diagnostics); + + return { program: transformResult.program, shouldAbort: false }; /** * Validate the libraries loaded during the compilation process are compatible. @@ -503,7 +527,7 @@ async function createProgram( async function loadLibrary( basedir: string, libraryNameOrPath: string, - ): Promise { + ): Promise<(LinterLibraryInstance & TransformerLibraryInstance) | undefined> { const [resolution, diagnostics] = await resolveEmitterModuleAndEntrypoint( basedir, libraryNameOrPath, @@ -518,11 +542,14 @@ async function createProgram( const libDefinition: TypeSpecLibrary | undefined = entrypoint?.esmExports.$lib; const metadata = computeLibraryMetadata(module, libDefinition); const linterDef = entrypoint?.esmExports.$linter; + const transformerDef = entrypoint?.esmExports.$transformer; return { ...resolution, metadata, definition: libDefinition, linter: linterDef && resolveLinterDefinition(libraryNameOrPath, linterDef), + transformer: + transformerDef && resolveTransformerDefinition(libraryNameOrPath, transformerDef), }; } diff --git a/packages/compiler/src/core/stats.ts b/packages/compiler/src/core/stats.ts index 936339ad103..05ca4bf666f 100644 --- a/packages/compiler/src/core/stats.ts +++ b/packages/compiler/src/core/stats.ts @@ -25,6 +25,12 @@ export interface RuntimeStats { [rule: string]: number; }; }; + transformer: { + total: number; + transforms: { + [transform: string]: number; + }; + }; emit: { total: number; emitters: { diff --git a/packages/compiler/src/core/transformer.ts b/packages/compiler/src/core/transformer.ts new file mode 100644 index 00000000000..8585a2d01bb --- /dev/null +++ b/packages/compiler/src/core/transformer.ts @@ -0,0 +1,240 @@ +import { mutateSubgraphWithNamespace } from "../experimental/mutators.js"; +import { compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; +import type { Program, TransformedProgram } from "./program.js"; +import { startTimer } from "./stats.js"; +import { + Diagnostic, + NoTarget, + Transform, + TransformSet, + TransformSetRef, + TransformerDefinition, + TransformerResolvedDefinition, +} from "./types.js"; + +type TransformerLibraryInstance = { transformer: TransformerResolvedDefinition }; + +export interface Transformer { + extendTransformSet(transformSet: TransformSet): Promise; + registerTransformLibrary(name: string, lib?: TransformerLibraryInstance): void; + transform(): TransformerResult; +} + +export interface TransformerStats { + runtime: { + total: number; + transforms: Record; + }; +} +export interface TransformerResult { + readonly diagnostics: readonly Diagnostic[]; + readonly program: TransformedProgram; + readonly stats: TransformerStats; +} + +/** Resolve a transformer definition for a library. */ +export function resolveTransformerDefinition( + libName: string, + transformer: TransformerDefinition, +): TransformerResolvedDefinition { + const transforms: Transform[] = transformer.transforms.map((t) => { + return { ...t, id: `${libName}/${t.name}` }; + }); + if ( + transformer.transforms.length === 0 || + (transformer.transformSets && "all" in transformer.transformSets) + ) { + return { + transforms, + transformSets: transformer.transformSets ?? {}, + }; + } else { + return { + transforms, + transformSets: { + all: { + enable: Object.fromEntries(transforms.map((x) => [x.id, true])) as any, + }, + ...transformer.transformSets, + }, + }; + } +} + +export function createTransformer( + program: Program, + loadLibrary: (name: string) => Promise, +): Transformer { + const tracer = program.tracer.sub("transformer"); + + const transformMap = new Map>(); + const enabledTransforms = new Map>(); + const transformerLibraries = new Map(); + + return { + extendTransformSet, + registerTransformLibrary, + transform, + }; + + async function extendTransformSet(transformSet: TransformSet): Promise { + tracer.trace("extend-transform-set.start", JSON.stringify(transformSet, null, 2)); + const diagnostics = createDiagnosticCollector(); + if (transformSet.extends) { + for (const extendingTransformSetName of transformSet.extends) { + const ref = parseTransformReference(extendingTransformSetName); + if (ref) { + const library = await resolveLibrary(ref.libraryName); + const libTransformerDefinition = library?.transformer; + const extendingTransformSet = libTransformerDefinition?.transformSets?.[ref.name]; + if (extendingTransformSet) { + await extendTransformSet(extendingTransformSet); + } else { + diagnostics.add({ + code: "unknown-transform-set", + message: `Unknown transform set '${ref.name}' in library '${ref.libraryName}'.`, + severity: "warning", + target: NoTarget, + } as Diagnostic); + } + } + } + } + + const enabledInThisSet = new Set(); + if (transformSet.enable) { + for (const [transformName, enable] of Object.entries(transformSet.enable)) { + if (enable === false) { + continue; + } + const ref = parseTransformReference(transformName as TransformSetRef); + if (ref) { + await resolveLibrary(ref.libraryName); + const transform = transformMap.get(transformName); + if (transform) { + enabledInThisSet.add(transformName); + enabledTransforms.set(transformName, transform); + } else { + diagnostics.add({ + code: "unknown-transform", + message: `Unknown transform '${ref.name}' in library '${ref.libraryName}'.`, + severity: "warning", + target: NoTarget, + } as Diagnostic); + } + } + } + } + + if (transformSet.disable) { + for (const transformName of Object.keys(transformSet.disable)) { + if (enabledInThisSet.has(transformName)) { + diagnostics.add({ + code: "transform-enabled-disabled", + message: `Transform '${transformName}' cannot be both enabled and disabled.`, + severity: "warning", + target: NoTarget, + } as Diagnostic); + } + enabledTransforms.delete(transformName); + } + } + tracer.trace( + "extend-transform-set.end", + "Transforms enabled: \n" + [...enabledTransforms.keys()].map((x) => ` - ${x}`).join("\n"), + ); + + return diagnostics.diagnostics; + } + + function transform(): TransformerResult { + const diagnostics = createDiagnosticCollector(); + const stats: TransformerStats = { + runtime: { + total: 0, + transforms: {}, + }, + }; + tracer.trace( + "transform", + `Running transformer with following transforms:\n` + + [...enabledTransforms.keys()].map((x) => ` - ${x}`).join("\n"), + ); + + const timer = startTimer(); + for (const t of enabledTransforms.values()) { + const createTiming = startTimer(); + // TODO is this okay? + mutateSubgraphWithNamespace(program, t.mutators, program.getGlobalNamespaceType()); + stats.runtime.transforms[t.id] = createTiming.end(); + // TODO fix timing + // for (const [name, cb] of Object.entries(listener)) { + // const timedCb = (...args: any[]) => { + // const duration = time(() => (cb as any)(...args)); + // stats.runtime.transforms[t.id] += duration; + // }; + // eventEmitter.on(name as any, timedCb); + // } + } + // navigateProgram(program, mapEventEmitterToNodeListener(eventEmitter)); + stats.runtime.total = timer.end(); + // For now, return the original program as the transformed program placeholder. + return { diagnostics: diagnostics.diagnostics, program: program as TransformedProgram, stats }; + } + + async function resolveLibrary(name: string): Promise { + const loadedLibrary = transformerLibraries.get(name); + if (loadedLibrary === undefined) { + return registerTransformLibrary(name); + } + return loadedLibrary; + } + + async function registerTransformLibrary( + name: string, + lib?: TransformerLibraryInstance, + ): Promise { + tracer.trace("register-library", name); + + const library = lib ?? (await loadLibrary(name)); + const transformer = library?.transformer; + if (transformer?.transforms) { + for (const t of transformer.transforms) { + tracer.trace( + "register-library.transform", + `Registering transform "${t.id}" for library "${name}".`, + ); + if (transformMap.has(t.id)) { + compilerAssert(false, `Unexpected duplicate transform: "${t.id}"`); + } else { + transformMap.set(t.id, t); + } + } + } + transformerLibraries.set(name, library); + + return library; + } + + function parseTransformReference( + ref: TransformSetRef, + ): { libraryName: string; name: string } | undefined { + const segments = ref.split("/"); + const name = segments.pop(); + const libraryName = segments.join("/"); + if (!libraryName || !name) { + return undefined; + } + return { libraryName, name }; + } +} + +export const builtInTransformerLibraryName = `@typespec/compiler/transformers`; +export function createBuiltInTransformerLibrary(): TransformerLibraryInstance { + // No built-in transforms yet; provide an empty definition. + const empty: TransformerResolvedDefinition = { + transforms: [], + transformSets: {}, + }; + return { transformer: empty }; +} diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 912701f3a82..b30ae463746 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1,4 +1,5 @@ import type { JSONSchemaType as AjvJSONSchemaType } from "ajv"; +import { Mutator } from "../experimental/mutators.js"; import type { ModuleResolutionResult } from "../module-resolver/module-resolver.js"; import type { YamlPathTarget, YamlScript } from "../yaml/types.js"; import type { Numeric } from "./numeric.js"; @@ -1894,9 +1895,16 @@ export interface LibraryInstance { entrypoint: JsSourceFileNode; metadata: LibraryMetadata; definition?: TypeSpecLibrary; +} + +export interface LinterLibraryInstance extends LibraryInstance { linter: LinterResolvedDefinition; } +export interface TransformerLibraryInstance extends LibraryInstance { + transformer: TransformerResolvedDefinition; +} + export type LibraryMetadata = FileLibraryMetadata | ModuleLibraryMetadata; interface LibraryMetadataBase { @@ -2384,6 +2392,48 @@ export type LinterRuleDiagnosticReport< M extends keyof T = "default", > = LinterRuleDiagnosticReportWithoutTarget & { target: DiagnosticTarget | typeof NoTarget }; +export interface TransformerDefinition { + transforms: TransformDefinition[]; + transformSets?: Record; +} + +export interface TransformerResolvedDefinition { + readonly transforms: Transform[]; + readonly transformSets: { + [name: string]: TransformSet; + }; +} + +export interface TransformDefinition { + /** Transform name (without the library name) */ + name: N; + /** Short description of the transform */ + description: string; + /** Specifies the URL at which the full documentation can be accessed. */ + url?: string; + /** Creator */ + mutators: Mutator[]; +} + +/** Resolved instance of a transform that will run. */ +export interface Transform extends TransformDefinition { + /** Expanded transform id in format `:` */ + id: string; +} + +/** Reference to a transform. In this format `:` */ +export type TransformSetRef = `${string}/${string}`; +export interface TransformSet { + /** Other transformset this transformset extends */ + extends?: TransformSetRef[]; + + /** Transforms to enable/configure */ + enable?: Record; + + /** Transforms to disable. A transform CANNOT be in enable and disable map. */ + disable?: Record; +} + export interface TypeSpecLibrary< T extends { [code: string]: DiagnosticMessages }, E extends Record = Record, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 42d965f8e81..fbce86ae01e 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -93,9 +93,11 @@ export { } from "./core/intrinsic-type-state.js"; export { createLinterRule as createRule, + createTransform, createTypeSpecLibrary, defineLinter, definePackageFlags, + defineTransformer, paramMessage, setTypeSpecNamespace, } from "./core/library.js"; @@ -358,12 +360,12 @@ export type { IntrinsicScalarName, IntrinsicType, JSONSchemaType, - LibraryInstance, LibraryLocationContext, LibraryMetadata, LineAndCharacter, LineComment, LinterDefinition, + LinterLibraryInstance, LinterResolvedDefinition, LinterRule, LinterRuleContext, @@ -439,6 +441,9 @@ export type { TextRange, Tracer, TracerOptions, + TransformDefinition, + TransformerDefinition, + TransformerLibraryInstance, Tuple, Type, TypeInstantiationMap, diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index 80fbe8465c0..8dc10a6f01f 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -42,6 +42,7 @@ export type { TestHostError, Tester, TesterInstance, + TransformerTesterInstance, TypeSpecTestLibrary, TypeSpecTestLibraryInit, } from "./types.js"; diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index f2801115515..53e34d2b44e 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -8,7 +8,14 @@ import { getIdentifierContext, getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; -import { CompilerHost, Diagnostic, Entity, NoTarget, SourceFile } from "../core/types.js"; +import { + CompilerHost, + Diagnostic, + Entity, + NoTarget, + SourceFile, + TransformSet, +} from "../core/types.js"; import { resolveModule } from "../module-resolver/module-resolver.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { extractMarkers } from "./fourslash.js"; @@ -16,7 +23,7 @@ import { createTestFileSystem } from "./fs.js"; import { GetMarkedEntities, Marker, TemplateWithMarkers } from "./marked-template.js"; import { StandardTestLibrary, addTestLib } from "./test-compiler-host.js"; import { resolveVirtualPath } from "./test-utils.js"; -import type { +import { EmitterTester, EmitterTesterInstance, MockFile, @@ -28,6 +35,8 @@ import type { Tester, TesterBuilder, TesterInstance, + TransformerTester, + TransformerTesterInstance, } from "./types.js"; export interface TesterOptions { @@ -139,6 +148,10 @@ interface EmitterTesterInternalParams extends TesterInternalParams { emitter: string; } +interface TransformerTesterInternalParams extends TesterInternalParams { + transformSet: TransformSet; +} + function createTesterBuilder< const I extends TesterInternalParams, const O extends TesterBuilder, @@ -203,6 +216,7 @@ function createTesterInternal(params: TesterInternalParams): Tester { ...createTesterBuilder(params, createTesterInternal), emit, createInstance, + transformer, }; function emit(emitter: string, options?: Record): EmitterTester { @@ -221,6 +235,16 @@ function createTesterInternal(params: TesterInternalParams): Tester { }); } + function transformer( + transformSet: TransformSet, + options?: Record, + ): TransformerTester { + return createTransformerTesterInternal({ + ...params, + transformSet, + }); + } + function createInstance(): Promise { return createTesterInstance(params); } @@ -250,6 +274,41 @@ function createEmitterTesterInternal( }; } +function createTransformerTesterInternal( + params: TransformerTesterInternalParams, +): TransformerTester { + return { + ...createCompilable(async (...args) => { + const instance = await createTransformerTesterInstance(params); + return instance.compileAndDiagnose(...args); + }), + ...createTesterBuilder( + params, + createTransformerTesterInternal, + ), + // transform, + createInstance: () => createTransformerTesterInstance(params), + }; + + // async function transform< + // T extends string | TemplateWithMarkers | Record>, + // >( + // code: T, + // options?: TestTransformOptions, + // ): Promise>> { + // const tester = await createTesterInstance(params); + // + // // todo + // return tester.compile(code, { + // ...options, + // compilerOptions: { + // ...(options ?? {}).compilerOptions, + // transformSet: params.transformSet, + // }, + // }); + // } +} + async function createEmitterTesterInstance( params: EmitterTesterInternalParams, ): Promise> { @@ -299,6 +358,37 @@ async function createEmitterTesterInstance( } } +async function createTransformerTesterInstance( + params: TransformerTesterInternalParams, +): Promise { + const tester = await createTesterInstance(params); + return { + fs: tester.fs, + ...createCompilable(compileAndDiagnose), + get program() { + return tester.program; + }, + }; + + async function compileAndDiagnose( + code: string | TemplateWithMarkers | Record>, + options?: TestCompileOptions, + ): Promise<[TestCompileResult, readonly Diagnostic[]]> { + if (options?.compilerOptions?.transformSet !== undefined) { + throw new Error("Cannot set transformSet in options."); + } + const resolvedOptions: TestCompileOptions = { + ...options, + compilerOptions: { + ...params.compilerOptions, + ...options?.compilerOptions, + transformSet: params.transformSet, + }, + }; + return tester.compileAndDiagnose(code, resolvedOptions); + } +} + async function createTesterInstance(params: TesterInternalParams): Promise { let savedProgram: Program | undefined; const fs = (await params.fs()).clone(); diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index b35b08ac683..e88c46d6df6 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -1,6 +1,7 @@ +import { TransformerConfig } from "../config/index.js"; import type { CompilerOptions } from "../core/options.js"; -import type { Program } from "../core/program.js"; -import type { CompilerHost, Diagnostic, Entity, Type } from "../core/types.js"; +import type { Program, TransformedProgram } from "../core/program.js"; +import { CompilerHost, Diagnostic, Entity, TransformSet, Type } from "../core/types.js"; import { PositionedMarker } from "./fourslash.js"; import { GetMarkedEntities, TemplateWithMarkers } from "./marked-template.js"; @@ -65,6 +66,8 @@ export interface TestCompileOptions { readonly compilerOptions?: CompilerOptions; } +export interface TestTransformOptions extends TestCompileOptions {} + interface Testable { /** * Compile the given code and validate no diagnostics(error or warnings) are present. @@ -146,6 +149,11 @@ export interface Tester extends Testable, TesterBuilder { * @param options - Options to pass to the emitter */ emit(emitter: string, options?: Record): EmitterTester; + /** + * Create a transformer tester + * @param options - Options to pass to the transformer + */ + transformer(transformSet: TransformSet, options?: TransformerConfig): TransformerTester; /** Create an instance of the tester */ createInstance(): Promise; } @@ -158,6 +166,11 @@ export interface TestEmitterCompileResult { readonly outputs: Record; } +export type TestTransformResult> = T & { + /** The program created in this transform. */ + readonly program: TransformedProgram; +} & Record; + export interface OutputTestable { compile(code: string | Record, options?: TestCompileOptions): Promise; compileAndDiagnose( @@ -193,6 +206,19 @@ export interface EmitterTester createInstance(): Promise>; } +/** Alternate version of the tester which runs the configured transformer */ +export interface TransformerTester extends Testable, TesterBuilder { + // transform< + // T extends string | TemplateWithMarkers | Record>, + // >( + // code: T, + // options?: TestTransformOptions, + // ): Promise>>; + + /** Create a mutable instance of the tester */ + createInstance(): Promise; +} + export interface TesterInstanceBase { /** Program created. Only available after calling `compile`, `diagnose` or `compileAndDiagnose` */ get program(): Program; @@ -211,6 +237,11 @@ export interface PositionedMarkerInFile extends PositionedMarker { readonly filename: string; } +/** Instance of a transformer tester */ +export interface TransformerTesterInstance extends TesterInstance { + get program(): TransformedProgram; +} + // #endregion // #region Legacy Test host diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 08137ace738..b1cff1aa0ce 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "vitest"; import { createLinterRule, createTypeSpecLibrary } from "../../src/core/library.js"; import { Linter, createLinter, resolveLinterDefinition } from "../../src/core/linter.js"; -import type { LibraryInstance, LinterDefinition } from "../../src/index.js"; +import type { LinterDefinition, LinterLibraryInstance } from "../../src/index.js"; import { createTestHost, expectDiagnosticEmpty, @@ -43,7 +43,7 @@ describe("compiler: linter", () => { } } - const library: LibraryInstance = { + const library: LinterLibraryInstance = { entrypoint: {} as any, metadata: { type: "module", name: "@typespec/test-linter" }, module: { type: "module", path: "", mainFile: "", manifest: { name: "", version: "" } }, diff --git a/packages/compiler/test/core/transformer.test.ts b/packages/compiler/test/core/transformer.test.ts new file mode 100644 index 00000000000..325bb13291a --- /dev/null +++ b/packages/compiler/test/core/transformer.test.ts @@ -0,0 +1,139 @@ +import { describe, it } from "vitest"; + +import { + Transformer, + createTransformer, + resolveTransformerDefinition, +} from "../../src/core/transformer.js"; +import type { TransformerDefinition, TransformerLibraryInstance } from "../../src/index.js"; +import { createTransform, createTypeSpecLibrary } from "../../src/index.js"; +import { + createTestHost, + expectDiagnosticEmpty, + expectDiagnostics, +} from "../../src/testing/index.js"; + +const noopTransform = createTransform({ + name: "noop", + description: "No operation transform", + mutators: [], +}); + +describe("compiler: transformer", () => { + async function createTestTransformer( + code: string | Record, + transformerDef: TransformerDefinition, + ): Promise { + const host = await createTestHost(); + if (typeof code === "string") { + host.addTypeSpecFile("main.tsp", code); + } else { + for (const [name, content] of Object.entries(code)) { + host.addTypeSpecFile(name, content); + } + } + + const library: TransformerLibraryInstance = { + entrypoint: {} as any, + metadata: { type: "module", name: "@typespec/test-transformer" }, + module: { type: "module", path: "", mainFile: "", manifest: { name: "", version: "" } }, + definition: createTypeSpecLibrary({ + name: "@typespec/test-transformer", + diagnostics: {}, + }), + transformer: resolveTransformerDefinition("@typespec/test-transformer", transformerDef), + }; + + await host.compile("main.tsp"); + + return createTransformer(host.program, (libName) => + Promise.resolve(libName === "@typespec/test-transformer" ? library : undefined), + ); + } + + it("registering a transform doesn't enable it", async () => { + const transformer = await createTestTransformer(`model Foo {}`, { + transforms: [noopTransform], + }); + expectDiagnosticEmpty(transformer.transform().diagnostics); + }); + + it("enabling a transform that doesn't exist emits a diagnostic", async () => { + const transformer = await createTestTransformer(`model Foo {}`, { + transforms: [noopTransform], + }); + expectDiagnostics( + await transformer.extendTransformSet({ + enable: { "@typespec/test-transformer/not-a-transform": true }, + }), + { + severity: "warning", + code: "unknown-transform", + message: `Unknown transform 'not-a-transform' in library '@typespec/test-transformer'.`, + }, + ); + }); + + it("enabling a transform set that doesn't exist emits a diagnostic", async () => { + const transformer = await createTestTransformer(`model Foo {}`, { + transforms: [noopTransform], + }); + expectDiagnostics( + await transformer.extendTransformSet({ extends: ["@typespec/test-transformer/not-a-set"] }), + { + severity: "warning", + code: "unknown-transform-set", + message: `Unknown transform set 'not-a-set' in library '@typespec/test-transformer'.`, + }, + ); + }); + + it("emits a diagnostic if enabling and disabling the same transform", async () => { + const transformer = await createTestTransformer(`model Foo {}`, { + transforms: [noopTransform], + }); + expectDiagnostics( + await transformer.extendTransformSet({ + enable: { "@typespec/test-transformer/noop": true }, + disable: { "@typespec/test-transformer/noop": "Reason" }, + }), + { + severity: "warning", + code: "transform-enabled-disabled", + message: `Transform '@typespec/test-transformer/noop' cannot be both enabled and disabled.`, + }, + ); + }); + + describe("when enabling a transform set", () => { + it("/all set is automatically provided and include all transforms", async () => { + const transformer = await createTestTransformer(`model Foo {}`, { + transforms: [noopTransform], + }); + expectDiagnosticEmpty( + await transformer.extendTransformSet({ + extends: ["@typespec/test-transformer/all"], + }), + ); + // No diagnostics expected from running transforms as noop transform doesn't report any. + expectDiagnosticEmpty(transformer.transform().diagnostics); + }); + + it("extending specific transform set enables the transforms inside", async () => { + const transformer = await createTestTransformer(`model Foo {}`, { + transforms: [noopTransform], + transformSets: { + custom: { + enable: { "@typespec/test-transformer/noop": true }, + }, + }, + }); + expectDiagnosticEmpty( + await transformer.extendTransformSet({ + extends: ["@typespec/test-transformer/custom"], + }), + ); + expectDiagnosticEmpty(transformer.transform().diagnostics); + }); + }); +}); diff --git a/packages/compiler/test/testing/tester.test.ts b/packages/compiler/test/testing/tester.test.ts index 819f98623db..fe0492b8bed 100644 --- a/packages/compiler/test/testing/tester.test.ts +++ b/packages/compiler/test/testing/tester.test.ts @@ -4,6 +4,8 @@ import { strictEqual } from "assert"; import { describe, expect, expectTypeOf, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; import { + createTransform, + defineTransformer, EmitContext, emitFile, Enum, @@ -357,3 +359,38 @@ describe("emitter", () => { }); }); }); + +describe("transformer", () => { + const TransformerTester = Tester.files({ + "node_modules/dummy-transformer/package.json": JSON.stringify({ + name: "dummy-transformer", + version: "1.0.0", + exports: { ".": "./index.js" }, + }), + "node_modules/dummy-transformer/index.js": mockFile.js({ + $transformer: defineTransformer({ + transforms: [ + createTransform({ + name: "dummy-transform", + description: "A dummy transform.", + mutators: [], + }), + ], + }), + }), + }).transformer({ extends: ["dummy-transformer/all"] }); + + it("can transform", async () => { + const result = await TransformerTester.compile(t.code` + model ${t.model("Foo")} {} + `); + expect(result.Foo.kind).toBe("Model"); + }); + + it("can wrap", async () => { + const result = await TransformerTester.wrap( + (x) => `model Test {}\n${x}\nmodel Test2 {}`, + ).compile(t.code`model ${t.model("Foo")} {}`); + expect(result.Foo.kind).toBe("Model"); + }); +}); diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 4c63017ffa5..06595befca0 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -24,9 +24,9 @@ "types": "./dist/src/index.d.ts", "default": "./dist/src/index.js" }, - "./testing": { - "types": "./dist/src/testing/index.d.ts", - "default": "./dist/src/testing/index.js" + "./transformer": { + "types": "./dist/src/transformers/index.d.ts", + "default": "./dist/src/transformers/index.js" } }, "engines": { @@ -38,6 +38,7 @@ "dependencies": { "@alloy-js/core": "^0.11.0", "@alloy-js/typescript": "^0.11.0", + "change-case": "^5.4.4", "graphql": "^16.9.0" }, "scripts": { diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index db6fe94e5fb..4e18d433c82 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -1,3 +1,4 @@ export { $onEmit } from "./emitter.js"; export { $lib } from "./lib.js"; +export { $transformer } from "./transformer.js"; export { $decorators } from "./tsp-index.js"; diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts new file mode 100644 index 00000000000..fb88ef617eb --- /dev/null +++ b/packages/graphql/src/lib/type-utils.ts @@ -0,0 +1,285 @@ +import { + type ArrayModelType, + type Enum, + getDoc, + getTypeName, + type IndeterminateEntity, + isNeverType, + isTemplateInstance, + type Model, + type Program, + type RecordModelType, + type Scalar, + type Type, + type Union, + type Value, + walkPropertiesInherited, +} from "@typespec/compiler"; +import { + type AliasStatementNode, + type IdentifierNode, + type ModelPropertyNode, + type ModelStatementNode, + type Node, + SyntaxKind, + type UnionStatementNode, +} from "@typespec/compiler/ast"; +import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from "change-case"; +import { GraphQLScalarType } from "graphql"; + +export const ANY_SCALAR = new GraphQLScalarType({ + name: "Any", +}); + +export function getTemplatedModelName(model: Model): string { + const name = getTypeName(model, {}); + const baseName = toTypeName(name.replace(/<[^>]*>/g, "")); + const templateString = getTemplateString(model); + return templateString ? `${baseName}Of${templateString}` : baseName; +} + +function splitWithAcronyms( + split: (name: string) => string[], + skipStart: boolean, + name: string, +): string[] { + const result = split(name); + + if (name === name.toUpperCase()) { + return result; + } + // Preserve strings of capital letters, e.g. "API" should be treated as three words ["A", "P", "I"] instead of one word + return result.flatMap((part) => { + const result = !skipStart && part.match(/^[A-Z]+$/) ? part.split("") : part; + skipStart = false; + return result; + }); +} + +export function toTypeName(name: string): string { + return pascalCase(sanitizeNameForGraphQL(getNameWithoutNamespace(name)), { + split: splitWithAcronyms.bind(null, split, false), + }); +} + +export function sanitizeNameForGraphQL(name: string, prefix: string = ""): string { + name = name.replace("[]", "Array"); + name = name.replaceAll(/\W/g, "_"); + if (!name.match("^[_a-zA-Z]")) { + name = `${prefix}_${name}`; + } + return name; +} + +export function toEnumMemberName(enumName: string, name: string) { + return constantCase(sanitizeNameForGraphQL(name, enumName), { + split: splitSeparateNumbers, + prefixCharacters: "_", + }); +} + +export function toFieldName(name: string): string { + return camelCase(sanitizeNameForGraphQL(name), { + prefixCharacters: "_", + split: splitWithAcronyms.bind(null, split, true), + }); +} + +function getNameWithoutNamespace(name: string): string { + const parts = name.trim().split("."); + return parts[parts.length - 1]; +} + +export function getUnionName(union: Union, program: Program): string { + // SyntaxKind.UnionExpression: Foo | Bar + // SyntaxKind.UnionStatement: union FooBarUnion { Foo, Bar } + // SyntaxKind.TypeReference: FooBarUnion + + const templateString = getTemplateString(union) ? "Of" + getTemplateString(union) : ""; + + switch (true) { + case !!union.name: + // The union is not anonymous, use its name + return union.name; + + case isReturnType(union): + // The union is a return type, use the name of the operation + // e.g. op getBaz(): Foo | Bar => GetBazUnion + return `${getUnionNameForOperation(program, union)}${templateString}Union`; + + case isModelProperty(union): + // The union is a model property, name it based on the model + property + // e.g. model Foo { bar: Bar | Baz } => FooBarUnion + const modelProperty = getModelProperty(union); + const propName = toTypeName(getNameForNode(modelProperty!)); + const unionModel = union.node?.parent?.parent as ModelStatementNode; + const modelName = unionModel ? getNameForNode(unionModel) : ""; + return `${modelName}${propName}${templateString}Union`; + + case isAliased(union): + // The union is an alias, name it based on the alias name + // e.g. alias Baz = Foo | Bar => Baz + const alias = getAlias(union); + const aliasName = getNameForNode(alias!); + return `${aliasName}${templateString}`; + + default: + throw new Error("Unrecognized union construction."); + } +} + +function isNamedType(type: Type | Value | IndeterminateEntity): type is { name: string } & Type { + return "name" in type && typeof (type as { name: unknown }).name === "string"; +} + +function isAliased(union: Union): boolean { + return union.node?.parent?.kind === SyntaxKind.AliasStatement; +} + +function getAlias(union: Union): AliasStatementNode | undefined { + return isAliased(union) ? (union.node?.parent as AliasStatementNode) : undefined; +} + +function isModelProperty(union: Union): boolean { + return union.node?.parent?.kind === SyntaxKind.ModelProperty; +} + +function getModelProperty(union: Union): ModelPropertyNode | undefined { + return isModelProperty(union) ? (union.node?.parent as ModelPropertyNode) : undefined; +} + +function isReturnType(type: Type): boolean { + return !!( + type.node && + type.node.parent?.kind === SyntaxKind.OperationSignatureDeclaration && + type.node.parent?.parent?.kind === SyntaxKind.OperationStatement + ); +} + +type NamedNode = Node & { id: IdentifierNode }; + +function getNameForNode(node: NamedNode): string { + return "id" in node && node.id?.kind === SyntaxKind.Identifier ? node.id.sv : ""; +} + +function getUnionNameForOperation(program: Program, union: Union): string { + const operationNode = (union.node as UnionStatementNode).parent?.parent; + const operation = program.checker.getTypeForNode(operationNode!); + + return toTypeName(getTypeName(operation)); +} + +export function getSingleNameWithNamespace(name: string): string { + return name.trim().replace(/\./g, "_"); +} + +// TODO: To replace this with the type-utils isArrayModelType function +export function isArray(model: Model): model is ArrayModelType { + return Boolean(model.indexer && model.indexer.key.name === "integer"); +} + +// TODO: To replace this with the type-utils isRecordModelType function +// The type-utils function takes an used program as an argument +// and this function is used in the selector which does not have access to +// the program +export function isRecordType(type: Model): type is RecordModelType { + return Boolean(type.indexer && type.indexer.key.name === "string"); +} + +export function isScalarOrEnumArray(type: Model): type is ArrayModelType { + return ( + isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum") + ); +} + +export function isUnionArray(type: Model): type is ArrayModelType { + return isArray(type) && type.indexer?.value.kind === "Union"; +} + +export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union; +export function unwrapModel(model: Exclude): Model; +export function unwrapModel(model: Model): Model | Scalar | Enum | Union { + if (!isArray(model)) { + return model; + } + + if (model.indexer?.value.kind) { + if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) { + return model.indexer.value as Model | Scalar | Enum | Union; + } + throw new Error(`Unexpected array type: ${model.indexer.value.kind}`); + } + return model; +} + +export function unwrapType(type: Model): Model | Scalar | Enum | Union; +export function unwrapType(type: Type): Type; +export function unwrapType(type: Type): Type { + if (type.kind === "Model") { + return unwrapModel(type); + } + return type; +} + +export function getGraphQLDoc(program: Program, type: Type): string | undefined { + // GraphQL uses CommonMark for descriptions + // https://spec.graphql.org/October2021/#sec-Descriptions + let doc = getDoc(program, type); + if (!program.compilerOptions.miscOptions?.isTest) { + doc = + (doc || "") + + ` + +Created from ${type.kind} +\`\`\` +${getTypeName(type)} +\`\`\` + `; + } + + if (doc) { + doc = doc.trim(); + doc.replaceAll("\\n", "\n"); + } + return doc; +} + +export function getTemplateString( + type: Type, + options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" }, +): string { + if (isTemplateInstance(type)) { + const args = type.templateMapper.args.filter(isNamedType).map((arg) => getTypeName(arg)); + return getTemplateStringInternal(args, options); + } + return ""; +} + +function getTemplateStringInternal( + args: string[], + options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" }, +): string { + return args.length > 0 + ? options.prefix + toTypeName(args.map(toTypeName).join(options.conjunction)) + : ""; +} + +export function isTrueModel(model: Model): boolean { + /* eslint-disable no-fallthrough */ + switch (true) { + // A scalar array is represented as a model with an indexer + // and a scalar type. We don't want to emit this as a model. + case isScalarOrEnumArray(model): + // A union array is represented as a model with an indexer + // and a union type. We don't want to emit this as a model. + case isUnionArray(model): + case isNeverType(model): + // If the model is purely a record, we don't want to emit it as a model. + // Instead, we will need to create a scalar + case isRecordType(model) && [...walkPropertiesInherited(model)].length === 0: + return false; + default: + return true; + } + /* eslint-enable no-fallthrough */ +} diff --git a/packages/graphql/src/transformer.ts b/packages/graphql/src/transformer.ts new file mode 100644 index 00000000000..ab44ea07223 --- /dev/null +++ b/packages/graphql/src/transformer.ts @@ -0,0 +1,13 @@ +import { defineTransformer } from "@typespec/compiler"; +import { renameTypesTransform } from "./transformers/rename-types.transform.js"; + +export const $transformer = defineTransformer({ + transforms: [renameTypesTransform], + transformSets: { + graphql_naming: { + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }, + }, +}); diff --git a/packages/graphql/src/transformers/index.ts b/packages/graphql/src/transformers/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/graphql/src/transformers/rename-types.transform.ts b/packages/graphql/src/transformers/rename-types.transform.ts new file mode 100644 index 00000000000..a024912a8c0 --- /dev/null +++ b/packages/graphql/src/transformers/rename-types.transform.ts @@ -0,0 +1,30 @@ +import type { Program } from "@typespec/compiler"; +import { createTransform } from "@typespec/compiler"; +import type { + unsafe_MutableType as MutableType, + unsafe_MutatorRecord as MutatorRecord, +} from "@typespec/compiler/experimental"; +import { sanitizeNameForGraphQL } from "../lib/type-utils.js"; + +const makeRenameMutator = (): MutatorRecord => ({ + mutate(target, clone, _program: Program) { + // TODO we shouldn't be modifying target directly + target.name = sanitizeNameForGraphQL(target.name); + }, +}); + +export const renameTypesTransform = createTransform({ + name: "rename-types", + description: "Rename types to be valid GraphQL names.", + mutators: [ + { + name: "Rename Types", + Enum: makeRenameMutator(), + EnumMember: makeRenameMutator(), + Model: makeRenameMutator(), + ModelProperty: makeRenameMutator(), + Operation: makeRenameMutator(), + Scalar: makeRenameMutator(), + }, + ], +}); diff --git a/packages/graphql/test/transformers/rename-types.transform.test.ts b/packages/graphql/test/transformers/rename-types.transform.test.ts new file mode 100644 index 00000000000..8e8e9b345d9 --- /dev/null +++ b/packages/graphql/test/transformers/rename-types.transform.test.ts @@ -0,0 +1,141 @@ +import { t, type TransformerTesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { renameTypesTransform } from "../../src/transformers/rename-types.transform.js"; +import { Tester } from "../test-host.js"; + +describe("Rename enums transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid enum names alone", async () => { + const { ValidEnum } = await tester.compile( + t.code`enum ${t.enum("ValidEnum")} { + ${t.enumMember("ValidEnumValue")} + }`, + ); + + expect(ValidEnum.name).toBe("ValidEnum"); + }); + + it("changes invalid enum names", async () => { + const { ValidEnumValue } = await tester.compile( + t.code`enum \`$Money$\` { + ${t.enumMember("ValidEnumValue")} + }`, + ); + + expect(ValidEnumValue.enum.name).toBe("_Money_"); + }); +}); + +describe("Rename enum members transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid enum member names alone", async () => { + const { ValidMember } = await tester.compile( + t.code`enum MyEnum { + ${t.enumMember("ValidMember")} + }`, + ); + + expect(ValidMember.name).toBe("ValidMember"); + }); + + it("changes invalid enum member names", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + \`$Value$\` + }`, + ); + + expect(MyEnum.members).toContain("_Value_"); + }); +}); + +describe("Rename models transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid model names alone", async () => { + const { ValidModel } = await tester.compile(t.code`model ${t.model("ValidModel")} { }`); + + expect(ValidModel.name).toBe("ValidModel"); + }); + + it("changes invalid model names", async () => { + const { prop } = await tester.compile( + t.code`model \`$Foo$\` { ${t.modelProperty("prop")}: string }`, + ); + + expect(prop.model?.name).toBe("_Foo_"); + }); +}); + +describe("Rename model properties transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid property names alone", async () => { + const { prop } = await tester.compile( + t.code`model ${t.model("M")} { ${t.modelProperty("prop")}: string }`, + ); + + expect(prop.name).toBe("prop"); + }); + + it("changes invalid property names", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`$prop$\`: string }`); + + expect(M.properties).toContain("_prop_"); + }); +}); + +describe("Rename operations transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid operation names alone", async () => { + const { ValidOp } = await tester.compile(t.code`op ${t.op("ValidOp")}(): void;`); + + expect(ValidOp.name).toBe("ValidOp"); + }); + + it("changes invalid operation names", async () => { + const { Iface } = await tester.compile( + t.code`interface ${t.interface("Iface")} { \`$Do$\`(): void; }`, + ); + + expect(Iface.operations).toContain("_Do_"); + }); +}); diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index ad68b784463..b4bcf7d0623 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../../tsconfig.base.json", + "references": [{ "path": "../compiler/tsconfig.json" }], "compilerOptions": { - "useDefineForClassFields": true, - "rootDir": ".", "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo", "verbatimModuleSyntax": true }, - "include": ["src", "test"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6df69d81a18..427db0cbeed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,6 +554,9 @@ importers: '@typespec/http': specifier: workspace:~ version: link:../http + change-case: + specifier: ^5.4.4 + version: 5.4.4 graphql: specifier: ^16.9.0 version: 16.11.0 @@ -19223,7 +19226,7 @@ snapshots: algoliasearch: 4.25.2 clipanion: 4.0.0-rc.4(typanion@3.14.0) diff: 5.2.0 - ink: 3.2.0(@types/react@18.3.24)(react@18.3.1) + ink: 3.2.0(@types/react@18.3.24)(react@17.0.2) ink-text-input: 4.0.3(ink@3.2.0(@types/react@18.3.24)(react@17.0.2))(react@17.0.2) react: 17.0.2 semver: 7.7.2 @@ -19416,7 +19419,7 @@ snapshots: '@yarnpkg/plugin-git': 3.1.3(@yarnpkg/core@4.4.3(typanion@3.14.0))(typanion@3.14.0) clipanion: 4.0.0-rc.4(typanion@3.14.0) es-toolkit: 1.39.10 - ink: 3.2.0(@types/react@18.3.24)(react@17.0.2) + ink: 3.2.0(@types/react@18.3.24)(react@18.3.1) react: 17.0.2 semver: 7.7.2 tslib: 2.8.1