From 3a9f722000c31fa4594f036fc9f25a2e5612fc9d Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Wed, 24 Jul 2024 16:23:23 -0500 Subject: [PATCH 1/3] Enable tab-completion for nuget package versions The tab completion takes into account any fragments of the version entered, as well as the --prerelease flag. --- .../NuGetPackageDownloader.cs | 43 ++++++++++++++++++- .../dotnet-add-package/AddPackageParser.cs | 34 ++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs b/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs index c8371ef865da..79a6b9273eaa 100644 --- a/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs +++ b/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs @@ -329,7 +329,7 @@ private IEnumerable LoadNuGetSources(PackageId packageId, Package packageSourceMapping ??= PackageSourceMapping.GetPackageSourceMapping(settings); - // filter package patterns if enabled + // filter package patterns if enabled if (_shouldUsePackageSourceMapping && packageSourceMapping?.IsEnabled == true) { IReadOnlyList sources = packageSourceMapping.GetConfiguredPackageSources(packageId.ToString()); @@ -722,6 +722,47 @@ public async Task GetLatestPackageVersion(PackageId packageId, return packageMetadata.Identity.Version; } + public async Task> GetPackageVersionsAsync(PackageId packageId, string versionPrefix = null, bool allowPrerelease = false, PackageSourceLocation packageSourceLocation = null, CancellationToken cancellationToken = default) + { + // grab allowed sources for the package in question + IEnumerable packagesSources = LoadNuGetSources(packageId, packageSourceLocation); + var autoCompletes = await Task.WhenAll(packagesSources.Select(async (source) => await GetAutocompleteAsync(source, cancellationToken).ConfigureAwait(false))).ConfigureAwait(false); + // filter down to autocomplete endpoints (not all sources support this) + var validAutoCompletes = autoCompletes.SelectMany(x => x); + // get versions valid for this source + var versionTasks = validAutoCompletes.Select(autocomplete => GetPackageVersionsForSource(autocomplete, packageId, versionPrefix, allowPrerelease, cancellationToken)).ToArray(); + var versions = await Task.WhenAll(versionTasks).ConfigureAwait(false); + // sources may have the same versions, so we have to dedupe. + return versions.SelectMany(v => v).Distinct().OrderDescending(); + } + + private async Task> GetAutocompleteAsync(PackageSource source, CancellationToken cancellationToken) + { + SourceRepository repository = GetSourceRepository(source); + if (await repository.GetResourceAsync(cancellationToken).ConfigureAwait(false) is var resource) + { + return [resource]; + } + else return Enumerable.Empty(); + } + + private static TimeSpan _cliCompletionsTimeout = TimeSpan.FromMilliseconds(500); + private async Task> GetPackageVersionsForSource(AutoCompleteResource autocomplete, PackageId packageId, string versionPrefix, bool allowPrerelease, CancellationToken cancellationToken) + { + try + { + var timeoutCts = new CancellationTokenSource(_cliCompletionsTimeout); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + // we use the NullLogger because we don't want to log to stdout for completions - they interfere with the completions mechanism of the shell program. + return await autocomplete.VersionStartsWith(packageId.ToString(), versionPrefix: versionPrefix ?? "", includePrerelease: allowPrerelease, sourceCacheContext: _cacheSettings, log: NullLogger.Instance, token: linkedCts.Token); + } + // any errors (i.e. auth) should just be ignored for completions + catch (Exception) + { + return Enumerable.Empty(); + } + } + private SourceRepository GetSourceRepository(PackageSource source) { if (!_sourceRepositories.ContainsKey(source)) diff --git a/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs b/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs index 97d1d7364e16..faead074280a 100644 --- a/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs +++ b/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs @@ -6,6 +6,8 @@ using System.Text.Json; using Microsoft.DotNet.Tools; using Microsoft.DotNet.Tools.Add.PackageReference; +using Microsoft.Extensions.EnvironmentAbstractions; +using NuGet.Versioning; using LocalizableStrings = Microsoft.DotNet.Tools.Add.PackageReference.LocalizableStrings; namespace Microsoft.DotNet.Cli @@ -21,7 +23,23 @@ internal static class AddPackageParser { Description = LocalizableStrings.CmdVersionDescription, HelpName = LocalizableStrings.CmdVersion - }.ForwardAsSingle(o => $"--version {o}"); + }.ForwardAsSingle(o => $"--version {o}") + .AddCompletions((context) => + { + // we can only do version completion if we have a package id + if (context.ParseResult.GetValue(CmdPackageArgument) is string packageId) + { + // we should take --prerelease flags into account for version completion + var allowPrerelease = context.ParseResult.GetValue(PrereleaseOption); + return QueryVersionsForPackage(packageId, context.WordToComplete, allowPrerelease, CancellationToken.None) + .Result + .Select(version => new CompletionItem(version.ToNormalizedString())); + } + else + { + return Enumerable.Empty(); + } + }); public static readonly CliOption FrameworkOption = new ForwardedOption("--framework", "-f") { @@ -121,5 +139,19 @@ internal static IEnumerable EnumerablePackageIdFromQueryResponse(Stream } } } + + internal static async Task> QueryVersionsForPackage(string packageId, string versionFragment, bool allowPrerelease, CancellationToken cancellationToken) + { + try + { + var downloader = new NuGetPackageDownloader.NuGetPackageDownloader(packageInstallDir: new DirectoryPath()); + var versions = await downloader.GetPackageVersionsAsync(new(packageId), versionFragment, allowPrerelease, cancellationToken: cancellationToken); + return versions; + } + catch (Exception) + { + return Enumerable.Empty(); + } + } } } From 78d586686a7a586c80999b3d13b9c089a64a1a89 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Wed, 31 Jul 2024 13:46:18 -0500 Subject: [PATCH 2/3] update package id completions as well --- .../NuGetPackageDownloader.cs | 31 +++++++++++++ .../dotnet-add-package/AddPackageParser.cs | 44 +++++-------------- .../ParserTests/AddReferenceParserTests.cs | 33 -------------- 3 files changed, 42 insertions(+), 66 deletions(-) diff --git a/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs b/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs index 79a6b9273eaa..d23235245c51 100644 --- a/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs +++ b/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs @@ -722,6 +722,21 @@ public async Task GetLatestPackageVersion(PackageId packageId, return packageMetadata.Identity.Version; } + public async Task> GetPackageIdsAsync(string idStem, bool allowPrerelease, PackageSourceLocation packageSourceLocation = null, CancellationToken cancellationToken = default) + { + // grab allowed sources for the package in question + PackageId packageId = new(idStem); + IEnumerable packagesSources = LoadNuGetSources(packageId, packageSourceLocation); + var autoCompletes = await Task.WhenAll(packagesSources.Select(async (source) => await GetAutocompleteAsync(source, cancellationToken).ConfigureAwait(false))).ConfigureAwait(false); + // filter down to autocomplete endpoints (not all sources support this) + var validAutoCompletes = autoCompletes.SelectMany(x => x); + // get versions valid for this source + var versionTasks = validAutoCompletes.Select(autocomplete => GetPackageIdsForSource(autocomplete, packageId, allowPrerelease, cancellationToken)).ToArray(); + var versions = await Task.WhenAll(versionTasks).ConfigureAwait(false); + // sources may have the same versions, so we have to dedupe. + return versions.SelectMany(v => v).Distinct().OrderDescending(); + } + public async Task> GetPackageVersionsAsync(PackageId packageId, string versionPrefix = null, bool allowPrerelease = false, PackageSourceLocation packageSourceLocation = null, CancellationToken cancellationToken = default) { // grab allowed sources for the package in question @@ -763,6 +778,22 @@ private async Task> GetPackageVersionsForSource(AutoCo } } + private async Task> GetPackageIdsForSource(AutoCompleteResource autocomplete, PackageId packageId, bool allowPrerelease, CancellationToken cancellationToken) + { + try + { + var timeoutCts = new CancellationTokenSource(_cliCompletionsTimeout); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + // we use the NullLogger because we don't want to log to stdout for completions - they interfere with the completions mechanism of the shell program. + return await autocomplete.IdStartsWith(packageId.ToString(), includePrerelease: allowPrerelease, log: NullLogger.Instance, token: linkedCts.Token); + } + // any errors (i.e. auth) should just be ignored for completions + catch (Exception) + { + return Enumerable.Empty(); + } + } + private SourceRepository GetSourceRepository(PackageSource source) { if (!_sourceRepositories.ContainsKey(source)) diff --git a/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs b/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs index faead074280a..98263a0c4fc7 100644 --- a/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs +++ b/src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs @@ -17,7 +17,12 @@ internal static class AddPackageParser public static readonly CliArgument CmdPackageArgument = new CliArgument(LocalizableStrings.CmdPackage) { Description = LocalizableStrings.CmdPackageDescription - }.AddCompletions((context) => QueryNuGet(context.WordToComplete).Select(match => new CompletionItem(match))); + }.AddCompletions((context) => + { + // we should take --prerelease flags into account for version completion + var allowPrerelease = context.ParseResult.GetValue(PrereleaseOption); + return QueryNuGet(context.WordToComplete, allowPrerelease, CancellationToken.None).Result.Select(packageId => new CompletionItem(packageId)); + }); public static readonly CliOption VersionOption = new ForwardedOption("--version", "-v") { @@ -99,44 +104,17 @@ private static CliCommand ConstructCommand() return command; } - public static IEnumerable QueryNuGet(string match) + public static async Task> QueryNuGet(string packageStem, bool allowPrerelease, CancellationToken cancellationToken) { - var httpClient = new HttpClient(); - - Stream result; - try { - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var response = httpClient.GetAsync($"https://api-v2v3search-0.nuget.org/autocomplete?q={match}&skip=0&take=100", cancellation.Token) - .Result; - - result = response.Content.ReadAsStreamAsync().Result; + var downloader = new NuGetPackageDownloader.NuGetPackageDownloader(packageInstallDir: new DirectoryPath()); + var versions = await downloader.GetPackageIdsAsync(packageStem, allowPrerelease, cancellationToken: cancellationToken); + return versions; } catch (Exception) { - yield break; - } - - foreach (var packageId in EnumerablePackageIdFromQueryResponse(result)) - { - yield return packageId; - } - } - - internal static IEnumerable EnumerablePackageIdFromQueryResponse(Stream result) - { - using (JsonDocument doc = JsonDocument.Parse(result)) - { - JsonElement root = doc.RootElement; - - if (root.TryGetProperty("data", out var data)) - { - foreach (JsonElement packageIdElement in data.EnumerateArray()) - { - yield return packageIdElement.GetString(); - } - } + return Enumerable.Empty(); } } diff --git a/test/dotnet.Tests/ParserTests/AddReferenceParserTests.cs b/test/dotnet.Tests/ParserTests/AddReferenceParserTests.cs index ea175dc3a433..2c97a02e0077 100644 --- a/test/dotnet.Tests/ParserTests/AddReferenceParserTests.cs +++ b/test/dotnet.Tests/ParserTests/AddReferenceParserTests.cs @@ -57,38 +57,5 @@ public void AddReferenceWithoutArgumentResultsInAnError() .Should() .BeEquivalentTo(string.Format(LocalizableStrings.RequiredArgumentMissingForCommand, "'reference'.")); } - - [Fact] - public void EnumerablePackageIdFromQueryResponseResultsPackageIds() - { - using (var stream = new MemoryStream()) - using (var writer = new StreamWriter(stream)) - { - writer.Write(_nugetResponseSample); - writer.Flush(); - stream.Position = 0; - - AddPackageParser.EnumerablePackageIdFromQueryResponse(stream) - .Should() - .Contain( - new List - { "System.Text.Json", - "System.Text.Json.Mobile" }); - } - } - - private string _nugetResponseSample = - @"{ - ""@context"": { - ""@vocab"": ""http://schema.nuget.org/schema#"" - }, - ""totalHits"": 2, - ""lastReopen"": ""2019-03-17T22:25:28.9238936Z"", - ""index"": ""v3-lucene2-v2v3-20171018"", - ""data"": [ - ""System.Text.Json"", - ""System.Text.Json.Mobile"" - ] -}"; } } From 8d52f5c4bfcb9439bef5b8e54e79d787632057a4 Mon Sep 17 00:00:00 2001 From: Chet Husk Date: Wed, 14 Aug 2024 21:32:28 -0500 Subject: [PATCH 3/3] Add tests for package id and versions tab-completions --- .../NuGetPackageDownloader.cs | 26 ++++-- .../TestProjects/NugetCompletion/nuget.config | 8 ++ .../CommandTests/CompleteCommandTests.cs | 93 +++++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 test/TestAssets/TestProjects/NugetCompletion/nuget.config diff --git a/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs b/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs index d23235245c51..b24c6dc730ca 100644 --- a/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs +++ b/src/Cli/dotnet/NugetPackageDownloader/NuGetPackageDownloader.cs @@ -731,10 +731,10 @@ public async Task> GetPackageIdsAsync(string idStem, bool al // filter down to autocomplete endpoints (not all sources support this) var validAutoCompletes = autoCompletes.SelectMany(x => x); // get versions valid for this source - var versionTasks = validAutoCompletes.Select(autocomplete => GetPackageIdsForSource(autocomplete, packageId, allowPrerelease, cancellationToken)).ToArray(); - var versions = await Task.WhenAll(versionTasks).ConfigureAwait(false); + var packageIdTasks = validAutoCompletes.Select(autocomplete => GetPackageIdsForSource(autocomplete, packageId, allowPrerelease, cancellationToken)).ToArray(); + var packageIdLists = await Task.WhenAll(packageIdTasks).ConfigureAwait(false); // sources may have the same versions, so we have to dedupe. - return versions.SelectMany(v => v).Distinct().OrderDescending(); + return packageIdLists.SelectMany(v => v).Distinct().OrderDescending(); } public async Task> GetPackageVersionsAsync(PackageId packageId, string versionPrefix = null, bool allowPrerelease = false, PackageSourceLocation packageSourceLocation = null, CancellationToken cancellationToken = default) @@ -761,6 +761,12 @@ private async Task> GetAutocompleteAsync(Packa else return Enumerable.Empty(); } + // only exposed for testing + internal static TimeSpan CliCompletionsTimeout + { + get => _cliCompletionsTimeout; + set => _cliCompletionsTimeout = value; + } private static TimeSpan _cliCompletionsTimeout = TimeSpan.FromMilliseconds(500); private async Task> GetPackageVersionsForSource(AutoCompleteResource autocomplete, PackageId packageId, string versionPrefix, bool allowPrerelease, CancellationToken cancellationToken) { @@ -771,8 +777,11 @@ private async Task> GetPackageVersionsForSource(AutoCo // we use the NullLogger because we don't want to log to stdout for completions - they interfere with the completions mechanism of the shell program. return await autocomplete.VersionStartsWith(packageId.ToString(), versionPrefix: versionPrefix ?? "", includePrerelease: allowPrerelease, sourceCacheContext: _cacheSettings, log: NullLogger.Instance, token: linkedCts.Token); } - // any errors (i.e. auth) should just be ignored for completions - catch (Exception) + catch (FatalProtocolException) // this most often means that the source didn't actually have a SearchAutocompleteService + { + return Enumerable.Empty(); + } + catch (Exception) // any errors (i.e. auth) should just be ignored for completions { return Enumerable.Empty(); } @@ -787,8 +796,11 @@ private async Task> GetPackageIdsForSource(AutoCompleteResou // we use the NullLogger because we don't want to log to stdout for completions - they interfere with the completions mechanism of the shell program. return await autocomplete.IdStartsWith(packageId.ToString(), includePrerelease: allowPrerelease, log: NullLogger.Instance, token: linkedCts.Token); } - // any errors (i.e. auth) should just be ignored for completions - catch (Exception) + catch (FatalProtocolException) // this most often means that the source didn't actually have a SearchAutocompleteService + { + return Enumerable.Empty(); + } + catch (Exception) // any errors (i.e. auth) should just be ignored for completions { return Enumerable.Empty(); } diff --git a/test/TestAssets/TestProjects/NugetCompletion/nuget.config b/test/TestAssets/TestProjects/NugetCompletion/nuget.config new file mode 100644 index 000000000000..6ce97590acdd --- /dev/null +++ b/test/TestAssets/TestProjects/NugetCompletion/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/dotnet.Tests/CommandTests/CompleteCommandTests.cs b/test/dotnet.Tests/CommandTests/CompleteCommandTests.cs index 4cc23ba5c6c8..88ee00d47099 100644 --- a/test/dotnet.Tests/CommandTests/CompleteCommandTests.cs +++ b/test/dotnet.Tests/CommandTests/CompleteCommandTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Cli; +using Microsoft.DotNet.Cli.NuGetPackageDownloader; namespace Microsoft.DotNet.Tests.Commands { @@ -330,6 +331,98 @@ public void GivenDotnetToolInWithPosition() reporter.Lines.OrderBy(c => c).Should().Equal(expected.OrderBy(c => c)); } + [Fact] + public void CompletesNugetPackageIds() + { + NuGetPackageDownloader.CliCompletionsTimeout = TimeSpan.FromDays(1); + var testAsset = _testAssetsManager.CopyTestAsset("NugetCompletion").WithSource(); + + string[] expected = ["Newtonsoft.Json"]; + var reporter = new BufferedReporter(); + var currentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(testAsset.Path); + CompleteCommand.RunWithReporter(GetArguments("dotnet add package Newt$"), reporter).Should().Be(0); + reporter.Lines.Should().Contain(expected); + } + finally + { + Directory.SetCurrentDirectory(currentDirectory); + } + } + + [Fact] + public void CompletesNugetPackageVersions() + { + NuGetPackageDownloader.CliCompletionsTimeout = TimeSpan.FromDays(1); + var testAsset = _testAssetsManager.CopyTestAsset("NugetCompletion").WithSource(); + + string knownPackage = "Newtonsoft.Json"; + string knownVersion = "13.0.1"; // not exhaustive + var reporter = new BufferedReporter(); + var currentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(testAsset.Path); + CompleteCommand.RunWithReporter(GetArguments($"dotnet add package {knownPackage} --version $"), reporter).Should().Be(0); + reporter.Lines.Should().Contain(knownVersion); + } + finally + { + Directory.SetCurrentDirectory(currentDirectory); + } + } + + [Fact] + public void CompletesNugetPackageVersionsWithStem() + { + NuGetPackageDownloader.CliCompletionsTimeout = TimeSpan.FromDays(1); + var testAsset = _testAssetsManager.CopyTestAsset("NugetCompletion").WithSource(); + + string knownPackage = "Newtonsoft.Json"; + string knownVersion = "13.0"; // not exhaustive + string[] expectedVersions = ["13.0.1", "13.0.2", "13.0.3"]; // not exhaustive + var reporter = new BufferedReporter(); + var currentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(testAsset.Path); + CompleteCommand.RunWithReporter(GetArguments($"dotnet add package {knownPackage} --version {knownVersion}$"), reporter).Should().Be(0); + reporter.Lines.Should().Contain(expectedVersions); + // by default only stable versions should be shown + reporter.Lines.Should().AllSatisfy(v => v.Should().NotContain("-")); + + } + finally + { + Directory.SetCurrentDirectory(currentDirectory); + } + } + + [Fact] + public void CompletesNugetPackageVersionsWithPrereleaseVersionsWhenSpecified() + { + NuGetPackageDownloader.CliCompletionsTimeout = TimeSpan.FromDays(1); + var testAsset = _testAssetsManager.CopyTestAsset("NugetCompletion").WithSource(); + + string knownPackage = "Spectre.Console"; + string knownVersion = "0.49.1"; + string[] expectedVersions = ["0.49.1", "0.49.1-preview.0.2", "0.49.1-preview.0.5"]; // exhaustive for this specific version + var reporter = new BufferedReporter(); + var currentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(testAsset.Path); + CompleteCommand.RunWithReporter(GetArguments($"dotnet add package {knownPackage} --prerelease --version {knownVersion}$"), reporter).Should().Be(0); + reporter.Lines.Should().Equal(expectedVersions); + } + finally + { + Directory.SetCurrentDirectory(currentDirectory); + } + } + /// /// Converts command annotated with dollar sign($) into string array with "--position" option pointing at dollar sign location. ///