Skip to content

Commit e752b13

Browse files
feat(rust): add dynamic-snippet generation support (#9339)
* chore(rust): enable git and git config for rust sdk generator docker * feat(rust): support `dynamic-snippets` * fix: biome lint * chore(rust): update seed * fix: biome * refactor(rust): dynamic-snippets folder structure and formatting * feat(rust): add formatting for readme snips * chore(rust): update seed * fix: biome check
1 parent 62d9335 commit e752b13

File tree

438 files changed

+4777
-81
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

438 files changed

+4777
-81
lines changed

generators/rust/base/src/formatter/RustFormatter.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Logger } from "@fern-api/logger";
2-
import { spawn } from "child_process";
2+
import { spawn, spawnSync } from "child_process";
33

44
export interface RustFormatterOptions {
55
outputDir: string;
@@ -52,3 +52,66 @@ export async function formatRustCode({ outputDir, logger }: RustFormatterOptions
5252
});
5353
});
5454
}
55+
56+
/**
57+
* Format a single Rust code snippet using rustfmt
58+
* @param code The Rust code to format
59+
* @param logger Optional logger for debug output
60+
* @returns The formatted code, or original code if formatting fails
61+
*/
62+
export function formatRustSnippet(code: string, logger?: Logger): string {
63+
try {
64+
const result = spawnSync("rustfmt", ["--edition=2021"], {
65+
input: code,
66+
encoding: "utf8",
67+
timeout: 5000 // 5 second timeout
68+
});
69+
70+
if (result.status === 0 && result.stdout) {
71+
logger?.debug("rustfmt snippet formatting successful");
72+
return result.stdout;
73+
} else {
74+
logger?.debug(`rustfmt snippet formatting failed with status ${result.status}`);
75+
}
76+
} catch (error) {
77+
logger?.debug(`rustfmt snippet formatting error: ${error}`);
78+
}
79+
return code; // Fallback to original if formatting fails
80+
}
81+
82+
/**
83+
* Format a single Rust code snippet asynchronously using rustfmt
84+
* @param code The Rust code to format
85+
* @param logger Optional logger for debug output
86+
* @returns Promise that resolves to formatted code, or original code if formatting fails
87+
*/
88+
export async function formatRustSnippetAsync(code: string, logger?: Logger): Promise<string> {
89+
return new Promise((resolve) => {
90+
const rustfmt = spawn("rustfmt", ["--edition=2021"], {
91+
stdio: "pipe"
92+
});
93+
94+
rustfmt.stdin.write(code);
95+
rustfmt.stdin.end();
96+
97+
let formatted = "";
98+
rustfmt.stdout.on("data", (data) => {
99+
formatted += data.toString();
100+
});
101+
102+
rustfmt.on("close", (exitCode) => {
103+
if (exitCode === 0 && formatted) {
104+
logger?.debug("rustfmt async snippet formatting successful");
105+
resolve(formatted);
106+
} else {
107+
logger?.debug(`rustfmt async snippet formatting failed with exit code ${exitCode}`);
108+
resolve(code); // Fallback to original
109+
}
110+
});
111+
112+
rustfmt.on("error", (error) => {
113+
logger?.debug(`rustfmt async snippet formatting error: ${error.message}`);
114+
resolve(code); // Fallback to original
115+
});
116+
});
117+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export type { RustFormatterOptions } from "./RustFormatter";
2-
export { formatRustCode } from "./RustFormatter";
2+
export { formatRustCode, formatRustSnippet, formatRustSnippetAsync } from "./RustFormatter";

generators/rust/dynamic-snippets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@fern-api/core-utils": "workspace:*",
3434
"@fern-api/dynamic-ir-sdk": "^59.6.1",
3535
"@fern-api/path-utils": "workspace:*",
36+
"@fern-api/rust-base": "workspace:*",
3637
"@fern-api/rust-codegen": "workspace:*",
3738
"@types/lodash-es": "^4.17.12",
3839
"@types/node": "18.15.3",

generators/rust/dynamic-snippets/src/EndpointSnippetGenerator.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AbstractFormatter, Scope, Severity } from "@fern-api/browser-compatible-base-generator";
22
import { assertNever } from "@fern-api/core-utils";
33
import { FernIr } from "@fern-api/dynamic-ir-sdk";
4+
import { formatRustSnippet, formatRustSnippetAsync } from "@fern-api/rust-base";
45
import { rust } from "@fern-api/rust-codegen";
56

67
import { DynamicSnippetsGeneratorContext } from "./context/DynamicSnippetsGeneratorContext";
@@ -24,7 +25,10 @@ export class EndpointSnippetGenerator {
2425
request: FernIr.dynamic.EndpointSnippetRequest;
2526
}): Promise<string> {
2627
const components = this.buildCodeComponents({ endpoint, snippet: request });
27-
return components.join("\n") + "\n";
28+
const rawCode = components.join("\n") + "\n";
29+
// Try to format with rustfmt
30+
const formattedCode = await formatRustSnippetAsync(rawCode);
31+
return formattedCode;
2832
}
2933

