Skip to content
Open
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-no-options-suffix`, `csharp-no-request-suffix`, `csharp-no-response-suffix`, and `csharp-use-standard-acronyms` linter rules for C# SDK model naming.
4 changes: 4 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,9 @@ 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-no-options-suffix": true,
"@azure-tools/typespec-client-generator-core/csharp-no-request-suffix": true,
"@azure-tools/typespec-client-generator-core/csharp-no-response-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,15 @@ 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-no-options-suffix"]);
ok(ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-no-request-suffix"]);
ok(ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-no-response-suffix"]);
ok(
ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-use-standard-acronyms"],
);
});
});
16 changes: 10 additions & 6 deletions packages/typespec-client-generator-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,16 @@ 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/no-unnamed-types`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/no-unnamed-types) | Requires types to be named rather than defined anonymously or inline. |
| [`@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/no-unnamed-types`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/no-unnamed-types) | Requires types to be named rather than defined anonymously or inline. |
| [`@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-no-options-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-options-suffix) | Model names ending with 'Options' should use 'Config' suffix instead for C# SDKs, except client options. |
| [`@azure-tools/typespec-client-generator-core/csharp-no-request-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-request-suffix) | Model names ending with 'Request' should use 'Content' suffix instead for C# SDKs. |
| [`@azure-tools/typespec-client-generator-core/csharp-no-response-suffix`](https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-response-suffix) | Model names ending with 'Response' should use 'Result' suffix instead 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: 16 additions & 1 deletion packages/typespec-client-generator-core/src/linter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { defineLinter } from "@typespec/compiler";
import { csharpNoOptionsSuffixRule } from "./rules/csharp-no-options-suffix.js";
import { csharpNoRequestSuffixRule } from "./rules/csharp-no-request-suffix.js";
import { csharpNoResponseSuffixRule } from "./rules/csharp-no-response-suffix.js";
import { csharpNoUrlSuffixRule } from "./rules/csharp-no-url-suffix.js";
import { csharpUseStandardAcronymsRule } from "./rules/csharp-use-standard-acronyms.js";
import { noUnnamedTypesRule } from "./rules/no-unnamed-types.rule.js";
import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js";
import { requireClientSuffixRule } from "./rules/require-client-suffix.rule.js";
Expand All @@ -9,9 +13,20 @@ const rules = [
propertyNameConflictRule,
noUnnamedTypesRule,
csharpNoUrlSuffixRule,
csharpNoOptionsSuffixRule,
csharpNoRequestSuffixRule,
csharpNoResponseSuffixRule,
csharpUseStandardAcronymsRule,
];

const csharpRules = [propertyNameConflictRule, csharpNoUrlSuffixRule];
const csharpRules = [
propertyNameConflictRule,
csharpNoUrlSuffixRule,
csharpNoOptionsSuffixRule,
csharpNoRequestSuffixRule,
csharpNoResponseSuffixRule,
csharpUseStandardAcronymsRule,
];

