Skip to content

feat(csharp): generate invocation-only dynamic snippets - #17405

Open
cadesark wants to merge 4 commits into
devin/1786644600-invocation-only-dynamic-snippetsfrom
cade/csharp-invocation-snippets
Open

feat(csharp): generate invocation-only dynamic snippets#17405
cadesark wants to merge 4 commits into
devin/1786644600-invocation-only-dynamic-snippetsfrom
cade/csharp-invocation-snippets

Conversation

@cadesark

@cadesark cadesark commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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, honoring options.clientVariableName (default client). No var client = new ...Client(...) construction, no Examples class/method scaffold, no using block, 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 using imports 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's toStringWithoutImports and the Java AST's renderNodeWithoutImports. One write pass populates the writer's references, then it returns { code, imports }: code is the node body (writer.toString(true), skipping the using block) and imports is the using block the body references (writer.importsToString() ?? ""). It honors skipGlobalQualifier: true to match the existing user-facing snippet style (no global::/System prefixes).

Invocations that construct imported types inline (e.g. a body value with System.DateTime / System.Guid) surface the corresponding using 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 for GET /http-methods/{id}:

  • Full snippet (unchanged): var client = new AcmeClient(...); \n await client.Endpoints.HTTPMethods.TestGetAsync("id"); (+ usings, class scaffold)
  • Invocation-only snippet: await client.Endpoints.HTTPMethods.TestGetAsync(\n "id"\n) with imports: "", 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; custom clientVariableName; and an import-referencing invocation (body with DateTime/Guid) asserting using 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 --write on 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


Open in Devin Review

cadesark and others added 4 commits August 13, 2026 18:10
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>
@cadesark cadesark self-assigned this Aug 13, 2026

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +115 to +118
namespace: "Examples",
generation: this.generation,
allNamespaceSegments: new Set(),
allTypeClassReferences: new Map(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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(...)).

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +110 to +114
// 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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant