diff --git a/Directory.Build.props b/Directory.Build.props index 8ca8a8757b..9921cb358e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,4 @@ - + @@ -16,6 +16,7 @@ true MicrosoftNuGet Microsoft + Microsoft3rdPartyAppComponentDual true true diff --git a/DotNetTools.sln b/DotNetTools.sln index 82fefe40d0..d996c043b6 100644 --- a/DotNetTools.sln +++ b/DotNetTools.sln @@ -56,6 +56,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.VisualStudio.Secr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat", "src\Microsoft.AspNetCore.DeveloperCertificates.XPlat\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj", "{96E71881-1465-44F5-B4B7-DF9B370FFD02}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.HttpRepl", "src\Microsoft.HttpRepl\Microsoft.HttpRepl.csproj", "{4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Repl", "src\Microsoft.Repl\Microsoft.Repl.csproj", "{EE9A6128-3DE2-4206-A5A4-3ED935084590}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Repl.Tests", "test\Microsoft.Repl.Tests\Microsoft.Repl.Tests.csproj", "{59C2B354-3B5E-40EB-A7BC-74583A5707CA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.HttpRepl.Tests", "test\Microsoft.HttpRepl.Tests\Microsoft.HttpRepl.Tests.csproj", "{BE7CC4CD-CD76-4211-B593-CAC84407162A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -140,6 +148,38 @@ Global {96E71881-1465-44F5-B4B7-DF9B370FFD02}.Release|Any CPU.Build.0 = Release|Any CPU {96E71881-1465-44F5-B4B7-DF9B370FFD02}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU {96E71881-1465-44F5-B4B7-DF9B370FFD02}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.Release|Any CPU.Build.0 = Release|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.Release|Any CPU.Build.0 = Release|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {EE9A6128-3DE2-4206-A5A4-3ED935084590}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.Release|Any CPU.Build.0 = Release|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {59C2B354-3B5E-40EB-A7BC-74583A5707CA}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.Release|Any CPU.Build.0 = Release|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {BE7CC4CD-CD76-4211-B593-CAC84407162A}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -155,6 +195,10 @@ Global {5E117F2E-7152-447F-BF47-59F759EEF3A7} = {62826851-7D74-4F1E-B7D1-12553B789CD8} {965F8820-F809-4081-9090-1AEC903F291B} = {62826851-7D74-4F1E-B7D1-12553B789CD8} {96E71881-1465-44F5-B4B7-DF9B370FFD02} = {66517987-2A5A-4330-B130-207039378FD4} + {4725BEAD-34F0-43C1-BF46-7AB16B4DE81D} = {66517987-2A5A-4330-B130-207039378FD4} + {EE9A6128-3DE2-4206-A5A4-3ED935084590} = {66517987-2A5A-4330-B130-207039378FD4} + {59C2B354-3B5E-40EB-A7BC-74583A5707CA} = {F5B382BC-258F-46E1-AC3D-10E5CCD55134} + {BE7CC4CD-CD76-4211-B593-CAC84407162A} = {F5B382BC-258F-46E1-AC3D-10E5CCD55134} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {57C07F14-2EAC-44FF-A277-B9221B4B2BF7} diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json index f5555f4929..6ea6e98cc5 100644 --- a/NuGetPackageVerifier.json +++ b/NuGetPackageVerifier.json @@ -24,6 +24,20 @@ "DotnetTool" ] }, + "dotnet-httprepl": { + "packageTypes": [ + "DotnetTool" + ], + "Exclusions": { + "ASSEMBLY_DESCRIPTION": { + "tools/netcoreapp3.0/any/System.Net.Http.Formatting.dll": "Referenced assembly, not built as part of this process" + }, + "VERSION_INFORMATIONALVERSION": { + "tools/netcoreapp3.0/any/Newtonsoft.Json.dll": "Referenced assembly, not built as part of this process", + "tools/netcoreapp3.0/any/Newtonsoft.Json.Bson.dll": "Referenced assembly, not built as part of this process" + } + } + }, "Microsoft.AspNetCore.DeveloperCertificates.XPlat": { "Exclusions": { "DOC_MISSING": { diff --git a/build/dependencies.props b/build/dependencies.props index 12e9bde1cc..da982434a6 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -1,4 +1,4 @@ - + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) @@ -11,13 +11,15 @@ 3.0.0-alpha1-10352 3.0.0-preview1-26821-04 15.6.1 + 5.2.6 2.0.3 4.6.0-preview1-26817-04 4.6.0-preview1-26817-04 + 11.0.2 9.0.1 2.3.1 2.4.0 - + diff --git a/src/Microsoft.HttpRepl/AggregateDirectoryStructure.cs b/src/Microsoft.HttpRepl/AggregateDirectoryStructure.cs new file mode 100644 index 0000000000..19dcb289f8 --- /dev/null +++ b/src/Microsoft.HttpRepl/AggregateDirectoryStructure.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.HttpRepl +{ + public class AggregateDirectoryStructure : IDirectoryStructure + { + private readonly IDirectoryStructure _first; + private readonly IDirectoryStructure _second; + + public AggregateDirectoryStructure(IDirectoryStructure first, IDirectoryStructure second) + { + _first = first; + _second = second; + } + + public IEnumerable DirectoryNames + { + get + { + HashSet values = new HashSet(StringComparer.OrdinalIgnoreCase); + values.UnionWith(_first.DirectoryNames); + values.UnionWith(_second.DirectoryNames); + return values.OrderBy(x => x, StringComparer.OrdinalIgnoreCase); + } + } + + public IDirectoryStructure Parent => _first.Parent ?? _second.Parent; + + public IDirectoryStructure GetChildDirectory(string name) + { + return new AggregateDirectoryStructure(_first.GetChildDirectory(name), _second.GetChildDirectory(name)); + } + + public IRequestInfo RequestInfo => _first.RequestInfo ?? _second.RequestInfo; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/BaseHttpCommand.cs b/src/Microsoft.HttpRepl/Commands/BaseHttpCommand.cs new file mode 100644 index 0000000000..9a5eba8227 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/BaseHttpCommand.cs @@ -0,0 +1,594 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.HttpRepl.Formatting; +using Microsoft.HttpRepl.Preferences; +using Microsoft.HttpRepl.Suggestions; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; +using Microsoft.Repl.Suggestions; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.Commands +{ + public abstract class BaseHttpCommand : CommandWithStructuredInputBase + { + private const string HeaderOption = nameof(HeaderOption); + private const string ResponseHeadersFileOption = nameof(ResponseHeadersFileOption); + private const string ResponseBodyFileOption = nameof(ResponseBodyFileOption); + private const string ResponseFileOption = nameof(ResponseFileOption); + private const string BodyFileOption = nameof(BodyFileOption); + private const string NoBodyOption = nameof(NoBodyOption); + private const string NoFormattingOption = nameof(NoFormattingOption); + private const string StreamingOption = nameof(StreamingOption); + private const string BodyContentOption = nameof(BodyContentOption); + private static readonly char[] HeaderSeparatorChars = new[] { '=', ':' }; + + private CommandInputSpecification _inputSpec; + + protected abstract string Verb { get; } + + protected abstract bool RequiresBody { get; } + + protected override CommandInputSpecification InputSpec + { + get + { + if (_inputSpec != null) + { + return _inputSpec; + } + + CommandInputSpecificationBuilder builder = CommandInputSpecification.Create(Verb) + .MaximumArgCount(1) + .WithOption(new CommandOptionSpecification(HeaderOption, requiresValue: true, forms: new[] {"--header", "-h"})) + .WithOption(new CommandOptionSpecification(ResponseFileOption, requiresValue: true, maximumOccurrences: 1, forms: new[] { "--response", })) + .WithOption(new CommandOptionSpecification(ResponseHeadersFileOption, requiresValue: true, maximumOccurrences: 1, forms: new[] { "--response:headers", })) + .WithOption(new CommandOptionSpecification(ResponseBodyFileOption, requiresValue: true, maximumOccurrences: 1, forms: new[] { "--response:body", })) + .WithOption(new CommandOptionSpecification(NoFormattingOption, maximumOccurrences: 1, forms: new[] { "--no-formatting", "-F" })) + .WithOption(new CommandOptionSpecification(StreamingOption, maximumOccurrences: 1, forms: new[] { "--streaming", "-s" })); + + if (RequiresBody) + { + builder = builder.WithOption(new CommandOptionSpecification(NoBodyOption, maximumOccurrences: 1, forms: "--no-body")) + .WithOption(new CommandOptionSpecification(BodyFileOption, requiresValue: true, maximumOccurrences: 1, forms: new[] {"--file", "-f"})) + .WithOption(new CommandOptionSpecification(BodyContentOption, requiresValue: true, maximumOccurrences: 1, forms: new[] {"--content", "-c"})); + } + + _inputSpec = builder.Finish(); + return _inputSpec; + } + } + + protected override async Task ExecuteAsync(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (programState.BaseAddress == null && (commandInput.Arguments.Count == 0 || !Uri.TryCreate(commandInput.Arguments[0].Text, UriKind.Absolute, out Uri _))) + { + shellState.ConsoleManager.Error.WriteLine("'set base {url}' must be called before issuing requests to a relative path".SetColor(programState.ErrorColor)); + return; + } + + if (programState.SwaggerEndpoint != null) + { + string swaggerRequeryBehaviorSetting = programState.GetStringPreference(WellKnownPreference.SwaggerRequeryBehavior, "auto"); + + if (swaggerRequeryBehaviorSetting.StartsWith("auto", StringComparison.OrdinalIgnoreCase)) + { + await SetSwaggerCommand.CreateDirectoryStructureForSwaggerEndpointAsync(shellState, programState, programState.SwaggerEndpoint, cancellationToken).ConfigureAwait(false); + } + } + + Dictionary thisRequestHeaders = new Dictionary(); + + foreach (InputElement header in commandInput.Options[HeaderOption]) + { + int equalsIndex = header.Text.IndexOfAny(HeaderSeparatorChars); + + if (equalsIndex < 0) + { + shellState.ConsoleManager.Error.WriteLine("Headers must be formatted as {header}={value} or {header}:{value}".SetColor(programState.ErrorColor)); + return; + } + + thisRequestHeaders[header.Text.Substring(0, equalsIndex)] = header.Text.Substring(equalsIndex + 1); + } + + Uri effectivePath = programState.GetEffectivePath(commandInput.Arguments.Count > 0 ? commandInput.Arguments[0].Text : string.Empty); + HttpRequestMessage request = new HttpRequestMessage(new HttpMethod(Verb.ToUpperInvariant()), effectivePath); + bool noBody = false; + + if (RequiresBody) + { + string filePath = null; + string bodyContent = null; + bool deleteFile = false; + noBody = commandInput.Options[NoBodyOption].Count > 0; + + if (!noBody) + { + if (commandInput.Options[BodyFileOption].Count > 0) + { + filePath = commandInput.Options[BodyFileOption][0].Text; + + if (!File.Exists(filePath)) + { + shellState.ConsoleManager.Error.WriteLine($"Content file {filePath} does not exist".SetColor(programState.ErrorColor)); + return; + } + } + else if (commandInput.Options[BodyContentOption].Count > 0) + { + bodyContent = commandInput.Options[BodyContentOption][0].Text; + } + else + { + string defaultEditorCommand = programState.GetStringPreference(WellKnownPreference.DefaultEditorCommand); + if (defaultEditorCommand == null) + { + shellState.ConsoleManager.Error.WriteLine($"The default editor must be configured using the command `pref set {WellKnownPreference.DefaultEditorCommand} \"{{commandline}}\"`".SetColor(programState.ErrorColor)); + return; + } + + deleteFile = true; + filePath = Path.GetTempFileName(); + + if (!thisRequestHeaders.TryGetValue("content-type", out string contentType) && programState.Headers.TryGetValue("content-type", out IEnumerable contentTypes)) + { + contentType = contentTypes.FirstOrDefault(); + } + + if (contentType == null) + { + contentType = "application/json"; + } + + string exampleBody = programState.GetExampleBody(commandInput.Arguments.Count > 0 ? commandInput.Arguments[0].Text : string.Empty, contentType, Verb); + request.Headers.TryAddWithoutValidation("Content-Type", contentType); + + if (!string.IsNullOrEmpty(exampleBody)) + { + File.WriteAllText(filePath, exampleBody); + } + + string defaultEditorArguments = programState.GetStringPreference(WellKnownPreference.DefaultEditorArguments) ?? ""; + string original = defaultEditorArguments; + string pathString = $"\"{filePath}\""; + + defaultEditorArguments = defaultEditorArguments.Replace("{filename}", pathString); + + if (string.Equals(defaultEditorArguments, original, StringComparison.Ordinal)) + { + defaultEditorArguments = (defaultEditorArguments + " " + pathString).Trim(); + } + + ProcessStartInfo info = new ProcessStartInfo(defaultEditorCommand, defaultEditorArguments); + + Process.Start(info)?.WaitForExit(); + } + } + + byte[] data = noBody + ? new byte[0] + : string.IsNullOrEmpty(bodyContent) + ? File.ReadAllBytes(filePath) + : Encoding.UTF8.GetBytes(bodyContent); + + HttpContent content = new ByteArrayContent(data); + request.Content = content; + + if (deleteFile) + { + File.Delete(filePath); + } + + foreach (KeyValuePair> header in programState.Headers) + { + content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + foreach (KeyValuePair header in thisRequestHeaders) + { + content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + foreach (KeyValuePair> header in programState.Headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + foreach (KeyValuePair header in thisRequestHeaders) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + string headersTarget = commandInput.Options[ResponseHeadersFileOption].FirstOrDefault()?.Text ?? commandInput.Options[ResponseFileOption].FirstOrDefault()?.Text; + string bodyTarget = commandInput.Options[ResponseBodyFileOption].FirstOrDefault()?.Text ?? commandInput.Options[ResponseFileOption].FirstOrDefault()?.Text; + + HttpResponseMessage response = await programState.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + await HandleResponseAsync(programState, commandInput, shellState.ConsoleManager, response, programState.EchoRequest, headersTarget, bodyTarget, cancellationToken).ConfigureAwait(false); + } + + private static async Task HandleResponseAsync(HttpState programState, DefaultCommandInput commandInput, IConsoleManager consoleManager, HttpResponseMessage response, bool echoRequest, string headersTargetFile, string bodyTargetFile, CancellationToken cancellationToken) + { + RequestConfig requestConfig = new RequestConfig(programState); + ResponseConfig responseConfig = new ResponseConfig(programState); + string protocolInfo; + + if (echoRequest) + { + string hostString = response.RequestMessage.RequestUri.Scheme + "://" + response.RequestMessage.RequestUri.Host + (!response.RequestMessage.RequestUri.IsDefaultPort ? ":" + response.RequestMessage.RequestUri.Port : ""); + consoleManager.WriteLine($"Request to {hostString}...".SetColor(requestConfig.AddressColor)); + consoleManager.WriteLine(); + + string method = response.RequestMessage.Method.ToString().ToUpperInvariant().SetColor(requestConfig.MethodColor); + string pathAndQuery = response.RequestMessage.RequestUri.PathAndQuery.SetColor(requestConfig.AddressColor); + protocolInfo = $"{"HTTP".SetColor(requestConfig.ProtocolNameColor)}{"/".SetColor(requestConfig.ProtocolSeparatorColor)}{response.Version.ToString().SetColor(requestConfig.ProtocolVersionColor)}"; + + consoleManager.WriteLine($"{method} {pathAndQuery} {protocolInfo}"); + IEnumerable>> requestHeaders = response.RequestMessage.Headers; + + if (response.RequestMessage.Content != null) + { + requestHeaders = requestHeaders.Union(response.RequestMessage.Content.Headers); + } + + foreach (KeyValuePair> header in requestHeaders.OrderBy(x => x.Key)) + { + string headerKey = header.Key.SetColor(requestConfig.HeaderKeyColor); + string headerSep = ":".SetColor(requestConfig.HeaderSeparatorColor); + string headerValue = string.Join(";".SetColor(requestConfig.HeaderValueSeparatorColor), header.Value.Select(x => x.Trim().SetColor(requestConfig.HeaderValueColor))); + consoleManager.WriteLine($"{headerKey}{headerSep} {headerValue}"); + } + + consoleManager.WriteLine(); + + if (response.RequestMessage.Content != null) + { + using (StreamWriter writer = new StreamWriter(new MemoryStream())) + { + await FormatBodyAsync(commandInput, programState, consoleManager, response.RequestMessage.Content, writer, cancellationToken).ConfigureAwait(false); + } + } + + consoleManager.WriteLine(); + consoleManager.WriteLine($"Response from {hostString}...".SetColor(requestConfig.AddressColor)); + consoleManager.WriteLine(); + } + + protocolInfo = $"{"HTTP".SetColor(responseConfig.ProtocolNameColor)}{"/".SetColor(responseConfig.ProtocolSeparatorColor)}{response.Version.ToString().SetColor(responseConfig.ProtocolVersionColor)}"; + string status = ((int)response.StatusCode).ToString().SetColor(responseConfig.StatusCodeColor) + " " + response.ReasonPhrase.SetColor(responseConfig.StatusReasonPhraseColor); + + consoleManager.WriteLine($"{protocolInfo} {status}"); + + IEnumerable>> responseHeaders = response.Headers; + + if (response.Content != null) + { + responseHeaders = responseHeaders.Union(response.Content.Headers); + } + + StreamWriter headerFileWriter; + + if (headersTargetFile != null) + { + headerFileWriter = new StreamWriter(File.Create(headersTargetFile)); + } + else + { + headerFileWriter = new StreamWriter(new MemoryStream()); + } + + foreach (KeyValuePair> header in responseHeaders.OrderBy(x => x.Key)) + { + string headerKey = header.Key.SetColor(responseConfig.HeaderKeyColor); + string headerSep = ":".SetColor(responseConfig.HeaderSeparatorColor); + string headerValue = string.Join(";".SetColor(responseConfig.HeaderValueSeparatorColor), header.Value.Select(x => x.Trim().SetColor(responseConfig.HeaderValueColor))); + consoleManager.WriteLine($"{headerKey}{headerSep} {headerValue}"); + headerFileWriter.WriteLine($"{header.Key}: {string.Join(";", header.Value.Select(x => x.Trim()))}"); + } + + StreamWriter bodyFileWriter; + if (!string.Equals(headersTargetFile, bodyTargetFile, StringComparison.Ordinal)) + { + headerFileWriter.Flush(); + headerFileWriter.Close(); + headerFileWriter.Dispose(); + + if (bodyTargetFile != null) + { + bodyFileWriter = new StreamWriter(File.Create(bodyTargetFile)); + } + else + { + bodyFileWriter = new StreamWriter(new MemoryStream()); + } + } + else + { + headerFileWriter.WriteLine(); + bodyFileWriter = headerFileWriter; + } + + consoleManager.WriteLine(); + + if (response.Content != null) + { + await FormatBodyAsync(commandInput, programState, consoleManager, response.Content, bodyFileWriter, cancellationToken).ConfigureAwait(false); + } + + bodyFileWriter.Flush(); + bodyFileWriter.Close(); + bodyFileWriter.Dispose(); + + consoleManager.WriteLine(); + } + + private static async Task FormatBodyAsync(DefaultCommandInput commandInput, HttpState programState, IConsoleManager consoleManager, HttpContent content, StreamWriter bodyFileWriter, CancellationToken cancellationToken) + { + if (commandInput.Options[StreamingOption].Count > 0) + { + Memory buffer = new Memory(new char[2048]); + Stream s = await content.ReadAsStreamAsync().ConfigureAwait(false); + StreamReader reader = new StreamReader(s); + consoleManager.WriteLine("Streaming the response, press any key to stop...".SetColor(programState.WarningColor)); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + ValueTask readTask = reader.ReadAsync(buffer, cancellationToken); + if (await WaitForCompletionAsync(readTask, cancellationToken).ConfigureAwait(false)) + { + if (readTask.Result == 0) + { + break; + } + + string str = new string(buffer.Span.Slice(0, readTask.Result)); + consoleManager.Write(str); + bodyFileWriter.Write(str); + } + else + { + break; + } + } + catch (OperationCanceledException) + { + } + } + + return; + } + + string contentType = null; + if (content.Headers.TryGetValues("Content-Type", out IEnumerable contentTypeValues)) + { + contentType = contentTypeValues.FirstOrDefault()?.Split(';').FirstOrDefault(); + } + + contentType = contentType?.ToUpperInvariant() ?? "text/plain"; + + if (commandInput.Options[NoFormattingOption].Count == 0) + { + if (contentType.EndsWith("/JSON", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("-JSON", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("+JSON", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("/JAVASCRIPT", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("-JAVASCRIPT", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("+JAVASCRIPT", StringComparison.OrdinalIgnoreCase)) + { + if (await FormatJsonAsync(programState, consoleManager, content, bodyFileWriter)) + { + return; + } + } + else if (contentType.EndsWith("/HTML", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("-HTML", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("+HTML", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("/XML", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("-XML", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("+XML", StringComparison.OrdinalIgnoreCase)) + { + if (await FormatXmlAsync(consoleManager, content, bodyFileWriter)) + { + return; + } + } + } + + string responseContent = await content.ReadAsStringAsync().ConfigureAwait(false); + bodyFileWriter.WriteLine(responseContent); + consoleManager.WriteLine(responseContent); + } + + private static async Task WaitForCompletionAsync(ValueTask readTask, CancellationToken cancellationToken) + { + while (!readTask.IsCompleted && !cancellationToken.IsCancellationRequested && !Console.KeyAvailable) + { + await Task.Delay(1, cancellationToken).ConfigureAwait(false); + } + + if (Console.KeyAvailable) + { + Console.ReadKey(false); + return false; + } + + return readTask.IsCompleted; + } + + private static async Task FormatXmlAsync(IWritable consoleManager, HttpContent content, StreamWriter bodyFileWriter) + { + string responseContent = await content.ReadAsStringAsync().ConfigureAwait(false); + try + { + XDocument body = XDocument.Parse(responseContent); + consoleManager.WriteLine(body.ToString()); + bodyFileWriter.WriteLine(body.ToString()); + return true; + } + catch + { + } + + return false; + } + + private static async Task FormatJsonAsync(HttpState programState, IWritable outputSink, HttpContent content, StreamWriter bodyFileWriter) + { + string responseContent = await content.ReadAsStringAsync().ConfigureAwait(false); + + try + { + JsonConfig config = new JsonConfig(programState); + string formatted = JsonVisitor.FormatAndColorize(config, responseContent); + outputSink.WriteLine(formatted); + bodyFileWriter.WriteLine(JToken.Parse(responseContent).ToString()); + return true; + } + catch + { + } + + return false; + } + + protected override string GetHelpDetails(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + return $"Issues a {Verb.ToUpperInvariant()} request"; + } + + public override string GetHelpSummary(IShellState shellState, HttpState programState) + { + return $"{Verb.ToLowerInvariant()} - Issues a {Verb.ToUpperInvariant()} request"; + } + + protected override IEnumerable GetArgumentSuggestionsForText(IShellState shellState, HttpState programState, ICoreParseResult parseResult, DefaultCommandInput commandInput, string normalCompletionString) + { + List results = new List(); + + if (programState.Structure != null && programState.BaseAddress != null) + { + //If it's an absolute URI, nothing to suggest + if (Uri.TryCreate(parseResult.Sections[1], UriKind.Absolute, out Uri _)) + { + return null; + } + + string path = normalCompletionString.Replace('\\', '/'); + int searchFrom = normalCompletionString.Length - 1; + int lastSlash = path.LastIndexOf('/', searchFrom); + string prefix; + + if (lastSlash < 0) + { + path = string.Empty; + prefix = normalCompletionString; + } + else + { + path = path.Substring(0, lastSlash + 1); + prefix = normalCompletionString.Substring(lastSlash + 1); + } + + IDirectoryStructure s = programState.Structure.TraverseTo(programState.PathSections.Reverse()).TraverseTo(path); + + foreach (string child in s.DirectoryNames) + { + if (child.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + results.Add(path + child); + } + } + } + + return results; + } + + protected override IEnumerable GetOptionValueCompletions(IShellState shellState, HttpState programState, string optionId, DefaultCommandInput commandInput, ICoreParseResult parseResult, string normalizedCompletionText) + { + if (string.Equals(optionId, BodyFileOption, StringComparison.Ordinal) || string.Equals(optionId, ResponseFileOption, StringComparison.OrdinalIgnoreCase) || string.Equals(optionId, ResponseBodyFileOption, StringComparison.OrdinalIgnoreCase) || string.Equals(optionId, ResponseHeadersFileOption, StringComparison.OrdinalIgnoreCase)) + { + return FileSystemCompletion.GetCompletions(normalizedCompletionText); + } + + if (string.Equals(optionId, HeaderOption, StringComparison.Ordinal)) + { + HashSet alreadySpecifiedHeaders = new HashSet(StringComparer.Ordinal); + IReadOnlyList options = commandInput.Options[HeaderOption]; + for (int i = 0; i < options.Count; ++i) + { + if (options[i] == commandInput.SelectedElement) + { + continue; + } + + string elementText = options[i].Text; + string existingHeaderName = elementText.Split(HeaderSeparatorChars)[0]; + alreadySpecifiedHeaders.Add(existingHeaderName); + } + + //Check to see if the selected element is in a header name or value + int equalsIndex = normalizedCompletionText.IndexOfAny(HeaderSeparatorChars); + string path = commandInput.Arguments.Count > 0 ? commandInput.Arguments[0].Text : string.Empty; + + if (equalsIndex < 0) + { + IEnumerable headerNameOptions = HeaderCompletion.GetCompletions(alreadySpecifiedHeaders, normalizedCompletionText); + + if (headerNameOptions == null) + { + return null; + } + + List allSuggestions = new List(); + foreach (string suggestion in headerNameOptions.Select(x => x)) + { + allSuggestions.Add(suggestion + ":"); + + IEnumerable suggestions = HeaderCompletion.GetValueCompletions(Verb, path, suggestion, string.Empty, programState); + + if (suggestions != null) + { + foreach (string valueSuggestion in suggestions) + { + allSuggestions.Add(suggestion + ":" + valueSuggestion); + } + } + } + + return allSuggestions; + } + else + { + //Didn't exit from the header name check, so must be a value + string headerName = normalizedCompletionText.Substring(0, equalsIndex); + IEnumerable suggestions = HeaderCompletion.GetValueCompletions(Verb, path, headerName, normalizedCompletionText.Substring(equalsIndex + 1), programState); + + if (suggestions == null) + { + return null; + } + + return suggestions.Select(x => normalizedCompletionText.Substring(0, equalsIndex + 1) + x); + } + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/ChangeDirectoryCommand.cs b/src/Microsoft.HttpRepl/Commands/ChangeDirectoryCommand.cs new file mode 100644 index 0000000000..a18aa2df9f --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/ChangeDirectoryCommand.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Suggestions; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class ChangeDirectoryCommand : CommandWithStructuredInputBase + { + protected override Task ExecuteAsync(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (commandInput.Arguments.Count == 0 || string.IsNullOrEmpty(commandInput.Arguments[0]?.Text)) + { + shellState.ConsoleManager.WriteLine($"/{string.Join("/", programState.PathSections.Reverse())}"); + } + else + { + string[] parts = commandInput.Arguments[0].Text.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + if (commandInput.Arguments[0].Text.StartsWith("/", StringComparison.Ordinal)) + { + programState.PathSections.Clear(); + } + + foreach (string part in parts) + { + switch (part) + { + case ".": + break; + case "..": + if (programState.PathSections.Count > 0) + { + programState.PathSections.Pop(); + } + break; + default: + programState.PathSections.Push(part); + break; + } + } + + IDirectoryStructure s = programState.Structure.TraverseTo(programState.PathSections.Reverse()); + + string thisDirMethod = s.RequestInfo != null && s.RequestInfo.Methods.Count > 0 + ? "[" + string.Join("|", s.RequestInfo.Methods) + "]" + : "[]"; + + shellState.ConsoleManager.WriteLine($"/{string.Join("/", programState.PathSections.Reverse())} {thisDirMethod}"); + } + + return Task.CompletedTask; + } + + protected override CommandInputSpecification InputSpec { get; } = CommandInputSpecification.Create("cd") + .MaximumArgCount(1) + .Finish(); + + protected override string GetHelpDetails(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + if (commandInput.Arguments.Count == 1 && !string.IsNullOrEmpty(commandInput.Arguments[0]?.Text)) + { + return "Prints the current directory if no argument is specified, otherwise changes to the specified directory"; + } + + return "Changes to the directory " + commandInput.Arguments[0].Text; + } + + public override string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "cd [directory name] - Prints the current directory if no argument is specified, otherwise changes to the specified directory"; + } + + protected override IEnumerable GetArgumentSuggestionsForText(IShellState shellState, HttpState programState, ICoreParseResult parseResult, DefaultCommandInput commandInput, string normalCompletionString) + { + return ServerPathCompletion.GetCompletions(programState, normalCompletionString); + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/ClearCommand.cs b/src/Microsoft.HttpRepl/Commands/ClearCommand.cs new file mode 100644 index 0000000000..4244946c95 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/ClearCommand.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class ClearCommand : ICommand + { + private static readonly string Name = "clear"; + private static readonly string AlternateName = "cls"; + + public bool? CanHandle(IShellState shellState, object programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count == 1 && (string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) || string.Equals(parseResult.Sections[0], AlternateName, StringComparison.OrdinalIgnoreCase)) + ? (bool?) true + : null; + } + + public Task ExecuteAsync(IShellState shellState, object programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + shellState.ConsoleManager.Clear(); + shellState.CommandDispatcher.OnReady(shellState); + return Task.CompletedTask; + } + + public string GetHelpDetails(IShellState shellState, object programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count == 1 && (string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) || string.Equals(parseResult.Sections[0], AlternateName, StringComparison.OrdinalIgnoreCase))) + { + return "Clears the shell"; + } + + return null; + } + + public string GetHelpSummary(IShellState shellState, object programState) + { + return "clear - Clears the shell"; + } + + public IEnumerable Suggest(IShellState shellState, object programState, ICoreParseResult parseResult) + { + if (parseResult.SelectedSection == 0 && + (string.IsNullOrEmpty(parseResult.Sections[parseResult.SelectedSection]) || Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { Name }; + } + + if (parseResult.SelectedSection == 0 && + (string.IsNullOrEmpty(parseResult.Sections[parseResult.SelectedSection]) || AlternateName.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { Name }; + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/ConfigCommand.cs b/src/Microsoft.HttpRepl/Commands/ConfigCommand.cs new file mode 100644 index 0000000000..938936303f --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/ConfigCommand.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Diagnostics; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class ConfigCommand : CommandWithStructuredInputBase + { + protected override async Task ExecuteAsync(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (programState.BaseAddress == null) + { + shellState.ConsoleManager.Error.WriteLine("Must be connected to a server to query configuration".SetColor(programState.ErrorColor)); + return; + } + + if (string.IsNullOrEmpty(programState.DiagnosticsState.DiagnosticsEndpoint)) + { + shellState.ConsoleManager.Error.WriteLine("Diagnostics endpoint must be set to query configuration (see set diag)".SetColor(programState.ErrorColor)); + return; + } + + string configUrl = programState.DiagnosticsState.DiagnosticItems.FirstOrDefault(x => x.DisplayName == "Configuration")?.Url; + + if (configUrl == null) + { + shellState.ConsoleManager.Error.WriteLine("Diagnostics endpoint does not expose configuration information".SetColor(programState.ErrorColor)); + return; + } + + HttpResponseMessage response = await programState.Client.GetAsync(new Uri(programState.BaseAddress, configUrl), cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + shellState.ConsoleManager.Error.WriteLine("Unable to get configuration information from diagnostics endpoint".SetColor(programState.ErrorColor)); + return; + } + + List configItems = await response.Content.ReadAsAsync>(cancellationToken).ConfigureAwait(false); + + foreach (ConfigItem item in configItems) + { + shellState.ConsoleManager.WriteLine($"{item.Key.Cyan()}: {item.Value}"); + } + } + + protected override CommandInputSpecification InputSpec { get; } = CommandInputSpecification.Create("config").Finish(); + + protected override string GetHelpDetails(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + return "config - Gets configuration information for the site if connected to a diagnostics endpoint"; + } + + public override string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "config - Gets configuration information for the site if connected to a diagnostics endpoint"; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/DeleteCommand.cs b/src/Microsoft.HttpRepl/Commands/DeleteCommand.cs new file mode 100644 index 0000000000..aedef544c0 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/DeleteCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class DeleteCommand : BaseHttpCommand + { + protected override string Verb => "delete"; + + protected override bool RequiresBody => false; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/EchoCommand.cs b/src/Microsoft.HttpRepl/Commands/EchoCommand.cs new file mode 100644 index 0000000000..e81ff810d4 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/EchoCommand.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class EchoCommand : CommandWithStructuredInputBase + { + private readonly HashSet _allowedModes = new HashSet(StringComparer.OrdinalIgnoreCase) {"on", "off"}; + + protected override bool CanHandle(IShellState shellState, HttpState programState, DefaultCommandInput commandInput) + { + if (commandInput.Arguments.Count == 0 || !_allowedModes.Contains(commandInput.Arguments[0]?.Text)) + { + shellState.ConsoleManager.Error.WriteLine("Allowed echo modes are 'on' and 'off'".SetColor(programState.ErrorColor)); + return false; + } + + return true; + } + + protected override Task ExecuteAsync(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + bool turnOn = string.Equals(commandInput.Arguments[0].Text, "on", StringComparison.OrdinalIgnoreCase); + programState.EchoRequest = turnOn; + + shellState.ConsoleManager.WriteLine("Request echoing is " + (turnOn ? "on" : "off")); + return Task.CompletedTask; + } + + protected override CommandInputSpecification InputSpec { get; } = CommandInputSpecification.Create("echo").ExactArgCount(1).Finish(); + + protected override string GetHelpDetails(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + return "Turns request echoing on or off"; + } + + public override string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "echo [on/off] - Turns request echoing on or off"; + } + + protected override IEnumerable GetArgumentSuggestionsForText(IShellState shellState, HttpState programState, ICoreParseResult parseResult, DefaultCommandInput commandInput, string normalCompletionString) + { + List result = _allowedModes.Where(x => x.StartsWith(normalCompletionString, StringComparison.OrdinalIgnoreCase)).ToList(); + return result.Count > 0 ? result : null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/ExitCommand.cs b/src/Microsoft.HttpRepl/Commands/ExitCommand.cs new file mode 100644 index 0000000000..af76ad95df --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/ExitCommand.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class ExitCommand : CommandWithStructuredInputBase + { + protected override Task ExecuteAsync(IShellState shellState, object programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + shellState.IsExiting = true; + return Task.CompletedTask; + } + + protected override CommandInputSpecification InputSpec { get; } = CommandInputSpecification.Create("exit").ExactArgCount(0).Finish(); + + protected override string GetHelpDetails(IShellState shellState, object programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + return "Exits the shell"; + } + + public override string GetHelpSummary(IShellState shellState, object programState) + { + return "exit - Exits the shell"; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/Formatter.cs b/src/Microsoft.HttpRepl/Commands/Formatter.cs new file mode 100644 index 0000000000..d1783dc357 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/Formatter.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class Formatter + { + private int _prefix; + private int _maxDepth; + + public void RegisterEntry(int prefixLength, int depth) + { + if (depth > _maxDepth) + { + _maxDepth = depth; + } + + if (prefixLength > _prefix) + { + _prefix = prefixLength; + } + } + + public string Format(string prefix, string entry, int level) + { + string indent = "".PadRight(level * 4); + return (indent + prefix).PadRight(_prefix + 3 + _maxDepth * 4) + entry; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/GetCommand.cs b/src/Microsoft.HttpRepl/Commands/GetCommand.cs new file mode 100644 index 0000000000..e5441cecd1 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/GetCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class GetCommand : BaseHttpCommand + { + protected override string Verb => "get"; + + protected override bool RequiresBody => false; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/HeadCommand.cs b/src/Microsoft.HttpRepl/Commands/HeadCommand.cs new file mode 100644 index 0000000000..fbd3de4971 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/HeadCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class HeadCommand : BaseHttpCommand + { + protected override string Verb => "head"; + + protected override bool RequiresBody => false; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/HelpCommand.cs b/src/Microsoft.HttpRepl/Commands/HelpCommand.cs new file mode 100644 index 0000000000..6677bde928 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/HelpCommand.cs @@ -0,0 +1,190 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Preferences; +using Microsoft.HttpRepl.Suggestions; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class HelpCommand : ICommand + { + private static readonly string Name = "help"; + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count > 0 && string.Equals(parseResult.Sections[0], Name) + ? (bool?)true + : null; + } + + public async Task ExecuteAsync(IShellState shellState, HttpState programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (shellState.CommandDispatcher is ICommandDispatcher dispatcher) + { + if (parseResult.Sections.Count == 1) + { + CoreGetHelp(shellState, dispatcher, programState); + } + else + { + bool anyHelp = false; + + if (parseResult.Slice(1) is ICoreParseResult continuationParseResult) + { + foreach (ICommand command in dispatcher.Commands) + { + string help = command.GetHelpDetails(shellState, programState, continuationParseResult); + + if (!string.IsNullOrEmpty(help)) + { + anyHelp = true; + shellState.ConsoleManager.WriteLine(help); + } + } + } + + if (!anyHelp) + { + //Maybe the input is an URL + if (parseResult.Sections.Count == 2) + { + + if (programState.SwaggerEndpoint != null) + { + string swaggerRequeryBehaviorSetting = programState.GetStringPreference(WellKnownPreference.SwaggerRequeryBehavior, "auto"); + + if (swaggerRequeryBehaviorSetting.StartsWith("auto", StringComparison.OrdinalIgnoreCase)) + { + await SetSwaggerCommand.CreateDirectoryStructureForSwaggerEndpointAsync(shellState, programState, programState.SwaggerEndpoint, cancellationToken).ConfigureAwait(false); + } + } + + IDirectoryStructure structure = programState.Structure.TraverseTo(parseResult.Sections[1]); + if (structure.DirectoryNames.Any()) + { + shellState.ConsoleManager.WriteLine("Child directories:"); + + foreach (string name in structure.DirectoryNames) + { + shellState.ConsoleManager.WriteLine(" " + name + "/"); + } + + anyHelp = true; + } + + if (structure.RequestInfo != null) + { + if (structure.RequestInfo.Methods.Count > 0) + { + if (anyHelp) + { + shellState.ConsoleManager.WriteLine(); + } + + anyHelp = true; + shellState.ConsoleManager.WriteLine("Available methods:"); + + foreach (string method in structure.RequestInfo.Methods) + { + shellState.ConsoleManager.WriteLine(" " + method.ToUpperInvariant()); + IReadOnlyList accepts = structure.RequestInfo.ContentTypesByMethod[method]; + string acceptsString = string.Join(", ", accepts.Where(x => !string.IsNullOrEmpty(x))); + if (!string.IsNullOrEmpty(acceptsString)) + { + shellState.ConsoleManager.WriteLine(" Accepts: " + acceptsString); + } + } + } + } + } + + if (!anyHelp) + { + shellState.ConsoleManager.WriteLine("Unable to locate any help information for the specified command"); + } + } + } + } + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count > 0 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase)) + { + if (parseResult.Sections.Count > 1) + { + return "Gets help about " + parseResult.Slice(1).CommandText; + } + else + { + return "Gets help"; + } + } + + return null; + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "help - Gets help"; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.SelectedSection == 0 && + (string.IsNullOrEmpty(parseResult.Sections[parseResult.SelectedSection]) || Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { Name }; + } + else if (parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase)) + { + if (shellState.CommandDispatcher is ICommandDispatcher dispatcher + && parseResult.Slice(1) is ICoreParseResult continuationParseResult) + { + HashSet suggestions = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (ICommand command in dispatcher.Commands) + { + IEnumerable commandSuggestions = command.Suggest(shellState, programState, continuationParseResult); + + if (commandSuggestions != null) + { + suggestions.UnionWith(commandSuggestions); + } + } + + if (continuationParseResult.SelectedSection == 0) + { + string normalizedCompletionText = continuationParseResult.Sections[0].Substring(0, continuationParseResult.CaretPositionWithinSelectedSection); + suggestions.UnionWith(ServerPathCompletion.GetCompletions(programState, normalizedCompletionText)); + } + + return suggestions.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + return null; + } + + public void CoreGetHelp(IShellState shellState, ICommandDispatcher dispatcher, HttpState programState) + { + foreach (ICommand command in dispatcher.Commands) + { + string help = command.GetHelpSummary(shellState, programState); + + if (!string.IsNullOrEmpty(help)) + { + shellState.ConsoleManager.WriteLine(help); + } + } + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/ListCommand.cs b/src/Microsoft.HttpRepl/Commands/ListCommand.cs new file mode 100644 index 0000000000..b0641f186b --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/ListCommand.cs @@ -0,0 +1,180 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Preferences; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class ListCommand : CommandWithStructuredInputBase + { + private const string RecursiveOption = nameof(RecursiveOption); + + protected override async Task ExecuteAsync(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (programState.SwaggerEndpoint != null) + { + string swaggerRequeryBehaviorSetting = programState.GetStringPreference(WellKnownPreference.SwaggerRequeryBehavior, "auto"); + + if (swaggerRequeryBehaviorSetting.StartsWith("auto", StringComparison.OrdinalIgnoreCase)) + { + await SetSwaggerCommand.CreateDirectoryStructureForSwaggerEndpointAsync(shellState, programState, programState.SwaggerEndpoint, cancellationToken).ConfigureAwait(false); + } + } + + if (programState.Structure == null || programState.BaseAddress == null) + { + return; + } + + string path = commandInput.Arguments.Count > 0 ? commandInput.Arguments[0].Text : string.Empty; + + //If it's an absolute URI, nothing to suggest + if (Uri.TryCreate(path, UriKind.Absolute, out Uri _)) + { + return; + } + + IDirectoryStructure s = programState.Structure.TraverseTo(programState.PathSections.Reverse()).TraverseTo(path); + + string thisDirMethod = s.RequestInfo != null && s.RequestInfo.Methods.Count > 0 + ? "[" + string.Join("|", s.RequestInfo.Methods) + "]" + : "[]"; + + List roots = new List(); + Formatter formatter = new Formatter(); + + roots.Add(new TreeNode(formatter, ".", thisDirMethod)); + + if (s.Parent != null) + { + string parentDirMethod = s.Parent.RequestInfo != null && s.Parent.RequestInfo.Methods.Count > 0 + ? "[" + string.Join("|", s.Parent.RequestInfo.Methods) + "]" + : "[]"; + + roots.Add(new TreeNode(formatter, "..", parentDirMethod)); + } + + int recursionDepth = 1; + + if (commandInput.Options[RecursiveOption].Count > 0) + { + if (string.IsNullOrEmpty(commandInput.Options[RecursiveOption][0]?.Text)) + { + recursionDepth = int.MaxValue; + } + else if (int.TryParse(commandInput.Options[RecursiveOption][0].Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int rd) && rd > 1) + { + recursionDepth = rd; + } + } + + foreach (string child in s.DirectoryNames) + { + IDirectoryStructure dir = s.GetChildDirectory(child); + + string methods = dir.RequestInfo != null && dir.RequestInfo.Methods.Count > 0 + ? "[" + string.Join("|", dir.RequestInfo.Methods) + "]" + : "[]"; + + TreeNode dirNode = new TreeNode(formatter, child, methods); + roots.Add(dirNode); + Recurse(dirNode, dir, recursionDepth - 1); + } + + foreach (TreeNode node in roots) + { + shellState.ConsoleManager.WriteLine(node.ToString()); + } + } + + private static void Recurse(TreeNode parentNode, IDirectoryStructure parent, int remainingDepth) + { + if (remainingDepth <= 0) + { + return; + } + + foreach (string child in parent.DirectoryNames) + { + IDirectoryStructure dir = parent.GetChildDirectory(child); + + string methods = dir.RequestInfo != null && dir.RequestInfo.Methods.Count > 0 + ? "[" + string.Join("|", dir.RequestInfo.Methods) + "]" + : "[]"; + + TreeNode node = parentNode.AddChild(child, methods); + Recurse(node, dir, remainingDepth - 1); + } + } + + + + protected override CommandInputSpecification InputSpec { get; } = CommandInputSpecification.Create("ls").AlternateName("dir") + .MaximumArgCount(1) + .WithOption(new CommandOptionSpecification(RecursiveOption, maximumOccurrences: 1, acceptsValue: true, forms: new[] {"-r", "--recursive"})) + .Finish(); + + protected override string GetHelpDetails(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + return "Lists the contents of a directory"; + } + + public override string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "ls - Performs a directory listing"; + } + + protected override IEnumerable GetArgumentSuggestionsForText(IShellState shellState, HttpState programState, ICoreParseResult parseResult, DefaultCommandInput commandInput, string normalCompletionString) + { + if (programState.Structure == null || programState.BaseAddress == null) + { + return null; + } + + //If it's an absolute URI, nothing to suggest + if (Uri.TryCreate(normalCompletionString, UriKind.Absolute, out Uri _)) + { + return null; + } + + string path = normalCompletionString.Replace('\\', '/'); + int searchFrom = normalCompletionString.Length - 1; + int lastSlash = path.LastIndexOf('/', searchFrom); + string prefix; + + if (lastSlash < 0) + { + path = string.Empty; + prefix = normalCompletionString; + } + else + { + path = path.Substring(0, lastSlash + 1); + prefix = normalCompletionString.Substring(lastSlash + 1); + } + + IDirectoryStructure s = programState.Structure.TraverseTo(programState.PathSections.Reverse()).TraverseTo(path); + + List results = new List(); + + foreach (string child in s.DirectoryNames) + { + if (child.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + results.Add(path + child); + } + } + + return results; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/OptionsCommand.cs b/src/Microsoft.HttpRepl/Commands/OptionsCommand.cs new file mode 100644 index 0000000000..5da5cadfaf --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/OptionsCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class OptionsCommand : BaseHttpCommand + { + protected override string Verb => "options"; + + protected override bool RequiresBody => false; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/PatchCommand.cs b/src/Microsoft.HttpRepl/Commands/PatchCommand.cs new file mode 100644 index 0000000000..bfb9cc9192 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/PatchCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class PatchCommand : BaseHttpCommand + { + protected override string Verb => "patch"; + + protected override bool RequiresBody => true; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/PostCommand.cs b/src/Microsoft.HttpRepl/Commands/PostCommand.cs new file mode 100644 index 0000000000..61edb6d4c8 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/PostCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class PostCommand : BaseHttpCommand + { + protected override string Verb => "post"; + + protected override bool RequiresBody => true; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/PrefCommand.cs b/src/Microsoft.HttpRepl/Commands/PrefCommand.cs new file mode 100644 index 0000000000..d4d5d19b4a --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/PrefCommand.cs @@ -0,0 +1,175 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Preferences; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class PrefCommand : CommandWithStructuredInputBase + { + private readonly HashSet _allowedSubcommands = new HashSet(StringComparer.OrdinalIgnoreCase) {"get", "set"}; + + public override string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "pref [get/set] {setting} [{value}] - Allows viewing or changing preferences"; + } + + protected override bool CanHandle(IShellState shellState, HttpState programState, DefaultCommandInput commandInput) + { + if (commandInput.Arguments.Count == 0 || !_allowedSubcommands.Contains(commandInput.Arguments[0]?.Text)) + { + shellState.ConsoleManager.Error.WriteLine("Whether get or set settings must be specified"); + return false; + } + + if (!string.Equals("get", commandInput.Arguments[0].Text) && (commandInput.Arguments.Count < 2 || string.IsNullOrEmpty(commandInput.Arguments[1]?.Text))) + { + shellState.ConsoleManager.Error.WriteLine("The preference to set must be specified"); + return false; + } + + return true; + } + + protected override string GetHelpDetails(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult) + { + if (commandInput.Arguments.Count == 0 || !_allowedSubcommands.Contains(commandInput.Arguments[0]?.Text)) + { + return "pref [get/set] {setting} [{value}] - Get or sets a preference to a particular value"; + } + + if (string.Equals(commandInput.Arguments[0].Text, "get", StringComparison.OrdinalIgnoreCase)) + { + return "pref get [{setting}] - Gets the value of the specified preference or lists all preferences if no preference is specified"; + } + else + { + return "pref set {setting} [{value}] - Sets (or clears if value is not specified) the value of the specified preference"; + } + } + + protected override Task ExecuteAsync(IShellState shellState, HttpState programState, DefaultCommandInput commandInput, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (string.Equals(commandInput.Arguments[0].Text, "get", StringComparison.OrdinalIgnoreCase)) + { + return GetSetting(shellState, programState, commandInput); + } + + return SetSetting(shellState, programState, commandInput); + } + + private static Task SetSetting(IShellState shellState, HttpState programState, DefaultCommandInput commandInput) + { + string prefName = commandInput.Arguments[1].Text; + string prefValue = commandInput.Arguments.Count > 2 ? commandInput.Arguments[2]?.Text : null; + + if (string.IsNullOrEmpty(prefValue)) + { + if (!programState.DefaultPreferences.TryGetValue(prefName, out string defaultValue)) + { + programState.Preferences.Remove(prefName); + } + else + { + programState.Preferences[prefName] = defaultValue; + } + } + else + { + programState.Preferences[prefName] = prefValue; + } + + if (!programState.SavePreferences()) + { + shellState.ConsoleManager.Error.WriteLine("Error saving preferences".SetColor(programState.ErrorColor)); + } + + return Task.CompletedTask; + } + + private static Task GetSetting(IShellState shellState, HttpState programState, DefaultCommandInput commandInput) + { + string preferenceName = commandInput.Arguments.Count > 1 ? commandInput.Arguments[1]?.Text : null; + + //If there's a particular setting to get the value of + if (!string.IsNullOrEmpty(preferenceName)) + { + if (programState.Preferences.TryGetValue(preferenceName, out string value)) + { + shellState.ConsoleManager.WriteLine("Configured value: " + value); + } + else + { + shellState.ConsoleManager.Error.WriteLine((commandInput.Arguments[1].Text + " does not have a configured value").SetColor(programState.ErrorColor)); + } + } + else + { + foreach (KeyValuePair entry in programState.Preferences.OrderBy(x => x.Key)) + { + shellState.ConsoleManager.WriteLine($"{entry.Key}={entry.Value}"); + } + } + + return Task.CompletedTask; + } + + protected override CommandInputSpecification InputSpec { get; } = CommandInputSpecification.Create("pref") + .MinimumArgCount(1) + .MaximumArgCount(3) + .Finish(); + + + protected override IEnumerable GetArgumentSuggestionsForText(IShellState shellState, HttpState programState, ICoreParseResult parseResult, DefaultCommandInput commandInput, string normalCompletionString) + { + if (parseResult.SelectedSection == 1) + { + return _allowedSubcommands.Where(x => x.StartsWith(normalCompletionString, StringComparison.OrdinalIgnoreCase)); + } + + if (parseResult.SelectedSection == 2) + { + string prefix = parseResult.Sections.Count > 2 ? normalCompletionString : string.Empty; + List matchingProperties = new List(); + + foreach (string val in WellKnownPreference.Catalog.Names) + { + if (val.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + matchingProperties.Add(val); + } + } + + return matchingProperties; + } + + if (parseResult.SelectedSection == 3 + && parseResult.Sections[2].StartsWith("colors.", StringComparison.OrdinalIgnoreCase)) + { + string prefix = parseResult.Sections.Count > 3 ? normalCompletionString : string.Empty; + List matchingProperties = new List(); + + foreach (string val in Enum.GetNames(typeof(AllowedColors))) + { + if (val.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + matchingProperties.Add(val); + } + } + + return matchingProperties; + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/PutCommand.cs b/src/Microsoft.HttpRepl/Commands/PutCommand.cs new file mode 100644 index 0000000000..4f37e1776c --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/PutCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Commands +{ + public class PutCommand : BaseHttpCommand + { + protected override string Verb => "put"; + + protected override bool RequiresBody => true; + } +} diff --git a/src/Microsoft.HttpRepl/Commands/RunCommand.cs b/src/Microsoft.HttpRepl/Commands/RunCommand.cs new file mode 100644 index 0000000000..3228dbccf1 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/RunCommand.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; +using Microsoft.Repl.Scripting; +using Microsoft.Repl.Suggestions; + +namespace Microsoft.HttpRepl.Commands +{ + public class RunCommand : ICommand + { + private static readonly string Name = "run"; + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count > 1 && parseResult.Sections.Count < 4 && string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) + ? (bool?)true + : null; + } + + public async Task ExecuteAsync(IShellState shellState, HttpState programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (!File.Exists(parseResult.Sections[1])) + { + shellState.ConsoleManager.Error.WriteLine($"Could not file script file {parseResult.Sections[1]}"); + return; + } + + bool suppressScriptLinesInHistory = true; + if (parseResult.Sections.Count == 3) + { + suppressScriptLinesInHistory = !string.Equals(parseResult.Sections[2], "+history"); + } + + string[] lines = File.ReadAllLines(parseResult.Sections[1]); + IScriptExecutor scriptExecutor = new ScriptExecutor(suppressScriptLinesInHistory); + await scriptExecutor.ExecuteScriptAsync(shellState, lines, cancellationToken).ConfigureAwait(false); + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count > 0 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase)) + { + if (parseResult.Sections.Count == 1) + { + return "Runs the specified script"; + } + + return "Runs the script " + parseResult.Sections[1]; + } + + return null; + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "run {path to script} - Runs a script"; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.SelectedSection == 0 && + (string.IsNullOrEmpty(parseResult.Sections[parseResult.SelectedSection]) || Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { Name }; + } + + if (parseResult.SelectedSection == 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase)) + { + return FileSystemCompletion.GetCompletions(parseResult.Sections[1].Substring(0, parseResult.CaretPositionWithinSelectedSection)); + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/SetBaseCommand.cs b/src/Microsoft.HttpRepl/Commands/SetBaseCommand.cs new file mode 100644 index 0000000000..76166d5c53 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/SetBaseCommand.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class SetBaseCommand : ICommand + { + private const string Name = "set"; + private const string SubCommand = "base"; + + public string Description => "Sets the base address to direct requests to."; + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase) + ? (bool?)true + : null; + } + + public async Task ExecuteAsync(IShellState shellState, HttpState state, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (parseResult.Sections.Count == 2) + { + state.BaseAddress = null; + } + else if (parseResult.Sections.Count != 3 || string.IsNullOrEmpty(parseResult.Sections[2]) || !Uri.TryCreate(EnsureTrailingSlash(parseResult.Sections[2]), UriKind.Absolute, out Uri serverUri)) + { + shellState.ConsoleManager.Error.WriteLine("Must specify a server".SetColor(state.ErrorColor)); + } + else + { + state.BaseAddress = serverUri; + try + { + await state.Client.SendAsync(new HttpRequestMessage(HttpMethod.Head, serverUri)).ConfigureAwait(false); + } + catch (Exception ex) when (ex.InnerException is SocketException se) + { + shellState.ConsoleManager.Error.WriteLine($"Warning: HEAD request to the specified address was unsuccessful ({se.Message})".SetColor(state.WarningColor)); + } + catch { } + } + + if (state.BaseAddress == null || !Uri.TryCreate(state.BaseAddress, "/swagger/v1/swagger.json", out Uri result)) + { + state.SwaggerStructure = null; + } + else + { + await SetSwaggerCommand.CreateDirectoryStructureForSwaggerEndpointAsync(shellState, state, result, cancellationToken).ConfigureAwait(false); + if (state.SwaggerStructure != null) + { + shellState.ConsoleManager.WriteLine("Using swagger metadata from " + result); + } + } + } + + private string EnsureTrailingSlash(string v) + { + if (!v.EndsWith("/", StringComparison.Ordinal)) + { + v += "/"; + } + + return v; + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase)) + { + return Description; + } + + return null; + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return Description; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count == 0) + { + return new[] { Name }; + } + + if (parseResult.Sections.Count > 0 && parseResult.SelectedSection == 0 && Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase)) + { + return new[] { Name }; + } + + if (string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) && parseResult.SelectedSection == 1 && (parseResult.Sections.Count < 2 || SubCommand.StartsWith(parseResult.Sections[1].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { SubCommand }; + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/SetDiagCommand.cs b/src/Microsoft.HttpRepl/Commands/SetDiagCommand.cs new file mode 100644 index 0000000000..4adb208de3 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/SetDiagCommand.cs @@ -0,0 +1,141 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Diagnostics; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class SetDiagCommand : ICommand + { + private static readonly string Name = "set"; + private static readonly string SubCommand = "diag"; + + public string Description => "Sets the diagnostics path to direct requests to."; + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase) + ? (bool?)true + : null; + } + + public async Task ExecuteAsync(IShellState shellState, HttpState programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (parseResult.Sections.Count == 2) + { + programState.DiagnosticsState.DiagnosticsEndpoint = null; + programState.DiagnosticsState.DiagnosticItems = null; + programState.DiagnosticsState.DiagEndpointsStructure = null; + return; + } + + if (parseResult.Sections.Count != 3 || string.IsNullOrEmpty(parseResult.Sections[2]) || !Uri.TryCreate(parseResult.Sections[2], UriKind.Relative, out Uri _)) + { + shellState.ConsoleManager.Error.WriteLine("Must specify a relative path".SetColor(programState.ErrorColor)); + } + else + { + programState.DiagnosticsState.DiagnosticsEndpoint = parseResult.Sections[2]; + HttpResponseMessage response = await programState.Client.GetAsync(new Uri(programState.BaseAddress, programState.DiagnosticsState.DiagnosticsEndpoint), cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + shellState.ConsoleManager.Error.WriteLine("Unable to access diagnostics endpoint".SetColor(programState.ErrorColor)); + programState.DiagnosticsState.DiagnosticsEndpoint = null; + programState.DiagnosticsState.DiagnosticItems = null; + } + else + { + programState.DiagnosticsState.DiagnosticItems = (await response.Content.ReadAsAsync>(cancellationToken).ConfigureAwait(false))?.Select(x => x.Value).ToList(); + + DiagItem endpointsItem = programState.DiagnosticsState.DiagnosticItems?.FirstOrDefault(x => string.Equals(x.DisplayName, "Endpoints", StringComparison.OrdinalIgnoreCase)); + + if (endpointsItem != null) + { + HttpResponseMessage endpointsResponse = await programState.Client.GetAsync(new Uri(programState.BaseAddress, endpointsItem.Url), cancellationToken).ConfigureAwait(false); + + if (!endpointsResponse.IsSuccessStatusCode) + { + shellState.ConsoleManager.Error.WriteLine("Unable to get endpoints information from diagnostics endpoint".SetColor(programState.ErrorColor)); + return; + } + + List endpoints = await endpointsResponse.Content.ReadAsAsync>(cancellationToken).ConfigureAwait(false); + DirectoryStructure structure = new DirectoryStructure(null); + + foreach (DiagEndpoint endpoint in endpoints) + { + if (endpoint.Url.StartsWith(endpointsItem.Url, StringComparison.OrdinalIgnoreCase) + || endpoint.Url.StartsWith("/graphql", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + FillDirectoryInfo(structure, endpoint.Url); + } + + programState.DiagnosticsState.DiagEndpointsStructure = structure; + } + } + } + } + + private static void FillDirectoryInfo(DirectoryStructure parent, string endpoint) + { + string[] parts = endpoint.Split('/'); + + foreach (string part in parts) + { + if (!string.IsNullOrEmpty(part)) + { + parent = parent.DeclareDirectory(part); + } + } + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return Description; + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase)) + { + return Description; + } + + return null; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count == 0) + { + return new[] { Name }; + } + + if (parseResult.Sections.Count > 0 && parseResult.SelectedSection == 0 && Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase)) + { + return new[] { Name }; + } + + if (string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) && parseResult.SelectedSection == 1 && (parseResult.Sections.Count < 2 || SubCommand.StartsWith(parseResult.Sections[1].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { SubCommand }; + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/SetHeaderCommand.cs b/src/Microsoft.HttpRepl/Commands/SetHeaderCommand.cs new file mode 100644 index 0000000000..3db62f47b3 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/SetHeaderCommand.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.Suggestions; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class SetHeaderCommand : ICommand + { + private static readonly string Name = "set"; + private static readonly string SubCommand = "header"; + + public string Description => "set header {name} [{{value}}] - Sets or clears a header"; + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count > 2 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase) + ? (bool?)true + : null; + } + + public Task ExecuteAsync(IShellState shellState, HttpState programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (parseResult.Sections.Count == 3) + { + programState.Headers.Remove(parseResult.Sections[2]); + } + else + { + programState.Headers[parseResult.Sections[2]] = parseResult.Sections.Skip(3).ToList(); + } + + return Task.CompletedTask; + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase)) + { + return Description; + } + + return null; + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return Description; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count == 0) + { + return new[] { Name }; + } + + if (parseResult.Sections.Count > 0 && parseResult.SelectedSection == 0 && Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase)) + { + return new[] { Name }; + } + + if (string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) && parseResult.SelectedSection == 1 && (parseResult.Sections.Count < 2 || SubCommand.StartsWith(parseResult.Sections[1].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { SubCommand }; + } + + if (parseResult.Sections.Count > 2 + && string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) + && string.Equals(SubCommand, parseResult.Sections[1], StringComparison.OrdinalIgnoreCase) && parseResult.SelectedSection == 2) + { + string prefix = parseResult.Sections[2].Substring(0, parseResult.CaretPositionWithinSelectedSection); + return HeaderCompletion.GetCompletions(null, prefix); + } + + if (parseResult.Sections.Count > 3 + && string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) + && string.Equals(SubCommand, parseResult.Sections[1], StringComparison.OrdinalIgnoreCase) && parseResult.SelectedSection == 3) + { + string prefix = parseResult.Sections[3].Substring(0, parseResult.CaretPositionWithinSelectedSection); + return HeaderCompletion.GetValueCompletions(null, string.Empty, parseResult.Sections[2], prefix, programState); + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/SetSwaggerCommand.cs b/src/Microsoft.HttpRepl/Commands/SetSwaggerCommand.cs new file mode 100644 index 0000000000..41c1564a99 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/SetSwaggerCommand.cs @@ -0,0 +1,277 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.HttpRepl.OpenApi; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.Commands +{ + public class SetSwaggerCommand : ICommand + { + private static readonly string Name = "set"; + private static readonly string SubCommand = "swagger"; + + public string Description => "Sets the swagger document to use for information about the current server"; + + private static void FillDirectoryInfo(DirectoryStructure parent, EndpointMetadata entry) + { + string[] parts = entry.Path.Split('/'); + + foreach (string part in parts) + { + if (!string.IsNullOrEmpty(part)) + { + parent = parent.DeclareDirectory(part); + } + } + + RequestInfo dirRequestInfo = new RequestInfo(); + + foreach (KeyValuePair>> requestInfo in entry.AvailableRequests) + { + string method = requestInfo.Key; + + foreach (KeyValuePair> parameterSetsByContentType in requestInfo.Value) + { + if (string.IsNullOrEmpty(parameterSetsByContentType.Key)) + { + dirRequestInfo.SetFallbackRequestBody(method, GetBodyString(null, parameterSetsByContentType.Value)); + } + + dirRequestInfo.SetRequestBody(method, parameterSetsByContentType.Key, GetBodyString(parameterSetsByContentType.Key, parameterSetsByContentType.Value)); + } + + dirRequestInfo.AddMethod(method); + } + + if (dirRequestInfo.Methods.Count > 0) + { + parent.RequestInfo = dirRequestInfo; + } + } + + private static string GetBodyString(string contentType, IEnumerable operation) + { + Parameter body = operation.FirstOrDefault(x => string.Equals(x.Location, "body", StringComparison.OrdinalIgnoreCase)); + + if (body != null) + { + JToken result = GenerateData(body.Schema); + return result?.ToString() ?? "{\n}"; + } + + return null; + } + + private static JToken GenerateData(Schema schema) + { + if (schema == null) + { + return null; + } + + if (schema.Example != null) + { + return JToken.FromObject(schema.Example); + } + + if (schema.Default != null) + { + return JToken.FromObject(schema.Default); + } + + if (schema.Type is null) + { + if (schema.Properties != null || schema.AdditionalProperties != null || schema.MinProperties.HasValue || schema.MaxProperties.HasValue) + { + schema.Type = "OBJECT"; + } + else if (schema.Items != null || schema.MinItems.HasValue || schema.MaxItems.HasValue) + { + schema.Type = "ARRAY"; + } + else if (schema.Minimum.HasValue || schema.Maximum.HasValue || schema.MultipleOf.HasValue) + { + schema.Type = "INTEGER"; + } + } + + switch (schema.Type?.ToUpperInvariant()) + { + case null: + case "STRING": + return ""; + case "NUMBER": + if (schema.Minimum.HasValue) + { + if (schema.Maximum.HasValue) + { + return (schema.Maximum.Value + schema.Minimum.Value) / 2; + } + + if (schema.ExclusiveMinimum) + { + return schema.Minimum.Value + 1; + } + + return schema.Minimum.Value; + } + return 1.1; + case "INTEGER": + if (schema.Minimum.HasValue) + { + if (schema.Maximum.HasValue) + { + return (int)((schema.Maximum.Value + schema.Minimum.Value) / 2); + } + + if (schema.ExclusiveMinimum) + { + return schema.Minimum.Value + 1; + } + + return schema.Minimum.Value; + } + return 0; + case "BOOLEAN": + return true; + case "ARRAY": + JArray container = new JArray(); + JToken item = GenerateData(schema.Items) ?? ""; + + int count = schema.MinItems.GetValueOrDefault(); + count = Math.Max(1, count); + + for (int i = 0; i < count; ++i) + { + container.Add(item.DeepClone()); + } + + return container; + case "OBJECT": + JObject obj = new JObject(); + foreach (KeyValuePair property in schema.Properties) + { + JToken data = GenerateData(property.Value) ?? ""; + obj[property.Key] = data; + } + return obj; + } + + return null; + } + + private static async Task> GetSwaggerDocAsync(HttpClient client, Uri uri) + { + var resp = await client.GetAsync(uri).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + string responseString = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); + JsonSerializer serializer = new JsonSerializer{ PreserveReferencesHandling = PreserveReferencesHandling.All }; + JObject responseObject = (JObject)serializer.Deserialize(new StringReader(responseString), typeof(JObject)); + EndpointMetadataReader reader = new EndpointMetadataReader(); + responseObject = await PointerUtil.ResolvePointersAsync(uri, responseObject, client).ConfigureAwait(false) as JObject; + + if (responseObject is null) + { + return new EndpointMetadata[0]; + } + + return reader.Read(responseObject); + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return Description; + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase)) + { + return Description; + } + + return null; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count == 0) + { + return new[] { Name }; + } + + if (parseResult.Sections.Count > 0 && parseResult.SelectedSection == 0 && Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase)) + { + return new[] { Name }; + } + + if (string.Equals(Name, parseResult.Sections[0], StringComparison.OrdinalIgnoreCase) && parseResult.SelectedSection == 1 && (parseResult.Sections.Count < 2 || SubCommand.StartsWith(parseResult.Sections[1].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { SubCommand }; + } + + return null; + } + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count > 1 && string.Equals(parseResult.Sections[0], Name, StringComparison.OrdinalIgnoreCase) && string.Equals(parseResult.Sections[1], SubCommand, StringComparison.OrdinalIgnoreCase) + ? (bool?)true + : null; + } + + public async Task ExecuteAsync(IShellState shellState, HttpState programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (parseResult.Sections.Count == 2) + { + programState.SwaggerStructure = null; + return; + } + + if (parseResult.Sections.Count != 3 || string.IsNullOrEmpty(parseResult.Sections[2]) || !Uri.TryCreate(parseResult.Sections[2], UriKind.Absolute, out Uri serverUri)) + { + shellState.ConsoleManager.Error.WriteLine("Must specify a swagger document".SetColor(programState.ErrorColor)); + } + else + { + await CreateDirectoryStructureForSwaggerEndpointAsync(shellState, programState, serverUri, cancellationToken).ConfigureAwait(false); + } + } + + internal static async Task CreateDirectoryStructureForSwaggerEndpointAsync(IShellState shellState, HttpState programState, Uri serverUri, CancellationToken cancellationToken) + { + programState.SwaggerEndpoint = serverUri; + + try + { + IEnumerable doc = await GetSwaggerDocAsync(programState.Client, serverUri).ConfigureAwait(false); + + DirectoryStructure d = new DirectoryStructure(null); + + foreach (EndpointMetadata entry in doc) + { + FillDirectoryInfo(d, entry); + } + + programState.SwaggerStructure = !cancellationToken.IsCancellationRequested ? d : null; + } + catch + { + programState.SwaggerStructure = null; + } + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/TreeNode.cs b/src/Microsoft.HttpRepl/Commands/TreeNode.cs new file mode 100644 index 0000000000..1ae5db3cbc --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/TreeNode.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.HttpRepl.Commands +{ + public class TreeNode + { + private readonly int _depth; + private readonly Formatter _formatter; + private readonly string _prefix; + private readonly string _entry; + private readonly List _children = new List(); + + public TreeNode(Formatter formatter, string prefix, string entry) + : this(formatter, prefix, entry, 0) + { + } + + private TreeNode(Formatter formatter, string prefix, string entry, int depth) + { + _formatter = formatter; + formatter.RegisterEntry(prefix.Length, depth); + _prefix = prefix; + _entry = entry; + _depth = depth; + } + + public TreeNode AddChild(string prefix, string entry) + { + TreeNode child = new TreeNode(_formatter, prefix, entry, _depth + 1); + _children.Add(child); + return child; + } + + public override string ToString() + { + string self = _formatter.Format(_prefix, _entry, _depth); + + if (_children.Count == 0) + { + return self; + } + + return self + Environment.NewLine + string.Join(Environment.NewLine, _children); + } + } +} diff --git a/src/Microsoft.HttpRepl/Commands/UICommand.cs b/src/Microsoft.HttpRepl/Commands/UICommand.cs new file mode 100644 index 0000000000..ac03d53d29 --- /dev/null +++ b/src/Microsoft.HttpRepl/Commands/UICommand.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.HttpRepl.Commands +{ + public class UICommand : ICommand + { + private static readonly string Name = "ui"; + + public bool? CanHandle(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + return parseResult.Sections.Count == 1 && string.Equals(parseResult.Sections[0], Name) + ? (bool?)true + : null; + } + + public Task ExecuteAsync(IShellState shellState, HttpState programState, ICoreParseResult parseResult, CancellationToken cancellationToken) + { + if (programState.BaseAddress == null) + { + shellState.ConsoleManager.Error.WriteLine("Must be connected to a server to launch Swagger UI".SetColor(programState.ErrorColor)); + return Task.CompletedTask; + } + + Uri uri = new Uri(programState.BaseAddress, "swagger"); + string agent = "cmd"; + string agentParam = $"/c start {uri.AbsoluteUri}"; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + agent = "open"; + agentParam = uri.AbsoluteUri; + } + else if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + agent = "xdg-open"; + agentParam = uri.AbsoluteUri; + } + + Process.Start(new ProcessStartInfo(agent, agentParam) { CreateNoWindow = true }); + return Task.CompletedTask; + } + + public string GetHelpDetails(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.Sections.Count == 1 && string.Equals(parseResult.Sections[0], Name)) + { + return "ui - Launches the Swagger UI page (if available) in the default browser"; + } + + return null; + } + + public string GetHelpSummary(IShellState shellState, HttpState programState) + { + return "ui - Launches the Swagger UI page (if available) in the default browser"; + } + + public IEnumerable Suggest(IShellState shellState, HttpState programState, ICoreParseResult parseResult) + { + if (parseResult.SelectedSection == 0 && + (string.IsNullOrEmpty(parseResult.Sections[parseResult.SelectedSection]) || Name.StartsWith(parseResult.Sections[0].Substring(0, parseResult.CaretPositionWithinSelectedSection), StringComparison.OrdinalIgnoreCase))) + { + return new[] { Name }; + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/Diagnostics/ConfigItem.cs b/src/Microsoft.HttpRepl/Diagnostics/ConfigItem.cs new file mode 100644 index 0000000000..8e5c2943e4 --- /dev/null +++ b/src/Microsoft.HttpRepl/Diagnostics/ConfigItem.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Diagnostics +{ + public class ConfigItem + { + public string Key { get; set; } + + public string Value { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/Diagnostics/DiagEndpoint.cs b/src/Microsoft.HttpRepl/Diagnostics/DiagEndpoint.cs new file mode 100644 index 0000000000..a38e50bada --- /dev/null +++ b/src/Microsoft.HttpRepl/Diagnostics/DiagEndpoint.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Diagnostics +{ + public class DiagEndpoint + { + public string DisplayName { get; set; } + public string Url { get; set; } + public DiagEndpointMetadata[] Metadata { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/Diagnostics/DiagEndpointMetadata.cs b/src/Microsoft.HttpRepl/Diagnostics/DiagEndpointMetadata.cs new file mode 100644 index 0000000000..5989daf094 --- /dev/null +++ b/src/Microsoft.HttpRepl/Diagnostics/DiagEndpointMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Diagnostics +{ + public class DiagEndpointMetadata + { + public object Item { get; set; } + public string Type { get; set; } + public string[] Interfaces { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/Diagnostics/DiagItem.cs b/src/Microsoft.HttpRepl/Diagnostics/DiagItem.cs new file mode 100644 index 0000000000..a9d0692cfd --- /dev/null +++ b/src/Microsoft.HttpRepl/Diagnostics/DiagItem.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.Diagnostics +{ + public class DiagItem + { + public string DisplayName { get; set; } + + public string Description { get; set; } + + public string Url { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/Diagnostics/DiagnosticsState.cs b/src/Microsoft.HttpRepl/Diagnostics/DiagnosticsState.cs new file mode 100644 index 0000000000..90033d9062 --- /dev/null +++ b/src/Microsoft.HttpRepl/Diagnostics/DiagnosticsState.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.HttpRepl.Diagnostics +{ + public class DiagnosticsState + { + public string DiagnosticsEndpoint { get; set; } + + public IReadOnlyList DiagnosticItems { get; internal set; } + + public IDirectoryStructure DiagEndpointsStructure { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/DirectoryStructure.cs b/src/Microsoft.HttpRepl/DirectoryStructure.cs new file mode 100644 index 0000000000..8787f962fc --- /dev/null +++ b/src/Microsoft.HttpRepl/DirectoryStructure.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.HttpRepl +{ + public class DirectoryStructure : IDirectoryStructure + { + private readonly Dictionary _childDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public DirectoryStructure(IDirectoryStructure parent) + { + Parent = parent; + } + + public IEnumerable DirectoryNames => _childDirectories.Keys; + + public IDirectoryStructure Parent { get; } + + public DirectoryStructure DeclareDirectory(string name) + { + if (_childDirectories.TryGetValue(name, out DirectoryStructure existing)) + { + return existing; + } + + return _childDirectories[name] = new DirectoryStructure(this); + } + + public IDirectoryStructure GetChildDirectory(string name) + { + if (_childDirectories.TryGetValue(name, out DirectoryStructure result)) + { + return result; + } + + IDirectoryStructure parameterizedTarget = _childDirectories.FirstOrDefault(x => x.Key.StartsWith('{') && x.Key.EndsWith('}')).Value; + + if (!(parameterizedTarget is null)) + { + return parameterizedTarget; + } + + return new DirectoryStructure(this); + } + + public IRequestInfo RequestInfo { get; set; } + } + + public class RequestInfo : IRequestInfo + { + private readonly HashSet _methods = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _requestBodiesByMethodByContentType = new Dictionary>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _fallbackBodyStringsByMethod = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _contentTypesByMethod = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Methods => _methods.ToList(); + + public IReadOnlyDictionary> ContentTypesByMethod => _contentTypesByMethod; + + public string GetRequestBodyForContentType(string contentType, string method) + { + if (_requestBodiesByMethodByContentType.TryGetValue(method, out Dictionary bodiesByContentType) + && bodiesByContentType.TryGetValue(contentType, out string body)) + { + return body; + } + + if (_fallbackBodyStringsByMethod.TryGetValue(method, out body)) + { + return body; + } + + return null; + } + + public void SetRequestBody(string method, string contentType, string body) + { + if (!_requestBodiesByMethodByContentType.TryGetValue(method, out Dictionary bodiesByContentType)) + { + _requestBodiesByMethodByContentType[method] = bodiesByContentType = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + if (!_contentTypesByMethod.TryGetValue(method, out IReadOnlyList contentTypesRaw)) + { + _contentTypesByMethod[method] = contentTypesRaw = new List(); + } + + List contentTypes = (List)contentTypesRaw; + contentTypes.Add(contentType); + + bodiesByContentType[contentType] = body; + } + + public void AddMethod(string method) + { + _methods.Add(method); + } + + public void SetFallbackRequestBody(string method, string fallbackBodyString) + { + _fallbackBodyStringsByMethod[method] = fallbackBodyString; + } + } +} diff --git a/src/Microsoft.HttpRepl/DirectoryStructureExtensions.cs b/src/Microsoft.HttpRepl/DirectoryStructureExtensions.cs new file mode 100644 index 0000000000..822fa41e6f --- /dev/null +++ b/src/Microsoft.HttpRepl/DirectoryStructureExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.HttpRepl +{ + public static class DirectoryStructureExtensions + { + public static IEnumerable GetDirectoryListingAtPath(this IDirectoryStructure structure, string path) + { + return structure.TraverseTo(path).DirectoryNames; + } + + public static IDirectoryStructure TraverseTo(this IDirectoryStructure structure, string path) + { + string[] parts = path.Replace('\\', '/').Split('/'); + return structure.TraverseTo(parts); + } + + public static IDirectoryStructure TraverseTo(this IDirectoryStructure structure, IEnumerable pathParts) + { + IDirectoryStructure s = structure; + IReadOnlyList parts = pathParts.ToList(); + + if (parts.Count == 0) + { + return s; + } + + if (parts[0] == string.Empty && parts.Count > 1) + { + while (s.Parent != null) + { + s = s.Parent; + } + } + + foreach (string part in parts) + { + if (part == ".") + { + continue; + } + + if (part == "..") + { + s = s.Parent ?? s; + } + else if (!string.IsNullOrEmpty(part)) + { + s = s.GetChildDirectory(part); + } + } + + return s; + } + } +} diff --git a/src/Microsoft.HttpRepl/Formatting/JsonVisitor.cs b/src/Microsoft.HttpRepl/Formatting/JsonVisitor.cs new file mode 100644 index 0000000000..2538e2fdf2 --- /dev/null +++ b/src/Microsoft.HttpRepl/Formatting/JsonVisitor.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Text; +using Microsoft.HttpRepl.Preferences; +using Microsoft.Repl.ConsoleHandling; +using Newtonsoft.Json; + +namespace Microsoft.HttpRepl.Formatting +{ + public static class JsonVisitor + { + public static string FormatAndColorize(IJsonConfig config, string jsonData) + { + if (jsonData == null) + { + return string.Empty; + } + + StringBuilder result = new StringBuilder(); + JsonTextReader reader = new JsonTextReader(new StringReader(jsonData)); + bool isValuePosition = false; + bool isTerminalValue = false; + bool isFirstToken = true; + + while (reader.Read()) + { + if (!isValuePosition) + { + //If we're about to write an end object/array, we shouldn't have a comma + if (reader.TokenType != JsonToken.EndArray && reader.TokenType != JsonToken.EndObject + && isTerminalValue) + { + result.Append(",".SetColor(config.CommaColor)); + } + + if (!isFirstToken) + { + result.AppendLine(); + } + } + + isFirstToken = false; + + if (!isValuePosition) + { + result.Append("".PadLeft(reader.Depth * config.IndentSize)); + } + + isTerminalValue = false; + isValuePosition = false; + JsonToken type = reader.TokenType; + + switch (type) + { + case JsonToken.StartObject: + result.Append("{".SetColor(config.ObjectBraceColor)); + break; + case JsonToken.EndObject: + result.Append("}".SetColor(config.ObjectBraceColor)); + isTerminalValue = true; + break; + case JsonToken.StartArray: + result.Append("[".SetColor(config.ArrayBraceColor)); + break; + case JsonToken.EndArray: + result.Append("]".SetColor(config.ArrayBraceColor)); + isTerminalValue = true; + break; + case JsonToken.PropertyName: + result.Append((reader.QuoteChar.ToString() + reader.Value + reader.QuoteChar).SetColor(config.NameColor) + ": ".SetColor(config.NameSeparatorColor)); + isValuePosition = true; + break; + case JsonToken.Boolean: + result.Append(reader.Value.ToString().ToLowerInvariant().SetColor(config.BoolColor)); + isTerminalValue = true; + break; + case JsonToken.Integer: + case JsonToken.Float: + result.Append(reader.Value.ToString().ToLowerInvariant().SetColor(config.NumericColor)); + isTerminalValue = true; + break; + case JsonToken.Null: + result.Append("null".SetColor(config.NullColor)); + isTerminalValue = true; + break; + case JsonToken.Comment: + result.Append(("//" + reader.Value).SetColor(config.NumericColor)); + break; + case JsonToken.String: + result.Append((reader.QuoteChar.ToString() + reader.Value + reader.QuoteChar.ToString()).SetColor(config.StringColor)); + isTerminalValue = true; + break; + case JsonToken.Raw: + case JsonToken.Date: + case JsonToken.Bytes: + case JsonToken.Undefined: + case JsonToken.None: + result.Append(reader.Value.ToString().SetColor(config.DefaultColor)); + isTerminalValue = true; + break; + case JsonToken.EndConstructor: + case JsonToken.StartConstructor: + default: + result.Append(reader.Value.ToString().SetColor(config.DefaultColor)); + break; + } + } + + return result.ToString(); + } + } +} diff --git a/src/Microsoft.HttpRepl/HttpState.cs b/src/Microsoft.HttpRepl/HttpState.cs new file mode 100644 index 0000000000..9104e4e02c --- /dev/null +++ b/src/Microsoft.HttpRepl/HttpState.cs @@ -0,0 +1,380 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using Microsoft.HttpRepl.Diagnostics; +using Microsoft.HttpRepl.Preferences; +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl +{ + public class HttpState + { + private string _userProfileDir; + private string _prefsFilePath; + + public HttpClient Client { get; } + + public AllowedColors ErrorColor => this.GetColorPreference(WellKnownPreference.ErrorColor, AllowedColors.BoldRed); + + public AllowedColors WarningColor => this.GetColorPreference(WellKnownPreference.WarningColor, AllowedColors.BoldYellow); + + public Stack PathSections { get; } + + public IDirectoryStructure SwaggerStructure { get; set; } + + public IDirectoryStructure Structure => DiagnosticsState.DiagEndpointsStructure == null + ? SwaggerStructure + : SwaggerStructure == null + ? DiagnosticsState.DiagEndpointsStructure + : new AggregateDirectoryStructure(SwaggerStructure, DiagnosticsState.DiagEndpointsStructure); + + public Uri BaseAddress { get; set; } + + public bool EchoRequest { get; set; } + + public Dictionary Preferences { get; } + + public IReadOnlyDictionary DefaultPreferences { get; } + + public string UserProfileDir + { + get + { + if (_userProfileDir == null) + { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + string profileDir = Environment.GetEnvironmentVariable(isWindows + ? "USERPROFILE" + : "HOME"); + + _userProfileDir = profileDir; + } + + return _userProfileDir; + } + } + + public string PrefsFilePath => _prefsFilePath ?? (_prefsFilePath = Path.Combine(UserProfileDir, ".httpreplprefs")); + + public Dictionary> Headers { get; } + + public DiagnosticsState DiagnosticsState { get; } + + public Uri SwaggerEndpoint { get; set; } + + public HttpState() + { + Client = new HttpClient(); + PathSections = new Stack(); + Preferences = new Dictionary(); + DefaultPreferences = CreateDefaultPreferencs(); + Headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { "User-Agent", new[] { "HTTP-REPL" } } + }; + Preferences = new Dictionary(DefaultPreferences); + LoadPreferences(); + DiagnosticsState = new DiagnosticsState(); + } + + public string GetPrompt() + { + return $"{GetEffectivePath(new string[0], false, out int _)?.ToString() ?? "(Disconnected)"}~ "; + } + + private void LoadPreferences() + { + if (File.Exists(PrefsFilePath)) + { + string[] prefsFile = File.ReadAllLines(PrefsFilePath); + + foreach (string line in prefsFile) + { + int equalsIndex = line.IndexOf('='); + + if (equalsIndex < 0) + { + continue; + } + + Preferences[line.Substring(0, equalsIndex)] = line.Substring(equalsIndex + 1); + } + } + } + + private IReadOnlyDictionary CreateDefaultPreferencs() + { + return new Dictionary + { + { WellKnownPreference.ProtocolColor, "BoldGreen" }, + { WellKnownPreference.StatusColor, "BoldYellow" }, + + { WellKnownPreference.JsonArrayBraceColor, "BoldCyan" }, + { WellKnownPreference.JsonCommaColor, "BoldYellow" }, + { WellKnownPreference.JsonNameColor, "BoldMagenta" }, + { WellKnownPreference.JsonNameSeparatorColor, "BoldWhite" }, + { WellKnownPreference.JsonObjectBraceColor, "Cyan" }, + { WellKnownPreference.JsonColor, "Green" } + }; + } + + public bool SavePreferences() + { + List lines = new List(); + foreach (KeyValuePair entry in Preferences.OrderBy(x => x.Key)) + { + //If the value didn't exist in the defaults or the value's different, include it in the user's preferences file + if (!DefaultPreferences.TryGetValue(entry.Key, out string value) || !string.Equals(value, entry.Value, StringComparison.Ordinal)) + { + lines.Add($"{entry.Key}={entry.Value}"); + } + } + + try + { + File.WriteAllLines(PrefsFilePath, lines); + return true; + } + catch + { + return false; + } + } + + public string GetExampleBody(string path, string contentType, string method) + { + Uri effectivePath = GetEffectivePath(path); + string rootRelativePath = effectivePath.LocalPath.Substring(BaseAddress.LocalPath.Length).TrimStart('/'); + IDirectoryStructure structure = SwaggerStructure?.TraverseTo(rootRelativePath); + return structure?.RequestInfo?.GetRequestBodyForContentType(contentType, method); + } + + public IEnumerable GetApplicableContentTypes(string method, string path) + { + Uri effectivePath = GetEffectivePath(path); + string rootRelativePath = effectivePath.LocalPath.Substring(BaseAddress.LocalPath.Length).TrimStart('/'); + IDirectoryStructure structure = SwaggerStructure?.TraverseTo(rootRelativePath); + IReadOnlyDictionary> contentTypesByMethod = structure?.RequestInfo?.ContentTypesByMethod; + + if (contentTypesByMethod != null) + { + if (method is null) + { + return contentTypesByMethod.Values.SelectMany(x => x).Distinct(StringComparer.OrdinalIgnoreCase); + } + + if (contentTypesByMethod.TryGetValue(method, out IReadOnlyList contentTypes)) + { + return contentTypes; + } + } + + return null; + } + + public Uri GetEffectivePath(string commandSpecifiedPath) + { + if (Uri.TryCreate(commandSpecifiedPath, UriKind.Absolute, out Uri absoluteUri)) + { + return absoluteUri; + } + + UriBuilder builder = new UriBuilder(BaseAddress); + string path = string.Join('/', PathSections.Reverse()); + string[] parts = path.Split('?'); + string query = null; + string query2 = null; + + if (parts.Length > 1) + { + path = parts[0]; + query = string.Join('?', parts.Skip(1)); + } + + builder.Path += path; + + if (commandSpecifiedPath.Length > 0) + { + if (commandSpecifiedPath[0] != '/') + { + string argPath = commandSpecifiedPath; + if (builder.Path.Length > 0 && builder.Path[builder.Path.Length - 1] != '/') + { + argPath = "/" + argPath; + } + + int queryIndex = argPath.IndexOf('?'); + path = argPath; + + if (queryIndex > -1) + { + query2 = argPath.Substring(queryIndex + 1); + path = argPath.Substring(0, queryIndex); + } + + builder.Path += path; + } + else + { + int queryIndex = commandSpecifiedPath.IndexOf('?'); + path = commandSpecifiedPath; + + if (queryIndex > -1) + { + query2 = commandSpecifiedPath.Substring(queryIndex + 1); + path = commandSpecifiedPath.Substring(0, queryIndex); + } + + builder.Path = path; + } + } + else + { + + int queryIndex = commandSpecifiedPath.IndexOf('?'); + path = commandSpecifiedPath; + + if (queryIndex > -1) + { + query2 = commandSpecifiedPath.Substring(queryIndex + 1); + path = commandSpecifiedPath.Substring(0, queryIndex); + } + + builder.Path += path; + } + + if (query != null) + { + if (!string.IsNullOrEmpty(builder.Query)) + { + query = "&" + query; + } + + builder.Query += query; + } + + if (query2 != null) + { + if (!string.IsNullOrEmpty(builder.Query)) + { + query2 = "&" + query2; + } + + builder.Query += query2; + } + + return builder.Uri; + } + + public Uri GetEffectivePath(IReadOnlyList sections, bool requiresBody, out int filePathIndex) + { + filePathIndex = 1; + + if (BaseAddress == null) + { + return null; + } + + UriBuilder builder = new UriBuilder(BaseAddress); + string path = string.Join('/', PathSections.Reverse()); + string[] parts = path.Split('?'); + string query = null; + string query2 = null; + + if (parts.Length > 1) + { + path = parts[0]; + query = string.Join('?', parts.Skip(1)); + } + + builder.Path += path; + + if (sections.Count > 1) + { + if (!requiresBody || !File.Exists(sections[1])) + { + if (sections[1].Length > 0) + { + if (sections[1][0] != '/') + { + string argPath = sections[1]; + if (builder.Path.Length > 0 && builder.Path[builder.Path.Length - 1] != '/') + { + argPath = "/" + argPath; + } + + int queryIndex = argPath.IndexOf('?'); + path = argPath; + + if (queryIndex > -1) + { + query2 = argPath.Substring(queryIndex + 1); + path = argPath.Substring(0, queryIndex); + } + + builder.Path += path; + } + else + { + int queryIndex = sections[1].IndexOf('?'); + path = sections[1]; + + if (queryIndex > -1) + { + query2 = sections[1].Substring(queryIndex + 1); + path = sections[1].Substring(0, queryIndex); + } + + builder.Path = path; + } + } + else + { + + int queryIndex = sections[1].IndexOf('?'); + path = sections[1]; + + if (queryIndex > -1) + { + query2 = sections[1].Substring(queryIndex + 1); + path = sections[1].Substring(0, queryIndex); + } + + builder.Path += path; + } + + filePathIndex = 2; + } + } + + if (query != null) + { + if (!string.IsNullOrEmpty(builder.Query)) + { + query = "&" + query; + } + + builder.Query += query; + } + + if (query2 != null) + { + if (!string.IsNullOrEmpty(builder.Query)) + { + query2 = "&" + query2; + } + + builder.Query += query2; + } + + return builder.Uri; + } + } +} diff --git a/src/Microsoft.HttpRepl/IDirectoryStructure.cs b/src/Microsoft.HttpRepl/IDirectoryStructure.cs new file mode 100644 index 0000000000..e3641e3ca8 --- /dev/null +++ b/src/Microsoft.HttpRepl/IDirectoryStructure.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.HttpRepl +{ + public interface IDirectoryStructure + { + IEnumerable DirectoryNames { get; } + + IDirectoryStructure Parent { get; } + + IDirectoryStructure GetChildDirectory(string name); + + IRequestInfo RequestInfo { get; } + } +} diff --git a/src/Microsoft.HttpRepl/IRequestInfo.cs b/src/Microsoft.HttpRepl/IRequestInfo.cs new file mode 100644 index 0000000000..24054f6142 --- /dev/null +++ b/src/Microsoft.HttpRepl/IRequestInfo.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.HttpRepl +{ + public interface IRequestInfo + { + IReadOnlyDictionary> ContentTypesByMethod { get; } + + IReadOnlyList Methods { get; } + + string GetRequestBodyForContentType(string contentType, string method); + } +} diff --git a/src/Microsoft.HttpRepl/Microsoft.HttpRepl.csproj b/src/Microsoft.HttpRepl/Microsoft.HttpRepl.csproj new file mode 100644 index 0000000000..9e8a0b343c --- /dev/null +++ b/src/Microsoft.HttpRepl/Microsoft.HttpRepl.csproj @@ -0,0 +1,31 @@ + + + + Exe + netcoreapp3.0 + true + dotnet-httprepl + latest + Command line tool to for making HTTP calls and viewing their results. + dotnet;http;httprepl + + + true + + + + + + + + + + + + + + + + + + diff --git a/src/Microsoft.HttpRepl/OpenApi/Either.cs b/src/Microsoft.HttpRepl/OpenApi/Either.cs new file mode 100644 index 0000000000..8e3069de89 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/Either.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.OpenApi +{ + public class Either + { + public Either(TOption1 option1) + { + Option1 = option1; + IsOption1 = true; + } + + public Either(TOption2 option2) + { + Option2 = option2; + IsOption1 = false; + } + + public bool IsOption1 { get; } + + public TOption1 Option1 { get; } + + public TOption2 Option2 { get; } + + public static implicit operator Either(TOption1 value) + { + return new Either(value); + } + + public static implicit operator Either(TOption2 value) + { + return new Either(value); + } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/EitherConverter.cs b/src/Microsoft.HttpRepl/OpenApi/EitherConverter.cs new file mode 100644 index 0000000000..a7197abc8b --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/EitherConverter.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Newtonsoft.Json; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class EitherConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return typeof(TOption1).IsAssignableFrom(objectType) || typeof(TOption2).IsAssignableFrom(objectType) || typeof(EitherConverter) == objectType; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + try + { + TOption1 option1 = serializer.Deserialize(reader); + return new Either(option1); + } + catch + { + TOption2 option2 = serializer.Deserialize(reader); + return new Either(option2); + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/EndpointMetadata.cs b/src/Microsoft.HttpRepl/OpenApi/EndpointMetadata.cs new file mode 100644 index 0000000000..27bb4d1b35 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/EndpointMetadata.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class EndpointMetadata + { + public EndpointMetadata(string path, IReadOnlyDictionary>> requestsByMethodAndContentType) + { + Path = path; + AvailableRequests = requestsByMethodAndContentType ?? new Dictionary>>(); + } + + public string Path { get; } + + public IReadOnlyDictionary>> AvailableRequests { get; } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/EndpointMetadataReader.cs b/src/Microsoft.HttpRepl/OpenApi/EndpointMetadataReader.cs new file mode 100644 index 0000000000..243b8fa92e --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/EndpointMetadataReader.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class EndpointMetadataReader + { + private readonly List _readers = new List + { + new OpenApiV3EndpointMetadataReader(), + new SwaggerV2EndpointMetadataReader(), + new SwaggerV1EndpointMetadataReader() + }; + + public void RegisterReader(IEndpointMetadataReader reader) + { + _readers.Add(reader); + } + + public IEnumerable Read(JObject document) + { + foreach (IEndpointMetadataReader reader in _readers) + { + if (reader.CanHandle(document)) + { + IEnumerable result = reader.ReadMetadata(document); + + if (result != null) + { + return result; + } + } + } + + return null; + } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/IEndpointMetadataReader.cs b/src/Microsoft.HttpRepl/OpenApi/IEndpointMetadataReader.cs new file mode 100644 index 0000000000..385f9dfce0 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/IEndpointMetadataReader.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public interface IEndpointMetadataReader + { + bool CanHandle(JObject document); + + IEnumerable ReadMetadata(JObject document); + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/OpenApiV3EndpointMetadataReader.cs b/src/Microsoft.HttpRepl/OpenApi/OpenApiV3EndpointMetadataReader.cs new file mode 100644 index 0000000000..c009951608 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/OpenApiV3EndpointMetadataReader.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class OpenApiV3EndpointMetadataReader : IEndpointMetadataReader + { + public bool CanHandle(JObject document) + { + return (document["openapi"]?.ToString() ?? "").StartsWith("3.", StringComparison.Ordinal); + } + + public IEnumerable ReadMetadata(JObject document) + { + List metadata = new List(); + + if (document["paths"] is JObject paths) + { + foreach (JProperty path in paths.Properties()) + { + if (!(path.Value is JObject pathBody)) + { + continue; + } + + Dictionary>> requestMethods = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + foreach (JProperty method in pathBody.Properties()) + { + List parameters = new List(); + + if (method.Value is JObject methodBody) + { + if (methodBody["parameters"] is JArray parametersArray) + { + foreach (JObject parameterObj in parametersArray.OfType()) + { + Parameter p = parameterObj.ToObject(); + p.Location = parameterObj["in"]?.ToString(); + + if (!(parameterObj["schema"] is JObject schemaObject)) + { + schemaObject = null; + } + + p.Schema = schemaObject?.ToObject() ?? parameterObj.ToObject(); + parameters.Add(p); + } + } + + if (methodBody["requestBody"] is JObject bodyObject) + { + if (!(bodyObject["content"] is JObject contentTypeLookup)) + { + continue; + } + + foreach (JProperty contentTypeEntry in contentTypeLookup.Properties()) + { + List parametersByContentType = new List(parameters); + Parameter p = bodyObject.ToObject(); + p.Location = "body"; + p.IsRequired = bodyObject["required"]?.ToObject() ?? false; + + if (!(bodyObject["schema"] is JObject schemaObject)) + { + schemaObject = null; + } + + p.Schema = schemaObject?.ToObject() ?? bodyObject.ToObject(); + parametersByContentType.Add(p); + + Dictionary> bucketByMethod; + if (!requestMethods.TryGetValue(method.Name, out IReadOnlyDictionary> bucketByMethodRaw)) + { + requestMethods[method.Name] = bucketByMethodRaw = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { "", parametersByContentType } + }; + } + + bucketByMethod = (Dictionary>)bucketByMethodRaw; + bucketByMethod[contentTypeEntry.Name] = parametersByContentType; + } + } + } + } + + metadata.Add(new EndpointMetadata(path.Name, requestMethods)); + } + } + + return metadata; + } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/Parameter.cs b/src/Microsoft.HttpRepl/OpenApi/Parameter.cs new file mode 100644 index 0000000000..0a44f489c5 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/Parameter.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.HttpRepl.OpenApi +{ + public class Parameter + { + public string Name { get; set; } + + public string Location { get; set; } + + public bool IsRequired { get; set; } + + public Schema Schema { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/PointerUtil.cs b/src/Microsoft.HttpRepl/OpenApi/PointerUtil.cs new file mode 100644 index 0000000000..5bff058b95 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/PointerUtil.cs @@ -0,0 +1,150 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public static class PointerUtil + { + public static Task ResolvePointersAsync(Uri loadLocation, JToken root, HttpClient client) + { + return ResolvePointersAsync(loadLocation, root, root, client); + } + + private static async Task ResolvePointersAsync(Uri loadLocation, JToken root, JToken toResolve, HttpClient client) + { + JToken cursor = root; + + if (toResolve is JArray arr) + { + for (int i = 0; i < arr.Count; ++i) + { + arr[i] = await ResolvePointersAsync(loadLocation, root, arr[i], client).ConfigureAwait(false); + } + } + else if (toResolve is JObject obj) + { + if (obj["$ref"] is JValue refVal && refVal.Type == JTokenType.String) + { + if (!Uri.TryCreate((string)refVal.Value, UriKind.RelativeOrAbsolute, out Uri loadTarget)) + { + //TODO: Error resolving pointer (pointer must be a valid URI) + return new JValue((object)null); + } + + if (!loadTarget.IsAbsoluteUri) + { + if (!Uri.TryCreate(loadLocation, loadTarget, out loadTarget)) + { + //TODO: Error resolving pointer (could not combine with base path) + return new JValue((object)null); + } + } + + //Check to see if we're changing source documents, if we are, get it + if (!string.Equals(loadLocation.Host, loadTarget.Host, StringComparison.OrdinalIgnoreCase) || !string.Equals(loadLocation.AbsolutePath, loadTarget.AbsolutePath, StringComparison.OrdinalIgnoreCase)) + { + HttpResponseMessage responseMessage = await client.GetAsync(loadTarget).ConfigureAwait(false); + + if (!responseMessage.IsSuccessStatusCode) + { + //TODO: Error resolving pointer (could not get referenced document) + return new JValue((object)null); + } + + string responseString = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); + JToken newRoot; + + try + { + newRoot = JToken.Parse(responseString); + } + catch + { + //TODO: Error resolving pointer (referenced document is not valid JSON) + return new JValue((object)null); + } + + cursor = await ResolvePointersAsync(loadTarget, newRoot, newRoot, client).ConfigureAwait(false); + } + + //We're in the right document, grab the bookmark (fragment) of the URI and get the element at that path + string fragment = loadTarget.Fragment; + + if (fragment.StartsWith('#')) + { + fragment = fragment.Substring(1); + } + + string[] parts = fragment.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length; ++i) + { + if (cursor is JArray ca) + { + if (!int.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out int index)) + { + //TODO: Error resolving pointer, array index is non-integral + return new JValue((object)null); + } + + if (index < 0 || index >= ca.Count) + { + //TODO: Error resolving pointer, array index is out of bounds + return new JValue((object)null); + } + + JToken val = ca[index]; + if (val is JObject vo && vo.TryGetValue("$ref", out JToken vor) && vor is JValue vorv && vorv.Type == JTokenType.String) + { + cursor = await ResolvePointersAsync(loadLocation, root, val, client).ConfigureAwait(false); + } + else + { + cursor = val; + } + } + else if (cursor is JObject co) + { + if (!co.TryGetValue(parts[i], out JToken val)) + { + //TODO: Error resolving pointer, no such property on object + return new JValue((object)null); + } + + if (val is JObject vo && vo.TryGetValue("$ref", out JToken vor) && vor is JValue vorv && vorv.Type == JTokenType.String) + { + cursor = await ResolvePointersAsync(loadLocation, root, val, client).ConfigureAwait(false); + } + else + { + cursor = val; + } + } + else + { + //TODO: Error resolving pointer, cannot index into literal + return new JValue((object)null); + } + } + + cursor = await ResolvePointersAsync(loadLocation, root, cursor, client); + return cursor.DeepClone(); + } + + foreach (JProperty property in obj.Properties().ToList()) + { + obj[property.Name] = await ResolvePointersAsync(loadLocation, root, property.Value, client).ConfigureAwait(false); + } + } + + return toResolve; + } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/Schema.cs b/src/Microsoft.HttpRepl/OpenApi/Schema.cs new file mode 100644 index 0000000000..c84bbdda0f --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/Schema.cs @@ -0,0 +1,128 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class Schema + { + public void PrepareForUsage(JToken document) + { + AdditionalProperties?.Option1?.PrepareForUsage(document); + + if (AllOf != null) + { + for (int i = 0; i < AllOf.Length; ++i) + { + AllOf[i].PrepareForUsage(document); + } + } + + if (AnyOf != null) + { + for (int i = 0; i < AnyOf.Length; ++i) + { + AnyOf[i].PrepareForUsage(document); + } + } + + if (OneOf != null) + { + for (int i = 0; i < OneOf.Length; ++i) + { + OneOf[i].PrepareForUsage(document); + } + } + + if (Properties != null) + { + IReadOnlyList keys = Properties.Keys.ToList(); + for (int i = 0; i < keys.Count; ++i) + { + Properties[keys[i]]?.PrepareForUsage(document); + } + } + + Items?.PrepareForUsage(document); + Not?.PrepareForUsage(document); + + if (Required?.Option1 != null) + { + if (Properties != null) + { + foreach (string propertyName in Required.Option1) + { + if (Properties.TryGetValue(propertyName, out Schema value)) + { + value.Required = true; + } + } + } + + Required = false; + } + } + + [JsonConverter(typeof(EitherConverter))] + public Either AdditionalProperties { get; set; } + + public Schema[] AllOf { get; set; } + + public Schema[] AnyOf { get; set; } + + public object Default { get; set; } + + public string Description { get; set; } + + public object[] Enum { get; set; } + + public object Example { get; set; } + + public bool ExclusiveMaximum { get; set; } + + public bool ExclusiveMinimum { get; set; } + + public string Format { get; set; } + + public Schema Items { get; set; } + + public double? Maximum { get; set; } + + public double? Minimum { get; set; } + + public int? MaxItems { get; set; } + + public int? MinItems { get; set; } + + public int? MaxLength { get; set; } + + public int? MinLength { get; set; } + + public int? MaxProperties { get; set; } + + public int? MinProperties { get; set; } + + public double? MultipleOf { get; set; } + + public Schema Not { get; set; } + + public Schema[] OneOf { get; set; } + + public string Pattern { get; set; } + + public Dictionary Properties { get; set; } + + [JsonConverter(typeof(EitherConverter))] + public Either Required { get; set; } + + public string Title { get; set; } + + public string Type { get; set; } + + public bool UniqueItems { get; set; } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/SwaggerV1EndpointMetadataReader.cs b/src/Microsoft.HttpRepl/OpenApi/SwaggerV1EndpointMetadataReader.cs new file mode 100644 index 0000000000..d62fc8c3b1 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/SwaggerV1EndpointMetadataReader.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class SwaggerV1EndpointMetadataReader : IEndpointMetadataReader + { + public bool CanHandle(JObject document) + { + return (document["swaggerVersion"]?.ToString() ?? "").StartsWith("1.", StringComparison.Ordinal); + } + + public IEnumerable ReadMetadata(JObject document) + { + List metadata = new List(); + + if (!(document["consumes"] is JArray globalConsumes)) + { + globalConsumes = new JArray(); + } + + if (document["apis"] is JObject obj) + { + foreach (JProperty property in obj.Properties()) + { + string path = obj["path"]?.ToString(); + + if (path is null) + { + continue; + } + + Dictionary>> requestMethods = new Dictionary>>(StringComparer.Ordinal); + + if (obj["operations"] is JArray operations) + { + foreach (JObject operationObject in operations.OfType()) + { + string method = operationObject["method"]?.ToString(); + List parameters = new List(); + + if (operationObject["parameters"] is JArray parametersArray) + { + foreach (JObject parameterObj in parametersArray.OfType()) + { + Parameter p = parameterObj.ToObject(); + p.Location = parameterObj["paramType"]?.ToString(); + p.IsRequired = parameterObj["required"]?.ToObject() ?? false; + + string type = parameterObj["type"]?.ToString(); + + if (type is null) + { + continue; + } + + switch (type.ToUpperInvariant()) + { + case "INTEGER": + case "NUMBER": + case "STRING": + case "BOOLEAN": + p.Schema = new Schema { Type = type }; + break; + case "FILE": + break; + default: + if (document["models"]?[type] is JObject schemaObject) + { + //TODO: Handle subtypes (https://github.com/OAI/OpenAPI-Specification/blob/master/versions/1.2.md#527-model-object) + p.Schema = schemaObject.ToObject(); + } + break; + } + + parameters.Add(p); + } + } + + if (!(operationObject["consumes"] is JArray consumes)) + { + consumes = globalConsumes; + } + + Dictionary> parametersByContentType = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { "", parameters } + }; + + foreach (JValue value in consumes.OfType().Where(x => x.Type == JTokenType.String)) + { + parametersByContentType[value.ToString()] = parameters; + } + } + } + + metadata.Add(new EndpointMetadata(path, requestMethods)); + } + } + + return metadata; + } + } +} diff --git a/src/Microsoft.HttpRepl/OpenApi/SwaggerV2EndpointMetadataReader.cs b/src/Microsoft.HttpRepl/OpenApi/SwaggerV2EndpointMetadataReader.cs new file mode 100644 index 0000000000..66b16d2ed5 --- /dev/null +++ b/src/Microsoft.HttpRepl/OpenApi/SwaggerV2EndpointMetadataReader.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace Microsoft.HttpRepl.OpenApi +{ + public class SwaggerV2EndpointMetadataReader : IEndpointMetadataReader + { + public bool CanHandle(JObject document) + { + return (document["swagger"]?.ToString() ?? "").StartsWith("2.", StringComparison.Ordinal); + } + + public IEnumerable ReadMetadata(JObject document) + { + List metadata = new List(); + + if (!(document["consumes"] is JArray globalConsumes)) + { + globalConsumes = new JArray(); + } + + if (document["paths"] is JObject obj) + { + foreach (JProperty property in obj.Properties()) + { + if (!(property.Value is JObject requestMethodInfos)) + { + continue; + } + + Dictionary>> requestMethods = new Dictionary>>(StringComparer.Ordinal); + + foreach (JProperty methodInfo in requestMethodInfos.Properties()) + { + List parameters = new List(); + + if (methodInfo.Value is JObject methodInfoDescription) + { + if (methodInfoDescription["parameters"] is JArray parametersArray) + { + foreach (JObject parameterObj in parametersArray.OfType()) + { + //TODO: Resolve refs here + + Parameter p = parameterObj.ToObject(); + p.Location = parameterObj["in"]?.ToString(); + p.IsRequired = parameterObj["required"]?.ToObject() ?? false; + + if (!(parameterObj["schema"] is JObject schemaObject)) + { + schemaObject = null; + } + + p.Schema = schemaObject?.ToObject() ?? parameterObj.ToObject(); + parameters.Add(p); + } + } + + if (!(methodInfoDescription["consumes"] is JArray consumes)) + { + consumes = globalConsumes; + } + + Dictionary> parametersByContentType = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + { "", parameters } + }; + + foreach (JValue value in consumes.OfType().Where(x => x.Type == JTokenType.String)) + { + parametersByContentType[value.ToString()] = parameters; + } + + requestMethods[methodInfo.Name] = parametersByContentType; + } + } + + metadata.Add(new EndpointMetadata(property.Name, requestMethods)); + } + } + + return metadata; + } + } +} diff --git a/src/Microsoft.HttpRepl/Preferences/IJsonConfig.cs b/src/Microsoft.HttpRepl/Preferences/IJsonConfig.cs new file mode 100644 index 0000000000..96d36ad7af --- /dev/null +++ b/src/Microsoft.HttpRepl/Preferences/IJsonConfig.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl.Preferences +{ + public interface IJsonConfig + { + int IndentSize { get; } + + AllowedColors DefaultColor { get; } + + AllowedColors ArrayBraceColor { get; } + + AllowedColors ObjectBraceColor { get; } + + AllowedColors CommaColor { get; } + + AllowedColors NameColor { get; } + + AllowedColors NameSeparatorColor { get; } + + AllowedColors BoolColor { get; } + + AllowedColors NumericColor { get; } + + AllowedColors StringColor { get; } + + AllowedColors NullColor { get; } + } +} diff --git a/src/Microsoft.HttpRepl/Preferences/JsonConfig.cs b/src/Microsoft.HttpRepl/Preferences/JsonConfig.cs new file mode 100644 index 0000000000..67ce8aa2ed --- /dev/null +++ b/src/Microsoft.HttpRepl/Preferences/JsonConfig.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl.Preferences +{ + public class JsonConfig : IJsonConfig + { + private readonly HttpState _state; + + public int IndentSize => _state.GetIntPreference(WellKnownPreference.JsonIndentSize, 2); + + public AllowedColors DefaultColor => _state.GetColorPreference(WellKnownPreference.JsonColor); + + private AllowedColors DefaultBraceColor => _state.GetColorPreference(WellKnownPreference.JsonBraceColor, DefaultSyntaxColor); + + private AllowedColors DefaultSyntaxColor => _state.GetColorPreference(WellKnownPreference.JsonSyntaxColor, DefaultColor); + + private AllowedColors DefaultLiteralColor => _state.GetColorPreference(WellKnownPreference.JsonLiteralColor, DefaultColor); + + public AllowedColors ArrayBraceColor => _state.GetColorPreference(WellKnownPreference.JsonArrayBraceColor, DefaultBraceColor); + + public AllowedColors ObjectBraceColor => _state.GetColorPreference(WellKnownPreference.JsonObjectBraceColor, DefaultBraceColor); + + public AllowedColors CommaColor => _state.GetColorPreference(WellKnownPreference.JsonCommaColor, DefaultSyntaxColor); + + public AllowedColors NameColor => _state.GetColorPreference(WellKnownPreference.JsonNameColor, StringColor); + + public AllowedColors NameSeparatorColor => _state.GetColorPreference(WellKnownPreference.JsonNameSeparatorColor, DefaultSyntaxColor); + + public AllowedColors BoolColor => _state.GetColorPreference(WellKnownPreference.JsonBoolColor, DefaultLiteralColor); + + public AllowedColors NumericColor => _state.GetColorPreference(WellKnownPreference.JsonNumericColor, DefaultLiteralColor); + + public AllowedColors StringColor => _state.GetColorPreference(WellKnownPreference.JsonStringColor, DefaultLiteralColor); + + public AllowedColors NullColor => _state.GetColorPreference(WellKnownPreference.JsonNullColor, DefaultLiteralColor); + + public JsonConfig(HttpState state) + { + _state = state; + } + } +} diff --git a/src/Microsoft.HttpRepl/Preferences/RequestConfig.cs b/src/Microsoft.HttpRepl/Preferences/RequestConfig.cs new file mode 100644 index 0000000000..ae2b41e1fc --- /dev/null +++ b/src/Microsoft.HttpRepl/Preferences/RequestConfig.cs @@ -0,0 +1,49 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl.Preferences +{ + public class RequestConfig : RequestOrResponseConfig + { + public RequestConfig(HttpState state) + : base(state) + { + } + + public override AllowedColors BodyColor => State.GetColorPreference(WellKnownPreference.RequestBodyColor, base.BodyColor); + + public override AllowedColors SchemeColor => State.GetColorPreference(WellKnownPreference.RequestSchemeColor, base.SchemeColor); + + public override AllowedColors HeaderKeyColor => State.GetColorPreference(WellKnownPreference.RequestHeaderKeyColor, base.HeaderKeyColor); + + public override AllowedColors HeaderSeparatorColor => State.GetColorPreference(WellKnownPreference.RequestHeaderSeparatorColor, base.HeaderSeparatorColor); + + public override AllowedColors HeaderValueSeparatorColor => State.GetColorPreference(WellKnownPreference.RequestHeaderValueSeparatorColor, base.HeaderValueSeparatorColor); + + public override AllowedColors HeaderValueColor => State.GetColorPreference(WellKnownPreference.RequestHeaderValueColor, base.HeaderValueColor); + + public override AllowedColors HeaderColor => State.GetColorPreference(WellKnownPreference.RequestHeaderColor, base.HeaderColor); + + public override AllowedColors GeneralColor => State.GetColorPreference(WellKnownPreference.RequestColor, base.GeneralColor); + + public override AllowedColors ProtocolColor => State.GetColorPreference(WellKnownPreference.RequestProtocolColor, base.ProtocolColor); + + public override AllowedColors ProtocolNameColor => State.GetColorPreference(WellKnownPreference.RequestProtocolNameColor, base.ProtocolNameColor); + + public override AllowedColors ProtocolVersionColor => State.GetColorPreference(WellKnownPreference.RequestProtocolVersionColor, base.ProtocolVersionColor); + + public override AllowedColors ProtocolSeparatorColor => State.GetColorPreference(WellKnownPreference.RequestProtocolSeparatorColor, base.ProtocolSeparatorColor); + + public override AllowedColors StatusColor => State.GetColorPreference(WellKnownPreference.RequestStatusColor, base.StatusColor); + + public override AllowedColors StatusCodeColor => State.GetColorPreference(WellKnownPreference.RequestStatusCodeColor, base.StatusCodeColor); + + public override AllowedColors StatusReasonPhraseColor => State.GetColorPreference(WellKnownPreference.RequestStatusReaseonPhraseColor, base.StatusReasonPhraseColor); + + public AllowedColors MethodColor => State.GetColorPreference(WellKnownPreference.RequestMethodColor, GeneralColor); + + public AllowedColors AddressColor => State.GetColorPreference(WellKnownPreference.RequestAddressColor, GeneralColor); + } +} diff --git a/src/Microsoft.HttpRepl/Preferences/RequestOrResponseConfig.cs b/src/Microsoft.HttpRepl/Preferences/RequestOrResponseConfig.cs new file mode 100644 index 0000000000..c4385ad032 --- /dev/null +++ b/src/Microsoft.HttpRepl/Preferences/RequestOrResponseConfig.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl.Preferences +{ + public abstract class RequestOrResponseConfig + { + protected HttpState State { get; } + + protected RequestOrResponseConfig(HttpState state) + { + State = state; + } + + public virtual AllowedColors BodyColor => State.GetColorPreference(WellKnownPreference.BodyColor, GeneralColor); + + public virtual AllowedColors SchemeColor => State.GetColorPreference(WellKnownPreference.SchemeColor, GeneralColor); + + public virtual AllowedColors HeaderKeyColor => State.GetColorPreference(WellKnownPreference.HeaderKeyColor, HeaderColor); + + public virtual AllowedColors HeaderSeparatorColor => State.GetColorPreference(WellKnownPreference.HeaderSeparatorColor, HeaderColor); + + public virtual AllowedColors HeaderValueSeparatorColor => State.GetColorPreference(WellKnownPreference.HeaderValueSeparatorColor, HeaderSeparatorColor); + + public virtual AllowedColors HeaderValueColor => State.GetColorPreference(WellKnownPreference.HeaderValueColor, HeaderColor); + + public virtual AllowedColors HeaderColor => State.GetColorPreference(WellKnownPreference.HeaderColor, GeneralColor); + + public virtual AllowedColors GeneralColor => State.GetColorPreference(WellKnownPreference.RequestOrResponseColor); + + public virtual AllowedColors ProtocolColor => State.GetColorPreference(WellKnownPreference.ProtocolColor, GeneralColor); + + public virtual AllowedColors ProtocolNameColor => State.GetColorPreference(WellKnownPreference.ProtocolNameColor, ProtocolColor); + + public virtual AllowedColors ProtocolVersionColor => State.GetColorPreference(WellKnownPreference.ProtocolVersionColor, ProtocolColor); + + public virtual AllowedColors ProtocolSeparatorColor => State.GetColorPreference(WellKnownPreference.ProtocolSeparatorColor, ProtocolColor); + + public virtual AllowedColors StatusColor => State.GetColorPreference(WellKnownPreference.StatusColor, GeneralColor); + + public virtual AllowedColors StatusCodeColor => State.GetColorPreference(WellKnownPreference.StatusCodeColor, StatusColor); + + public virtual AllowedColors StatusReasonPhraseColor => State.GetColorPreference(WellKnownPreference.StatusReaseonPhraseColor, StatusColor); + } +} diff --git a/src/Microsoft.HttpRepl/Preferences/ResponseConfig.cs b/src/Microsoft.HttpRepl/Preferences/ResponseConfig.cs new file mode 100644 index 0000000000..d3a6c9c26f --- /dev/null +++ b/src/Microsoft.HttpRepl/Preferences/ResponseConfig.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl.Preferences +{ + public class ResponseConfig : RequestOrResponseConfig + { + public ResponseConfig(HttpState state) + : base(state) + { + } + + public override AllowedColors BodyColor => State.GetColorPreference(WellKnownPreference.ResponseBodyColor, base.BodyColor); + + public override AllowedColors SchemeColor => State.GetColorPreference(WellKnownPreference.ResponseSchemeColor, base.SchemeColor); + + public override AllowedColors HeaderKeyColor => State.GetColorPreference(WellKnownPreference.ResponseHeaderKeyColor, base.HeaderKeyColor); + + public override AllowedColors HeaderSeparatorColor => State.GetColorPreference(WellKnownPreference.ResponseHeaderSeparatorColor, base.HeaderSeparatorColor); + + public override AllowedColors HeaderValueSeparatorColor => State.GetColorPreference(WellKnownPreference.ResponseHeaderValueSeparatorColor, base.HeaderValueSeparatorColor); + + public override AllowedColors HeaderValueColor => State.GetColorPreference(WellKnownPreference.ResponseHeaderValueColor, base.HeaderValueColor); + + public override AllowedColors HeaderColor => State.GetColorPreference(WellKnownPreference.ResponseHeaderColor, base.HeaderColor); + + public override AllowedColors GeneralColor => State.GetColorPreference(WellKnownPreference.ResponseColor, base.GeneralColor); + + public override AllowedColors ProtocolColor => State.GetColorPreference(WellKnownPreference.ResponseProtocolColor, base.ProtocolColor); + + public override AllowedColors ProtocolNameColor => State.GetColorPreference(WellKnownPreference.ResponseProtocolNameColor, base.ProtocolNameColor); + + public override AllowedColors ProtocolVersionColor => State.GetColorPreference(WellKnownPreference.ResponseProtocolVersionColor, base.ProtocolVersionColor); + + public override AllowedColors ProtocolSeparatorColor => State.GetColorPreference(WellKnownPreference.ResponseProtocolSeparatorColor, base.ProtocolSeparatorColor); + + public override AllowedColors StatusColor => State.GetColorPreference(WellKnownPreference.ResponseStatusColor, base.StatusColor); + + public override AllowedColors StatusCodeColor => State.GetColorPreference(WellKnownPreference.ResponseStatusCodeColor, base.StatusCodeColor); + + public override AllowedColors StatusReasonPhraseColor => State.GetColorPreference(WellKnownPreference.ResponseStatusReaseonPhraseColor, base.StatusReasonPhraseColor); + } +} diff --git a/src/Microsoft.HttpRepl/Preferences/WellKnownPreference.cs b/src/Microsoft.HttpRepl/Preferences/WellKnownPreference.cs new file mode 100644 index 0000000000..a0456bb99f --- /dev/null +++ b/src/Microsoft.HttpRepl/Preferences/WellKnownPreference.cs @@ -0,0 +1,206 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Repl.ConsoleHandling; + +namespace Microsoft.HttpRepl.Preferences +{ + public static class WellKnownPreference + { + public static class Catalog + { + private static IReadOnlyList _names; + + public static IReadOnlyList Names + { + get + { + if (_names != null) + { + return _names; + } + + List matchingProperties = new List(); + + foreach (PropertyInfo property in typeof(WellKnownPreference).GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (property.PropertyType == typeof(string) && property.GetMethod != null && property.GetValue(null) is string val) + { + matchingProperties.Add(val); + } + } + + return _names = matchingProperties; + } + } + } + + public static string JsonArrayBraceColor { get; } = "colors.json.arrayBrace"; + + public static string JsonObjectBraceColor { get; } = "colors.json.objectBrace"; + + public static string JsonNameColor { get; } = "colors.json.name"; + + public static string JsonNameSeparatorColor { get; } = "colors.json.nameSeparator"; + + public static string JsonIndentSize { get; } = "formatting.json.indentSize"; + + public static string JsonCommaColor { get; } = "colors.json.comma"; + + public static string JsonLiteralColor { get; } = "colors.json.literal"; + + public static string JsonNullColor { get; } = "colors.json.null"; + + public static string JsonBoolColor { get; } = "colors.json.bool"; + + public static string JsonNumericColor { get; } = "colors.json.numeric"; + + public static string JsonStringColor { get; } = "colors.json.string"; + + public static string JsonColor { get; } = "colors.json"; + + public static string JsonSyntaxColor { get; } = "colors.json.syntax"; + + public static string JsonBraceColor { get; } = "colors.json.brace"; + + public static string RequestColor { get; } = "colors.request"; + + public static string RequestBodyColor { get; } = "colors.request.body"; + + public static string RequestSchemeColor { get; } = "colors.request.scheme"; + + public static string RequestHeaderKeyColor { get; } = "colors.request.header.key"; + + public static string RequestHeaderSeparatorColor { get; } = "colors.request.header.separator"; + + public static string RequestHeaderValueSeparatorColor { get; } = "colors.request.header.valueSeparator"; + + public static string RequestHeaderValueColor { get; } = "colors.request.header.value"; + + public static string RequestHeaderColor { get; } = "colors.request.header"; + + public static string RequestProtocolColor { get; } = "colors.request.protocol"; + + public static string RequestProtocolNameColor { get; } = "colors.request.protocol.name"; + + public static string RequestProtocolSeparatorColor { get; } = "colors.request.protocol.separator"; + + public static string RequestProtocolVersionColor { get; } = "colors.request.protocol.version"; + + public static string RequestStatusColor { get; } = "colors.request.status"; + + public static string RequestStatusCodeColor { get; } = "colors.request.status.code"; + + public static string RequestStatusReaseonPhraseColor { get; } = "colors.request.status.reasonPhrase"; + + public static string RequestMethodColor { get; } = "colors.request.method"; + + public static string RequestAddressColor { get; } = "colors.request.address"; + + + public static string ResponseColor { get; } = "colors.response"; + + public static string ResponseBodyColor { get; } = "colors.response.body"; + + public static string ResponseSchemeColor { get; } = "colors.response.scheme"; + + public static string ResponseHeaderKeyColor { get; } = "colors.response.header.key"; + + public static string ResponseHeaderSeparatorColor { get; } = "colors.response.header.separator"; + + public static string ResponseHeaderValueSeparatorColor { get; } = "colors.response.header.valueSeparator"; + + public static string ResponseHeaderValueColor { get; } = "colors.response.header.value"; + + public static string ResponseHeaderColor { get; } = "colors.response.header"; + + public static string ResponseProtocolColor { get; } = "colors.response.protocol"; + + public static string ResponseProtocolNameColor { get; } = "colors.response.protocol.name"; + + public static string ResponseProtocolSeparatorColor { get; } = "colors.response.protocol.separator"; + + public static string ResponseProtocolVersionColor { get; } = "colors.response.protocol.version"; + + public static string ResponseStatusColor { get; } = "colors.response.status"; + + public static string ResponseStatusCodeColor { get; } = "colors.response.status.code"; + + public static string ResponseStatusReaseonPhraseColor { get; } = "colors.response.status.reasonPhrase"; + + public static string RequestOrResponseColor { get; } = "colors.requestOrResponse"; + + public static string ErrorColor { get; } = "colors.error"; + + public static string WarningColor { get; } = "colors.warning"; + + public static string BodyColor { get; } = "colors.body"; + + public static string SchemeColor { get; } = "colors.scheme"; + + public static string HeaderKeyColor { get; } = "colors.header.key"; + + public static string HeaderSeparatorColor { get; } = "colors.header.separator"; + + public static string HeaderValueSeparatorColor { get; } = "colors.header.valueSeparator"; + + public static string HeaderValueColor { get; } = "colors.header.value"; + + public static string HeaderColor { get; } = "colors.header"; + + public static string ProtocolColor { get; } = "colors.protocol"; + + public static string ProtocolNameColor { get; } = "colors.protocol.name"; + + public static string ProtocolSeparatorColor { get; } = "colors.protocol.separator"; + + public static string ProtocolVersionColor { get; } = "colors.protocol.version"; + + public static string StatusColor { get; } = "colors.status"; + + public static string StatusCodeColor { get; } = "colors.status.code"; + + public static string StatusReaseonPhraseColor { get; } = "colors.status.reasonPhrase"; + + + public static string DefaultEditorCommand { get; } = "editor.command.default"; + + public static string DefaultEditorArguments { get; } = "editor.command.default.arguments"; + + public static string SwaggerRequeryBehavior { get; } = "swagger.requery"; + + + public static AllowedColors GetColorPreference(this HttpState programState, string preference, AllowedColors defaultvalue = AllowedColors.None) + { + if (!programState.Preferences.TryGetValue(preference, out string preferenceValueString) || !Enum.TryParse(preferenceValueString, true, out AllowedColors result)) + { + result = defaultvalue; + } + + return result; + } + + public static int GetIntPreference(this HttpState programState, string preference, int defaultValue = 0) + { + if (!programState.Preferences.TryGetValue(preference, out string preferenceValueString) || !int.TryParse(preferenceValueString, out int result)) + { + result = defaultValue; + } + + return result; + } + + public static string GetStringPreference(this HttpState programState, string preference, string defaultValue = null) + { + if (!programState.Preferences.TryGetValue(preference, out string result)) + { + result = defaultValue; + } + + return result; + } + } +} diff --git a/src/Microsoft.HttpRepl/Program.cs b/src/Microsoft.HttpRepl/Program.cs new file mode 100644 index 0000000000..e41a36ff21 --- /dev/null +++ b/src/Microsoft.HttpRepl/Program.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; +using Microsoft.HttpRepl.Commands; + +namespace Microsoft.HttpRepl +{ + class Program + { + static async Task Main(string[] args) + { + var state = new HttpState(); + + if (Console.IsOutputRedirected) + { + Reporter.Error.WriteLine("Cannot start the REPL when output is being redirected".SetColor(state.ErrorColor)); + return; + } + + var dispatcher = DefaultCommandDispatcher.Create(state.GetPrompt, state); + dispatcher.AddCommand(new ChangeDirectoryCommand()); + dispatcher.AddCommand(new ClearCommand()); + //dispatcher.AddCommand(new ConfigCommand()); + dispatcher.AddCommand(new DeleteCommand()); + dispatcher.AddCommand(new EchoCommand()); + dispatcher.AddCommand(new ExitCommand()); + dispatcher.AddCommand(new HeadCommand()); + dispatcher.AddCommand(new HelpCommand()); + dispatcher.AddCommand(new GetCommand()); + dispatcher.AddCommand(new ListCommand()); + dispatcher.AddCommand(new OptionsCommand()); + dispatcher.AddCommand(new PatchCommand()); + dispatcher.AddCommand(new PrefCommand()); + dispatcher.AddCommand(new PostCommand()); + dispatcher.AddCommand(new PutCommand()); + dispatcher.AddCommand(new RunCommand()); + dispatcher.AddCommand(new SetBaseCommand()); + dispatcher.AddCommand(new SetDiagCommand()); + dispatcher.AddCommand(new SetHeaderCommand()); + dispatcher.AddCommand(new SetSwaggerCommand()); + dispatcher.AddCommand(new UICommand()); + + CancellationTokenSource source = new CancellationTokenSource(); + var shell = new Shell(dispatcher); + shell.ShellState.ConsoleManager.AddBreakHandler(() => source.Cancel()); + if (args.Length > 0) + { + if (string.Equals(args[0], "--help", StringComparison.OrdinalIgnoreCase)) + { + shell.ShellState.ConsoleManager.WriteLine("Usage: dotnet httprepl [] [options]"); + shell.ShellState.ConsoleManager.WriteLine(); + shell.ShellState.ConsoleManager.WriteLine("Arguments:"); + shell.ShellState.ConsoleManager.WriteLine(" - The initial base address for the REPL."); + shell.ShellState.ConsoleManager.WriteLine(); + shell.ShellState.ConsoleManager.WriteLine("Options:"); + shell.ShellState.ConsoleManager.WriteLine(" --help - Show help information."); + + shell.ShellState.ConsoleManager.WriteLine(); + shell.ShellState.ConsoleManager.WriteLine("REPL Commands:"); + new HelpCommand().CoreGetHelp(shell.ShellState, (ICommandDispatcher)shell.ShellState.CommandDispatcher, state); + return; + } + + shell.ShellState.CommandDispatcher.OnReady(shell.ShellState); + shell.ShellState.InputManager.SetInput(shell.ShellState, $"set base \"{args[0]}\""); + await shell.ShellState.CommandDispatcher.ExecuteCommandAsync(shell.ShellState, CancellationToken.None).ConfigureAwait(false); + } + Task result = shell.RunAsync(source.Token); + await result.ConfigureAwait(false); + } + } +} diff --git a/src/Microsoft.HttpRepl/Properties/launchSettings.json b/src/Microsoft.HttpRepl/Properties/launchSettings.json new file mode 100644 index 0000000000..dcce8f72a3 --- /dev/null +++ b/src/Microsoft.HttpRepl/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Microsoft.HttpRepl": { + "commandName": "Project", + "commandLineArgs": "http://localhost" + } + } +} \ No newline at end of file diff --git a/src/Microsoft.HttpRepl/Suggestions/HeaderCompletion.cs b/src/Microsoft.HttpRepl/Suggestions/HeaderCompletion.cs new file mode 100644 index 0000000000..74ceab2507 --- /dev/null +++ b/src/Microsoft.HttpRepl/Suggestions/HeaderCompletion.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.HttpRepl.Suggestions +{ + public class HeaderCompletion + { + private static readonly IEnumerable CommonHeaders = new[] + { + "A-IM", + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Accept-Language", + "Accept-Datetime", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + "Authorization", + "Cache-Control", + "Connection", + "Content-Length", + "Content-MD5", + "Content-Type", + "Cookie", + "Date", + "Expect", + "Forwarded", + "From", + "Host", + "If-Match", + "If-Modified-Since", + "If-None-Match", + "If-Range", + "If-Unmodified-Since", + "Max-Forwards", + "Origin", + "Pragma", + "Proxy-Authentication", + "Range", + "Referer", + "TE", + "User-Agent", + "Upgrade", + "Via", + "Warning", + //Non-standard + "Upgrade-Insecure-Requests", + "X-Requested-With", + "DNT", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "Front-End-Https", + "X-Http-Method-Override", + "X-ATT-DeviceId", + "X-Wap-Profile", + "Proxy-Connection", + "X-UIDH", + "X-Csrf-Token", + "X-Request-ID", + "X-Correlation-ID" + }; + + private static readonly IReadOnlyList DefaultContentTypesList = null; + + public static IEnumerable GetCompletions(IReadOnlyCollection existingHeaders, string prefix) + { + List result = CommonHeaders.Where(x => x.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !(existingHeaders?.Contains(x) ?? false)).ToList(); + + if (result.Count > 0) + { + return result; + } + + return null; + } + + public static IEnumerable GetValueCompletions(string method, string path, string header, string prefix, HttpState programState) + { + switch (header.ToUpperInvariant()) + { + case "CONTENT-TYPE": + IEnumerable results = programState.GetApplicableContentTypes(method, path) ?? DefaultContentTypesList; + + if (results is null) + { + return null; + } + + return results.Where(x => !string.IsNullOrEmpty(x) && x.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + default: + return null; + } + } + } +} diff --git a/src/Microsoft.HttpRepl/Suggestions/ServerPathCompletion.cs b/src/Microsoft.HttpRepl/Suggestions/ServerPathCompletion.cs new file mode 100644 index 0000000000..2311b714b3 --- /dev/null +++ b/src/Microsoft.HttpRepl/Suggestions/ServerPathCompletion.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.HttpRepl.Suggestions +{ + public static class ServerPathCompletion + { + public static IEnumerable GetCompletions(HttpState programState, string normalCompletionString) + { + + //If it's an absolute URI, nothing to suggest + if (Uri.TryCreate(normalCompletionString, UriKind.Absolute, out Uri _)) + { + return null; + } + + string path = normalCompletionString.Replace('\\', '/'); + int searchFrom = normalCompletionString.Length - 1; + int lastSlash = path.LastIndexOf('/', searchFrom); + string prefix; + + if (lastSlash < 0) + { + path = string.Empty; + prefix = normalCompletionString; + } + else + { + path = path.Substring(0, lastSlash + 1); + prefix = normalCompletionString.Substring(lastSlash + 1); + } + + IDirectoryStructure s = programState.Structure.TraverseTo(programState.PathSections.Reverse()).TraverseTo(path); + + if (s?.DirectoryNames == null) + { + return null; + } + + List results = new List(); + + foreach (string child in s.DirectoryNames) + { + if (child.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + results.Add(path + child); + } + } + + return results; + } + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandHistory.cs b/src/Microsoft.Repl/Commanding/CommandHistory.cs new file mode 100644 index 0000000000..27489df0d1 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandHistory.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Repl.Commanding +{ + public class CommandHistory : ICommandHistory + { + private readonly int _maxEntries; + private readonly List _commandLines = new List(); + private int _currentCommand = -1; + private int _suspensionDepth; + + public CommandHistory(int maxEntries = 50) + { + _maxEntries = maxEntries; + } + + public void AddCommand(string command) + { + if (_suspensionDepth > 0) + { + return; + } + + _commandLines.Add(command); + if (_commandLines.Count > _maxEntries) + { + _commandLines.RemoveAt(0); + } + _currentCommand = -1; + } + + public string GetNextCommand() + { + if (_commandLines.Count == 0) + { + return string.Empty; + } + + if (_currentCommand == -1 || _currentCommand >= _commandLines.Count - 1) + { + _currentCommand = -1; + return string.Empty; + } + + return _commandLines[++_currentCommand]; + } + + public string GetPreviousCommand() + { + if (_commandLines.Count == 0) + { + return string.Empty; + } + + if (_currentCommand == -1) + { + _currentCommand = _commandLines.Count; + } + + if (_currentCommand > 0) + { + return _commandLines[--_currentCommand]; + } + + return _commandLines[0]; + } + + public IDisposable SuspendHistory() + { + ++_suspensionDepth; + return new Disposable(() => --_suspensionDepth); + } + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandInputLocation.cs b/src/Microsoft.Repl/Commanding/CommandInputLocation.cs new file mode 100644 index 0000000000..e1ed19a50c --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandInputLocation.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.Commanding +{ + public enum CommandInputLocation + { + CommandName, + Argument, + OptionName, + OptionValue + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandInputProcessingIssue.cs b/src/Microsoft.Repl/Commanding/CommandInputProcessingIssue.cs new file mode 100644 index 0000000000..f294718ff0 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandInputProcessingIssue.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.Commanding +{ + public class CommandInputProcessingIssue + { + public CommandInputProcessingIssueKind Kind { get; } + + public string Text { get; } + + public CommandInputProcessingIssue(CommandInputProcessingIssueKind kind, string text) + { + Kind = kind; + Text = text; + } + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandInputProcessingIssueKind.cs b/src/Microsoft.Repl/Commanding/CommandInputProcessingIssueKind.cs new file mode 100644 index 0000000000..dfc8a5f563 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandInputProcessingIssueKind.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.Commanding +{ + public enum CommandInputProcessingIssueKind + { + CommandMismatch, + ArgumentCountOutOfRange, + UnknownOption, + OptionUseCountOutOfRange, + MissingRequiredOptionInput, + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandInputSpecification.cs b/src/Microsoft.Repl/Commanding/CommandInputSpecification.cs new file mode 100644 index 0000000000..05a14e8641 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandInputSpecification.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Repl.Commanding +{ + public class CommandInputSpecification + { + public IReadOnlyList> CommandName { get; } + + public char OptionPreamble { get; } + + public int MinimumArguments { get; } + + public int MaximumArguments { get; } + + public IReadOnlyList Options { get; } + + public CommandInputSpecification(IReadOnlyList> name, char optionPreamble, IReadOnlyList options, int minimumArgs, int maximumArgs) + { + CommandName = name; + OptionPreamble = optionPreamble; + MinimumArguments = minimumArgs; + MaximumArguments = maximumArgs; + + if (MinimumArguments < 0) + { + MinimumArguments = 0; + } + + if (MaximumArguments < MinimumArguments) + { + MaximumArguments = MinimumArguments; + } + + Options = options; + } + + public static CommandInputSpecificationBuilder Create(string baseName, params string[] additionalNameParts) + { + List nameParts = new List {baseName}; + nameParts.AddRange(additionalNameParts); + return new CommandInputSpecificationBuilder(nameParts); + } + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandInputSpecificationBuilder.cs b/src/Microsoft.Repl/Commanding/CommandInputSpecificationBuilder.cs new file mode 100644 index 0000000000..7b5b529e3c --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandInputSpecificationBuilder.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Repl.Commanding +{ + public class CommandInputSpecificationBuilder + { + private readonly List> _name; + private char _optionPreamble; + private int _minimumArgs; + private int _maximumArgs; + private readonly List _options = new List(); + + public CommandInputSpecificationBuilder(IReadOnlyList name) + { + _name = new List> { name }; + _optionPreamble = '-'; + } + + public CommandInputSpecificationBuilder WithOptionPreamble(char optionChar) + { + _optionPreamble = optionChar; + return this; + } + + public CommandInputSpecificationBuilder ExactArgCount(int count) + { + _minimumArgs = count; + _maximumArgs = count; + return this; + } + + public CommandInputSpecificationBuilder MinimumArgCount(int count) + { + _minimumArgs = count; + if (_maximumArgs < count) + { + _maximumArgs = count; + } + + return this; + } + + public CommandInputSpecificationBuilder MaximumArgCount(int count) + { + _maximumArgs = count; + + if (_minimumArgs > count) + { + _minimumArgs = count; + } + + return this; + } + + public CommandInputSpecificationBuilder WithOption(CommandOptionSpecification option) + { + _options.Add(option); + return this; + } + + public CommandInputSpecification Finish() + { + return new CommandInputSpecification(_name, _optionPreamble, _options, _minimumArgs, _maximumArgs); + } + + public CommandInputSpecificationBuilder AlternateName(string baseName, params string[] additionalNameParts) + { + List nameParts = new List { baseName }; + nameParts.AddRange(additionalNameParts); + _name.Add(nameParts); + return this; + } + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandOptionSpecification.cs b/src/Microsoft.Repl/Commanding/CommandOptionSpecification.cs new file mode 100644 index 0000000000..6f7e4469fe --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandOptionSpecification.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Repl.Commanding +{ + public class CommandOptionSpecification + { + public string Id { get; } + + public IReadOnlyList Forms { get; } + + public int MaximumOccurrences { get; } + + public int MinimumOccurrences { get; } + + public bool AcceptsValue { get; } + + public bool RequiresValue { get; } + + public CommandOptionSpecification(string id, bool acceptsValue = false, bool requiresValue = false, int minimumOccurrences = 0, int maximumOccurrences = int.MaxValue, params string[] forms) + { + Id = id; + Forms = forms; + MinimumOccurrences = minimumOccurrences; + MaximumOccurrences = maximumOccurrences > minimumOccurrences ? maximumOccurrences : minimumOccurrences; + RequiresValue = requiresValue; + AcceptsValue = RequiresValue || acceptsValue; + } + } +} diff --git a/src/Microsoft.Repl/Commanding/CommandWithStructuredInputBase.cs b/src/Microsoft.Repl/Commanding/CommandWithStructuredInputBase.cs new file mode 100644 index 0000000000..a7e07b3cee --- /dev/null +++ b/src/Microsoft.Repl/Commanding/CommandWithStructuredInputBase.cs @@ -0,0 +1,213 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Commanding +{ + public abstract class CommandWithStructuredInputBase : ICommand + where TParseResult : ICoreParseResult + { + public abstract string GetHelpSummary(IShellState shellState, TProgramState programState); + + public string GetHelpDetails(IShellState shellState, TProgramState programState, TParseResult parseResult) + { + if (!DefaultCommandInput.TryProcess(InputSpec, parseResult, out DefaultCommandInput commandInput, out IReadOnlyList processingIssues) + && processingIssues.Any(x => x.Kind == CommandInputProcessingIssueKind.CommandMismatch)) + { + //If this is the right command, just not the right syntax, report the usage errors + return null; + } + + return GetHelpDetails(shellState, programState, commandInput, parseResult); + } + + protected abstract string GetHelpDetails(IShellState shellState, TProgramState programState, DefaultCommandInput commandInput, TParseResult parseResult); + + public IEnumerable Suggest(IShellState shellState, TProgramState programState, TParseResult parseResult) + { + DefaultCommandInput.TryProcess(InputSpec, parseResult, out DefaultCommandInput commandInput, out IReadOnlyList _); + + string normalCompletionString = parseResult.SelectedSection == parseResult.Sections.Count + ? string.Empty + : parseResult.Sections[parseResult.SelectedSection].Substring(0, parseResult.CaretPositionWithinSelectedSection); + + //If we're completing in a name position, offer completion for the command name + if (parseResult.SelectedSection < InputSpec.CommandName.Count) + { + IReadOnlyList commandName = null; + for (int j = 0; j < InputSpec.CommandName.Count; ++j) + { + bool success = true; + for (int i = 0; i < parseResult.SelectedSection; ++i) + { + if (!string.Equals(InputSpec.CommandName[j][i], parseResult.Sections[i], StringComparison.OrdinalIgnoreCase)) + { + success = false; + break; + } + } + + if (success) + { + commandName = InputSpec.CommandName[j]; + break; + } + } + + if (commandName is null) + { + return null; + } + + if (commandName[parseResult.SelectedSection].StartsWith(normalCompletionString, StringComparison.OrdinalIgnoreCase)) + { + return new[] {commandName[parseResult.SelectedSection]}; + } + } + + if (commandInput is null) + { + return null; + } + + if (normalCompletionString.StartsWith(InputSpec.OptionPreamble)) + { + return GetOptionCompletions(commandInput, normalCompletionString); + } + + IEnumerable completions = Enumerable.Empty(); + CommandInputLocation? inputLocation = commandInput.SelectedElement?.Location; + + if (inputLocation != CommandInputLocation.OptionValue && commandInput.Arguments.Count < InputSpec.MaximumArguments) + { + IEnumerable results = GetArgumentSuggestionsForText(shellState, programState, parseResult, commandInput, normalCompletionString); + + if (results != null) + { + completions = results; + } + } + + switch (inputLocation) + { + case CommandInputLocation.OptionName: + { + IEnumerable results = GetOptionCompletions(commandInput, normalCompletionString); + + if (results != null) + { + completions = completions.Union(results); + } + + break; + } + case CommandInputLocation.OptionValue: + { + IEnumerable results = GetOptionValueCompletions(shellState, programState, commandInput.SelectedElement.Owner.NormalizedText, commandInput, parseResult, normalCompletionString); + + if (results != null) + { + completions = completions.Union(results); + } + + break; + } + case CommandInputLocation.Argument: + { + IEnumerable argumentResults = GetArgumentSuggestionsForText(shellState, programState, parseResult, commandInput, normalCompletionString); + + if (argumentResults != null) + { + completions = completions.Union(argumentResults); + } + + if (string.IsNullOrEmpty(normalCompletionString)) + { + IEnumerable results = GetOptionCompletions(commandInput, normalCompletionString); + + if (results != null) + { + completions = completions.Union(results); + } + } + + break; + } + } + + return completions; + } + + protected virtual IEnumerable GetOptionValueCompletions(IShellState shellState, TProgramState programState, string optionId, DefaultCommandInput commandInput, TParseResult parseResult, string normalizedCompletionText) + { + return null; + } + + protected virtual IEnumerable GetArgumentSuggestionsForText(IShellState shellState, TProgramState programState, TParseResult parseResult, DefaultCommandInput commandInput, string normalCompletionString) + { + return null; + } + + private IEnumerable GetOptionCompletions(DefaultCommandInput commandInput, string normalCompletionString) + { + return InputSpec.Options.Where(x => commandInput.Options[x.Id].Count < x.MaximumOccurrences) + .SelectMany(x => x.Forms) + .Where(x => x.StartsWith(normalCompletionString, StringComparison.OrdinalIgnoreCase)); + } + + public bool? CanHandle(IShellState shellState, TProgramState programState, TParseResult parseResult) + { + if (!DefaultCommandInput.TryProcess(InputSpec, parseResult, out DefaultCommandInput commandInput, out IReadOnlyList processingIssues)) + { + //If this is the right command, just not the right syntax, report the usage errors + if (processingIssues.All(x => x.Kind != CommandInputProcessingIssueKind.CommandMismatch)) + { + foreach (CommandInputProcessingIssue issue in processingIssues) + { + shellState.ConsoleManager.Error.WriteLine(GetStringForIssue(issue)); + } + + string help = GetHelpDetails(shellState, programState, parseResult); + shellState.ConsoleManager.WriteLine(help); + return false; + } + + //If there was a mismatch in the command name, this isn't our input to handle + return null; + } + + return CanHandle(shellState, programState, commandInput); + } + + protected virtual bool CanHandle(IShellState shellState, TProgramState programState, DefaultCommandInput commandInput) + { + return true; + } + + protected virtual string GetStringForIssue(CommandInputProcessingIssue issue) + { + //TODO: Make this nicer + return issue.Kind + " -- " + issue.Text; + } + + public Task ExecuteAsync(IShellState shellState, TProgramState programState, TParseResult parseResult, CancellationToken cancellationToken) + { + if (!DefaultCommandInput.TryProcess(InputSpec, parseResult, out DefaultCommandInput commandInput, out IReadOnlyList _)) + { + return Task.CompletedTask; + } + + return ExecuteAsync(shellState, programState, commandInput, parseResult, cancellationToken); + } + + protected abstract Task ExecuteAsync(IShellState shellState, TProgramState programState, DefaultCommandInput commandInput, TParseResult parseResult, CancellationToken cancellationToken); + + protected abstract CommandInputSpecification InputSpec { get; } + } +} diff --git a/src/Microsoft.Repl/Commanding/DefaultCommandDispatcher.cs b/src/Microsoft.Repl/Commanding/DefaultCommandDispatcher.cs new file mode 100644 index 0000000000..22be314827 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/DefaultCommandDispatcher.cs @@ -0,0 +1,173 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Commanding +{ + public static class DefaultCommandDispatcher + { + public static DefaultCommandDispatcher Create(Func getPrompt, TProgramState programState) + { + return new DefaultCommandDispatcher(getPrompt, programState); + } + + public static DefaultCommandDispatcher Create(Action onReady, TProgramState programState) + { + return new DefaultCommandDispatcher(onReady, programState); + } + + public static DefaultCommandDispatcher Create(Func getPrompt, TProgramState programState, IParser parser) + where TParseResult : ICoreParseResult + { + return new DefaultCommandDispatcher(getPrompt, programState, parser); + } + + public static DefaultCommandDispatcher Create(Action onReady, TProgramState programState, IParser parser) + where TParseResult : ICoreParseResult + { + return new DefaultCommandDispatcher(onReady, programState, parser); + } + } + + public class DefaultCommandDispatcher : DefaultCommandDispatcher + { + public DefaultCommandDispatcher(Func getPrompt, TProgramState programState) + : base(getPrompt, programState, new CoreParser()) + { + } + + public DefaultCommandDispatcher(Action onReady, TProgramState programState) + : base(onReady, programState, new CoreParser()) + { + } + } + + public class DefaultCommandDispatcher : ICommandDispatcher + where TParseResult : ICoreParseResult + { + private readonly Action _onReady; + private readonly TProgramState _programState; + private readonly IParser _parser; + private readonly HashSet> _commands = new HashSet>(); + private bool _isReady; + + public DefaultCommandDispatcher(Func getPrompt, TProgramState programState, IParser parser) + : this(s => s.ConsoleManager.Write(getPrompt()), programState, parser) + { + } + + public DefaultCommandDispatcher(Action onReady, TProgramState programState, IParser parser) + { + _onReady = onReady; + _programState = programState; + _parser = parser; + } + + public void AddCommand(ICommand command) + { + _commands.Add(command); + } + + public IEnumerable> Commands => _commands; + + public IParser Parser => _parser; + + public IReadOnlyList CollectSuggesetions(IShellState shellState) + { + string line = shellState.InputManager.GetCurrentBuffer(); + TParseResult parseResult = _parser.Parse(line, shellState.ConsoleManager.CaretPosition); + HashSet suggestions = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (ICommand command in _commands) + { + IEnumerable commandSuggestions = command.Suggest(shellState, _programState, parseResult); + + if (commandSuggestions != null) + { + suggestions.UnionWith(commandSuggestions); + } + } + + return suggestions.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + } + + public async Task ExecuteCommandAsync(IShellState shellState, CancellationToken cancellationToken) + { + _isReady = false; + shellState.ConsoleManager.WriteLine(); + string commandText = shellState.InputManager.GetCurrentBuffer(); + + if (!string.IsNullOrWhiteSpace(commandText)) + { + shellState.CommandHistory.AddCommand(shellState.InputManager.GetCurrentBuffer()); + + try + { + await ExecuteCommandInternalAsync(shellState, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + shellState.ConsoleManager.Error.WriteLine(ex.ToString().Bold().Red()); + } + + if (cancellationToken.IsCancellationRequested) + { + shellState.ConsoleManager.Error.WriteLine("Execution was cancelled".Bold().Red()); + } + } + + if (!_isReady) + { + shellState.ConsoleManager.WriteLine(); + OnReady(shellState); + } + + shellState.InputManager.ResetInput(); + } + + private async Task ExecuteCommandInternalAsync(IShellState shellState, CancellationToken cancellationToken) + { + string line = shellState.InputManager.GetCurrentBuffer(); + TParseResult parseResult = _parser.Parse(line, shellState.ConsoleManager.CaretPosition); + + if (!string.IsNullOrWhiteSpace(parseResult.CommandText)) + { + foreach (ICommand command in _commands) + { + bool? result = command.CanHandle(shellState, _programState, parseResult); + + if (result.HasValue) + { + if (result.Value) + { + await command.ExecuteAsync(shellState, _programState, parseResult, cancellationToken); + } + + //If the handler returned non-null, the input would be directed to it, but it's not valid input + return; + } + } + + shellState.ConsoleManager.Error.WriteLine("No matching command found".Red().Bold()); + shellState.ConsoleManager.Error.WriteLine("Execute 'help' to se available commands".Red().Bold()); + } + } + + public void OnReady(IShellState shellState) + { + if (!_isReady) + { + _onReady(shellState); + shellState.ConsoleManager.ResetCommandStart(); + _isReady = true; + } + } + } +} diff --git a/src/Microsoft.Repl/Commanding/DefaultCommandInput.cs b/src/Microsoft.Repl/Commanding/DefaultCommandInput.cs new file mode 100644 index 0000000000..8ddaa031dc --- /dev/null +++ b/src/Microsoft.Repl/Commanding/DefaultCommandInput.cs @@ -0,0 +1,222 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Commanding +{ + public class DefaultCommandInput + where TParseResult : ICoreParseResult + { + public DefaultCommandInput(IReadOnlyList commandName, IReadOnlyList arguments, IReadOnlyDictionary> options, InputElement selectedElement) + { + CommandName = commandName; + Arguments = arguments; + Options = options; + SelectedElement = selectedElement; + } + + public static bool TryProcess(CommandInputSpecification spec, TParseResult parseResult, out DefaultCommandInput result, out IReadOnlyList processingIssues) + { + List issues = null; + List commandNameElements = null; + + foreach (IReadOnlyList commandName in spec.CommandName) + { + if (TryProcessCommandName(commandName, parseResult, out List nameElements, out issues)) + { + commandNameElements = nameElements; + break; + } + } + + if (commandNameElements is null) + { + result = null; + processingIssues = issues; + return false; + } + + List arguments = new List(); + Dictionary options = new Dictionary(); + InputElement currentOption = null; + CommandOptionSpecification currentOptionSpec = null; + InputElement selectedElement = null; + + for (int i = spec.CommandName.Count; i < parseResult.Sections.Count; ++i) + { + //If we're not looking at an option name + if (!parseResult.Sections[i].StartsWith(spec.OptionPreamble) || parseResult.IsQuotedSection(i)) + { + if (currentOption is null) + { + InputElement currentElement = new InputElement(CommandInputLocation.Argument, parseResult.Sections[i], parseResult.Sections[i], i); + + if (i == parseResult.SelectedSection) + { + selectedElement = currentElement; + } + + arguments.Add(currentElement); + } + else + { + //If the option isn't a defined one or it is and indicates that it accepts a value, add the section as an option value, + // otherwise add it as an argument + if (currentOptionSpec?.AcceptsValue ?? true) + { + InputElement currentElement = new InputElement(currentOption, CommandInputLocation.OptionValue, parseResult.Sections[i], parseResult.Sections[i], i); + + if (i == parseResult.SelectedSection) + { + selectedElement = currentElement; + } + + options[currentOption] = currentElement; + currentOption = null; + currentOptionSpec = null; + } + else + { + InputElement currentElement = new InputElement(CommandInputLocation.Argument, parseResult.Sections[i], parseResult.Sections[i], i); + + if (i == parseResult.SelectedSection) + { + selectedElement = currentElement; + } + + arguments.Add(currentElement); + } + } + } + //If we are looking at an option name + else + { + //Otherwise, check to see whether the previous option had a required argument before committing it + if (!(currentOption is null)) + { + options[currentOption] = null; + + if (currentOptionSpec?.RequiresValue ?? false) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.MissingRequiredOptionInput, currentOption.Text)); + } + } + + CommandOptionSpecification optionSpec = spec.Options.FirstOrDefault(x => x.Forms.Any(y => string.Equals(y, parseResult.Sections[i], StringComparison.Ordinal))); + + if (optionSpec is null) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.UnknownOption, parseResult.Sections[i])); + } + + currentOption = new InputElement(CommandInputLocation.OptionName, parseResult.Sections[i], optionSpec?.Id, i); + + if (i == parseResult.SelectedSection) + { + selectedElement = currentOption; + } + + currentOptionSpec = optionSpec; + } + } + + //Clear any option in progress + if (!(currentOption is null)) + { + options[currentOption] = null; + + if (currentOptionSpec?.RequiresValue ?? false) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.MissingRequiredOptionInput, currentOption.Text)); + } + } + + //Check to make sure our argument count is in range, if not add an issue + if (arguments.Count > spec.MaximumArguments || arguments.Count < spec.MinimumArguments) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.ArgumentCountOutOfRange, arguments.Count.ToString())); + } + + //Build up the dictionary of options by normal form, then validate counts for every option in the spec + Dictionary> optionsByNormalForm = new Dictionary>(StringComparer.Ordinal); + + foreach (KeyValuePair entry in options) + { + if (entry.Key.NormalizedText is null) + { + continue; + } + + if (!optionsByNormalForm.TryGetValue(entry.Key.NormalizedText, out IReadOnlyList rawBucket)) + { + optionsByNormalForm[entry.Key.NormalizedText] = rawBucket = new List(); + } + + List bucket = (List) rawBucket; + bucket.Add(entry.Value); + } + + foreach (CommandOptionSpecification optionSpec in spec.Options) + { + if (!optionsByNormalForm.TryGetValue(optionSpec.Id, out IReadOnlyList values)) + { + optionsByNormalForm[optionSpec.Id] = values = new List(); + } + + if (values.Count < optionSpec.MinimumOccurrences || values.Count > optionSpec.MaximumOccurrences) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.OptionUseCountOutOfRange, values.Count.ToString())); + } + } + + result = new DefaultCommandInput(commandNameElements, arguments, optionsByNormalForm, selectedElement); + processingIssues = issues; + return issues.Count == 0; + } + + private static bool TryProcessCommandName(IReadOnlyList commandName, TParseResult parseResult, out List nameElements, out List processingIssues) + { + List issues = new List(); + List commandNameElements = new List(); + + if (commandName.Count > parseResult.Sections.Count) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.CommandMismatch, commandName[parseResult.Sections.Count])); + } + + for (int i = 0; i < commandName.Count && i < parseResult.Sections.Count; ++i) + { + if (!string.Equals(commandName[i], parseResult.Sections[i], StringComparison.OrdinalIgnoreCase)) + { + issues.Add(new CommandInputProcessingIssue(CommandInputProcessingIssueKind.CommandMismatch, parseResult.Sections[i])); + } + + commandNameElements.Add(new InputElement(CommandInputLocation.CommandName, parseResult.Sections[i], commandName[i], i)); + } + + processingIssues = issues; + + //If we have a command name mismatch, no point in continuing + if (issues.Count > 0) + { + nameElements = null; + return false; + } + + nameElements = commandNameElements; + return true; + } + + public InputElement SelectedElement { get; } + + public IReadOnlyList CommandName { get; } + + public IReadOnlyList Arguments { get; } + + public IReadOnlyDictionary> Options { get; } + } +} diff --git a/src/Microsoft.Repl/Commanding/ICommand.cs b/src/Microsoft.Repl/Commanding/ICommand.cs new file mode 100644 index 0000000000..3b754924f0 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/ICommand.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Commanding +{ + public interface ICommand + where TParseResult : ICoreParseResult + { + string GetHelpSummary(IShellState shellState, TProgramState programState); + + string GetHelpDetails(IShellState shellState, TProgramState programState, TParseResult parseResult); + + IEnumerable Suggest(IShellState shellState, TProgramState programState, TParseResult parseResult); + + bool? CanHandle(IShellState shellState, TProgramState programState, TParseResult parseResult); + + Task ExecuteAsync(IShellState shellState, TProgramState programState, TParseResult parseResult, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Repl/Commanding/ICommandDispatcher.cs b/src/Microsoft.Repl/Commanding/ICommandDispatcher.cs new file mode 100644 index 0000000000..d2d9000123 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/ICommandDispatcher.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Commanding +{ + public interface ICommandDispatcher + { + IParser Parser { get; } + + IReadOnlyList CollectSuggesetions(IShellState shellState); + + void OnReady(IShellState shellState); + + Task ExecuteCommandAsync(IShellState shellState, CancellationToken cancellationToken); + } + + public interface ICommandDispatcher : ICommandDispatcher + where TParseResult : ICoreParseResult + { + IEnumerable> Commands { get; } + } +} diff --git a/src/Microsoft.Repl/Commanding/ICommandHistory.cs b/src/Microsoft.Repl/Commanding/ICommandHistory.cs new file mode 100644 index 0000000000..cd506fb2b3 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/ICommandHistory.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Repl.Commanding +{ + public interface ICommandHistory + { + string GetPreviousCommand(); + + string GetNextCommand(); + + void AddCommand(string command); + + IDisposable SuspendHistory(); + } +} diff --git a/src/Microsoft.Repl/Commanding/InputElement.cs b/src/Microsoft.Repl/Commanding/InputElement.cs new file mode 100644 index 0000000000..1f0c7cd764 --- /dev/null +++ b/src/Microsoft.Repl/Commanding/InputElement.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.Commanding +{ + public class InputElement + { + public CommandInputLocation Location { get; } + + public string Text { get; } + + public string NormalizedText { get; } + + public InputElement Owner { get; } + + public int ParseResultSectionIndex { get; } + + public InputElement(CommandInputLocation location, string text, string normalizedText, int sectionIndex) + : this(null, location, text, normalizedText, sectionIndex) + { + } + + public InputElement(InputElement owner, CommandInputLocation location, string text, string normalizedText, int sectionIndex) + { + Owner = owner; + Location = location; + Text = text; + NormalizedText = normalizedText; + ParseResultSectionIndex = sectionIndex; + } + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/AllowedColors.cs b/src/Microsoft.Repl/ConsoleHandling/AllowedColors.cs new file mode 100644 index 0000000000..fd9245db28 --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/AllowedColors.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Repl.ConsoleHandling +{ + [Flags] + public enum AllowedColors + { + Black = 0x00, + BoldBlack = Bold | Black, + Red = 0x01, + BoldRed = Bold | Red, + Green = 0x02, + BoldGreen = Bold | Green, + Yellow = 0x03, + BoldYellow = Bold | Yellow, + Blue = 0x04, + BoldBlue = Bold | Blue, + Magenta = 0x05, + BoldMagenta = Bold | Magenta, + Cyan = 0x06, + BoldCyan = Bold | Cyan, + White = 0x07, + BoldWhite = White | Bold, + Bold = 0x100, + None = 0x99 + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/AnsiColorExtensions.cs b/src/Microsoft.Repl/ConsoleHandling/AnsiColorExtensions.cs new file mode 100644 index 0000000000..8d535bf15f --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/AnsiColorExtensions.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.ConsoleHandling +{ + public static class AnsiColorExtensions + { + public static string Black(this string text) + { + return "\x1B[30m" + text + "\x1B[39m"; + } + + public static string Red(this string text) + { + return "\x1B[31m" + text + "\x1B[39m"; + } + public static string Green(this string text) + { + return "\x1B[32m" + text + "\x1B[39m"; + } + + public static string Yellow(this string text) + { + return "\x1B[33m" + text + "\x1B[39m"; + } + + public static string Blue(this string text) + { + return "\x1B[34m" + text + "\x1B[39m"; + } + + public static string Magenta(this string text) + { + return "\x1B[35m" + text + "\x1B[39m"; + } + + public static string Cyan(this string text) + { + return "\x1B[36m" + text + "\x1B[39m"; + } + + public static string White(this string text) + { + return "\x1B[37m" + text + "\x1B[39m"; + } + + public static string Bold(this string text) + { + return "\x1B[1m" + text + "\x1B[22m"; + } + + public static string SetColor(this string text, AllowedColors color) + { + if (color.HasFlag(AllowedColors.Bold)) + { + text = text.Bold(); + color = color & ~AllowedColors.Bold; + } + + switch (color) + { + case AllowedColors.Black: + return text.Black(); + case AllowedColors.Red: + return text.Red(); + case AllowedColors.Green: + return text.Green(); + case AllowedColors.Yellow: + return text.Yellow(); + case AllowedColors.Blue: + return text.Blue(); + case AllowedColors.Magenta: + return text.Magenta(); + case AllowedColors.Cyan: + return text.Cyan(); + case AllowedColors.White: + return text.White(); + default: + return text; + } + } + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/AnsiConsole.cs b/src/Microsoft.Repl/ConsoleHandling/AnsiConsole.cs new file mode 100644 index 0000000000..205c7713b5 --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/AnsiConsole.cs @@ -0,0 +1,154 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Repl.ConsoleHandling +{ + public class AnsiConsole + { + private AnsiConsole(TextWriter writer) + { + Writer = writer; + + OriginalForegroundColor = Console.ForegroundColor; + } + + private int _boldRecursion; + + public static AnsiConsole GetOutput() + { + return new AnsiConsole(Console.Out); + } + + public static AnsiConsole GetError() + { + return new AnsiConsole(Console.Error); + } + + public TextWriter Writer { get; } + + public ConsoleColor OriginalForegroundColor { get; } + + private void SetColor(ConsoleColor color) + { + const int light = 0x08; + int c = (int)color; + + Console.ForegroundColor = + c < 0 ? color : // unknown, just use it + _boldRecursion > 0 ? (ConsoleColor)(c | light) : // ensure color is light + (ConsoleColor)(c & ~light); // ensure color is dark + } + + private void SetBold(bool bold) + { + _boldRecursion += bold ? 1 : -1; + if (_boldRecursion > 1 || (_boldRecursion == 1 && !bold)) + { + return; + } + + // switches on _boldRecursion to handle boldness + SetColor(Console.ForegroundColor); + } + + public void WriteLine(string message) + { + Write(message); + Writer.WriteLine(); + } + + public void Write(char message) + { + Writer.Write(message); + } + + public void Write(string message) + { + if (message is null) + { + return; + } + + var escapeScan = 0; + for (; ; ) + { + var escapeIndex = message.IndexOf("\x1b[", escapeScan, StringComparison.Ordinal); + if (escapeIndex == -1) + { + var text = message.Substring(escapeScan); + Writer.Write(text); + break; + } + else + { + var startIndex = escapeIndex + 2; + var endIndex = startIndex; + while (endIndex != message.Length && + message[endIndex] >= 0x20 && + message[endIndex] <= 0x3f) + { + endIndex += 1; + } + + var text = message.Substring(escapeScan, escapeIndex - escapeScan); + Writer.Write(text); + if (endIndex == message.Length) + { + break; + } + + switch (message[endIndex]) + { + case 'm': + if (int.TryParse(message.Substring(startIndex, endIndex - startIndex), out int value)) + { + switch (value) + { + case 1: + SetBold(true); + break; + case 22: + SetBold(false); + break; + case 30: + SetColor(ConsoleColor.Black); + break; + case 31: + SetColor(ConsoleColor.Red); + break; + case 32: + SetColor(ConsoleColor.Green); + break; + case 33: + SetColor(ConsoleColor.Yellow); + break; + case 34: + SetColor(ConsoleColor.Blue); + break; + case 35: + SetColor(ConsoleColor.Magenta); + break; + case 36: + SetColor(ConsoleColor.Cyan); + break; + case 37: + SetColor(ConsoleColor.Gray); + break; + case 39: + Console.ForegroundColor = OriginalForegroundColor; + break; + } + } + break; + } + + escapeScan = endIndex + 1; + } + } + } + } + +} diff --git a/src/Microsoft.Repl/ConsoleHandling/ConsoleManager.cs b/src/Microsoft.Repl/ConsoleHandling/ConsoleManager.cs new file mode 100644 index 0000000000..ad4231ad5e --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/ConsoleManager.cs @@ -0,0 +1,207 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace Microsoft.Repl.ConsoleHandling +{ + public class ConsoleManager : IConsoleManager + { + private readonly List _breakHandlers = new List(); + + public Point Caret => new Point(Console.CursorLeft, Console.CursorTop); + + public Point CommandStart => new Point(Caret.X - CaretPosition % Console.BufferWidth, Caret.Y - CaretPosition / Console.BufferWidth); + + public int CaretPosition { get; private set; } + + public bool IsKeyAvailable => Console.KeyAvailable; + + public bool IsCaretVisible + { + get => Reporter.Output.IsCaretVisible; + set => Reporter.Output.IsCaretVisible = value; + } + + public ConsoleManager() + { + Error = new Writable(CaretUpdateScope, Reporter.Error); + Console.CancelKeyPress += OnCancelKeyPress; + } + + public void Clear() + { + using (CaretUpdateScope()) + { + Console.Clear(); + ResetCommandStart(); + } + } + + public void MoveCaret(int positions) + { + using (CaretUpdateScope()) + { + if (positions == 0) + { + return; + } + + while (positions < 0 && CaretPosition > 0) + { + if (-positions > Console.BufferWidth) + { + if (Console.CursorTop == 0) + { + Console.CursorLeft = 0; + positions = 0; + } + else + { + positions += Console.BufferWidth; + --Console.CursorTop; + } + } + else + { + int remaining = Console.CursorLeft + positions; + + if (remaining >= 0) + { + Console.CursorLeft = remaining; + } + else if (Console.CursorTop == 0) + { + Console.CursorLeft = 0; + } + else + { + --Console.CursorTop; + Console.CursorLeft = Console.BufferWidth + remaining; + } + + positions = 0; + } + } + + while (positions > 0) + { + if (positions > Console.BufferWidth) + { + positions -= Console.BufferWidth; + ++Console.CursorTop; + } + else + { + int spaceLeftOnLine = Console.BufferWidth - Console.CursorLeft - 1; + if (positions > spaceLeftOnLine) + { + ++Console.CursorTop; + Console.CursorLeft = positions - spaceLeftOnLine - 1; + } + else + { + Console.CursorLeft += positions; + } + + positions = 0; + } + } + } + } + + public ConsoleKeyInfo ReadKey(CancellationToken cancellationToken) + { + while (!Console.KeyAvailable && !cancellationToken.IsCancellationRequested) + { + Thread.Sleep(2); + } + + if (cancellationToken.IsCancellationRequested) + { + return default(ConsoleKeyInfo); + } + else + { + return Console.ReadKey(true); + } + } + + public void ResetCommandStart() + { + CaretPosition = 0; + } + + public void Write(char c) + { + using (CaretUpdateScope()) + { + Reporter.Output.Write(c); + } + } + + public void Write(string s) + { + using (CaretUpdateScope()) + { + Reporter.Output.Write(s); + } + } + + public void WriteLine() + { + using (CaretUpdateScope()) + { + Reporter.Output.WriteLine(); + } + } + + public void WriteLine(string s) + { + if (s is null) + { + return; + } + + using (CaretUpdateScope()) + { + Reporter.Output.WriteLine(s); + } + } + + public IDisposable AddBreakHandler(Action handler) + { + Disposable result = new Disposable(() => ReleaseBreakHandler(handler)); + _breakHandlers.Add(handler); + return result; + } + + private IDisposable CaretUpdateScope() + { + Point currentCaret = Caret; + return new Disposable(() => + { + int y = Caret.Y - currentCaret.Y; + int x = Caret.X - currentCaret.X; + CaretPosition += y * Console.BufferWidth + x; + }); + } + + private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + Action handler = _breakHandlers.LastOrDefault(); + handler?.Invoke(); + } + + private void ReleaseBreakHandler(Action handler) + { + _breakHandlers.Remove(handler); + } + + public IWritable Error { get; } + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/IConsoleManager.cs b/src/Microsoft.Repl/ConsoleHandling/IConsoleManager.cs new file mode 100644 index 0000000000..ca93dbe88d --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/IConsoleManager.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; + +namespace Microsoft.Repl.ConsoleHandling +{ + public interface IConsoleManager : IWritable + { + Point Caret { get; } + + Point CommandStart { get; } + + int CaretPosition { get; } + + IWritable Error { get; } + + bool IsKeyAvailable { get; } + + void Clear(); + + void MoveCaret(int positions); + + ConsoleKeyInfo ReadKey(CancellationToken cancellationToken); + + void ResetCommandStart(); + + IDisposable AddBreakHandler(Action onBreak); + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/IWritable.cs b/src/Microsoft.Repl/ConsoleHandling/IWritable.cs new file mode 100644 index 0000000000..fcb5441a69 --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/IWritable.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.ConsoleHandling +{ + public interface IWritable + { + void Write(char c); + + void Write(string s); + + void WriteLine(); + + void WriteLine(string s); + + bool IsCaretVisible { get; set; } + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/Point.cs b/src/Microsoft.Repl/ConsoleHandling/Point.cs new file mode 100644 index 0000000000..e705eada63 --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/Point.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.ConsoleHandling +{ + public struct Point + { + public readonly int X; + + public readonly int Y; + + public Point(int x, int y) + { + X = x; + Y = y; + } + + public static bool operator >(Point left, Point right) + { + return left.Y > right.Y || (left.Y == right.Y && right.X > left.X); + } + + public static bool operator <(Point left, Point right) + { + return left.Y < right.Y || (left.Y == right.Y && right.X < left.X); + } + + public static bool operator ==(Point left, Point right) + { + return left.X == right.X && left.Y == right.Y; + } + + public static bool operator !=(Point left, Point right) + { + return left.X != right.X || left.Y != right.Y; + } + + public override bool Equals(object obj) + { + return obj is Point other && other.X == X && other.Y == Y; + } + + public override int GetHashCode() + { + return X ^ Y; + } + + public override string ToString() + { + return $"(X={X}, Y={Y})"; + } + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/Reporter.cs b/src/Microsoft.Repl/ConsoleHandling/Reporter.cs new file mode 100644 index 0000000000..a7da8d0060 --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/Reporter.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Repl.ConsoleHandling +{ + public class Reporter : IWritable + { + private static readonly Reporter NullReporter = new Reporter(null); + private static readonly object Sync = new object(); + + private readonly AnsiConsole _console; + + static Reporter() + { + Reset(); + } + + private Reporter(AnsiConsole console) + { + _console = console; + } + + public static Reporter Output { get; private set; } + public static Reporter Error { get; private set; } + public static Reporter Verbose { get; private set; } + + /// + /// Resets the Reporters to write to the current Console Out/Error. + /// + public static void Reset() + { + lock (Sync) + { + Output = new Reporter(AnsiConsole.GetOutput()); + Error = new Reporter(AnsiConsole.GetError()); + Verbose = IsVerbose ? + new Reporter(AnsiConsole.GetOutput()) : + NullReporter; + } + } + + public void WriteLine(string message) + { + if (message is null) + { + return; + } + + lock (Sync) + { + if (ShouldPassAnsiCodesThrough) + { + _console?.Writer?.WriteLine(message); + } + else + { + _console?.WriteLine(message); + } + } + } + + public void WriteLine() + { + lock (Sync) + { + _console?.Writer?.WriteLine(); + } + } + + public void Write(char message) + { + lock (Sync) + { + if (ShouldPassAnsiCodesThrough) + { + _console?.Writer?.Write(message); + } + else + { + _console?.Write(message); + } + } + } + + public void Write(string message) + { + lock (Sync) + { + if (ShouldPassAnsiCodesThrough) + { + _console?.Writer?.Write(message); + } + else + { + _console?.Write(message); + } + } + } + + private static bool IsVerbose => bool.TryParse(Environment.GetEnvironmentVariable("DOTNET_CLI_CONTEXT_VERBOSE") ?? "false", out bool value) && value; + + private bool ShouldPassAnsiCodesThrough => bool.TryParse(Environment.GetEnvironmentVariable("DOTNET_CLI_CONTEXT_ANSI_PASS_THRU") ?? "false", out bool value) && value; + + private bool _isCaretVisible = true; + + public bool IsCaretVisible + { + get => _isCaretVisible; + set + { + Console.CursorVisible = value; + _isCaretVisible = value; + } + } + } +} diff --git a/src/Microsoft.Repl/ConsoleHandling/Writable.cs b/src/Microsoft.Repl/ConsoleHandling/Writable.cs new file mode 100644 index 0000000000..1b32e939f4 --- /dev/null +++ b/src/Microsoft.Repl/ConsoleHandling/Writable.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Repl.ConsoleHandling +{ + internal class Writable : IWritable + { + private readonly Func _caretUpdater; + private readonly Reporter _reporter; + + public Writable(Func caretUpdater, Reporter reporter) + { + _caretUpdater = caretUpdater; + _reporter = reporter; + } + + public bool IsCaretVisible + { + get => _reporter.IsCaretVisible; + set => _reporter.IsCaretVisible = value; + } + + public void Write(char c) + { + using (_caretUpdater()) + { + _reporter.Write(c); + } + } + + public void Write(string s) + { + using (_caretUpdater()) + { + _reporter.Write(s); + } + } + + public void WriteLine() + { + using (_caretUpdater()) + { + _reporter.WriteLine(); + } + } + + public void WriteLine(string s) + { + using (_caretUpdater()) + { + _reporter.WriteLine(s); + } + } + } +} diff --git a/src/Microsoft.Repl/Disposable.cs b/src/Microsoft.Repl/Disposable.cs new file mode 100644 index 0000000000..40b892ace1 --- /dev/null +++ b/src/Microsoft.Repl/Disposable.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Repl +{ + public class Disposable : IDisposable + { + private Action _onDispose; + + public Disposable(Action onDispose) + { + _onDispose = onDispose; + } + public virtual void Dispose() + { + _onDispose?.Invoke(); + _onDispose = null; + } + } + + public class Disposable : Disposable + where T : class + { + public Disposable(T value, Action onDispose) + : base (onDispose) + { + Value = value; + } + + public T Value { get; private set; } + + public override void Dispose() + { + if (Value is IDisposable d) + { + d.Dispose(); + Value = null; + } + + base.Dispose(); + } + } +} diff --git a/src/Microsoft.Repl/IShellState.cs b/src/Microsoft.Repl/IShellState.cs new file mode 100644 index 0000000000..1941fa65f9 --- /dev/null +++ b/src/Microsoft.Repl/IShellState.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Input; +using Microsoft.Repl.Suggestions; + +namespace Microsoft.Repl +{ + public interface IShellState + { + IInputManager InputManager { get; } + + ICommandHistory CommandHistory { get; } + + IConsoleManager ConsoleManager { get; } + + ICommandDispatcher CommandDispatcher { get; } + + ISuggestionManager SuggestionManager { get; } + + bool IsExiting { get; set; } + } +} diff --git a/src/Microsoft.Repl/Input/AsyncKeyPressHandler.cs b/src/Microsoft.Repl/Input/AsyncKeyPressHandler.cs new file mode 100644 index 0000000000..4ac3793979 --- /dev/null +++ b/src/Microsoft.Repl/Input/AsyncKeyPressHandler.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Repl.Input +{ + public delegate Task AsyncKeyPressHandler(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken); +} diff --git a/src/Microsoft.Repl/Input/IInputManager.cs b/src/Microsoft.Repl/Input/IInputManager.cs new file mode 100644 index 0000000000..e8e98b2d54 --- /dev/null +++ b/src/Microsoft.Repl/Input/IInputManager.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Repl.Input +{ + public interface IInputManager + { + bool IsOverwriteMode { get; set; } + + IInputManager RegisterKeyHandler(ConsoleKey key, AsyncKeyPressHandler handler); + + IInputManager RegisterKeyHandler(ConsoleKey key, ConsoleModifiers modifiers, AsyncKeyPressHandler handler); + + void ResetInput(); + + Task StartAsync(IShellState state, CancellationToken cancellationToken); + + void SetInput(IShellState state, string input); + + string GetCurrentBuffer(); + + void RemovePreviousCharacter(IShellState state); + + void RemoveCurrentCharacter(IShellState state); + + void Clear(IShellState state); + } +} diff --git a/src/Microsoft.Repl/Input/InputManager.cs b/src/Microsoft.Repl/Input/InputManager.cs new file mode 100644 index 0000000000..cd1157c6ff --- /dev/null +++ b/src/Microsoft.Repl/Input/InputManager.cs @@ -0,0 +1,373 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Repl.Input +{ + public class InputManager : IInputManager + { + private readonly Dictionary> _handlers = new Dictionary>(); + private readonly List _inputBuffer = new List(); + + public bool IsOverwriteMode { get; set; } + + public void Clear(IShellState state) + { + SetInput(state, string.Empty); + } + + public string GetCurrentBuffer() + { + return _inputBuffer.Stringify(); + } + + public IInputManager RegisterKeyHandler(ConsoleKey key, AsyncKeyPressHandler handler) + { + if (!_handlers.TryGetValue(key, out Dictionary handlers)) + { + _handlers[key] = handlers = new Dictionary(); + } + + if (handler == null) + { + handlers.Remove(default(ConsoleModifiers)); + } + else + { + handlers[default(ConsoleModifiers)] = handler; + } + + return this; + } + + public IInputManager RegisterKeyHandler(ConsoleKey key, ConsoleModifiers modifiers, AsyncKeyPressHandler handler) + { + if (!_handlers.TryGetValue(key, out Dictionary handlers)) + { + _handlers[key] = handlers = new Dictionary(); + } + + if (handler == null) + { + handlers.Remove(modifiers); + } + else + { + handlers[modifiers] = handler; + } + + return this; + } + + public void RemoveCurrentCharacter(IShellState state) + { + int caret = state.ConsoleManager.CaretPosition; + + if (caret == _inputBuffer.Count) + { + return; + } + + List update = _inputBuffer.ToList(); + update.RemoveAt(caret); + state.ConsoleManager.IsCaretVisible = false; + SetInput(state, update); + state.ConsoleManager.MoveCaret(caret - state.ConsoleManager.CaretPosition); + state.ConsoleManager.IsCaretVisible = true; + } + + public void RemovePreviousCharacter(IShellState state) + { + int caret = state.ConsoleManager.CaretPosition; + if (caret == 0) + { + return; + } + + List update = _inputBuffer.ToList(); + update.RemoveAt(caret - 1); + state.ConsoleManager.IsCaretVisible = false; + SetInput(state, update, false); + state.ConsoleManager.MoveCaret(caret - state.ConsoleManager.CaretPosition - 1); + state.ConsoleManager.IsCaretVisible = true; + } + + public void SetInput(IShellState state, string input) + { + SetInput(state, input.ToCharArray()); + } + + public void ResetInput() + { + _inputBuffer.Clear(); + } + + private string _ttyState; + + private void StashEchoState() + { + _ttyState = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX) + ? GetTtyState() + : null; + + if (!string.IsNullOrEmpty(_ttyState)) + { + //"gfmt1:cflag=4300:iflag=6b02:lflag=200005c7:oflag=3:discard=f:dsusp=19:eof=4:eol=ff:eol2=ff:erase=7f:intr=3:kill=15:lnext=16:min=1:quit=1c:reprint=12:start=11:status=14:stop=13:susp=1a:time=0:werase=17:ispeed=38400:ospeed=38400\n" + ProcessStartInfo psi = new ProcessStartInfo("stty", "gfmt1:erase=08:werase=08 -echo"); + Process p = Process.Start(psi); + p?.WaitForExit(); + } + } + + private static string GetTtyState() + { + ProcessStartInfo psi = new ProcessStartInfo("stty", "-g") + { + RedirectStandardOutput = true + }; + Process p = Process.Start(psi); + p?.WaitForExit(); + string result = p?.StandardOutput.ReadToEnd(); + return result; + } + + private void RestoreTtyState() + { + if (!string.IsNullOrEmpty(_ttyState)) + { + ProcessStartInfo psi = new ProcessStartInfo("stty", _ttyState); + Process p = Process.Start(psi); + p?.WaitForExit(); + } + } + + private void SetInput(IShellState state, IReadOnlyList input, bool moveCaret = true) + { + bool oldCaretVisibility = state.ConsoleManager.IsCaretVisible; + state.ConsoleManager.IsCaretVisible = false; + int lastCommonPosition = 0; + + for (; lastCommonPosition < input.Count && lastCommonPosition < _inputBuffer.Count && _inputBuffer[lastCommonPosition] == input[lastCommonPosition]; ++lastCommonPosition) + { + } + + state.ConsoleManager.MoveCaret(-state.ConsoleManager.CaretPosition + lastCommonPosition); + string str = new string(input.Skip(lastCommonPosition).ToArray()); + int trailing = _inputBuffer.Count - input.Count; + + if (trailing > 0) + { + str = str.PadRight(trailing + str.Length); + } + + state.ConsoleManager.Write(str); + + if (trailing > 0 && moveCaret) + { + state.ConsoleManager.MoveCaret(-trailing); + } + + _inputBuffer.Clear(); + _inputBuffer.AddRange(input); + + if (oldCaretVisibility) + { + state.ConsoleManager.IsCaretVisible = true; + } + } + + public async Task StartAsync(IShellState state, CancellationToken cancellationToken) + { + StashEchoState(); + + try + { + List presses = null; + while (!state.IsExiting && !cancellationToken.IsCancellationRequested) + { + ConsoleKeyInfo keyPress = state.ConsoleManager.ReadKey(cancellationToken); + + if (_handlers.TryGetValue(keyPress.Key, out Dictionary handlerLookup) && handlerLookup.TryGetValue(keyPress.Modifiers, out AsyncKeyPressHandler handler)) + { + using (CancellationTokenSource source = new CancellationTokenSource()) + using (state.ConsoleManager.AddBreakHandler(() => source.Cancel())) + { + if (presses != null) + { + FlushInput(state, ref presses); + } + + await handler(keyPress, state, source.Token).ConfigureAwait(false); + } + } + else if (!string.IsNullOrEmpty(_ttyState) && keyPress.Modifiers == ConsoleModifiers.Control) + { + if (presses != null) + { + FlushInput(state, ref presses); + } + + //TODO: Verify on a mac whether these are still needed + if (keyPress.Key == ConsoleKey.A) + { + state.ConsoleManager.MoveCaret(-state.ConsoleManager.CaretPosition); + } + else if (keyPress.Key == ConsoleKey.E) + { + state.ConsoleManager.MoveCaret(_inputBuffer.Count - state.ConsoleManager.CaretPosition); + } + } + //TODO: Register these like regular commands + else if (!string.IsNullOrEmpty(_ttyState) && keyPress.Modifiers == ConsoleModifiers.Alt) + { + if (presses != null) + { + FlushInput(state, ref presses); + } + + //Move back a word + if (keyPress.Key == ConsoleKey.B) + { + int i = state.ConsoleManager.CaretPosition - 1; + + if (i < 0) + { + continue; + } + + bool letterMode = char.IsLetterOrDigit(_inputBuffer[i]); + + for (; i > 0 && (char.IsLetterOrDigit(_inputBuffer[i]) == letterMode); --i) + { + } + + if (letterMode && i > 0) + { + ++i; + } + + if (i > -1) + { + state.ConsoleManager.MoveCaret(i - state.ConsoleManager.CaretPosition); + } + } + //Move forward a word + else if (keyPress.Key == ConsoleKey.F) + { + int i = state.ConsoleManager.CaretPosition + 1; + + if (i >= _inputBuffer.Count) + { + continue; + } + + bool letterMode = char.IsLetterOrDigit(_inputBuffer[i]); + + for (; i < _inputBuffer.Count && (char.IsLetterOrDigit(_inputBuffer[i]) == letterMode); ++i) + { + } + + if (letterMode && i < _inputBuffer.Count - 1 && i > 0) + { + --i; + } + + state.ConsoleManager.MoveCaret(i - state.ConsoleManager.CaretPosition); + } + } + else if (!keyPress.Modifiers.HasFlag(ConsoleModifiers.Alt) && !keyPress.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + if (state.ConsoleManager.IsKeyAvailable) + { + if (presses == null) + { + presses = new List(); + } + + presses.Add(keyPress); + continue; + } + + if (presses != null) + { + presses.Add(keyPress); + FlushInput(state, ref presses); + continue; + } + + if (state.ConsoleManager.CaretPosition == _inputBuffer.Count) + { + _inputBuffer.Add(keyPress.KeyChar); + state.ConsoleManager.Write(keyPress.KeyChar); + } + else if (IsOverwriteMode) + { + _inputBuffer[state.ConsoleManager.CaretPosition] = keyPress.KeyChar; + state.ConsoleManager.Write(keyPress.KeyChar); + } + else + { + state.ConsoleManager.IsCaretVisible = false; + _inputBuffer.Insert(state.ConsoleManager.CaretPosition, keyPress.KeyChar); + int currentCaretPosition = state.ConsoleManager.CaretPosition; + string s = new string(_inputBuffer.ToArray(), state.ConsoleManager.CaretPosition, _inputBuffer.Count - state.ConsoleManager.CaretPosition); + state.ConsoleManager.Write(s); + state.ConsoleManager.MoveCaret(currentCaretPosition - state.ConsoleManager.CaretPosition + 1); + state.ConsoleManager.IsCaretVisible = true; + } + } + } + } + finally + { + RestoreTtyState(); + } + } + + private void FlushInput(IShellState state, ref List presses) + { + string str = new string(presses.Select(x => x.KeyChar).ToArray()); + + if (state.ConsoleManager.CaretPosition == _inputBuffer.Count) + { + _inputBuffer.AddRange(str); + state.ConsoleManager.Write(str); + } + else if (IsOverwriteMode) + { + for (int i = 0; i < str.Length; ++i) + { + if (state.ConsoleManager.CaretPosition + i < _inputBuffer.Count) + { + _inputBuffer[state.ConsoleManager.CaretPosition + i] = str[i]; + } + else + { + _inputBuffer.AddRange(str.Skip(i)); + break; + } + } + + state.ConsoleManager.Write(str); + } + else + { + state.ConsoleManager.IsCaretVisible = false; + _inputBuffer.InsertRange(state.ConsoleManager.CaretPosition, str); + int currentCaretPosition = state.ConsoleManager.CaretPosition; + string s = new string(_inputBuffer.ToArray(), state.ConsoleManager.CaretPosition, _inputBuffer.Count - state.ConsoleManager.CaretPosition); + state.ConsoleManager.Write(s); + state.ConsoleManager.MoveCaret(currentCaretPosition - state.ConsoleManager.CaretPosition + str.Length); + state.ConsoleManager.IsCaretVisible = true; + } + + presses = null; + } + } +} diff --git a/src/Microsoft.Repl/Input/KeyHandlers.cs b/src/Microsoft.Repl/Input/KeyHandlers.cs new file mode 100644 index 0000000000..750db8d2f6 --- /dev/null +++ b/src/Microsoft.Repl/Input/KeyHandlers.cs @@ -0,0 +1,248 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Input +{ + public static class KeyHandlers + { + public static void RegisterDefaultKeyHandlers(IInputManager inputManager) + { + //Navigation in line + inputManager.RegisterKeyHandler(ConsoleKey.LeftArrow, LeftArrow); + inputManager.RegisterKeyHandler(ConsoleKey.LeftArrow, ConsoleModifiers.Control, LeftArrow); + inputManager.RegisterKeyHandler(ConsoleKey.RightArrow, RightArrow); + inputManager.RegisterKeyHandler(ConsoleKey.RightArrow, ConsoleModifiers.Control, RightArrow); + inputManager.RegisterKeyHandler(ConsoleKey.Home, Home); + inputManager.RegisterKeyHandler(ConsoleKey.A, ConsoleModifiers.Control, Home); + inputManager.RegisterKeyHandler(ConsoleKey.End, End); + inputManager.RegisterKeyHandler(ConsoleKey.E, ConsoleModifiers.Control, End); + + //Command history + inputManager.RegisterKeyHandler(ConsoleKey.UpArrow, UpArrow); + inputManager.RegisterKeyHandler(ConsoleKey.DownArrow, DownArrow); + + //Completion + inputManager.RegisterKeyHandler(ConsoleKey.Tab, Tab); + inputManager.RegisterKeyHandler(ConsoleKey.Tab, ConsoleModifiers.Shift, Tab); + + //Input manipulation + inputManager.RegisterKeyHandler(ConsoleKey.Escape, Escape); + inputManager.RegisterKeyHandler(ConsoleKey.U, ConsoleModifiers.Control, Escape); + inputManager.RegisterKeyHandler(ConsoleKey.Delete, Delete); + inputManager.RegisterKeyHandler(ConsoleKey.Backspace, Backspace); + + //Insert/Overwrite mode + inputManager.RegisterKeyHandler(ConsoleKey.Insert, Insert); + + //Execute command + inputManager.RegisterKeyHandler(ConsoleKey.Enter, Enter); + + //Map non-printable keys that aren't handled by default + inputManager.RegisterKeyHandler(ConsoleKey.F1, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F2, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F3, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F4, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F5, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F6, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F7, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F8, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F9, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F10, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F11, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F12, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F13, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F14, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F15, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F16, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F17, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F18, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F19, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F20, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F21, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F22, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F23, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.F24, Unhandled); + + inputManager.RegisterKeyHandler(ConsoleKey.Applications, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Attention, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserBack, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserFavorites, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserForward, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserHome, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserRefresh, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserSearch, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.BrowserStop, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Clear, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.CrSel, Unhandled); + + inputManager.RegisterKeyHandler(ConsoleKey.EraseEndOfFile, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Execute, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.ExSel, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Help, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.LaunchApp1, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.LaunchApp2, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.LaunchMail, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.LaunchMediaSelect, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.LeftWindows, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.MediaNext, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.MediaPlay, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.MediaPrevious, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.MediaStop, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.NoName, Unhandled); + + inputManager.RegisterKeyHandler(ConsoleKey.Pa1, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Packet, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.PageDown, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.PageUp, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Pause, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Play, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Print, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.PrintScreen, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Process, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.RightWindows, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Select, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Separator, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Sleep, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.VolumeDown, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.VolumeMute, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.VolumeUp, Unhandled); + inputManager.RegisterKeyHandler(ConsoleKey.Zoom, Unhandled); + } + + private static Task End(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + state.ConsoleManager.MoveCaret(state.InputManager.GetCurrentBuffer().Length - state.ConsoleManager.CaretPosition); + return Task.CompletedTask; + } + + public static Task Home(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + state.ConsoleManager.MoveCaret(-state.ConsoleManager.CaretPosition); + return Task.CompletedTask; + } + + public static Task LeftArrow(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + if (state.ConsoleManager.CaretPosition > 0) + { + if (!keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + state.ConsoleManager.MoveCaret(-1); + } + else + { + string line = state.InputManager.GetCurrentBuffer(); + ICoreParseResult parseResult = state.CommandDispatcher.Parser.Parse(line, state.ConsoleManager.CaretPosition); + int targetSection = parseResult.SelectedSection - (parseResult.CaretPositionWithinSelectedSection > 0 ? 0 : 1); + + if (targetSection < 0) + { + targetSection = 0; + } + + int desiredPosition = parseResult.SectionStartLookup[targetSection]; + state.ConsoleManager.MoveCaret(desiredPosition - state.ConsoleManager.CaretPosition); + } + } + + return Task.CompletedTask; + } + + public static Task RightArrow(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + string line = state.InputManager.GetCurrentBuffer(); + + if (state.ConsoleManager.CaretPosition < line.Length) + { + if (!keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + state.ConsoleManager.MoveCaret(1); + } + else + { + ICoreParseResult parseResult = state.CommandDispatcher.Parser.Parse(line, state.ConsoleManager.CaretPosition); + int targetSection = parseResult.SelectedSection + 1; + + if (targetSection >= parseResult.Sections.Count) + { + state.ConsoleManager.MoveCaret(line.Length - state.ConsoleManager.CaretPosition); + } + else + { + int desiredPosition = parseResult.SectionStartLookup[targetSection]; + state.ConsoleManager.MoveCaret(desiredPosition - state.ConsoleManager.CaretPosition); + } + } + } + + return Task.CompletedTask; + } + + public static Task UpArrow(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + string line = state.CommandHistory.GetPreviousCommand(); + state.InputManager.SetInput(state, line); + return Task.CompletedTask; + } + + public static Task DownArrow(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + string line = state.CommandHistory.GetNextCommand(); + state.InputManager.SetInput(state, line); + return Task.CompletedTask; + } + + public static Task Enter(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + return state.CommandDispatcher.ExecuteCommandAsync(state, cancellationToken); + } + + public static Task Backspace(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + state.InputManager.RemovePreviousCharacter(state); + return Task.CompletedTask; + } + + public static Task Unhandled(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public static Task Escape(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + state.InputManager.SetInput(state, string.Empty); + return Task.CompletedTask; + } + + public static Task Tab(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + if (keyInfo.Modifiers.HasFlag(ConsoleModifiers.Shift)) + { + state.SuggestionManager.PreviousSuggestion(state); + } + else + { + state.SuggestionManager.NextSuggestion(state); + } + + return Task.CompletedTask; + } + + public static Task Delete(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + state.InputManager.RemoveCurrentCharacter(state); + return Task.CompletedTask; + } + + public static Task Insert(ConsoleKeyInfo keyInfo, IShellState state, CancellationToken cancellationToken) + { + state.InputManager.IsOverwriteMode = !state.InputManager.IsOverwriteMode; + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Repl/Microsoft.Repl.csproj b/src/Microsoft.Repl/Microsoft.Repl.csproj new file mode 100644 index 0000000000..1615d686ad --- /dev/null +++ b/src/Microsoft.Repl/Microsoft.Repl.csproj @@ -0,0 +1,10 @@ + + + + netcoreapp3.0 + A framework for creating REPLs in .NET Core. + dotnet;repl + false + + + diff --git a/src/Microsoft.Repl/Parsing/CoreParseResult.cs b/src/Microsoft.Repl/Parsing/CoreParseResult.cs new file mode 100644 index 0000000000..1655e4a36a --- /dev/null +++ b/src/Microsoft.Repl/Parsing/CoreParseResult.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Repl.Parsing +{ + public class CoreParseResult : ICoreParseResult + { + public CoreParseResult(int caretPositionWithinCommandText, int caretPositionWithinSelectedSection, string commandText, IReadOnlyList sections, int selectedSection, IReadOnlyDictionary sectionStartLookup, HashSet quotedSections) + { + CaretPositionWithinCommandText = caretPositionWithinCommandText; + CaretPositionWithinSelectedSection = caretPositionWithinSelectedSection; + CommandText = commandText; + Sections = sections; + SelectedSection = selectedSection; + SectionStartLookup = sectionStartLookup; + _quotedSections = quotedSections; + } + + public int CaretPositionWithinCommandText { get; } + + public int CaretPositionWithinSelectedSection { get; } + + public string CommandText { get; } + + public IReadOnlyList Sections { get; } + + public int SelectedSection { get; } + + public IReadOnlyDictionary SectionStartLookup { get; } + + private readonly HashSet _quotedSections; + + public bool IsQuotedSection(int index) + { + return _quotedSections.Contains(index); + } + + public virtual ICoreParseResult Slice(int numberOfLeadingSectionsToRemove) + { + if (numberOfLeadingSectionsToRemove == 0) + { + return this; + } + + if (numberOfLeadingSectionsToRemove >= Sections.Count) + { + return new CoreParseResult(0, 0, string.Empty, new[] { string.Empty }, 0, new Dictionary { { 0, 0 } }, new HashSet()); + } + + string commandText = CommandText.Substring(SectionStartLookup[numberOfLeadingSectionsToRemove]); + int caretPositionWithinCommandText = CaretPositionWithinCommandText - SectionStartLookup[numberOfLeadingSectionsToRemove]; + + if (caretPositionWithinCommandText < 0) + { + caretPositionWithinCommandText = 0; + } + + Dictionary sectionStartLookup = new Dictionary(); + List sections = new List(); + for (int i = 0; i < Sections.Count - numberOfLeadingSectionsToRemove; ++i) + { + sectionStartLookup[i] = SectionStartLookup[numberOfLeadingSectionsToRemove + i] - SectionStartLookup[numberOfLeadingSectionsToRemove]; + sections.Add(Sections[numberOfLeadingSectionsToRemove + i]); + } + + int selectedSection = SelectedSection - numberOfLeadingSectionsToRemove; + + if (selectedSection < 0) + { + selectedSection = 0; + } + + HashSet quotedSections = new HashSet(_quotedSections.Where(x => x > 0).Select(x => x - 1)); + return new CoreParseResult(caretPositionWithinCommandText, CaretPositionWithinSelectedSection, commandText, sections, selectedSection, sectionStartLookup, quotedSections); + } + } +} diff --git a/src/Microsoft.Repl/Parsing/CoreParser.cs b/src/Microsoft.Repl/Parsing/CoreParser.cs new file mode 100644 index 0000000000..7075f764a0 --- /dev/null +++ b/src/Microsoft.Repl/Parsing/CoreParser.cs @@ -0,0 +1,132 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Repl.Parsing +{ + public class CoreParser : IParser + { + public ICoreParseResult Parse(string commandText, int caretPosition) + { + List sections = commandText.Split(' ').ToList(); + Dictionary sectionStartLookup = new Dictionary(); + HashSet quotedSections = new HashSet(); + int runningIndex = 0; + int selectedSection = -1; + int caretPositionWithinSelectedSection = 0; + bool isInQuotedSection = false; + + for (int i = 0; i < sections.Count; ++i) + { + int thisSectionLength = sections[i].Length; + bool isLastSection = i == sections.Count - 1; + + //If currently in a quoted section, combine with the previous section, check to see if this section closes the quotes + if (isInQuotedSection) + { + //Combine with the previous section + sections[i - 1] += " " + sections[i]; + sections.RemoveAt(i--); + + //Check for the closing quote + int sectionLength = sections[i].Length; + if (sections[i][sectionLength - 1] == '"') + { + if (sectionLength > 1 && sections[i][sectionLength - 2] != '\\') + { + isInQuotedSection = false; + } + } + } + //Not in a quoted section, check to see if we're starting one + else + { + sectionStartLookup[i] = runningIndex; + + if (sections[i].Length > 0) + { + if (sections[i][0] == '"') + { + isInQuotedSection = true; + } + } + } + + //Update the running index, adding one for all but the last element to account for the spaces between the sections + runningIndex += thisSectionLength + (isLastSection ? 0 : 1); + + //If the selected section hasn't been determined yet, and the end of the text is past the caret, set the selected + // section to the current section and set the initial value for the caret position within the selected section. + // Note that the caret position within the selected section, unlike the other positions, accounts for escape + // sequences and must be fixed up when escape sequences are removed + if (selectedSection == -1 && runningIndex > caretPosition) + { + selectedSection = i; + caretPositionWithinSelectedSection = caretPosition - sectionStartLookup[i]; + } + } + + //Unescape the sections + // Note that this isn't combined with the above loop to avoid additional complexity in the quoted section case + for (int i = 0; i < sections.Count; ++i) + { + string s = sections[i]; + + //Trim quotes if needed + if (s.Length > 1) + { + if (s[0] == s[s.Length - 1] && s[0] == '"') + { + s = s.Substring(1, s.Length - 2); + quotedSections.Add(i); + + //Fix up the caret position in the text + if (selectedSection == i) + { + //If the caret was on the closing quote, back up to the last character of the section + if (caretPositionWithinSelectedSection == s.Length - 1) + { + caretPositionWithinSelectedSection -= 2; + } + //If the caret was after the opening quote, back up one + else if (caretPositionWithinSelectedSection > 0) + { + --caretPositionWithinSelectedSection; + } + } + } + } + + for (int j = 0; j < s.Length - 1; ++j) + { + if (s[j] == '\\') + { + if (s[j + 1] == '\\' || s[j + 1] == '"') + { + s = s.Substring(0, j) + s.Substring(j + 1); + + //If we're changing the selected section, and we're removing a character + // from before the caret position, back the caret position up to account for it + if (selectedSection == i && j < caretPositionWithinSelectedSection) + { + --caretPositionWithinSelectedSection; + } + } + } + } + + sections[i] = s; + } + + if (selectedSection == -1) + { + selectedSection = sections.Count - 1; + caretPositionWithinSelectedSection = sections[selectedSection].Length; + } + + return new CoreParseResult(caretPosition, caretPositionWithinSelectedSection, commandText, sections, selectedSection, sectionStartLookup, quotedSections); + } + } +} diff --git a/src/Microsoft.Repl/Parsing/ICoreParseResult.cs b/src/Microsoft.Repl/Parsing/ICoreParseResult.cs new file mode 100644 index 0000000000..55bf5b2264 --- /dev/null +++ b/src/Microsoft.Repl/Parsing/ICoreParseResult.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Repl.Parsing +{ + public interface ICoreParseResult + { + int CaretPositionWithinCommandText { get; } + + int CaretPositionWithinSelectedSection { get; } + + string CommandText { get; } + + IReadOnlyList Sections { get; } + + bool IsQuotedSection(int index); + + int SelectedSection { get; } + + IReadOnlyDictionary SectionStartLookup { get; } + + ICoreParseResult Slice(int numberOfLeadingSectionsToRemove); + } +} diff --git a/src/Microsoft.Repl/Parsing/IParser.cs b/src/Microsoft.Repl/Parsing/IParser.cs new file mode 100644 index 0000000000..86ce9ed940 --- /dev/null +++ b/src/Microsoft.Repl/Parsing/IParser.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.Parsing +{ + public interface IParser + { + ICoreParseResult Parse(string commandText, int caretPosition); + } + + public interface IParser : IParser + { + new TParseResult Parse(string commandText, int caretPosition); + } +} diff --git a/src/Microsoft.Repl/Scripting/IScriptExecutor.cs b/src/Microsoft.Repl/Scripting/IScriptExecutor.cs new file mode 100644 index 0000000000..fcd5be2b53 --- /dev/null +++ b/src/Microsoft.Repl/Scripting/IScriptExecutor.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Repl.Scripting +{ + public interface IScriptExecutor + { + Task ExecuteScriptAsync(IShellState shellState, IEnumerable commandTexts, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Repl/Scripting/ScriptExecutor.cs b/src/Microsoft.Repl/Scripting/ScriptExecutor.cs new file mode 100644 index 0000000000..6fafa6cbe4 --- /dev/null +++ b/src/Microsoft.Repl/Scripting/ScriptExecutor.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Scripting +{ + public class ScriptExecutor : IScriptExecutor + where TParseResult : ICoreParseResult + { + private readonly bool _hideScriptLinesFromHistory; + + public ScriptExecutor(bool hideScriptLinesFromHistory = true) + { + _hideScriptLinesFromHistory = hideScriptLinesFromHistory; + } + + public async Task ExecuteScriptAsync(IShellState shellState, IEnumerable commandTexts, CancellationToken cancellationToken) + { + if (shellState.CommandDispatcher is ICommandDispatcher dispatcher) + { + IDisposable suppressor = _hideScriptLinesFromHistory ? shellState.CommandHistory.SuspendHistory() : null; + + using (suppressor) + { + foreach (string commandText in commandTexts) + { + if (string.IsNullOrWhiteSpace(commandText)) + { + continue; + } + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + dispatcher.OnReady(shellState); + shellState.ConsoleManager.ResetCommandStart(); + shellState.InputManager.SetInput(shellState, commandText); + await dispatcher.ExecuteCommandAsync(shellState, cancellationToken).ConfigureAwait(false); + } + } + } + } + } +} diff --git a/src/Microsoft.Repl/Shell.cs b/src/Microsoft.Repl/Shell.cs new file mode 100644 index 0000000000..8d6cddb035 --- /dev/null +++ b/src/Microsoft.Repl/Shell.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Repl.Commanding; +using Microsoft.Repl.Input; +using Microsoft.Repl.Suggestions; + +namespace Microsoft.Repl +{ + public class Shell + { + public Shell(IShellState shellState) + { + KeyHandlers.RegisterDefaultKeyHandlers(shellState.InputManager); + ShellState = shellState; + } + + public Shell(ICommandDispatcher dispatcher, ISuggestionManager suggestionManager = null) + : this(new ShellState(dispatcher, suggestionManager)) + { + } + + public IShellState ShellState { get; } + + public Task RunAsync(CancellationToken cancellationToken) + { + ShellState.CommandDispatcher.OnReady(ShellState); + return ShellState.InputManager.StartAsync(ShellState, cancellationToken); + } + } +} diff --git a/src/Microsoft.Repl/ShellState.cs b/src/Microsoft.Repl/ShellState.cs new file mode 100644 index 0000000000..cb94bc63a2 --- /dev/null +++ b/src/Microsoft.Repl/ShellState.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Repl.Commanding; +using Microsoft.Repl.ConsoleHandling; +using Microsoft.Repl.Input; +using Microsoft.Repl.Suggestions; + +namespace Microsoft.Repl +{ + public class ShellState : IShellState + { + public ShellState(ICommandDispatcher commandDispatcher, ISuggestionManager suggestionManager = null, IInputManager inputManager = null, ICommandHistory commandHistory = null, IConsoleManager consoleManager = null) + { + InputManager = inputManager ?? new InputManager(); + CommandHistory = commandHistory ?? new CommandHistory(); + ConsoleManager = consoleManager ?? new ConsoleManager(); + CommandDispatcher = commandDispatcher; + SuggestionManager = suggestionManager ?? new SuggestionManager(); + } + + public IInputManager InputManager { get; } + + public ICommandHistory CommandHistory { get; } + + public IConsoleManager ConsoleManager { get; } + + public ICommandDispatcher CommandDispatcher { get; } + + public bool IsExiting { get; set; } + + public ISuggestionManager SuggestionManager { get; } + } +} diff --git a/src/Microsoft.Repl/Suggestions/FileSystemCompletion.cs b/src/Microsoft.Repl/Suggestions/FileSystemCompletion.cs new file mode 100644 index 0000000000..5b852a9b59 --- /dev/null +++ b/src/Microsoft.Repl/Suggestions/FileSystemCompletion.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.Repl.Suggestions +{ + public static class FileSystemCompletion + { + public static IEnumerable GetCompletions(string prefix) + { + if (prefix.StartsWith('\"')) + { + prefix = prefix.Substring(1); + + int lastQuote = prefix.LastIndexOf('\"'); + + if (lastQuote > -1) + { + prefix = prefix.Remove(lastQuote, 1); + } + + while (prefix.EndsWith($"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}")) + { + prefix = prefix.Substring(0, prefix.Length - 1); + } + } + + int lastPathIndex = prefix.LastIndexOfAny(new[] { '\\', '/' }); + if (lastPathIndex < 0) + { + return null; + } + + string dir = prefix.Substring(0, lastPathIndex + 1); + + if (dir.IndexOfAny(Path.GetInvalidPathChars()) > -1) + { + return null; + } + + string partPrefix = prefix.Substring(lastPathIndex + 1); + if (Directory.Exists(dir)) + { + return Directory.EnumerateDirectories(dir).Where(x => Path.GetFileName(x).StartsWith(partPrefix, StringComparison.OrdinalIgnoreCase)) + .Union(Directory.EnumerateFiles(dir).Where(x => Path.GetFileName(x).StartsWith(partPrefix, StringComparison.OrdinalIgnoreCase))).Select(x => x.IndexOf(' ') > -1 ? $"\"{x}\"" : x); + } + + return null; + } + } +} diff --git a/src/Microsoft.Repl/Suggestions/ISuggestionManager.cs b/src/Microsoft.Repl/Suggestions/ISuggestionManager.cs new file mode 100644 index 0000000000..51e09c3a1c --- /dev/null +++ b/src/Microsoft.Repl/Suggestions/ISuggestionManager.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Repl.Suggestions +{ + public interface ISuggestionManager + { + void NextSuggestion(IShellState shellState); + + void PreviousSuggestion(IShellState shellState); + } +} diff --git a/src/Microsoft.Repl/Suggestions/SuggestionManager.cs b/src/Microsoft.Repl/Suggestions/SuggestionManager.cs new file mode 100644 index 0000000000..ba68ee9d46 --- /dev/null +++ b/src/Microsoft.Repl/Suggestions/SuggestionManager.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Repl.Parsing; + +namespace Microsoft.Repl.Suggestions +{ + public class SuggestionManager : ISuggestionManager + { + private int _currentSuggestion; + private IReadOnlyList _suggestions; + private ICoreParseResult _expected; + + public void NextSuggestion(IShellState shellState) + { + string line = shellState.InputManager.GetCurrentBuffer(); + ICoreParseResult parseResult = shellState.CommandDispatcher.Parser.Parse(line, shellState.ConsoleManager.CaretPosition); + string currentSuggestion; + + //Check to see if we're continuing before querying for suggestions again + if (_expected != null + && string.Equals(_expected.CommandText, parseResult.CommandText, StringComparison.Ordinal) + && _expected.SelectedSection == parseResult.SelectedSection + && _expected.CaretPositionWithinSelectedSection == parseResult.CaretPositionWithinSelectedSection) + { + if (_suggestions == null || _suggestions.Count == 0) + { + return; + } + + _currentSuggestion = (_currentSuggestion + 1) % _suggestions.Count; + currentSuggestion = _suggestions[_currentSuggestion]; + } + else + { + _currentSuggestion = 0; + _suggestions = shellState.CommandDispatcher.CollectSuggesetions(shellState); + + if (_suggestions == null || _suggestions.Count == 0) + { + return; + } + + currentSuggestion = _suggestions[0]; + } + + //We now have a suggestion, take the command text leading up to the section being suggested for, + // concatenate that and the suggestion text, turn it in to keys, submit it to the input manager, + // reset the caret, store the parse result of the new text as what's expected for a continuation + string newText = parseResult.CommandText.Substring(0, parseResult.SectionStartLookup[parseResult.SelectedSection]) + currentSuggestion; + _expected = shellState.CommandDispatcher.Parser.Parse(newText, newText.Length); + shellState.InputManager.SetInput(shellState, newText); + } + + public void PreviousSuggestion(IShellState shellState) + { + string line = shellState.InputManager.GetCurrentBuffer(); + ICoreParseResult parseResult = shellState.CommandDispatcher.Parser.Parse(line, shellState.ConsoleManager.CaretPosition); + string currentSuggestion; + + //Check to see if we're continuing before querying for suggestions again + if (_expected != null + && string.Equals(_expected.CommandText, parseResult.CommandText, StringComparison.Ordinal) + && _expected.SelectedSection == parseResult.SelectedSection + && _expected.CaretPositionWithinSelectedSection == parseResult.CaretPositionWithinSelectedSection) + { + if (_suggestions == null || _suggestions.Count == 0) + { + return; + } + + _currentSuggestion = (_currentSuggestion - 1 + _suggestions.Count) % _suggestions.Count; + currentSuggestion = _suggestions[_currentSuggestion]; + } + else + { + _suggestions = shellState.CommandDispatcher.CollectSuggesetions(shellState); + _currentSuggestion = _suggestions.Count - 1; + + if (_suggestions == null || _suggestions.Count == 0) + { + return; + } + + currentSuggestion = _suggestions[_suggestions.Count - 1]; + } + + //We now have a suggestion, take the command text leading up to the section being suggested for, + // concatenate that and the suggestion text, turn it in to keys, submit it to the input manager, + // reset the caret, store the parse result of the new text as what's expected for a continuation + string newText = parseResult.CommandText.Substring(0, parseResult.SectionStartLookup[parseResult.SelectedSection]) + currentSuggestion; + _expected = shellState.CommandDispatcher.Parser.Parse(newText, newText.Length); + shellState.InputManager.SetInput(shellState, newText); + } + } +} diff --git a/src/Microsoft.Repl/Utils.cs b/src/Microsoft.Repl/Utils.cs new file mode 100644 index 0000000000..eaf8160222 --- /dev/null +++ b/src/Microsoft.Repl/Utils.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Repl +{ + public static class Utils + { + public static string Stringify(this IReadOnlyList keys) + { + return string.Join("", keys); + } + } +} diff --git a/test/Microsoft.HttpRepl.Tests/JsonVisitorTests.cs b/test/Microsoft.HttpRepl.Tests/JsonVisitorTests.cs new file mode 100644 index 0000000000..30155b022f --- /dev/null +++ b/test/Microsoft.HttpRepl.Tests/JsonVisitorTests.cs @@ -0,0 +1,60 @@ +using Microsoft.HttpRepl.Formatting; +using Microsoft.HttpRepl.Preferences; +using Microsoft.Repl.ConsoleHandling; +using Xunit; + +namespace Microsoft.HttpRepl.Tests +{ + public class JsonVisitorTests + { + [Fact] + public void JsonVisitor_ObjectWithComments() + { + string testData = @"[ + { + //Object 1 + ""property"": ""value"", + ""and"": ""again"" + }, + { + //Object 2 + }, + [ + //An array + ], + null, + 1, + 3.2, + ""test"", + false +]"; + + string formatted = JsonVisitor.FormatAndColorize(new MockJsonConfig(), testData); + } + + private class MockJsonConfig : IJsonConfig + { + public int IndentSize => 2; + + public AllowedColors DefaultColor => AllowedColors.None; + + public AllowedColors ArrayBraceColor => AllowedColors.None; + + public AllowedColors ObjectBraceColor => AllowedColors.None; + + public AllowedColors CommaColor => AllowedColors.None; + + public AllowedColors NameColor => AllowedColors.None; + + public AllowedColors NameSeparatorColor => AllowedColors.None; + + public AllowedColors BoolColor => AllowedColors.None; + + public AllowedColors NumericColor => AllowedColors.None; + + public AllowedColors StringColor => AllowedColors.None; + + public AllowedColors NullColor => AllowedColors.None; + } + } +} diff --git a/test/Microsoft.HttpRepl.Tests/Microsoft.HttpRepl.Tests.csproj b/test/Microsoft.HttpRepl.Tests/Microsoft.HttpRepl.Tests.csproj new file mode 100644 index 0000000000..409c13f325 --- /dev/null +++ b/test/Microsoft.HttpRepl.Tests/Microsoft.HttpRepl.Tests.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + + + + + + + diff --git a/test/Microsoft.Repl.Tests/Microsoft.Repl.Tests.csproj b/test/Microsoft.Repl.Tests/Microsoft.Repl.Tests.csproj new file mode 100644 index 0000000000..62a1e3b306 --- /dev/null +++ b/test/Microsoft.Repl.Tests/Microsoft.Repl.Tests.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + + + + + diff --git a/test/Microsoft.Repl.Tests/ParserTests.cs b/test/Microsoft.Repl.Tests/ParserTests.cs new file mode 100644 index 0000000000..422123db85 --- /dev/null +++ b/test/Microsoft.Repl.Tests/ParserTests.cs @@ -0,0 +1,26 @@ +using Microsoft.Repl.Parsing; +using Xunit; + +namespace Microsoft.Repl.Tests +{ + public class ParserTests + { + [Fact] + public void ParserTests_Basic() + { + string testString = "\"this is a test\" of \"Escape\\\\Sequences\\\"\""; + + CoreParser parser = new CoreParser(); + ICoreParseResult result = parser.Parse(testString, 29); + + Assert.Equal(3, result.Sections.Count); + Assert.Equal(2, result.SelectedSection); + Assert.Equal(0, result.SectionStartLookup[0]); + Assert.Equal(17, result.SectionStartLookup[1]); + Assert.Equal(20, result.SectionStartLookup[2]); + Assert.Equal(7, result.CaretPositionWithinSelectedSection); + Assert.Equal(29, result.CaretPositionWithinCommandText); + Assert.Equal(testString, result.CommandText); + } + } +} diff --git a/version.props b/version.props index 0b4b9416cf..370000a866 100644 --- a/version.props +++ b/version.props @@ -2,7 +2,7 @@ 3.0.0 15.6 - alpha1 + preview2 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix)-final $(VsixVersion).$(BuildNumber)