From 5613ac0ca436e0d11a1d5af90ee1d73419ab241e Mon Sep 17 00:00:00 2001 From: Lubos Date: Tue, 16 Dec 2025 05:54:39 +0800 Subject: [PATCH] fix(@hey-api/sdk): dedup classes --- dev/openapi-ts.config.ts | 19 +- packages/codegen-core/src/files/file.ts | 2 +- packages/codegen-core/src/planner/analyzer.ts | 16 +- packages/codegen-core/src/planner/planner.ts | 85 ++- packages/codegen-core/src/planner/scope.ts | 35 ++ packages/codegen-core/src/planner/types.d.ts | 22 +- packages/codegen-core/src/symbols/symbol.ts | 6 +- .../src/openApi/shared/utils/name.ts | 7 +- .../src/openApi/shared/utils/operation.ts | 12 +- .../plugins/@angular/common/httpRequests.ts | 6 +- .../plugins/@angular/common/httpResources.ts | 16 +- .../src/plugins/@hey-api/sdk/config.ts | 1 + .../src/plugins/@hey-api/sdk/model/class.ts | 312 ++++++++++ .../src/plugins/@hey-api/sdk/shared/class.ts | 571 ++++++++++-------- .../plugins/@hey-api/sdk/shared/operation.ts | 27 +- .../plugins/@hey-api/sdk/shared/signature.ts | 6 +- .../src/plugins/@hey-api/sdk/types.d.ts | 12 +- .../@hey-api/typescript/shared/export.ts | 6 +- .../@hey-api/typescript/v1/toAst/string.ts | 7 +- .../src/plugins/@pinia/colada/v0/plugin.ts | 7 +- .../plugins/@tanstack/query-core/v5/plugin.ts | 7 +- .../openapi-ts/src/plugins/swr/v2/plugin.ts | 7 +- packages/openapi-ts/src/ts-dsl/base.ts | 8 +- packages/openapi-ts/src/ts-dsl/decl/class.ts | 13 +- packages/openapi-ts/src/ts-dsl/decl/field.ts | 7 +- packages/openapi-ts/src/ts-dsl/decl/getter.ts | 18 +- packages/openapi-ts/src/ts-dsl/decl/method.ts | 20 +- packages/openapi-ts/src/ts-dsl/decl/setter.ts | 18 +- .../{stringCase.test.ts => to-case.test.ts} | 32 +- packages/openapi-ts/src/utils/exports.ts | 31 +- .../src/utils/{stringCase.ts => to-case.ts} | 79 ++- 31 files changed, 919 insertions(+), 496 deletions(-) create mode 100644 packages/codegen-core/src/planner/scope.ts create mode 100644 packages/openapi-ts/src/plugins/@hey-api/sdk/model/class.ts rename packages/openapi-ts/src/utils/__tests__/{stringCase.test.ts => to-case.test.ts} (89%) rename packages/openapi-ts/src/utils/{stringCase.ts => to-case.ts} (72%) diff --git a/dev/openapi-ts.config.ts b/dev/openapi-ts.config.ts index b5b117b67..3ead7e0ca 100644 --- a/dev/openapi-ts.config.ts +++ b/dev/openapi-ts.config.ts @@ -277,7 +277,7 @@ export default defineConfig(() => { // asClass: true, // auth: false, // classNameBuilder: '{{name}}', - // classNameBuilder: '{{name}}Service', + classNameBuilder: '{{name}}Service', // classStructure: 'off', // client: false, // getSignature: ({ fields, signature, operation }) => { @@ -285,7 +285,8 @@ export default defineConfig(() => { // fields.unwrap('path') // }, // include... - instance: true, + instance: 'Root', + methodNameBuilder: '{{name}}Methods', name: '@hey-api/sdk', // operationId: false, // paramsStructure: 'flat', @@ -304,10 +305,7 @@ export default defineConfig(() => { symbols: { // getFilePath: (symbol) => { // if (symbol.name) { - // return utils.stringCase({ - // case: 'camelCase', - // value: symbol.name, - // }); + // return utils.toCase(symbol.name, 'camelCase'); // } // return; // }, @@ -351,7 +349,7 @@ export default defineConfig(() => { // name: '{{name}}MO', // name: 'options', }, - name: '@tanstack/react-query', + // name: '@tanstack/react-query', queryKeys: { // name: '{{name}}QK', // name: 'options', @@ -421,10 +419,7 @@ export default defineConfig(() => { symbols: { // getFilePath: (symbol) => { // if (symbol.name) { - // return utils.stringCase({ - // case: 'camelCase', - // value: symbol.name, - // }); + // return utils.toCase(symbol.name, 'camelCase'); // } // return; // }, @@ -615,7 +610,7 @@ export default defineConfig(() => { { exportFromIndex: true, // mutationOptions: '{{name}}Mutationssss', - name: '@pinia/colada', + // name: '@pinia/colada', // queryOptions: { // name: '{{name}}Queryyyyy', // }, diff --git a/packages/codegen-core/src/files/file.ts b/packages/codegen-core/src/files/file.ts index 3e5aad135..adb37dcc9 100644 --- a/packages/codegen-core/src/files/file.ts +++ b/packages/codegen-core/src/files/file.ts @@ -5,7 +5,7 @@ import { fileBrand } from '../brands'; import { debug } from '../debug'; import type { Language } from '../languages/types'; import type { INode } from '../nodes/node'; -import type { NameScopes } from '../planner/types'; +import type { NameScopes } from '../planner/scope'; import type { IProject } from '../project/types'; import type { Renderer } from '../renderer'; import type { IFileIn } from './types'; diff --git a/packages/codegen-core/src/planner/analyzer.ts b/packages/codegen-core/src/planner/analyzer.ts index 1af9bfab9..7a9900165 100644 --- a/packages/codegen-core/src/planner/analyzer.ts +++ b/packages/codegen-core/src/planner/analyzer.ts @@ -3,21 +3,17 @@ import type { INode } from '../nodes/node'; import { fromRef, isRef, ref } from '../refs/refs'; import type { Ref } from '../refs/types'; import type { Symbol } from '../symbols/symbol'; -import type { IAnalysisContext, Input, NameScopes, Scope } from './types'; - -const createScope = (parent?: Scope): Scope => ({ - children: [], - localNames: new Map(), - parent, - symbols: [], -}); +import type { NameScopes, Scope } from './scope'; +import { createScope } from './scope'; +import type { IAnalysisContext, Input } from './types'; export class AnalysisContext implements IAnalysisContext { + scope: Scope; scopes: Scope = createScope(); symbol?: Symbol; - scope: Scope = this.scopes; constructor(symbol?: Symbol) { + this.scope = this.scopes; this.symbol = symbol; } @@ -62,7 +58,7 @@ export class AnalysisContext implements IAnalysisContext { } pushScope(): void { - const scope = createScope(this.scope); + const scope = createScope({ parent: this.scope }); this.scope.children.push(scope); this.scope = scope; } diff --git a/packages/codegen-core/src/planner/planner.ts b/packages/codegen-core/src/planner/planner.ts index 932e57b95..4938a23b5 100644 --- a/packages/codegen-core/src/planner/planner.ts +++ b/packages/codegen-core/src/planner/planner.ts @@ -12,7 +12,8 @@ import type { Symbol } from '../symbols/symbol'; import type { SymbolKind } from '../symbols/types'; import type { AnalysisContext } from './analyzer'; import { Analyzer } from './analyzer'; -import type { AssignOptions, NameScopes } from './types'; +import type { AssignOptions, Scope } from './scope'; +import { createScope } from './scope'; const isTypeOnlyKind = (kind: SymbolKind) => kind === 'type' || kind === 'interface'; @@ -76,7 +77,7 @@ export class Planner { this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => { const symbol = node.symbol; if (!symbol) return; - this.assignTopLevelName(symbol, ctx); + this.assignTopLevelName({ ctx, symbol }); }); this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => { @@ -86,8 +87,11 @@ export class Planner { const dep = fromRef(dependency); // top-level or external symbol if (dep.file) return; - this.assignLocalName(dep, ctx, { - scopesToUpdate: [file.allNames], + this.assignLocalName({ + ctx, + file, + scopesToUpdate: [createScope({ localNames: file.allNames })], + symbol: dep, }); }); }); @@ -166,7 +170,7 @@ export class Planner { }); exp.setFile(target); sourceFile.set(exp.id, file); - this.assignTopLevelName(exp, ctx); + this.assignTopLevelName({ ctx, symbol: exp }); let entry = fileMap.get(exp.finalName); if (!entry) { @@ -253,7 +257,7 @@ export class Planner { if (!dep.file || dep.file.id === file.id) return; if (dep.external) { - this.assignTopLevelName(dep, ctx); + this.assignTopLevelName({ ctx, symbol: dep }); } const fromFileId = dep.file.id; @@ -272,8 +276,10 @@ export class Planner { name: dep.finalName, }); imp.setFile(file); - this.assignTopLevelName(imp, ctx, { - scope: imp.file!.allNames, + this.assignTopLevelName({ + ctx, + scope: createScope({ localNames: imp.file!.allNames }), + symbol: imp, }); entry = { dep, @@ -334,17 +340,22 @@ export class Planner { * Supports optional overrides for the naming scope and scopes to update. */ private assignTopLevelName( - symbol: Symbol, - ctx: AnalysisContext, - options?: Partial, + args: Partial & { + ctx: AnalysisContext; + symbol: Symbol; + }, ): void { - if (!symbol.file) return; - this.assignSymbolName(symbol, { - scope: options?.scope ?? symbol.file.topLevelNames, + if (!args.symbol.file) return; + this.assignSymbolName({ + ...args, + file: args.symbol.file, + scope: + args?.scope ?? + createScope({ localNames: args.symbol.file.topLevelNames }), scopesToUpdate: [ - symbol.file.allNames, - ctx.scopes.localNames, - ...(options?.scopesToUpdate ?? []), + createScope({ localNames: args.symbol.file.allNames }), + args.ctx.scopes, + ...(args?.scopesToUpdate ?? []), ], }); } @@ -357,14 +368,17 @@ export class Planner { * Updates all provided name scopes accordingly. */ private assignLocalName( - symbol: Symbol, - ctx: AnalysisContext, - options: Pick, 'scope'> & - Pick, + args: Pick, 'scope'> & + Pick & { + ctx: AnalysisContext; + /** The file the symbol belongs to. */ + file: File; + symbol: Symbol; + }, ): void { - this.assignSymbolName(symbol, { - scope: options.scope ?? ctx.localNames(ctx.scope), - scopesToUpdate: options.scopesToUpdate, + this.assignSymbolName({ + ...args, + scope: args.scope ?? args.ctx.scope, }); } @@ -375,20 +389,29 @@ export class Planner { * * Updates all specified name scopes with the assigned final name. */ - private assignSymbolName(symbol: Symbol, options: AssignOptions): void { + private assignSymbolName( + args: AssignOptions & { + ctx: AnalysisContext; + /** The file the symbol belongs to. */ + file: File; + symbol: Symbol; + }, + ): void { + const { ctx, file, scope, scopesToUpdate, symbol } = args; if (this.cacheResolvedNames.has(symbol.id)) return; const baseName = symbol.name; let finalName = symbol.nameSanitizer?.(baseName) ?? baseName; let attempt = 1; + const localNames = ctx.localNames(scope); while (true) { - const kinds = [...(options.scope.get(finalName) ?? [])]; + const kinds = [...(localNames.get(finalName) ?? [])]; const ok = kinds.every((kind) => canShareName(symbol.kind, kind)); if (ok) break; - const language = symbol.node?.language || symbol.file?.language; + const language = symbol.node?.language || file.language; const resolver = (language ? this.project.nameConflictResolvers[language] : undefined) ?? this.project.defaultNameConflictResolver; @@ -403,7 +426,7 @@ export class Planner { symbol.setFinalName(finalName); this.cacheResolvedNames.add(symbol.id); - const updateScopes = [options.scope, ...options.scopesToUpdate]; + const updateScopes = [scope, ...scopesToUpdate]; for (const scope of updateScopes) { this.updateScope(symbol, scope); } @@ -414,11 +437,11 @@ export class Planner { * * Ensures the name scope tracks all kinds associated with a given name. */ - private updateScope(symbol: Symbol, scope: NameScopes): void { + private updateScope(symbol: Symbol, scope: Scope): void { const name = symbol.finalName; - const cache = scope.get(name) ?? new Set(); + const cache = scope.localNames.get(name) ?? new Set(); cache.add(symbol.kind); - scope.set(name, cache); + scope.localNames.set(name, cache); } private symbolToFileIn(symbol: Symbol): IFileIn { diff --git a/packages/codegen-core/src/planner/scope.ts b/packages/codegen-core/src/planner/scope.ts new file mode 100644 index 000000000..d48821d12 --- /dev/null +++ b/packages/codegen-core/src/planner/scope.ts @@ -0,0 +1,35 @@ +import type { Ref } from '../refs/types'; +import type { Symbol } from '../symbols/symbol'; +import type { SymbolKind } from '../symbols/types'; + +export type NameScopes = Map>; + +export type Scope = { + /** Child scopes. */ + children: Array; + /** Resolved names in this scope. */ + localNames: NameScopes; + /** Parent scope, if any. */ + parent?: Scope; + /** Symbols registered in this scope. */ + symbols: Array>; +}; + +export type AssignOptions = { + /** The primary scope in which to assign a symbol's final name. */ + scope: Scope; + /** Additional scopes to update as side effects when assigning a symbol's final name. */ + scopesToUpdate: ReadonlyArray; +}; + +export const createScope = ( + args: { + localNames?: NameScopes; + parent?: Scope; + } = {}, +): Scope => ({ + children: [], + localNames: args.localNames || new Map(), + parent: args.parent, + symbols: [], +}); diff --git a/packages/codegen-core/src/planner/types.d.ts b/packages/codegen-core/src/planner/types.d.ts index aa98ed3d7..a62a01380 100644 --- a/packages/codegen-core/src/planner/types.d.ts +++ b/packages/codegen-core/src/planner/types.d.ts @@ -1,34 +1,14 @@ import type { Ref } from '../refs/types'; import type { Symbol } from '../symbols/symbol'; -import type { SymbolKind } from '../symbols/types'; - -export type AssignOptions = { - /** The primary scope in which to assign a symbol's final name. */ - scope: NameScopes; - /** Additional scopes to update as side effects when assigning a symbol's final name. */ - scopesToUpdate: ReadonlyArray; -}; +import type { NameScopes, Scope } from './scope'; export type Input = Ref | object | string | number | undefined; -export type NameScopes = Map>; - export type NameConflictResolver = (args: { attempt: number; baseName: string; }) => string | null; -export type Scope = { - /** Child scopes. */ - children: Array; - /** Resolved names in this scope. */ - localNames: NameScopes; - /** Parent scope, if any. */ - parent?: Scope; - /** Symbols registered in this scope. */ - symbols: Array>; -}; - export interface IAnalysisContext { /** Register a dependency on another symbol. */ addDependency(symbol: Ref): void; diff --git a/packages/codegen-core/src/symbols/symbol.ts b/packages/codegen-core/src/symbols/symbol.ts index ed285d87c..726f4dc59 100644 --- a/packages/codegen-core/src/symbols/symbol.ts +++ b/packages/codegen-core/src/symbols/symbol.ts @@ -337,7 +337,11 @@ export class Symbol { * Returns a debug‑friendly string representation identifying the symbol. */ toString(): string { - return `[Symbol ${this.name}#${this.id}]`; + const canonical = this.canonical; + if (canonical._finalName && canonical._finalName !== canonical._name) { + return `[Symbol ${canonical._name} → ${canonical._finalName}#${canonical.id}]`; + } + return `[Symbol ${canonical._name}#${canonical.id}]`; } /** diff --git a/packages/openapi-ts/src/openApi/shared/utils/name.ts b/packages/openapi-ts/src/openApi/shared/utils/name.ts index fff76b867..74424495e 100644 --- a/packages/openapi-ts/src/openApi/shared/utils/name.ts +++ b/packages/openapi-ts/src/openApi/shared/utils/name.ts @@ -1,6 +1,5 @@ -import { stringCase } from '~/utils/stringCase'; - -import type { StringCase, StringName } from '../../../types/case'; +import type { StringCase, StringName } from '~/types/case'; +import { toCase } from '~/utils/to-case'; export const buildName = ({ config, @@ -19,5 +18,5 @@ export const buildName = ({ name = config.name.replace('{{name}}', `${separator}${name}${separator}`); } - return stringCase({ case: config.case, value: name }); + return toCase(name, config.case); }; diff --git a/packages/openapi-ts/src/openApi/shared/utils/operation.ts b/packages/openapi-ts/src/openApi/shared/utils/operation.ts index ec11085f9..7d75734ea 100644 --- a/packages/openapi-ts/src/openApi/shared/utils/operation.ts +++ b/packages/openapi-ts/src/openApi/shared/utils/operation.ts @@ -1,7 +1,7 @@ import type { Context } from '~/ir/context'; import { createOperationKey } from '~/ir/operation'; import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { State } from '../types/state'; @@ -49,20 +49,14 @@ export const operationToId = ({ (!context.config.plugins['@hey-api/sdk'] || context.config.plugins['@hey-api/sdk'].config.operationId) ) { - result = stringCase({ - case: targetCase, - value: sanitizeNamespaceIdentifier(id), - }); + result = toCase(sanitizeNamespaceIdentifier(id), targetCase); } else { const pathWithoutPlaceholders = path .replace(/{(.*?)}/g, 'by-$1') // replace slashes with hyphens for camelcase method at the end .replace(/[/:+]/g, '-'); - result = stringCase({ - case: targetCase, - value: `${method}-${pathWithoutPlaceholders}`, - }); + result = toCase(`${method}-${pathWithoutPlaceholders}`, targetCase); } if (count > 1) { diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpRequests.ts b/packages/openapi-ts/src/plugins/@angular/common/httpRequests.ts index 3d37e0734..c5fd134ac 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/httpRequests.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/httpRequests.ts @@ -8,7 +8,7 @@ import { isOperationOptionsRequired, } from '~/plugins/shared/utils/operation'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { AngularCommonPlugin } from './types'; @@ -107,9 +107,7 @@ const generateAngularClassRequests = ({ generateClass(childClass); currentClass.nodes.push( - $.field( - stringCase({ case: 'camelCase', value: childClass.className }), - ).assign( + $.field(toCase(childClass.className, 'camelCase')).assign( $.new( buildName({ config: { diff --git a/packages/openapi-ts/src/plugins/@angular/common/httpResources.ts b/packages/openapi-ts/src/plugins/@angular/common/httpResources.ts index ae433738f..fee0c24b0 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/httpResources.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/httpResources.ts @@ -8,7 +8,7 @@ import { isOperationOptionsRequired, } from '~/plugins/shared/utils/operation'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { AngularCommonPlugin } from './types'; @@ -107,12 +107,7 @@ const generateAngularClassServices = ({ generateClass(childClass); currentClass.nodes.push( - $.field( - stringCase({ - case: 'camelCase', - value: childClass.className, - }), - ).assign( + $.field(toCase(childClass.className, 'camelCase')).assign( $.new( buildName({ config: { @@ -237,12 +232,7 @@ const generateResourceCallExpression = ({ for (let i = 1; i < firstEntry.path.length; i++) { const className = firstEntry.path[i]; if (className) { - methodAccess = methodAccess.attr( - stringCase({ - case: 'camelCase', - value: className, - }), - ); + methodAccess = methodAccess.attr(toCase(className, 'camelCase')); } } diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts index 4cd1c7aee..aa5dc94ab 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts @@ -12,6 +12,7 @@ export const defaultConfig: HeyApiSdkPlugin['Config'] = { client: true, exportFromIndex: true, instance: '', + methodNameBuilder: '{{name}}', operationId: true, paramsStructure: 'grouped', response: 'body', diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/model/class.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/model/class.ts new file mode 100644 index 000000000..3ccf4d237 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/model/class.ts @@ -0,0 +1,312 @@ +import type { IR } from '~/ir/types'; +import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; +import { + createOperationComment, + isOperationOptionsRequired, +} from '~/plugins/shared/utils/operation'; +import { $ } from '~/ts-dsl'; +import { toCase } from '~/utils/to-case'; + +import { createClientClass, createRegistryClass } from '../shared/class'; +import { nuxtTypeComposable, nuxtTypeDefault } from '../shared/constants'; +import { + operationClassName, + operationMethodName, + operationParameters, + operationStatements, +} from '../shared/operation'; +import type { HeyApiSdkPlugin } from '../types'; + +/** + * Represents a class in the SDK hierarchy. + * + * Classes can be nested (via children) and contain operations (methods). + */ +export class SdkClassModel { + /** Nested classes within this class. */ + children: Map = new Map(); + /** The name of this class (e.g., "Users", "Accounts"). */ + name: string; + /** Operations that will become methods in this class. */ + operations: Array = []; + /** Parent class in the hierarchy. Undefined if this is the root class. */ + parent?: SdkClassModel; + + constructor(name: string, parent?: SdkClassModel) { + this.name = name; + this.parent = parent; + } + + get isRoot(): boolean { + return !this.parent; + } + + /** + * Adds an operation to this class. + * + * The operation will be converted to a method during code generation. + */ + addOperation(operation: IR.OperationObject): void { + this.operations.push(operation); + } + + /** + * Gets or creates a child class. + * + * If the child doesn't exist, it's created automatically. + * + * @param name - The name of the child class + * @returns The child class instance + */ + child(name: string): SdkClassModel { + if (!this.children.has(name)) { + this.children.set(name, new SdkClassModel(name, this)); + } + return this.children.get(name)!; + } + + /** + * Inserts an operation into the class tree. + * + * Parses the operation ID and creates the class hierarchy. + */ + insert( + operation: IR.OperationObject, + plugin: HeyApiSdkPlugin['Instance'], + ): void { + const classSegments = + plugin.config.classStructure === 'auto' && operation.operationId + ? operation.operationId.split(/[./]/).slice(0, -1) + : []; + + // eslint-disable-next-line @typescript-eslint/no-this-alias + let cursor: SdkClassModel = this; + for (const segment of classSegments) { + cursor = cursor.child(segment); + } + + cursor.addOperation(operation); + } + + /** + * Converts this class group to a class node. + */ + toNode(plugin: HeyApiSdkPlugin['Instance']): { + dependencies: Array>; + node: ReturnType; + } { + const dependencies: Array> = []; + + const client = getClientPlugin(plugin.context.config); + const isAngularClient = client.name === '@hey-api/client-angular'; + const isNuxtClient = client.name === '@hey-api/client-nuxt'; + + const symbolClass = plugin.symbol( + operationClassName({ plugin, value: this.name }), + { + meta: { + category: 'utility', + resource: 'class', + resourceId: this.name, + tool: 'sdk', + }, + }, + ); + const node = $.class(symbolClass) + .export() + .extends( + plugin.referenceSymbol({ + category: 'utility', + resource: 'class', + resourceId: 'HeyApiClient', + tool: 'sdk', + }), + ) + .$if(isAngularClient && this.isRoot, (c) => + c.decorator( + plugin.referenceSymbol({ + category: 'external', + resource: '@angular/core.Injectable', + }), + $.object().prop('providedIn', $.literal('root')), + ), + ); + + if (this.isRoot) { + const symbolClient = plugin.symbol('HeyApiClient', { + meta: { + category: 'utility', + resource: 'class', + resourceId: 'HeyApiClient', + tool: 'sdk', + }, + }); + const clientNode = createClientClass({ plugin, symbol: symbolClient }); + dependencies.push(clientNode); + const symbolRegistry = plugin.symbol('HeyApiRegistry', { + meta: { + category: 'utility', + resource: 'class', + resourceId: 'HeyApiRegistry', + tool: 'sdk', + }, + }); + const registryNode = createRegistryClass({ + plugin, + sdkSymbol: symbolClass, + symbol: symbolRegistry, + }); + dependencies.push(registryNode); + node.field('__registry', (f) => + f + .public() + .static() + .readonly() + .assign($.new(symbolRegistry).generic(symbolClass)), + ); + node.newline(); + + const symClient = plugin.getSymbol({ category: 'client' }); + const isClientRequired = !plugin.config.client || !symClient; + const symbolClientType = plugin.referenceSymbol({ + category: 'external', + resource: 'client.Client', + }); + node.init((i) => + i + .param('args', (p) => + p.required(isClientRequired).type( + $.type + .object() + .prop('client', (p) => + p.required(isClientRequired).type(symbolClientType), + ) + .prop('key', (p) => p.optional().type('string')), + ), + ) + .do( + $('super').call('args'), + $(symbolClass) + .attr('__registry') + .attr('set') + .call('this', $('args').attr('key').required(isClientRequired)), + ), + ); + } + + this.operations.forEach((operation, index) => { + if (index > 0 || node.hasBody) node.newline(); + const symbolMethod = plugin.symbol( + operationMethodName({ + operation, + plugin, + value: + plugin.config.classStructure === 'auto' && operation.operationId + ? toCase(operation.operationId.split(/[./]/).pop()!, 'camelCase') + : operation.id, + }), + ); + const isRequiredOptions = isOperationOptionsRequired({ + context: plugin.context, + operation, + }); + const opParameters = operationParameters({ + isRequiredOptions, + operation, + plugin, + }); + const statements = operationStatements({ + isRequiredOptions, + opParameters, + operation, + plugin, + }); + node.method(symbolMethod, (m) => + m + .$if(createOperationComment(operation), (m, v) => m.doc(v)) + .public() + .static(!isAngularClient && !plugin.config.instance) + .$if( + isNuxtClient, + (m) => + m + .generic(nuxtTypeComposable, (t) => + t + .extends( + plugin.referenceSymbol({ + category: 'external', + resource: 'client.Composable', + }), + ) + .default($.type.literal('$fetch')), + ) + .generic(nuxtTypeDefault, (t) => + t.$if( + plugin.querySymbol({ + category: 'type', + resource: 'operation', + resourceId: operation.id, + role: 'response', + }), + (t, s) => t.extends(s).default(s), + ), + ), + (m) => + m.generic('ThrowOnError', (t) => + t + .extends('boolean') + .default( + ('throwOnError' in client.config + ? client.config.throwOnError + : false) ?? false, + ), + ), + ) + .params(...opParameters.parameters) + .do(...statements), + ); + }); + + for (const child of this.children.values()) { + if (node.hasBody) node.newline(); + const refChild = plugin.referenceSymbol({ + category: 'utility', + resource: 'class', + resourceId: child.name, + tool: 'sdk', + }); + const memberName = toCase(refChild.name, 'camelCase'); + const privateName = plugin.symbol(`_${memberName}`); + const getterName = plugin.symbol(memberName); + node.field(privateName, (f) => f.private().optional().type(refChild)); + node.do( + $.getter(getterName, (g) => + g.returns(refChild).do( + $('this') + .attr(privateName) + .nullishAssign( + $.new(refChild).args( + $.object().prop('client', $('this').attr('client')), + ), + ) + .return(), + ), + ), + ); + } + + return { dependencies, node }; + } + + /** + * Recursively walks the tree depth-first. + * + * Yields this node, then all descendants. + */ + *walk(): Generator { + for (const child of this.children.values()) { + yield* child.walk(); + } + yield this; + } +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/class.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/class.ts index a575fa769..967d8dbfa 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/class.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/class.ts @@ -7,8 +7,9 @@ import { } from '~/plugins/shared/utils/operation'; import type { TsDsl } from '~/ts-dsl'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; +import { SdkClassModel } from '../model/class'; import type { HeyApiSdkPlugin } from '../types'; import { nuxtTypeComposable, nuxtTypeDefault } from './constants'; import { @@ -46,7 +47,7 @@ type SdkClassEntry = { export const registryName = '__registry'; -const createRegistryClass = ({ +export const createRegistryClass = ({ plugin, sdkSymbol, symbol, @@ -54,7 +55,7 @@ const createRegistryClass = ({ plugin: HeyApiSdkPlugin['Instance']; sdkSymbol: Symbol; symbol: Symbol; -}): TsDsl => { +}): ReturnType => { const symbolDefaultKey = plugin.symbol('defaultKey'); const symbolInstances = plugin.symbol('instances'); return $.class(symbol) @@ -107,16 +108,14 @@ const createRegistryClass = ({ ); }; -const createClientClass = ({ +export const createClientClass = ({ plugin, symbol, }: { plugin: HeyApiSdkPlugin['Instance']; symbol: Symbol; -}): TsDsl => { - const symClient = plugin.getSymbol({ - category: 'client', - }); +}): ReturnType => { + const symClient = plugin.getSymbol({ category: 'client' }); const optionalClient = Boolean(plugin.config.client && symClient); const symbolClient = plugin.referenceSymbol({ category: 'external', @@ -165,133 +164,146 @@ export const generateClassSdk = ({ */ const generatedClasses = new Set(); + const sdkModel = plugin.config.instance + ? new SdkClassModel(plugin.config.instance) + : undefined; + plugin.forEach( 'operation', ({ operation }) => { - const isRequiredOptions = isOperationOptionsRequired({ - context: plugin.context, - operation, - }); - const symbolResponse = isNuxtClient - ? plugin.querySymbol({ - category: 'type', - resource: 'operation', - resourceId: operation.id, - role: 'response', - }) - : undefined; - - const classes = operationClasses({ operation, plugin }); - - for (const entry of classes.values()) { - entry.path.forEach((currentClassName, index) => { - const symbolCurrentClass = plugin.referenceSymbol({ - category: 'utility', - resource: 'class', - resourceId: currentClassName, - tool: 'sdk', - }); - if (!sdkClasses.has(symbolCurrentClass.meta!.resourceId!)) { - sdkClasses.set(symbolCurrentClass.meta!.resourceId!, { - className: symbolCurrentClass.meta!.resourceId!, - classes: new Set(), - id: symbolCurrentClass.id, - methods: new Set(), - nodes: [], - root: !index, - }); - } - - const parentClassName = entry.path[index - 1]; - if (parentClassName) { - const symbolParentClass = plugin.referenceSymbol({ + if (sdkModel) { + sdkModel.insert(operation, plugin); + } else { + const isRequiredOptions = isOperationOptionsRequired({ + context: plugin.context, + operation, + }); + const symbolResponse = isNuxtClient + ? plugin.querySymbol({ + category: 'type', + resource: 'operation', + resourceId: operation.id, + role: 'response', + }) + : undefined; + + const classes = operationClasses({ operation, plugin }); + + for (const entry of classes.values()) { + entry.path.forEach((currentClassName, index) => { + const symbolCurrentClass = plugin.referenceSymbol({ category: 'utility', resource: 'class', - resourceId: parentClassName, + resourceId: currentClassName, tool: 'sdk', }); - if ( - symbolParentClass.meta?.resourceId !== - symbolCurrentClass.meta?.resourceId - ) { - const parentClass = sdkClasses.get( - symbolParentClass.meta!.resourceId!, - )!; - parentClass.classes.add(symbolCurrentClass.meta!.resourceId!); - sdkClasses.set(symbolParentClass.meta!.resourceId!, parentClass); + if (!sdkClasses.has(symbolCurrentClass.meta!.resourceId!)) { + sdkClasses.set(symbolCurrentClass.meta!.resourceId!, { + className: symbolCurrentClass.meta!.resourceId!, + classes: new Set(), + id: symbolCurrentClass.id, + methods: new Set(), + nodes: [], + root: !index, + }); } - } - const isLast = entry.path.length === index + 1; - // add methods only to the last class - if (!isLast) { - return; - } + const parentClassName = entry.path[index - 1]; + if (parentClassName) { + const symbolParentClass = plugin.referenceSymbol({ + category: 'utility', + resource: 'class', + resourceId: parentClassName, + tool: 'sdk', + }); + if ( + symbolParentClass.meta?.resourceId !== + symbolCurrentClass.meta?.resourceId + ) { + const parentClass = sdkClasses.get( + symbolParentClass.meta!.resourceId!, + )!; + parentClass.classes.add(symbolCurrentClass.meta!.resourceId!); + sdkClasses.set( + symbolParentClass.meta!.resourceId!, + parentClass, + ); + } + } - const currentClass = sdkClasses.get( - symbolCurrentClass.meta!.resourceId!, - )!; + const isLast = entry.path.length === index + 1; + // add methods only to the last class + if (!isLast) { + return; + } - const methodName = entry.methodName; - if (currentClass.methods.has(methodName)) return; - currentClass.methods.add(methodName); + const currentClass = sdkClasses.get( + symbolCurrentClass.meta!.resourceId!, + )!; - const opParameters = operationParameters({ - isRequiredOptions, - operation, - plugin, - }); - const statements = operationStatements({ - isRequiredOptions, - opParameters, - operation, - plugin, - }); - const functionNode = $.method(methodName, (m) => - m - .$if(createOperationComment(operation), (m, v) => m.doc(v)) - .public() - .static(!isAngularClient && !plugin.config.instance) - .$if( - isNuxtClient, - (m) => - m - .generic(nuxtTypeComposable, (t) => + const methodName = entry.methodName; + if (currentClass.methods.has(methodName)) return; + currentClass.methods.add(methodName); + + const opParameters = operationParameters({ + isRequiredOptions, + operation, + plugin, + }); + const statements = operationStatements({ + isRequiredOptions, + opParameters, + operation, + plugin, + }); + const functionNode = $.method(methodName, (m) => + m + .$if(createOperationComment(operation), (m, v) => m.doc(v)) + .public() + .static(!isAngularClient && !plugin.config.instance) + .$if( + isNuxtClient, + (m) => + m + .generic(nuxtTypeComposable, (t) => + t + .extends( + plugin.referenceSymbol({ + category: 'external', + resource: 'client.Composable', + }), + ) + .default($.type.literal('$fetch')), + ) + .generic(nuxtTypeDefault, (t) => + t.$if(symbolResponse, (t, s) => + t.extends(s).default(s), + ), + ), + (m) => + m.generic('ThrowOnError', (t) => t - .extends( - plugin.referenceSymbol({ - category: 'external', - resource: 'client.Composable', - }), - ) - .default($.type.literal('$fetch')), - ) - .generic(nuxtTypeDefault, (t) => - t.$if(symbolResponse, (t, s) => t.extends(s).default(s)), + .extends('boolean') + .default( + ('throwOnError' in client.config + ? client.config.throwOnError + : false) ?? false, + ), ), - (m) => - m.generic('ThrowOnError', (t) => - t - .extends('boolean') - .default( - ('throwOnError' in client.config - ? client.config.throwOnError - : false) ?? false, - ), - ), - ) - .params(...opParameters.parameters) - .do(...statements), - ); + ) + .params(...opParameters.parameters) + .do(...statements), + ); - if (!currentClass.nodes.length) { - currentClass.nodes.push(functionNode); - } else { - currentClass.nodes.push($.newline(), functionNode); - } + if (!currentClass.nodes.length) { + currentClass.nodes.push(functionNode); + } else { + currentClass.nodes.push($.newline(), functionNode); + } - sdkClasses.set(symbolCurrentClass.meta!.resourceId!, currentClass); - }); + sdkClasses.set(symbolCurrentClass.meta!.resourceId!, currentClass); + }); + } } }, { @@ -299,177 +311,202 @@ export const generateClassSdk = ({ }, ); - const clientIndex = plugin.config.instance ? plugin.node(null) : undefined; - const symbolClient = - clientIndex !== undefined - ? plugin.symbol('HeyApiClient', { - meta: { - category: 'utility', - resource: 'class', - resourceId: 'HeyApiClient', - tool: 'sdk', - }, - }) + if (!sdkModel) { + const clientIndex = plugin.config.instance ? plugin.node(null) : undefined; + const symbolClient = + clientIndex !== undefined + ? plugin.symbol('HeyApiClient', { + meta: { + category: 'utility', + resource: 'class', + resourceId: 'HeyApiClient', + tool: 'sdk', + }, + }) + : undefined; + const registryIndex = plugin.config.instance + ? plugin.node(null) : undefined; - const registryIndex = plugin.config.instance ? plugin.node(null) : undefined; - const generateClass = (currentClass: SdkClassEntry) => { - const resourceId = currentClass.className; + const generateClass = (currentClass: SdkClassEntry) => { + const resourceId = currentClass.className; - if (generatedClasses.has(resourceId)) return; - generatedClasses.add(resourceId); + if (generatedClasses.has(resourceId)) return; + generatedClasses.add(resourceId); - if (clientIndex !== undefined && symbolClient && !symbolClient.node) { - const node = createClientClass({ plugin, symbol: symbolClient }); - plugin.node(node, clientIndex); - } + if (clientIndex !== undefined && symbolClient && !symbolClient.node) { + const node = createClientClass({ plugin, symbol: symbolClient }); + plugin.node(node, clientIndex); + } - for (const childClassName of currentClass.classes) { - const childClass = sdkClasses.get(childClassName)!; - generateClass(childClass); + for (const childClassName of currentClass.classes) { + const childClass = sdkClasses.get(childClassName)!; + generateClass(childClass); - const refChildClass = plugin.referenceSymbol({ - category: 'utility', - resource: 'class', - resourceId: childClass.className, - tool: 'sdk', - }); + const refChildClass = plugin.referenceSymbol({ + category: 'utility', + resource: 'class', + resourceId: childClass.className, + tool: 'sdk', + }); - const originalMemberName = stringCase({ - case: 'camelCase', - value: refChildClass.meta!.resourceId!, - }); - // avoid collisions with existing method names - let memberName = originalMemberName; - if (currentClass.methods.has(memberName)) { - let index = 2; - let attempt = `${memberName}${index}`; - while (currentClass.methods.has(attempt)) { - attempt = `${memberName}${index++}`; + const originalMemberName = toCase( + refChildClass.meta!.resourceId!, + 'camelCase', + ); + // avoid collisions with existing method names + let memberName = originalMemberName; + if (currentClass.methods.has(memberName)) { + let index = 2; + let attempt = `${memberName}${index}`; + while (currentClass.methods.has(attempt)) { + attempt = `${memberName}${index++}`; + } + memberName = attempt; } - memberName = attempt; - } - currentClass.methods.add(memberName); + currentClass.methods.add(memberName); - if (currentClass.nodes.length > 0) { - currentClass.nodes.push($.newline()); - } - - if (plugin.config.instance) { - const privateName = plugin.symbol(`_${memberName}`); - const privateNode = $.field(privateName, (f) => - f.private().optional().type(refChildClass), - ); - currentClass.nodes.push(privateNode); - const getterNode = $.getter(memberName, (g) => - g.returns(refChildClass).do( - $('this') - .attr(privateName) - .nullishAssign( - $.new(refChildClass).args( - $.object().prop('client', $('this').attr('client')), - ), - ) - .return(), - ), - ); - currentClass.nodes.push(getterNode); - } else { - const subClassReferenceNode = plugin.isSymbolRegistered( - refChildClass.id, - ) - ? $.field(memberName, (f) => f.static().assign($(refChildClass))) - : $.getter(memberName, (g) => - g.public().static().do($.return(refChildClass)), - ); - currentClass.nodes.push(subClassReferenceNode); - } - } + if (currentClass.nodes.length > 0) { + currentClass.nodes.push($.newline()); + } - const symbol = plugin.symbol(resourceId, { - meta: { - category: 'utility', - resource: 'class', - resourceId, - tool: 'sdk', - }, - }); - - if (currentClass.root && registryIndex !== undefined) { - const symClient = plugin.getSymbol({ category: 'client' }); - const isClientRequired = !plugin.config.client || !symClient; - const symbolClient = plugin.referenceSymbol({ - category: 'external', - resource: 'client.Client', - }); - const ctor = $.init((i) => - i - .param('args', (p) => - p.required(isClientRequired).type( - $.type - .object() - .prop('client', (p) => - p.required(isClientRequired).type(symbolClient), + if (plugin.config.instance) { + const privateName = plugin.symbol(`_${memberName}`); + const privateNode = $.field(privateName, (f) => + f.private().optional().type(refChildClass), + ); + currentClass.nodes.push(privateNode); + const getterNode = $.getter(memberName, (g) => + g.returns(refChildClass).do( + $('this') + .attr(privateName) + .nullishAssign( + $.new(refChildClass).args( + $.object().prop('client', $('this').attr('client')), + ), ) - .prop('key', (p) => p.optional().type('string')), + .return(), ), + ); + currentClass.nodes.push(getterNode); + } else { + const subClassReferenceNode = plugin.isSymbolRegistered( + refChildClass.id, ) - .do( - $('super').call('args'), - $(symbol) - .attr(registryName) - .attr('set') - .call('this', $('args').attr('key').required(isClientRequired)), - ), - ); - - if (!currentClass.nodes.length) { - currentClass.nodes.unshift(ctor); - } else { - currentClass.nodes.unshift(ctor, $.newline()); + ? $.field(memberName, (f) => f.static().assign($(refChildClass))) + : $.getter(memberName, (g) => + g.public().static().do($.return(refChildClass)), + ); + currentClass.nodes.push(subClassReferenceNode); + } } - const symbolRegistry = plugin.symbol('HeyApiRegistry', { + const symbol = plugin.symbol(resourceId, { meta: { category: 'utility', resource: 'class', - resourceId: 'HeyApiRegistry', + resourceId, tool: 'sdk', }, }); - const node = createRegistryClass({ - plugin, - sdkSymbol: symbol, - symbol: symbolRegistry, - }); - plugin.node(node, registryIndex); - const registryNode = $.field(registryName, (f) => - f - .public() - .static() - .readonly() - .assign($.new(symbolRegistry).generic(symbol)), - ); - currentClass.nodes.unshift(registryNode, $.newline()); + + if (currentClass.root && registryIndex !== undefined) { + const symClient = plugin.getSymbol({ category: 'client' }); + const isClientRequired = !plugin.config.client || !symClient; + const symbolClient = plugin.referenceSymbol({ + category: 'external', + resource: 'client.Client', + }); + const ctor = $.init((i) => + i + .param('args', (p) => + p.required(isClientRequired).type( + $.type + .object() + .prop('client', (p) => + p.required(isClientRequired).type(symbolClient), + ) + .prop('key', (p) => p.optional().type('string')), + ), + ) + .do( + $('super').call('args'), + $(symbol) + .attr(registryName) + .attr('set') + .call('this', $('args').attr('key').required(isClientRequired)), + ), + ); + + if (!currentClass.nodes.length) { + currentClass.nodes.unshift(ctor); + } else { + currentClass.nodes.unshift(ctor, $.newline()); + } + + const symbolRegistry = plugin.symbol('HeyApiRegistry', { + meta: { + category: 'utility', + resource: 'class', + resourceId: 'HeyApiRegistry', + tool: 'sdk', + }, + }); + const node = createRegistryClass({ + plugin, + sdkSymbol: symbol, + symbol: symbolRegistry, + }); + plugin.node(node, registryIndex); + const registryNode = $.field(registryName, (f) => + f + .public() + .static() + .readonly() + .assign($.new(symbolRegistry).generic(symbol)), + ); + currentClass.nodes.unshift(registryNode, $.newline()); + } + + const node = $.class(symbol) + .export() + .extends(symbolClient) + .$if(isAngularClient && currentClass.root, (c) => + c.decorator( + plugin.referenceSymbol({ + category: 'external', + resource: '@angular/core.Injectable', + }), + $.object().prop('providedIn', $.literal('root')), + ), + ) + .do(...currentClass.nodes); + plugin.node(node); + }; + + for (const sdkClass of sdkClasses.values()) { + generateClass(sdkClass); + } + } else { + const allDependencies: Array> = []; + const allNodes: Array> = []; + + for (const model of sdkModel.walk()) { + const { dependencies, node } = model.toNode(plugin); + allDependencies.push(...dependencies); + allNodes.push(node); } - const node = $.class(symbol) - .export() - .extends(symbolClient) - .$if(isAngularClient && currentClass.root, (c) => - c.decorator( - plugin.referenceSymbol({ - category: 'external', - resource: '@angular/core.Injectable', - }), - $.object().prop('providedIn', $.literal('root')), - ), - ) - .do(...currentClass.nodes); - plugin.node(node); - }; + const uniqueDeps = new Map>(); + for (const dep of allDependencies) { + if (dep.symbol) uniqueDeps.set(dep.symbol.id, dep); + } + for (const dep of uniqueDeps.values()) { + plugin.node(dep); + } - for (const sdkClass of sdkClasses.values()) { - generateClass(sdkClass); + for (const node of allNodes) { + plugin.node(node); + } } }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts index c3fc799b9..1d35d73bc 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts @@ -6,7 +6,7 @@ import type { IR } from '~/ir/types'; import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize'; import { getClientPlugin } from '~/plugins/@hey-api/client-core/utils'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { Field, Fields } from '../../client-core/bundle/params'; import type { HeyApiSdkPlugin } from '../types'; @@ -30,14 +30,15 @@ interface ClassNameEntry { path: ReadonlyArray; } -const operationClassName = ({ +export const operationClassName = ({ plugin, value, }: { plugin: HeyApiSdkPlugin['Instance']; value: string; }) => { - const name = stringCase({ case: 'PascalCase', value }); + // TODO: expose casing option + const name = toCase(value, 'PascalCase'); return ( (typeof plugin.config.classNameBuilder === 'string' ? plugin.config.classNameBuilder.replace('{{name}}', name) @@ -48,10 +49,20 @@ const operationClassName = ({ export const operationMethodName = ({ operation, plugin, + value, }: { operation: IR.OperationObject; plugin: HeyApiSdkPlugin['Instance']; -}) => plugin.config.methodNameBuilder?.(operation) || operation.id; + value?: string; +}) => { + // TODO: expose casing option + const name = toCase(value || operation.id, 'camelCase'); + return ( + (typeof plugin.config.methodNameBuilder === 'string' + ? plugin.config.methodNameBuilder.replace('{{name}}', name) + : plugin.config.methodNameBuilder?.(name, operation)) || name + ); +}; /** * Returns a list of classes where this operation appears in the generated SDK. @@ -73,10 +84,10 @@ export const operationClasses = ({ classCandidates = operation.operationId.split(/[./]/).filter(Boolean); if (classCandidates.length >= 2) { const methodCandidate = classCandidates.pop()!; - methodName = stringCase({ - case: 'camelCase', - value: sanitizeNamespaceIdentifier(methodCandidate), - }); + methodName = toCase( + sanitizeNamespaceIdentifier(methodCandidate), + 'camelCase', + ); className = classCandidates.pop()!; } } diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/signature.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/signature.ts index cbd7f7a68..bf11fc7b7 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/signature.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/signature.ts @@ -1,7 +1,7 @@ import type { IR } from '~/ir/types'; import type { PluginInstance } from '~/plugins/shared/utils/instance'; import { refToName } from '~/utils/ref'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { Field } from '../../client-core/bundle/params'; @@ -83,7 +83,7 @@ export const getSignatureParameters = ({ } else if (operation.body.schema.$ref) { // alias body for more ergonomic naming, e.g. user if the type is User const name = refToName(operation.body.schema.$ref); - const key = stringCase({ case: 'camelCase', value: name }); + const key = toCase(name, 'camelCase'); addParameter(key, 'body'); } else { addParameter('body', 'body'); @@ -157,7 +157,7 @@ export const getSignatureParameters = ({ } } else if (operation.body.schema.$ref) { const value = refToName(operation.body.schema.$ref); - const originalName = stringCase({ case: 'camelCase', value }); + const originalName = toCase(value, 'camelCase'); const name = conflicts.has(originalName) ? `${location}_${originalName}` : originalName; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts index c0b39fa84..b947f5b72 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts @@ -75,9 +75,11 @@ export type UserConfig = Plugin.Name<'@hey-api/sdk'> & instance?: string | boolean; /** * Customise the name of methods within the service. By default, - * {@link IR.OperationObject.id} is used. + * `operation.id` is used. */ - methodNameBuilder?: (operation: IR.OperationObject) => string; + methodNameBuilder?: + | string + | ((name: string, operation: IR.OperationObject) => string); // TODO: parser - rename operationId option to something like inferId?: boolean /** * Use operation ID to generate operation names? @@ -252,9 +254,11 @@ export type Config = Plugin.Name<'@hey-api/sdk'> & instance: string; /** * Customise the name of methods within the service. By default, - * {@link IR.OperationObject.id} is used. + * `operation.id` is used. */ - methodNameBuilder?: (operation: IR.OperationObject) => string; + methodNameBuilder: + | string + | ((name: string, operation: IR.OperationObject) => string); // TODO: parser - rename operationId option to something like inferId?: boolean /** * Use operation ID to generate operation names? diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts index d43096065..986488b99 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts @@ -6,7 +6,7 @@ import { createSchemaComment } from '~/plugins/shared/utils/schema'; import type { MaybeTsDsl, TypeTsDsl } from '~/ts-dsl'; import { $, regexp } from '~/ts-dsl'; import { pathToJsonPointer, refToName } from '~/utils/ref'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { HeyApiTypeScriptPlugin } from '../types'; import type { IrSchemaToAstOptions } from './types'; @@ -52,10 +52,8 @@ const schemaToEnumObject = ({ } if (key) { - key = stringCase({ - case: plugin.config.enums.case, + key = toCase(key, plugin.config.enums.case, { stripLeadingSeparators: false, - value: key, }); regexp.number.lastIndex = 0; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts index acaad7db4..132bd6193 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts @@ -3,7 +3,7 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import type { SchemaWithType } from '~/plugins'; import type { TypeTsDsl } from '~/ts-dsl'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { IrSchemaToAstOptions } from '../../shared/types'; @@ -66,10 +66,7 @@ export const stringToAst = ({ const symbolTypeId = plugin.referenceSymbol(queryTypeId); const symbolTypeName = plugin.registerSymbol({ meta: query, - name: stringCase({ - case: plugin.config.case, - value: `${type}_id`, - }), + name: toCase(`${type}_id`, plugin.config.case), }); const node = $.type .alias(symbolTypeName) diff --git a/packages/openapi-ts/src/plugins/@pinia/colada/v0/plugin.ts b/packages/openapi-ts/src/plugins/@pinia/colada/v0/plugin.ts index 34e44d61d..adb41fbb4 100644 --- a/packages/openapi-ts/src/plugins/@pinia/colada/v0/plugin.ts +++ b/packages/openapi-ts/src/plugins/@pinia/colada/v0/plugin.ts @@ -1,7 +1,7 @@ import { registryName } from '~/plugins/@hey-api/sdk/shared/class'; import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import { createMutationOptions } from '../mutationOptions'; import { createQueryOptions } from '../queryOptions'; @@ -76,10 +76,7 @@ export const handlerV0: PiniaColadaPlugin['Handler'] = ({ plugin }) => { e.attr(registryName).attr('get').call(), ); for (const className of entry.path.slice(1)) { - const cls = stringCase({ - case: 'camelCase', - value: className, - }); + const cls = toCase(className, 'camelCase'); queryFn = queryFn.attr(cls); } queryFn = queryFn.attr(entry.methodName); diff --git a/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/plugin.ts b/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/plugin.ts index 7619e0243..601eefdb3 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/plugin.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/plugin.ts @@ -1,7 +1,7 @@ import { registryName } from '~/plugins/@hey-api/sdk/shared/class'; import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { PluginHandler } from '../types'; import { createInfiniteQueryOptions } from './infiniteQueryOptions'; @@ -100,10 +100,7 @@ export const handlerV5: PluginHandler = ({ plugin }) => { e.attr(registryName).attr('get').call(), ); for (const className of entry.path.slice(1)) { - const cls = stringCase({ - case: 'camelCase', - value: className, - }); + const cls = toCase(className, 'camelCase'); queryFn = queryFn.attr(cls); } queryFn = queryFn.attr(entry.methodName); diff --git a/packages/openapi-ts/src/plugins/swr/v2/plugin.ts b/packages/openapi-ts/src/plugins/swr/v2/plugin.ts index f949256cf..65754a523 100644 --- a/packages/openapi-ts/src/plugins/swr/v2/plugin.ts +++ b/packages/openapi-ts/src/plugins/swr/v2/plugin.ts @@ -1,7 +1,7 @@ import { registryName } from '~/plugins/@hey-api/sdk/shared/class'; import { operationClasses } from '~/plugins/@hey-api/sdk/shared/operation'; import { $ } from '~/ts-dsl'; -import { stringCase } from '~/utils/stringCase'; +import { toCase } from '~/utils/to-case'; import type { SwrPlugin } from '../types'; import { createUseSwr } from './useSwr'; @@ -41,10 +41,7 @@ export const handlerV2: SwrPlugin['Handler'] = ({ plugin }) => { e.attr(registryName).attr('get').call(), ); for (const className of entry.path.slice(1)) { - const cls = stringCase({ - case: 'camelCase', - value: className, - }); + const cls = toCase(className, 'camelCase'); queryFn = queryFn.attr(cls); } queryFn = queryFn.attr(entry.methodName); diff --git a/packages/openapi-ts/src/ts-dsl/base.ts b/packages/openapi-ts/src/ts-dsl/base.ts index c6f1aab6e..ebe29d37a 100644 --- a/packages/openapi-ts/src/ts-dsl/base.ts +++ b/packages/openapi-ts/src/ts-dsl/base.ts @@ -135,10 +135,10 @@ export abstract class TsDsl implements Node { if (value instanceof Array) { return value.map((item) => { if (isRef(item)) item = fromRef(item); - return this.unwrap(item, ctx); + return this.unwrap(ctx, item); }) as NodeOfMaybe; } - return this.unwrap(value as any, ctx) as NodeOfMaybe; + return this.unwrap(ctx, value as any) as NodeOfMaybe; } protected $type( @@ -174,13 +174,13 @@ export abstract class TsDsl implements Node { if (value instanceof Array) { return value.map((item) => this.$type(ctx, item, args)) as TypeOfMaybe; } - return this.unwrap(value as any, ctx) as TypeOfMaybe; + return this.unwrap(ctx, value as any) as TypeOfMaybe; } /** Unwraps nested nodes into raw TypeScript AST. */ private unwrap( - value: I, ctx: AstContext, + value: I, ): I extends TsDsl ? N : I { return (isNode(value) ? value.toAst(ctx) : value) as I extends TsDsl< infer N diff --git a/packages/openapi-ts/src/ts-dsl/decl/class.ts b/packages/openapi-ts/src/ts-dsl/decl/class.ts index 2100cfe09..5975bdce2 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/class.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/class.ts @@ -18,6 +18,7 @@ import { safeRuntimeName } from '../utils/name'; import type { FieldName } from './field'; import { FieldTsDsl } from './field'; import { InitTsDsl } from './init'; +import type { MethodName } from './method'; import { MethodTsDsl } from './method'; type Base = Symbol | string; @@ -63,6 +64,11 @@ export class ClassTsDsl extends Mixed { } } + /** Returns true if the class has any members. */ + get hasBody(): boolean { + return this.body.length > 0; + } + /** Adds one or more class members (fields, methods, etc.). */ do(...items: Body): this { this.body.push(...items); @@ -83,14 +89,15 @@ export class ClassTsDsl extends Mixed { } /** Adds a class constructor. */ - init(fn?: (i: InitTsDsl) => void): this { - const i = new InitTsDsl(fn); + init(fn?: InitTsDsl | ((i: InitTsDsl) => void)): this { + const i = + typeof fn === 'function' ? new InitTsDsl(fn) : fn || new InitTsDsl(); this.body.push(i); return this; } /** Adds a class method. */ - method(name: string, fn?: (m: MethodTsDsl) => void): this { + method(name: MethodName, fn?: (m: MethodTsDsl) => void): this { const m = new MethodTsDsl(name, fn); this.body.push(m); return this; diff --git a/packages/openapi-ts/src/ts-dsl/decl/field.ts b/packages/openapi-ts/src/ts-dsl/decl/field.ts index 73f8d00d6..550df0ec3 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/field.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/field.ts @@ -1,8 +1,10 @@ import type { AnalysisContext, AstContext, + Ref, Symbol, } from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; import ts from 'typescript'; import { TsDsl, TypeTsDsl } from '../base'; @@ -43,17 +45,18 @@ const Mixed = DecoratorMixin( export class FieldTsDsl extends Mixed { readonly '~dsl' = 'FieldTsDsl'; - protected name: FieldName; + protected name: Ref; protected _type?: TypeTsDsl; constructor(name: FieldName, fn?: (f: FieldTsDsl) => void) { super(); - this.name = name; + this.name = ref(name); fn?.(this); } override analyze(ctx: AnalysisContext): void { super.analyze(ctx); + ctx.analyze(this.name); ctx.analyze(this._type); } diff --git a/packages/openapi-ts/src/ts-dsl/decl/getter.ts b/packages/openapi-ts/src/ts-dsl/decl/getter.ts index 6ec55d14b..6905c0d3f 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/getter.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/getter.ts @@ -1,4 +1,10 @@ -import type { AnalysisContext, AstContext } from '@hey-api/codegen-core'; +import type { + AnalysisContext, + AstContext, + Ref, + Symbol, +} from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; import ts from 'typescript'; import { TsDsl } from '../base'; @@ -17,7 +23,7 @@ import { ParamMixin } from '../mixins/param'; import { TypeReturnsMixin } from '../mixins/type-returns'; import { BlockTsDsl } from '../stmt/block'; -export type GetterName = string | ts.PropertyName; +export type GetterName = Symbol | string | ts.PropertyName; const Mixed = AbstractMixin( AsyncMixin( @@ -44,15 +50,17 @@ const Mixed = AbstractMixin( export class GetterTsDsl extends Mixed { readonly '~dsl' = 'GetterTsDsl'; - protected name: GetterName; + protected name: Ref; constructor(name: GetterName, fn?: (g: GetterTsDsl) => void) { super(); - this.name = name; + this.name = ref(name); fn?.(this); } override analyze(ctx: AnalysisContext): void { + ctx.analyze(this.name); + ctx.pushScope(); try { super.analyze(ctx); @@ -64,7 +72,7 @@ export class GetterTsDsl extends Mixed { override toAst(ctx: AstContext) { const node = ts.factory.createGetAccessorDeclaration( [...this.$decorators(ctx), ...this.modifiers], - this.name, + this.$node(ctx, this.name) as ts.PropertyName, this.$params(ctx), this.$returns(ctx), this.$node(ctx, new BlockTsDsl(...this._do).pretty()), diff --git a/packages/openapi-ts/src/ts-dsl/decl/method.ts b/packages/openapi-ts/src/ts-dsl/decl/method.ts index e21d1ca97..e3d811f00 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/method.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/method.ts @@ -1,4 +1,10 @@ -import type { AnalysisContext, AstContext } from '@hey-api/codegen-core'; +import type { + AnalysisContext, + AstContext, + Ref, + Symbol, +} from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; import ts from 'typescript'; import { TsDsl } from '../base'; @@ -20,6 +26,8 @@ import { TypeReturnsMixin } from '../mixins/type-returns'; import { BlockTsDsl } from '../stmt/block'; import { TokenTsDsl } from '../token'; +export type MethodName = Symbol | string; + const Mixed = AbstractMixin( AsyncMixin( DecoratorMixin( @@ -49,15 +57,17 @@ const Mixed = AbstractMixin( export class MethodTsDsl extends Mixed { readonly '~dsl' = 'MethodTsDsl'; - protected name: string; + protected name: Ref; - constructor(name: string, fn?: (m: MethodTsDsl) => void) { + constructor(name: MethodName, fn?: (m: MethodTsDsl) => void) { super(); - this.name = name; + this.name = ref(name); fn?.(this); } override analyze(ctx: AnalysisContext): void { + ctx.analyze(this.name); + ctx.pushScope(); try { super.analyze(ctx); @@ -70,7 +80,7 @@ export class MethodTsDsl extends Mixed { const node = ts.factory.createMethodDeclaration( [...this.$decorators(ctx), ...this.modifiers], undefined, - this.name, + this.$node(ctx, this.name) as ts.PropertyName, this._optional ? this.$node(ctx, new TokenTsDsl().optional()) : undefined, this.$generics(ctx), this.$params(ctx), diff --git a/packages/openapi-ts/src/ts-dsl/decl/setter.ts b/packages/openapi-ts/src/ts-dsl/decl/setter.ts index 6b7f82feb..0f87c3918 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/setter.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/setter.ts @@ -1,4 +1,10 @@ -import type { AnalysisContext, AstContext } from '@hey-api/codegen-core'; +import type { + AnalysisContext, + AstContext, + Ref, + Symbol, +} from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; import ts from 'typescript'; import { TsDsl } from '../base'; @@ -16,7 +22,7 @@ import { import { ParamMixin } from '../mixins/param'; import { BlockTsDsl } from '../stmt/block'; -export type SetterName = string | ts.PropertyName; +export type SetterName = Symbol | string | ts.PropertyName; const Mixed = AbstractMixin( AsyncMixin( @@ -39,15 +45,17 @@ const Mixed = AbstractMixin( export class SetterTsDsl extends Mixed { readonly '~dsl' = 'SetterTsDsl'; - protected name: SetterName; + protected name: Ref; constructor(name: SetterName, fn?: (s: SetterTsDsl) => void) { super(); - this.name = name; + this.name = ref(name); fn?.(this); } override analyze(ctx: AnalysisContext): void { + ctx.analyze(this.name); + ctx.pushScope(); try { super.analyze(ctx); @@ -59,7 +67,7 @@ export class SetterTsDsl extends Mixed { override toAst(ctx: AstContext) { const node = ts.factory.createSetAccessorDeclaration( [...this.$decorators(ctx), ...this.modifiers], - this.name, + this.$node(ctx, this.name) as ts.PropertyName, this.$params(ctx), this.$node(ctx, new BlockTsDsl(...this._do).pretty()), ); diff --git a/packages/openapi-ts/src/utils/__tests__/stringCase.test.ts b/packages/openapi-ts/src/utils/__tests__/to-case.test.ts similarity index 89% rename from packages/openapi-ts/src/utils/__tests__/stringCase.test.ts rename to packages/openapi-ts/src/utils/__tests__/to-case.test.ts index 806c044ca..ffaca396f 100644 --- a/packages/openapi-ts/src/utils/__tests__/stringCase.test.ts +++ b/packages/openapi-ts/src/utils/__tests__/to-case.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { StringCase } from '~/types/case'; -import { stringCase } from '../stringCase'; +import { toCase } from '../to-case'; const cases: ReadonlyArray = [ 'camelCase', @@ -178,16 +178,16 @@ const scenarios: ReadonlyArray<{ }, ]; -describe('stringCase', () => { - describe.each(cases)('%s', (style) => { - switch (style) { +describe('toCase', () => { + describe.each(cases)('%s', (casing) => { + switch (casing) { case 'PascalCase': it.each(scenarios)( '$value -> $PascalCase', ({ PascalCase, stripLeadingSeparators, value }) => { - expect( - stringCase({ case: style, stripLeadingSeparators, value }), - ).toBe(PascalCase); + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( + PascalCase, + ); }, ); break; @@ -195,9 +195,9 @@ describe('stringCase', () => { it.each(scenarios)( '$value -> $camelCase', ({ camelCase, stripLeadingSeparators, value }) => { - expect( - stringCase({ case: style, stripLeadingSeparators, value }), - ).toBe(camelCase); + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( + camelCase, + ); }, ); break; @@ -205,9 +205,9 @@ describe('stringCase', () => { it.each(scenarios)( '$value -> $SCREAMING_SNAKE_CASE', ({ SCREAMING_SNAKE_CASE, stripLeadingSeparators, value }) => { - expect( - stringCase({ case: style, stripLeadingSeparators, value }), - ).toBe(SCREAMING_SNAKE_CASE); + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( + SCREAMING_SNAKE_CASE, + ); }, ); break; @@ -215,9 +215,9 @@ describe('stringCase', () => { it.each(scenarios)( '$value -> $snake_case', ({ snake_case, stripLeadingSeparators, value }) => { - expect( - stringCase({ case: style, stripLeadingSeparators, value }), - ).toBe(snake_case); + expect(toCase(value, casing, { stripLeadingSeparators })).toBe( + snake_case, + ); }, ); break; diff --git a/packages/openapi-ts/src/utils/exports.ts b/packages/openapi-ts/src/utils/exports.ts index e529c9363..4082989a5 100644 --- a/packages/openapi-ts/src/utils/exports.ts +++ b/packages/openapi-ts/src/utils/exports.ts @@ -1,6 +1,31 @@ -import { stringCase } from './stringCase'; +import type { StringCase } from '~/types/case'; -// publicly exposed utils +import { toCase } from './to-case'; + +/** + * Utilities shared across the package. + */ export const utils = { - stringCase, + /** + * @deprecated use `toCase` instead + */ + stringCase({ + case: casing, + stripLeadingSeparators, + value, + }: { + readonly case: StringCase | undefined; + /** + * If leading separators have a semantic meaning, we might not want to + * remove them. + */ + stripLeadingSeparators?: boolean; + value: string; + }) { + return toCase(value, casing, { stripLeadingSeparators }); + }, + /** + * Converts the given string to the specified casing. + */ + toCase, }; diff --git a/packages/openapi-ts/src/utils/stringCase.ts b/packages/openapi-ts/src/utils/to-case.ts similarity index 72% rename from packages/openapi-ts/src/utils/stringCase.ts rename to packages/openapi-ts/src/utils/to-case.ts index 41f869e50..255fbc6e1 100644 --- a/packages/openapi-ts/src/utils/stringCase.ts +++ b/packages/openapi-ts/src/utils/to-case.ts @@ -3,7 +3,7 @@ import type { StringCase } from '~/types/case'; const uppercaseRegExp = /[\p{Lu}]/u; const lowercaseRegExp = /[\p{Ll}]/u; const identifierRegExp = /([\p{Alpha}\p{N}_]|$)/u; -const separatorsRegExp = /[_.:\- `\\[\]{}\\/]+/; +const separatorsRegExp = /[_.:\- `\\[\](){}\\/]+/; const leadingSeparatorsRegExp = new RegExp(`^${separatorsRegExp.source}`); const separatorsAndIdentifierRegExp = new RegExp( @@ -15,32 +15,25 @@ const numbersAndIdentifierRegExp = new RegExp( 'gu', ); -const preserveCase = ({ - case: _case, - string, -}: { - readonly case: StringCase; - string: string; -}) => { +const preserveCase = (value: string, casing: StringCase) => { let isLastCharLower = false; let isLastCharUpper = false; let isLastLastCharUpper = false; let isLastLastCharPreserved = false; const separator = - _case === 'snake_case' || _case === 'SCREAMING_SNAKE_CASE' ? '_' : '-'; + casing === 'snake_case' || casing === 'SCREAMING_SNAKE_CASE' ? '_' : '-'; - for (let index = 0; index < string.length; index++) { - const character = string[index]!; - isLastLastCharPreserved = - index > 2 ? string[index - 3] === separator : true; + for (let index = 0; index < value.length; index++) { + const character = value[index]!; + isLastLastCharPreserved = index > 2 ? value[index - 3] === separator : true; let nextIndex = index + 1; - let nextCharacter = string[nextIndex]; + let nextCharacter = value[nextIndex]; separatorsRegExp.lastIndex = 0; while (nextCharacter && separatorsRegExp.test(nextCharacter)) { nextIndex += 1; - nextCharacter = string[nextIndex]; + nextCharacter = value[nextIndex]; } const isSeparatorBeforeNextCharacter = nextIndex !== index + 1; @@ -55,7 +48,7 @@ const preserveCase = ({ lowercaseRegExp.test(nextCharacter))) ) { // insert separator behind character - string = `${string.slice(0, index)}${separator}${string.slice(index)}`; + value = `${value.slice(0, index)}${separator}${value.slice(index)}`; index++; isLastLastCharUpper = isLastCharUpper; isLastCharLower = false; @@ -72,7 +65,7 @@ const preserveCase = ({ ) ) { // insert separator 2 characters behind - string = `${string.slice(0, index - 1)}${separator}${string.slice(index - 1)}`; + value = `${value.slice(0, index - 1)}${separator}${value.slice(index - 1)}`; isLastLastCharUpper = isLastCharUpper; isLastCharLower = true; isLastCharUpper = false; @@ -87,29 +80,33 @@ const preserveCase = ({ } } - return string; + return value; }; -export const stringCase = ({ - case: _case, - stripLeadingSeparators = true, - value, -}: { - readonly case: StringCase | undefined; - /** - * If leading separators have a semantic meaning, we might not want to - * remove them. - */ - stripLeadingSeparators?: boolean; - value: string; -}): string => { - let result = value.trim(); +/** + * Converts the given string to the specified casing. + * + * @param value - The string to convert + * @param casing - The target casing + * @param options - Additional options + * @returns The converted string + */ +export const toCase = ( + value: string, + casing: StringCase | undefined, + options: { + /** + * If leading separators have a semantic meaning, we might not want to + * remove them. + */ + stripLeadingSeparators?: boolean; + } = {}, +) => { + const stripLeadingSeparators = options.stripLeadingSeparators ?? true; - if (!result.length) { - return ''; - } + let result = value.trim(); - if (!_case || _case === 'preserve') { + if (!result.length || !casing || casing === 'preserve') { return result; } @@ -119,7 +116,7 @@ export const stringCase = ({ return ''; } - return _case === 'PascalCase' || _case === 'SCREAMING_SNAKE_CASE' + return casing === 'PascalCase' || casing === 'SCREAMING_SNAKE_CASE' ? result.toLocaleUpperCase() : result.toLocaleLowerCase(); } @@ -127,7 +124,7 @@ export const stringCase = ({ const hasUpperCase = result !== result.toLocaleLowerCase(); if (hasUpperCase) { - result = preserveCase({ case: _case, string: result }); + result = preserveCase(result, casing); } if (stripLeadingSeparators || result[0] !== value[0]) { @@ -135,15 +132,15 @@ export const stringCase = ({ } result = - _case === 'SCREAMING_SNAKE_CASE' + casing === 'SCREAMING_SNAKE_CASE' ? result.toLocaleUpperCase() : result.toLocaleLowerCase(); - if (_case === 'PascalCase') { + if (casing === 'PascalCase') { result = `${result.charAt(0).toLocaleUpperCase()}${result.slice(1)}`; } - if (_case === 'snake_case' || _case === 'SCREAMING_SNAKE_CASE') { + if (casing === 'snake_case' || casing === 'SCREAMING_SNAKE_CASE') { result = result.replaceAll( separatorsAndIdentifierRegExp, (match, identifier, offset) => { -- 2.51.2