diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs index 777c96bf558..9f31bebadcd 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs @@ -32,6 +32,10 @@ public async Task ExecuteAsync() var outputPath = CodeModelGenerator.Instance.Configuration.OutputDirectory; var generatedSourceOutputPath = CodeModelGenerator.Instance.Configuration.ProjectGeneratedDirectory; + // Resolve PackageReference items from the .csproj so custom code referencing + // external NuGet types (e.g., Azure.Storage.Common) compiles correctly. + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + GeneratedCodeWorkspace customCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: true); // The generated attributes need to be added into the workspace before loading the custom code. Otherwise, // Roslyn doesn't load the attributes completely and we are unable to get the attribute arguments. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 6a389540e83..431b5443059 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.Build.Construction; using Microsoft.CodeAnalysis; +using MSBuildProjectCollection = Microsoft.Build.Evaluation.ProjectCollection; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Simplification; @@ -17,6 +18,8 @@ using Microsoft.TypeSpec.Generator.Providers; using Microsoft.TypeSpec.Generator.Utilities; using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; namespace Microsoft.TypeSpec.Generator { @@ -280,6 +283,147 @@ public async Task PostProcessAsync() } } + /// + /// Resolves PackageReference items from the project's .csproj file and adds their assemblies + /// as metadata references so that custom code referencing external NuGet types compiles correctly. + /// + internal static async Task AddPackageReferencesFromProject() + { + var packageName = CodeModelGenerator.Instance.Configuration.PackageName; + string projectFilePath = Path.GetFullPath( + Path.Combine(CodeModelGenerator.Instance.Configuration.ProjectDirectory, $"{packageName}.csproj")); + + if (!File.Exists(projectFilePath)) + { + return; + } + + var projectRoot = ProjectRootElement.Open(projectFilePath, new MSBuildProjectCollection()); + + var nugetSettings = Settings.LoadDefaultSettings(projectFilePath); + var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings); + + // Build a set of assembly names already registered so we can skip them + var existingRefs = new HashSet( + CodeModelGenerator.Instance.AdditionalMetadataReferences + .Where(r => r.Display is not null) + .Select(r => Path.GetFileNameWithoutExtension(r.Display!)) + .Where(n => !string.IsNullOrEmpty(n)), + StringComparer.OrdinalIgnoreCase); + + foreach (var item in projectRoot.Items.Where(i => i.ItemType == "PackageReference")) + { + var refPackageName = item.Include; + + if (string.IsNullOrEmpty(refPackageName)) + { + continue; + } + + // Skip packages already added as metadata references (e.g., by a plugin) + if (existingRefs.Contains(refPackageName)) + { + continue; + } + + // Search the NuGet global packages folder for any cached version of this package. + string? resolvedAssemblyPath = FindPackageAssembly(globalPackagesFolder, refPackageName); + + // If not found in cache, download the latest version from NuGet feeds + if (resolvedAssemblyPath == null) + { + try + { + var latestVersion = await ResolveLatestPackageVersion(refPackageName, nugetSettings); + if (latestVersion != null) + { + var downloader = new NugetPackageDownloader(refPackageName, latestVersion, null, nugetSettings); + var downloadedPath = await downloader.DownloadAndInstallPackage(); + var downloadedAssembly = Path.Combine(downloadedPath, $"{refPackageName}.dll"); + if (File.Exists(downloadedAssembly)) + { + resolvedAssemblyPath = downloadedAssembly; + } + } + } + catch (Exception ex) + { + CodeModelGenerator.Instance.Emitter.Debug( + $"Could not download package {refPackageName}: {ex.Message}"); + } + } + + if (resolvedAssemblyPath != null) + { + CodeModelGenerator.Instance.AddMetadataReference( + MetadataReference.CreateFromFile(resolvedAssemblyPath)); + CodeModelGenerator.Instance.Emitter.Debug( + $"Added metadata reference: {refPackageName} from {resolvedAssemblyPath}"); + } + } + } + + /// + /// Searches the NuGet global packages folder for a package assembly across all cached versions. + /// Returns the first matching assembly found, preferring newer versions. + /// + private static string? FindPackageAssembly(string globalPackagesFolder, string packageName) + { + var packageDir = Path.Combine(globalPackagesFolder, packageName.ToLowerInvariant()); + + if (!Directory.Exists(packageDir)) + { + return null; + } + + foreach (var versionDir in Directory.GetDirectories(packageDir).OrderDescending()) + { + foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) + { + var assemblyPath = Path.Combine(versionDir, "lib", tfm, $"{packageName}.dll"); + if (File.Exists(assemblyPath)) + { + return assemblyPath; + } + } + } + + return null; + } + + /// + /// Queries configured NuGet feeds to resolve the latest stable version of a package. + /// + private static async Task ResolveLatestPackageVersion(string packageName, ISettings nugetSettings) + { + var sources = SettingsUtility.GetEnabledSources(nugetSettings); + using var cacheContext = new SourceCacheContext(); + foreach (var source in sources) + { + try + { + var repository = Repository.Factory.GetCoreV3(source.Source); + var resource = await repository.GetResourceAsync(); + var versions = await resource.GetAllVersionsAsync( + packageName, cacheContext, NuGet.Common.NullLogger.Instance, CancellationToken.None); + var latest = versions? + .Where(v => !v.IsPrerelease) + .OrderByDescending(v => v) + .FirstOrDefault(); + if (latest != null) + { + return latest.ToString(); + } + } + catch + { + // Skip sources that fail (auth, network, etc.) + } + } + + return null; + } + internal static async Task LoadBaselineContract() { var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 62c2fa12635..0a75d8c9360 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -97,6 +97,245 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.NotNull(fooMethod, "Foo method should be found in the SimpleType"); } + [Test] + public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create a fake external package assembly in the NuGet cache + var externalPkgName = "My.External.Library"; + var externalPkgVersion = "2.0.0"; + var externalPkgDir = Path.Combine( + nugetCacheDir, externalPkgName.ToLowerInvariant(), externalPkgVersion, "lib", "netstandard2.0"); + Directory.CreateDirectory(externalPkgDir); + + var externalSyntaxTree = CSharpSyntaxTree.ParseText(@" +namespace My.External.Library +{ + public class ExternalCredential { } +}"); + var externalCompilation = CSharpCompilation.Create( + externalPkgName, + [externalSyntaxTree], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var externalDllPath = Path.Combine(externalPkgDir, $"{externalPkgName}.dll"); + var emitResult = externalCompilation.Emit(externalDllPath); + Assert.IsTrue(emitResult.Success, "Failed to emit external test assembly"); + + // Create a .csproj with a PackageReference to the external package + var csprojContent = $@" + + netstandard2.0 + + + + {externalPkgVersion} + + +"; + var csProjPath = Path.Combine(_projectDir!, "src", $"{ns}.csproj"); + File.WriteAllText(csProjPath, csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore + 1, refCountAfter, "Should have added one metadata reference"); + } + + [Test] + public async Task AddPackageReferencesFromProject_SkipsWhenNoCsproj() + { + // Use a namespace that doesn't match any .csproj in the project dir + MockHelpers.LoadMockGenerator( + inputNamespaceName: "NonExistentNamespace", + outputPath: _projectDir, + configuration: "{\"package-name\": \"NonExistentNamespace\"}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references when no .csproj exists"); + } + + [Test] + public async Task AddPackageReferencesFromProject_SkipsPackageNotInCache() + { + var ns = "TestNamespace"; + + // Create a .csproj referencing a package that doesn't exist in + // the cache or on any NuGet feed — should gracefully skip it. + var csprojContent = @" + + netstandard2.0 + + + + 1.0.0 + + +"; + var csProjPath = Path.Combine(_projectDir!, "src", $"{ns}.csproj"); + File.WriteAllText(csProjPath, csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references for packages not in cache"); + } + + [Test] + public async Task AddPackageReferencesFromProject_ResolvesPackageWithNoVersion() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create a fake package in the cache (simulating a centrally managed package) + var externalPkgName = "Centrally.Managed.Package"; + CreateFakeNuGetPackage(nugetCacheDir, externalPkgName, "4.2.0"); + + // Create a .csproj with no Version on the PackageReference + var csprojContent = $@" + + netstandard2.0 + + + + +"; + File.WriteAllText(Path.Combine(_projectDir!, "src", $"{ns}.csproj"), csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore + 1, refCountAfter, + "Should resolve package from cache even without a version (centrally managed)"); + } + + [Test] + public async Task AddPackageReferencesFromProject_SkipsAlreadyAddedReferences() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create a fake external package assembly in the NuGet cache + var externalPkgName = "Already.Added.Package"; + var externalPkgVersion = "1.0.0"; + var dllPath = CreateFakeNuGetPackage(nugetCacheDir, externalPkgName, externalPkgVersion); + + // Create a .csproj referencing the package + var csprojContent = $@" + + netstandard2.0 + + + + {externalPkgVersion} + + +"; + File.WriteAllText(Path.Combine(_projectDir!, "src", $"{ns}.csproj"), csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + // Pre-add the reference (simulating a plugin that already added it) + CodeModelGenerator.Instance.AddMetadataReference( + MetadataReference.CreateFromFile(dllPath)); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore, refCountAfter, + "Should not add duplicate reference for a package already in AdditionalMetadataReferences"); + } + + [Test] + public async Task AddPackageReferencesFromProject_AddsMultiplePackageReferences() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create two fake packages in the cache + CreateFakeNuGetPackage(nugetCacheDir, "First.Package", "1.0.0"); + CreateFakeNuGetPackage(nugetCacheDir, "Second.Package", "3.5.0"); + + var csprojContent = @" + + netstandard2.0 + + + + 1.0.0 + + + 3.5.0 + + +"; + File.WriteAllText(Path.Combine(_projectDir!, "src", $"{ns}.csproj"), csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore + 2, refCountAfter, "Should have added two metadata references"); + } + + /// + /// Creates a fake NuGet package assembly in the given cache directory and returns the DLL path. + /// + private static string CreateFakeNuGetPackage(string nugetCacheDir, string packageName, string version) + { + var pkgDir = Path.Combine( + nugetCacheDir, packageName.ToLowerInvariant(), version, "lib", "netstandard2.0"); + Directory.CreateDirectory(pkgDir); + + var syntaxTree = CSharpSyntaxTree.ParseText($@" +namespace {packageName} +{{ + public class Placeholder {{ }} +}}"); + var compilation = CSharpCompilation.Create( + packageName, + [syntaxTree], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var dllPath = Path.Combine(pkgDir, $"{packageName}.dll"); + var result = compilation.Emit(dllPath); + Assert.IsTrue(result.Success, $"Failed to emit fake assembly for {packageName}"); + return dllPath; + } + private void CreateTestAssemblyAndProjectFile(string nugetCacheDir, string csProjectFileName) { var ns = csProjectFileName.StartsWith("TestNamespaceUnevaluatedFrameworkValue")