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
1 change: 1 addition & 0 deletions packages/http-client-csharp/emitter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ export { InputClient, InputModelType } from "./type/input-type.js";

/** @internal */
export { $decorators } from "./tsp-index.js";
export { $linter } from "./linter.js";

export type { DynamicModelDecorator } from "../../generated-defs/TypeSpec.HttpClient.CSharp.js";
16 changes: 16 additions & 0 deletions packages/http-client-csharp/emitter/src/linter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

import { defineLinter } from "@typespec/compiler";
import { singleWordModelNameRule } from "./rules/index.js";

export const $linter = defineLinter({
rules: [singleWordModelNameRule],
ruleSets: {
recommended: {
enable: {
[`@typespec/http-client-csharp/${singleWordModelNameRule.name}`]: true,
},
},
},
});
4 changes: 4 additions & 0 deletions packages/http-client-csharp/emitter/src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

export { singleWordModelNameRule } from "./single-word-model-name.rule.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

import { createRule, getSourceLocation, type Model, paramMessage } from "@typespec/compiler";

export const singleWordModelNameRule = createRule({
name: "single-word-model-name",
severity: "warning",
description:
"Model names should be multi-word to avoid naming collisions with BCL or third-party types.",
messages: {
default: paramMessage`Model name '${"modelName"}' is a single word. Consider using a more descriptive multi-word name to avoid naming collisions.`,
clientNameSingleWord: paramMessage`Client name override '${"newName"}' for model '${"modelName"}' is still a single word. Use a multi-word name to avoid naming collisions.`,
},
create(context) {
return {
model: (model: Model) => {
if (!isModelStatement(model)) {
return;
}

// Check if model has a @clientName / @@clientName decorator
const decoratorOverride = getClientNameFromDecorators(model);
if (decoratorOverride !== undefined) {
if (isSingleWord(decoratorOverride.name)) {
// Flag at the decorator node so Ctrl+. targets the override
const target = decoratorOverride.decoratorNode ?? model;
context.reportDiagnostic({
messageId: "clientNameSingleWord",
format: { newName: decoratorOverride.name, modelName: model.name },
target,
});
}
// Has override (multi-word) — model is fine
return;
}

// No override — flag if single-word
if (isSingleWord(model.name)) {
context.reportDiagnostic({
messageId: "default",
format: { modelName: model.name },
target: model,
});
}
},
};
},
});

interface ClientNameInfo {
name: string;
decoratorNode?: any;
}

function getClientNameFromDecorators(model: Model): ClientNameInfo | undefined {
for (const dec of model.decorators) {
const defName = dec.definition?.name;
if (defName?.endsWith("clientName")) {
for (const arg of dec.args ?? []) {
if (typeof arg.jsValue === "string") {
return {
name: arg.jsValue,
decoratorNode: dec.node,
};
}
}
}
}
return undefined;
}

/**
* Checks if a model is an explicit model statement (not expression, intersection, etc.)
* and is in user source (not node_modules or built-in).
*/
function isModelStatement(model: Model): boolean {
const name = model.name;
if (!name || name === "" || name.startsWith("_")) return false;
if (!model.node) return false;
if (model.templateMapper !== undefined) return false;
if (!("id" in model.node) || !(model.node as any).id) return false;

const location = getSourceLocation(model.node);
if (location && location.file?.path?.includes("node_modules")) return false;

return true;
}

/**
* Checks if a name is a single PascalCase word.
* Single-word: "Document", "Format", "Client"
* Multi-word: "TableDocument", "BlobFormat", "HttpClient"
*/
function isSingleWord(name: string): boolean {
if (name.length <= 1) return false;

// Must start with uppercase
if (!/^[A-Z]/.test(name)) return false;

// Split on uppercase boundaries: "FooBar" → ["Foo", "Bar"]
const segments = name.split(/(?=[A-Z])/);

// If there's only one segment, it's a single word
// But allow known acronym patterns (e.g., "HTTP" is one segment but OK)
if (segments.length <= 1) return true;

// Filter out single-char segments from acronym runs
// "HTTPClient" → segments ["H", "T", "T", "P", "Client"] → meaningful: ["Client"] + acronym prefix
// We need a smarter split: find runs of uppercase + a lowercase-started word
const words = splitPascalCase(name);
return words.length <= 1;
}

