diff --git a/eng/Packages.props b/eng/Packages.props index d6eb372ef5ad0..e722942df5723 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -144,7 +144,7 @@ Language Server --> - + diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/ProjectDependencyHelper.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/ProjectDependencyHelper.cs index ec6a41fc194a8..f4fa163168c13 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/ProjectDependencyHelper.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/ProjectDependencyHelper.cs @@ -3,8 +3,10 @@ // See the LICENSE file in the project root for more information. using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.PooledObjects; +using Microsoft.CodeAnalysis.Shared.Utilities; using Microsoft.Extensions.Logging; using NuGet.ProjectModel; using NuGet.Versioning; @@ -14,6 +16,8 @@ namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; internal static class ProjectDependencyHelper { + private const string NuGetCacheFileName = "project.nuget.cache"; + internal static bool NeedsRestore(ProjectFileInfo newProjectFileInfo, ProjectFileInfo? previousProjectFileInfo, ILogger logger) { if (previousProjectFileInfo is null) @@ -60,6 +64,155 @@ private static bool CheckProjectAssetsForUnresolvedDependencies(ProjectFileInfo return false; } + // Fast path: consult the 'project.nuget.cache' file NuGet writes next to the assets file. If it + // confirms the last restore still accounts for every package reference the project declares, we can + // skip parsing the (potentially large) project.assets.json entirely. + if (TryConfirmRestoreUpToDateFromNuGetCache(projectFileInfo, projectAssetsPath)) + { + return false; + } + + return CheckAssetsFileForUnresolvedReferences(projectFileInfo, projectAssetsPath, logger); + } + + /// + /// Uses the project.nuget.cache file NuGet writes next to to + /// confirm without parsing the (potentially large) project.assets.json that the last restore is still + /// applicable to the project we're loading. The cache must be valid ( verifies + /// the format version matches the NuGet library we build against, the last restore succeeded, and a dependency + /// graph hash was recorded) and the packages that restore produced + /// () must satisfy every package reference the project currently + /// declares. The latter check catches a project whose package set changed since the last restore—for example a + /// package added directly to the project or via a shared Directory.Packages.props—in which case the + /// previously restored assets no longer reflect the project and we can't claim it's up to date. + /// + /// + /// only when the cache proves the restore is current for every package reference the + /// project declares; otherwise (the cache is missing, invalid, or doesn't account for + /// all of the project's package references), in which case the caller should inspect the assets file directly. + /// + /// + /// This intentionally does not reproduce NuGet's dependency-graph-hash comparison, which needs the current + /// restore dependency graph that is not available here. Instead it confirms the restored package set covers the + /// project's declared references and otherwise defers to the precise assets-file check, so it can only ever + /// accelerate the up-to-date case and never report a stale restore as current. + /// + private static bool TryConfirmRestoreUpToDateFromNuGetCache(ProjectFileInfo projectFileInfo, string projectAssetsPath) + { + var cachePath = Path.Combine(Path.GetDirectoryName(projectAssetsPath)!, NuGetCacheFileName); + if (!File.Exists(cachePath)) + { + return false; + } + + var cacheFile = IOUtilities.PerformIO(() => + { + using var stream = File.OpenRead(cachePath); + return CacheFileFormat.Read(stream, NuGet.Common.NullLogger.Instance, cachePath); + }); + + if (cacheFile is null) + { + return false; + } + + // IsValid checks Version == CacheFile.CurrentVersion (the format version this NuGet build understands), + // Success, and that a dependency graph hash was recorded. + if (!cacheFile.IsValid || cacheFile.ExpectedPackageFilePaths is not { Count: > 0 }) + { + return false; + } + + // Reconstruct the set of packages the last restore produced and confirm every package reference the + // project currently declares is present with a satisfying version. If a reference isn't accounted for, + // the project changed since the restore (or the cache is otherwise incomplete), so we can't confirm it's + // up to date and defer to the assets-file check. + var restoredPackages = TryCreateRestoredPackagesMap(cacheFile.ExpectedPackageFilePaths); + if (restoredPackages is null) + { + return false; + } + + foreach (var reference in projectFileInfo.PackageReferences) + { + if (!IsPackageReferenceResolved(reference, restoredPackages)) + { + return false; + } + } + + return true; + } + + /// + /// Builds a map of package id to the versions present in the restore, derived from the + /// . Each entry looks like + /// {packagesFolder}/{id}/{version}/{id}.{version}.nupkg.sha512, so the package id and resolved version + /// are the two directories that contain the file. Returns if no package could be parsed. + /// + private static Dictionary>? TryCreateRestoredPackagesMap(IList expectedPackageFilePaths) + { + var versionsById = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var filePath in expectedPackageFilePaths) + { + var versionDirectory = Path.GetDirectoryName(filePath); + var idDirectory = Path.GetDirectoryName(versionDirectory); + if (string.IsNullOrEmpty(versionDirectory) || string.IsNullOrEmpty(idDirectory)) + { + continue; + } + + var id = Path.GetFileName(idDirectory); + if (string.IsNullOrEmpty(id) || !NuGetVersion.TryParse(Path.GetFileName(versionDirectory), out var version)) + { + continue; + } + + if (!versionsById.TryGetValue(id, out var versions)) + { + versionsById.Add(id, OneOrMany.Create(version)); + } + else if (!versions.Contains(version)) + { + versionsById[id] = versions.Add(version); + } + } + + return versionsById.Count == 0 ? null : versionsById; + } + + /// + /// Determines whether is satisfied by a restored package in + /// (a map of package id to the versions present in the restore). A + /// reference is resolved when its name is present and at least one restored version satisfies the requested + /// version range. + /// + private static bool IsPackageReferenceResolved(PackageReferenceItem reference, IReadOnlyDictionary> restoredPackages) + { + if (!restoredPackages.TryGetValue(reference.Name, out var versions)) + { + // The package name isn't in the restore at all. + return false; + } + + var requestedVersionRange = VersionRange.TryParse(reference.VersionRange, out var versionRange) + ? versionRange + : VersionRange.All; + + foreach (var version in versions) + { + if (requestedVersionRange.Satisfies(version)) + { + return true; + } + } + + return false; + } + + private static bool CheckAssetsFileForUnresolvedReferences(ProjectFileInfo projectFileInfo, string projectAssetsPath, ILogger logger) + { // Iterate the project's package references and check if there is a package with the same name // and acceptable version in the lock file. @@ -71,21 +224,8 @@ private static bool CheckProjectAssetsForUnresolvedDependencies(ProjectFileInfo foreach (var reference in projectFileInfo.PackageReferences) { - if (!projectAssetsMap.TryGetValue(reference.Name, out var projectAssetsVersions)) + if (!IsPackageReferenceResolved(reference, projectAssetsMap)) { - // If the package name isn't in the lock file then it's unresolved. - unresolved.Add(reference); - continue; - } - - var requestedVersionRange = VersionRange.TryParse(reference.VersionRange, out var versionRange) - ? versionRange - : VersionRange.All; - - var projectAssetsHasVersion = projectAssetsVersions.Any(projectAssetsVersion => SatisfiesVersion(requestedVersionRange, projectAssetsVersion)); - if (!projectAssetsHasVersion) - { - // If the package name is in the lock file but none of the versions satisfy the requested version range then it's unresolved. unresolved.Add(reference); } } @@ -101,20 +241,15 @@ private static bool CheckProjectAssetsForUnresolvedDependencies(ProjectFileInfo return false; - static ImmutableDictionary> CreateProjectAssetsMap(LockFile lockFile) + static ImmutableDictionary> CreateProjectAssetsMap(LockFile lockFile) { // Create a map of package names to all versions in the lock file. var map = lockFile.Libraries .GroupBy(l => l.Name, l => l.Version, StringComparer.OrdinalIgnoreCase) - .ToImmutableDictionary(g => g.Key, g => g.ToImmutableArray(), StringComparer.OrdinalIgnoreCase); + .ToImmutableDictionary(g => g.Key, g => OneOrMany.Create(g.ToImmutableArray()), StringComparer.OrdinalIgnoreCase); return map; } - - static bool SatisfiesVersion(VersionRange requestedVersionRange, NuGetVersion projectAssetsVersion) - { - return requestedVersionRange.Satisfies(projectAssetsVersion); - } } internal static async Task RestoreProjectsAsync(WorkDoneProgressManager workDoneProgressManager, ImmutableArray projectPaths, bool enableProgressReporting, DotnetCliHelper dotnetCliHelper, ILogger logger, CancellationToken cancellationToken)