diff --git a/packages/http-client-csharp/emitter/src/index.ts b/packages/http-client-csharp/emitter/src/index.ts index 5a0b24094c7..2fbde08b848 100644 --- a/packages/http-client-csharp/emitter/src/index.ts +++ b/packages/http-client-csharp/emitter/src/index.ts @@ -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"; diff --git a/packages/http-client-csharp/emitter/src/linter.ts b/packages/http-client-csharp/emitter/src/linter.ts new file mode 100644 index 00000000000..389e934f824 --- /dev/null +++ b/packages/http-client-csharp/emitter/src/linter.ts @@ -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, + }, + }, + }, +}); diff --git a/packages/http-client-csharp/emitter/src/rules/index.ts b/packages/http-client-csharp/emitter/src/rules/index.ts new file mode 100644 index 00000000000..072aea1478d --- /dev/null +++ b/packages/http-client-csharp/emitter/src/rules/index.ts @@ -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"; diff --git a/packages/http-client-csharp/emitter/src/rules/single-word-model-name.rule.ts b/packages/http-client-csharp/emitter/src/rules/single-word-model-name.rule.ts new file mode 100644 index 00000000000..701bd8e87f4 --- /dev/null +++ b/packages/http-client-csharp/emitter/src/rules/single-word-model-name.rule.ts @@ -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; +} diff --git a/packages/http-client-csharp/emitter/src/tsp-index.ts b/packages/http-client-csharp/emitter/src/tsp-index.ts index 039061ac673..4e5bdceae0e 100644 --- a/packages/http-client-csharp/emitter/src/tsp-index.ts +++ b/packages/http-client-csharp/emitter/src/tsp-index.ts @@ -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 = { diff --git a/packages/http-client-csharp/emitter/test/Unit/rules/single-word-model-name.rule.test.ts b/packages/http-client-csharp/emitter/test/Unit/rules/single-word-model-name.rule.test.ts new file mode 100644 index 00000000000..07bff807d62 --- /dev/null +++ b/packages/http-client-csharp/emitter/test/Unit/rules/single-word-model-name.rule.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/http-client-csharp/linter-test/.gitignore b/packages/http-client-csharp/linter-test/.gitignore new file mode 100644 index 00000000000..504afef81fb --- /dev/null +++ b/packages/http-client-csharp/linter-test/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/packages/http-client-csharp/linter-test/client.tsp b/packages/http-client-csharp/linter-test/client.tsp new file mode 100644 index 00000000000..69ac125b14a --- /dev/null +++ b/packages/http-client-csharp/linter-test/client.tsp @@ -0,0 +1,6 @@ +import "@azure-tools/typespec-client-generator-core"; +import "./main.tsp"; + +using Azure.ClientGenerator.Core; +using Azure.Storage.Tables; + diff --git a/packages/http-client-csharp/linter-test/main.tsp b/packages/http-client-csharp/linter-test/main.tsp new file mode 100644 index 00000000000..af192e7c24f --- /dev/null +++ b/packages/http-client-csharp/linter-test/main.tsp @@ -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; +} diff --git a/packages/http-client-csharp/linter-test/package.json b/packages/http-client-csharp/linter-test/package.json new file mode 100644 index 00000000000..8238e4fedb3 --- /dev/null +++ b/packages/http-client-csharp/linter-test/package.json @@ -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:.." + } +} diff --git a/packages/http-client-csharp/linter-test/tspconfig.yaml b/packages/http-client-csharp/linter-test/tspconfig.yaml new file mode 100644 index 00000000000..5354a1cb174 --- /dev/null +++ b/packages/http-client-csharp/linter-test/tspconfig.yaml @@ -0,0 +1,7 @@ +imports: + - ./client.tsp +emit: + - "@typespec/http-client-csharp" +linter: + extends: + - "@typespec/http-client-csharp/recommended" diff --git a/packages/typespec-vscode/src/code-action-provider.ts b/packages/typespec-vscode/src/code-action-provider.ts index b723ac1571f..bccbafaf742 100644 --- a/packages/typespec-vscode/src/code-action-provider.ts +++ b/packages/typespec-vscode/src/code-action-provider.ts @@ -4,6 +4,7 @@ import logger from "./log/logger.js"; import { getDirectoryPath, isPathAbsolute } from "./path-utils.js"; import { CodeActionCommand } from "./types.js"; import { searchAndLoadPackageJson } from "./utils.js"; +import { isSingleWordModelNameDiagnostic } from "./vscode-cmd/suggest-model-name/suggest-model-name.js"; export function createCodeActionProvider() { return vscode.languages.registerCodeActionsProvider( @@ -89,6 +90,54 @@ export class TypeSpecCodeActionProvider implements vscode.CodeActionProvider { ), ); } + // AI-powered rename for single-word model names (C# emitter linter rule) + if (isSingleWordModelNameDiagnostic(diagnostic)) { + const isClientNameOverride = diagnostic.message.includes("Client name override"); + const modelName = _document.getText(diagnostic.range); + + if (isClientNameOverride) { + // Diagnostic is on a @@clientName line — only offer to update the name + const updateAction = new vscode.CodeAction( + `Update name with AI suggestions...`, + vscode.CodeActionKind.QuickFix, + ); + updateAction.command = { + command: CodeActionCommand.SuggestModelName, + title: `Update client name override`, + arguments: [_document, diagnostic, "updateClientName"], + }; + updateAction.diagnostics = [diagnostic]; + updateAction.isPreferred = true; + actions.unshift(updateAction); + } else { + // Diagnostic is on the model declaration — offer both approaches + const directAction = new vscode.CodeAction( + `Rename '${modelName}' directly (AI suggestions)...`, + vscode.CodeActionKind.QuickFix, + ); + directAction.command = { + command: CodeActionCommand.SuggestModelName, + title: `Rename '${modelName}' directly`, + arguments: [_document, diagnostic, "direct"], + }; + directAction.diagnostics = [diagnostic]; + directAction.isPreferred = true; + actions.unshift(directAction); + + const clientNameAction = new vscode.CodeAction( + `Override '${modelName}' via @@clientName (AI suggestions)...`, + vscode.CodeActionKind.QuickFix, + ); + clientNameAction.command = { + command: CodeActionCommand.SuggestModelName, + title: `Override '${modelName}' via @@clientName`, + arguments: [_document, diagnostic, "clientName"], + }; + clientNameAction.diagnostics = [diagnostic]; + clientNameAction.isPreferred = true; + actions.splice(1, 0, clientNameAction); + } + } } return actions; diff --git a/packages/typespec-vscode/src/extension.ts b/packages/typespec-vscode/src/extension.ts index 39d203e46b7..6e6b11d80fd 100644 --- a/packages/typespec-vscode/src/extension.ts +++ b/packages/typespec-vscode/src/extension.ts @@ -111,6 +111,70 @@ export async function activate(context: ExtensionContext) { }), ); + context.subscriptions.push( + commands.registerCommand( + CodeActionCommand.SuggestModelName, + async ( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + approach: string, + ) => { + const { suggestModelName } = await import( + "./vscode-cmd/suggest-model-name/suggest-model-name.js" + ); + await suggestModelName(document, diagnostic, approach as "direct" | "clientName"); + }, + ), + ); + + // When client.tsp is deleted, remove its import from tspconfig.yaml + // and nudge open .tsp files to trigger recompilation + const clientTspWatcher = vscode.workspace.createFileSystemWatcher("**/client.tsp"); + clientTspWatcher.onDidDelete(async (uri) => { + const dir = vscode.Uri.joinPath(uri, ".."); + const tspConfigUri = vscode.Uri.joinPath(dir, "tspconfig.yaml"); + try { + const raw = await vscode.workspace.fs.readFile(tspConfigUri); + let content = new TextDecoder().decode(raw); + const updated = content.replace(/\n\s*-\s*\.\/client\.tsp\s*/g, "\n"); + const cleaned = updated.replace(/^imports:\s*\n(?=\S|\s*$)/m, ""); + if (cleaned !== content) { + await vscode.workspace.fs.writeFile(tspConfigUri, new TextEncoder().encode(cleaned)); + } + } catch { + // tspconfig.yaml doesn't exist or can't be read + } + + // Nudge open .tsp editors to trigger recompilation after a short delay + setTimeout(async () => { + for (const editor of vscode.window.visibleTextEditors) { + if ( + editor.document.languageId === "typespec" && + !editor.document.uri.path.endsWith("client.tsp") + ) { + // Append and immediately remove a comment to trigger a content change + const lastLine = editor.document.lineCount - 1; + const lastChar = editor.document.lineAt(lastLine).text.length; + const endPos = new vscode.Position(lastLine, lastChar); + const applied = await editor.edit( + (eb) => eb.insert(endPos, " "), + { undoStopBefore: false, undoStopAfter: false }, + ); + if (applied) { + await editor.edit( + (eb) => { + const newLastChar = editor.document.lineAt(lastLine).text.length; + eb.delete(new vscode.Range(lastLine, newLastChar - 1, lastLine, newLastChar)); + }, + { undoStopBefore: false, undoStopAfter: false }, + ); + } + } + } + }, 500); + }); + context.subscriptions.push(clientTspWatcher); + /* emit command. */ context.subscriptions.push( commands.registerCommand(CommandName.EmitCode, async (uri: vscode.Uri) => { diff --git a/packages/typespec-vscode/src/types.ts b/packages/typespec-vscode/src/types.ts index fcfc8808721..3ac481e40be 100644 --- a/packages/typespec-vscode/src/types.ts +++ b/packages/typespec-vscode/src/types.ts @@ -21,6 +21,7 @@ export const enum CommandName { export const enum CodeActionCommand { OpenUrl = "typespec.openUrl", NpmInstallPackage = "typespec.npmInstallPackage", + SuggestModelName = "typespec.suggestModelName", } export type RestartServerCommandResult = Result; diff --git a/packages/typespec-vscode/src/vscode-cmd/suggest-model-name/suggest-model-name.ts b/packages/typespec-vscode/src/vscode-cmd/suggest-model-name/suggest-model-name.ts new file mode 100644 index 00000000000..09ab75f493f --- /dev/null +++ b/packages/typespec-vscode/src/vscode-cmd/suggest-model-name/suggest-model-name.ts @@ -0,0 +1,583 @@ +import vscode from "vscode"; +import { sendLmChatRequest } from "../../lm/language-model.js"; +import logger from "../../log/logger.js"; + +const SINGLE_WORD_DIAGNOSTIC_CODE = "@typespec/http-client-csharp/single-word-model-name"; +const MODEL_FAMILY = "copilot-gpt-4.1"; + +type RenameApproach = "direct" | "clientName"; + +interface ModelNameContext { + modelName: string; + modelSource: string; + namespaceName: string; + documentUri: vscode.Uri; + diagnosticRange: vscode.Range; +} + +/** + * Command that fetches AI name suggestions and applies the chosen rename. + * The approach (direct vs clientName) is passed in — the user already picked + * it from the Ctrl+. code action menu. + */ +export async function suggestModelName( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + approach: RenameApproach, +): Promise { + const ctx = extractModelContext(document, diagnostic); + if (!ctx) { + vscode.window.showErrorMessage("Could not extract model information from the diagnostic."); + return; + } + + // Fetch AI suggestions with a progress notification + const suggestions = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Generating AI name suggestions for '${ctx.modelName}'...`, + cancellable: true, + }, + async (_progress, token) => { + return await fetchAiSuggestions(ctx, token); + }, + ); + + if (!suggestions || suggestions.length === 0) { + vscode.window.showWarningMessage( + "Could not generate AI suggestions. Check that GitHub Copilot is active.", + ); + return; + } + + // Show QuickPick with AI suggestions + const nameItems = suggestions.map((name, i) => ({ + label: name, + description: i === 0 ? "(recommended)" : undefined, + })); + + const titleMap: Record = { + direct: `Rename '${ctx.modelName}' to:`, + clientName: `Add @@clientName override for '${ctx.modelName}':`, + updateClientName: `Update client name for '${ctx.modelName}' to:`, + }; + + const selectedName = await vscode.window.showQuickPick(nameItems, { + title: titleMap[approach] ?? `Choose new name for '${ctx.modelName}':`, + placeHolder: "Select a suggested name", + }); + + if (!selectedName) return; + + if (approach === "direct") { + await applyDirectRename(ctx, selectedName.label); + } else if (approach === "updateClientName") { + await applyUpdateClientName(document, diagnostic, selectedName.label); + } else { + await applyClientNameOverride(ctx, selectedName.label); + } +} + +function extractModelContext( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, +): ModelNameContext | undefined { + const range = diagnostic.range; + const lineText = document.lineAt(range.start.line).text; + + // Check if this diagnostic is on a @@clientName line vs a model declaration + const isOnClientNameLine = lineText.includes("@@clientName"); + + let modelName: string; + let modelSource: string; + let namespaceName: string; + + if (isOnClientNameLine) { + // Extract model name from @@clientName(ModelName, "...") + const clientNameMatch = lineText.match(/@@clientName\(\s*(\w+)/); + modelName = clientNameMatch ? clientNameMatch[1] : document.getText(range); + + // Find the actual model source in other open documents or imported files + const modelInfo = findModelSourceAcrossFiles(modelName, document); + modelSource = modelInfo.source; + namespaceName = modelInfo.namespace; + } else { + modelName = document.getText(range); + modelSource = extractModelSource(document, range); + namespaceName = extractNamespace(document, range); + } + + if (!modelName) return undefined; + + return { + modelName, + modelSource, + namespaceName, + documentUri: document.uri, + diagnosticRange: range, + }; +} + +/** + * Searches for a model's source code across workspace files when the diagnostic + * is on a @@clientName line (not the model declaration itself). + */ +function findModelSourceAcrossFiles( + modelName: string, + clientTspDocument: vscode.TextDocument, +): { source: string; namespace: string } { + // Check all open text documents for the model declaration + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.fsPath === clientTspDocument.uri.fsPath) continue; + if (!doc.uri.fsPath.endsWith(".tsp")) continue; + + const text = doc.getText(); + // Look for "model ModelName {" or "model ModelName " patterns + const modelPattern = new RegExp(`model\\s+${modelName}\\s*[{<]`); + const match = text.match(modelPattern); + if (match && match.index !== undefined) { + const pos = doc.positionAt(match.index); + const range = new vscode.Range(pos, pos); + return { + source: extractModelSource(doc, range), + namespace: extractNamespace(doc, range), + }; + } + } + + // Fallback: try the main.tsp in the same directory + const dir = vscode.Uri.joinPath(clientTspDocument.uri, ".."); + const mainTspUri = vscode.Uri.joinPath(dir, "main.tsp"); + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.toString() === mainTspUri.toString()) { + const text = doc.getText(); + const modelPattern = new RegExp(`model\\s+${modelName}\\s*[{<]`); + const match = text.match(modelPattern); + if (match && match.index !== undefined) { + const pos = doc.positionAt(match.index); + const range = new vscode.Range(pos, pos); + return { + source: extractModelSource(doc, range), + namespace: extractNamespace(doc, range), + }; + } + } + } + + // Last resort: return minimal context + return { + source: `model ${modelName} {}`, + namespace: extractNamespace(clientTspDocument, new vscode.Range(0, 0, 0, 0)), + }; +} + +function extractModelSource(document: vscode.TextDocument, diagnosticRange: vscode.Range): string { + // Walk backward to find the start of decorators/doc comments, forward to find closing brace + const text = document.getText(); + let startOffset = document.offsetAt(diagnosticRange.start); + + // Walk back to find the line with 'model' keyword or decorators + while (startOffset > 0 && text[startOffset - 1] !== "\n") { + startOffset--; + } + // Continue walking back through decorator lines + while (startOffset > 0) { + const prevLineStart = text.lastIndexOf("\n", startOffset - 2) + 1; + const prevLine = text.slice(prevLineStart, startOffset).trim(); + if (prevLine.startsWith("@") || prevLine.startsWith("*") || prevLine.startsWith("/**")) { + startOffset = prevLineStart; + } else { + break; + } + } + + // Walk forward to find the matching closing brace + let braceCount = 0; + let endOffset = document.offsetAt(diagnosticRange.end); + let foundOpen = false; + while (endOffset < text.length) { + if (text[endOffset] === "{") { + braceCount++; + foundOpen = true; + } else if (text[endOffset] === "}") { + braceCount--; + if (foundOpen && braceCount === 0) { + endOffset++; + break; + } + } + endOffset++; + } + + return text.slice(startOffset, endOffset); +} + +function extractNamespace(document: vscode.TextDocument, range: vscode.Range): string { + const text = document.getText(); + const offset = document.offsetAt(range.start); + const textBefore = text.slice(0, offset); + + // Simple regex to find the last namespace declaration + const nsMatch = textBefore.match(/namespace\s+([\w.]+)\s*[{;]/g); + if (nsMatch && nsMatch.length > 0) { + const last = nsMatch[nsMatch.length - 1]; + const nameMatch = last.match(/namespace\s+([\w.]+)/); + return nameMatch ? nameMatch[1] : ""; + } + return ""; +} + +async function fetchAiSuggestions( + ctx: ModelNameContext, + token?: vscode.CancellationToken, +): Promise { + const prompt = `You are a .NET naming expert for Azure SDKs. Given the following TypeSpec model, suggest exactly 5 better multi-word names. + +Requirements: +- Each name MUST be 2+ words in PascalCase (e.g., "StorageDocument" not just "Document") +- A key goal is UNIQUENESS across Azure services — the name must not conflict with models of the same name in other Azure services (e.g., "Document" exists in Cosmos DB, AI Document Intelligence, Search, etc.) +- Names should describe the model's PURPOSE based on its properties, doc comments, and context +- The namespace can inform your suggestions but should not be the only source of inspiration — prioritize what the model represents functionally +- Order by confidence: put your best suggestion first, then descending +- Follow .NET and Azure SDK naming conventions + +Namespace: ${ctx.namespaceName} +Current name: ${ctx.modelName} + +Return ONLY the 5 names, one per line. No explanations, no numbering, no backticks. + +\`\`\`tsp +${ctx.modelSource} +\`\`\``; + + try { + // Try multiple model families — availability varies by Copilot version + const families = ["gpt-4o", "gpt-4", "gpt-3.5-turbo", "copilot-gpt-4.1"]; + let response: string | undefined; + + for (const family of families) { + if (token?.isCancellationRequested) return []; + try { + logger.info(`Trying LM model family: ${family}`); + response = await sendLmChatRequest( + [{ role: "user", message: prompt }], + family, + undefined, + `suggest-model-name-${ctx.modelName}`, + ); + if (response) break; + } catch { + // Try next family + } + } + + if (!response) { + logger.warning("No LM model responded for model name suggestions"); + return []; + } + + logger.info(`AI raw response for '${ctx.modelName}': ${response}`); + + return response + .split(/[\r\n]+/) + .map((line) => { + // Strip numbering (e.g., "1. FooBar", "1) FooBar", "- FooBar") + let cleaned = line.trim().replace(/^[\d]+[.)]\s*/, "").replace(/^[-*•]\s*/, ""); + // Strip backticks and quotes + cleaned = cleaned.replace(/[`'"]/g, "").trim(); + return cleaned; + }) + .filter((line) => line.length > 0 && /^[A-Z][A-Za-z0-9_]*$/.test(line)) + .slice(0, 5); + } catch (e) { + logger.error("Failed to get AI suggestions for model name", [e]); + return []; + } +} + +async function applyDirectRename(ctx: ModelNameContext, newName: string): Promise { + // Use the language server's rename provider to find and update all references + const position = ctx.diagnosticRange.start; + + try { + const workspaceEdit = await vscode.commands.executeCommand( + "vscode.executeDocumentRenameProvider", + ctx.documentUri, + position, + newName, + ); + + if (workspaceEdit && workspaceEdit.size > 0) { + const success = await vscode.workspace.applyEdit(workspaceEdit); + if (success) { + const entryCount = [...workspaceEdit.entries()].reduce((sum, [, edits]) => sum + edits.length, 0); + vscode.window.showInformationMessage( + `Renamed '${ctx.modelName}' to '${newName}' (${entryCount} reference${entryCount > 1 ? "s" : ""} updated)`, + ); + } else { + vscode.window.showErrorMessage(`Failed to apply rename`); + } + } else { + // Fallback: simple text replacement if rename provider returns nothing + const edit = new vscode.WorkspaceEdit(); + edit.replace(ctx.documentUri, ctx.diagnosticRange, newName); + await vscode.workspace.applyEdit(edit); + vscode.window.showInformationMessage(`Renamed model to '${newName}'`); + } + } catch (e) { + // Fallback if rename provider fails + logger.warning(`Rename provider failed, falling back to simple replace: ${e}`); + const edit = new vscode.WorkspaceEdit(); + edit.replace(ctx.documentUri, ctx.diagnosticRange, newName); + const success = await vscode.workspace.applyEdit(edit); + if (success) { + vscode.window.showInformationMessage(`Renamed model to '${newName}' (references may need manual update)`); + } else { + vscode.window.showErrorMessage(`Failed to rename model`); + } + } +} + +/** + * Updates the name string in an existing @@clientName decorator. + * If the diagnostic is on the @@clientName line itself, edits in place. + * If the diagnostic is on the model line (auto-discovered client.tsp), finds client.tsp and edits there. + */ +async function applyUpdateClientName( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + newName: string, +): Promise { + const lineText = document.lineAt(diagnostic.range.start.line).text; + + // Check if we're on a @@clientName line directly + if (lineText.includes("@@clientName")) { + await replaceClientNameInLine(document.uri, diagnostic.range.start.line, newName); + return; + } + + // Auto-discovered case: diagnostic is on the model line, find client.tsp + const modelName = document.getText(diagnostic.range); + const docDir = vscode.Uri.joinPath(document.uri, ".."); + const clientTspUri = vscode.Uri.joinPath(docDir, "client.tsp"); + + try { + const clientDoc = await vscode.workspace.openTextDocument(clientTspUri); + // Find the @@clientName line for this model + for (let i = 0; i < clientDoc.lineCount; i++) { + const line = clientDoc.lineAt(i).text; + if (line.includes("@@clientName") && line.includes(modelName)) { + await replaceClientNameInLine(clientTspUri, i, newName); + // Show the edited file + await vscode.window.showTextDocument(clientDoc, { preview: true, preserveFocus: true }); + return; + } + } + vscode.window.showErrorMessage( + `Could not find @@clientName for '${modelName}' in client.tsp`, + ); + } catch { + vscode.window.showErrorMessage(`Could not open client.tsp`); + } +} + +async function replaceClientNameInLine( + uri: vscode.Uri, + lineNumber: number, + newName: string, +): Promise { + const doc = await vscode.workspace.openTextDocument(uri); + const lineText = doc.lineAt(lineNumber).text; + + const match = lineText.match(/,\s*"([^"]+)"/); + if (!match || match.index === undefined) { + vscode.window.showErrorMessage("Could not find the name string in the @@clientName decorator."); + return; + } + + const quoteStart = lineText.indexOf('"', match.index) + 1; + const quoteEnd = lineText.indexOf('"', quoteStart); + const range = new vscode.Range(lineNumber, quoteStart, lineNumber, quoteEnd); + + const edit = new vscode.WorkspaceEdit(); + edit.replace(uri, range, newName); + const success = await vscode.workspace.applyEdit(edit); + + if (success) { + vscode.window.showInformationMessage(`Updated client name to '${newName}'`); + } else { + vscode.window.showErrorMessage(`Failed to update client name`); + } +} + +async function applyClientNameOverride(ctx: ModelNameContext, newName: string): Promise { + const docDir = vscode.Uri.joinPath(ctx.documentUri, ".."); + const clientTspUri = vscode.Uri.joinPath(docDir, "client.tsp"); + + const overrideLine = `@@clientName(${ctx.modelName}, "${newName}");`; + + let fileExists = false; + let existingContent = ""; + try { + // Read from the editor buffer if open, otherwise from disk + const openDoc = vscode.workspace.textDocuments.find( + (d) => d.uri.toString() === clientTspUri.toString(), + ); + if (openDoc) { + existingContent = openDoc.getText(); + } else { + const raw = await vscode.workspace.fs.readFile(clientTspUri); + existingContent = new TextDecoder().decode(raw); + } + fileExists = true; + } catch { + // File doesn't exist + } + + if (existingContent.includes(overrideLine)) { + vscode.window.showInformationMessage(`Override already exists in client.tsp`); + return; + } + + const edit = new vscode.WorkspaceEdit(); + + if (!fileExists) { + // Create new file via WorkspaceEdit + const mainFileName = ctx.documentUri.path.split("/").pop() ?? "main.tsp"; + const lines = [ + `import "@azure-tools/typespec-client-generator-core";`, + `import "./${mainFileName}";`, + ``, + `using Azure.ClientGenerator.Core;`, + ]; + if (ctx.namespaceName) { + lines.push(`using ${ctx.namespaceName};`); + } + lines.push(``, overrideLine, ``); + + edit.createFile(clientTspUri, { ignoreIfExists: true }); + edit.insert(clientTspUri, new vscode.Position(0, 0), lines.join("\n")); + } else { + // Append to existing file via WorkspaceEdit (respects unsaved buffers) + const openDoc = + vscode.workspace.textDocuments.find( + (d) => d.uri.toString() === clientTspUri.toString(), + ) ?? (await vscode.workspace.openTextDocument(clientTspUri)); + + // Add using if needed + if (ctx.namespaceName && !existingContent.includes(`using ${ctx.namespaceName};`)) { + const lastUsingLine = findLastLineMatching(openDoc, /^using\s/); + if (lastUsingLine >= 0) { + const insertPos = new vscode.Position(lastUsingLine + 1, 0); + edit.insert(clientTspUri, insertPos, `using ${ctx.namespaceName};\n`); + } + } + + // Insert after the last existing @@clientName line, or at end with a blank line + const lastClientNameLine = findLastLineMatching(openDoc, /^@@clientName\(/); + if (lastClientNameLine >= 0) { + // Append right after the last @@clientName line + const insertPos = new vscode.Position(lastClientNameLine + 1, 0); + edit.insert(clientTspUri, insertPos, `${overrideLine}\n`); + } else { + // No existing @@clientName lines — append at end with a blank line separator + const lastLine = openDoc.lineCount - 1; + const lastChar = openDoc.lineAt(lastLine).text.length; + const endPos = new vscode.Position(lastLine, lastChar); + const separator = existingContent.endsWith("\n") ? "" : "\n"; + edit.insert(clientTspUri, endPos, `${separator}\n${overrideLine}\n`); + } + } + + const success = await vscode.workspace.applyEdit(edit); + if (!success) { + vscode.window.showErrorMessage("Failed to update client.tsp"); + return; + } + + // Ensure tspconfig.yaml imports client.tsp + await ensureClientTspImport(docDir); + + vscode.window.showInformationMessage( + `Added @@clientName override to client.tsp: ${ctx.modelName} → ${newName}`, + ); + + const doc = await vscode.workspace.openTextDocument(clientTspUri); + await vscode.window.showTextDocument(doc, { preview: true, preserveFocus: true }); +} + +function findLastLineMatching(doc: vscode.TextDocument, pattern: RegExp): number { + for (let i = doc.lineCount - 1; i >= 0; i--) { + if (pattern.test(doc.lineAt(i).text)) return i; + } + return -1; +} + +/** + * Ensures tspconfig.yaml has `imports: - ./client.tsp` so client.tsp is part of compilation. + */ +async function ensureClientTspImport(projectDir: vscode.Uri): Promise { + const tspConfigUri = vscode.Uri.joinPath(projectDir, "tspconfig.yaml"); + + let content: string; + try { + const raw = await vscode.workspace.fs.readFile(tspConfigUri); + content = new TextDecoder().decode(raw); + } catch { + return; // No tspconfig.yaml — nothing to do + } + + const importLine = "./client.tsp"; + + // Check if already imported + if (content.includes(importLine)) { + return; + } + + // Add imports section or append to existing one + const importsMatch = content.match(/^imports:\s*$/m); + if (importsMatch && importsMatch.index !== undefined) { + // imports: section exists but doesn't have client.tsp — append to it + const insertPos = importsMatch.index + importsMatch[0].length; + content = content.slice(0, insertPos) + `\n - ${importLine}` + content.slice(insertPos); + } else if (content.match(/^imports:/m)) { + // imports: section exists with items — append after last import line + const lines = content.split("\n"); + let lastImportLine = -1; + let inImports = false; + for (let i = 0; i < lines.length; i++) { + if (/^imports:/.test(lines[i])) { + inImports = true; + lastImportLine = i; + } else if (inImports && /^\s+-\s/.test(lines[i])) { + lastImportLine = i; + } else if (inImports && !/^\s*$/.test(lines[i])) { + break; + } + } + if (lastImportLine >= 0) { + lines.splice(lastImportLine + 1, 0, ` - ${importLine}`); + content = lines.join("\n"); + } + } else { + // No imports section — add one at the top + content = `imports:\n - ${importLine}\n` + content; + } + + await vscode.workspace.fs.writeFile(tspConfigUri, new TextEncoder().encode(content)); +} + +/** + * Check if a diagnostic is a single-word model name warning from the C# emitter. + */ +export function isSingleWordModelNameDiagnostic(diagnostic: vscode.Diagnostic): boolean { + if (diagnostic.source !== "TypeSpec") return false; + const code = diagnostic.code; + if (!code) return false; + // The code can be a string or an object with a "value" property + if (typeof code === "string") return code === SINGLE_WORD_DIAGNOSTIC_CODE; + if (typeof code === "object" && "value" in code) { + return String(code.value) === SINGLE_WORD_DIAGNOSTIC_CODE; + } + return false; +}