export const $linter = defineLinter({
rules,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
CodeFix,
Enum,
InsertTextCodeFixEdit,
Model,
ModelProperty,
Expand All @@ -15,10 +16,12 @@ import {
import type { TypeSpecScriptNode } from "@typespec/compiler/ast";
import { SyntaxKind } from "@typespec/compiler/ast";

type AugmentDecoratorTarget = Enum | Model | ModelProperty;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should that just be Type, augment can target every single decorable type


/**
* Get the namespace name for a target type.
*/
function getTargetNamespace(target: Model | ModelProperty): string {
function getTargetNamespace(target: AugmentDecoratorTarget): string {
if (target.kind === "ModelProperty") {
const model = target.model;
if (model?.namespace) {
Expand All @@ -36,7 +39,7 @@ function getTargetNamespace(target: Model | ModelProperty): string {
* 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 {
function buildShortRef(target: AugmentDecoratorTarget): string {
if (target.kind === "ModelProperty") {
const model = target.model;
return model ? `${model.name}.${target.name}` : target.name;
Expand All @@ -48,7 +51,7 @@ function buildShortRef(target: Model | ModelProperty): string {
* 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 {
function buildFqn(target: AugmentDecoratorTarget): string {
if (target.kind === "ModelProperty") {
const model = target.model;
if (model && model.namespace) {
Expand All @@ -59,7 +62,7 @@ function buildFqn(target: Model | ModelProperty): string {
}
return target.name;
}
// Model
// Model or enum
if (target.namespace) {
const nsName = getNamespaceFullName(target.namespace);
return nsName ? `${nsName}.${target.name}` : target.name;
Expand Down Expand Up @@ -119,7 +122,7 @@ function findUsingInsertPos(
* @param args The decorator arguments as literal strings.
*/
export function createAugmentDecoratorCodeFix(
target: Model | ModelProperty,
target: AugmentDecoratorTarget,
decoratorName: string,
args?: string[],
): CodeFix {
Expand Down Expand Up @@ -159,7 +162,7 @@ export function createAugmentDecoratorCodeFix(
* @param args The decorator arguments as literal strings.
*/
export function createClientTspAugmentDecoratorCodeFix(
target: Model | ModelProperty,
target: AugmentDecoratorTarget,
decoratorName: string,
program: Program,
args?: string[],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Model, createRule, getNamespaceFullName, paramMessage } from "@typespec/compiler";
import { createTCGCContext } from "../context.js";
import { getLibraryName } from "../public-utils.js";
import { createClientTspAugmentDecoratorCodeFix } from "./codefix-helpers.js";

export interface CSharpModelSuffixRuleOptions {
name: string;
badSuffix: string;
replacementSuffix: string;
description: string;
url: string;
shouldSkip?: (model: Model, csharpName: string) => boolean;
}

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

export function createCSharpModelSuffixRule(options: CSharpModelSuffixRuleOptions) {
return createRule({
name: options.name,
description: options.description,
severity: "warning",
url: options.url,
messages: {
default: paramMessage`Model '${"modelName"}' ends with '${"badSuffix"}'. Use '${"replacementSuffix"}' 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");
if (!csharpName.endsWith(options.badSuffix)) return;
if (options.shouldSkip?.(model, csharpName)) return;

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

export 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"
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

would it not make more sense to have a single one for all the suffix, this would be more inline with other linting rules we have

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createCSharpModelSuffixRule } from "./csharp-model-suffix-utils.js";

export const csharpNoOptionsSuffixRule = createCSharpModelSuffixRule({
name: "csharp-no-options-suffix",
badSuffix: "Options",
replacementSuffix: "Config",
description:
"Model names ending with 'Options' should use 'Config' suffix instead for C# SDKs, except client options.",
url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-options-suffix",
shouldSkip: (_model, csharpName) => csharpName.endsWith("ClientOptions"),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createCSharpModelSuffixRule } from "./csharp-model-suffix-utils.js";

export const csharpNoRequestSuffixRule = createCSharpModelSuffixRule({
name: "csharp-no-request-suffix",
badSuffix: "Request",
replacementSuffix: "Content",
description: "Model names ending with 'Request' should use 'Content' suffix instead for C# SDKs.",
url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-request-suffix",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {
createCSharpModelSuffixRule,
isStandardAzureCoreErrorResponse,
} from "./csharp-model-suffix-utils.js";

export const csharpNoResponseSuffixRule = createCSharpModelSuffixRule({
name: "csharp-no-response-suffix",
badSuffix: "Response",
replacementSuffix: "Result",
description: "Model names ending with 'Response' should use 'Result' suffix instead for C# SDKs.",
url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-response-suffix",
shouldSkip: isStandardAzureCoreErrorResponse,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
Enum,
Model,
ModelProperty,
Program,
createRule,
paramMessage,
type LinterRuleContext,
} from "@typespec/compiler";
import { createTCGCContext } from "../context.js";
import { getLibraryName } from "../public-utils.js";
import { createClientTspAugmentDecoratorCodeFix } from "./codefix-helpers.js";

const acronymMap = new Map([
["db", "DB"],
["ip", "IP"],
["os", "OS"],
]);

// Match a known acronym (IP/DB/OS) only when it is a *complete word* within the
// identifier, never a substring that merely starts a longer word. This is what
// keeps "Oslo", "Ipsum" and "Osmosis" from being mangled into "OSlo" etc.
//
// /(?:^ip|^db|^os|Ip|Db|Os)(?![a-z])/g
// ^ip | ^db | ^os → lowercase acronym at the start of the name (e.g. "ipAddress", "osProfile")
// Ip | Db | Os → capitalized acronym at a camelCase boundary (e.g. "PublicIpAddress", "CosmosDb")
// (?![a-z]) → must NOT be followed by a lowercase letter, i.e. the acronym ends a
// word (next char is uppercase or end-of-string): keeps "IpAddress"/"MacOs",
// but rejects "Oslo" (Os+"lo") and "Ipsum" (Ip+"sum")
// /g → fix every acronym occurrence in the identifier
const acronymRegex = /(?:^ip|^db|^os|Ip|Db|Os)(?![a-z])/g;

function getCorrectedName(name: string): string | undefined {
const corrected = name.replace(
acronymRegex,
(match) => acronymMap.get(match.toLowerCase()) ?? match,
);
return corrected === name ? undefined : corrected;
}

function reportIfNeeded(
context: LinterRuleContext<any>,
program: Program,
target: Enum | Model | ModelProperty,
csharpName: string,
) {
const suggestion = getCorrectedName(csharpName);
if (suggestion === undefined) return;

context.reportDiagnostic({
format: { name: csharpName, suggestion },
target,
codefixes: [
createClientTspAugmentDecoratorCodeFix(target, "clientName", program, [
`"${suggestion}"`,
`"csharp"`,
]),
],
});
}

export const csharpUseStandardAcronymsRule = createRule({
name: "csharp-use-standard-acronyms",
description: "C# SDK names should use standard acronym casing.",
severity: "warning",
url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-use-standard-acronyms",
messages: {
default: paramMessage`Name '${"name"}' should use standard C# acronym casing (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 {
enum: (enumType: Enum) => {
if (enumType.node === undefined) return;
reportIfNeeded(
context,
context.program,
enumType,
getLibraryName(tcgcContext, enumType, "csharp"),
);
},
model: (model: Model) => {
if (model.node === undefined) return;
reportIfNeeded(
context,
context.program,
model,
getLibraryName(tcgcContext, model, "csharp"),
);
},
modelProperty: (property: ModelProperty) => {
if (property.node === undefined) return;
reportIfNeeded(
context,
context.program,
property,
getLibraryName(tcgcContext, property, "csharp"),
);
},
};
},
});
Loading
Loading