3034
public generateSnippetSync({
@@ -35,7 +39,10 @@ export class EndpointSnippetGenerator {
3539
request: FernIr.dynamic.EndpointSnippetRequest;
3640
}): string {
3741
const components = this.buildCodeComponents({ endpoint, snippet: request });
38-
return components.join("\n") + "\n";
42+
const rawCode = components.join("\n") + "\n";
43+
// Try sync formatting with rustfmt, but fallback to raw code if it fails
44+
const formattedCode = formatRustSnippet(rawCode);
45+
return formattedCode;
3946
}
4047

4148
private buildCodeBlock({
@@ -110,7 +117,9 @@ export class EndpointSnippetGenerator {
110117
method: "expect",
111118
args: [rust.Expression.stringLiteral("Failed to build client")]
112119
})
113-
})
120+
}),
121+
// Add the actual API method call
122+
this.callMethod({ endpoint, snippet })
114123
]);
115124

116125
// Create the standalone function
@@ -146,14 +155,20 @@ export class EndpointSnippetGenerator {
146155
endpoint: FernIr.dynamic.Endpoint;
147156
snippet: FernIr.dynamic.EndpointSnippetRequest;
148157
}): rust.UseStatement[] {
149-
const imports = [
158+
const imports = ["ClientConfig", this.getClientName({ endpoint })];
159+
160+
// Add request struct import if this endpoint uses an inlined request
161+
if (endpoint.request.type === "inlined") {
162+
const requestStructName = this.context.getStructName(endpoint.request.declaration.name);
163+
imports.push(requestStructName);
164+
}
165+
166+
return [
150167
new rust.UseStatement({
151168
path: this.context.getPackageName(),
152-
items: ["ClientConfig", this.getClientName({ endpoint })]
169+
items: imports
153170
})
154171
];
155-
156-
return imports;
157172
}
158173

159174
private getClientConfigStruct({

generators/rust/dynamic-snippets/src/context/DynamicTypeInstantiationMapper.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export class DynamicTypeInstantiationMapper {
3939
return this.convertLiteral({ literal: args.typeReference.value, value: args.value });
4040
case "unknown":
4141
return this.convertUnknown({ value: args.value });
42+
case "named":
43+
return this.convertNamed({ typeReference: args.typeReference, value: args.value });
44+
case "optional":
45+
case "nullable":
46+
return this.convertOptional({ typeReference: args.typeReference, value: args.value });
47+
case "list":
48+
return this.convertList({ typeReference: args.typeReference, value: args.value });
4249
default:
4350
return rust.Expression.raw('todo!("Unhandled type reference")');
4451
}
@@ -102,9 +109,61 @@ export class DynamicTypeInstantiationMapper {
102109

103110
if (typeof value === "object") {
104111
// Use serde_json for complex objects
105-
return rust.Expression.raw(`serde_json::json!(${JSON.stringify(JSON.stringify(value))})`);
112+
return rust.Expression.raw(`serde_json::json!(${JSON.stringify(value)})`);
106113
}
107114

108115
return rust.Expression.stringLiteral("");
109116
}
117+
118+
private convertNamed({
119+
typeReference,
120+
value
121+
}: {
122+
typeReference: FernIr.dynamic.TypeReference;
123+
value: unknown;
124+
}): rust.Expression {
125+
// For now, use JSON for named types - this removes the todo!() error
126+
// TODO: Implement proper struct instantiation in the future
127+
if (typeof value === "object" && value != null) {
128+
return rust.Expression.raw(`serde_json::json!(${JSON.stringify(value)})`);
129+
}
130+
131+
return rust.Expression.stringLiteral(String(value));
132+
}
133+
134+
private convertOptional({
135+
typeReference,
136+
value
137+
}: {
138+
typeReference: FernIr.dynamic.TypeReference;
139+
value: unknown;
140+
}): rust.Expression {
141+
if (value == null) {
142+
return rust.Expression.none();
143+
}
144+
// For optional/nullable, use the inner type's value structure
145+
const innerTypeRef =
146+
(typeReference as FernIr.dynamic.TypeReference.Optional | FernIr.dynamic.TypeReference.Nullable).value ||
147+
({ type: "unknown" } as FernIr.dynamic.TypeReference);
148+
const innerValue = this.convert({ typeReference: innerTypeRef, value });
149+
return rust.Expression.functionCall("Some", [innerValue]);
150+
}
151+
152+
private convertList({
153+
typeReference,
154+
value
155+
}: {
156+
typeReference: FernIr.dynamic.TypeReference;
157+
value: unknown;
158+
}): rust.Expression {
159+
if (!Array.isArray(value)) {
160+
return rust.Expression.vec([]);
161+
}
162+
// For lists, use the inner type's value structure
163+
const innerTypeRef =
164+
(typeReference as FernIr.dynamic.TypeReference.List).value ||
165+
({ type: "unknown" } as FernIr.dynamic.TypeReference);
166+
const elements = value.map((item) => this.convert({ typeReference: innerTypeRef, value: item }));
167+
return rust.Expression.vec(elements);
168+
}
110169
}

generators/rust/sdk/src/SdkGeneratorCli.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { generateModels } from "@fern-api/rust-model";
88
import { FernGeneratorExec } from "@fern-fern/generator-exec-sdk";
99
import { Endpoint } from "@fern-fern/generator-exec-sdk/api";
1010
import { IntermediateRepresentation } from "@fern-fern/ir-sdk/api";
11+
import { mkdir, writeFile } from "fs/promises";
12+
import { join } from "path";
1113
import { EnvironmentGenerator } from "./environment/EnvironmentGenerator";
1214
import { ErrorGenerator } from "./error/ErrorGenerator";
1315
import { ClientConfigGenerator } from "./generators/ClientConfigGenerator";
@@ -70,6 +72,10 @@ export class SdkGeneratorCli extends AbstractRustGeneratorCli<SdkCustomConfigSch
7072
await context.project.persist();
7173
context.logger.info("=== PERSIST COMPLETE ===");
7274

75+
// Generate dynamic-snippets directory with individual .rs files
76+
await this.generateDynamicSnippetFiles(context);
77+
context.logger.info("=== dynamic-snippets COMPLETE ===");
78+
7379
context.logger.info("=== RUNNING rustfmt ===");
7480
await formatRustCode({
7581
outputDir: context.project.absolutePathToOutputDirectory,
@@ -397,6 +403,68 @@ export class SdkGeneratorCli extends AbstractRustGeneratorCli<SdkCustomConfigSch
397403
}
398404
}
399405

406+
// ===========================
407+
// DYNAMIC SNIPPETS GENERATION
408+
// ===========================
409+
410+
private async generateDynamicSnippetFiles(context: SdkGeneratorContext): Promise<void> {
411+
context.logger.info("=== GENERATING DYNAMIC SNIPPETS ===");
412+
413+
// Check if we have output directory configured
414+
if (!context.config.output.path) {
415+
context.logger.info("No output path configured, skipping dynamic snippets");
416+
return;
417+
}
418+
419+
// Check if we have dynamic IR
420+
const dynamicIr = context.ir.dynamic;
421+
if (dynamicIr == null) {
422+
context.logger.info("No dynamic IR available, skipping dynamic snippets");
423+
return;
424+
}
425+
426+
// Create dynamic snippets generator
427+
const dynamicSnippetsGenerator = new DynamicSnippetsGenerator({
428+
ir: convertIr(dynamicIr),
429+
config: context.config
430+
});
431+
432+
let exampleIndex = 0;
433+
434+
// Process each endpoint from dynamic IR
435+
for (const [endpointId, endpoint] of Object.entries(dynamicIr.endpoints)) {
436+
// Get examples for this endpoint
437+
const examples = endpoint.examples ?? [];
438+
439+
for (const example of examples) {
440+
try {
441+
// Convert example to dynamic snippet request
442+
const snippetRequest = convertDynamicEndpointSnippetRequest(example);
443+
444+
// Generate the snippet
445+
const snippetResponse = await dynamicSnippetsGenerator.generate(snippetRequest);
446+
447+
if (snippetResponse.snippet) {
448+
// Create dynamic-snippets directory if it doesn't exist
449+
const dynamicSnippetsDir = join(context.config.output.path, "dynamic-snippets");
450+
await mkdir(dynamicSnippetsDir, { recursive: true });
451+
452+
// Write the Rust snippet file directly
453+
const snippetPath = join(dynamicSnippetsDir, `example${exampleIndex}.rs`);
454+
await writeFile(snippetPath, snippetResponse.snippet);
455+
456+
context.logger.info(`Generated dynamic snippet: ${snippetPath}`);
457+
exampleIndex++;
458+
}
459+
} catch (error) {
460+
context.logger.warn(`Failed to generate dynamic snippet for ${endpointId}: ${error}`);
461+
}
462+
}
463+
}
464+
465+
context.logger.info(`=== DYNAMIC SNIPPETS COMPLETE (${exampleIndex} files) ===`);
466+
}
467+
400468
// ===========================
401469
// UTILITY METHODS
402470
// ===========================

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

seed/rust-sdk/accept-header/README.md

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

seed/rust-sdk/accept-header/dynamic-snippets/example0.rs

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

seed/rust-sdk/accept-header/dynamic-snippets/example1.rs

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)