Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-client-generator-core"
- "@azure-tools/typespec-azure-rulesets"
---

Add `csharp-model-suffix` and `csharp-use-standard-acronyms` linter rules for C# SDK model naming.
2 changes: 2 additions & 0 deletions packages/typespec-azure-rulesets/src/rulesets/client-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ import type { LinterRuleSet } from "@typespec/compiler";
export default {
enable: {
"@azure-tools/typespec-client-generator-core/csharp-no-url-suffix": true,
"@azure-tools/typespec-client-generator-core/csharp-model-suffix": true,
"@azure-tools/typespec-client-generator-core/csharp-use-standard-acronyms": true,
},
} satisfies LinterRuleSet;
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ describe("expect all rules to be defined", () => {
});
});

it("client-sdk enables csharp-no-url-suffix", () => {
it("client-sdk enables C# naming rules", () => {
const ruleset = $linter.ruleSets?.["client-sdk"];
ok(ruleset);
ok(ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-no-url-suffix"]);
ok(ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-model-suffix"]);
ok(
ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-use-standard-acronyms"],
);
});
});
12 changes: 7 additions & 5 deletions packages/typespec-client-generator-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ Available ruleSets:

## Rules

| Name | Description |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| [`@azure-tools/typespec-client-generator-core/require-client-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/require-client-suffix) | Client names should end with 'Client'. |
| [`@azure-tools/typespec-client-generator-core/property-name-conflict`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/property-name-conflict) | Avoid naming conflicts between a property and a model of the same name. |
| [`@azure-tools/typespec-client-generator-core/csharp-no-url-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-url-suffix) | Properties ending with 'Url' should use 'Uri' suffix instead to follow .NET naming conventions. |
| Name | Description |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| [`@azure-tools/typespec-client-generator-core/require-client-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/require-client-suffix) | Client names should end with 'Client'. |
| [`@azure-tools/typespec-client-generator-core/property-name-conflict`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/property-name-conflict) | Avoid naming conflicts between a property and a model of the same name. |
| [`@azure-tools/typespec-client-generator-core/csharp-no-url-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-url-suffix) | Properties ending with 'Url' should use 'Uri' suffix instead to follow .NET naming conventions. |
| [`@azure-tools/typespec-client-generator-core/csharp-model-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-model-suffix) | Model names should use recommended suffixes for C# SDKs. |
| [`@azure-tools/typespec-client-generator-core/csharp-use-standard-acronyms`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-use-standard-acronyms) | C# SDK names should use standard acronym casing. |

## Decorators

Expand Down
17 changes: 15 additions & 2 deletions packages/typespec-client-generator-core/src/linter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { defineLinter } from "@typespec/compiler";
import { csharpModelSuffixRule } from "./rules/csharp-model-suffix.js";
import { csharpNoUrlSuffixRule } from "./rules/csharp-no-url-suffix.js";
import { csharpUseStandardAcronymsRule } from "./rules/csharp-use-standard-acronyms.js";
import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js";
import { requireClientSuffixRule } from "./rules/require-client-suffix.rule.js";

const rules = [requireClientSuffixRule, propertyNameConflictRule, csharpNoUrlSuffixRule];
const rules = [
requireClientSuffixRule,
propertyNameConflictRule,
csharpNoUrlSuffixRule,
csharpModelSuffixRule,
csharpUseStandardAcronymsRule,
];

const csharpRules = [propertyNameConflictRule, csharpNoUrlSuffixRule];
const csharpRules = [
propertyNameConflictRule,
csharpNoUrlSuffixRule,
csharpModelSuffixRule,
csharpUseStandardAcronymsRule,
];

export const $linter = defineLinter({
rules,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import type {
CodeFix,
InsertTextCodeFixEdit,
Model,
ModelProperty,
Program,
} from "@typespec/compiler";
import type { CodeFix, InsertTextCodeFixEdit, Program, Type } from "@typespec/compiler";
import {
createSourceFile,
defineCodeFix,
getNamespaceFullName,
getSourceLocation,
getTypeName,
resolvePath,
} from "@typespec/compiler";
import type { TypeSpecScriptNode } from "@typespec/compiler/ast";
Expand All @@ -18,53 +12,32 @@ import { SyntaxKind } from "@typespec/compiler/ast";
/**
* Get the namespace name for a target type.
*/
function getTargetNamespace(target: Model | ModelProperty): string {
if (target.kind === "ModelProperty") {
const model = target.model;
if (model?.namespace) {
return getNamespaceFullName(model.namespace);
}
return "";
}
if (target.namespace) {
return getNamespaceFullName(target.namespace);
}
return "";
function getTargetNamespace(target: Type): string {
const shortRef = buildShortRef(target);
const fqn = buildFqn(target);
return fqn.endsWith(`.${shortRef}`) ? fqn.slice(0, -shortRef.length - 1) : "";
}

/**
* Build a short reference for a type target (e.g., "Model.property").
* Used for same-file augment decorators where the namespace is already in scope.
*/
function buildShortRef(target: Model | ModelProperty): string {
if (target.kind === "ModelProperty") {
const model = target.model;
return model ? `${model.name}.${target.name}` : target.name;
function buildShortRef(target: Type): string {
if (target.kind === "UnionVariant" && typeof target.name === "string") {
return `${getTypeName(target.union, { nameOnly: true, printable: true })}.${target.name}`;
}
return target.name;
return getTypeName(target, { nameOnly: true, printable: true });
}

/**
* Build the fully qualified name for a type target (e.g., "Azure.Service.Model.property").
* Used for cross-file augment decorators where the namespace may not be in scope.
*/
function buildFqn(target: Model | ModelProperty): string {
if (target.kind === "ModelProperty") {
const model = target.model;
if (model && model.namespace) {
const nsName = getNamespaceFullName(model.namespace);
return nsName ? `${nsName}.${model.name}.${target.name}` : `${model.name}.${target.name}`;
} else if (model) {
return `${model.name}.${target.name}`;
}
return target.name;
}
// Model
if (target.namespace) {
const nsName = getNamespaceFullName(target.namespace);
return nsName ? `${nsName}.${target.name}` : target.name;
function buildFqn(target: Type): string {
if (target.kind === "UnionVariant" && typeof target.name === "string") {
return `${getTypeName(target.union, { printable: true })}.${target.name}`;
}
return target.name;
return getTypeName(target, { printable: true });
}

function getLineEnd(text: string, start: number): number {
Expand Down Expand Up @@ -119,7 +92,7 @@ function findUsingInsertPos(
* @param args The decorator arguments as literal strings.
*/
export function createAugmentDecoratorCodeFix(
target: Model | ModelProperty,
target: Type,
decoratorName: string,
args?: string[],
): CodeFix {
Expand Down Expand Up @@ -159,7 +132,7 @@ export function createAugmentDecoratorCodeFix(
* @param args The decorator arguments as literal strings.
*/
export function createClientTspAugmentDecoratorCodeFix(
target: Model | ModelProperty,
target: Type,
decoratorName: string,
program: Program,
args?: string[],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
C# SDK model names should use these recommended suffixes:

- Use `Config` instead of `Options`, except for client options.
- Use `Content` instead of `Request`.
- Use `Result` instead of `Response`.

The rule checks the C#-resolved model name and respects `@clientName` overrides.

#### ❌ Incorrect

```tsp
model SearchOptions {
filter: string;
}

model CreateWidgetRequest {
name: string;
}

model CreateWidgetResponse {
id: string;
}
```

#### ✅ Correct

```tsp
model SearchConfig {
filter: string;
}

model CreateWidgetContent {
name: string;
}

model CreateWidgetResult {
id: string;
}
```

Or using `@@clientName` in `client.tsp` to override just the C# name:

```tsp
// client.tsp
@@clientName(SearchOptions, "SearchConfig", "csharp");
@@clientName(CreateWidgetRequest, "CreateWidgetContent", "csharp");
@@clientName(CreateWidgetResponse, "CreateWidgetResult", "csharp");
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Model, createRule, fileRef, getNamespaceFullName, paramMessage } from "@typespec/compiler";
import { createTCGCContext } from "../context.js";
import { getLibraryName } from "../public-utils.js";
import { createClientTspAugmentDecoratorCodeFix } from "./codefix-helpers.js";

interface SuffixConvention {
messageId: "options" | "request" | "response";
badSuffix: string;
replacementSuffix: string;
shouldSkip?: (model: Model, csharpName: string) => boolean;
}

const suffixConventions: readonly SuffixConvention[] = [
{
messageId: "options",
badSuffix: "Options",
replacementSuffix: "Config",
shouldSkip: (_model, csharpName) => csharpName.endsWith("ClientOptions"),
},
{
messageId: "request",
badSuffix: "Request",
replacementSuffix: "Content",
},
{
messageId: "response",
badSuffix: "Response",
replacementSuffix: "Result",
shouldSkip: isStandardAzureCoreErrorResponse,
},
];

function getSuggestedName(name: string, convention: SuffixConvention) {
return name.slice(0, -convention.badSuffix.length) + convention.replacementSuffix;
}

export const csharpModelSuffixRule = createRule({
name: "csharp-model-suffix",
docs: fileRef.fromPackageRoot("src/rules/csharp-model-suffix.md"),
description: "Model names should use recommended suffixes for C# SDKs.",
severity: "warning",
url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-model-suffix",
messages: {
options: paramMessage`Model '${"modelName"}' ends with 'Options'. Use 'Config' suffix instead (e.g. '${"suggestion"}'). Use @clientName("${"suggestion"}", "csharp") to rename it for C#.`,
request: paramMessage`Model '${"modelName"}' ends with 'Request'. Use 'Content' suffix instead (e.g. '${"suggestion"}'). Use @clientName("${"suggestion"}", "csharp") to rename it for C#.`,
response: paramMessage`Model '${"modelName"}' ends with 'Response'. Use 'Result' suffix instead (e.g. '${"suggestion"}'). Use @clientName("${"suggestion"}", "csharp") to rename it for C#.`,
},
create(context) {
const tcgcContext = createTCGCContext(
context.program,
"@azure-tools/typespec-client-generator-core",
{ mutateNamespace: false },
);

return {
model: (model: Model) => {
if (model.node === undefined) return;

const csharpName = getLibraryName(tcgcContext, model, "csharp");
const convention = suffixConventions.find(
(x) => csharpName.endsWith(x.badSuffix) && !x.shouldSkip?.(model, csharpName),
);
if (convention === undefined) return;

const suggestion = getSuggestedName(csharpName, convention);
context.reportDiagnostic({
messageId: convention.messageId,
format: { modelName: csharpName, suggestion },
target: model,
codefixes: [
createClientTspAugmentDecoratorCodeFix(model, "clientName", context.program, [
`"${suggestion}"`,
`"csharp"`,
]),
],
});
},
};
},
});

function isStandardAzureCoreErrorResponse(model: Model, csharpName: string) {
if (csharpName !== "ErrorResponse") return false;
const namespace = model.namespace ? getNamespaceFullName(model.namespace) : "";
return (
namespace === "Azure.Core.Foundations" || namespace === "Azure.ResourceManager.CommonTypes"
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
C# SDK names should use standard acronym casing. This initial rule covers `IP`, `DB`, and `OS`.

The rule checks the C#-resolved name and respects `@clientName` overrides.

#### ❌ Incorrect

```tsp
model IpAddress {
value: string;
}

model CosmosDb {
id: string;
}
```

#### ✅ Correct

```tsp
model IPAddress {
value: string;
}

model CosmosDB {
id: string;
}
```

Or using `@@clientName` in `client.tsp` to override just the C# name:

```tsp
// client.tsp
@@clientName(IpAddress, "IPAddress", "csharp");
```
Loading
Loading