diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f7e0ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +#OS junk files +Thumbs.db +*.DS_Store + +#Visual Studio files +*.obj +*.exe +*.pdb +*.user +*.aps +*.pch +*.vspscc +*.vssscc +*_i.c +*_p.c +*.ncb +*.suo +*.tlb +*.tlh +*.bak +*.cache +*.ilk +*.log* +*.lib +*.sbr +*.sdf +ipch/ +obj/ +[Bb]in +[Dd]ebug*/ +[Rr]elease*/ + +#upgrade from 2010 +Backup/* +_UpgradeReport_Files/* +UpgradeLog.* + +#Tooling +_ReSharper*/ +[Tt]est[Rr]esult* +*.dotCover + +#StyleCop +StyleCop.Cache + +#Project files +[Bb]uild/ + +#Nuget Files +*.nupkg +#MA: using nuget package restore! +packages/ diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..03b0dd5 --- /dev/null +++ b/License.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Michael Adelson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/MedallionShell.sln b/MedallionShell.sln new file mode 100644 index 0000000..670af70 --- /dev/null +++ b/MedallionShell.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Express 2013 for Windows Desktop +VisualStudioVersion = 12.0.21005.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MedallionShell", "MedallionShell\MedallionShell.csproj", "{15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/MedallionShell/Async/AsyncLock.cs b/MedallionShell/Async/AsyncLock.cs new file mode 100644 index 0000000..41b3dae --- /dev/null +++ b/MedallionShell/Async/AsyncLock.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Async +{ + // http://blogs.msdn.com/b/pfxteam/archive/2012/02/12/10266988.aspx + internal sealed class AsyncLock + { + private readonly AsyncSemaphore semaphore = new AsyncSemaphore(initialCount: 1, maxCount: 1); + private readonly Task cachedReleaserTask; + + public AsyncLock() + { + this.cachedReleaserTask = Task.FromResult(new Releaser(this)); + } + + public Task AcquireAsync() + { + var wait = this.semaphore.WaitAsync(); + return wait.IsCompleted + ? this.cachedReleaserTask + : wait.ContinueWith( + CreateReleaserFunc, + this, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private static Releaser CreateReleaser(Task ignored, object asyncLock) + { + return new Releaser((AsyncLock)asyncLock); + } + private static Func CreateReleaserFunc = CreateReleaser; + + public struct Releaser : IDisposable + { + private readonly AsyncLock @lock; + + internal Releaser(AsyncLock @lock) + { + this.@lock = @lock; + } + + public void Dispose() + { + this.@lock.semaphore.Release(); + } + } + } +} diff --git a/MedallionShell/Async/AsyncSemaphore.cs b/MedallionShell/Async/AsyncSemaphore.cs new file mode 100644 index 0000000..7d430d8 --- /dev/null +++ b/MedallionShell/Async/AsyncSemaphore.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Async +{ + // based on http://blogs.msdn.com/b/pfxteam/archive/2012/02/12/10266983.aspx + internal sealed class AsyncSemaphore + { + private static readonly Task CachedCompletedTask = Task.FromResult(true); + private readonly Queue> waiters = new Queue>(); + private readonly int? maxCount; + private int count; + + public AsyncSemaphore(int initialCount, int? maxCount = null) + { + Throw.IfOutOfRange(initialCount, "initialCount", min: 0); + if (maxCount.HasValue) + { + Throw.IfOutOfRange(maxCount.Value, "maxCount", min: 0); + Throw.IfOutOfRange(initialCount, "initialCount", max: maxCount); + this.maxCount = maxCount; + } + this.count = initialCount; + } + + public Task WaitAsync() + { + lock (this.waiters) + { + if (this.count > 0) + { + --this.count; + return CachedCompletedTask; + } + else + { + var waiter = new TaskCompletionSource(); + this.waiters.Enqueue(waiter); + return waiter.Task; + } + } + } + + public void Release() + { + TaskCompletionSource toRelease = null; + lock (this.waiters) + { + if (this.waiters.Count > 0) + { + toRelease = this.waiters.Dequeue(); + } + else if (this.maxCount.HasValue && this.count == this.maxCount) + { + throw new InvalidOperationException("Max count value exceeded on the semaphore"); + } + else + { + ++this.count; + } + } + if (toRelease != null) + { + toRelease.SetResult(true); + } + } + } +} diff --git a/MedallionShell/Command.cs b/MedallionShell/Command.cs new file mode 100644 index 0000000..fa67871 --- /dev/null +++ b/MedallionShell/Command.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Shell +{ + public abstract partial class Command + { + // TODO do we want this in the base class? + public abstract Process Process { get; } + public abstract IReadOnlyList Processes { get; } + + public abstract Stream StandardInputStream { get; } + public abstract Stream StandardOutputStream { get; } + public abstract Stream StandardErrorStream { get; } + + public abstract Task Task { get; } + + public Command PipeTo(Command command) + { + return this | command; + } + + public Command PipeStandardOutputTo(Stream stream) + { + return this > stream; + } + + public Command PipeStandardErrorTo(Stream stream) + { + Throw.IfNull(stream, "stream"); + // TODO + return this; + } + + public Command PipeStandardInputFrom(Stream stream) + { + return this < stream; + } + + #region ---- Operator overloads ---- + public static Command operator |(Command first, Command second) + { + return new PipedCommand(first, second); + } + + public static Command operator >(Command command, Stream stream) + { + Throw.IfNull(command, "command"); + Throw.IfNull(stream, "stream"); + + // TODO + + return command; + } + + public static Command operator <(Command command, Stream stream) + { + Throw.IfNull(command, "command"); + Throw.IfNull(stream, "stream"); + + // TODO + + return command; + } + + public static Command operator >(Command command, string path) + { + Throw.IfNull(command, "command"); + Throw.IfNull(path, "path"); + + // used over File.OpenWrite to get read file share, which seems potentially useful and + // not that harmful + var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read); + return command > stream; + } + + public static Command operator <(Command command, string path) + { + Throw.IfNull(command, "command"); + Throw.IfNull(path, "path"); + + return command > File.OpenRead(path); + } + + public static bool operator true(Command command) + { + Throw.IfNull(command, "command"); + + return command.Task.Result.Success; + } + + public static bool operator false(Command command) + { + Throw.IfNull(command, "command"); + + return !command.Task.Result.Success; + } + + public static Command operator &(Command @this, Command that) + { + throw new NotSupportedException("Bitwise & is not supported. It exists only to enable '&&'") + } + #endregion + } +} diff --git a/MedallionShell/CommandResult.cs b/MedallionShell/CommandResult.cs new file mode 100644 index 0000000..1714e86 --- /dev/null +++ b/MedallionShell/CommandResult.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Shell +{ + public sealed class CommandResult + { + internal CommandResult(int exitCode) + { + this.exitCode = exitCode; + } + + private readonly int exitCode; + public int ExitCode { get; } + + /// + /// Returns true iff the exit code is 0 (indicating success) + /// + public bool Success { get { return this.ExitCode == 0; } } + } +} diff --git a/MedallionShell/Helpers.cs b/MedallionShell/Helpers.cs new file mode 100644 index 0000000..fdfb3bf --- /dev/null +++ b/MedallionShell/Helpers.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Shell +{ + internal static class Throw + { + /// + /// Throws an if the given value is null + /// + public static void IfNull(T value, string parameterName) + { + Throw.If(value == null, parameterName); + } + + /// + /// Throws an if the given condition is true + /// + public static void If(bool condition, string parameterName) + { + Throw.If(condition, parameterName); + } + + /// + /// Throws an if the given value is outside of the specified range + /// + public static void IfOutOfRange(T value, string paramName, T? min = null, T? max = null) + where T : struct, IComparable + { + if (min.HasValue && value.CompareTo(min.Value) < 0) + { + throw new ArgumentOutOfRangeException(paramName, string.Format("Expected: >= {0}, but was {1}", min, value)); + } + if (max.HasValue && value.CompareTo(max.Value) > 0) + { + throw new ArgumentOutOfRangeException(paramName, string.Format("Expected: <= {0}, but was {1}", max, value)); + } + } + } + + internal static class Throw + where TException : Exception + { + /// + /// Throws an exception of type if the condition is true + /// + public static void If(bool condition, string message) + { + if (condition) + { + throw Create(message); + } + } + + /// + /// As , but allows the message to be specified lazily. The message function will only be evaluated + /// if the condition is true + /// + public static void If(bool condition, Func message) + { + if (condition) + { + throw Create(message()); + } + } + + private static TException Create(string message) + { + return (TException)Activator.CreateInstance(typeof(TException), message); + } + } +} diff --git a/MedallionShell/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj new file mode 100644 index 0000000..3213844 --- /dev/null +++ b/MedallionShell/MedallionShell.csproj @@ -0,0 +1,61 @@ + + + + + Debug + AnyCPU + {15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30} + Library + Properties + Medallion.Shell + MedallionShell + v4.5 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MedallionShell/PipedCommand.cs b/MedallionShell/PipedCommand.cs new file mode 100644 index 0000000..e53c094 --- /dev/null +++ b/MedallionShell/PipedCommand.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Shell +{ + internal sealed class PipedCommand : Command + { + private readonly Command first, second; + + internal PipedCommand(Command first, Command second) + { + Throw.IfNull(first, "first"); + Throw.IfNull(second, "second"); + + this.first = first; + this.second = second; + } + + public override Process Process + { + get { return this.second.Process; } + } + + private IReadOnlyList processes; + public override IReadOnlyList Processes + { + get { return this.processes ?? (this.processes = new[] { this.first.Process, this.second.Process }.Where(p => p != null).ToArray()); } + } + + public override Stream StandardInputStream + { + get { return this.first.StandardInputStream; } + } + + public override System.IO.Stream StandardOutputStream + { + get { return this.second.StandardOutputStream; } + } + + public override System.IO.Stream StandardErrorStream + { + get { return this.second.StandardErrorStream; } + } + + public override Task Task + { + get { return this.second.Task; } + } + } +} diff --git a/MedallionShell/ProcessCommand.cs b/MedallionShell/ProcessCommand.cs new file mode 100644 index 0000000..c3125ec --- /dev/null +++ b/MedallionShell/ProcessCommand.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Shell +{ + internal sealed class ProcessCommand : Command + { + private readonly Task processTask; + + internal ProcessCommand(ProcessStartInfo startInfo) + { + this.process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + this.processTask = CreateProcessTask(this.Process); + this.Process.Start(); + } + + private readonly Process process; + public override System.Diagnostics.Process Process + { + get { return this.process; } + } + + private IReadOnlyList processes; + public override IReadOnlyList Processes + { + get { return this.processes ?? (this.processes = new[] { this.Process }); } + } + + public override System.IO.Stream StandardInputStream + { + get { return this.Process.StandardInput.BaseStream; } + } + + public override System.IO.Stream StandardOutputStream + { + get { return this.Process.StandardOutput.BaseStream; } + } + + public override System.IO.Stream StandardErrorStream + { + get { return this.Process.StandardError.BaseStream; } + } + + public override Task Task + { + get { throw new NotImplementedException(); } + } + + private async Task CreateTask() + { + await this.processTask; + + return new CommandResult(this.Process.ExitCode); + } + + private static Task CreateProcessTask(Process process) + { + var taskCompletionSource = new TaskCompletionSource(); + process.Exited += (o, e) => taskCompletionSource.SetResult(true); + return taskCompletionSource.Task; + } + } +} diff --git a/MedallionShell/Properties/AssemblyInfo.cs b/MedallionShell/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f5aecfc --- /dev/null +++ b/MedallionShell/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("MedallionShell")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("MedallionShell")] +[assembly: AssemblyCopyright("Copyright © 2014")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("72bc0d99-30a6-4b08-839b-452044b717be")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/MedallionShell/Streams/MultiStream.cs b/MedallionShell/Streams/MultiStream.cs new file mode 100644 index 0000000..7f0e088 --- /dev/null +++ b/MedallionShell/Streams/MultiStream.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Streams +{ + internal sealed class MultiStream : Stream + { + private readonly object @lock = new object(); + private readonly IReadOnlyList streams; + private readonly long[] + + public MultiStream(IEnumerable streams) + { + Throw.IfNull(streams, "streams"); + + this.streams = streams.ToArray(); + Throw.If(this.streams.Any(s => s == null || !s.CanRead), "streams: must all be non-null and readable!"); + } + + public override bool CanRead + { + // allowed because we require all streams to be readable in the constructor + get { return true; } + } + + public override bool CanSeek + { + get { return this.streams.Count > 0 && this.streams.All(s => s.CanSeek); } + } + + public override bool CanWrite + { + get { return false; } + } + + public override void Flush() + { + // no-op (since we don't write) + } + + public override long Length + { + // todo use counts of streams so far + get { return this.streams.Sum(s => s.Length); } + } + + public override long Position + { + get + { + lock (this.@lock) + { + throw new NotImplementedException("todo sum lengths of non-completed streams + pos in last stream!"); + } + } + set + { + lock (this.@lock) + { + throw new NotImplementedException("need to set position in underlying streams!"); + } + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + + } + + public override long Seek(long offset, SeekOrigin origin) + { + + } + + public override void SetLength(long value) + { + throw new NotSupportedException("SetLength"); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("Stream is read-only"); + } + } +} diff --git a/MedallionShell/Streams/ProcessStreamHandler.cs b/MedallionShell/Streams/ProcessStreamHandler.cs new file mode 100644 index 0000000..722fae2 --- /dev/null +++ b/MedallionShell/Streams/ProcessStreamHandler.cs @@ -0,0 +1,146 @@ +using Medallion.Shell.Async; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Streams +{ + internal sealed class ProcessStreamHandler + { + public enum Mode + { + /// + /// The contents of the stream is buffered internally so that the process will never be blocked because + /// the pipe is full. This is the default mode. + /// + Buffer = 0, + /// + /// The contents of the stream can be accessed manually via + /// operations. However, internal buffering ensures that the process will never be blocked because + /// the output stream internal buffer is full. + /// + BufferedManualRead, + /// + /// The contents of the stream can be accessed manually via + /// operations. If the process writes faster than the consumer reads, it may fill the buffer + /// and block + /// + ManualRead, + /// + /// The contents of the stream is read and discarded + /// + DiscardContents, + /// + /// The contents of the stream is piped to another stream + /// + Piped, + } + + private readonly AsyncLock @lock; + private readonly Stream stream; + private volatile bool finished; + private Mode mode; + private Stream pipeStream; + private MemoryStream buffer = new MemoryStream(); + + public ProcessStreamHandler(Stream stream) + { + Throw.IfNull(stream, "stream"); + this.stream = stream; + } + + + public void SetMode(Mode mode, Stream pipeStream = null) + { + Throw.If(mode == Mode.Piped != (pipeStream != null), "pipeStream: must be non-null if an only if switching to piped mode"); + + using (this.@lock.AcquireAsync().Result) + { + // when just buffering, you can switch to any other mode (important since we start + // in this mode) + if (this.mode == Mode.Buffer + // when in manual read mode, you can always start buffering + || (this.mode == Mode.ManualRead && mode == Mode.BufferedManualRead) + // when in buffered read mode, you can always stop buffering + || (this.mode == Mode.BufferedManualRead && mode == Mode.ManualRead)) + { + this.mode = mode; + this.pipeStream = pipeStream; + } + else if (this.mode != mode || pipeStream != this.pipeStream) + { + string message; + switch (this.mode) + { + case Mode.DiscardContents: + message = "The stream has been set to discard its contents, so it cannot be used in another mode"; + break; + case Mode.Piped: + message = pipeStream != this.pipeStream + ? "The stream is already being piped to a different stream" + : "The stream is being piped to another stream, so it cannot be used in another mode"; + break; + default: + throw new NotImplementedException("Unexpected mode " + mode.ToString()); + } + + throw new InvalidOperationException(message); + } + } + } + + private async Task ReadLoop() + { + Mode mode; + var localBuffer = new byte[512]; + while (!this.finished) + { + using (await this.@lock.AcquireAsync().ConfigureAwait(false)) + { + mode = this.mode; + + if (mode == Mode.BufferedManualRead) + { + var bytesRead = await this.stream.ReadAsync(localBuffer, offset: 0, count: localBuffer.Length); + if (bytesRead > 0) + { + + } + else if (bytesRead < 0) + { + this.finished = true; + } + } + } + + if (mode == Mode.Piped) + { + await this.buffer.CopyToAsync(this.pipeStream); + await this.stream.CopyToAsync(this.stream); + this.finished = true; + } + + else if (mode == Mode.DiscardContents) + { + // TODO should we do this in the piped case? + using (await this.@lock.AcquireAsync()) + { + this.buffer.Dispose(); + this.buffer = null; + } + + int readResult; + do + { + readResult = await this.stream.ReadAsync(localBuffer, offset: 0, count: localBuffer.Length); + } + while (readResult >= 0); + } + } + } + } +} \ No newline at end of file