Skip to content

Commit aac448e

Browse files
haiyuazhangHaiyuan ZhangCopilot
authored
Add no-url-suffix linter rule (#4541)
Closes #4561 Add `dotnet-no-url-suffix` linter rule that flags model properties ending with `Url` and suggests using `Uri` suffix instead, to follow .NET SDK naming conventions. --------- Co-authored-by: Haiyuan Zhang <haiyzhan@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0592536 commit aac448e

14 files changed

Lines changed: 592 additions & 12 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@azure-tools/typespec-client-generator-core"
5+
---
6+
7+
Add `csharp-no-url-suffix` linter rule that flags model properties ending with `Url` and suggests using `Uri` suffix instead, following .NET SDK naming conventions. Includes auto-fix to add `@@clientName` decorator in client.tsp.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@azure-tools/typespec-azure-rulesets"
5+
---
6+
7+
Add a new `client-sdk` ruleset and enable the `csharp-no-url-suffix` rule in it. The rule applies only to specs configured to emit a client SDK, i.e. those that extend `@azure-tools/typespec-azure-rulesets/client-sdk` in their `tspconfig.yaml`.

packages/typespec-azure-rulesets/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Available ruleSets:
2424
2525
- `@azure-tools/typespec-azure-rulesets/data-plane`
2626
- `@azure-tools/typespec-azure-rulesets/resource-manager`
27+
- `@azure-tools/typespec-azure-rulesets/client-sdk`
2728

2829
## Rules
2930

packages/typespec-azure-rulesets/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createTypeSpecLibrary, defineLinter } from "@typespec/compiler";
2+
import clientSdkRuleset from "./rulesets/client-sdk.js";
23
import dataPlaneRuleset from "./rulesets/data-plane.js";
34
import resourceManagerRuleset from "./rulesets/resource-manager.js";
45

@@ -12,5 +13,6 @@ export const $linter = defineLinter({
1213
ruleSets: {
1314
"data-plane": dataPlaneRuleset,
1415
"resource-manager": resourceManagerRuleset,
16+
"client-sdk": clientSdkRuleset,
1517
},
1618
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { LinterRuleSet } from "@typespec/compiler";
2+
3+
// Rules that apply to specs configured to emit a client SDK. Enable by extending
4+
// "@azure-tools/typespec-azure-rulesets/client-sdk" in tspconfig.yaml.
5+
export default {
6+
enable: {
7+
"@azure-tools/typespec-client-generator-core/csharp-no-url-suffix": true,
8+
},
9+
} satisfies LinterRuleSet;

packages/typespec-azure-rulesets/test/validate-rules-defined.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,10 @@ describe("expect all rules to be defined", () => {
5050
}
5151
});
5252
});
53+
54+
it("client-sdk enables csharp-no-url-suffix", () => {
55+
const ruleset = $linter.ruleSets?.["client-sdk"];
56+
ok(ruleset);
57+
ok(ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-no-url-suffix"]);
58+
});
5359
});

packages/typespec-client-generator-core/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,12 @@ Available ruleSets:
118118

119119
## Rules
120120

