Skip to content

Commit 08a820a

Browse files
fix(csharp): Improve generator performance (#6504)
--------- Co-authored-by: fern-support <[email protected]>
1 parent e50eacb commit 08a820a

20 files changed

+406
-147
lines changed

generators/browser-compatible-base/src/ast/AbstractFormatter.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
export abstract class AbstractFormatter {
22
abstract format(content: string): Promise<string>;
33
abstract formatSync(content: string): string;
4+
formatMultiple(contents: string[]): Promise<string[]> {
5+
return Promise.all(contents.map((content) => this.format(content)));
6+
}
7+
formatMultipleSync(contents: string[]): string[] {
8+
return contents.map((content) => this.formatSync(content));
9+
}
410
}
511

612
export class NopFormatter extends AbstractFormatter {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { File } from "@fern-api/base-generator";
2+
import { BaseCsharpCustomConfigSchema } from "@fern-api/csharp-codegen";
3+
import { RelativeFilePath } from "@fern-api/fs-utils";
4+
5+
import { AbstractCsharpGeneratorContext } from "./context/AbstractCsharpGeneratorContext";
6+
7+
export abstract class AsyncFileGenerator<
8+
GeneratedFile extends File,
9+
CustomConfig extends BaseCsharpCustomConfigSchema,
10+
Context extends AbstractCsharpGeneratorContext<CustomConfig>
11+
> {
12+
constructor(protected readonly context: Context) {}
13+
14+
public async generate(): Promise<GeneratedFile> {
15+
if (await this.shouldGenerate()) {
16+
this.context.logger.debug(`Generating ${this.getFilepath()}`);
17+
} else {
18+
this.context.logger.warn(
19+
`Internal warning: Generating ${this.getFilepath()} even though the file generator should not have been called.`
20+
);
21+
}
22+
return await this.doGenerate();
23+
}
24+
25+
public async shouldGenerate(): Promise<boolean> {
26+
return true;
27+
}
28+
29+
protected abstract doGenerate(): Promise<GeneratedFile>;
30+
31+
protected abstract getFilepath(): RelativeFilePath;
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import os from "os";
2+
import path from "path";
3+
4+
/**
5+
* Find the path to a .NET global tool with environment variable support and fallbacks
6+
* @param {string} toolName - Name of the .NET tool to find
7+
* @returns {string} - Full path to the tool executable
8+
*/
9+
export function findDotnetToolPath(toolName: string): string {
10+
// Priority 1: Check if a direct override environment variable exists
11+
const toolEnvVar = `DOTNET_TOOL_${toolName.toUpperCase()}_PATH`;
12+
if (process.env[toolEnvVar]) {
13+
return process.env[toolEnvVar];
14+
}
15+
16+
// Priority 2: Check custom tools directory from DOTNET_TOOLS_PATH
17+
if (process.env.DOTNET_TOOLS_PATH) {
18+
const customPath = path.join(process.env.DOTNET_TOOLS_PATH, toolName);
19+
return customPath;
20+
}
21+
22+
// Priority 3: Check DOTNET_CLI_HOME if set
23+
if (process.env.DOTNET_CLI_HOME) {
24+
const cliHomePath = path.join(process.env.DOTNET_CLI_HOME, ".dotnet", "tools", toolName);
25+
return cliHomePath;
26+
}
27+
28+
// Priority 4: Check standard location based on OS
29+
const homeDir = os.homedir();
30+
const standardPath = path.join(homeDir, ".dotnet", "tools", toolName);
31+
return standardPath;
32+
}

generators/csharp/base/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./project";
2+
export { AsyncFileGenerator } from "./AsyncFileGenerator";
23
export { FileGenerator } from "./FileGenerator";
34
export { TestFileGenerator } from "./TestFileGenerator";
45
export { AbstractCsharpGeneratorContext } from "./context/AbstractCsharpGeneratorContext";
@@ -8,4 +9,5 @@ export { AsIsFiles } from "./AsIs";
89
export { BaseCsharpCustomConfigSchema } from "@fern-api/csharp-codegen";
910
export { CsharpProject } from "./project/CsharpProject";
1011
export { CsharpProtobufTypeMapper } from "./proto/CsharpProtobufTypeMapper";
12+
export { findDotnetToolPath } from "./findDotNetToolPath";
1113
export * from "./proto/constants";

generators/csharp/base/src/project/CsharpProject.ts

+11-10
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, readFile, writeFile } from "fs/promises";
1+
import { access, mkdir, readFile, writeFile } from "fs/promises";
22
import { template } from "lodash-es";
33
import path from "path";
44

@@ -9,6 +9,7 @@ import { loggingExeca } from "@fern-api/logging-execa";
99

1010
import { AsIsFiles } from "../AsIs";
1111
import { AbstractCsharpGeneratorContext } from "../context/AbstractCsharpGeneratorContext";
12+
import { findDotnetToolPath } from "../findDotNetToolPath";
1213
import { CSharpFile } from "./CSharpFile";
1314

1415
const SRC_DIRECTORY_NAME = "src";
@@ -162,12 +163,10 @@ export class CsharpProject extends AbstractProject<AbstractCsharpGeneratorContex
162163
await this.createPublicCoreDirectory({ absolutePathToProjectDirectory });
163164

164165
try {
165-
await loggingExeca(this.context.logger, "dotnet", ["csharpier", "."], {
166+
const csharpier = findDotnetToolPath("dotnet-csharpier");
167+
await loggingExeca(this.context.logger, csharpier, ["--fast", "--no-msbuild-check", "."], {
166168
doNotPipeOutput: true,
167-
cwd: absolutePathToSrcDirectory,
168-
env: {
169-
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
170-
}
169+
cwd: absolutePathToSrcDirectory
171170
});
172171
} catch (error) {
173172
this.context.logger.warn("csharpier command failed, continuing without formatting.");
@@ -179,10 +178,12 @@ export class CsharpProject extends AbstractProject<AbstractCsharpGeneratorContex
179178
}: {
180179
absolutePathToSrcDirectory: AbsoluteFilePath;
181180
}): Promise<AbsoluteFilePath> {
182-
await loggingExeca(this.context.logger, "dotnet", ["new", "sln", "-n", this.name], {
183-
doNotPipeOutput: true,
184-
cwd: absolutePathToSrcDirectory
185-
});
181+
await access(path.join(absolutePathToSrcDirectory, `${this.name}.sln`)).catch(() =>
182+
loggingExeca(this.context.logger, "dotnet", ["new", "sln", "-n", this.name, "--no-update-check"], {
183+
doNotPipeOutput: true,
184+
cwd: absolutePathToSrcDirectory
185+
})
186+
);
186187

187188
const absolutePathToProjectDirectory = join(absolutePathToSrcDirectory, RelativeFilePath.of(this.name));
188189
this.context.logger.debug(`mkdir ${absolutePathToProjectDirectory}`);

generators/csharp/codegen/src/ast/core/AstNode.ts

+55
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,34 @@ export abstract class AstNode extends AbstractAstNode {
4242
const stringNode = writer.toString(skipImports);
4343
return formatter != null ? formatter.formatSync(stringNode) : stringNode;
4444
}
45+
public toStringAsync({
46+
namespace,
47+
allNamespaceSegments,
48+
allTypeClassReferences,
49+
rootNamespace,
50+
customConfig,
51+
formatter,
52+
skipImports = false
53+
}: {
54+
namespace: string;
55+
allNamespaceSegments: Set<string>;
56+
allTypeClassReferences: Map<string, Set<Namespace>>;
57+
rootNamespace: string;
58+
customConfig: BaseCsharpCustomConfigSchema;
59+
formatter?: AbstractFormatter;
60+
skipImports?: boolean;
61+
}): Promise<string> {
62+
const writer = new Writer({
63+
namespace,
64+
allNamespaceSegments,
65+
allTypeClassReferences,
66+
rootNamespace,
67+
customConfig
68+
});
69+
this.write(writer);
70+
const stringNode = writer.toString(skipImports);
71+
return formatter != null ? formatter.format(stringNode) : Promise.resolve(stringNode);
72+
}
4573

4674
public toFormattedSnippet({
4775
allNamespaceSegments,
@@ -69,4 +97,31 @@ export abstract class AstNode extends AbstractAstNode {
6997
body: formatter.formatSync(writer.buffer)
7098
};
7199
}
100+
101+
public async toFormattedSnippetAsync({
102+
allNamespaceSegments,
103+
allTypeClassReferences,
104+
rootNamespace,
105+
customConfig,
106+
formatter
107+
}: {
108+
allNamespaceSegments: Set<string>;
109+
allTypeClassReferences: Map<string, Set<Namespace>>;
110+
rootNamespace: string;
111+
customConfig: BaseCsharpCustomConfigSchema;
112+
formatter: AbstractFormatter;
113+
}): Promise<FormattedAstNodeSnippet> {
114+
const writer = new Writer({
115+
namespace: "",
116+
allNamespaceSegments,
117+
allTypeClassReferences,
118+
rootNamespace,
119+
customConfig
120+
});
121+
this.write(writer);
122+
return {
123+
imports: writer.importsToString(),
124+
body: await formatter.format(writer.buffer)
125+
};
126+
}
72127
}

generators/csharp/formatter/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
},
3131
"devDependencies": {
3232
"@fern-api/base-generator": "workspace:*",
33+
"@fern-api/csharp-base": "workspace:*",
3334
"@types/jest": "^29.5.14",
3435
"@fern-api/configs": "workspace:*",
3536
"@types/node": "18.15.3",
@@ -38,6 +39,7 @@
3839
"prettier": "^3.4.2",
3940
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
4041
"typescript": "5.7.2",
41-
"vitest": "^2.1.9"
42+
"vitest": "^2.1.9",
43+
"execa": "^5.1.1"
4244
}
43-
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,50 @@
1-
import { execSync } from "child_process";
1+
import execa from "execa";
22

33
import { AbstractFormatter } from "@fern-api/base-generator";
4+
import { findDotnetToolPath } from "@fern-api/csharp-base";
45

56
export class CsharpFormatter extends AbstractFormatter {
7+
private readonly csharpier: string;
8+
9+
constructor() {
10+
super();
11+
this.csharpier = findDotnetToolPath("dotnet-csharpier");
12+
}
13+
14+
private appendSemicolon(content: string): string {
15+
return content.endsWith(";") ? content : content + ";";
16+
}
17+
618
public async format(content: string): Promise<string> {
7-
return this.formatCode(content);
19+
content = this.appendSemicolon(content);
20+
21+
const { stdout } = await execa(this.csharpier, ["--fast", "--no-msbuild-check"], {
22+
input: content,
23+
encoding: "utf-8",
24+
stripFinalNewline: false
25+
});
26+
return stdout;
827
}
928

10-
public formatSync(content: string): string {
11-
return this.formatCode(content);
29+
public override async formatMultiple(contents: string[]): Promise<string[]> {
30+
const content = contents.map((c, index) => `Dummy${index}.cs\u0003${this.appendSemicolon(c)}\u0003`).join();
31+
32+
const { stdout } = await execa(this.csharpier, ["--fast", "--no-msbuild-check", "--pipe-multiple-files"], {
33+
input: content,
34+
encoding: "utf-8",
35+
stripFinalNewline: false
36+
});
37+
return stdout.split("\u0003");
1238
}
1339

14-
private formatCode(content: string): string {
15-
if (!content.endsWith(";")) {
16-
content += ";";
17-
}
18-
try {
19-
return execSync("dotnet csharpier", { input: content, encoding: "utf-8" });
20-
} catch (e: unknown) {
21-
return content;
22-
}
40+
public formatSync(content: string): string {
41+
content = this.appendSemicolon(content);
42+
43+
const { stdout } = execa.sync(this.csharpier, ["--fast", "--no-msbuild-check"], {
44+
input: content,
45+
encoding: "utf-8",
46+
stripFinalNewline: false
47+
});
48+
return stdout;
2349
}
2450
}

generators/csharp/model/src/ModelGeneratorContext.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FernGeneratorExec, GeneratorNotificationService } from "@fern-api/base-generator";
1+
import { AbstractFormatter, FernGeneratorExec, GeneratorNotificationService } from "@fern-api/base-generator";
22
import { AbstractCsharpGeneratorContext, AsIsFiles } from "@fern-api/csharp-base";
33
import { CsharpFormatter } from "@fern-api/csharp-formatter";
44
import { RelativeFilePath } from "@fern-api/fs-utils";
@@ -8,7 +8,7 @@ import { FernFilepath, IntermediateRepresentation, TypeId, WellKnownProtobufType
88
import { ModelCustomConfigSchema } from "./ModelCustomConfig";
99

1010
export class ModelGeneratorContext extends AbstractCsharpGeneratorContext<ModelCustomConfigSchema> {
11-
public readonly formatter: CsharpFormatter;
11+
public readonly formatter: AbstractFormatter;
1212
public constructor(
1313
ir: IntermediateRepresentation,
1414
config: FernGeneratorExec.config.GeneratorConfig,

generators/csharp/sdk/package.json

+10-10
Original file line numberDiff line numberDiff line change
@@ -29,32 +29,32 @@
2929
"dockerTagLatest": "pnpm dist:cli && docker build -f ./Dockerfile -t fernapi/fern-csharp-sdk:latest ../../.."
3030
},
3131
"devDependencies": {
32+
"@fern-api/base-generator": "workspace:*",
33+
"@fern-api/configs": "workspace:*",
3234
"@fern-api/core-utils": "workspace:*",
3335
"@fern-api/csharp-base": "workspace:*",
3436
"@fern-api/csharp-codegen": "workspace:*",
3537
"@fern-api/csharp-formatter": "workspace:*",
3638
"@fern-api/fern-csharp-model": "workspace:*",
3739
"@fern-api/fs-utils": "workspace:*",
38-
"@fern-api/base-generator": "workspace:*",
3940
"@fern-api/logger": "workspace:*",
4041
"@fern-fern/generator-cli-sdk": "0.0.17",
4142
"@fern-fern/generator-exec-sdk": "^0.0.1021",
4243
"@fern-fern/ir-sdk": "^57.0.0",
43-
"lodash-es": "^4.17.21",
44-
"url-join": "^5.0.0",
45-
"zod": "^3.22.3",
44+
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
4645
"@types/jest": "^29.5.14",
4746
"@types/lodash-es": "^4.17.12",
48-
"@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.14",
49-
"esbuild": "^0.24.0",
50-
"tsup": "^8.3.5",
51-
"@fern-api/configs": "workspace:*",
5247
"@types/node": "18.15.3",
48+
"@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.14",
5349
"depcheck": "^1.4.7",
50+
"esbuild": "^0.24.0",
5451
"eslint": "^8.56.0",
52+
"lodash-es": "^4.17.21",
5553
"prettier": "^3.4.2",
56-
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
54+
"tsup": "^8.3.5",
5755
"typescript": "5.7.2",
58-
"vitest": "^2.1.9"
56+
"url-join": "^5.0.0",
57+
"vitest": "^2.1.9",
58+
"zod": "^3.22.3"
5959
}
6060
}

generators/csharp/sdk/src/SdkGeneratorCli.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class SdkGeneratorCLI extends AbstractCsharpGeneratorCli<SdkCustomConfigS
9191
}
9292

9393
private generateRequests(context: SdkGeneratorContext, service: HttpService, serviceId: string) {
94-
for (const endpoint of service.endpoints) {
94+
service.endpoints.forEach((endpoint) => {
9595
if (endpoint.sdkRequest != null && endpoint.sdkRequest.shape.type === "wrapper") {
9696
const wrappedRequestGenerator = new WrappedRequestGenerator({
9797
wrapper: endpoint.sdkRequest.shape,
@@ -102,10 +102,12 @@ export class SdkGeneratorCLI extends AbstractCsharpGeneratorCli<SdkCustomConfigS
102102
const wrappedRequest = wrappedRequestGenerator.generate();
103103
context.project.addSourceFiles(wrappedRequest);
104104
}
105-
}
105+
});
106106
}
107107

108108
protected async generate(context: SdkGeneratorContext): Promise<void> {
109+
await context.snippetGenerator.populateSnippetsCache();
110+
109111
const models = generateModels({ context });
110112
for (const file of models) {
111113
context.project.addSourceFiles(file);
@@ -124,7 +126,7 @@ export class SdkGeneratorCLI extends AbstractCsharpGeneratorCli<SdkCustomConfigS
124126
}
125127
}
126128

127-
for (const [_, subpackage] of Object.entries(context.ir.subpackages)) {
129+
Object.entries(context.ir.subpackages).forEach(([_, subpackage]) => {
128130
const service = subpackage.service != null ? context.getHttpServiceOrThrow(subpackage.service) : undefined;
129131
const subClient = new SubPackageClientGenerator({
130132
context,
@@ -137,7 +139,7 @@ export class SdkGeneratorCLI extends AbstractCsharpGeneratorCli<SdkCustomConfigS
137139
if (subpackage.service != null && service != null) {
138140
this.generateRequests(context, service, subpackage.service);
139141
}
140-
}
142+
});
141143

142144
const baseOptionsGenerator = new BaseOptionsGenerator(context);
143145

@@ -222,7 +224,7 @@ export class SdkGeneratorCLI extends AbstractCsharpGeneratorCli<SdkCustomConfigS
222224
context.project.addTestFiles(test);
223225

224226
if (context.config.output.snippetFilepath != null) {
225-
const snippets = new SnippetJsonGenerator({ context }).generate();
227+
const snippets = await new SnippetJsonGenerator({ context }).generate();
226228
await writeFile(
227229
context.config.output.snippetFilepath,
228230
JSON.stringify(await FernGeneratorExecSerializers.Snippets.jsonOrThrow(snippets), undefined, 4)

0 commit comments

Comments
 (0)