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)