Skip to content
Draft
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-single-word-model-name` linter rule for C# SDK model naming, with optional AI-powered quick fix suggestions in editors.
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ 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-single-word-model-name": true,
},
} satisfies LinterRuleSet;
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ describe("expect all rules to be defined", () => {
});
});

it("client-sdk enables csharp-no-url-suffix", () => {
it("client-sdk enables client SDK 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-single-word-model-name"
],
);
});
});
8 changes: 7 additions & 1 deletion packages/typespec-client-generator-core/src/linter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineLinter } from "@typespec/compiler";
import { csharpNoSingleWordModelNameRule } from "./rules/csharp-no-single-word-model-name.js";
import { csharpNoUrlSuffixRule } from "./rules/csharp-no-url-suffix.js";
import { noUnnamedTypesRule } from "./rules/no-unnamed-types.rule.js";
import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js";
Expand All @@ -9,9 +10,14 @@ const rules = [
propertyNameConflictRule,
noUnnamedTypesRule,
csharpNoUrlSuffixRule,
csharpNoSingleWordModelNameRule,
];

const csharpRules = [propertyNameConflictRule, csharpNoUrlSuffixRule];
const csharpRules = [
propertyNameConflictRule,
csharpNoUrlSuffixRule,
csharpNoSingleWordModelNameRule,
];

export const $linter = defineLinter({
rules,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import {
CodeFix,
Model,
Program,
createRule,
getDoc,
getNamespaceFullName,
getSourceLocation,
paramMessage,
} from "@typespec/compiler";
import { SyntaxKind } from "@typespec/compiler/ast";
import { createTCGCContext } from "../context.js";
import { getLibraryName } from "../public-utils.js";
import { createClientTspAugmentDecoratorCodeFix } from "./codefix-helpers.js";

interface ChatCompletionConnection {
sendRequest(
requestName: "custom/chatCompletion",
params: {
messages: { role: "user"; message: string }[];
modelFamily: string;
id: string;
},
): Promise<string | undefined>;
}

interface DynamicCodeFix extends CodeFix {
resolveCodefixes?: () => Promise<CodeFix[]>;
}

function splitPascalCase(name: string): string[] {
const words: string[] = [];
let current = "";

for (let i = 0; i < name.length; i++) {
const char = name[i];
const isUpper = char >= "A" && char <= "Z";
const nextIsLower = i + 1 < name.length && name[i + 1] >= "a" && name[i + 1] <= "z";

if (isUpper && current.length > 0) {
const prevIsLower = current[current.length - 1] >= "a" && current[current.length - 1] <= "z";
if (prevIsLower || nextIsLower) {
words.push(current);
current = char;
} else {
current += char;
}
} else {
current += char;
}
}
if (current.length > 0) words.push(current);
return words;
}

function isSingleWord(name: string): boolean {
if (name.length <= 1) return false;
if (!/^[A-Z]/.test(name)) return false;
return splitPascalCase(name).length <= 1;
}

function getLspConnection(): ChatCompletionConnection | undefined {
const connection = (globalThis as { lspConnection?: unknown }).lspConnection;
if (typeof connection !== "object" || connection === null) return undefined;
const sendRequest = (connection as Partial<ChatCompletionConnection>).sendRequest;
if (typeof sendRequest !== "function") return undefined;
return connection as ChatCompletionConnection;
}

function extractModelSource(model: Model): string {
if (model.node === undefined || model.node.kind !== SyntaxKind.ModelStatement) return "";
const location = getSourceLocation(model.node);
return location.file.text.slice(model.node.pos, model.node.end).trim();
}

function getModelDocumentation(program: Program, model: Model): string {
return getDoc(program, model)?.trim() ?? "";
}

function parseSuggestions(response: string): string[] {
return [
...new Set(
response
.split("\n")
.map((line) =>
line
.trim()
.replace(/^`+|`+$/g, "")
.replace(/^\d+\.\s*/, "")
.trim(),
)
.filter((name) => name && /^[A-Z][a-zA-Z0-9]*$/.test(name) && !isSingleWord(name)),
),
].slice(0, 5);
}

async function fetchAiNameSuggestions(
program: Program,
model: Model,
csharpName: string,
): Promise<string[]> {
const connection = getLspConnection();
if (connection === undefined) {
return [];
}

const namespaceName = model.namespace ? getNamespaceFullName(model.namespace) : "";
const modelDocumentation = getModelDocumentation(program, model);
const documentationSection =
modelDocumentation === "" ? "" : `\nModel documentation:\n${modelDocumentation}\n`;
const prompt = `You are a .NET naming expert. A TypeSpec model named "${csharpName}" in namespace "${namespaceName}" is a single word that may collide with .NET types.
${documentationSection}
Model definition:
${extractModelSource(model)}