121-
| Name | Description |
122-
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
123-
| [`@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'. |
124-
| [`@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. |
125-
| [`@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. |
121+
| Name | Description |
122+
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
123+
| [`@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'. |
124+
| [`@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. |
125+
| [`@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. |
126+
| [`@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. |
126127

127128
## Decorators
128129

packages/typespec-client-generator-core/src/linter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { defineLinter } from "@typespec/compiler";
2+
import { csharpNoUrlSuffixRule } from "./rules/csharp-no-url-suffix.js";
23
import { noUnnamedTypesRule } from "./rules/no-unnamed-types.rule.js";
34
import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js";
45
import { requireClientSuffixRule } from "./rules/require-client-suffix.rule.js";
56

6-
const rules = [requireClientSuffixRule, propertyNameConflictRule, noUnnamedTypesRule];
7+
const rules = [
8+
requireClientSuffixRule,
9+
propertyNameConflictRule,
10+
noUnnamedTypesRule,
11+
csharpNoUrlSuffixRule,
12+
];
713

8-
const csharpRules = [propertyNameConflictRule];
14+
const csharpRules = [propertyNameConflictRule, csharpNoUrlSuffixRule];
915

1016
export const $linter = defineLinter({
1117
rules,
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import type {
2+
CodeFix,
3+
InsertTextCodeFixEdit,
4+
Model,
5+
ModelProperty,
6+
Program,
7+
} from "@typespec/compiler";
8+
import {
9+
createSourceFile,
10+
defineCodeFix,
11+
getNamespaceFullName,
12+
getSourceLocation,
13+
resolvePath,
14+
} from "@typespec/compiler";
15+
import type { TypeSpecScriptNode } from "@typespec/compiler/ast";
16+
import { SyntaxKind } from "@typespec/compiler/ast";
17+
18+
/**
19+
* Get the namespace name for a target type.
20+
*/
21+
function getTargetNamespace(target: Model | ModelProperty): string {
22+
if (target.kind === "ModelProperty") {
23+
const model = target.model;
24+
if (model?.namespace) {
25+
return getNamespaceFullName(model.namespace);
26+
}
27+
return "";
28+
}
29+
if (target.namespace) {
30+
return getNamespaceFullName(target.namespace);
31+
}
32+
return "";
33+
}
34+
35+
/**
36+
* Build a short reference for a type target (e.g., "Model.property").
37+
* Used for same-file augment decorators where the namespace is already in scope.
38+
*/
39+
function buildShortRef(target: Model | ModelProperty): string {
40+
if (target.kind === "ModelProperty") {
41+
const model = target.model;
42+
return model ? `${model.name}.${target.name}` : target.name;
43+
}
44+
return target.name;
45+
}
46+
47+
/**
48+
* Build the fully qualified name for a type target (e.g., "Azure.Service.Model.property").
49+
* Used for cross-file augment decorators where the namespace may not be in scope.
50+
*/
51+
function buildFqn(target: Model | ModelProperty): string {
52+
if (target.kind === "ModelProperty") {
53+
const model = target.model;
54+
if (model && model.namespace) {
55+
const nsName = getNamespaceFullName(model.namespace);
56+
return nsName ? `${nsName}.${model.name}.${target.name}` : `${model.name}.${target.name}`;
57+
} else if (model) {
58+
return `${model.name}.${target.name}`;
59+
}
60+
return target.name;
61+
}
62+
// Model
63+
if (target.namespace) {
64+
const nsName = getNamespaceFullName(target.namespace);
65+
return nsName ? `${nsName}.${target.name}` : target.name;
66+
}
67+
return target.name;
68+
}
69+
70+
function getLineEnd(text: string, start: number): number {
71+
const newline = text.indexOf("\n", start);
72+
return newline === -1 ? text.length : newline + 1;
73+
}
74+
75+
function skipBlankLines(text: string, start: number): number {
76+
let pos = start;
77+
while (pos < text.length) {
78+
const lineEnd = getLineEnd(text, pos);
79+
if (text.slice(pos, lineEnd).trim() !== "") break;
80+
pos = lineEnd;
81+
}
82+
return pos;
83+
}
84+
85+
function findImportInsertPos(script: TypeSpecScriptNode | undefined, text: string): number {
86+
const statements = script?.statements ?? [];
87+
const lastImport = statements.filter((x) => x.kind === SyntaxKind.ImportStatement).at(-1);
88+
if (lastImport) {
89+
return getLineEnd(text, lastImport.end);
90+
}
91+
92+
const firstUsing = statements.find((x) => x.kind === SyntaxKind.UsingStatement);
93+
return firstUsing?.pos ?? 0;
94+
}
95+
96+
function findUsingInsertPos(
97+
script: TypeSpecScriptNode | undefined,
98+
text: string,
99+
importInsertPos: number,
100+
): number {
101+
const statements = script?.statements ?? [];
102+
const lastUsing = statements.filter((x) => x.kind === SyntaxKind.UsingStatement).at(-1);
103+
if (lastUsing) {
104+
return getLineEnd(text, lastUsing.end);
105+
}
106+
107+
return skipBlankLines(text, importInsertPos);
108+
}
109+
110+
/**
111+
* Create a codefix that adds an augment decorator (@@decorator) at the end of
112+
* the SAME file where the target is defined.
113+
*
114+
* This is the augment (`@@`) counterpart to the compiler's
115+
* `createAddDecoratorCodeFix()` (which adds `@` decorators).
116+
*
117+
* @param target The type to target with the augment decorator.
118+
* @param decoratorName The decorator name (e.g., "clientName").
119+
* @param args The decorator arguments as literal strings.
120+
*/
121+
export function createAugmentDecoratorCodeFix(
122+
target: Model | ModelProperty,
123+
decoratorName: string,
124+
args?: string[],
125+
): CodeFix {
126+
const ref = buildShortRef(target);
127+
const argsStr = args && args.length > 0 ? `, ${args.join(", ")}` : "";
128+
const decoratorText = `@@${decoratorName}(${ref}${argsStr})`;
129+
130+
return defineCodeFix({
131+
id: `add-augment-${decoratorName}`,
132+
label: `Add \`${decoratorText}\``,
133+
fix: (fixContext) => {
134+
if (target.node === undefined) return [];
135+
const location = getSourceLocation(target.node);
136+
return fixContext.appendText(
137+
{ pos: location.file.text.length, file: location.file },
138+
`\n${decoratorText};\n`,
139+
);
140+
},
141+
});
142+
}
143+
144+
/**
145+
* Create a codefix that writes an augment decorator (@@decorator) to client.tsp.
146+
*
147+
* This builds on `createAugmentDecoratorCodeFix` but targets client.tsp instead
148+
* of the same file. It additionally handles:
149+
* - Creating client.tsp if it doesn't exist
150+
* - Adding import/using statements at the top (without duplicates)
151+
* - Adding `using <namespace>` for the target's service namespace
152+
* - Using short references (e.g., `Foo.imageUrl`) instead of FQN when using is in scope
153+
*
154+
* Assumes client.tsp is imported via tspconfig.yaml so it appears in program.sourceFiles.
155+
*
156+
* @param target The type to target with the augment decorator.
157+
* @param decoratorName The decorator name (e.g., "clientName").
158+
* @param program The compiler program.
159+
* @param args The decorator arguments as literal strings.
160+
*/
161+
export function createClientTspAugmentDecoratorCodeFix(
162+
target: Model | ModelProperty,
163+
decoratorName: string,
164+
program: Program,
165+
args?: string[],
166+
): CodeFix {
167+
const shortRef = buildShortRef(target);
168+
const targetNamespace = getTargetNamespace(target);
169+
const argsStr = args && args.length > 0 ? `, ${args.join(", ")}` : "";
170+
// Use short ref in label (cleaner display)
171+
const decoratorText = `@@${decoratorName}(${shortRef}${argsStr})`;
172+
const projectRoot = program.projectRoot;
173+
const clientTspPath = resolvePath(projectRoot, "client.tsp");
174+
const clientScript = program.sourceFiles.get(clientTspPath);
175+
176+
// Read client.tsp content once via direct lookup (O(1)).
177+
// Assumes client.tsp is imported via tspconfig.yaml imports.
178+
const existingText = clientScript?.file.text ?? "";
179+
180+
return defineCodeFix({
181+
id: `add-${decoratorName}-in-client-tsp`,
182+
label: `Add \`${decoratorText}\` in client.tsp`,
183+
fix: (fixContext) => {
184+
if (target.node === undefined) return [];
185+
186+
const clientFile = createSourceFile(existingText, clientTspPath);
187+
const edits: InsertTextCodeFixEdit[] = [];
188+
189+
// Build imports/using without moving existing import statements after usings.
190+
const tcgcImport = `import "@azure-tools/typespec-client-generator-core";`;
191+
const usingTcgc = `using Azure.ClientGenerator.Core;`;
192+
193+
const missingImports: string[] = [];
194+
if (!existingText.includes(tcgcImport)) {
195+
missingImports.push(tcgcImport);
196+
}
197+
198+
const missingUsings: string[] = [];
199+
if (!existingText.includes(usingTcgc)) {
200+
missingUsings.push(usingTcgc);
201+
}
202+
// Add using for the target's service namespace so we can use short references
203+
if (targetNamespace) {
204+
const usingNs = `using ${targetNamespace};`;
205+
if (!existingText.includes(usingNs) && !missingUsings.includes(usingNs)) {
206+
missingUsings.push(usingNs);
207+
}
208+
}
209+
210+
const importInsertPos = findImportInsertPos(clientScript, existingText);
211+
const usingInsertPos = findUsingInsertPos(clientScript, existingText, importInsertPos);
212+
213+
if (
214+
missingImports.length > 0 &&
215+
missingUsings.length > 0 &&
216+
importInsertPos === usingInsertPos
217+
) {
218+
const text = [...missingImports, "", ...missingUsings].join("\n") + "\n\n";
219+
edits.push({
220+
kind: "insert-text",
221+
pos: importInsertPos,
222+
text,
223+
file: clientFile,
224+
});
225+
} else {
226+
if (missingImports.length > 0) {
227+
edits.push({
228+
kind: "insert-text",
229+
pos: importInsertPos,
230+
text: missingImports.join("\n") + "\n",
231+
file: clientFile,
232+
});
233+
}
234+
if (missingUsings.length > 0) {
235+
const needsLeadingBlank = usingInsertPos === importInsertPos && importInsertPos > 0;
236+
const needsTrailingBlank = usingInsertPos === existingText.length;
237+
edits.push({
238+
kind: "insert-text",
239+
pos: usingInsertPos,
240+
text: `${needsLeadingBlank ? "\n" : ""}${missingUsings.join("\n")}\n${needsTrailingBlank ? "\n" : ""}`,
241+
file: clientFile,
242+
});
243+
}
244+
}
245+
246+
// Use short ref if the namespace is in scope (via using), otherwise FQN
247+
const hasNamespaceUsing =
248+
targetNamespace &&
249+
(existingText.includes(`using ${targetNamespace};`) ||
250+
missingUsings.includes(`using ${targetNamespace};`));
251+
const ref = hasNamespaceUsing ? shortRef : buildFqn(target);
252+
253+
// Append augment decorator at the END of the file
254+
edits.push({
255+
kind: "insert-text",
256+
pos: existingText.length,
257+
text: `@@${decoratorName}(${ref}${argsStr});\n`,
258+
file: clientFile,
259+
});
260+
261+
return edits;
262+
},
263+
});
264+
}

0 commit comments

Comments
 (0)