/**
* Splits a PascalCase name into logical words, handling acronyms.
* "TableDocument" → ["Table", "Document"]
* "HTTPClient" → ["HTTP", "Client"]
* "Document" → ["Document"]
* "ID" → ["ID"]
*/
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) {
// Start of a new word if:
// 1. Previous was lowercase (e.g., "Table|D")
// 2. Current is uppercase and next is lowercase, and previous was uppercase
// (e.g., "HTT|P|Client" → "HTTP" boundary before "Client")
const prevIsLower =
current[current.length - 1] >= "a" && current[current.length - 1] <= "z";

if (prevIsLower) {
words.push(current);
current = char;
} else if (nextIsLower && current.length > 0) {
// Acronym boundary: "HTTP|Client"
words.push(current);
current = char;
} else {
current += char;
}
} else {
current += char;
}
}

if (current.length > 0) {
words.push(current);
}

return words;
}
1 change: 1 addition & 0 deletions packages/http-client-csharp/emitter/src/tsp-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TypeSpecHttpClientCSharpDecorators } from "../../generated-defs/Ty
import { $dynamicModel } from "./lib/decorators.js";

export { $lib } from "./lib/lib.js";
export { $linter } from "./linter.js";

/** @internal */
export const $decorators = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

import {
createLinterRuleTester,
createTestRunner,
type LinterRuleTester,
} from "@typespec/compiler/testing";
import { beforeEach, describe, it } from "vitest";
import { singleWordModelNameRule } from "../../../src/rules/single-word-model-name.rule.js";

describe("single-word-model-name rule", () => {
let ruleTester: LinterRuleTester;

beforeEach(async () => {
const runner = await createTestRunner();
ruleTester = createLinterRuleTester(
runner,
singleWordModelNameRule,
"@typespec/http-client-csharp",
);
});

describe("should flag single-word model names", () => {
it("flags a simple single-word model name", async () => {
await ruleTester.expect(`model Document {}`).toEmitDiagnostics({
code: "@typespec/http-client-csharp/single-word-model-name",
message:
"Model name 'Document' is a single word. Consider using a more descriptive multi-word name to avoid naming collisions.",
});
});

it("flags another single-word model name", async () => {
await ruleTester.expect(`model Format { name: string; }`).toEmitDiagnostics({
code: "@typespec/http-client-csharp/single-word-model-name",
message:
"Model name 'Format' is a single word. Consider using a more descriptive multi-word name to avoid naming collisions.",
});
});

it("flags single-word model in a namespace", async () => {
await ruleTester
.expect(
`
namespace Azure.Storage.Tables {
model Client {
name: string;
}
}
`,
)
.toEmitDiagnostics({
code: "@typespec/http-client-csharp/single-word-model-name",
message:
"Model name 'Client' is a single word. Consider using a more descriptive multi-word name to avoid naming collisions.",
});
});
});

describe("should not flag multi-word model names", () => {
it("allows two-word PascalCase model name", async () => {
await ruleTester.expect(`model TableDocument {}`).toBeValid();
});

it("allows three-word PascalCase model name", async () => {
await ruleTester.expect(`model AzureTableDocument {}`).toBeValid();
});

it("allows model name with acronym prefix", async () => {
await ruleTester.expect(`model HTTPClient {}`).toBeValid();
});

it("allows model name ending with acronym", async () => {
await ruleTester.expect(`model ConnectionTLS {}`).toBeValid();
});
});
});
2 changes: 2 additions & 0 deletions packages/http-client-csharp/linter-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
6 changes: 6 additions & 0 deletions packages/http-client-csharp/linter-test/client.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import "@azure-tools/typespec-client-generator-core";
import "./main.tsp";

using Azure.ClientGenerator.Core;
using Azure.Storage.Tables;

39 changes: 39 additions & 0 deletions packages/http-client-csharp/linter-test/main.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import "@typespec/http";

using TypeSpec.Http;

namespace Azure.Storage.Tables;

// Single-word model names — should trigger linter warnings

@doc("Represents a document about eating carrots")
model Document {
@doc("The document ID")
id: string;

@doc("The document content")
content: string;

@doc("Last modified timestamp")
lastModified: utcDateTime;
}

// Multi-word model names — should NOT trigger linter warnings

@doc("Represents a table entity")
model TableEntity {
partitionKey: string;
rowKey: string;
}

@doc("Storage configuration options")
model StorageConfiguration {
maxRetries: int32;
timeout: duration;
}

@route("/tables")
interface Tables {
@get list(): TableEntity[];
@get read(@path id: string): Document;
}
9 changes: 9 additions & 0 deletions packages/http-client-csharp/linter-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "linter-test",
"private": true,
"dependencies": {
"@typespec/compiler": "1.8.0",
"@typespec/http": "1.8.0",
"@typespec/http-client-csharp": "file:.."
}
}
7 changes: 7 additions & 0 deletions packages/http-client-csharp/linter-test/tspconfig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
imports:
- ./client.tsp
emit:
- "@typespec/http-client-csharp"
linter:
extends:
- "@typespec/http-client-csharp/recommended"
Loading
Loading