Suggest exactly 5 better multi-word PascalCase names. Reply with only the names, one per line.`;

try {
const result = await connection.sendRequest("custom/chatCompletion", {
messages: [{ role: "user", message: prompt }],
modelFamily: "claude-opus-4.6",
id: `csharp-no-single-word-model-name-${namespaceName}.${csharpName}`,
});
return typeof result === "string" ? parseSuggestions(result) : [];
} catch {
return [];
}
}

function createClientNameCodeFix(
model: Model,
program: Program,
csharpName: string,
name: string,
index: number,
): CodeFix {
const fix = createClientTspAugmentDecoratorCodeFix(model, "clientName", program, [
`"${name}"`,
`"csharp"`,
]);
return {
...fix,
id: `${fix.id}-${index}`,
label: `Rename '${csharpName}' to '${name}' in client.tsp`,
};
}

function createAiClientNameCodeFix(model: Model, program: Program, csharpName: string): CodeFix {
const fix: DynamicCodeFix = {
id: "ai-rename-single-word-model",
label: "Suggest multi-word C# names",
fix: () => [],
resolveCodefixes: async () => {
const suggestions = await fetchAiNameSuggestions(program, model, csharpName);
const names =
suggestions.length > 0
? suggestions
: [`${model.namespace?.name ?? "Service"}${csharpName}`].filter((x) => !isSingleWord(x));
return names.map((name, index) =>
createClientNameCodeFix(model, program, csharpName, name, index),
);
},
};
return fix;
}

export const csharpNoSingleWordModelNameRule = createRule({
name: "csharp-no-single-word-model-name",
description:
"Model names should be multi-word to avoid naming collisions with .NET platform types.",
severity: "warning",
url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-single-word-model-name",
messages: {
default: paramMessage`Model name '${"name"}' is a single word. Use a more descriptive multi-word name.`,
},
create(context) {
const tcgcContext = createTCGCContext(
context.program,
"@azure-tools/typespec-client-generator-core",
{ mutateNamespace: false },
);

return {
model: (model: Model) => {
if (model.node === undefined || model.node.kind !== SyntaxKind.ModelStatement) return;
if (model.templateMapper !== undefined) return;

const csharpName = getLibraryName(tcgcContext, model, "csharp");
if (!isSingleWord(csharpName)) return;

context.reportDiagnostic({
format: { name: csharpName },
target: model,
codefixes: [createAiClientNameCodeFix(model, context.program, csharpName)],
});
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing";
import { beforeEach, it } from "vitest";
import { csharpNoSingleWordModelNameRule } from "../../src/rules/csharp-no-single-word-model-name.js";
import { SimpleTester } from "../tester.js";

let tester: LinterRuleTester;

beforeEach(async () => {
const runner = await SimpleTester.createInstance();
tester = createLinterRuleTester(
runner,
csharpNoSingleWordModelNameRule,
"@azure-tools/typespec-client-generator-core",
);
});

it("emits warning for single-word model name", async () => {
await tester.expect(`model Document { id: string; }`).toEmitDiagnostics({
code: "@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name",
message: "Model name 'Document' is a single word. Use a more descriptive multi-word name.",
});
});

it("is valid for multi-word model name", async () => {
await tester.expect(`model TableDocument { id: string; }`).toBeValid();
});

it("is valid for acronym + word model name", async () => {
await tester.expect(`model HTTPClient { url: string; }`).toBeValid();
});

it("does not flag single-char names", async () => {
await tester.expect(`model T { id: string; }`).toBeValid();
});

it("is valid when @clientName provides multi-word name", async () => {
await tester
.expect(
`@clientName("StorageDocument", "csharp")
model Document { id: string; }`,
)
.toBeValid();
});

it("emits warning when @clientName is still single-word", async () => {
await tester
.expect(
`@clientName("Blob", "csharp")
model Document { id: string; }`,
)
.toEmitDiagnostics({
code: "@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name",
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: "csharp-no-single-word-model-name"
---

```text title="Full name"
@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name
```

Model names should be multi-word to avoid naming collisions with .NET platform or third-party types. The codefix can suggest C# names and write `@@clientName` to `client.tsp`.

#### ❌ Incorrect

```tsp
model Document {
id: string;
}
```

#### ✅ Correct

```tsp
model TableDocument {
id: string;
}
```

```tsp
@clientName("StorageDocument", "csharp")
model Document {
id: string;
}
```