feat(csharp): generate invocation-only dynamic snippets - #17405
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…snippets The invocation-only dynamic-snippets hook now returns a structured InvocationSnippetResponse (snippet + imports + clientName + errors) instead of a bare call string. Callers such as documentation code templates can now regenerate the imports and client instantiation alongside the call and keep them in sync across SDK renames. The branded-string-alias case, which previously returned undefined (dropping the call because it referenced an SDK import the caller couldn't supply), now returns the call plus the required import block. generateInvocationSync still returns undefined only for the "generator doesn't implement the hook" capability check the multi-language fan-out relies on. Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors the finalized TypeScript structured contract (PR #17393) and the Python (#17402) / Go (#17403) / Java (#17404) ports: alongside the full snippet, the generator now returns InvocationSnippetResponse = { snippet, imports, clientName, errors } for callers (e.g. docs code templates) that render the invocation inside code they already own. - snippet: the bare call (honoring options.clientVariableName), no client construction, no Examples class/method scaffold, no trailing `;`. - imports: the C# `using ...;` block the call references, captured separately via a new AstNode.toStringWithoutImports helper (the C# analogue of the TS AST's toStringWithoutImports and the Java AST's renderNodeWithoutImports). Empty string when the call needs no usings; populated when the invocation constructs imported types inline (e.g. a body value with System.DateTime / System.Guid). - clientName: the generated client class name (Types.RootClientForSnippets.name). - errors: preserved from the existing error reporter. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
AI Review Summary
Adds AstNode.toStringWithoutImports to the C# AST and an invocation-only snippet path to EndpointSnippetGenerator, mirroring the TS/Python/Go/Java ports. Main concerns: the new render pass hardcodes namespace: "Examples" with empty namespace/type-reference collections (unlike the full-snippet path), the tests exercise a generateInvocationSync entry point that isn't in this diff, and formatSync on a bare expression fragment is a latent hazard.
- 🟡 2 warning(s)
- 🔵 2 suggestion(s)
| namespace: "Examples", | ||
| generation: this.generation, | ||
| allNamespaceSegments: new Set(), | ||
| allTypeClassReferences: new Map(), |
There was a problem hiding this comment.
🟡 warning
The full-snippet path renders with the real context (this.context.getNamespace() / getAllNamespaceSegments() / getAllTypeClassReferences()), while this passes a hardcoded "Examples" namespace and empty collections. Those inputs drive namespace-collision detection and type qualification in Writer, so an invocation referencing a type whose name collides with a namespace segment can render differently (or unqualified/ambiguously) here versus the full snippet — and the imports block computed alongside it will match that divergent render. Pass the same context values used by generateSnippet.
| }; | ||
|
|
||
| it("generates the invocation without client instantiation or class scaffold", () => { | ||
| const response = generator.generateInvocationSync(request); |
There was a problem hiding this comment.
🟡 warning
The tests call generator.generateInvocationSync(...), but this diff only adds EndpointSnippetGenerator.generateInvocationSnippetSync. If the wiring in DynamicSnippetsGenerator isn't inherited from the base package on the stacked branch, this won't compile — worth confirming the dispatch exists (and is covered) rather than relying on the base branch.
| const body = writer.toString(true); | ||
| const imports = writer.importsToString() ?? ""; | ||
| return { | ||
| code: formatter != null ? formatter.formatSync(body) : body, |
There was a problem hiding this comment.
🔵 suggestion
formatSync is being handed a bare expression fragment, not a compilation unit. Most C# formatters (csharpier) will throw or mangle on unparseable input. No caller passes a formatter today, so this is latent — either drop the formatter param from this helper or document that callers must pass a formatter tolerant of fragments.
| snippet: this.stripTrailingSemicolon(code.trim()), | ||
| imports, | ||
| clientName: this.Types.RootClientForSnippets.name, | ||
| errors: this.context.errors.empty() ? undefined : this.context.errors.toDynamicSnippetErrors() |
There was a problem hiding this comment.
🔵 suggestion
this.context.errors is shared across calls on the same generator instance; if this method is invoked repeatedly without a scope/reset, errors from a prior endpoint will leak into this response. Confirm the caller scopes errors the way generateSnippet does (this.context.errors.scope(...)).
| // construction, no `Examples` class/method scaffold, and no trailing `;`. When the call | ||
| // references SDK or stdlib types (e.g. a body value constructed with `DateTime` or a | ||
| // `Guid`) the `using ...;` block it needs is surfaced separately so the caller can render | ||
| // it rather than falling back to the complete snippet. | ||
| const { code, imports } = invocation.toStringWithoutImports({ |
There was a problem hiding this comment.
🟡 Invocation-only snippets skip the check that the requested example belongs to the endpoint
The invocation-only snippet path builds the call directly (this.callMethod(...) at generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts:110) without the example-matching validation the normal snippet path performs, so a request naming an example that belongs to a different endpoint silently produces a snippet for the wrong endpoint instead of being rejected.
Impact: Documentation can show a code sample for the wrong API operation when several operations share the same method and path.
Missing example-id guard compared with the full-snippet builder
buildCodeBlock (generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts:141-160) throws "Endpoint does not have an example that matches the snippet" when the request is an EndpointExample whose id is not among endpoint.examples. AbstractDynamicSnippetsGenerator relies on that throw to skip candidate endpoints: resolveEndpoints can return multiple endpoints for the same method+path (different namespaces), and both generateSync and generateInvocationSync (generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGenerator.ts:116-152) iterate the candidates and return the first result that produced no errors.
Because generateInvocationSnippetSync bypasses buildCodeBlock, no throw occurs, so the first candidate endpoint always wins even when the example id identifies a different one — diverging from the behavior of generateSnippetSync for identical input.
Prompt for agents
In generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts, generateInvocationSnippetSync calls callMethod directly and therefore skips the validation that buildCodeBlock performs: when the incoming snippet is a DynamicIR EndpointExample, buildCodeBlock throws if no example on the endpoint has a matching id. AbstractDynamicSnippetsGenerator.generateInvocationSync iterates all endpoints matching the method+path and returns the first successful response, so without the throw the wrong endpoint (e.g. a same-path endpoint in a different namespace) can be selected. Consider extracting the example-id validation into a small helper used by both buildCodeBlock and generateInvocationSnippetSync so the invocation-only path performs the same check.
Was this helpful? React with 👍 or 👎 to provide feedback.
741c4f6 to
c85a00c
Compare
What
Ports the invocation-only dynamic-snippet hook to the C# generator, mirroring the finalized TypeScript reference (PR #17393) and the Python (#17402) / Go (#17403) / Java (#17404) ports. Stacked on the TS PR's base branch.
Alongside the existing full snippet (client construction + call), the generator now returns the structured
InvocationSnippetResponse = { snippet, imports, clientName, errors }for callers (e.g. docs code templates) that render the invocation inside code they already own.Contract (mirrors #17393)
snippet— the bare call, honoringoptions.clientVariableName(defaultclient). Novar client = new ...Client(...)construction, noExamplesclass/method scaffold, nousingblock, and no trailing;(stripped).imports— the C#using ...;block the call references, as a rendered string;""when none.clientName— the generated client class name (Types.RootClientForSnippets.name, e.g.AcmeClient).errors— preserved from the existing error reporter.How
usingimports are captured (AST change)The C# AST already tracks references→usings in
Writer(importsToString()/toString(skipImports)). I added a single new helper,AstNode.toStringWithoutImports(...)— the C# analogue of the TS AST'stoStringWithoutImportsand the Java AST'srenderNodeWithoutImports. One write pass populates the writer's references, then it returns{ code, imports }:codeis the node body (writer.toString(true), skipping the using block) andimportsis the using block the body references (writer.importsToString() ?? ""). It honorsskipGlobalQualifier: trueto match the existing user-facing snippet style (noglobal::/Systemprefixes).Invocations that construct imported types inline (e.g. a body value with
System.DateTime/System.Guid) surface the correspondingusing System;rather than falling back to the complete snippet. A plain C# invocation references no usings, so a bare call returns"".clientName
Obtained from the C# codegen client-name accessor:
this.Types.RootClientForSnippets.name.Before / after
The invocation node is shared with the full-snippet builder, so it renders multiline (matching
invokeMethod). Example forGET /http-methods/{id}:var client = new AcmeClient(...); \n await client.Endpoints.HTTPMethods.TestGetAsync("id");(+usings, class scaffold)snippet:await client.Endpoints.HTTPMethods.TestGetAsync(\n "id"\n)withimports: "",clientName: "AcmeClient".Tests
New
src/__test__/InvocationSnippet.test.ts(5 tests, mirrors the other ports): bare call exact string (no scaffold, no trailing;);imports === ""for a bare call;clientName; customclientVariableName; and an import-referencing invocation (body withDateTime/Guid) assertingusing System;is surfaced. All existing snapshots/tests remain green (48 passing in the package).Verify
pnpm turbo run compile --filter @fern-api/csharp-codegen --filter @fern-api/csharp-dynamic-snippets— green.pnpm turbo run test --filter @fern-api/csharp-dynamic-snippets— 48 passing, no snapshot files changed.npx biome check --writeon the 3 changed.ts— clean.Changelog:
generators/csharp/sdk/changes/unreleased/invocation-only-dynamic-snippets.yml(type: feat).Do not merge — stacked on #17393.
Generated with Claude Code