diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 5dc360c3b..307e437be 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -207,6 +207,12 @@ "Recording trace... Click Cancel to stop and save.": "Recording trace... Click Cancel to stop and save.", "Recording logs... Click Cancel to stop and save.": "Recording logs... Click Cancel to stop and save.", "Collecting C# Logs": "Collecting C# Logs", + "Only C# files can be converted to a project.": "Only C# files can be converted to a project.", + "This file is not detected as a file-based app entry point.": "This file is not detected as a file-based app entry point.", + "No C# files found in the workspace.": "No C# files found in the workspace.", + "No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.": "No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.", + "Select a file-based C# app to convert to a project": "Select a file-based C# app to convert to a project", + "dotnet project convert": "dotnet project convert", "Nested Code Action": "Nested Code Action", "Fix All: ": "Fix All: ", "Pick a fix all scope": "Pick a fix all scope", diff --git a/package.json b/package.json index 9802b1c60..058768fe4 100644 --- a/package.json +++ b/package.json @@ -2016,6 +2016,12 @@ "title": "%command.dotnet.restartServer%", "category": ".NET", "enablement": "isWorkspaceTrusted && dotnet.server.activationContext == 'Roslyn'" + }, + { + "command": "dotnet.convertToProject", + "title": "%command.dotnet.convertToProject%", + "category": ".NET", + "enablement": "isWorkspaceTrusted && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')" } ], "keybindings": [ @@ -5733,6 +5739,11 @@ "when": "(resourceLangId == csharp || resourceLangId == aspnetcorerazor) && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@1" }, + { + "command": "dotnet.convertToProject", + "when": "resourceLangId == csharp && resourcePath in dotnet.likelyFbaEntryPoints && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "group": "2_dotnet@2" + }, { "command": "dotnet.test.runTestsInContext", "when": "editorLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'OmniSharp')", @@ -5749,6 +5760,11 @@ "command": "csharp.changeProjectContextFileExplorer", "when": "(resourceLangId == csharp || resourceLangId == aspnetcorerazor) && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@1" + }, + { + "command": "dotnet.convertToProject", + "when": "resourceLangId == csharp && resourcePath in dotnet.likelyFbaEntryPoints && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "group": "2_dotnet@2" } ], "issue/reporter": [ diff --git a/package.nls.cs.json b/package.nls.cs.json index 3966f19a9..58d54df4b 100644 --- a/package.nls.cs.json +++ b/package.nls.cs.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Zobrazit smlouvu o podmínkách dekompilátoru", "command.dotnet.generateAssets.currentProject": "Generovat prostředky pro sestavení a ladění", "command.dotnet.openSolution": "Otevřít řešení", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Restartovat jazykový server", "command.dotnet.restore.all": "Obnovit všechny projekty", "command.dotnet.restore.project": "Obnovit projekt", diff --git a/package.nls.de.json b/package.nls.de.json index 63c2151e4..e875aacc4 100644 --- a/package.nls.de.json +++ b/package.nls.de.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Vereinbarung zu den Decompilerbedingungen anzeigen", "command.dotnet.generateAssets.currentProject": "Ressourcen für Build und Debuggen generieren", "command.dotnet.openSolution": "Projektmappe öffnen", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Sprachserver neu starten", "command.dotnet.restore.all": "Alle Projekte wiederherstellen", "command.dotnet.restore.project": "Projekt wiederherstellen", diff --git a/package.nls.es.json b/package.nls.es.json index fb394aba2..a7f2a4ced 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Mostrar el contrato de términos del descompilador", "command.dotnet.generateAssets.currentProject": "Generar recursos para compilar y depurar", "command.dotnet.openSolution": "Abrir solución", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Reiniciar servidor de lenguaje", "command.dotnet.restore.all": "Restaurar todos los proyectos", "command.dotnet.restore.project": "Restaurar proyecto", diff --git a/package.nls.fr.json b/package.nls.fr.json index 64611f496..e6b936150 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Afficher l'accord sur les termes du décompilateur", "command.dotnet.generateAssets.currentProject": "Générer des actifs pour la construction et le débogage", "command.dotnet.openSolution": "Solution ouverte", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Redémarrer le serveur de langue", "command.dotnet.restore.all": "Restaurer tous les projets", "command.dotnet.restore.project": "Restaurer le projet", diff --git a/package.nls.it.json b/package.nls.it.json index ab6c277d8..9a3458970 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Mostra il contratto per i termini del decompilatore", "command.dotnet.generateAssets.currentProject": "Genera gli asset per la compilazione e il debug", "command.dotnet.openSolution": "Apri soluzione", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Riavvia il server di linguaggio", "command.dotnet.restore.all": "Ripristina tutti i progetti", "command.dotnet.restore.project": "Ripristina progetto", diff --git a/package.nls.ja.json b/package.nls.ja.json index 861ac5659..bbe294888 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "逆コンパイラの使用契約条件を表示する", "command.dotnet.generateAssets.currentProject": "ビルド用およびデバッグ用の資産を生成する", "command.dotnet.openSolution": "ソリューションを開く", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "言語サーバーを再起動する", "command.dotnet.restore.all": "すべてのプロジェクトを復元する", "command.dotnet.restore.project": "プロジェクトの復元", diff --git a/package.nls.json b/package.nls.json index b11039058..205c37457 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,7 @@ "command.dotnet.test.runTestsInContext": "Run Tests in Context", "command.dotnet.test.debugTestsInContext": "Debug Tests in Context", "command.dotnet.restartServer": "Restart Language Server", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "configuration.dotnet.autoInsert.enableAutoInsert": "Enable automatic adjustments of code constructs on typing, including documentation comment insertion, brace formatting adjustments, and raw string literal support.", "configuration.dotnet.formatting.organizeImportsOnFormat": "Specifies whether 'using' directives should be grouped and sorted during document formatting.", "configuration.dotnet.defaultSolution.description": "The path of the default solution to be opened in the workspace when multiple solutions are available.", diff --git a/package.nls.ko.json b/package.nls.ko.json index 5916ba856..437458431 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "디컴파일러 계약 표시", "command.dotnet.generateAssets.currentProject": "빌드 및 디버그에 대한 자산 생성", "command.dotnet.openSolution": "솔루션 열기", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "언어 서버 다시 시작", "command.dotnet.restore.all": "모든 프로젝트 복원", "command.dotnet.restore.project": "프로젝트 복원", diff --git a/package.nls.pl.json b/package.nls.pl.json index fd2ed30f4..798cbbb51 100644 --- a/package.nls.pl.json +++ b/package.nls.pl.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Pokaż umowę warunków dekompilowania", "command.dotnet.generateAssets.currentProject": "Generuj zasoby na potrzeby kompilacji i debugowania", "command.dotnet.openSolution": "Otwórz rozwiązanie", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Ponownie uruchom serwer języka", "command.dotnet.restore.all": "Przywróć wszystkie projekty", "command.dotnet.restore.project": "Przywróć projekt", diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 7b53204e1..8a3798d69 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Mostrar o contrato de termos do descompilador", "command.dotnet.generateAssets.currentProject": "Gerar Ativos para Compilação e Depuração", "command.dotnet.openSolution": "Abrir Solução", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Reiniciar o Servidor de Linguagem", "command.dotnet.restore.all": "Restaurar Todos os Projetos", "command.dotnet.restore.project": "Restaurar Projeto", diff --git a/package.nls.ru.json b/package.nls.ru.json index f1eb99a77..4a84b22c7 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Показать соглашение об условиях декомпиляции", "command.dotnet.generateAssets.currentProject": "Создание ресурсов для сборки и отладки", "command.dotnet.openSolution": "Открыть решение", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Перезапустить языковой сервер", "command.dotnet.restore.all": "Восстановить все проекты", "command.dotnet.restore.project": "Восстановить проект", diff --git a/package.nls.tr.json b/package.nls.tr.json index e36ce4a05..b36fb451c 100644 --- a/package.nls.tr.json +++ b/package.nls.tr.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Derleyici koşulları sözleşmesini göster", "command.dotnet.generateAssets.currentProject": "Derleme ve Hata Ayıklama için Varlıklar Oluşturun", "command.dotnet.openSolution": "Çözümü Aç", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Dil Sunucusunu Yeniden Başlat", "command.dotnet.restore.all": "Tüm Projeleri Geri Yükleyin", "command.dotnet.restore.project": "Projeyi Geri Yükle", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 17b5559d9..67c97e4c5 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "显示反编译程序条款协议", "command.dotnet.generateAssets.currentProject": "生成用于生成和调试的资产", "command.dotnet.openSolution": "打开解决方案", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "重启语言服务器", "command.dotnet.restore.all": "还原所有项目", "command.dotnet.restore.project": "还原项目", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index a95c79d90..7a8600055 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "顯示解編程式條款合約", "command.dotnet.generateAssets.currentProject": "產生用於建置和偵錯的資產", "command.dotnet.openSolution": "開啟方案", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "重新啟動語言伺服器", "command.dotnet.restore.all": "還原所有專案", "command.dotnet.restore.project": "還原專案", diff --git a/src/lsptoolshost/activate.ts b/src/lsptoolshost/activate.ts index 52f4acb38..b0c38d0d1 100644 --- a/src/lsptoolshost/activate.ts +++ b/src/lsptoolshost/activate.ts @@ -20,6 +20,7 @@ import { registerCodeActionFixAllCommands } from './diagnostics/fixAllCodeAction import { commonOptions, languageServerOptions } from '../shared/options'; import { registerNestedCodeActionCommands } from './diagnostics/nestedCodeAction'; import { registerRestoreCommands } from './projectRestore/restore'; +import { registerConvertToProjectCommands } from './fileBasedApps/convertToProject'; import { registerMiscellaneousFileNotifier } from './workspace/miscellaneousFileNotifier'; import { TelemetryEventNames } from '../shared/telemetryEventNames'; import { WorkspaceStatus } from './workspace/workspaceStatus'; @@ -93,6 +94,8 @@ export async function activateRoslynLanguageServer( registerRestoreCommands(context, languageServer, _channel); + registerConvertToProjectCommands(context); + registerSourceGeneratorRefresh(context, languageServer, _channel); context.subscriptions.push(registerLanguageServerOptionChanges(optionObservable)); diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts new file mode 100644 index 000000000..e585cda49 --- /dev/null +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -0,0 +1,322 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +export const convertToProjectCommandName = 'dotnet.convertToProject'; +export const likelyFbaEntryPointsContextKey = 'dotnet.likelyFbaEntryPoints'; + +/** + * Registers the `dotnet.convertToProject` command. When invoked with a URI (context menu) + * it converts that file directly; without a URI (command palette) it shows a quick pick of + * discoverable FBA entry points. Requires .NET 10 SDK or later. + */ +export function registerConvertToProjectCommands(context: vscode.ExtensionContext): void { + const refreshMenuContext = async (): Promise => refreshConvertToProjectMenuContext(); + + context.subscriptions.push( + vscode.commands.registerCommand(convertToProjectCommandName, async (uri?: vscode.Uri): Promise => { + if (uri !== undefined) { + // Invoked from the right-click context menu with a specific file. + await convertToProject(uri); + } else { + // Invoked from the command palette -- let the user pick from discoverable apps. + await pickAndConvertToProject(); + } + }), + vscode.workspace.onDidOpenTextDocument((document) => { + if (document.languageId === 'csharp' && path.extname(document.uri.fsPath) !== '.cs') { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidCloseTextDocument((document) => { + if (document.languageId === 'csharp' && path.extname(document.uri.fsPath) !== '.cs') { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidSaveTextDocument((document) => { + if (document.languageId === 'csharp' || path.extname(document.uri.fsPath) === '.csproj') { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidCreateFiles((event) => { + if (event.files.some((uri) => isConvertToProjectRefreshCandidate(uri))) { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidDeleteFiles((event) => { + if (event.files.some((uri) => isConvertToProjectRefreshCandidate(uri))) { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidRenameFiles((event) => { + if ( + event.files.some( + (file) => + isConvertToProjectRefreshCandidate(file.oldUri) || + isConvertToProjectRefreshCandidate(file.newUri) + ) + ) { + void refreshMenuContext(); + } + }) + ); + + void refreshMenuContext(); +} + +/** + * Converts the given file-based C# app to a project-based app by running + * `dotnet project convert ` in an integrated terminal. + */ +async function convertToProject(uri: vscode.Uri): Promise { + // Use language ID (not extension) so files with custom language associations are accepted. + const document = + vscode.workspace.textDocuments.find((d) => d.uri.fsPath === uri.fsPath) ?? + (await vscode.workspace.openTextDocument(uri)); + + if (document.languageId !== 'csharp') { + vscode.window.showErrorMessage(vscode.l10n.t('Only C# files can be converted to a project.')); + return; + } + + const kind = detectTextDocumentFileBasedAppKind(document); + if (!isLikelyFbaEntryPoint(uri.fsPath, kind, await getCsprojDirs())) { + vscode.window.showInformationMessage( + vscode.l10n.t('This file is not detected as a file-based app entry point.') + ); + return; + } + + await runConvertCommand(uri.fsPath); +} + +/** + * Shows a quick-pick list of discoverable FBA entry points and converts the one selected. + * + * All C# files are candidates. Files inside a `.csproj` cone are filtered out unless they + * contain `#!` or `#:` markers. Open files are included via language ID; closed + * files are matched by `.cs` extension (VS Code only exposes language IDs for open files). + */ +async function pickAndConvertToProject(): Promise { + const allCsUris = await findCandidateCsUris(); + + if (allCsUris.length === 0) { + vscode.window.showInformationMessage(vscode.l10n.t('No C# files found in the workspace.')); + return; + } + + const entryPoints: vscode.QuickPickItem[] = []; + const csprojDirs = await getCsprojDirs(); + + for (const fileUri of allCsUris) { + const kind = detectFileBasedAppKindForUri(fileUri); + const filePath = fileUri.fsPath; + + if (isLikelyFbaEntryPoint(filePath, kind, csprojDirs)) { + const label = path.basename(filePath); + const description = vscode.workspace.asRelativePath(fileUri, true); + entryPoints.push({ label, description, detail: filePath }); + } + } + + if (entryPoints.length === 0) { + vscode.window.showInformationMessage( + vscode.l10n.t( + 'No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.' + ) + ); + return; + } + + const picked = await vscode.window.showQuickPick(entryPoints, { + placeHolder: vscode.l10n.t('Select a file-based C# app to convert to a project'), + matchOnDescription: true, + matchOnDetail: true, + }); + + // If user clicks away, cancelling operation + if (!picked?.detail) { + return; + } + + await runConvertCommand(picked.detail); +} + +export async function refreshConvertToProjectMenuContext(): Promise { + const entryPoints = await findLikelyFbaEntryPointUris(); + const contextValue = Object.fromEntries(entryPoints.map((uri) => [uri.path, true])); + await vscode.commands.executeCommand('setContext', likelyFbaEntryPointsContextKey, contextValue); +} + +/** + * Returns `true` when `filePath` resides inside the directory cone of at least one + * `.csproj` file -- i.e. when any directory in `csprojDirs` is an ancestor of (or the + * same directory as) the file's parent directory. + */ +export function isInProjectCone(filePath: string, csprojDirs: Set): boolean { + let dir = path.dirname(filePath); + let parent = path.dirname(dir); + while (parent !== dir) { + if (csprojDirs.has(dir)) { + return true; + } + dir = parent; + parent = path.dirname(dir); + } + // Check the final (root) directory. + return csprojDirs.has(dir); +} + +/** + * Returns `true` when the file should be shown as a "Convert to Project" option. + * + * C# files outside all `.csproj` cones are always shown. C# files inside a `.csproj` cone are + * shown only when they contain top-of-file file-based app markers (`#!` or `#:`). + */ +export function isLikelyFbaEntryPoint(filePath: string, kind: FileBasedAppKind, csprojDirs: Set): boolean { + if (!isInProjectCone(filePath, csprojDirs)) { + return true; + } + + return kind === FileBasedAppKind.Shebang || kind === FileBasedAppKind.Directives; +} + +/** + * Runs `dotnet project convert ` in a new integrated terminal whose working + * directory is set to the folder that contains the file. A fresh terminal is always + * created so that no shell-specific `cd` command is needed -- the `cwd` option handles + * the working directory in a way that works on Bash, PowerShell, and CMD alike. + */ +async function runConvertCommand(filePath: string): Promise { + const workingDir = path.dirname(filePath); + const fileName = path.basename(filePath); + + const terminal = vscode.window.createTerminal({ + name: vscode.l10n.t('dotnet project convert'), + cwd: workingDir, + }); + + terminal.show(/*preserveFocus:*/ true); + terminal.sendText(`dotnet project convert "${fileName}"`); +} + +export enum FileBasedAppKind { + /** The file does not include `#!` or `#:` directives. */ + None, + /** The file starts with `#!`, making it a discoverable entry point. */ + Shebang, + /** The file contains `#:` directives (package/sdk/property), strongly suggesting it is an entry point. */ + Directives, +} + +/** + * Detects whether `filePath` looks like an FBA entry point by scanning the first few lines + * for `#!` or `#:` markers. Mirrors Roslyn's `FileBasedProgramsEntryPointDiscovery`. + */ +export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { + return detectFileBasedAppKindFromContent(defaultReadFileHead(filePath)); +} + +function detectTextDocumentFileBasedAppKind(document: Pick): FileBasedAppKind { + return detectFileBasedAppKindFromContent(document.getText().slice(0, 1024)); +} + +function detectFileBasedAppKindForUri(uri: vscode.Uri): FileBasedAppKind { + const openDocument = vscode.workspace.textDocuments.find((document) => document.uri.fsPath === uri.fsPath); + return openDocument ? detectTextDocumentFileBasedAppKind(openDocument) : detectFileBasedAppKind(uri.fsPath); +} + +function detectFileBasedAppKindFromContent(content: string | null): FileBasedAppKind { + if (content === null) { + return FileBasedAppKind.None; + } + + // Strip an optional UTF-8 BOM before checking for `#!`. + const stripped = content.startsWith('\uFEFF') ? content.slice(1) : content; + + if (stripped.startsWith('#!')) { + return FileBasedAppKind.Shebang; + } + + // Check the first few non-empty lines for `#:` directives. + const lines = stripped.split(/\r?\n/); + const nonEmptyLinesToCheck = 5; + let checkedLines = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + if (trimmed.startsWith('#:')) { + return FileBasedAppKind.Directives; + } + // After encountering a line that is neither blank nor a directive, stop scanning. + // Real FBA entry points must put their directives before other C# tokens, + // though FBA directives can come after comments + if (++checkedLines >= nonEmptyLinesToCheck) { + break; + } + } + + return FileBasedAppKind.None; +} + +async function findCandidateCsUris(): Promise { + // Collect C# files from the workspace by extension, then augment with any already-open + // documents that VS Code treats as C# even if they lack a .cs extension. + const csFilesByExtension = await vscode.workspace.findFiles('**/*.cs', '**/obj/**'); + const csFileSet = new Set(csFilesByExtension.map((u) => u.fsPath)); + + const allCsUris: vscode.Uri[] = [...csFilesByExtension]; + for (const doc of vscode.workspace.textDocuments) { + if (doc.languageId === 'csharp' && !csFileSet.has(doc.uri.fsPath)) { + allCsUris.push(doc.uri); + } + } + + return allCsUris; +} + +async function findLikelyFbaEntryPointUris(): Promise { + const allCsUris = await findCandidateCsUris(); + const csprojDirs = await getCsprojDirs(); + + return allCsUris.filter((fileUri) => + isLikelyFbaEntryPoint(fileUri.fsPath, detectFileBasedAppKindForUri(fileUri), csprojDirs) + ); +} + +async function getCsprojDirs(): Promise> { + // Build a set of directories that contain a .csproj so we can quickly check whether + // a given .cs file lives inside a project cone. + const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); + return new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); +} + +function isConvertToProjectRefreshCandidate(uri: vscode.Uri): boolean { + const extension = path.extname(uri.fsPath); + return extension === '.cs' || extension === '.csproj'; +} + +/** + * Default implementation of the `readFileHead` parameter for `detectFileBasedAppKind`. + * Reads the first 1 KB of the file -- sufficient to find `#!` / `#:` near the top + * without loading potentially large source files. + */ +function defaultReadFileHead(filePath: string): string | null { + try { + const buffer = Buffer.alloc(1024); + const fd = fs.openSync(filePath, 'r'); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + fs.closeSync(fd); + return buffer.subarray(0, bytesRead).toString('utf8'); + } catch { + // Ignore unreadable files. + return null; + } +} diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts new file mode 100644 index 000000000..d17545f40 --- /dev/null +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, test, expect } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + detectFileBasedAppKind, + FileBasedAppKind, + isInProjectCone, + isLikelyFbaEntryPoint, +} from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; + +describe('detectFileBasedAppKind', () => { + const tempDir = path.join(__dirname, '.convertToProject-test-files'); + const tempFiles: string[] = []; + + beforeEach(() => { + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + for (const filePath of tempFiles.splice(0)) { + try { + fs.unlinkSync(filePath); + } catch { + // Ignore cleanup failures. + } + } + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures. + } + }); + + function writeTempFile(content: string): string { + const filePath = path.join(tempDir, `app-${Date.now()}-${tempFiles.length}.cs`); + fs.writeFileSync(filePath, content); + tempFiles.push(filePath); + return filePath; + } + + describe('Shebang detection', () => { + test('returns Shebang for a file starting with #!', () => { + const filePath = writeTempFile('#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Shebang); + }); + + test('returns Shebang when file starts with BOM then #!', () => { + const filePath = writeTempFile('\uFEFF#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Shebang); + }); + + test('does not return Shebang when #! appears after a non-empty line', () => { + const filePath = writeTempFile('using System;\n#!/usr/bin/env dotnet\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + }); + + describe('Directives detection', () => { + test('returns Directives for a file starting with #: package directive', () => { + const filePath = writeTempFile('#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives for a file starting with #: sdk directive', () => { + const filePath = writeTempFile('#:sdk Microsoft.NET.Sdk.Web\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives when #: directive is preceded only by blank lines', () => { + const filePath = writeTempFile('\n\n#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives when BOM precedes a #: directive', () => { + const filePath = writeTempFile('\uFEFF#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives when #: appears after a blank line, a comment, and another blank line', () => { + const filePath = writeTempFile('\n// some comment\n\n#:package Foo@1.0.0\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + + test('does not return Directives when #: appears after 5 non-blank lines', () => { + const filePath = writeTempFile( + ['line1', 'line2', 'line3', 'line4', 'line5', '#:package Foo@1.0.0'].join('\n') + ); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + }); + + describe('None cases', () => { + test('returns None for a C# class file without directives', () => { + const filePath = writeTempFile('using System;\n\nnamespace Foo {\n public class Bar {}\n}\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + + test('returns None for an empty file', () => { + const filePath = writeTempFile(''); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + + test('returns None when the file does not exist (unreadable)', () => { + expect(detectFileBasedAppKind('/nonexistent/path/that/does/not/exist.cs')).toBe(FileBasedAppKind.None); + }); + + test('returns None for a file that only has blank lines', () => { + const filePath = writeTempFile('\n\n\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + + test('returns None when #: appears inside a string literal on the first line', () => { + // The algorithm checks trimmed lines; this line does NOT start with #: + const filePath = writeTempFile('var s = "#: not a directive";\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + }); +}); + +describe('isInProjectCone', () => { + const sep = path.sep; + + function dirs(...paths: string[]): Set { + return new Set(paths); + } + + test('returns true when the file is directly inside a csproj directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}project${sep}Foo.cs`, csprojDirs)).toBe(true); + }); + + test('returns true when the file is in a subdirectory of a csproj directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}project${sep}src${sep}Foo.cs`, csprojDirs)).toBe(true); + }); + + test('returns false when the file has no csproj in any ancestor directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}scripts${sep}app.cs`, csprojDirs)).toBe(false); + }); + + test('returns false when csprojDirs is empty', () => { + expect(isInProjectCone(`${sep}workspace${sep}scripts${sep}app.cs`, new Set())).toBe(false); + }); + + test('returns false when the csproj directory is a sibling, not an ancestor', () => { + const csprojDirs = dirs(`${sep}workspace${sep}projectA`); + expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(false); + }); + + test('returns true when a file is multiple levels deep under a csproj directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}project${sep}a${sep}b${sep}c${sep}Foo.cs`, csprojDirs)).toBe(true); + }); + + test('handles multiple csproj directories -- returns true if any ancestor matches', () => { + const csprojDirs = dirs(`${sep}workspace${sep}projectA`, `${sep}workspace${sep}projectB`); + expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(true); + }); +}); + +describe('isLikelyFbaEntryPoint', () => { + const sep = path.sep; + const fileInCone = `${sep}workspace${sep}project${sep}app.cs`; + const fileOutsideCone = `${sep}workspace${sep}scripts${sep}app.cs`; + const csprojDirs = new Set([`${sep}workspace${sep}project`]); + + test('returns false for a file in a csproj cone without file-based app directives', () => { + expect(isLikelyFbaEntryPoint(fileInCone, FileBasedAppKind.None, csprojDirs)).toBe(false); + }); + + test('returns true for a shebang file in a csproj cone', () => { + expect(isLikelyFbaEntryPoint(fileInCone, FileBasedAppKind.Shebang, csprojDirs)).toBe(true); + }); + + test('returns true for a directives file in a csproj cone', () => { + expect(isLikelyFbaEntryPoint(fileInCone, FileBasedAppKind.Directives, csprojDirs)).toBe(true); + }); + + test('returns true for a file outside any csproj cone without directives', () => { + expect(isLikelyFbaEntryPoint(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); + }); +}); diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts new file mode 100644 index 000000000..613812a6d --- /dev/null +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { + convertToProjectCommandName, + registerConvertToProjectCommands, + refreshConvertToProjectMenuContext, +} from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; + +jest.mock('vscode', () => ({ + commands: { + registerCommand: jest.fn((_name, _handler) => ({ dispose: jest.fn() })), + executeCommand: jest.fn(async () => undefined), + }, + workspace: { + textDocuments: [], + openTextDocument: jest.fn(), + findFiles: jest.fn(), + asRelativePath: jest.fn((uri: { fsPath: string }) => uri.fsPath), + onDidOpenTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCloseTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCreateFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidDeleteFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidRenameFiles: jest.fn(() => ({ dispose: jest.fn() })), + }, + window: { + showErrorMessage: jest.fn(), + showInformationMessage: jest.fn(), + terminals: [], + createTerminal: jest.fn(), + showQuickPick: jest.fn(), + }, + l10n: { + t: jest.fn((message: string) => message), + }, +})); + +type MockDocument = Pick; +type MockTerminal = Pick; + +type WorkspaceMock = { + textDocuments: MockDocument[]; + openTextDocument: jest.Mock<(uri: vscode.Uri) => Promise>; + findFiles: jest.Mock<(include: string, exclude: string) => Promise>; + asRelativePath: jest.Mock<(uri: { fsPath: string }) => string>; +}; + +type WindowMock = { + showErrorMessage: jest.Mock<(message: string) => void>; + showInformationMessage: jest.Mock<(message: string) => void>; + terminals: MockTerminal[]; + createTerminal: jest.Mock<(options: { name: string; cwd: string }) => MockTerminal>; + showQuickPick: jest.Mock; +}; + +const workspaceMock = vscode.workspace as unknown as WorkspaceMock; +const windowMock = vscode.window as unknown as WindowMock; +const executeCommandMock = vscode.commands.executeCommand as unknown as jest.Mock; +const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< + (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable +>; + +function createTerminal(name = 'dotnet project convert'): MockTerminal { + return { + name, + show: jest.fn(), + sendText: jest.fn(), + }; +} + +function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { + return registerCommandMock.mock.calls[0][1] as (uri?: vscode.Uri) => Promise; +} + +function createDocument(uri: vscode.Uri, languageId: string, text = ''): MockDocument { + return { + uri, + languageId, + getText: jest.fn(() => text), + }; +} + +async function registerCommands(context: vscode.ExtensionContext): Promise { + registerConvertToProjectCommands(context); + await refreshConvertToProjectMenuContext(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as vscode.Uri[]); + executeCommandMock.mockClear(); + windowMock.showInformationMessage.mockReset(); +} + +beforeEach(() => { + jest.clearAllMocks(); + workspaceMock.textDocuments = []; + workspaceMock.openTextDocument.mockReset(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as vscode.Uri[]); + workspaceMock.asRelativePath.mockReset().mockImplementation((uri: { fsPath: string }) => uri.fsPath); + windowMock.showErrorMessage.mockReset(); + windowMock.showInformationMessage.mockReset(); + windowMock.terminals = []; + windowMock.createTerminal.mockReset(); + windowMock.showQuickPick.mockReset(); +}); + +describe('registerConvertToProjectCommands', () => { + test('registers the command and pushes the disposable onto subscriptions', async () => { + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + registerConvertToProjectCommands(context); + + expect(registerCommandMock).toHaveBeenCalledWith(convertToProjectCommandName, expect.any(Function)); + expect(context.subscriptions).toContain(registerCommandMock.mock.results[0].value); + + await getRegisteredHandler()(); + + expect(workspaceMock.findFiles).toHaveBeenCalledWith('**/*.cs', '**/obj/**'); + expect(windowMock.showInformationMessage).toHaveBeenCalledWith('No C# files found in the workspace.'); + }); +}); + +describe('convertToProject command handler', () => { + test('uses an already open C# document and creates a new terminal', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'csharp'); + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + await registerCommands(context); + await getRegisteredHandler()(uri); + + expect(workspaceMock.openTextDocument).not.toHaveBeenCalled(); + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: '/workspace', + }); + expect(terminal.show).toHaveBeenCalledWith(true); + expect(terminal.sendText).toHaveBeenCalledTimes(1); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); + + test('opens a closed C# document and creates a new terminal', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'csharp'); + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + await registerCommands(context); + await getRegisteredHandler()(uri); + + expect(workspaceMock.openTextDocument).toHaveBeenCalledWith(uri); + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: '/workspace', + }); + expect(terminal.show).toHaveBeenCalledWith(true); + expect(terminal.sendText).toHaveBeenCalledTimes(1); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); + + test('always creates a new terminal even when one with the same name already exists', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'csharp'); + const existingTerminal = createTerminal(); + const newTerminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.terminals = [existingTerminal]; + windowMock.createTerminal.mockReturnValue(newTerminal as vscode.Terminal); + + await registerCommands(context); + await getRegisteredHandler()(uri); + + expect(windowMock.createTerminal).toHaveBeenCalled(); + expect(existingTerminal.sendText).not.toHaveBeenCalled(); + expect(newTerminal.sendText).toHaveBeenCalledTimes(1); + expect(newTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); + + test('does not send any cd command regardless of platform', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'csharp'); + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + await registerCommands(context); + await getRegisteredHandler()(uri); + + const sentTexts = (terminal.sendText as jest.Mock).mock.calls.map((c) => c[0] as string); + for (const text of sentTexts) { + expect(text).not.toMatch(/^cd\b/); + } + }); + + test('shows an error for a non-C# document and does not run the convert command', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'plaintext'); + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + await registerCommands(context); + await getRegisteredHandler()(uri); + + expect(windowMock.showErrorMessage).toHaveBeenCalledWith('Only C# files can be converted to a project.'); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(terminal.show).not.toHaveBeenCalled(); + expect(terminal.sendText).not.toHaveBeenCalled(); + }); + + test('shows an info message for a C# file in a csproj cone without FBA directives', async () => { + const uri = { fsPath: '/workspace/project/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'csharp'); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + + await registerCommands(context); + workspaceMock.findFiles.mockResolvedValueOnce([{ fsPath: '/workspace/project/App.csproj' } as vscode.Uri]); + + await getRegisteredHandler()(uri); + + expect(windowMock.showInformationMessage).toHaveBeenCalledWith( + 'This file is not detected as a file-based app entry point.' + ); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + }); + + test('converts a shebang file even when it is in a csproj cone', async () => { + const filePath = path.join('/workspace', 'project', 'app.cs'); + const uri = { fsPath: filePath } as vscode.Uri; + const document = createDocument(uri, 'csharp', '#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + await registerCommands(context); + workspaceMock.findFiles.mockResolvedValueOnce([{ fsPath: '/workspace/project/App.csproj' } as vscode.Uri]); + + await getRegisteredHandler()(uri); + + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: path.join('/workspace', 'project'), + }); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); +}); diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts new file mode 100644 index 000000000..9e7d18daf --- /dev/null +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -0,0 +1,370 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; + +jest.mock('vscode', () => { + const workspace = { + _textDocuments: [] as Array<{ languageId: string; uri: { fsPath: string; path: string } }>, + findFiles: jest.fn<(include: string, exclude: string) => Promise>>(), + openTextDocument: jest.fn(), + asRelativePath: jest.fn<(uri: { fsPath: string }, includeWorkspace?: boolean) => string>(), + onDidOpenTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCloseTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCreateFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidDeleteFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidRenameFiles: jest.fn(() => ({ dispose: jest.fn() })), + }; + + Object.defineProperty(workspace, 'textDocuments', { + get: () => workspace._textDocuments, + set: (value) => { + workspace._textDocuments = value; + }, + }); + + const window = { + _terminals: [] as Array<{ + name: string; + show: jest.Mock<(preserveFocus?: boolean) => void>; + sendText: jest.Mock<(text: string) => void>; + }>, + showErrorMessage: jest.fn<(message: string) => void>(), + showInformationMessage: jest.fn<(message: string) => void>(), + showQuickPick: + jest.fn< + ( + items: Array<{ label: string; description: string; detail: string }>, + options: { placeHolder: string; matchOnDescription: boolean; matchOnDetail: boolean } + ) => Promise<{ label: string; description: string; detail: string } | undefined> + >(), + createTerminal: jest.fn< + (options: { name: string; cwd: string }) => { + name: string; + show: jest.Mock<(preserveFocus?: boolean) => void>; + sendText: jest.Mock<(text: string) => void>; + } + >(), + }; + + Object.defineProperty(window, 'terminals', { + get: () => window._terminals, + set: (value) => { + window._terminals = value; + }, + }); + + return { + commands: { + registerCommand: jest.fn((_name: string, _handler: (uri?: { fsPath: string }) => Promise) => ({ + dispose: jest.fn(), + })), + executeCommand: jest.fn().mockImplementation(async () => Promise.resolve()), + }, + workspace, + window: { + ...window, + activeTextEditor: undefined, + onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), + }, + l10n: { + t: (message: string) => message, + }, + Uri: { + file: (filePath: string) => ({ fsPath: filePath, path: filePath }), + }, + }; +}); + +import * as vscode from 'vscode'; +import { + convertToProjectCommandName, + likelyFbaEntryPointsContextKey, + registerConvertToProjectCommands, + refreshConvertToProjectMenuContext, +} from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; + +type MockUri = { fsPath: string; path: string }; +type MockTextDocument = { languageId: string; uri: MockUri; getText: () => string }; +type MockQuickPickItem = { label: string; description: string; detail: string }; +type MockTerminal = { + name: string; + show: jest.Mock<(preserveFocus?: boolean) => void>; + sendText: jest.Mock<(text: string) => void>; +}; + +type WorkspaceMock = { + textDocuments: MockTextDocument[]; + openTextDocument: jest.Mock; + findFiles: jest.Mock<(include: string, exclude: string) => Promise>; + asRelativePath: jest.Mock<(uri: MockUri, includeWorkspace?: boolean) => string>; + onDidOpenTextDocument: jest.Mock; + onDidCloseTextDocument: jest.Mock; + onDidSaveTextDocument: jest.Mock; + onDidCreateFiles: jest.Mock; + onDidDeleteFiles: jest.Mock; + onDidRenameFiles: jest.Mock; +}; + +type WindowMock = { + showErrorMessage: jest.Mock<(message: string) => void>; + showInformationMessage: jest.Mock<(message: string) => void>; + terminals: MockTerminal[]; + createTerminal: jest.Mock<(options: { name: string; cwd: string }) => MockTerminal>; + showQuickPick: jest.Mock< + ( + items: MockQuickPickItem[], + options: { placeHolder: string; matchOnDescription: boolean; matchOnDetail: boolean } + ) => Promise + >; +}; + +const workspaceMock = vscode.workspace as unknown as WorkspaceMock; +const windowMock = vscode.window as unknown as WindowMock; +const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< + (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable +>; + +const mockTerminal: MockTerminal = { + name: 'dotnet project convert', + show: jest.fn<(preserveFocus?: boolean) => void>(), + sendText: jest.fn<(text: string) => void>(), +}; +const workspaceRoot = path.join(path.sep, 'workspace'); +const executeCommandMock = vscode.commands.executeCommand as unknown as jest.Mock; + +function uri(filePath: string): MockUri { + return vscode.Uri.file(filePath) as MockUri; +} + +function createDocument(filePath: string, languageId = 'csharp', text = ''): MockTextDocument { + return { + languageId, + uri: uri(filePath), + getText: () => text, + }; +} + +function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { + return registerCommandMock.mock.calls[0][1] as (uri?: vscode.Uri) => Promise; +} + +async function registerCommands(): Promise { + registerConvertToProjectCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + await refreshConvertToProjectMenuContext(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as MockUri[]); + executeCommandMock.mockClear(); + windowMock.showInformationMessage.mockReset(); +} + +async function invokePickAndConvert(): Promise { + await registerCommands(); + await getRegisteredHandler()(); +} + +beforeEach(() => { + jest.clearAllMocks(); + + workspaceMock.textDocuments = []; + workspaceMock.openTextDocument.mockReset(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as MockUri[]); + workspaceMock.asRelativePath + .mockReset() + .mockImplementation((fileUri: MockUri) => path.relative(workspaceRoot, fileUri.fsPath)); + + windowMock.showErrorMessage.mockReset(); + windowMock.showInformationMessage.mockReset(); + windowMock.terminals = []; + windowMock.createTerminal.mockReset().mockReturnValue(mockTerminal); + windowMock.showQuickPick.mockReset(); + + mockTerminal.show.mockReset(); + mockTerminal.sendText.mockReset(); +}); + +describe('registerConvertToProjectCommands', () => { + test('registers the convert-to-project command', () => { + registerConvertToProjectCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + + expect(registerCommandMock).toHaveBeenCalledWith(convertToProjectCommandName, expect.any(Function)); + }); +}); + +describe('pickAndConvertToProject', () => { + test('shows an info message when no C# files are found', async () => { + await invokePickAndConvert(); + + expect(windowMock.showInformationMessage).toHaveBeenCalledWith('No C# files found in the workspace.'); + }); + + test('shows an info message when all C# files are inside project cones', async () => { + const filePath = path.join(workspaceRoot, 'app', 'Program.cs'); + const projectPath = path.join(workspaceRoot, 'app', 'App.csproj'); + + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]).mockResolvedValueOnce([uri(projectPath)]); + + await getRegisteredHandler()(); + + expect(windowMock.showInformationMessage).toHaveBeenCalledWith( + 'No file-based C# apps were found in the workspace. ' + + 'A file-based app entry point must not be part of any `.csproj` project, ' + + 'unless it contains a top-of-file `#!` or `#:` directive.' + ); + }); + + test('shows a quick pick for file-based apps outside project cones', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue(undefined); + + await getRegisteredHandler()(); + + expect(windowMock.showQuickPick).toHaveBeenCalledWith( + [{ label: 'Program.cs', description: path.join('scripts', 'Program.cs'), detail: filePath }], + { + placeHolder: 'Select a file-based C# app to convert to a project', + matchOnDescription: true, + matchOnDetail: true, + } + ); + }); + + test('adds open C# documents without a .cs extension to the quick-pick candidates', async () => { + const discoveredPath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + const openPath = path.join(workspaceRoot, 'scripts', 'app'); + + workspaceMock.textDocuments = [ + createDocument(discoveredPath), + createDocument(openPath), + createDocument(path.join(workspaceRoot, 'notes.txt'), 'plaintext'), + ]; + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(discoveredPath)]); + windowMock.showQuickPick.mockResolvedValue(undefined); + + await getRegisteredHandler()(); + + expect(windowMock.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining([{ label: 'app', description: path.join('scripts', 'app'), detail: openPath }]), + expect.any(Object) + ); + }); + + test('does not run conversion when the user dismisses the quick pick', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue(undefined); + + await getRegisteredHandler()(); + + expect(mockTerminal.sendText).not.toHaveBeenCalled(); + }); + + test('runs the convert command when the user picks a file', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue({ + label: 'Program.cs', + description: path.join('scripts', 'Program.cs'), + detail: filePath, + }); + + await getRegisteredHandler()(); + + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: path.join(workspaceRoot, 'scripts'), + }); + expect(mockTerminal.sendText).toHaveBeenCalledTimes(1); + expect(mockTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "Program.cs"'); + }); + + test('does not send any cd command when the user picks a file', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue({ + label: 'Program.cs', + description: path.join('scripts', 'Program.cs'), + detail: filePath, + }); + + await getRegisteredHandler()(); + + const sentTexts = mockTerminal.sendText.mock.calls.map((c) => c[0] as string); + for (const text of sentTexts) { + expect(text).not.toMatch(/^cd\b/); + } + }); + + test('always creates a new terminal when converting a picked file', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + const existingTerminal: MockTerminal = { + name: 'dotnet project convert', + show: jest.fn<(preserveFocus?: boolean) => void>(), + sendText: jest.fn<(text: string) => void>(), + }; + + await registerCommands(); + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.terminals = [existingTerminal]; + windowMock.showQuickPick.mockResolvedValue({ + label: 'Program.cs', + description: path.join('scripts', 'Program.cs'), + detail: filePath, + }); + + await getRegisteredHandler()(); + + expect(windowMock.createTerminal).toHaveBeenCalled(); + expect(existingTerminal.sendText).not.toHaveBeenCalled(); + }); +}); + +describe('refreshConvertToProjectMenuContext', () => { + test('sets a resource-path membership map for likely FBA entry points', async () => { + const outsideProjectPath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + const insideProjectPath = path.join(workspaceRoot, 'app', 'Program.cs'); + const openPath = path.join(workspaceRoot, 'scripts', 'app'); + const projectPath = path.join(workspaceRoot, 'app', 'App.csproj'); + + workspaceMock.textDocuments = [createDocument(openPath)]; + workspaceMock.findFiles.mockResolvedValueOnce([uri(outsideProjectPath), uri(insideProjectPath)]); + workspaceMock.findFiles.mockResolvedValueOnce([uri(projectPath)]); + + await refreshConvertToProjectMenuContext(); + + expect(executeCommandMock).toHaveBeenCalledWith('setContext', likelyFbaEntryPointsContextKey, { + [uri(outsideProjectPath).path]: true, + [uri(openPath).path]: true, + }); + }); +}); + +describe('package contributions', () => { + test('use the likely-FBA context key in editor and explorer menus', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8')); + const editorMenu = packageJson.contributes.menus['editor/context'].find( + (item: { command: string }) => item.command === convertToProjectCommandName + ); + const explorerMenu = packageJson.contributes.menus['explorer/context'].find( + (item: { command: string }) => item.command === convertToProjectCommandName + ); + + expect(editorMenu.when).toContain(`resourcePath in ${likelyFbaEntryPointsContextKey}`); + expect(explorerMenu.when).toContain(`resourcePath in ${likelyFbaEntryPointsContextKey}`); + }); +});