winget-cli

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit ce587d46e0ae12ff648d036600ac5c4dd3a76405
parent ab355842fd34166c660c0d96598a19a34954a743
Author: JohnMcPMS <johnmcp@microsoft.com>
Date:   Mon,  3 Aug 2020 11:50:17 -0700

Enable command line completion with complete command (#521)


Diffstat:
Adoc/Completion.md | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/AppInstallerCLICore.vcxproj | 8++++++++
Msrc/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters | 24++++++++++++++++++++++++
Asrc/AppInstallerCLICore/ChannelStreams.cpp | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/AppInstallerCLICore/ChannelStreams.h | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Command.cpp | 455+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/AppInstallerCLICore/Command.h | 7+++++--
Asrc/AppInstallerCLICore/Commands/CompleteCommand.cpp | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/AppInstallerCLICore/Commands/CompleteCommand.h | 24++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Commands/InstallCommand.cpp | 29+++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Commands/InstallCommand.h | 2++
Msrc/AppInstallerCLICore/Commands/RootCommand.cpp | 2++
Msrc/AppInstallerCLICore/Commands/SearchCommand.cpp | 24++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Commands/SearchCommand.h | 8+++++---
Msrc/AppInstallerCLICore/Commands/ShowCommand.cpp | 7+++++++
Msrc/AppInstallerCLICore/Commands/ShowCommand.h | 8+++++---
Msrc/AppInstallerCLICore/Commands/SourceCommand.cpp | 37+++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Commands/SourceCommand.h | 56++++++++++++++++++++++++++++++++------------------------
Asrc/AppInstallerCLICore/CompletionData.cpp | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/AppInstallerCLICore/CompletionData.h | 32++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Core.cpp | 9++++++---
Msrc/AppInstallerCLICore/ExecutionArgs.h | 10++++++++++
Msrc/AppInstallerCLICore/ExecutionContext.cpp | 12+++++++++++-
Msrc/AppInstallerCLICore/ExecutionContext.h | 19++++++++++++++++---
Msrc/AppInstallerCLICore/ExecutionReporter.cpp | 109+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/AppInstallerCLICore/ExecutionReporter.h | 127++++++++++++++++++++++++++-----------------------------------------------------
Msrc/AppInstallerCLICore/Invocation.h | 5+++--
Msrc/AppInstallerCLICore/Resources.h | 5+++++
Msrc/AppInstallerCLICore/VTSupport.cpp | 73++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/AppInstallerCLICore/VTSupport.h | 20+++++++++++++++-----
Asrc/AppInstallerCLICore/Workflows/CompletionFlow.cpp | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/AppInstallerCLICore/Workflows/CompletionFlow.h | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Workflows/SourceFlow.h | 1+
Msrc/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 41+++++++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLICore/Workflows/WorkflowBase.h | 33+++++++++++++++++++++++++++++++--
Msrc/AppInstallerCLICore/pch.h | 2++
Msrc/AppInstallerCLIE2ETests/SetUpFixture.cs | 8++++----
Msrc/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj | 1+
Asrc/AppInstallerCLIPackage/Register-WingetdevAutoComplete.ps1 | 10++++++++++
Msrc/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw | 15+++++++++++++++
Msrc/AppInstallerCLITests/AppInstallerCLITests.vcxproj | 1+
Msrc/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters | 3+++
Msrc/AppInstallerCLITests/Command.cpp | 27++++++++++++++++++++++++++-
Asrc/AppInstallerCLITests/Completion.cpp | 549+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCLITests/SQLiteIndex.cpp | 18++++++++++++++++++
Msrc/AppInstallerCLITests/Strings.cpp | 14++++++++++++++
Msrc/AppInstallerCLITests/WorkFlow.cpp | 32++++++++++++++++----------------
Msrc/AppInstallerCommonCore/AppInstallerLogging.cpp | 6++++++
Msrc/AppInstallerCommonCore/AppInstallerStrings.cpp | 62++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/AppInstallerCommonCore/AppInstallerTelemetry.cpp | 53+++++++++++++++++++++++++++++++++++++----------------
Msrc/AppInstallerCommonCore/Downloader.cpp | 6+++---
Msrc/AppInstallerCommonCore/FileLogger.cpp | 34+++++++++++++++++++++++++++++++---
Msrc/AppInstallerCommonCore/Public/AppInstallerErrors.h | 1+
Msrc/AppInstallerCommonCore/Public/AppInstallerFileLogger.h | 3+++
Msrc/AppInstallerCommonCore/Public/AppInstallerLanguageUtilities.h | 4++--
Msrc/AppInstallerCommonCore/Public/AppInstallerLogging.h | 3+++
Msrc/AppInstallerCommonCore/Public/AppInstallerStrings.h | 3+++
Msrc/AppInstallerCommonCore/Public/AppInstallerTelemetry.h | 19+++++++++++++++++++
Msrc/AppInstallerCommonCore/Public/winget/LocIndependent.h | 1+
Msrc/AppInstallerCommonCore/Runtime.cpp | 6+++---
Msrc/AppInstallerRepositoryCore/Microsoft/Schema/1_0/Interface.cpp | 2++
Msrc/AppInstallerRepositoryCore/Microsoft/Schema/1_0/SearchResultsTable.cpp | 24+++++++++++++++++-------
Msrc/AppInstallerRepositoryCore/Public/AppInstallerRepositorySearch.h | 3+++
63 files changed, 2506 insertions(+), 404 deletions(-)

diff --git a/doc/Completion.md b/doc/Completion.md @@ -0,0 +1,58 @@ +# WinGet Command Line Tab Completion + +WinGet offers a `complete` command that can be leveraged by your shell to provide context sensitive tab completion. It allows for completion of command names, argument names, and argument values, dependent on the current command line state. + +> Note, this feature has not yet been released. Hold tight, it is coming soon! + +## Examples + +> These examples assume that the tab completion in your shell works similar to PowerShell; repeated presses of tab (`⇥`) will result in cycling through the possible values. + +Input | Result | Reason +--- | --- | --- +`winget ⇥` | `winget install` | `install` is the first command below the root +`winget sh⇥` | `winget show` | `show` is the first command that starts with `sh` +`winget source l⇥` | `winget source list` | `list` is the first sub-command of source that starts with `l` +`winget -⇥` | `winget --version` | `--version` is the first argument defined for the root +`winget install power⇥` | `winget install "Power Toys"` | `"Power Toys"` is the first package whose Id, Name, or Moniker starts with `power` +`winget install "Power Toys" --version ⇥` | `winget install "Power Toys" --version 0.19.2` | `0.19.2` is the highest version of Power Toys at the time of writing + +## PowerShell + +You can add the argument completer to your `$PROFILE`, which will enable it in all subsequent PowerShell sessions. +For more information, see [How to create your profile](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles#how-to-create-a-profile) and [Profiles and execution policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles#profiles-and-execution-policy). + +Here is the PowerShell command to add to your `$PROFILE`: + +```PowerShell +Register-ArgumentCompleter -Native -CommandName winget -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + [Console]::InputEncoding = [Console]::OutputEncoding = $OutputEncoding = [System.Text.Utf8Encoding]::new() + $Local:word = $wordToComplete.Replace('"', '""') + $Local:ast = $commandAst.ToString().Replace('"', '""') + winget complete --word="$Local:word" --commandline "$Local:ast" --position $cursorPosition | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} +``` + +## Command Reference + +The complete command takes 3 required arguments: + +Argument | Description +--- | --- +`--word` | The current word that is being completed; the token that the cursor is located within. Can be empty to indicate no current value at the cursor, but if provided, it must appear as a substring in the command line. +`--commandline` | The entire current command line, including `winget`. See the examples above; everything but the tab character (`⇥`) should be provided to this argument. +`--position` | The current position of the cursor in the command line. Can be greater than the length of the command line string to indicate at the end. + +When a word value is provided, the completion operates in replacement mode. It will suggest completions that would fit correctly at this location that also start with the given word value. + +When a word value is not provided (an empty value is provided for word, ex. `--word=`), the completion operates in insertion mode. It will suggest completions that would fit as a new value in the cursor's location. + +Based on the arguments, the completions suggested can be one of: +1. A sub command :: The cursor is located just after a command and there are sub commands available. +2. An argument specifier :: The cursor is not positioned after an argument specifier that expects a value, and there are arguments available. +3. An argument value :: The cursor is positioned after an argument specifier that expects a value, or a positional argument is expected. + +After evaluating all of these cases, the potential completions are output, one on each line. If the completion string contains a space, it is wrapped in quotations.+ \ No newline at end of file diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -173,7 +173,9 @@ </ItemDefinitionGroup> <ItemGroup> <ClInclude Include="Argument.h" /> + <ClInclude Include="ChannelStreams.h" /> <ClInclude Include="Command.h" /> + <ClInclude Include="Commands\CompleteCommand.h" /> <ClInclude Include="Commands\ExperimentalCommand.h" /> <ClInclude Include="Commands\FeaturesCommand.h" /> <ClInclude Include="Commands\HashCommand.h" /> @@ -184,6 +186,7 @@ <ClInclude Include="Commands\SourceCommand.h" /> <ClInclude Include="Commands\ValidateCommand.h" /> <ClInclude Include="Commands\SettingsCommand.h" /> + <ClInclude Include="CompletionData.h" /> <ClInclude Include="ExecutionArgs.h" /> <ClInclude Include="ExecutionContext.h" /> <ClInclude Include="ExecutionProgress.h" /> @@ -195,6 +198,7 @@ <ClInclude Include="Search\Search.h" /> <ClInclude Include="TableOutput.h" /> <ClInclude Include="VTSupport.h" /> + <ClInclude Include="Workflows\CompletionFlow.h" /> <ClInclude Include="Workflows\ShellExecuteInstallerHandler.h" /> <ClInclude Include="Workflows\InstallFlow.h" /> <ClInclude Include="Workflows\ManifestComparator.h" /> @@ -204,7 +208,9 @@ </ItemGroup> <ItemGroup> <ClCompile Include="Argument.cpp" /> + <ClCompile Include="ChannelStreams.cpp" /> <ClCompile Include="Command.cpp" /> + <ClCompile Include="Commands\CompleteCommand.cpp" /> <ClCompile Include="Commands\ExperimentalCommand.cpp" /> <ClCompile Include="Commands\FeaturesCommand.cpp" /> <ClCompile Include="Commands\HashCommand.cpp" /> @@ -215,6 +221,7 @@ <ClCompile Include="Commands\SourceCommand.cpp" /> <ClCompile Include="Commands\ValidateCommand.cpp" /> <ClCompile Include="Commands\SettingsCommand.cpp" /> + <ClCompile Include="CompletionData.cpp" /> <ClCompile Include="Core.cpp" /> <ClCompile Include="ExecutionContext.cpp" /> <ClCompile Include="ExecutionProgress.cpp" /> @@ -224,6 +231,7 @@ </ClCompile> <ClCompile Include="Resources.cpp" /> <ClCompile Include="VTSupport.cpp" /> + <ClCompile Include="Workflows\CompletionFlow.cpp" /> <ClCompile Include="Workflows\ShellExecuteInstallerHandler.cpp" /> <ClCompile Include="Workflows\InstallFlow.cpp" /> <ClCompile Include="Workflows\ManifestComparator.cpp" /> diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -111,6 +111,18 @@ <ClInclude Include="Commands\FeaturesCommand.h"> <Filter>Commands</Filter> </ClInclude> + <ClInclude Include="Commands\CompleteCommand.h"> + <Filter>Commands</Filter> + </ClInclude> + <ClInclude Include="CompletionData.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="Workflows\CompletionFlow.h"> + <Filter>Workflows</Filter> + </ClInclude> + <ClInclude Include="ChannelStreams.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="pch.cpp"> @@ -188,6 +200,18 @@ <ClCompile Include="Commands\FeaturesCommand.cpp"> <Filter>Commands</Filter> </ClCompile> + <ClCompile Include="Commands\CompleteCommand.cpp"> + <Filter>Commands</Filter> + </ClCompile> + <ClCompile Include="CompletionData.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="Workflows\CompletionFlow.cpp"> + <Filter>Workflows</Filter> + </ClCompile> + <ClCompile Include="ChannelStreams.cpp"> + <Filter>Source Files</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <None Include="PropertySheet.props" /> diff --git a/src/AppInstallerCLICore/ChannelStreams.cpp b/src/AppInstallerCLICore/ChannelStreams.cpp @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "ChannelStreams.h" + + +namespace AppInstaller::CLI::Execution +{ + using namespace Settings; + using namespace VirtualTerminal; + + BaseStream::BaseStream(std::ostream& out, bool enabled, bool VTEnabled) : + m_out(out), m_enabled(enabled), m_VTEnabled(VTEnabled) {} + + BaseStream& BaseStream::operator<<(std::ostream& (__cdecl* f)(std::ostream&)) + { + if (m_enabled) + { + f(m_out); + } + return *this; + } + + BaseStream& BaseStream::operator<<(const Sequence& sequence) + { + if (m_enabled && m_VTEnabled) + { + m_out << sequence; + } + return *this; + } + + BaseStream& BaseStream::operator<<(const ConstructedSequence& sequence) + { + if (m_enabled && m_VTEnabled) + { + m_out << sequence; + } + return *this; + } + + OutputStream::OutputStream(std::ostream& out, bool enabled, bool VTEnabled) : + m_out(out, enabled, VTEnabled) {} + + void OutputStream::AddFormat(const Sequence& sequence) + { + m_format.Append(sequence); + } + + void OutputStream::ApplyFormat() + { + // Only apply format if m_applyFormatAtOne == 1 coming into this function. + if (m_applyFormatAtOne) + { + if (!--m_applyFormatAtOne) + { + m_out << m_format; + } + } + } + + OutputStream& OutputStream::operator<<(std::ostream& (__cdecl* f)(std::ostream&)) + { + m_out << f; + return *this; + } + + OutputStream& OutputStream::operator<<(const Sequence& sequence) + { + m_out << sequence; + // An incoming sequence will be valid for 1 "standard" output after this one. + // We set this to 2 to make that happen, because when it is 1, we will output + // the format for the current OutputStream. + m_applyFormatAtOne = 2; + return *this; + } + + OutputStream& OutputStream::operator<<(const ConstructedSequence& sequence) + { + m_out << sequence; + // An incoming sequence will be valid for 1 "standard" output after this one. + // We set this to 2 to make that happen, because when it is 1, we will output + // the format for the current OutputStream. + m_applyFormatAtOne = 2; + return *this; + } + + NoVTStream::NoVTStream(std::ostream& out, bool enabled) : + m_out(out, enabled, false) {} + + NoVTStream& NoVTStream::operator<<(std::ostream& (__cdecl* f)(std::ostream&)) + { + m_out << f; + return *this; + } +} diff --git a/src/AppInstallerCLICore/ChannelStreams.h b/src/AppInstallerCLICore/ChannelStreams.h @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "ExecutionProgress.h" +#include "Resources.h" +#include "VTSupport.h" +#include <winget/LocIndependent.h> + +#include <ostream> +#include <string> + + +namespace AppInstaller::CLI::Execution +{ + namespace details + { + // List of approved types for output, others are potentially not localized. + template <typename T> + struct IsApprovedForOutput + { + static constexpr bool value = false; + }; + +#define WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(_t_) \ + template <> \ + struct IsApprovedForOutput<_t_> \ + { \ + static constexpr bool value = true; \ + } + + // It is assumed that single char values need not be localized, as they are matched + // ordinally or they are punctuation / other. + WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(char); + // Localized strings (and from an Id for one for convenience). + WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Resource::StringId); + WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Resource::LocString); + // Strings explicitly declared as localization independent. + WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Utility::LocIndView); + WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Utility::LocIndString); + // Normalized strings come from user data and should therefore already by localized + // by how they are chosen (or there is no localized version). + WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Utility::NormalizedString); + } + + // The base stream for all channels. + struct BaseStream + { + BaseStream(std::ostream& out, bool enabled, bool VTEnabled); + + template <typename T> + BaseStream& operator<<(const T& t) + { + if (m_enabled) + { + m_out << t; + } + return *this; + } + + BaseStream& operator<<(std::ostream& (__cdecl* f)(std::ostream&)); + BaseStream& operator<<(const VirtualTerminal::Sequence& sequence); + BaseStream& operator<<(const VirtualTerminal::ConstructedSequence& sequence); + + private: + std::ostream& m_out; + bool m_enabled; + bool m_VTEnabled; + }; + + // Holds output formatting information. + struct OutputStream + { + OutputStream(std::ostream& out, bool enabled, bool VTEnabled); + + // Adds a format to the current value. + void AddFormat(const VirtualTerminal::Sequence& sequence); + + template <typename T> + OutputStream& operator<<(const T& t) + { + // You've found your way here because you tried to output a type that may not localized. + // In order to ensure that all output is localized, only the types with specializations of + // details::IsApprovedForOutput above can be output. + // * If your string is a simple message, it should be put in + // /src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw + // and referenced in /src/AppInstallerCLICore/Resources.h. Then either output the + // Resource::StringId, or load it manually and output the Resource::LocString. + // * If your string is *definitely* localization independent, you can tag it as such with + // the Utility::LocInd(View/String) types. + // * If your string came from outside of the source code, it is best to store it in a + // Utility::NormalizedString so that it has a normalized representation. This also + // informs the output that there is no localized version to use. + // TODO: Convert the rest of the code base and uncomment to enforce localization. + //static_assert(details::IsApprovedForOutput<std::decay_t<T>>::value, "This type may not be localized, see comment for more information"); + ApplyFormat(); + m_out << t; + return *this; + } + + OutputStream& operator<<(std::ostream& (__cdecl* f)(std::ostream&)); + OutputStream& operator<<(const VirtualTerminal::Sequence& sequence); + OutputStream& operator<<(const VirtualTerminal::ConstructedSequence& sequence); + + private: + // Applies the format for the stream. + void ApplyFormat(); + + BaseStream m_out; + size_t m_applyFormatAtOne = 1; + VirtualTerminal::ConstructedSequence m_format; + }; + + // Does not allow VT at all. + struct NoVTStream + { + NoVTStream(std::ostream& out, bool enabled); + + template <typename T> + NoVTStream& operator<<(const T& t) + { + m_out << t; + return *this; + } + + NoVTStream& operator<<(std::ostream& (__cdecl* f)(std::ostream&)); + NoVTStream& operator<<(const VirtualTerminal::Sequence& sequence) = delete; + NoVTStream& operator<<(const VirtualTerminal::ConstructedSequence& sequence) = delete; + + private: + BaseStream m_out; + }; +} diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp @@ -279,6 +279,129 @@ namespace AppInstaller::CLI throw CommandException(Resource::String::UnrecognizedCommand, *itr); } + // The argument parsing state machine. + // It is broken out to enable completion to process arguments, ignore errors, + // and determine the likely state of the word to be completed. + struct ParseArgumentsStateMachine + { + ParseArgumentsStateMachine(Invocation& inv, Execution::Args& execArgs, std::vector<CLI::Argument> arguments); + + ParseArgumentsStateMachine(const ParseArgumentsStateMachine&) = delete; + ParseArgumentsStateMachine& operator=(const ParseArgumentsStateMachine&) = delete; + + ParseArgumentsStateMachine(ParseArgumentsStateMachine&&) = default; + ParseArgumentsStateMachine& operator=(ParseArgumentsStateMachine&&) = default; + + // Processes the next argument from the invocation. + // Returns true if there was an argument to process; + // returns false if there were none. + bool Step(); + + // Throws if there was an error during the prior step. + void ThrowIfError() const; + + // The current state of the state machine. + // An empty state indicates that the next argument can be anything. + struct State + { + State() = default; + State(Execution::Args::Type type, std::string_view arg) : m_type(type), m_arg(arg) {} + State(CommandException ce) : m_exception(std::move(ce)) {} + + // If set, indicates that the next argument is a value for this type. + const std::optional<Execution::Args::Type>& Type() const { return m_type; } + + // The actual argument string associated with Type. + const std::string& Arg() const { return m_arg; } + + // If set, indicates that the last argument produced an error. + const std::optional<CommandException>& Exception() const { return m_exception; } + + private: + std::optional<Execution::Args::Type> m_type; + std::string m_arg; + std::optional<CommandException> m_exception; + }; + + const State& GetState() const { return m_state; } + + bool OnlyPositionalRemain() const { return m_onlyPositionalArgumentsRemain; } + + // Gets the next positional argument, or nullptr if there is not one. + const CLI::Argument* NextPositional(); + + const std::vector<CLI::Argument>& Arguments() const { return m_arguments; } + + private: + State StepInternal(); + + void ProcessAdjoinedValue(Execution::Args::Type type, std::string_view value); + + Invocation& m_invocation; + Execution::Args& m_executionArgs; + std::vector<CLI::Argument> m_arguments; + + Invocation::iterator m_invocationItr; + std::vector<CLI::Argument>::iterator m_positionalSearchItr; + bool m_onlyPositionalArgumentsRemain = false; + + State m_state; + }; + + ParseArgumentsStateMachine::ParseArgumentsStateMachine(Invocation& inv, Execution::Args& execArgs, std::vector<CLI::Argument> arguments) : + m_invocation(inv), + m_executionArgs(execArgs), + m_arguments(std::move(arguments)), + m_invocationItr(m_invocation.begin()), + m_positionalSearchItr(m_arguments.begin()) + { + } + + bool ParseArgumentsStateMachine::Step() + { + if (m_invocationItr == m_invocation.end()) + { + return false; + } + + m_state = StepInternal(); + return true; + } + + void ParseArgumentsStateMachine::ThrowIfError() const + { + if (m_state.Exception()) + { + throw m_state.Exception().value(); + } + // If the next argument was to be a value, but none was provided, convert it to an exception. + else if (m_state.Type() && m_invocationItr == m_invocation.end()) + { + throw CommandException(Resource::String::MissingArgumentError, m_state.Arg()); + } + } + + const CLI::Argument* ParseArgumentsStateMachine::NextPositional() + { + // Find the next appropriate positional arg if the current itr isn't one or has hit its limit. + if (m_positionalSearchItr != m_arguments.end() && + (m_positionalSearchItr->Type() != ArgumentType::Positional || m_executionArgs.GetCount(m_positionalSearchItr->ExecArgType()) == m_positionalSearchItr->Limit())) + { + do + { + ++m_positionalSearchItr; + } + while (m_positionalSearchItr != m_arguments.end() && m_positionalSearchItr->Type() != ArgumentType::Positional); + } + + if (m_positionalSearchItr == m_arguments.end()) + { + return nullptr; + } + + return &*m_positionalSearchItr; + } + // Parse arguments as such: // 1. If argument starts with a single -, only the single character alias is considered. // a. If the named argument alias (a) needs a VALUE, it can be provided in these ways: @@ -292,156 +415,166 @@ namespace AppInstaller::CLI // --arg VALUE // 3. If the argument does not start with any -, it is considered the next positional argument. // 4. If the argument is only a double --, all further arguments are only considered as positional. - void Command::ParseArguments(Invocation& inv, Execution::Args& execArgs) const + ParseArgumentsStateMachine::State ParseArgumentsStateMachine::StepInternal() { - auto definedArgs = GetArguments(); - Argument::GetCommon(definedArgs); - auto positionalSearchItr = definedArgs.begin(); - - // The user can override processing '-blah' as an argument name by passing '--'. - bool onlyPositionalArgsRemain = false; + std::string_view currArg = *m_invocationItr; + ++m_invocationItr; - for (auto incomingArgsItr = inv.begin(); incomingArgsItr != inv.end(); ++incomingArgsItr) + // If the previous step indicated a value was needed, set it and forget it. + if (m_state.Type()) { - const std::string& currArg = *incomingArgsItr; + m_executionArgs.AddArg(m_state.Type().value(), currArg); + return {}; + } - if (onlyPositionalArgsRemain || currArg.empty() || currArg[0] != APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR) + // This is a positional argument + if (m_onlyPositionalArgumentsRemain || currArg.empty() || currArg[0] != APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR) + { + const CLI::Argument* nextPositional = NextPositional(); + if (!nextPositional) { - // Positional argument, find the next appropriate one if the current itr isn't one or has hit its limit. - if (positionalSearchItr != definedArgs.end() && - (positionalSearchItr->Type() != ArgumentType::Positional || execArgs.GetCount(positionalSearchItr->ExecArgType()) == positionalSearchItr->Limit())) - { - for (++positionalSearchItr; positionalSearchItr != definedArgs.end() && positionalSearchItr->Type() != ArgumentType::Positional; ++positionalSearchItr); - } + return CommandException(Resource::String::ExtraPositionalError, currArg); + } - if (positionalSearchItr == definedArgs.end()) - { - throw CommandException(Resource::String::ExtraPositionalError, currArg); - } + m_executionArgs.AddArg(nextPositional->ExecArgType(), currArg); + } + // The currentArg must not be empty, and starts with a - + else if (currArg.length() == 1) + { + return CommandException(Resource::String::InvalidArgumentSpecifierError, currArg); + } + // Now it must be at least 2 chars + else if (currArg[1] != APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR) + { + // Parse the single character alias argument + char currChar = currArg[1]; - execArgs.AddArg(positionalSearchItr->ExecArgType(), currArg); - } - // The currentArg must not be empty, and starts with a - - else if (currArg.length() == 1) + auto itr = std::find_if(m_arguments.begin(), m_arguments.end(), [&](const Argument& arg) { return (currChar == arg.Alias()); }); + if (itr == m_arguments.end()) { - throw CommandException(Resource::String::InvalidArgumentSpecifierError, currArg); + return CommandException(Resource::String::InvalidAliasError, currArg); } - // Now it must be at least 2 chars - else if (currArg[1] != APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR) - { - // Parse the single character alias argument - char currChar = currArg[1]; - auto itr = std::find_if(definedArgs.begin(), definedArgs.end(), [&](const Argument& arg) { return (currChar == arg.Alias()); }); - if (itr == definedArgs.end()) - { - throw CommandException(Resource::String::InvalidAliasError, currArg); - } + if (itr->Type() == ArgumentType::Flag) + { + m_executionArgs.AddArg(itr->ExecArgType()); - if (itr->Type() == ArgumentType::Flag) + for (size_t i = 2; i < currArg.length(); ++i) { - execArgs.AddArg(itr->ExecArgType()); + currChar = currArg[i]; - for (size_t i = 2; i < currArg.length(); ++i) + auto itr2 = std::find_if(m_arguments.begin(), m_arguments.end(), [&](const Argument& arg) { return (currChar == arg.Alias()); }); + if (itr2 == m_arguments.end()) { - currChar = currArg[i]; - - auto itr2 = std::find_if(definedArgs.begin(), definedArgs.end(), [&](const Argument& arg) { return (currChar == arg.Alias()); }); - if (itr2 == definedArgs.end()) - { - throw CommandException(Resource::String::AdjoinedNotFoundError, currArg); - } - else if (itr2->Type() != ArgumentType::Flag) - { - throw CommandException(Resource::String::AdjoinedNotFlagError, currArg); - } - else - { - execArgs.AddArg(itr2->ExecArgType()); - } + return CommandException(Resource::String::AdjoinedNotFoundError, currArg); } - } - else if (currArg.length() > 2) - { - if (currArg[2] == APPINSTALLER_CLI_ARGUMENT_SPLIT_CHAR) + else if (itr2->Type() != ArgumentType::Flag) { - execArgs.AddArg(itr->ExecArgType(), currArg.substr(3)); + return CommandException(Resource::String::AdjoinedNotFlagError, currArg); } else { - throw CommandException(Resource::String::SingleCharAfterDashError, currArg); + m_executionArgs.AddArg(itr2->ExecArgType()); } } + } + else if (currArg.length() > 2) + { + if (currArg[2] == APPINSTALLER_CLI_ARGUMENT_SPLIT_CHAR) + { + ProcessAdjoinedValue(itr->ExecArgType(), currArg.substr(3)); + } else { - ++incomingArgsItr; - if (incomingArgsItr == inv.end()) - { - throw CommandException(Resource::String::MissingArgumentError, currArg); - } - execArgs.AddArg(itr->ExecArgType(), *incomingArgsItr); + return CommandException(Resource::String::SingleCharAfterDashError, currArg); } } - // The currentArg is at least 2 chars, both of which are -- - else if (currArg.length() == 2) + else { - onlyPositionalArgsRemain = true; + return { itr->ExecArgType(), currArg }; } - // The currentArg is more than 2 chars, both of which are -- - else + } + // The currentArg is at least 2 chars, both of which are -- + else if (currArg.length() == 2) + { + m_onlyPositionalArgumentsRemain = true; + } + // The currentArg is more than 2 chars, both of which are -- + else + { + // This is an arg name, find it and process its value if needed. + // Skip the double arg identifier chars. + std::string_view argName = currArg.substr(2); + bool argFound = false; + + bool hasValue = false; + std::string_view argValue; + size_t splitChar = argName.find_first_of(APPINSTALLER_CLI_ARGUMENT_SPLIT_CHAR); + if (splitChar != std::string::npos) { - // This is an arg name, find it and process its value if needed. - // Skip the double arg identifier chars. - std::string argName = currArg.substr(2); - bool argFound = false; - - bool hasValue = false; - std::string argValue; - size_t splitChar = argName.find_first_of(APPINSTALLER_CLI_ARGUMENT_SPLIT_CHAR); - if (splitChar != std::string::npos) - { - hasValue = true; - argValue = argName.substr(splitChar + 1); - argName.resize(splitChar); - } + hasValue = true; + argValue = argName.substr(splitChar + 1); + argName = argName.substr(0, splitChar); + } - for (const auto& arg : definedArgs) + for (const auto& arg : m_arguments) + { + if (Utility::CaseInsensitiveEquals(argName, arg.Name())) { - if (Utility::CaseInsensitiveEquals(argName, arg.Name())) + if (arg.Type() == ArgumentType::Flag) { - if (arg.Type() == ArgumentType::Flag) - { - if (hasValue) - { - throw CommandException(Resource::String::FlagContainAdjoinedError, currArg); - } - - execArgs.AddArg(arg.ExecArgType()); - } - else if (hasValue) + if (hasValue) { - execArgs.AddArg(arg.ExecArgType(), std::move(argValue)); + return CommandException(Resource::String::FlagContainAdjoinedError, currArg); } - else - { - ++incomingArgsItr; - if (incomingArgsItr == inv.end()) - { - throw CommandException(Resource::String::MissingArgumentError, currArg); - } - execArgs.AddArg(arg.ExecArgType(), *incomingArgsItr); - } - argFound = true; - break; + + m_executionArgs.AddArg(arg.ExecArgType()); } + else if (hasValue) + { + ProcessAdjoinedValue(arg.ExecArgType(), argValue); + } + else + { + return { arg.ExecArgType(), currArg }; + } + argFound = true; + break; } + } - if (!argFound) - { - throw CommandException(Resource::String::InvalidNameError, *incomingArgsItr); - } + if (!argFound) + { + return CommandException(Resource::String::InvalidNameError, currArg); } } + + // If we get here, the next argument can be anything again. + return {}; + } + + void ParseArgumentsStateMachine::ProcessAdjoinedValue(Execution::Args::Type type, std::string_view value) + { + // If the adjoined value is wrapped in quotes, strip them off. + if (value.length() >= 2 && value[0] == '"' && value[value.length() - 1] == '"') + { + value = value.substr(1, value.length() - 2); + } + + m_executionArgs.AddArg(type, std::string{ value }); + } + + void Command::ParseArguments(Invocation& inv, Execution::Args& execArgs) const + { + auto definedArgs = GetArguments(); + Argument::GetCommon(definedArgs); + + ParseArgumentsStateMachine stateMachine{ inv, execArgs, std::move(definedArgs) }; + + while (stateMachine.Step()) + { + stateMachine.ThrowIfError(); + } } void Command::ValidateArguments(Execution::Args& execArgs) const @@ -455,6 +588,104 @@ namespace AppInstaller::CLI ValidateArgumentsInternal(execArgs); } + // Completion can produce one of several things if the completion context is appropriate: + // 1. Sub commands, if the context is immediately after this command. + // 2. Argument names, if a value is not expected. + // 3. Argument values, if one is expected. + void Command::Complete(Execution::Context& context) const + { + CompletionData& data = context.Get<Execution::Data::CompletionData>(); + const std::string& word = data.Word(); + + // The word we are to complete is directly after the command, thus it's sub-commands are potentials. + if (data.BeforeWord().begin() == data.BeforeWord().end()) + { + for (const auto& command : GetCommands()) + { + if (word.empty() || Utility::CaseInsensitiveStartsWith(command->Name(), word)) + { + context.Reporter.Completion() << command->Name() << std::endl; + } + } + } + + // Consume what remains, if any, of the preceding values to determine what type the word is. + auto definedArgs = GetArguments(); + Argument::GetCommon(definedArgs); + + ParseArgumentsStateMachine stateMachine{ data.BeforeWord(), context.Args, std::move(definedArgs) }; + + // We don't care if there are errors along the way, just do the best that can be done and try to + // complete whatever would be next if the bad strings were simply ignored. To do that we just spin + // through the state until we reach our word. + while (stateMachine.Step()); + + const auto& state = stateMachine.GetState(); + + // This means that anything is possible, so argument names are on the table. + if (!state.Type() && !stateMachine.OnlyPositionalRemain()) + { + // Use argument names if: + // 1. word is empty + // 2. word is just "-" + // 3. word starts with "--" + if (word.empty() || + word == APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_STRING || + Utility::CaseInsensitiveStartsWith(word, APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_STRING APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_STRING)) + { + for (const auto& arg : stateMachine.Arguments()) + { + if (word.length() <= 2 || Utility::CaseInsensitiveStartsWith(arg.Name(), word.substr(2))) + { + context.Reporter.Completion() << APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR << APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR << arg.Name() << std::endl; + } + } + } + // Use argument aliases if the word is already one; allow cycling through them. + else if (Utility::CaseInsensitiveStartsWith(word, APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_STRING) && word.length() == 2) + { + for (const auto& arg : stateMachine.Arguments()) + { + if (arg.Alias() != Argument::NoAlias) + { + context.Reporter.Completion() << APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR << arg.Alias() << std::endl; + } + } + } + } + + std::optional<Execution::Args::Type> typeToComplete = state.Type(); + + // We are not waiting on an argument value, so the next could be a positional if the incoming word is not an argument name. + // If there is one, offer to complete it. + if (!typeToComplete && (word.empty() || word[0] != APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR)) + { + const auto* nextPositional = stateMachine.NextPositional(); + if (nextPositional) + { + typeToComplete = nextPositional->ExecArgType(); + } + } + + // To enable more complete scenarios, also attempt to parse any arguments after the word to complete. + // This will allow these later values to affect the result of the completion (for instance, if a specific source is listed). + { + ParseArgumentsStateMachine afterWordStateMachine{ data.AfterWord(), context.Args, stateMachine.Arguments() }; + while (afterWordStateMachine.Step()); + } + + // Let the derived command take over supplying context sensitive argument value. + if (typeToComplete) + { + Complete(context, typeToComplete.value()); + } + } + + void Command::Complete(Execution::Context&, Execution::Args::Type) const + { + // Derived commands must suppy context sensitive argument values. + } + void Command::Execute(Execution::Context& context) const { AICLI_LOG(CLI, Info, << "Executing command: " << Name()); diff --git a/src/AppInstallerCLICore/Command.h b/src/AppInstallerCLICore/Command.h @@ -26,11 +26,11 @@ namespace AppInstaller::CLI CommandException(Resource::LocString message, std::string_view param) : m_message(std::move(message)), m_param(param) {} const Resource::LocString& Message() const { return m_message; } - const Utility::LocIndView Param() const { return m_param; } + const Utility::LocIndString Param() const { return m_param; } private: Resource::LocString m_message; - Utility::LocIndView m_param; + Utility::LocIndString m_param; }; struct Command @@ -83,6 +83,9 @@ namespace AppInstaller::CLI virtual void ParseArguments(Invocation& inv, Execution::Args& execArgs) const; virtual void ValidateArguments(Execution::Args& execArgs) const; + virtual void Complete(Execution::Context& context) const; + virtual void Complete(Execution::Context& context, Execution::Args::Type valueType) const; + virtual void Execute(Execution::Context& context) const; protected: diff --git a/src/AppInstallerCLICore/Commands/CompleteCommand.cpp b/src/AppInstallerCLICore/Commands/CompleteCommand.cpp @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "CompleteCommand.h" +#include "RootCommand.h" +#include "Resources.h" + +namespace AppInstaller::CLI +{ + using namespace std::string_view_literals; + using namespace Execution; + + std::vector<Argument> CompleteCommand::GetArguments() const + { + return { + Argument{ "word", Argument::NoAlias, Args::Type::Word, Resource::String::WordArgumentDescription, ArgumentType::Standard, Argument::Visibility::Example, true }, + Argument{ "commandline", Argument::NoAlias, Args::Type::CommandLine, Resource::String::CommandLineArgumentDescription, ArgumentType::Standard, Argument::Visibility::Example, true }, + Argument{ "position", Argument::NoAlias, Args::Type::Position, Resource::String::PositionArgumentDescription, ArgumentType::Standard, Argument::Visibility::Example, true }, + }; + } + + Resource::LocString CompleteCommand::ShortDescription() const + { + return { Resource::String::CompleteCommandShortDescription }; + } + + Resource::LocString CompleteCommand::LongDescription() const + { + return { Resource::String::CompleteCommandLongDescription }; + } + + std::string CompleteCommand::HelpLink() const + { + // TODO: Define me and point to the right location + return "https://aka.ms/winget-command-complete"; + } + + void CompleteCommand::ExecuteInternal(Execution::Context& context) const + { + try + { + CompletionData data{ + context.Args.GetArg(Args::Type::Word), + context.Args.GetArg(Args::Type::CommandLine), + context.Args.GetArg(Args::Type::Position) }; + + std::unique_ptr<Command> command = std::make_unique<RootCommand>(); + + std::unique_ptr<Command> subCommand = command->FindSubCommand(data.BeforeWord()); + while (subCommand) + { + command = std::move(subCommand); + subCommand = command->FindSubCommand(data.BeforeWord()); + } + + // Create a new Context to execute the Complete from + Context subContext = context.Clone(); + subContext.Reporter.SetChannel(Execution::Reporter::Channel::Completion); + subContext.Add<Data::CompletionData>(std::move(data)); + + // Disable all telemetry while doing a completion + Logging::DisableTelemetryScope disable; + + AICLI_LOG(CLI, Info, << "Complete handing off to command " << command->FullName()); + command->Complete(subContext); + } + catch (const CommandException& ce) + { + AICLI_LOG(CLI, Info, << "Error encountered during completion, ignoring: " << ce.Message()); + } + catch (...) + { + AICLI_LOG(CLI, Info, << "Error encountered during completion, ignoring..."); + } + } +} diff --git a/src/AppInstallerCLICore/Commands/CompleteCommand.h b/src/AppInstallerCLICore/Commands/CompleteCommand.h @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "Command.h" + +namespace AppInstaller::CLI +{ + // Command to enable tab completion scenarios, including allowing them to + // be context sensitive in their data output. + struct CompleteCommand final : public Command + { + CompleteCommand(std::string_view parent) : Command("complete", parent, Visibility::Hidden) {} + + std::vector<Argument> GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + std::string HelpLink() const override; + + protected: + void ExecuteInternal(Execution::Context& context) const override; + }; +} diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "InstallCommand.h" +#include "Workflows/CompletionFlow.h" #include "Workflows/InstallFlow.h" #include "Workflows/WorkflowBase.h" #include "Resources.h" @@ -48,6 +49,34 @@ namespace AppInstaller::CLI return { Resource::String::InstallCommandLongDescription }; } + void InstallCommand::Complete(Execution::Context& context, Execution::Args::Type valueType) const + { + switch (valueType) + { + case Execution::Args::Type::Query: + case Execution::Args::Type::Manifest: + case Execution::Args::Type::Id: + case Execution::Args::Type::Name: + case Execution::Args::Type::Moniker: + case Execution::Args::Type::Version: + case Execution::Args::Type::Channel: + case Execution::Args::Type::Source: + context << + Workflow::CompleteWithSingleSemanticsForValue(valueType); + break; + case Execution::Args::Type::Language: + // May well move to CompleteWithSingleSemanticsForValue, + // but for now output nothing. + context << + Workflow::CompleteWithEmptySet; + break; + case Execution::Args::Type::Log: + case Execution::Args::Type::InstallLocation: + // Intentionally output nothing to allow pass through to filesystem. + break; + } + } + std::string InstallCommand::HelpLink() const { return "https://aka.ms/winget-command-install"; diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.h b/src/AppInstallerCLICore/Commands/InstallCommand.h @@ -14,6 +14,8 @@ namespace AppInstaller::CLI Resource::LocString ShortDescription() const override; Resource::LocString LongDescription() const override; + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; + std::string HelpLink() const override; protected: diff --git a/src/AppInstallerCLICore/Commands/RootCommand.cpp b/src/AppInstallerCLICore/Commands/RootCommand.cpp @@ -12,6 +12,7 @@ #include "SettingsCommand.h" #include "FeaturesCommand.h" #include "ExperimentalCommand.h" +#include "CompleteCommand.h" #include "Resources.h" #include "TableOutput.h" @@ -32,6 +33,7 @@ namespace AppInstaller::CLI std::make_unique<SettingsCommand>(FullName()), std::make_unique<FeaturesCommand>(FullName()), std::make_unique<ExperimentalCommand>(FullName()), + std::make_unique<CompleteCommand>(FullName()), }); } diff --git a/src/AppInstallerCLICore/Commands/SearchCommand.cpp b/src/AppInstallerCLICore/Commands/SearchCommand.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "SearchCommand.h" +#include "Workflows/CompletionFlow.h" #include "Workflows/WorkflowBase.h" #include "Resources.h" @@ -35,6 +36,29 @@ namespace AppInstaller::CLI return { Resource::String::SearchCommandLongDescription }; } + void SearchCommand::Complete(Execution::Context& context, Execution::Args::Type valueType) const + { + switch (valueType) + { + case Execution::Args::Type::Query: + context << + Workflow::OpenSource << + Workflow::RequireCompletionWordNonEmpty << + Workflow::SearchSourceForManyCompletion << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Id: + case Execution::Args::Type::Name: + case Execution::Args::Type::Moniker: + case Execution::Args::Type::Tag: + case Execution::Args::Type::Command: + case Execution::Args::Type::Source: + context << + Workflow::CompleteWithSingleSemanticsForValue(valueType); + break; + } + } + std::string SearchCommand::HelpLink() const { return "https://aka.ms/winget-command-search"; diff --git a/src/AppInstallerCLICore/Commands/SearchCommand.h b/src/AppInstallerCLICore/Commands/SearchCommand.h @@ -9,10 +9,12 @@ namespace AppInstaller::CLI { SearchCommand(std::string_view parent) : Command("search", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; std::string HelpLink() const override; diff --git a/src/AppInstallerCLICore/Commands/ShowCommand.cpp b/src/AppInstallerCLICore/Commands/ShowCommand.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "ShowCommand.h" #include "Workflows/ShowFlow.h" +#include "Workflows/CompletionFlow.h" #include "Workflows/WorkflowBase.h" #include "Resources.h" @@ -34,6 +35,12 @@ namespace AppInstaller::CLI return { Resource::String::ShowCommandLongDescription }; } + void ShowCommand::Complete(Execution::Context& context, Execution::Args::Type valueType) const + { + context << + Workflow::CompleteWithSingleSemanticsForValue(valueType); + } + std::string ShowCommand::HelpLink() const { return "https://aka.ms/winget-command-show"; diff --git a/src/AppInstallerCLICore/Commands/ShowCommand.h b/src/AppInstallerCLICore/Commands/ShowCommand.h @@ -9,10 +9,12 @@ namespace AppInstaller::CLI { ShowCommand(std::string_view parent) : Command("show", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; std::string HelpLink() const override; diff --git a/src/AppInstallerCLICore/Commands/SourceCommand.cpp b/src/AppInstallerCLICore/Commands/SourceCommand.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "SourceCommand.h" +#include "Workflows/CompletionFlow.h" #include "Workflows/SourceFlow.h" #include "Workflows/WorkflowBase.h" #include "Resources.h" @@ -94,6 +95,15 @@ namespace AppInstaller::CLI return { Resource::String::SourceListCommandLongDescription }; } + void SourceListCommand::Complete(Context& context, Args::Type valueType) const + { + if (valueType == Args::Type::SourceName) + { + context << + Workflow::CompleteSourceName; + } + } + std::string SourceListCommand::HelpLink() const { return std::string{ s_SourceCommand_HelpLink }; @@ -123,6 +133,15 @@ namespace AppInstaller::CLI return { Resource::String::SourceUpdateCommandLongDescription }; } + void SourceUpdateCommand::Complete(Context& context, Args::Type valueType) const + { + if (valueType == Args::Type::SourceName) + { + context << + Workflow::CompleteSourceName; + } + } + std::string SourceUpdateCommand::HelpLink() const { return std::string{ s_SourceCommand_HelpLink }; @@ -152,6 +171,15 @@ namespace AppInstaller::CLI return { Resource::String::SourceRemoveCommandLongDescription }; } + void SourceRemoveCommand::Complete(Context& context, Args::Type valueType) const + { + if (valueType == Args::Type::SourceName) + { + context << + Workflow::CompleteSourceName; + } + } + std::string SourceRemoveCommand::HelpLink() const { return std::string{ s_SourceCommand_HelpLink }; @@ -183,6 +211,15 @@ namespace AppInstaller::CLI return { Resource::String::SourceResetCommandLongDescription }; } + void SourceResetCommand::Complete(Context& context, Args::Type valueType) const + { + if (valueType == Args::Type::SourceName) + { + context << + Workflow::CompleteSourceName; + } + } + std::string SourceResetCommand::HelpLink() const { return std::string{ s_SourceCommand_HelpLink }; diff --git a/src/AppInstallerCLICore/Commands/SourceCommand.h b/src/AppInstallerCLICore/Commands/SourceCommand.h @@ -9,89 +9,97 @@ namespace AppInstaller::CLI { SourceCommand(std::string_view parent) : Command("source", parent) {} - virtual std::vector<std::unique_ptr<Command>> GetCommands() const override; + std::vector<std::unique_ptr<Command>> GetCommands() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; std::string HelpLink() const override; protected: - virtual void ExecuteInternal(Execution::Context& context) const; + void ExecuteInternal(Execution::Context& context) const override; }; struct SourceAddCommand final : public Command { SourceAddCommand(std::string_view parent) : Command("add", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; std::string HelpLink() const override; protected: - virtual void ExecuteInternal(Execution::Context& context) const override; + void ExecuteInternal(Execution::Context& context) const override; }; struct SourceListCommand final : public Command { SourceListCommand(std::string_view parent) : Command("list", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; std::string HelpLink() const override; protected: - virtual void ExecuteInternal(Execution::Context& context) const override; + void ExecuteInternal(Execution::Context& context) const override; }; struct SourceUpdateCommand final : public Command { SourceUpdateCommand(std::string_view parent) : Command("update", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; std::string HelpLink() const override; protected: - virtual void ExecuteInternal(Execution::Context& context) const override; + void ExecuteInternal(Execution::Context& context) const override; }; struct SourceRemoveCommand final : public Command { SourceRemoveCommand(std::string_view parent) : Command("remove", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; std::string HelpLink() const override; protected: - virtual void ExecuteInternal(Execution::Context& context) const override; + void ExecuteInternal(Execution::Context& context) const override; }; struct SourceResetCommand final : public Command { SourceResetCommand(std::string_view parent) : Command("reset", parent) {} - virtual std::vector<Argument> GetArguments() const override; + std::vector<Argument> GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; - virtual Resource::LocString ShortDescription() const override; - virtual Resource::LocString LongDescription() const override; + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; std::string HelpLink() const override; protected: - virtual void ExecuteInternal(Execution::Context& context) const override; + void ExecuteInternal(Execution::Context& context) const override; }; } diff --git a/src/AppInstallerCLICore/CompletionData.cpp b/src/AppInstallerCLICore/CompletionData.cpp @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "CompletionData.h" +#include "Resources.h" + +namespace AppInstaller::CLI +{ + using namespace std::string_view_literals; + using namespace Utility::literals; + using namespace Settings; + + // Completion takes in the following values: + // Word :: The token from the command line that is being targeted for completion. + // This value may have quotes surrounding it, and will need to be removed in such a case. + // CommandLine :: The full command line that contains the word to be completed. + // This value has the fully quoted strings, as well as escaped quotations if needed. + // Position :: The position of the cursor within the command line. + // + // Completions here will not attempt to take exact cursor position into account; meaning if the cursor + // is in the middle of the word, it is not different than at the beginning or end. This functionality + // could be added later. + CompletionData::CompletionData(std::string_view word, std::string_view commandLine, std::string_view position) + { + m_word = word; + + AICLI_LOG(CLI, Info, << "Completing word '" << m_word << '\''); + + // Determine position as an integer + size_t cursor = wil::safe_cast<size_t>(std::stoull(std::string{ position })); + + AICLI_LOG(CLI, Info, << "Cursor position starts at '" << cursor << '\''); + + // First, move the cursor from the UTF-8 grapheme position to the UTF-8 byte position. + // This simplifies the rest of the code. + cursor = Utility::UTF8Substring(commandLine, 0, cursor).length(); + + AICLI_LOG(CLI, Info, << "Cursor position moved to '" << cursor << '\''); + + std::vector<std::string> argsBeforeWord; + std::vector<std::string> argsAfterWord; + + // If the word is empty, we must determine where the split is. We operate as PowerShell does; the cursor + // being at the front of a token results in an empty word and an insertion rather than a replacement. + // If the user put spaces at the front of the statement, this can lead to the position being out of sorts; + // PowerShell sends the cursor position, but does not include leading spaces in the AST output. If the + // user puts too many spaces at the front we will be unable to determine the true location. + if (m_word.empty()) + { + // The cursor is past the end, so everything is before the word. + if (cursor >= commandLine.length()) + { + // Move the position to the end in case it was extended past it. + ParseInto(commandLine, argsBeforeWord, true); + } + // The cursor is not past the end; ensure that the preceding character is whitespace or move the + // position back until it is. This is far from foolproof, but until we have evidence otherwise, + // very few users are likely to put any spaces at the front of their statements, let alone many. + else + { + for (; cursor > 0 && !std::isspace(commandLine[cursor - 1]); --cursor); + + AICLI_LOG(CLI, Info, << "Cursor position moved to '" << cursor << '\''); + + // If we actually hit the front of the string, something bad probably happened. + THROW_HR_IF(APPINSTALLER_CLI_ERROR_COMPLETE_INPUT_BAD, cursor == 0); + + ParseInto(commandLine.substr(0, cursor), argsBeforeWord, true); + ParseInto(commandLine.substr(cursor), argsAfterWord, false); + } + } + // If the word is not empty, the cursor is either in the middle of a token, or at the end of one. + // The value will be replaced, and we will remove it from the args here. + else + { + std::vector<std::string> allArgs; + ParseInto(commandLine, allArgs, true); + + // Find the word amongst the arguments + std::vector<size_t> wordIndeces; + for (size_t i = 0; i < allArgs.size(); ++i) + { + if (m_word == allArgs[i]) + { + wordIndeces.push_back(i); + } + } + + // If we didn't find a matching string, we probably made some bad assumptions. + THROW_HR_IF(APPINSTALLER_CLI_ERROR_COMPLETE_INPUT_BAD, wordIndeces.empty()); + + // If we find an exact match only once, we can just split on that. + size_t wordIndexForSplit = wordIndeces[0]; + + // If we found more than one match, we have to rely on the position to + // determine which argument is the word in question. + if (wordIndeces.size() > 1) + { + // Escape the word and search for it in the command line. + std::string escapedWord = m_word; + Utility::FindAndReplace(escapedWord, "\"", "\"\""); + + std::vector<size_t> escapedIndeces; + for (size_t offset = 0; offset < commandLine.length();) + { + size_t pos = commandLine.find(escapedWord, offset); + + if (pos == std::string::npos) + { + break; + } + + escapedIndeces.push_back(pos); + offset = pos + escapedWord.length(); + } + + // If these are out of sync we don't have much hope. + THROW_HR_IF(APPINSTALLER_CLI_ERROR_COMPLETE_INPUT_BAD, wordIndeces.size() != escapedIndeces.size()); + + // Find the closest one to the position. This can be fooled as above if there is + // leading whitespace in the statement. But it is the best we can do. + size_t indexToUse = std::numeric_limits<size_t>::max(); + size_t distanceToCursor = std::numeric_limits<size_t>::max(); + + for (size_t i = 0; i < escapedIndeces.size(); ++i) + { + size_t lowerBound = escapedIndeces[i]; + size_t upperBound = lowerBound + escapedWord.length(); + size_t distance = 0; + + // The cursor is square in the middle of this location, this is the one. + if (cursor > lowerBound && cursor <= upperBound) + { + indexToUse = i; + break; + } + else if (cursor <= lowerBound) + { + distance = lowerBound - cursor; + } + else // cursor > upperBound + { + distance = cursor - upperBound; + } + + if (distance < distanceToCursor) + { + indexToUse = i; + distanceToCursor = distance; + } + } + + // It really would be unexpected to not find a closest one. + THROW_HR_IF(APPINSTALLER_CLI_ERROR_COMPLETE_INPUT_BAD, indexToUse == std::numeric_limits<size_t>::max()); + + wordIndexForSplit = wordIndeces[indexToUse]; + } + + std::vector<std::string>* moveTarget = &argsBeforeWord; + for (size_t i = 0; i < allArgs.size(); ++i) + { + if (i == wordIndexForSplit) + { + // Intentionally leave the matched arg behind. + moveTarget = &argsAfterWord; + } + else + { + moveTarget->emplace_back(std::move(allArgs[i])); + } + } + } + + // Move the arguments into an Invocation for future use. + m_argsBeforeWord = std::make_unique<CLI::Invocation>(std::move(argsBeforeWord)); + m_argsAfterWord = std::make_unique<CLI::Invocation>(std::move(argsAfterWord)); + + AICLI_LOG(CLI, Info, << "Completion invoked for arguments:" << [&]() { + std::stringstream strstr; + for (const auto& arg : *m_argsBeforeWord) + { + strstr << " '" << arg << '\''; + } + if (m_word.empty()) + { + strstr << " << [insert] >> "; + } + else + { + strstr << " << [replace] '" << m_word << "' >> "; + } + for (const auto& arg : *m_argsAfterWord) + { + strstr << " '" << arg << '\''; + } + return strstr.str(); + }()); + } + + void CompletionData::ParseInto(std::string_view line, std::vector<std::string>& args, bool skipFirst) + { + std::wstring commandLineW = Utility::ConvertToUTF16(line); + int argc = 0; + wil::unique_hlocal_ptr<LPWSTR> argv{ CommandLineToArgvW(commandLineW.c_str(), &argc) }; + THROW_LAST_ERROR_IF_NULL(argv); + + for (int i = (skipFirst ? 1 : 0); i < argc; ++i) + { + args.emplace_back(Utility::ConvertToUTF8(argv.get()[i])); + } + } +} diff --git a/src/AppInstallerCLICore/CompletionData.h b/src/AppInstallerCLICore/CompletionData.h @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "ExecutionArgs.h" +#include "Invocation.h" + +#include <memory> +#include <string> +#include <string_view> +#include <vector> + + +namespace AppInstaller::CLI +{ + // Data created by CompleteCommand to be consumed by other commands in order + // to provide context sensitive results. + struct CompletionData + { + CompletionData(std::string_view word, std::string_view commandLine, std::string_view position); + + const std::string& Word() const { return m_word; } + Invocation& BeforeWord() const { return *m_argsBeforeWord; } + Invocation& AfterWord() const { return *m_argsAfterWord; } + + private: + static void ParseInto(std::string_view line, std::vector<std::string>& args, bool skipFirst); + + std::string m_word; + std::unique_ptr<CLI::Invocation> m_argsBeforeWord; + std::unique_ptr<CLI::Invocation> m_argsAfterWord; + }; +} diff --git a/src/AppInstallerCLICore/Core.cpp b/src/AppInstallerCLICore/Core.cpp @@ -44,17 +44,20 @@ namespace AppInstaller::CLI { init_apartment(); - // Set output to UTF8 - ConsoleOutputCPRestore utf8CP(CP_UTF8); - // Enable all logging for this phase; we will update once we have the arguments Logging::Log().EnableChannel(Logging::Channel::All); Logging::Log().SetLevel(Logging::Level::Verbose); Logging::AddFileLogger(); Logging::EnableWilFailureTelemetry(); + // Set output to UTF8 + ConsoleOutputCPRestore utf8CP(CP_UTF8); + Logging::Telemetry().LogStartup(); + // Initiate the background cleanup of the log file location. + Logging::BeginLogFileCleanup(); + Execution::Context context{ std::cout, std::cin }; context.EnableCtrlHandler(); diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h @@ -50,6 +50,11 @@ namespace AppInstaller::CLI::Execution //Validate Command ValidateManifest, + // Complete Command + Word, + CommandLine, + Position, + // Other Force, // Generic flag to enable a command to skip some check ListVersions, // Used in Show command to list all available versions of an app @@ -100,6 +105,11 @@ namespace AppInstaller::CLI::Execution m_parsedArgs[arg].emplace_back(std::move(value)); } + void AddArg(Type arg, std::string_view value) + { + m_parsedArgs[arg].emplace_back(value); + } + private: std::map<Type, std::vector<std::string>> m_parsedArgs; }; diff --git a/src/AppInstallerCLICore/ExecutionContext.cpp b/src/AppInstallerCLICore/ExecutionContext.cpp @@ -70,6 +70,11 @@ namespace AppInstaller::CLI::Execution } } + Context Context::Clone() + { + return { Reporter }; + } + void Context::EnableCtrlHandler(bool enabled) { SetCtrlHandlerContext(enabled ? this : nullptr); @@ -96,7 +101,7 @@ namespace AppInstaller::CLI::Execution } } - void Context::Terminate(HRESULT hr) + void Context::Terminate(HRESULT hr, std::string_view file, size_t line) { if (hr == APPINSTALLER_CLI_ERROR_CTRL_SIGNAL_RECEIVED) { @@ -113,6 +118,11 @@ namespace AppInstaller::CLI::Execution } } + if (!file.empty() && line) + { + Logging::Telemetry().LogCommandTermination(hr, file, line); + } + m_isTerminated = true; m_terminationHR = hr; } diff --git a/src/AppInstallerCLICore/ExecutionContext.h b/src/AppInstallerCLICore/ExecutionContext.h @@ -7,6 +7,7 @@ #include <Manifest/Manifest.h> #include "ExecutionReporter.h" #include "ExecutionArgs.h" +#include "CompletionData.h" #include <filesystem> #include <map> @@ -21,8 +22,7 @@ #define AICLI_TERMINATE_CONTEXT_ARGS(_context_,_hr_) \ do { \ HRESULT AICLI_TERMINATE_CONTEXT_ARGS_hr = _hr_; \ - ::AppInstaller::Logging::Telemetry().LogCommandTermination(AICLI_TERMINATE_CONTEXT_ARGS_hr, __FILE__, __LINE__); \ - _context_.Terminate(AICLI_TERMINATE_CONTEXT_ARGS_hr); \ + _context_.Terminate(AICLI_TERMINATE_CONTEXT_ARGS_hr, __FILE__, __LINE__); \ return; \ } while(0,0) @@ -51,6 +51,7 @@ namespace AppInstaller::CLI::Execution InstallerPath, LogPath, InstallerArgs, + CompletionData, Max }; @@ -116,6 +117,12 @@ namespace AppInstaller::CLI::Execution using value_t = std::string; }; + template <> + struct DataMapping<Data::CompletionData> + { + using value_t = CLI::CompletionData; + }; + // Used to deduce the DataVariant type; making a variant that includes std::monostate and all DataMapping types. template <size_t... I> inline auto Deduce(std::index_sequence<I...>) { return std::variant<std::monostate, DataMapping<static_cast<Data>(I)>::value_t...>{}; } @@ -142,6 +149,9 @@ namespace AppInstaller::CLI::Execution // The arguments given to execute with. Args Args; + // Creates a copy of this context as it was at construction. + Context Clone(); + // Enables reception of CTRL signals. // Only one context can be enabled to handle CTRL signals at a time. void EnableCtrlHandler(bool enabled = true); @@ -156,7 +166,7 @@ namespace AppInstaller::CLI::Execution HRESULT GetTerminationHR() const { return m_terminationHR; } // Set the context to the terminated state. - void Terminate(HRESULT hr); + void Terminate(HRESULT hr, std::string_view file = {}, size_t line = {}); // Adds a value to the context data, or overwrites an existing entry. // This must be used to create the initial data entry, but Get can be used to modify. @@ -184,6 +194,9 @@ namespace AppInstaller::CLI::Execution #endif private: + // Clone the reporter for this constructor. + Context(Execution::Reporter& reporter) : Reporter(reporter, Execution::Reporter::clone_t{}) {} + DestructionToken m_disableCtrlHandlerOnExit = false; bool m_isTerminated = false; HRESULT m_terminationHR = S_OK; diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -18,60 +18,25 @@ namespace AppInstaller::CLI::Execution Reporter::Reporter(std::ostream& outStream, std::istream& inStream) : m_out(outStream), m_in(inStream), - m_consoleMode(), - m_progressBar(outStream, m_consoleMode.IsVTEnabled()), - m_spinner(outStream, m_consoleMode.IsVTEnabled()) + m_progressBar(std::in_place, outStream, IsVTEnabled()), + m_spinner(std::in_place, outStream, IsVTEnabled()) {} - Reporter::OutputStream::OutputStream(std::ostream& out, bool enableVT) : - m_out(out), m_isVTEnabled(enableVT) {} - - void Reporter::OutputStream::AddFormat(const Sequence& sequence) - { - m_format.append(sequence.Get()); - } - - void Reporter::OutputStream::ApplyFormat() - { - // Only apply format if m_applyFormatAtOne == 1 coming into this function. - if (m_isVTEnabled && m_applyFormatAtOne) - { - if (!--m_applyFormatAtOne) - { - m_out << m_format; - } - } - } - - Reporter::OutputStream& Reporter::OutputStream::operator<<(std::ostream& (__cdecl* f)(std::ostream&)) - { - f(m_out); - return *this; - } - - Reporter::OutputStream& Reporter::OutputStream::operator<<(const Sequence& sequence) - { - m_out << sequence; - // An incoming sequence will be valid for 1 "standard" output after this one. - // We set this to 2 to make that happen, because when it is 1, we will output - // the format for the current OutputStream. - m_applyFormatAtOne = 2; - return *this; - } - Reporter::~Reporter() { // The goal of this is to return output to its previous state. // For now, we assume this means "default". - if (m_consoleMode.IsVTEnabled()) - { - m_out << TextFormat::Default; - } + GetBasicOutputStream() << TextFormat::Default; } - Reporter::OutputStream Reporter::GetOutputStream(Level level) + Reporter::Reporter(const Reporter& other, clone_t) : + Reporter(other.m_out, other.m_in) { - OutputStream result(m_out, m_consoleMode.IsVTEnabled()); + } + + OutputStream Reporter::GetOutputStream(Level level) + { + OutputStream result = GetBasicOutputStream(); switch (level) { @@ -94,32 +59,61 @@ namespace AppInstaller::CLI::Execution return result; } + OutputStream Reporter::GetBasicOutputStream() + { + return { m_out, m_channel == Channel::Output, IsVTEnabled() }; + } + + void Reporter::SetChannel(Channel channel) + { + m_channel = channel; + + if (m_channel != Channel::Output) + { + // Disable progress for non-output channels + m_spinner.reset(); + m_progressBar.reset(); + } + } + void Reporter::SetStyle(VisualStyle style) { - m_spinner.SetStyle(style); - m_progressBar.SetStyle(style); + if (m_spinner) + { + m_spinner->SetStyle(style); + } + if (m_progressBar) + { + m_progressBar->SetStyle(style); + } if (style == VisualStyle::NoVT) { - m_consoleMode.DisableVT(); + m_isVTEnabled = false; } } void Reporter::ShowIndefiniteProgress(bool running) { - if (running) + if (m_spinner) { - m_spinner.ShowSpinner(); - } - else - { - m_spinner.StopSpinner(); + if (running) + { + m_spinner->ShowSpinner(); + } + else + { + m_spinner->StopSpinner(); + } } } void Reporter::OnProgress(uint64_t current, uint64_t maximum, ProgressType type) { ShowIndefiniteProgress(false); - m_progressBar.ShowProgress(current, maximum, type); + if (m_progressBar) + { + m_progressBar->ShowProgress(current, maximum, type); + } } void Reporter::SetProgressCallback(ProgressCallback* callback) @@ -139,4 +133,9 @@ namespace AppInstaller::CLI::Execution callback->Cancel(); } } + + bool Reporter::IsVTEnabled() const + { + return m_isVTEnabled && ConsoleModeRestore::Instance().IsVTEnabled(); + } } diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h @@ -2,6 +2,7 @@ // Licensed under the MIT License. #pragma once #include "ExecutionProgress.h" +#include "ChannelStreams.h" #include "Resources.h" #include "VTSupport.h" #include <AppInstallerProgress.h> @@ -12,6 +13,7 @@ #include <atomic> #include <iomanip> #include <istream> +#include <optional> #include <ostream> #include <string> @@ -20,40 +22,18 @@ namespace AppInstaller::CLI::Execution { #define WINGET_OSTREAM_FORMAT_HRESULT(hr) "0x" << std::hex << std::setw(8) << std::setfill('0') << hr - namespace details + // Reporter should be the central place to show workflow status to user. + struct Reporter : public IProgressSink { - // List of approved types for output, others are potentially not localized. - template <typename T> - struct IsApprovedForOutput + // The channel that the reporter is targeting. + // Based on commands/arguments, only one of these channels can be chosen. + enum class Channel { - static constexpr bool value = false; + Output, + Completion, }; -#define WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(_t_) \ - template <> \ - struct IsApprovedForOutput<_t_> \ - { \ - static constexpr bool value = true; \ - } - - // It is assumed that single char values need not be localized, as they are matched - // ordinally or they are punctuation / other. - WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(char); - // Localized strings (and from an Id for one for convenience). - WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Resource::StringId); - WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Resource::LocString); - // Strings explicitly declared as localization independent. - WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Utility::LocIndView); - WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Utility::LocIndString); - // Normalized strings come from user data and should therefore already by localized - // by how they are chosen (or there is no localized version). - WINGET_CREATE_ISAPPROVEDFOROUTPUT_SPECIALIZATION(Utility::NormalizedString); - } - - // Reporter should be the central place to show workflow status to user. - // Todo: need to implement actual console output to show progress bar, etc - struct Reporter : public IProgressSink - { + // The level for the Output channel. enum class Level { Verbose, @@ -63,51 +43,17 @@ namespace AppInstaller::CLI::Execution }; Reporter(std::ostream& outStream, std::istream& inStream); + Reporter(const Reporter&) = delete; + Reporter& operator=(const Reporter&) = delete; - ~Reporter(); + Reporter(Reporter&&) = default; + Reporter& operator=(Reporter&&) = default; - // Holds output formatting information. - struct OutputStream - { - OutputStream(std::ostream& out, bool enableVT); - - // Adds a format to the current value. - void AddFormat(const VirtualTerminal::Sequence& sequence); - - template <typename T> - OutputStream& operator<<(const T& t) - { - // You've found your way here because you tried to output a type that may not localized. - // In order to ensure that all output is localized, only the types with specializations of - // details::IsApprovedForOutput above can be output. - // * If your string is a simple message, it should be put in - // /src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw - // and referenced in /src/AppInstallerCLICore/Resources.h. Then either output the - // Resource::StringId, or load it manually and output the Resource::LocString. - // * If your string is *definitely* localization independent, you can tag it as such with - // the Utility::LocInd(View/String) types. - // * If your string came from outside of the source code, it is best to store it in a - // Utility::NormalizedString so that it has a normalized representation. This also - // informs the output that there is no localized version to use. - // TODO: Convert the rest of the code base and uncomment to enforce localization. - //static_assert(details::IsApprovedForOutput<std::decay_t<T>>::value, "This type may not be localized, see comment for more information"); - ApplyFormat(); - m_out << t; - return *this; - } - - OutputStream& operator<<(std::ostream& (__cdecl* f)(std::ostream&)); - OutputStream& operator<<(const VirtualTerminal::Sequence& sequence); - - private: - // Applies the format for the stream. - void ApplyFormat(); - - std::ostream& m_out; - bool m_isVTEnabled; - size_t m_applyFormatAtOne = 1; - std::string m_format; - }; + // Request that a clone be constructed from the given reporter. + struct clone_t {}; + Reporter(const Reporter& other, clone_t); + + ~Reporter(); // Get a stream for verbose output. OutputStream Verbose() { return GetOutputStream(Level::Verbose); } @@ -121,10 +67,17 @@ namespace AppInstaller::CLI::Execution // Get a stream for error output. OutputStream Error() { return GetOutputStream(Level::Error); } + // Get a stream for outputting completion words. + NoVTStream Completion() { return NoVTStream(m_out, m_channel == Channel::Completion); } + // Gets a stream for output of the given level. OutputStream GetOutputStream(Level level); - void EmptyLine() { m_out << std::endl; } + void EmptyLine() { GetBasicOutputStream() << std::endl; } + + // Sets the channel that will be reported to. + // Only do this once and as soon as the channel is determined. + void SetChannel(Channel channel); // Sets the visual style (mostly for progress currently) void SetStyle(AppInstaller::Settings::VisualStyle style); @@ -141,10 +94,7 @@ namespace AppInstaller::CLI::Execution template <typename F> auto ExecuteWithProgress(F&& f, bool hideProgressWhenDone = false) { - if (m_consoleMode.IsVTEnabled()) - { - m_out << VirtualTerminal::Cursor::Visibility::DisableShow; - } + GetBasicOutputStream() << VirtualTerminal::Cursor::Visibility::DisableShow; ProgressCallback callback(this); SetProgressCallback(&callback); @@ -154,12 +104,12 @@ namespace AppInstaller::CLI::Execution { SetProgressCallback(nullptr); ShowIndefiniteProgress(false); - m_progressBar.EndProgress(hideProgressWhenDone); - - if (m_consoleMode.IsVTEnabled()) + if (m_progressBar) { - m_out << VirtualTerminal::Cursor::Visibility::EnableShow; + m_progressBar->EndProgress(hideProgressWhenDone); } + + GetBasicOutputStream() << VirtualTerminal::Cursor::Visibility::EnableShow; }); return f(callback); } @@ -171,11 +121,18 @@ namespace AppInstaller::CLI::Execution void CancelInProgressTask(bool force); private: + // Gets whether VT is enabled for this reporter. + bool IsVTEnabled() const; + + // Gets a stream for output for internal use. + OutputStream GetBasicOutputStream(); + + Channel m_channel = Channel::Output; std::ostream& m_out; std::istream& m_in; - VirtualTerminal::ConsoleModeRestore m_consoleMode; - IndefiniteSpinner m_spinner; - ProgressBar m_progressBar; + bool m_isVTEnabled = true; + std::optional<IndefiniteSpinner> m_spinner; + std::optional<ProgressBar> m_progressBar; wil::srwlock m_progressCallbackLock; std::atomic<ProgressCallback*> m_progressCallback; }; diff --git a/src/AppInstallerCLICore/Invocation.h b/src/AppInstallerCLICore/Invocation.h @@ -23,8 +23,8 @@ namespace AppInstaller::CLI iterator operator--() { return { --m_arg, m_args }; } iterator operator--(int) { return { m_arg--, m_args }; } - bool operator==(const iterator& other) { return m_arg == other.m_arg; } - bool operator!=(const iterator& other) { return m_arg != other.m_arg; } + bool operator==(const iterator& other) const { return m_arg == other.m_arg; } + bool operator!=(const iterator& other) const { return m_arg != other.m_arg; } const std::string& operator*() const { return m_args[m_arg]; } const std::string* operator->() const { return &(m_args[m_arg]); } @@ -36,6 +36,7 @@ namespace AppInstaller::CLI std::vector<std::string>& m_args; }; + size_t size() const { return m_args.size(); } iterator begin() { return { m_currentFirstArg, m_args }; } iterator end() { return { m_args.size(), m_args }; } void consume(const iterator& i) { m_currentFirstArg = i.index() + 1; } diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h @@ -36,7 +36,10 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(ChannelArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(Command); WINGET_DEFINE_RESOURCE_STRINGID(CommandArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(CommandLineArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(CommandRequiresAdmin); + WINGET_DEFINE_RESOURCE_STRINGID(CompleteCommandLongDescription); + WINGET_DEFINE_RESOURCE_STRINGID(CompleteCommandShortDescription); WINGET_DEFINE_RESOURCE_STRINGID(CountArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(Done); WINGET_DEFINE_RESOURCE_STRINGID(ExactArgumentDescription); @@ -112,6 +115,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(OverrideArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(Package); WINGET_DEFINE_RESOURCE_STRINGID(PendingWorkError); + WINGET_DEFINE_RESOURCE_STRINGID(PositionArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(PreviewVersion); WINGET_DEFINE_RESOURCE_STRINGID(PrivacyStatement); WINGET_DEFINE_RESOURCE_STRINGID(QueryArgumentDescription); @@ -191,6 +195,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(VerifyFileSignedMsix); WINGET_DEFINE_RESOURCE_STRINGID(VersionArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(VersionsArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(WordArgumentDescription); }; // A localized string diff --git a/src/AppInstallerCLICore/VTSupport.cpp b/src/AppInstallerCLICore/VTSupport.cpp @@ -18,45 +18,41 @@ namespace AppInstaller::CLI::VirtualTerminal } } - ConsoleModeRestore::ConsoleModeRestore(bool enableVTProcessing) + ConsoleModeRestore::ConsoleModeRestore() { - if (enableVTProcessing) + // Set output mode to handle virtual terminal sequences + HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); + if (hOut == INVALID_HANDLE_VALUE) { - // Set output mode to handle virtual terminal sequences - HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); - if (hOut == INVALID_HANDLE_VALUE) - { - LOG_LAST_ERROR(); - } - else if (hOut == NULL) + LOG_LAST_ERROR(); + } + else if (hOut == NULL) + { + AICLI_LOG(CLI, Info, << "VT not enabled due to null output handle"); + } + else + { + if (!GetConsoleMode(hOut, &m_previousMode)) { - AICLI_LOG(CLI, Info, << "VT not enabled due to null output handle"); + // If the user redirects output, the handle will be invalid for this function. + // Don't log it in that case. + LOG_LAST_ERROR_IF(GetLastError() != ERROR_INVALID_HANDLE); } else { - if (!GetConsoleMode(hOut, &m_previousMode)) - { - // If the user redirects output, the handle will be invalid for this function. - // Don't log it in that case. - LOG_LAST_ERROR_IF(GetLastError() != ERROR_INVALID_HANDLE); - } - else + // Try to degrade in case DISABLE_NEWLINE_AUTO_RETURN isn't supported. + for (DWORD mode : { ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN, ENABLE_VIRTUAL_TERMINAL_PROCESSING}) { - // Try to degrade in case DISABLE_NEWLINE_AUTO_RETURN isn't supported. - for (DWORD mode : { ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN, ENABLE_VIRTUAL_TERMINAL_PROCESSING}) + DWORD outMode = m_previousMode | mode; + if (!SetConsoleMode(hOut, outMode)) + { + // Even if it is a different error, log it and try to carry on. + LOG_LAST_ERROR_IF(GetLastError() != STATUS_INVALID_PARAMETER); + } + else { - DWORD outMode = m_previousMode | mode; - if (!SetConsoleMode(hOut, outMode)) - { - // Even if it is a different error, log it and try to carry on. - LOG_LAST_ERROR_IF(GetLastError() != STATUS_INVALID_PARAMETER); - } - else - { - m_token = true; - m_isVTEnabled = true; - break; - } + m_token = true; + break; } } } @@ -72,6 +68,21 @@ namespace AppInstaller::CLI::VirtualTerminal } } + const ConsoleModeRestore& ConsoleModeRestore::Instance() + { + static ConsoleModeRestore s_instance; + return s_instance; + } + + void ConstructedSequence::Append(const Sequence& sequence) + { + if (sequence.Get()) + { + m_str += sequence.Get(); + Set(m_str); + } + } + // The escape character that begins all VT sequences #define AICLI_VT_ESCAPE "\x1b" diff --git a/src/AppInstallerCLICore/VTSupport.h b/src/AppInstallerCLICore/VTSupport.h @@ -12,8 +12,6 @@ namespace AppInstaller::CLI::VirtualTerminal // RAII class to enable VT support and restore the console mode. struct ConsoleModeRestore { - ConsoleModeRestore(bool enableVTProcessing = true); - ~ConsoleModeRestore(); ConsoleModeRestore(const ConsoleModeRestore&) = delete; @@ -22,12 +20,15 @@ namespace AppInstaller::CLI::VirtualTerminal ConsoleModeRestore(ConsoleModeRestore&&) = default; ConsoleModeRestore& operator=(ConsoleModeRestore&&) = default; - void DisableVT() { m_isVTEnabled = false; } + // Gets the singleton. + static const ConsoleModeRestore& Instance(); - bool IsVTEnabled() const { return m_isVTEnabled; } + // Returns true if VT support has been enabled for the console. + bool IsVTEnabled() const { return m_token; } private: - bool m_isVTEnabled = false; + ConsoleModeRestore(); + DestructionToken m_token = false; DWORD m_previousMode = 0; }; @@ -50,8 +51,17 @@ namespace AppInstaller::CLI::VirtualTerminal // A VT sequence that is constructed at runtime. struct ConstructedSequence : public Sequence { + ConstructedSequence() { Set(m_str); } explicit ConstructedSequence(std::string s) : m_str(std::move(s)) { Set(m_str); } + ConstructedSequence(const ConstructedSequence& other) : m_str(other.m_str) { Set(m_str); } + ConstructedSequence& operator=(const ConstructedSequence& other) { m_str = other.m_str; Set(m_str); } + + ConstructedSequence(ConstructedSequence&& other) : m_str(std::move(other.m_str)) { Set(m_str); } + ConstructedSequence& operator=(ConstructedSequence&& other) { m_str = std::move(other.m_str); Set(m_str); } + + void Append(const Sequence& sequence); + private: std::string m_str; }; diff --git a/src/AppInstallerCLICore/Workflows/CompletionFlow.cpp b/src/AppInstallerCLICore/Workflows/CompletionFlow.cpp @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "pch.h" +#include "CompletionFlow.h" + +namespace AppInstaller::CLI::Workflow +{ + using namespace AppInstaller::CLI::Execution; + using namespace AppInstaller::Utility::literals; + + namespace + { + // Outputs the completion string, wrapping it in quotes if needed. + void OutputCompletionString(Execution::NoVTStream& stream, std::string_view value) + { + if (value.find_first_of(' ') != std::string_view::npos) + { + stream << '"' << value << '"' << std::endl; + } + else + { + stream << value << std::endl; + } + } + } + + void CompleteSourceName(Execution::Context& context) + { + const std::string& word = context.Get<Data::CompletionData>().Word(); + auto stream = context.Reporter.Completion(); + + for (const auto& source : Repository::GetSources()) + { + if (word.empty() || Utility::CaseInsensitiveStartsWith(source.Name, word)) + { + OutputCompletionString(stream, source.Name); + } + } + } + + void RequireCompletionWordNonEmpty(Execution::Context& context) + { + if (context.Get<Data::CompletionData>().Word().empty()) + { + AICLI_LOG(CLI, Verbose, << "Completion word empty, cannot complete"); + AICLI_TERMINATE_CONTEXT(E_NOT_SET); + } + } + + void CompleteWithMatchedField(Execution::Context& context) + { + auto& searchResult = context.Get<Execution::Data::SearchResult>(); + auto stream = context.Reporter.Completion(); + + for (size_t i = 0; i < searchResult.Matches.size(); ++i) + { + if (searchResult.Matches[i].MatchCriteria.Value.empty()) + { + OutputCompletionString(stream, searchResult.Matches[i].Application->GetId()); + } + else + { + OutputCompletionString(stream, searchResult.Matches[i].MatchCriteria.Value); + } + } + } + + void CompleteWithSearchResultVersions(Execution::Context& context) + { + const std::string& word = context.Get<Data::CompletionData>().Word(); + auto stream = context.Reporter.Completion(); + + for (const auto& vc : context.Get<Execution::Data::SearchResult>().Matches[0].Application->GetVersions()) + { + std::string version = vc.GetVersion().ToString(); + if (word.empty() || Utility::CaseInsensitiveStartsWith(version, word)) + { + OutputCompletionString(stream, version); + } + } + } + + void CompleteWithSearchResultChannels(Execution::Context& context) + { + const std::string& word = context.Get<Data::CompletionData>().Word(); + auto stream = context.Reporter.Completion(); + + std::vector<std::string> channels; + + for (const auto& vc : context.Get<Execution::Data::SearchResult>().Matches[0].Application->GetVersions()) + { + std::string channel = vc.GetChannel().ToString(); + if ((word.empty() || Utility::CaseInsensitiveStartsWith(channel, word)) && + std::find(channels.begin(), channels.end(), channel) == channels.end()) + { + channels.emplace_back(std::move(channel)); + } + } + + for (const auto& c : channels) + { + OutputCompletionString(stream, c); + } + } + + void CompleteWithSingleSemanticsForValue::operator()(Execution::Context& context) const + { + switch (m_type) + { + case Execution::Args::Type::Query: + context << + Workflow::OpenSource << + Workflow::RequireCompletionWordNonEmpty << + Workflow::SearchSourceForSingleCompletion << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Manifest: + // Intentionally output none to enable pass through to filesystem. + break; + case Execution::Args::Type::Id: + context << + Workflow::OpenSource << + Workflow::SearchSourceForCompletionField(Repository::ApplicationMatchField::Id) << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Name: + context << + Workflow::OpenSource << + Workflow::SearchSourceForCompletionField(Repository::ApplicationMatchField::Name) << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Moniker: + context << + Workflow::OpenSource << + Workflow::SearchSourceForCompletionField(Repository::ApplicationMatchField::Moniker) << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Tag: + context << + Workflow::OpenSource << + Workflow::SearchSourceForCompletionField(Repository::ApplicationMatchField::Tag) << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Command: + context << + Workflow::OpenSource << + Workflow::SearchSourceForCompletionField(Repository::ApplicationMatchField::Command) << + Workflow::CompleteWithMatchedField; + break; + case Execution::Args::Type::Version: + // Here we require that the standard search finds a single entry, and we list those versions. + context << + Workflow::OpenSource << + Workflow::SearchSourceForSingle << + Workflow::EnsureOneMatchFromSearchResult << + Workflow::CompleteWithSearchResultVersions; + break; + case Execution::Args::Type::Channel: + // Here we require that the standard search finds a single entry, and we list those channels. + context << + Workflow::OpenSource << + Workflow::SearchSourceForSingle << + Workflow::EnsureOneMatchFromSearchResult << + Workflow::CompleteWithSearchResultChannels; + break; + case Execution::Args::Type::Source: + context << + Workflow::CompleteSourceName; + break; + } + } + + void CompleteWithEmptySet(Execution::Context& context) + { + context.Reporter.Completion() << std::endl; + } +} diff --git a/src/AppInstallerCLICore/Workflows/CompletionFlow.h b/src/AppInstallerCLICore/Workflows/CompletionFlow.h @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "ExecutionContext.h" +#include "WorkflowBase.h" +#include <AppInstallerRepositorySearch.h> + +namespace AppInstaller::CLI::Workflow +{ + // Outputs completion possibilities for the source name argument. + // Required Args: None + // Inputs: CompletionData + // Outputs: None + void CompleteSourceName(Execution::Context& context); + + // Terminates the context if the completion word is empty. + // Required Args: None + // Inputs: CompletionData + // Outputs: None + void RequireCompletionWordNonEmpty(Execution::Context& context); + + // Outputs the matched field for the results. + // Required Args: None + // Inputs: SearchResult + // Outputs: None + void CompleteWithMatchedField(Execution::Context& context); + + // Outputs the versions available for the single search result. + // Required Args: None + // Inputs: CompletionData, SearchResult + // Outputs: None + void CompleteWithSearchResultVersions(Execution::Context& context); + + // Outputs the channels available for the single search result. + // Required Args: None + // Inputs: CompletionData, SearchResult + // Outputs: None + void CompleteWithSearchResultChannels(Execution::Context& context); + + // Executes the appropriate completion flow for the given argument in the context of a command + // that targets a single manifest (ex. show or install). + // Required Args: None + // Inputs: CompletionData + // Outputs: None + struct CompleteWithSingleSemanticsForValue : public WorkflowTask + { + CompleteWithSingleSemanticsForValue(Execution::Args::Type type) : WorkflowTask("CompleteWithSingleSemanticsForValue"), m_type(type) {} + + void operator()(Execution::Context& context) const override; + + private: + Execution::Args::Type m_type; + }; + + // Outputs an empty line to indicate that there are no completions. + // Required Args: None + // Inputs: None + // Outputs: None + void CompleteWithEmptySet(Execution::Context& context); +} diff --git a/src/AppInstallerCLICore/Workflows/SourceFlow.h b/src/AppInstallerCLICore/Workflows/SourceFlow.h @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #pragma once +#include "Command.h" #include "ExecutionContext.h" namespace AppInstaller::CLI::Workflow diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -205,6 +205,47 @@ namespace AppInstaller::CLI::Workflow context.Add<Execution::Data::SearchResult>(context.Get<Execution::Data::Source>()->Search(searchRequest)); } + void SearchSourceForManyCompletion(Execution::Context& context) + { + MatchType matchType = MatchType::StartsWith; + + SearchRequest searchRequest; + std::string_view query = context.Get<Execution::Data::CompletionData>().Word(); + searchRequest.Query.emplace(RequestMatch(matchType, query)); + + SearchSourceApplyFilters(context, searchRequest, matchType); + + context.Add<Execution::Data::SearchResult>(context.Get<Execution::Data::Source>()->Search(searchRequest)); + } + + void SearchSourceForSingleCompletion(Execution::Context& context) + { + MatchType matchType = MatchType::StartsWith; + + SearchRequest searchRequest; + std::string_view query = context.Get<Execution::Data::CompletionData>().Word(); + searchRequest.Inclusions.emplace_back(ApplicationMatchFilter(ApplicationMatchField::Id, matchType, query)); + searchRequest.Inclusions.emplace_back(ApplicationMatchFilter(ApplicationMatchField::Name, matchType, query)); + searchRequest.Inclusions.emplace_back(ApplicationMatchFilter(ApplicationMatchField::Moniker, matchType, query)); + + SearchSourceApplyFilters(context, searchRequest, matchType); + + context.Add<Execution::Data::SearchResult>(context.Get<Execution::Data::Source>()->Search(searchRequest)); + } + + void SearchSourceForCompletionField::operator()(Execution::Context& context) const + { + const std::string& word = context.Get<Execution::Data::CompletionData>().Word(); + + SearchRequest searchRequest; + searchRequest.Inclusions.emplace_back(ApplicationMatchFilter(m_field, MatchType::StartsWith, word)); + + // If filters are provided, be generous with the search no matter the intended result. + SearchSourceApplyFilters(context, searchRequest, MatchType::Substring); + + context.Add<Execution::Data::SearchResult>(context.Get<Execution::Data::Source>()->Search(searchRequest)); + } + void ReportSearchResult(Execution::Context& context) { auto& searchResult = context.Get<Execution::Data::SearchResult>(); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -2,8 +2,9 @@ // Licensed under the MIT License. #pragma once #include "ExecutionArgs.h" - #include <winget/ExperimentalFeature.h> +#include <AppInstallerRepositorySearch.h> + #include <string> #include <string_view> @@ -55,12 +56,40 @@ namespace AppInstaller::CLI::Workflow // Outputs: SearchResult void SearchSourceForMany(Execution::Context& context); - // Performs a search on the source with the semantics of targeting a single application. + // Performs a search on the source with the semantics of targeting a single package. // Required Args: None // Inputs: Source // Outputs: SearchResult void SearchSourceForSingle(Execution::Context& context); + // Performs a search on the source with the semantics of targeting many packages, + // but for completion purposes. + // Required Args: None + // Inputs: Source, CompletionData + // Outputs: SearchResult + void SearchSourceForManyCompletion(Execution::Context& context); + + // Performs a search on the source with the semantics of targeting a single package, + // but for completion purposes. + // Required Args: None + // Inputs: Source, CompletionData + // Outputs: SearchResult + void SearchSourceForSingleCompletion(Execution::Context& context); + + // Searches the source for the specific field as a completion. + // Required Args: None + // Inputs: CompletionData, Source + // Outputs: None + struct SearchSourceForCompletionField : public WorkflowTask + { + SearchSourceForCompletionField(Repository::ApplicationMatchField field) : WorkflowTask("SearchSourceForCompletionField"), m_field(field) {} + + void operator()(Execution::Context& context) const override; + + private: + Repository::ApplicationMatchField m_field; + }; + // Outputs the search results. // Required Args: None // Inputs: SearchResult diff --git a/src/AppInstallerCLICore/pch.h b/src/AppInstallerCLICore/pch.h @@ -4,6 +4,7 @@ #define NOMINMAX #include <windows.h> +#include <shellapi.h> #include <WinInet.h> #include <winrt/Windows.Foundation.h> @@ -15,6 +16,7 @@ #include <winrt/Windows.ApplicationModel.Store.Preview.InstallControl.h> #include <wil/result_macros.h> +#include <wil/safecast.h> #include <array> #include <iostream> diff --git a/src/AppInstallerCLIE2ETests/SetUpFixture.cs b/src/AppInstallerCLIE2ETests/SetUpFixture.cs @@ -62,18 +62,18 @@ namespace AppInstallerCLIE2ETests ShouldDisableDevModeOnExit = EnableDevMode(true); - Assert.True(TestCommon.RunCommand("certutil.exe", "-addstore -f \"TRUSTEDPEOPLE\" " + TestCommon.GetTestDataFile(Constants.AppInstallerTestCert))); - Assert.True(TestCommon.RunCommand("certutil.exe", "-addstore -f \"ROOT\" " + TestCommon.GetTestDataFile(Constants.IndexPackageRootCert))); + Assert.True(TestCommon.RunCommand("certutil.exe", "-addstore -f \"TRUSTEDPEOPLE\" " + TestCommon.GetTestDataFile(Constants.AppInstallerTestCert)), "Add AppInstallerTestCert"); + Assert.True(TestCommon.RunCommand("certutil.exe", "-addstore -f \"ROOT\" " + TestCommon.GetTestDataFile(Constants.IndexPackageRootCert)), "Add IndexPackageRootCert"); if (TestCommon.PackagedContext) { if (TestCommon.LooseFileRegistration) { - Assert.True(TestCommon.InstallMsixRegister(TestCommon.AICLIPackagePath)); + Assert.True(TestCommon.InstallMsixRegister(TestCommon.AICLIPackagePath), "InstallMsixRegister"); } else { - Assert.True(TestCommon.InstallMsix(TestCommon.AICLIPackagePath)); + Assert.True(TestCommon.InstallMsix(TestCommon.AICLIPackagePath), "InstallMsix"); } } } diff --git a/src/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj b/src/AppInstallerCLIPackage/AppInstallerCLIPackage.wapproj @@ -70,6 +70,7 @@ <Content Include="Images\Square44x44Logo.targetsize-24_altform-unplated.png" /> <Content Include="Images\StoreLogo.png" /> <Content Include="Images\Wide310x150Logo.scale-200.png" /> + <None Include="Register-WingetdevAutoComplete.ps1" /> <PRIResource Include="Shared\Strings\en-us\winget.resw" /> <PRIResource Include="shared\strings\af-ZA\winget.resw" Condition="Exists('shared\strings\af-ZA\winget.resw')" /> <PRIResource Include="shared\strings\am-ET\winget.resw" Condition="Exists('shared\strings\am-ET\winget.resw')" /> diff --git a/src/AppInstallerCLIPackage/Register-WingetdevAutoComplete.ps1 b/src/AppInstallerCLIPackage/Register-WingetdevAutoComplete.ps1 @@ -0,0 +1,9 @@ +Register-ArgumentCompleter -Native -CommandName wingetdev -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + [Console]::InputEncoding = [Console]::OutputEncoding = $OutputEncoding = [System.Text.Utf8Encoding]::new() + $Local:word = $wordToComplete.Replace('"', '""') + $Local:ast = $commandAst.ToString().Replace('"', '""') + wingetdev complete --word="$Local:word" --commandline "$Local:ast" --position $cursorPosition | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +}+ \ No newline at end of file diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -147,9 +147,18 @@ <data name="CommandArgumentDescription" xml:space="preserve"> <value>Filter results by command</value> </data> + <data name="CommandLineArgumentDescription" xml:space="preserve"> + <value>The full command line for completion</value> + </data> <data name="CommandRequiresAdmin" xml:space="preserve"> <value>This command requires administrator privileges to execute.</value> </data> + <data name="CompleteCommandLongDescription" xml:space="preserve"> + <value>This command can be used to request context sensitive command line completion. The command line, cursor position, and word to be completed are passed in. The output is a set of potential values based on the inputs, one possible value per line.</value> + </data> + <data name="CompleteCommandShortDescription" xml:space="preserve"> + <value>Enables context sensitive command line completion</value> + </data> <data name="CountArgumentDescription" xml:space="preserve"> <value>Show no more than specified number of results</value> </data> @@ -389,6 +398,9 @@ They can be configured through the settings file 'winget settings'.</value> <data name="PendingWorkError" xml:space="preserve"> <value>Oops, we forgot to do this...</value> </data> + <data name="PositionArgumentDescription" xml:space="preserve"> + <value>The position of the cursor within the command line</value> + </data> <data name="PreviewVersion" xml:space="preserve"> <value>Preview</value> </data> @@ -637,4 +649,7 @@ They can be configured through the settings file 'winget settings'.</value> <data name="VersionsArgumentDescription" xml:space="preserve"> <value>Show available versions of the package</value> </data> + <data name="WordArgumentDescription" xml:space="preserve"> + <value>The value provided before completion is requested</value> + </data> </root> \ No newline at end of file diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -178,6 +178,7 @@ </ItemGroup> <ItemGroup> <ClCompile Include="Command.cpp" /> + <ClCompile Include="Completion.cpp" /> <ClCompile Include="Downloader.cpp" /> <ClCompile Include="ExperimentalFeature.cpp" /> <ClCompile Include="HashCommand.cpp" /> diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -92,6 +92,9 @@ <ClCompile Include="ExperimentalFeature.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="Completion.cpp"> + <Filter>Source Files</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <None Include="PropertySheet.props" /> diff --git a/src/AppInstallerCLITests/Command.cpp b/src/AppInstallerCLITests/Command.cpp @@ -141,7 +141,7 @@ struct CommandExceptionMatcher : public Catch::MatcherBase<CommandException> bool match(const CommandException& ce) const override { - return ce.Param() == m_expectedArg; + return ce.Param().get() == m_expectedArg; } std::string describe() const override @@ -155,6 +155,15 @@ private: std::string m_expectedArg; }; +namespace Catch { + template<> + struct StringMaker<CommandException> { + static std::string convert(CommandException const& ce) { + return std::string{ "CommandException{ '" } + ce.Message().get() + "', '" + ce.Param().get() + "'}"; + } + }; +} + #define REQUIRE_COMMAND_EXCEPTION(_expr_, _arg_) REQUIRE_THROWS_MATCHES(_expr_, CommandException, CommandExceptionMatcher(_arg_)) void RequireValueParsedToArg(const std::string& value, const Argument& arg, const Args& args) @@ -432,6 +441,22 @@ TEST_CASE("ParseArguments_NameWithSeparatedValue", "[command]") RequireValueParsedToArg(values[1], command.m_args[2], args); } +TEST_CASE("ParseArguments_NameWithSeparatedValueMissing", "[command]") +{ + Args args; + TestCommand command({ + Argument{ "pos1", 'p', Args::Type::Channel, DefaultDesc, ArgumentType::Positional }, + Argument{ "std1", 's', Args::Type::Command, DefaultDesc, ArgumentType::Standard }, + Argument{ "pos2", 'q', Args::Type::Count, DefaultDesc, ArgumentType::Positional }, + Argument{ "flag1", 'f', Args::Type::Exact, DefaultDesc, ArgumentType::Flag }, + }); + + std::vector<std::string> values{ "--pos2" }; + Invocation inv{ std::vector<std::string>(values) }; + + REQUIRE_COMMAND_EXCEPTION(command.ParseArguments(inv, args), values[0]); +} + TEST_CASE("ParseArguments_UnknownName", "[command]") { Args args; diff --git a/src/AppInstallerCLITests/Completion.cpp b/src/AppInstallerCLITests/Completion.cpp @@ -0,0 +1,549 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TestCommon.h" +#include <AppInstallerErrors.h> +#include <Command.h> +#include <Commands/CompleteCommand.h> +#include <Commands/RootCommand.h> +#include <Commands/SourceCommand.h> +#include <CompletionData.h> + +using namespace std::string_literals; +using namespace std::string_view_literals; +using namespace TestCommon; +using namespace AppInstaller; +using namespace AppInstaller::CLI; +using namespace AppInstaller::CLI::Execution; + + +TEST_CASE("CompletionData_EmptyWord_PositionAtEnd", "[complete]") +{ + CompletionData cd{ "", "winget ", "7" }; + REQUIRE(cd.Word() == ""); + REQUIRE(cd.BeforeWord().size() == 0); + REQUIRE(cd.AfterWord().size() == 0); +} + +TEST_CASE("CompletionData_EmptyWord_PositionPastEnd", "[complete]") +{ + CompletionData cd{ "", "winget ", "8" }; + REQUIRE(cd.Word() == ""); + REQUIRE(cd.BeforeWord().size() == 0); + REQUIRE(cd.AfterWord().size() == 0); +} + +TEST_CASE("CompletionData_EmptyWord_PositionCorrect", "[complete]") +{ + CompletionData cd{ "", "winget install", "7" }; + REQUIRE(cd.Word() == ""); + REQUIRE(cd.BeforeWord().size() == 0); + REQUIRE(cd.AfterWord().size() == 1); +} + + +TEST_CASE("CompletionData_EmptyWord_PositionOffset", "[complete]") +{ + CompletionData cd{ "", "winget install PowerToys --version", "17" }; + REQUIRE(cd.Word() == ""); + REQUIRE(cd.BeforeWord().size() == 1); + REQUIRE(cd.AfterWord().size() == 2); +} + +TEST_CASE("CompletionData_Word_NoMatch", "[complete]") +{ + auto lambda = []() { CompletionData cd{ "foo", "winget install PowerToys --version", "17" }; }; + REQUIRE_THROWS_HR(lambda(), APPINSTALLER_CLI_ERROR_COMPLETE_INPUT_BAD); +} + +TEST_CASE("CompletionData_Word_SingleMatch", "[complete]") +{ + CompletionData cd{ "power", "winget install power --version", "17" }; + REQUIRE(cd.Word() == "power"); + REQUIRE(cd.BeforeWord().size() == 1); + REQUIRE(cd.AfterWord().size() == 1); +} + +TEST_CASE("CompletionData_Word_MultiMatch_PositionCorrect", "[complete]") +{ + CompletionData cd{ "power", "winget install power --id power", "27" }; + REQUIRE(cd.Word() == "power"); + REQUIRE(cd.BeforeWord().size() == 3); + REQUIRE(cd.AfterWord().size() == 0); +} + +TEST_CASE("CompletionData_Word_MultiMatch_PositionOffset", "[complete]") +{ + CompletionData cd{ "power", "winget install power --id power", "21" }; + REQUIRE(cd.Word() == "power"); + REQUIRE(cd.BeforeWord().size() == 1); + REQUIRE(cd.AfterWord().size() == 2); +} + +TEST_CASE("CompletionData_UTF8_EmptyWord_End", "[complete]") +{ + CompletionData cd{ "", u8"winget install \x175\x12b\x14b\x1e5\x229\x288 --version ", "32" }; + REQUIRE(cd.Word() == ""); + REQUIRE(cd.BeforeWord().size() == 3); + REQUIRE(cd.AfterWord().size() == 0); +} + +TEST_CASE("CompletionData_UTF8_EmptyWord_Middle", "[complete]") +{ + CompletionData cd{ "", u8"winget install \x175\x12b\x14b\x1e5\x229\x288 --version ", "22" }; + REQUIRE(cd.Word() == ""); + REQUIRE(cd.BeforeWord().size() == 2); + REQUIRE(cd.AfterWord().size() == 1); +} + +TEST_CASE("CompletionData_UTF8_UTF8Word", "[complete]") +{ + CompletionData cd{ u8"\x175\x12b\x14b\x1e5\x229\x288", u8"winget install \x175\x12b\x14b\x1e5\x229\x288 --version ", "18" }; + REQUIRE(cd.Word() == u8"\x175\x12b\x14b\x1e5\x229\x288"); + REQUIRE(cd.BeforeWord().size() == 1); + REQUIRE(cd.AfterWord().size() == 1); +} + +void OutputAllSubCommands(Command& command, std::ostream& out, std::string_view filter = {}) +{ + for (const auto& c : command.GetCommands()) + { + if (Utility::CaseInsensitiveStartsWith(c->Name(), filter)) + { + out << c->Name() << std::endl; + } + } +} + +void OutputAllArgumentNames(Command& command, std::ostream& out, std::string_view filter = {}, bool includeCommon = true) +{ + auto args = command.GetArguments(); + if (includeCommon) + { + Argument::GetCommon(args); + } + + for (const auto& a : args) + { + if (Utility::CaseInsensitiveStartsWith(a.Name(), filter)) + { + out << APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR << APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR << a.Name() << std::endl; + } + } +} + +void OutputAllArgumentAliases(Command& command, std::ostream& out, bool includeCommon = true) +{ + auto args = command.GetArguments(); + if (includeCommon) + { + Argument::GetCommon(args); + } + + for (const auto& a : args) + { + if (a.Alias() != Argument::NoAlias) + { + out << APPINSTALLER_CLI_ARGUMENT_IDENTIFIER_CHAR << a.Alias() << std::endl; + } + } +} + +TEST_CASE("CompleteCommand_FindRoot", "[complete]") +{ + std::stringstream out, in; + Context context{ out, in }; + + CompleteCommand command{ "test" }; + context.Args.AddArg(Args::Type::Word, ""sv); + context.Args.AddArg(Args::Type::CommandLine, "winget "sv); + context.Args.AddArg(Args::Type::Position, "7"sv); + command.Execute(context); + + // Create expected values + RootCommand expectedCommand; + std::stringstream expected; + OutputAllSubCommands(expectedCommand, expected); + OutputAllArgumentNames(expectedCommand, expected); + + REQUIRE(out.str() == expected.str()); +} + +TEST_CASE("CompleteCommand_FindSource", "[complete]") +{ + std::stringstream out, in; + Context context{ out, in }; + + CompleteCommand command{ "test" }; + context.Args.AddArg(Args::Type::Word, ""sv); + context.Args.AddArg(Args::Type::CommandLine, "winget source "sv); + context.Args.AddArg(Args::Type::Position, "14"sv); + command.Execute(context); + + // Create expected values + SourceCommand expectedCommand{ "test" }; + std::stringstream expected; + OutputAllSubCommands(expectedCommand, expected); + OutputAllArgumentNames(expectedCommand, expected); + + REQUIRE(out.str() == expected.str()); +} + +TEST_CASE("CompleteCommand_FindSourceAdd", "[complete]") +{ + std::stringstream out, in; + Context context{ out, in }; + + CompleteCommand command{ "test" }; + context.Args.AddArg(Args::Type::Word, ""sv); + context.Args.AddArg(Args::Type::CommandLine, "winget source add "sv); + context.Args.AddArg(Args::Type::Position, "18"sv); + command.Execute(context); + + // Create expected values + SourceAddCommand expectedCommand{ "test" }; + std::stringstream expected; + OutputAllSubCommands(expectedCommand, expected); + OutputAllArgumentNames(expectedCommand, expected); + + REQUIRE(out.str() == expected.str()); +} + +struct CompletionTestCommand : public Command +{ + CompletionTestCommand() : Command("test", "") {} + CompletionTestCommand(std::string_view name) : Command(name, "") {} + + std::vector<std::unique_ptr<Command>> GetCommands() const override + { + std::vector<std::unique_ptr<Command>> result; + + for (const auto& sc : SubCommandNames) + { + result.emplace_back(std::make_unique<CompletionTestCommand>(sc)); + } + + return result; + } + + std::vector<Argument> GetArguments() const override + { + return Arguments; + } + + using Command::Complete; + + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override + { + if (ArgumentValueCallback) + { + ArgumentValueCallback(context, valueType); + } + } + + std::vector<std::string> SubCommandNames; + std::vector<Argument> Arguments; + std::function<void(Context&, Execution::Args::Type)> ArgumentValueCallback; +}; + +struct CompletionTestContext +{ + CompletionTestContext(std::string_view word, std::string_view commandLine, std::string_view position) : + context(out, in) + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Completion); + context.Add<Data::CompletionData>(CompletionData{ word, commandLine, position }); + } + + std::stringstream out; + std::stringstream in; + Context context; +}; + +TEST_CASE("CommandComplete_Simple", "[complete]") +{ + CompletionTestContext ctc{ "", "winget ", "7" }; + + CompletionTestCommand command; + command.SubCommandNames = { "test1", "test2" }; + command.Arguments = { Argument{ "arg1", 'a', Args::Type::Query, Resource::String::Done, ArgumentType::Standard } }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllSubCommands(command, expected); + OutputAllArgumentNames(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); +} + +TEST_CASE("CommandComplete_PartialCommandMatch", "[complete]") +{ + CompletionTestContext ctc{ "cart", "winget cart", "11" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllSubCommands(command, expected, "cart"); + OutputAllArgumentNames(command, expected, "cart"); + + REQUIRE(ctc.out.str() == expected.str()); +} + +TEST_CASE("CommandComplete_CommandsNotAllowed", "[complete]") +{ + CompletionTestContext ctc{ "", "winget foobar ", "14" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllArgumentNames(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); +} + +TEST_CASE("CommandComplete_Routing1", "[complete]") +{ + CompletionTestContext ctc{ "", "winget --arg1 ", "14" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Standard }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[0].ExecArgType()); +} + +TEST_CASE("CommandComplete_Routing2", "[complete]") +{ + CompletionTestContext ctc{ "", "winget --arg2 ", "14" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Standard }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[1].ExecArgType()); +} + +TEST_CASE("CommandComplete_PositionalRouting", "[complete]") +{ + CompletionTestContext ctc{ "", "winget ", "7" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllSubCommands(command, expected); + OutputAllArgumentNames(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[1].ExecArgType()); +} + +TEST_CASE("CommandComplete_PositionalRoutingAfterArgs", "[complete]") +{ + CompletionTestContext ctc{ "", "winget --arg1 value ", "20" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllArgumentNames(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[1].ExecArgType()); +} + +TEST_CASE("CommandComplete_PositionalRoutingAfterDoubleDash", "[complete]") +{ + CompletionTestContext ctc{ "", "winget -- ", "10" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[1].ExecArgType()); +} + +TEST_CASE("CommandComplete_ArgNamesAfterDash", "[complete]") +{ + CompletionTestContext ctc{ "-", "winget -", "8" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllArgumentNames(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); +} + +TEST_CASE("CommandComplete_AliasNames", "[complete]") +{ + CompletionTestContext ctc{ "-a", "winget -a", "9" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllArgumentAliases(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); +} + +TEST_CASE("CommandComplete_ArgNamesFilter", "[complete]") +{ + CompletionTestContext ctc{ "--a", "winget --a", "10" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + Argument{ "foo1", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Positional }, + }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllArgumentNames(command, expected, "a"); + + REQUIRE(ctc.out.str() == expected.str()); +} + +TEST_CASE("CommandComplete_IgnoreBadArgs", "[complete]") +{ + CompletionTestContext ctc{ "", "winget foo bar --arg1 ", "22" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Standard }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[0].ExecArgType()); +} + +TEST_CASE("CommandComplete_OtherArgsParsed", "[complete]") +{ + CompletionTestContext ctc{ "", "winget --arg1 value1 --arg2 value2", "21" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Standard }, + }; + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type) { FAIL("No argument value should be requested"); }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + OutputAllArgumentNames(command, expected); + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(ctc.context.Args.Contains(command.Arguments[0].ExecArgType())); + REQUIRE(ctc.context.Args.GetArg(command.Arguments[0].ExecArgType()) == "value1"); + REQUIRE(ctc.context.Args.Contains(command.Arguments[1].ExecArgType())); + REQUIRE(ctc.context.Args.GetArg(command.Arguments[1].ExecArgType()) == "value2"); +} + +TEST_CASE("CommandComplete_Complex", "[complete]") +{ + CompletionTestContext ctc{ "", "winget foo --arg1 value1 bar junk --arg2 ", "41" }; + + CompletionTestCommand command; + command.SubCommandNames = { "car", "cart", "cartesion", "carpet" }; + command.Arguments = { + Argument{ "arg1", '1', Args::Type::Query, Resource::String::Done, ArgumentType::Standard }, + Argument{ "arg2", '2', Args::Type::Channel, Resource::String::Done, ArgumentType::Standard }, + }; + Args::Type argType = static_cast<Args::Type>(-1); + command.ArgumentValueCallback = [&](Context&, Execution::Args::Type type) { argType = type; }; + command.Complete(ctc.context); + + // Create expected values + std::stringstream expected; + + REQUIRE(ctc.out.str() == expected.str()); + REQUIRE(argType == command.Arguments[1].ExecArgType()); + REQUIRE(ctc.context.Args.Contains(command.Arguments[0].ExecArgType())); + REQUIRE(ctc.context.Args.GetArg(command.Arguments[0].ExecArgType()) == "value1"); +} diff --git a/src/AppInstallerCLITests/SQLiteIndex.cpp b/src/AppInstallerCLITests/SQLiteIndex.cpp @@ -1342,3 +1342,21 @@ TEST_CASE("SQLiteIndex_Search_CaseInsensitive", "[sqliteindex]") auto results = index.Search(request); REQUIRE(results.Matches.size() == 3); } + +TEST_CASE("SQLiteIndex_Search_StartsWith", "[sqliteindex]") +{ + TempFile tempFile{ "repolibtest_tempdb"s, ".db"s }; + INFO("Using temporary file named: " << tempFile.GetPath()); + + SQLiteIndex index = SearchTestSetup(tempFile, { + { "NopeId", "id3", "Moniker", "Version", "Channel", { "Tag" }, { "Command" }, "Path1" }, + { "Id2", "Na", "Moniker", "Version", "Channel", { "ID3" }, { "Command" }, "Path2" }, + { "Id3", "No", "Moniker", "Version", "Channel", { "Tag" }, { "Command" }, "Path3" }, + }); + + SearchRequest request; + request.Inclusions.push_back(ApplicationMatchFilter(ApplicationMatchField::Id, MatchType::StartsWith, "id")); + + auto results = index.Search(request); + REQUIRE(results.Matches.size() == 2); +} diff --git a/src/AppInstallerCLITests/Strings.cpp b/src/AppInstallerCLITests/Strings.cpp @@ -22,6 +22,7 @@ TEST_CASE("UTF8Substring", "[strings]") { REQUIRE(UTF8Substring("", 0, 0) == ""); REQUIRE(UTF8Substring("abcd", 0, 4) == "abcd"); + REQUIRE(UTF8Substring("abcd", 0, 5) == "abcd"); REQUIRE(UTF8Substring("abcd", 0, 2) == "ab"); REQUIRE(UTF8Substring("abcd", 1, 0) == ""); REQUIRE(UTF8Substring("abcd", 1, 1) == "b"); @@ -79,3 +80,16 @@ TEST_CASE("Trim", "[strings]") REQUIRE(Trim(str.assign(" Multiple words")) == "Multiple words"); REQUIRE(Trim(str.assign("Much after is taken \f\n\r\t\v\v\t\r\n\f ")) == "Much after is taken"); } + +TEST_CASE("CaseInsensitiveStartsWith", "[strings]") +{ + REQUIRE(CaseInsensitiveStartsWith("startswith", "starts")); + REQUIRE(CaseInsensitiveStartsWith("startswith", "STAR")); + REQUIRE(CaseInsensitiveStartsWith("startswith", "startSWITH")); + REQUIRE(CaseInsensitiveStartsWith("startswith", "")); + + REQUIRE(!CaseInsensitiveStartsWith("starts", "startswith")); + REQUIRE(!CaseInsensitiveStartsWith("", "nuffing")); + REQUIRE(!CaseInsensitiveStartsWith("withstarts", "starts")); + REQUIRE(!CaseInsensitiveStartsWith(" starts", "starts")); +} diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp @@ -368,8 +368,8 @@ TEST_CASE("ShellExecuteHandlerInstallerArgs", "[InstallFlow]") // Msi type with /silent and /log and /custom and /installlocation, no switches specified in manifest auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallerArgTest_Msi_NoSwitches.yaml")); context.Args.AddArg(Execution::Args::Type::Silent); - context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"); - context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"); + context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"sv); + context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"sv); context.Add<Data::Installer>(manifest.Installers.at(0)); context << GetInstallerArgs; std::string installerArgs = context.Get<Data::InstallerArgs>(); @@ -384,8 +384,8 @@ TEST_CASE("ShellExecuteHandlerInstallerArgs", "[InstallFlow]") // Msi type with /silent and /log and /custom and /installlocation, switches specified in manifest auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallerArgTest_Msi_WithSwitches.yaml")); context.Args.AddArg(Execution::Args::Type::Silent); - context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"); - context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"); + context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"sv); + context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"sv); context.Add<Data::Installer>(manifest.Installers.at(0)); context << GetInstallerArgs; std::string installerArgs = context.Get<Data::InstallerArgs>(); @@ -414,8 +414,8 @@ TEST_CASE("ShellExecuteHandlerInstallerArgs", "[InstallFlow]") // Inno type with /silent and /log and /custom and /installlocation, no switches specified in manifest auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallerArgTest_Inno_NoSwitches.yaml")); context.Args.AddArg(Execution::Args::Type::Silent); - context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"); - context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"); + context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"sv); + context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"sv); context.Add<Data::Installer>(manifest.Installers.at(0)); context << GetInstallerArgs; std::string installerArgs = context.Get<Data::InstallerArgs>(); @@ -430,8 +430,8 @@ TEST_CASE("ShellExecuteHandlerInstallerArgs", "[InstallFlow]") // Inno type with /silent and /log and /custom and /installlocation, switches specified in manifest auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallerArgTest_Inno_WithSwitches.yaml")); context.Args.AddArg(Execution::Args::Type::Silent); - context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"); - context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"); + context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"sv); + context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"sv); context.Add<Data::Installer>(manifest.Installers.at(0)); context << GetInstallerArgs; std::string installerArgs = context.Get<Data::InstallerArgs>(); @@ -447,9 +447,9 @@ TEST_CASE("ShellExecuteHandlerInstallerArgs", "[InstallFlow]") // Override switch specified. The whole arg passed to installer is overridden. auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallerArgTest_Inno_WithSwitches.yaml")); context.Args.AddArg(Execution::Args::Type::Silent); - context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"); - context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"); - context.Args.AddArg(Execution::Args::Type::Override, "/OverrideEverything"); + context.Args.AddArg(Execution::Args::Type::Log, "MyLog.log"sv); + context.Args.AddArg(Execution::Args::Type::InstallLocation, "MyDir"sv); + context.Args.AddArg(Execution::Args::Type::Override, "/OverrideEverything"sv); context.Add<Data::Installer>(manifest.Installers.at(0)); context << GetInstallerArgs; std::string installerArgs = context.Get<Data::InstallerArgs>(); @@ -465,7 +465,7 @@ TEST_CASE("InstallFlow_SearchAndInstall", "[InstallFlow]") TestContext context{ installOutput, std::cin }; OverrideForOpenSource(context); OverrideForShellExecute(context); - context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnOne"); + context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnOne"sv); InstallCommand install({}); install.Execute(context); @@ -486,7 +486,7 @@ TEST_CASE("InstallFlow_SearchFoundNoApp", "[InstallFlow]") std::ostringstream installOutput; TestContext context{ installOutput, std::cin }; OverrideForOpenSource(context); - context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnZero"); + context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnZero"sv); InstallCommand install({}); install.Execute(context); @@ -501,7 +501,7 @@ TEST_CASE("InstallFlow_SearchFoundMultipleApp", "[InstallFlow]") std::ostringstream installOutput; TestContext context{ installOutput, std::cin }; OverrideForOpenSource(context); - context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnTwo"); + context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnTwo"sv); InstallCommand install({}); install.Execute(context); @@ -516,7 +516,7 @@ TEST_CASE("InstallFlow_SearchAndShowAppInfo", "[ShowFlow]") std::ostringstream showOutput; TestContext context{ showOutput, std::cin }; OverrideForOpenSource(context); - context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnOne"); + context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnOne"sv); ShowCommand show({}); show.Execute(context); @@ -534,7 +534,7 @@ TEST_CASE("InstallFlow_SearchAndShowAppVersion", "[ShowFlow]") std::ostringstream showOutput; TestContext context{ showOutput, std::cin }; OverrideForOpenSource(context); - context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnOne"); + context.Args.AddArg(Execution::Args::Type::Query, "TestQueryReturnOne"sv); context.Args.AddArg(Execution::Args::Type::ListVersions); ShowCommand show({}); diff --git a/src/AppInstallerCommonCore/AppInstallerLogging.cpp b/src/AppInstallerCommonCore/AppInstallerLogging.cpp @@ -6,6 +6,7 @@ #include "Public/AppInstallerFileLogger.h" #include "Public/AppInstallerTelemetry.h" #include "Public/AppInstallerDateTime.h" +#include "Public/AppInstallerRuntime.h" namespace AppInstaller::Logging { @@ -132,6 +133,11 @@ namespace AppInstaller::Logging { Log().AddLogger(std::make_unique<FileLogger>(filePath)); } + + void BeginLogFileCleanup() + { + FileLogger::BeginCleanup(Runtime::GetPathTo(Runtime::PathName::DefaultLogLocation)); + } } std::ostream& operator<<(std::ostream& out, const std::chrono::system_clock::time_point& time) diff --git a/src/AppInstallerCommonCore/AppInstallerStrings.cpp b/src/AppInstallerCommonCore/AppInstallerStrings.cpp @@ -52,14 +52,37 @@ namespace AppInstaller::Utility } } + // Gets the current break value; the byte offset or UBRK_DONE. + int32_t CurrentBreak() const { return m_currentBrk; } + + // Gets the current byte offset, throwing if the value is UBRK_DONE or negative. + size_t CurrentOffset() const + { + THROW_HR_IF(E_UNEXPECTED, m_currentBrk < 0); + return static_cast<size_t>(m_currentBrk); + } + + // Returns the byte offset of the next break in the string int32_t Next() { - return ubrk_next(m_brk.get()); + m_currentBrk = ubrk_next(m_brk.get()); + return m_currentBrk; + } + + // Returns the byte offset of the next count'th break in the string + int32_t Advance(size_t count) + { + for (size_t i = 0; i < count && m_currentBrk != UBRK_DONE; ++i) + { + Next(); + } + return m_currentBrk; } private: wil::unique_any<UText*, decltype(utext_close), &utext_close> m_text; wil::unique_any<UBreakIterator*, decltype(ubrk_close), &ubrk_close> m_brk; + int32_t m_currentBrk = 0; }; } @@ -69,6 +92,11 @@ namespace AppInstaller::Utility return ToLower(a) == ToLower(b); } + bool CaseInsensitiveStartsWith(std::string_view a, std::string_view b) + { + return a.length() >= b.length() && CaseInsensitiveEquals(a.substr(0, b.length()), b); + } + std::string ConvertToUTF8(std::wstring_view input) { if (input.empty()) @@ -127,29 +155,27 @@ namespace AppInstaller::Utility { ICUBreakIterator itr{ input, UBRK_CHARACTER }; - size_t utf8Offset = 0; + // Offset was past end, throw just like std::string::substr + if (itr.Advance(offset) == UBRK_DONE) + { + throw std::out_of_range("UTF8Substring: offset past end of input"); + } + + size_t utf8Offset = itr.CurrentOffset(); size_t utf8Count = 0; - size_t graphemeClusterOffset = 0; - int32_t i = 0; - while (i != UBRK_DONE) + // Count past end, convert to npos to get all of string + if (itr.Advance(count) == UBRK_DONE) { - if (graphemeClusterOffset == offset) - { - utf8Offset = i; - } - else if (graphemeClusterOffset == offset + count) - { - utf8Count = i - utf8Offset; - break; - } - - i = itr.Next(); - graphemeClusterOffset++; + utf8Count = std::string_view::npos; + } + else + { + utf8Count = itr.CurrentOffset() - utf8Offset; } return input.substr(utf8Offset, utf8Count); - } + } std::string Normalize(std::string_view input, NORM_FORM form) { diff --git a/src/AppInstallerCommonCore/AppInstallerTelemetry.cpp b/src/AppInstallerCommonCore/AppInstallerTelemetry.cpp @@ -32,6 +32,14 @@ namespace AppInstaller::Logging namespace { + // Used to disable telemetry on the fly. + std::atomic_bool s_isTelemetryEnabled{ true }; + + bool IsTelemetryEnabled() + { + return g_IsTelemetryProviderEnabled && s_isTelemetryEnabled; + } + void __stdcall wilResultLoggingCallback(const wil::FailureInfo& info) noexcept { Telemetry().LogFailure(info); @@ -69,7 +77,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogFailure(const wil::FailureInfo& failure) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "FailureInfo", @@ -103,7 +111,7 @@ namespace AppInstaller::Logging packageVersion = Runtime::GetPackageVersion(); } - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "ClientVersion", @@ -117,7 +125,7 @@ namespace AppInstaller::Logging AICLI_LOG(Core, Info, << "WinGet, version [" << version << "], activity [" << *GetActivityId() << ']'); AICLI_LOG(Core, Info, << "OS: " << Runtime::GetOSVersion()); - AICLI_LOG(Core, Info, << "Command line Args: " << GetCommandLineA()); + AICLI_LOG(Core, Info, << "Command line Args: " << Utility::ConvertToUTF8(GetCommandLineW())); if (Runtime::IsRunningInPackagedContext()) { AICLI_LOG(Core, Info, << "Package: " << packageVersion); @@ -126,7 +134,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogCommand(std::string_view commandName) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "CommandFound", @@ -142,7 +150,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogCommandSuccess(std::string_view commandName) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "CommandSuccess", @@ -158,7 +166,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogCommandTermination(HRESULT hr, std::string_view file, size_t line) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "CommandTermination", @@ -176,7 +184,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogException(std::string_view commandName, std::string_view type, std::string_view message) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "Exception", @@ -194,7 +202,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogManifestFields(std::string_view id, std::string_view name, std::string_view version, bool localManifest) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "ManifestFields", @@ -213,7 +221,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogNoAppMatch() noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "NoAppMatch", @@ -228,7 +236,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogMultiAppMatch() noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "MultiAppMatch", @@ -243,7 +251,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogAppFound(std::string_view name, std::string_view id) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "AppFound", @@ -260,7 +268,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogSelectedInstaller(int arch, std::string_view url, std::string_view installerType, std::string_view scope, std::string_view language) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "SelectedInstaller", @@ -294,7 +302,7 @@ namespace AppInstaller::Logging size_t maximum, std::string_view request) { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "SearchRequest", @@ -316,7 +324,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogSearchResultCount(uint64_t resultCount) noexcept { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "SearchResultCount", @@ -336,7 +344,7 @@ namespace AppInstaller::Logging const std::vector<uint8_t>& actual, bool overrideHashMismatch) { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "HashMismatch", @@ -362,7 +370,7 @@ namespace AppInstaller::Logging void TelemetryTraceLogger::LogInstallerFailure(std::string_view id, std::string_view version, std::string_view channel, std::string_view type, uint32_t errorCode) { - if (g_IsTelemetryProviderEnabled) + if (IsTelemetryEnabled()) { TraceLoggingWriteActivity(g_hTelemetryProvider, "InstallerFailure", @@ -384,4 +392,17 @@ namespace AppInstaller::Logging { wil::SetResultLoggingCallback(wilResultLoggingCallback); } + + DisableTelemetryScope::DisableTelemetryScope() + { + m_token = s_isTelemetryEnabled.exchange(false); + } + + DisableTelemetryScope::~DisableTelemetryScope() + { + if (m_token) + { + s_isTelemetryEnabled = true; + } + } } \ No newline at end of file diff --git a/src/AppInstallerCommonCore/Downloader.cpp b/src/AppInstallerCommonCore/Downloader.cpp @@ -151,8 +151,8 @@ namespace AppInstaller::Utility bool IsUrlRemote(std::string_view url) { // Very simple choice right now: "does it start with http:// or https://"? - if (CaseInsensitiveEquals(url.substr(0, s_http_start.length()), s_http_start) || - CaseInsensitiveEquals(url.substr(0, s_https_start.length()), s_https_start)) + if (CaseInsensitiveStartsWith(url, s_http_start) || + CaseInsensitiveStartsWith(url, s_https_start)) { return true; } @@ -163,7 +163,7 @@ namespace AppInstaller::Utility bool IsUrlSecure(std::string_view url) { // Very simple choice right now: "does it start with https://"? - if (CaseInsensitiveEquals(url.substr(0, s_https_start.length()), s_https_start)) + if (CaseInsensitiveStartsWith(url, s_https_start)) { return true; } diff --git a/src/AppInstallerCommonCore/FileLogger.cpp b/src/AppInstallerCommonCore/FileLogger.cpp @@ -4,13 +4,17 @@ #include "Public/AppInstallerFileLogger.h" #include "Public/AppInstallerRuntime.h" +#include "Public/AppInstallerStrings.h" #include "Public/AppInstallerDateTime.h" -#define AICLI_FILELOGGER_DEFAULT_FILE_PREFIX "WinGet-" -#define AICLI_FILELOGGER_DEFAULT_FILE_EXT ".log" namespace AppInstaller::Logging { + using namespace std::string_view_literals; + using namespace std::chrono_literals; + + static constexpr std::string_view s_fileLoggerDefaultFilePrefix = "WinGet-"sv; + static constexpr std::string_view s_fileLoggerDefaultFileExt = ".log"sv; FileLogger::FileLogger(const std::filesystem::path& filePath) { @@ -18,7 +22,7 @@ namespace AppInstaller::Logging { m_name = "file"; m_filePath = Runtime::GetPathTo(Runtime::PathName::DefaultLogLocation); - m_filePath /= AICLI_FILELOGGER_DEFAULT_FILE_PREFIX + Utility::GetCurrentTimeForFilename() + AICLI_FILELOGGER_DEFAULT_FILE_EXT; + m_filePath /= s_fileLoggerDefaultFilePrefix.data() + Utility::GetCurrentTimeForFilename() + s_fileLoggerDefaultFileExt.data(); } else { @@ -56,4 +60,28 @@ namespace AppInstaller::Logging { // Just eat any exceptions here; better than losing logs } + + void FileLogger::BeginCleanup(const std::filesystem::path& filePath) + { + std::thread([filePath]() + { + try + { + auto now = std::filesystem::file_time_type::clock::now(); + + // Remove all files that are older than 7 days from the standard log location. + for (auto& file : std::filesystem::directory_iterator{ filePath }) + { + if (file.is_regular_file() && + now - file.last_write_time() > (7 * 24h) && + Utility::CaseInsensitiveStartsWith(file.path().filename().string(), s_fileLoggerDefaultFilePrefix)) + { + std::filesystem::remove(file.path()); + } + } + } + // Just throw out everything + catch (...) {} + }).detach(); + } } diff --git a/src/AppInstallerCommonCore/Public/AppInstallerErrors.h b/src/AppInstallerCommonCore/Public/AppInstallerErrors.h @@ -40,6 +40,7 @@ #define APPINSTALLER_CLI_ERROR_MSSTORE_APP_BLOCKED_BY_POLICY ((HRESULT)0x8A15001C) #define APPINSTALLER_CLI_ERROR_EXPERIMENTAL_FEATURE_DISABLED ((HRESULT)0x8A15001D) #define APPINSTALLER_CLI_ERROR_MSSTORE_INSTALL_FAILED ((HRESULT)0x8A15001E) +#define APPINSTALLER_CLI_ERROR_COMPLETE_INPUT_BAD ((HRESULT)0x8A15001F) namespace AppInstaller { diff --git a/src/AppInstallerCommonCore/Public/AppInstallerFileLogger.h b/src/AppInstallerCommonCore/Public/AppInstallerFileLogger.h @@ -29,6 +29,9 @@ namespace AppInstaller::Logging virtual void Write(Channel channel, Level level, std::string_view message) noexcept override; + // Starts a background task to clean up old log files. + static void BeginCleanup(const std::filesystem::path& filePath); + private: std::string m_name; std::filesystem::path m_filePath; diff --git a/src/AppInstallerCommonCore/Public/AppInstallerLanguageUtilities.h b/src/AppInstallerCommonCore/Public/AppInstallerLanguageUtilities.h @@ -15,9 +15,9 @@ namespace AppInstaller template <typename T> struct ResetWhenMovedFrom { - ResetWhenMovedFrom() = default; + ResetWhenMovedFrom() : m_var{} {} - ResetWhenMovedFrom(T t) : m_var(t) {} + ResetWhenMovedFrom(T t) : m_var{ t } {} // Not copyable ResetWhenMovedFrom(const ResetWhenMovedFrom&) = delete; diff --git a/src/AppInstallerCommonCore/Public/AppInstallerLogging.h b/src/AppInstallerCommonCore/Public/AppInstallerLogging.h @@ -133,6 +133,9 @@ namespace AppInstaller::Logging // Adds the default file logger to the DiagnosticLogger. void AddFileLogger(const std::filesystem::path& filePath = {}); + + // Starts a background task to clean up old log files. + void BeginLogFileCleanup(); } // Enable output of system_clock timepoints. diff --git a/src/AppInstallerCommonCore/Public/AppInstallerStrings.h b/src/AppInstallerCommonCore/Public/AppInstallerStrings.h @@ -11,6 +11,9 @@ namespace AppInstaller::Utility // Compares the two UTF8 strings in a case insensitive manner. bool CaseInsensitiveEquals(std::string_view a, std::string_view b); + // Determins if string a starts with string b. + bool CaseInsensitiveStartsWith(std::string_view a, std::string_view b); + // Converts the given UTF16 string to UTF8 std::string ConvertToUTF8(std::wstring_view input); diff --git a/src/AppInstallerCommonCore/Public/AppInstallerTelemetry.h b/src/AppInstallerCommonCore/Public/AppInstallerTelemetry.h @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #pragma once +#include <AppInstallerLanguageUtilities.h> #include <wil/result_macros.h> #include <string_view> @@ -97,4 +98,22 @@ namespace AppInstaller::Logging // Turns on wil failure telemetry and logging. void EnableWilFailureTelemetry(); + + // An RAII object to disable telemetry during its lifetime. + // Primarily used by the complete command to prevent messy input from spamming us. + struct DisableTelemetryScope + { + DisableTelemetryScope(); + + DisableTelemetryScope(const DisableTelemetryScope&) = delete; + DisableTelemetryScope& operator=(const DisableTelemetryScope&) = delete; + + DisableTelemetryScope(DisableTelemetryScope&&) = default; + DisableTelemetryScope& operator=(DisableTelemetryScope&&) = default; + + ~DisableTelemetryScope(); + + private: + DestructionToken m_token; + }; } diff --git a/src/AppInstallerCommonCore/Public/winget/LocIndependent.h b/src/AppInstallerCommonCore/Public/winget/LocIndependent.h @@ -33,6 +33,7 @@ namespace AppInstaller::Utility { LocIndString() = default; + explicit LocIndString(std::string_view sv) : m_value(sv) {} explicit LocIndString(std::string v) : m_value(std::move(v)) {} LocIndString(const LocIndString&) = default; diff --git a/src/AppInstallerCommonCore/Runtime.cpp b/src/AppInstallerCommonCore/Runtime.cpp @@ -145,7 +145,7 @@ namespace AppInstaller::Runtime // In the extremely unlikely event of a failure, this is merely a sentinel value // to indicated such. The only other option is to completely prevent execution, // which seems unnecessary. - return LocIndString{ "error" }; + return LocIndString{ "error"sv }; } strstr << version->Build; @@ -171,7 +171,7 @@ namespace AppInstaller::Runtime // In the extremely unlikely event of a failure, this is merely a sentinel value // to indicated such. The only other option is to completely prevent execution, // which seems unnecessary. - return LocIndString{ "error" }; + return LocIndString{ "error"sv }; } std::ostringstream strstr; @@ -182,7 +182,7 @@ namespace AppInstaller::Runtime else { // Calling code should avoid calling in when this is the case. - return LocIndString{ "none" }; + return LocIndString{ "none"sv }; } } diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/1_0/Interface.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/1_0/Interface.cpp @@ -145,6 +145,8 @@ namespace AppInstaller::Repository::Microsoft::Schema::V1_0 return { MatchType::Exact }; case MatchType::CaseInsensitive: return { MatchType::Exact, MatchType::CaseInsensitive }; + case MatchType::StartsWith: + return { MatchType::CaseInsensitive, MatchType::StartsWith }; case MatchType::Substring: return { MatchType::Exact, MatchType::CaseInsensitive, MatchType::Substring }; case MatchType::Wildcard: diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/1_0/SearchResultsTable.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/1_0/SearchResultsTable.cpp @@ -68,18 +68,28 @@ namespace AppInstaller::Repository::Microsoft::Schema::V1_0 return; } - std::string_view valueToUse = value; - std::string escapedValue; + std::string valueToUse; + if (escapeValueForLike) { - escapedValue = SQLite::EscapeStringForLike(value); - valueToUse = escapedValue; + valueToUse = SQLite::EscapeStringForLike(value); + } + else + { + valueToUse = value; } - if (match == MatchType::Substring) + switch (match) { - escapedValue = "%"s + std::string(valueToUse) + '%'; - valueToUse = escapedValue; + case AppInstaller::Repository::MatchType::StartsWith: + valueToUse += '%'; + break; + case AppInstaller::Repository::MatchType::Substring: + valueToUse = "%"s + valueToUse + '%'; + break; + default: + // No changes required for others. + break; } statement.Bind(bindIndex, valueToUse); diff --git a/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySearch.h b/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySearch.h @@ -20,6 +20,7 @@ namespace AppInstaller::Repository { Exact, CaseInsensitive, + StartsWith, Substring, Wildcard, Fuzzy, @@ -133,6 +134,8 @@ namespace AppInstaller::Repository return "Exact"sv; case MatchType::CaseInsensitive: return "CaseInsensitive"sv; + case MatchType::StartsWith: + return "StartsWith"sv; case MatchType::Substring: return "Substring"sv; case MatchType::Wildcard: