diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f457cc9d4..5a5d29c54b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,22 +32,22 @@ jobs: - name: Install runtime dependencies run: apt-get install -y xvfb - name: Restore cache for _build/tools - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: _build/tools key: build-tools-${{ hashFiles('build', 'build.ps1', 'build.cake') }} - name: Restore cache for _build/cake - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: _build/cake key: build-cake-${{ hashFiles('build.cake') }} - name: Restore cache for _build/lib/nuget - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: _build/lib/nuget key: nuget-oldref-modules-${{ hashFiles('**/packages.config') }}-${{ hashFiles('**/*.csproj') }} - name: Restore cache for ~/.nuget/packages - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.nuget/packages key: nuget-packref-modules-${{ hashFiles('**/packages.config') }}-${{ hashFiles('**/*.csproj') }} @@ -69,7 +69,7 @@ jobs: if: matrix.configuration == 'release' && ( matrix.mono == '6.8' || matrix.mono == 'latest' ) - name: Upload ckan.exe artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ckan.exe path: _build/repack/Release/ckan.exe @@ -100,26 +100,26 @@ jobs: steps: - uses: actions/checkout@v3 - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: '5.0.x' - name: Restore cache for _build/tools - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: _build/tools key: build-tools-${{ hashFiles('build', 'build.ps1', 'build.cake') }} - name: Restore cache for _build/cake - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: _build/cake key: build-cake-${{ hashFiles('build.cake') }} - name: Restore cache for _build/lib/nuget - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: _build/lib/nuget key: nuget-oldref-modules-${{ hashFiles('**/packages.config') }}-${{ hashFiles('**/*.csproj') }} - name: Restore cache for ~/.nuget/packages - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.nuget/packages key: nuget-packref-modules-${{ hashFiles('**/packages.config') }}-${{ hashFiles('**/*.csproj') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7764f2f7f6..f0e516287b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file. - [Netkan] Fix Netkan swinfo transformer null list error (#3869 by: HebaruSan) - [Tooling] Deduce primary branch name in merge script (#3884 by: HebaruSan; reviewed: techman83) +- [CLI] Parse quoted strings for `ckan prompt` (#3889 by: HebaruSan; reviewed: techman83) ## v1.33.2 (Laplace) diff --git a/Cmdline/Action/Prompt.cs b/Cmdline/Action/Prompt.cs index b9cd803636..d3f287cb6f 100644 --- a/Cmdline/Action/Prompt.cs +++ b/Cmdline/Action/Prompt.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Linq; using System.Collections.Generic; +using System.Text.RegularExpressions; using CommandLine; using CommandLine.Text; @@ -52,7 +53,8 @@ public int RunCommand(object raw_options) { // Parse input as if it was a normal command line, // but with a persistent GameInstanceManager object. - int cmdExitCode = MainClass.Execute(manager, opts, command.Split(' ')); + int cmdExitCode = MainClass.Execute(manager, opts, + ParseTextField(command)); // Clear the command if no exception was thrown if (headless && cmdExitCode != Exit.OK) { @@ -70,7 +72,27 @@ public int RunCommand(object raw_options) return Exit.OK; } - private string ReadLineWithCompletion(bool headless) + /// + /// Split string on spaces, unless they are between quotes. + /// Inspired by https://stackoverflow.com/a/14655145/2422988 + /// + /// The string to parse + /// Array split by strings, with quoted parts joined together + private static string[] ParseTextField(string input) + => quotePattern.Matches(input) + .Cast() + .Select(m => m.Value) + .ToArray(); + + /// + /// Look for non-quotes surrounded by quotes, or non-space-or-quotes, or end preceded by space. + /// No attempt to allow escaped quotes within quotes. + /// Inspired by https://stackoverflow.com/a/14655145/2422988 + /// + private static readonly Regex quotePattern = new Regex( + @"(?<="")[^""]*(?="")|[^ ""]+|(?<= )$", RegexOptions.Compiled); + + private static string ReadLineWithCompletion(bool headless) { try { @@ -87,7 +109,7 @@ private string ReadLineWithCompletion(bool headless) private string[] GetSuggestions(string text, int index) { - string[] pieces = text.Split(new char[] { ' ' }); + string[] pieces = ParseTextField(text); TypeInfo ti = typeof(Actions).GetTypeInfo(); List extras = new List { exitCommand, "help" }; foreach (string piece in pieces.Take(pieces.Length - 1)) @@ -103,88 +125,99 @@ private string[] GetSuggestions(string text, int index) extras.Clear(); } var lastPiece = pieces.LastOrDefault() ?? ""; - return lastPiece.StartsWith("--") ? GetOptions(ti, lastPiece.Substring(2)) - : HasVerbs(ti) ? GetVerbs(ti, lastPiece, extras) - : WantsAvailIdentifiers(ti) ? GetAvailIdentifiers(lastPiece) - : WantsInstIdentifiers(ti) ? GetInstIdentifiers(lastPiece) - : WantsGameInstances(ti) ? GetGameInstances(lastPiece) - : null; + return lastPiece.StartsWith("--") ? GetLongOptions(ti, lastPiece.Substring(2)) + : lastPiece.StartsWith("-") ? GetShortOptions(ti, lastPiece.Substring(1)) + : HasVerbs(ti) ? GetVerbs(ti, lastPiece, extras) + : WantsAvailIdentifiers(ti) ? GetAvailIdentifiers(lastPiece) + : WantsInstIdentifiers(ti) ? GetInstIdentifiers(lastPiece) + : WantsGameInstances(ti) ? GetGameInstances(lastPiece) + : null; } - private string[] GetOptions(TypeInfo ti, string prefix) - { - return ti.DeclaredProperties - .Select(p => p.GetCustomAttribute()?.LongName) + private static string[] GetLongOptions(TypeInfo ti, string prefix) + => AllBaseTypes(ti.AsType()) + .SelectMany(t => t.GetTypeInfo().DeclaredProperties) + .Select(p => p.GetCustomAttribute()?.LongName + ?? p.GetCustomAttribute()?.LongName + ?? p.GetCustomAttribute()?.LongName) .Where(o => o != null && o.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) .OrderBy(o => o) .Select(o => $"--{o}") .ToArray(); - } - - private bool HasVerbs(TypeInfo ti) - { - return ti.DeclaredProperties - .Any(p => p.GetCustomAttribute() != null); - } - private string[] GetVerbs(TypeInfo ti, string prefix, IEnumerable extras) - { - return ti.DeclaredProperties - .Select(p => p.GetCustomAttribute()?.LongName) - .Where(v => v != null) - .Concat(extras) - .Where(v => v.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) - .OrderBy(v => v) + private static string[] GetShortOptions(TypeInfo ti, string prefix) + => AllBaseTypes(ti.AsType()) + .SelectMany(t => t.GetTypeInfo().DeclaredProperties) + .Select(p => p.GetCustomAttribute()?.ShortName + ?? p.GetCustomAttribute()?.ShortName + ?? p.GetCustomAttribute()?.ShortName) + .Where(o => o != null && $"{o}".StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) + .OrderBy(o => o) + .Select(o => $"-{o}") .ToArray(); - } - private bool WantsAvailIdentifiers(TypeInfo ti) + private static IEnumerable AllBaseTypes(Type start) { - return ti.DeclaredProperties - .Any(p => p.GetCustomAttribute() != null); + for (Type t = start; t != null; t = t.BaseType) + { + yield return t; + } } + private static bool HasVerbs(TypeInfo ti) + => ti.DeclaredProperties + .Any(p => p.GetCustomAttribute() != null); + + private static string[] GetVerbs(TypeInfo ti, string prefix, IEnumerable extras) + => ti.DeclaredProperties + .Select(p => p.GetCustomAttribute()?.LongName) + .Where(v => v != null) + .Concat(extras) + .Where(v => v.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) + .OrderBy(v => v) + .ToArray(); + + private static bool WantsAvailIdentifiers(TypeInfo ti) + => ti.DeclaredProperties + .Any(p => p.GetCustomAttribute() != null); + private string[] GetAvailIdentifiers(string prefix) { CKAN.GameInstance inst = MainClass.GetGameInstance(manager); - return RegistryManager.Instance(inst).registry - .CompatibleModules(inst.VersionCriteria()) - .Where(m => !m.IsDLC) - .Select(m => m.identifier) - .Where(ident => ident.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) - .ToArray(); + return RegistryManager.Instance(inst) + .registry + .CompatibleModules(inst.VersionCriteria()) + .Where(m => !m.IsDLC) + .Select(m => m.identifier) + .Where(ident => ident.StartsWith(prefix, + StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); } - private bool WantsInstIdentifiers(TypeInfo ti) - { - return ti.DeclaredProperties - .Any(p => p.GetCustomAttribute() != null); - } + private static bool WantsInstIdentifiers(TypeInfo ti) + => ti.DeclaredProperties + .Any(p => p.GetCustomAttribute() != null); private string[] GetInstIdentifiers(string prefix) { CKAN.GameInstance inst = MainClass.GetGameInstance(manager); var registry = RegistryManager.Instance(inst).registry; return registry.Installed(false, false) - .Select(kvp => kvp.Key) - .Where(ident => ident.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) - && !registry.GetInstalledVersion(ident).IsDLC) - .ToArray(); + .Select(kvp => kvp.Key) + .Where(ident => ident.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase) + && !registry.GetInstalledVersion(ident).IsDLC) + .ToArray(); } - private bool WantsGameInstances(TypeInfo ti) - { - return ti.DeclaredProperties - .Any(p => p.GetCustomAttribute() != null); - } + private static bool WantsGameInstances(TypeInfo ti) + => ti.DeclaredProperties + .Any(p => p.GetCustomAttribute() != null); private string[] GetGameInstances(string prefix) - { - return manager.Instances - .Select(kvp => kvp.Key) - .Where(ident => ident.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) - .ToArray(); - } + => manager.Instances + .Select(kvp => kvp.Key) + .Where(ident => ident.StartsWith(prefix, StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); private readonly GameInstanceManager manager; private const string exitCommand = "exit"; diff --git a/Cmdline/Options.cs b/Cmdline/Options.cs index a7b3164e2a..11e89f0a52 100644 --- a/Cmdline/Options.cs +++ b/Cmdline/Options.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Collections.Generic; using System.Text.RegularExpressions; + using log4net; using log4net.Core; using CommandLine; diff --git a/Core/Meta.cs b/Core/Meta.cs index c4d8a620d8..dac54e12bb 100644 --- a/Core/Meta.cs +++ b/Core/Meta.cs @@ -23,32 +23,31 @@ public static string GetVersion(VersionFormat format = VersionFormat.Normal) .GetAssemblyAttribute() .InformationalVersion; - var dashIndex = version.IndexOf('-'); - var plusIndex = version.IndexOf('+'); - switch (format) { case VersionFormat.Short: - if (dashIndex >= 0) - version = version.Substring(0, dashIndex); - else if (plusIndex >= 0) - version = version.Substring(0, plusIndex); - - break; + return $"v{version.UpToCharacters(shortDelimiters)}"; case VersionFormat.Normal: - if (plusIndex >= 0) - version = version.Substring(0, plusIndex); - - break; + return $"v{version.UpToCharacter('+')}"; case VersionFormat.Full: - break; + return $"v{version}"; default: throw new ArgumentOutOfRangeException(nameof(format), format, null); } - - return "v" + version; } + private static readonly char[] shortDelimiters = new char[] { '-', '+' }; + + private static string UpToCharacter(this string orig, char what) + => orig.UpToIndex(orig.IndexOf(what)); + + private static string UpToCharacters(this string orig, char[] what) + => orig.UpToIndex(orig.IndexOfAny(what)); + + private static string UpToIndex(this string orig, int index) + => index == -1 ? orig + : orig.Substring(0, index); + private static T GetAssemblyAttribute(this Assembly assembly) => (T)assembly.GetCustomAttributes(typeof(T), false) .First(); diff --git a/Core/Meta.cs.in b/Core/Meta.cs.in deleted file mode 100644 index 26a4da002a..0000000000 --- a/Core/Meta.cs.in +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.RegularExpressions; - -namespace CKAN -{ - public static class Meta - { - public readonly static string Development = "development"; - - // Do *not* change the following line, BUILD_VERSION is - // replaced by our build system with our actual version. - - private readonly static string BUILD_VERSION = <%version%>; - - /// - /// Returns the version of the CKAN.dll used, complete with git info - /// and other decorations as filled in by our build system. - /// Eg: v1.3.5-12-g055d7c3 (beta) or "development (unstable)" - /// - public static string Version() - { - string version = BuildVersion(); - - #if (STABLE) - version += " (stable)"; - #else - version += " (beta)"; - #endif - - return version; - } - - /// - /// Returns only the build info, with no decorations, or "development" if - /// unknown. - /// - public static string BuildVersion() - { - return BUILD_VERSION ?? Development; - } - - /// - /// Returns just our release number (eg: 1.0.3), or null for a dev build. - /// - public static Version ReleaseNumber() - { - string build_version = BuildVersion(); - - if (build_version == Development) - { - return null; - } - - string short_version = Regex.Match(build_version, @"^(.*)-\d+-.*$").Result("$1"); - - return new Version(short_version); - } - - /// - /// Returns true if this is a 'stable' build, false otherwise. - /// - public static bool IsStable() - { - #if (STABLE) - return true; - #else - return false; - #endif - } - } -} diff --git a/Core/Properties/Resources.fr-FR.resx b/Core/Properties/Resources.fr-FR.resx index 60bef93254..53cd781054 100644 --- a/Core/Properties/Resources.fr-FR.resx +++ b/Core/Properties/Resources.fr-FR.resx @@ -641,9 +641,6 @@ Libérez de l'espace sur cet appareil ou modifiez vos paramètres pour utiliser {0} {1} ({2}, {3} restant) - - * Installation : {0} {1} ({2}, {3} restant) - * Mise à jour : {0} {1} vers {2} ({3}, {4} restant) diff --git a/Core/Properties/Resources.it-IT.resx b/Core/Properties/Resources.it-IT.resx index 2d98b7c075..dac4472307 100644 --- a/Core/Properties/Resources.it-IT.resx +++ b/Core/Properties/Resources.it-IT.resx @@ -630,9 +630,6 @@ Libera spazio su quel dispositivo o modifica le impostazioni per utilizzare un'a {0} {1} ({2}, {3} rimanenti) - - * Installa: {0} {1} ({2}, {3} rimanenti) - * Aggiorna: {0} {1} a {2} ({3}, {4} rimanenti) diff --git a/Core/Properties/Resources.pl-PL.resx b/Core/Properties/Resources.pl-PL.resx index 8f76375558..acf3f82d2f 100644 --- a/Core/Properties/Resources.pl-PL.resx +++ b/Core/Properties/Resources.pl-PL.resx @@ -630,9 +630,6 @@ Zwolnij miejsce na tym urządzeniu lub zmień ustawienia, aby użyć innej lokal {0} {1} ({2}, {3} pozostało) - - * Instalacja: {0} {1} ({2}, {3} pozostało) - * Aktualizacja: {0} {1} do {2} ({3}, {4} pozostało) diff --git a/Core/Properties/Resources.resx b/Core/Properties/Resources.resx index f2d1af10af..210054617e 100644 --- a/Core/Properties/Resources.resx +++ b/Core/Properties/Resources.resx @@ -304,7 +304,6 @@ Free up space on that device or change your settings to use another location. {0} {1} (cached) {0} {1} ({2}, {3}) {0} {1} ({2}, {3} remaining) - * Install: {0} {1} ({2}, {3} remaining) * Upgrade: {0} {1} to {2} ({3}, {4} remaining) installed-{0}