diff --git a/PettingZoo.Core/Generator/IExampleGenerator.cs b/PettingZoo.Core/Generator/IExampleGenerator.cs index ae49c7a..95b0e95 100644 --- a/PettingZoo.Core/Generator/IExampleGenerator.cs +++ b/PettingZoo.Core/Generator/IExampleGenerator.cs @@ -1,25 +1,33 @@ using System; -using System.Collections.Generic; namespace PettingZoo.Core.Generator { - public interface IExampleSource : IDisposable + public interface IExampleGenerator { - IExampleFolder GetRootFolder(); + void Select(object? ownerWindow, Action onExampleSelected); } - public interface IExampleFolder - { - public string Name { get; } - - public IReadOnlyList Folders { get; } - public IReadOnlyList Messages { get; } - } - - - public interface IExampleMessage + public interface IExample { string Generate(); } + + + public interface IClassTypeExample : IExample + { + public string AssemblyName { get; } + public string? Namespace { get; } + public string ClassName { get; } + + public string FullClassName => !string.IsNullOrEmpty(Namespace) ? Namespace + "." : "" + ClassName; + } + + + /* + public interface IValidatingExample : IExample + { + bool Validate(string payload, out string validationMessage); + } + */ } diff --git a/PettingZoo.Core/PettingZoo.Core.csproj b/PettingZoo.Core/PettingZoo.Core.csproj index e37010b..6302265 100644 --- a/PettingZoo.Core/PettingZoo.Core.csproj +++ b/PettingZoo.Core/PettingZoo.Core.csproj @@ -8,6 +8,7 @@ + diff --git a/PettingZoo.Core/Rendering/MessageBodyRenderer.cs b/PettingZoo.Core/Rendering/MessageBodyRenderer.cs index f3d4128..029be9c 100644 --- a/PettingZoo.Core/Rendering/MessageBodyRenderer.cs +++ b/PettingZoo.Core/Rendering/MessageBodyRenderer.cs @@ -7,7 +7,7 @@ namespace PettingZoo.Core.Rendering { public class MessageBodyRenderer { - public static Dictionary> ContentTypeHandlers = new() + private static readonly Dictionary> ContentTypeHandlers = new() { { "application/json", RenderJson } }; @@ -15,7 +15,7 @@ namespace PettingZoo.Core.Rendering public static string Render(byte[] body, string? contentType) { - return (contentType != null) && ContentTypeHandlers.TryGetValue(contentType, out var handler) + return contentType != null && ContentTypeHandlers.TryGetValue(contentType, out var handler) ? handler(body) : Encoding.UTF8.GetString(body); diff --git a/PettingZoo.Core/Settings/PettingZooPaths.cs b/PettingZoo.Core/Settings/PettingZooPaths.cs new file mode 100644 index 0000000..b432e3b --- /dev/null +++ b/PettingZoo.Core/Settings/PettingZooPaths.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using System.Reflection; + +namespace PettingZoo.Core.Settings +{ + public static class PettingZooPaths + { + public static string AppDataRoot { get; } + public static string InstallationRoot { get; } + + public static string LogPath => Path.Combine(AppDataRoot, @"Logs"); + + public static string DatabasePath => AppDataRoot; + + + public const string AssembliesPath = @"Assemblies"; + + public static string AppDataAssemblies => Path.Combine(AppDataRoot, AssembliesPath); + public static string InstallationAssemblies => Path.Combine(InstallationRoot, AssembliesPath); + + + static PettingZooPaths() + { + var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (appDataPath == null) + throw new IOException("Could not resolve application data path"); + + AppDataRoot = Path.Combine(appDataPath, @"PettingZoo"); + if (!Directory.CreateDirectory(AppDataRoot).Exists) + throw new IOException($"Failed to create directory: {AppDataRoot}"); + + InstallationRoot = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location ?? Assembly.GetExecutingAssembly().Location)!; + } + } +} diff --git a/PettingZoo.RabbitMQ/PettingZoo.RabbitMQ.csproj b/PettingZoo.RabbitMQ/PettingZoo.RabbitMQ.csproj index 7a65453..45d6771 100644 --- a/PettingZoo.RabbitMQ/PettingZoo.RabbitMQ.csproj +++ b/PettingZoo.RabbitMQ/PettingZoo.RabbitMQ.csproj @@ -9,6 +9,7 @@ + diff --git a/PettingZoo.Settings.LiteDB/BaseLiteDBRepository.cs b/PettingZoo.Settings.LiteDB/BaseLiteDBRepository.cs index f255b99..8a605e6 100644 --- a/PettingZoo.Settings.LiteDB/BaseLiteDBRepository.cs +++ b/PettingZoo.Settings.LiteDB/BaseLiteDBRepository.cs @@ -1,5 +1,6 @@ using LiteDB; using LiteDB.Async; +using PettingZoo.Core.Settings; namespace PettingZoo.Settings.LiteDB { @@ -15,15 +16,7 @@ namespace PettingZoo.Settings.LiteDB public BaseLiteDBRepository(string databaseName) { - var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if (appDataPath == null) - throw new IOException("Could not resolve application data path"); - - var databasePath = Path.Combine(appDataPath, @"PettingZoo"); - if (!Directory.CreateDirectory(databasePath).Exists) - throw new IOException($"Failed to create directory: {databasePath}"); - - databaseFilename = Path.Combine(databasePath, $"{databaseName}.litedb"); + databaseFilename = Path.Combine(PettingZooPaths.DatabasePath, $"{databaseName}.litedb"); } diff --git a/PettingZoo.Settings.LiteDB/PettingZoo.Settings.LiteDB.csproj b/PettingZoo.Settings.LiteDB/PettingZoo.Settings.LiteDB.csproj index d6532ad..f5fef95 100644 --- a/PettingZoo.Settings.LiteDB/PettingZoo.Settings.LiteDB.csproj +++ b/PettingZoo.Settings.LiteDB/PettingZoo.Settings.LiteDB.csproj @@ -9,6 +9,7 @@ + diff --git a/PettingZoo.Tapeti/AssemblyLoader/FilePackageAssemblies.cs b/PettingZoo.Tapeti/AssemblyLoader/FilePackageAssemblies.cs new file mode 100644 index 0000000..c62a8c3 --- /dev/null +++ b/PettingZoo.Tapeti/AssemblyLoader/FilePackageAssemblies.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace PettingZoo.Tapeti.AssemblyLoader +{ + public class FilePackageAssemblies : IPackageAssemblies + { + private readonly string[] filenames; + + + public FilePackageAssemblies(params string[] filenames) + { + this.filenames = filenames; + } + + + public Task> GetAssemblies(IProgress progress, CancellationToken cancellationToken) + { + return Task.FromResult(filenames.Select(f => (IPackageAssembly)new FilePackageAssembly(f))); + } + + + + private class FilePackageAssembly : IPackageAssembly + { + private readonly string filename; + + + public FilePackageAssembly(string filename) + { + this.filename = filename; + } + + + public Stream GetStream() + { + return new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + } + } +} diff --git a/PettingZoo.Tapeti/AssemblyLoader/IPackageAssemblies.cs b/PettingZoo.Tapeti/AssemblyLoader/IPackageAssemblies.cs new file mode 100644 index 0000000..e25d5f1 --- /dev/null +++ b/PettingZoo.Tapeti/AssemblyLoader/IPackageAssemblies.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PettingZoo.Tapeti.AssemblyLoader +{ + public interface IPackageAssemblies + { + Task> GetAssemblies(IProgress progress, CancellationToken cancellationToken); + } + + + public interface IPackageAssembly + { + Stream GetStream(); + } +} diff --git a/PettingZoo.Tapeti/AssemblyLoader/NuGetPackageAssemblies.cs b/PettingZoo.Tapeti/AssemblyLoader/NuGetPackageAssemblies.cs new file mode 100644 index 0000000..0d98901 --- /dev/null +++ b/PettingZoo.Tapeti/AssemblyLoader/NuGetPackageAssemblies.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using NuGet.Frameworks; +using NuGet.Packaging; +using PettingZoo.Tapeti.NuGet; + +namespace PettingZoo.Tapeti.AssemblyLoader +{ + public class NuGetPackageAssemblies : IPackageAssemblies + { + private readonly INuGetPackageVersion packageVersion; + + + public NuGetPackageAssemblies(INuGetPackageVersion packageVersion) + { + this.packageVersion = packageVersion; + } + + + public async Task> GetAssemblies(IProgress progress, CancellationToken cancellationToken) + { + await using var packageStream = new MemoryStream(); + await packageVersion.Download(packageStream, cancellationToken); + + packageStream.Seek(0, SeekOrigin.Begin); + using var packageReader = new PackageArchiveReader(packageStream); + + // Determine which frameworks versions PettingZoo is compatible with so that we can actually load the assemblies + var targetFrameworkAttribute = Assembly.GetExecutingAssembly() + .GetCustomAttributes(typeof(TargetFrameworkAttribute), false) + .Cast() + .Single(); + + var targetFramework = NuGetFramework.ParseFrameworkName(targetFrameworkAttribute.FrameworkName, DefaultFrameworkNameProvider.Instance); + + var libVersion = (await packageReader.GetLibItemsAsync(cancellationToken)) + .Where(l => DefaultCompatibilityProvider.Instance.IsCompatible(targetFramework, l.TargetFramework)) + .OrderByDescending(l => l.TargetFramework) + .FirstOrDefault(); + + if (libVersion == null) + return Enumerable.Empty(); + + + var assemblies = new List(); + + foreach (var filename in libVersion.Items.Where(f => f.EndsWith(@".dll", StringComparison.InvariantCultureIgnoreCase))) + { + var assembly = await new NuGetPackageAssembly().CopyFrom(packageReader.GetStream(filename)); + assemblies.Add(assembly); + } + + return assemblies; + } + + + + private class NuGetPackageAssembly : IPackageAssembly + { + private readonly MemoryStream buffer = new(); + + + public async Task CopyFrom(Stream stream) + { + await stream.CopyToAsync(buffer); + return this; + } + + + public Stream GetStream() + { + return new MemoryStream(buffer.GetBuffer()); + } + } + } +} diff --git a/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs b/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs new file mode 100644 index 0000000..b7a5072 --- /dev/null +++ b/PettingZoo.Tapeti/AssemblyParser/AssemblyParser.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Loader; +using Newtonsoft.Json; +using PettingZoo.Core.Generator; + +namespace PettingZoo.Tapeti.AssemblyParser +{ + public class AssemblyParser : IDisposable + { + private readonly AssemblyLoadContext loadContext; + + public AssemblyParser(params string[] extraAssembliesPaths) + { + // Using the MetadataLoadContext introduces extra complexity since types can not be compared + // (a string from the loaded assembly does not equal our typeof(string) for example). + // So instead we'll use a regular AssemblyLoadContext. Not ideal, and will probably cause other side-effects + // if we're not careful, but I don't feel like writing a full metadata parser right now. + // If you have a better idea, it's open-source! :-) + loadContext = new AssemblyLoadContext(null, true); + + foreach (var extraAssembly in extraAssembliesPaths.SelectMany(p => Directory.Exists(p) + ? Directory.GetFiles(p, "*.dll") + : Enumerable.Empty())) + { + loadContext.LoadFromAssemblyPath(extraAssembly); + } + } + + + public void Dispose() + { + loadContext.Unload(); + GC.SuppressFinalize(this); + } + + + public IEnumerable GetExamples(Stream assemblyStream) + { + var assembly = loadContext.LoadFromStream(assemblyStream); + + foreach (var type in assembly.GetTypes().Where(t => t.IsClass)) + yield return new TypeExample(type); + } + + + + private class TypeExample : IClassTypeExample + { + public string AssemblyName => type.Assembly.GetName().Name ?? ""; + public string? Namespace => type.Namespace; + public string ClassName => type.Name; + + private readonly Type type; + + + public TypeExample(Type type) + { + this.type = type; + } + + + public string Generate() + { + /* + We can't create an instance of the type to serialize easily, as most will depend on + assemblies not included in the NuGet package, so we'll parse the Type ourselves. + This is still much easier than using MetadataReader, as we can more easily check against + standard types like Nullable. + + The only external dependencies should be the attributes, like [RequiredGuid]. The messaging models + themselves should not inherit from classes outside of their assembly, or include properties + with types from other assemblies. With that assumption, walking the class structure should be safe. + The extraAssemblies passed to TapetiClassLibraryExampleSource can also be used to give it a better chance. + */ + var serialized = TypeToJObjectConverter.Convert(type); + return serialized.ToString(Formatting.Indented); + } + } + } +} diff --git a/PettingZoo.Tapeti/NuGet/INuGetPackageManager.cs b/PettingZoo.Tapeti/NuGet/INuGetPackageManager.cs new file mode 100644 index 0000000..a7a7880 --- /dev/null +++ b/PettingZoo.Tapeti/NuGet/INuGetPackageManager.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PettingZoo.Tapeti.NuGet +{ + public interface INuGetPackageManager + { + public IReadOnlyList Sources { get; } + } + + + public interface INuGetPackageSource + { + public string Name { get; } + + public Task> Search(string searchTerm, bool includePrerelease, CancellationToken cancellationToken); + } + + + public interface INuGetPackage + { + public string Title { get; } + public string Description { get; } + public string Authors { get; } + public string Version { get; } + + public Task> GetVersions(CancellationToken cancellationToken); + } + + + public interface INuGetPackageVersion : IComparable + { + public string Version { get; } + + public Task Download(Stream destination, CancellationToken cancellationToken); + } +} diff --git a/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs b/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs new file mode 100644 index 0000000..def5e42 --- /dev/null +++ b/PettingZoo.Tapeti/NuGet/NuGetPackageManager.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using NuGet.Common; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; +using ILogger = Serilog.ILogger; + +namespace PettingZoo.Tapeti.NuGet +{ + public class NuGetPackageManager : INuGetPackageManager + { + private const string NuGetDefaultSource = @"https://api.nuget.org/v3/index.json"; + + private readonly ILogger logger; + private readonly SourceCacheContext cache; + private readonly List sources; + + public IReadOnlyList Sources => sources; + + + public NuGetPackageManager(ILogger logger) + { + this.logger = logger; + cache = new SourceCacheContext(); + sources = new List + { + new(logger.ForContext("source", NuGetDefaultSource), cache, "nuget.org", NuGetDefaultSource) + }; + } + + + public NuGetPackageManager WithSourcesFrom(string nuGetConfig) + { + if (!File.Exists(nuGetConfig)) + return this; + + var doc = new XmlDocument(); + doc.Load(nuGetConfig); + + var nodes = doc.SelectNodes(@"/configuration/packageSources/add"); + if (nodes == null) + return this; + + foreach (var entry in nodes.Cast()) + { + if (entry.Attributes == null) + continue; + + var nameAttribute = entry.Attributes["key"]; + var urlAttribute = entry.Attributes["value"]; + + if (string.IsNullOrEmpty(nameAttribute?.Value) || string.IsNullOrEmpty(urlAttribute?.Value)) + continue; + + sources.Add(new Source(logger.ForContext("source", urlAttribute.Value), cache, nameAttribute.Value, urlAttribute.Value)); + } + + return this; + } + + + + private class Source : INuGetPackageSource + { + private readonly ILogger logger; + private readonly SourceCacheContext cache; + private readonly SourceRepository repository; + + public string Name { get; } + + + public Source(ILogger logger, SourceCacheContext cache, string name, string url) + { + this.logger = logger; + this.cache = cache; + Name = name; + repository = Repository.Factory.GetCoreV3(url); + } + + + public async Task> Search(string searchTerm, bool includePrerelease, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return Array.Empty(); + + try + { + var resource = await repository.GetResourceAsync(cancellationToken); + var filter = new SearchFilter(includePrerelease); + + var result = (await resource.SearchAsync(searchTerm, filter, 0, 20, new NullLogger(), + cancellationToken)) + .Select(p => new Package(logger, cache, repository, p)) + .ToArray(); + + return result; + } + catch (Exception e) + { + logger.Error(e, "NuGet Search failed for term '{searchTerm}' (includePrerelease {includePrerelease})", searchTerm, includePrerelease); + throw; + } + } + } + + + protected class Package : INuGetPackage + { + private readonly ILogger logger; + private readonly SourceCacheContext cache; + private readonly SourceRepository repository; + private readonly IPackageSearchMetadata packageSearchMetadata; + + public string Title => packageSearchMetadata.Title; + public string Description => packageSearchMetadata.Description; + public string Authors => packageSearchMetadata.Authors; + public string Version => packageSearchMetadata.Identity.Version.ToString(); + + + private IReadOnlyList? versions; + + + public Package(ILogger logger, SourceCacheContext cache, SourceRepository repository, IPackageSearchMetadata packageSearchMetadata) + { + this.logger = logger; + this.cache = cache; + this.repository = repository; + this.packageSearchMetadata = packageSearchMetadata; + } + + + public async Task> GetVersions(CancellationToken cancellationToken) + { + try + { + return versions ??= (await packageSearchMetadata.GetVersionsAsync()) + .Select(v => new PackageVersion(cache, repository, packageSearchMetadata, v.Version)) + .ToArray(); + } + catch (Exception e) + { + logger.Error(e, "NuGet GetVersions failed for packge Id '{packageId}')", packageSearchMetadata.Identity.Id); + throw; + } + } + } + + + protected class PackageVersion : INuGetPackageVersion, IComparable + { + private readonly SourceCacheContext cache; + private readonly SourceRepository repository; + private readonly IPackageSearchMetadata packageSearchMetadata; + + protected readonly NuGetVersion NuGetVersion; + + + public PackageVersion(SourceCacheContext cache, SourceRepository repository, IPackageSearchMetadata packageSearchMetadata, NuGetVersion nuGetVersion) + { + this.cache = cache; + this.repository = repository; + this.packageSearchMetadata = packageSearchMetadata; + NuGetVersion = nuGetVersion; + } + + + public string Version => NuGetVersion.ToString(); + + + public async Task Download(Stream destination, CancellationToken cancellationToken) + { + var resource = await repository.GetResourceAsync(cancellationToken); + await resource.CopyNupkgToStreamAsync(packageSearchMetadata.Identity.Id, NuGetVersion, destination, cache, new NullLogger(), cancellationToken); + } + + + public int CompareTo(INuGetPackageVersion? other) + { + if (ReferenceEquals(this, other)) return 0; + if (other == null) return 1; + + return other is PackageVersion packageVersion ? CompareTo(packageVersion) : string.Compare(Version, other.Version, StringComparison.Ordinal); + } + + public int CompareTo(PackageVersion? other) + { + if (ReferenceEquals(this, other)) return 0; + return other == null ? 1 : NuGetVersion.CompareTo(other.NuGetVersion); + } + } + } +} diff --git a/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj b/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj index d9b83bb..8234b3e 100644 --- a/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj +++ b/PettingZoo.Tapeti/PettingZoo.Tapeti.csproj @@ -1,16 +1,70 @@ - + - net6.0 + net6.0-windows 0.1 + true + enable + + + + + + + + + + + + + + True + True + ClassSelectionStrings.resx + + + True + True + PackageProgressStrings.resx + + + Code + + + True + True + PackageSelectionStrings.resx + + + + + + PublicResXFileCodeGenerator + ClassSelectionStrings.Designer.cs + + + PublicResXFileCodeGenerator + PackageProgressStrings.Designer.cs + + + PublicResXFileCodeGenerator + PackageSelectionStrings.Designer.cs + + + + + + $(DefaultXamlRuntime) + Designer + diff --git a/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs b/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs new file mode 100644 index 0000000..e34ad5d --- /dev/null +++ b/PettingZoo.Tapeti/TapetiClassLibraryExampleGenerator.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using PettingZoo.Core.Generator; +using PettingZoo.Core.Settings; +using PettingZoo.Tapeti.AssemblyLoader; +using PettingZoo.Tapeti.NuGet; +using PettingZoo.Tapeti.UI.ClassSelection; +using PettingZoo.Tapeti.UI.PackageProgress; +using PettingZoo.Tapeti.UI.PackageSelection; +using Serilog; + +namespace PettingZoo.Tapeti +{ + public class TapetiClassLibraryExampleGenerator : IExampleGenerator + { + private readonly ILogger logger; + + + public TapetiClassLibraryExampleGenerator(ILogger logger) + { + this.logger = logger; + } + + + public void Select(object? ownerWindow, Action onExampleSelected) + { + var packageManager = new NuGetPackageManager(logger) + .WithSourcesFrom(Path.Combine(PettingZooPaths.InstallationRoot, @"nuget.config")) + .WithSourcesFrom(Path.Combine(PettingZooPaths.AppDataRoot, @"nuget.config")); + + var dispatcher = Dispatcher.CurrentDispatcher; + + var viewModel = new PackageSelectionViewModel(packageManager); + var selectionWindow = new PackageSelectionWindow(viewModel) + { + Owner = ownerWindow as Window + }; + + viewModel.Select += (_, args) => + { + dispatcher.Invoke(() => + { + var windowBounds = selectionWindow.RestoreBounds; + selectionWindow.Close(); + + var progressWindow = new PackageProgressWindow(); + progressWindow.Left = windowBounds.Left + (windowBounds.Width - progressWindow.Width) / 2; + progressWindow.Left = windowBounds.Top + (windowBounds.Height - progressWindow.Height) / 2; + progressWindow.Show(); + + Task.Run(async () => + { + try + { + // TODO allow cancelling (by closing the progress window and optionally a Cancel button) + var assemblies = await args.Assemblies.GetAssemblies(progressWindow, CancellationToken.None); + + // var classes = + var examples = LoadExamples(assemblies); + + dispatcher.Invoke(() => + { + progressWindow.Close(); + progressWindow = null; + + var classSelectionViewModel = new ClassSelectionViewModel(examples); + var classSelectionWindow = new ClassSelectionWindow(classSelectionViewModel) + { + Top = windowBounds.Top, + Left = windowBounds.Left, + Width = windowBounds.Width, + Height = windowBounds.Height + }; + + classSelectionViewModel.Select += (_, example) => + { + classSelectionWindow.Close(); + onExampleSelected(example); + }; + + classSelectionWindow.ShowDialog(); + }); + } + catch (Exception e) + { + dispatcher.Invoke(() => + { + // ReSharper disable once ConstantConditionalAccessQualifier - if I remove it, there's a "Dereference of a possibly null reference" warning instead + progressWindow?.Close(); + + MessageBox.Show($"Error while loading assembly: {e.Message}", "Petting Zoo - Exception", MessageBoxButton.OK, MessageBoxImage.Error); + }); + } + }); + }); + }; + + selectionWindow.ShowDialog(); + } + + + private static IEnumerable LoadExamples(IEnumerable assemblies) + { + var assemblyParser = new AssemblyParser.AssemblyParser( + PettingZooPaths.AppDataAssemblies, + PettingZooPaths.InstallationAssemblies + ); + + return assemblies + .SelectMany(a => + { + using var stream = a.GetStream(); + return assemblyParser.GetExamples(stream).ToArray(); + }) + .ToArray(); + } + } +} diff --git a/PettingZoo.Tapeti/TapetiClassLibraryExampleSource.cs b/PettingZoo.Tapeti/TapetiClassLibraryExampleSource.cs deleted file mode 100644 index ec91428..0000000 --- a/PettingZoo.Tapeti/TapetiClassLibraryExampleSource.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.InteropServices; -using Newtonsoft.Json; -using PettingZoo.Core.Generator; - -namespace PettingZoo.Tapeti -{ - public class TapetiClassLibraryExampleSource : IExampleSource - { - private readonly string classLibraryFilename; - private readonly IEnumerable extraAssemblies; - private Lazy assemblySource; - - - public TapetiClassLibraryExampleSource(string classLibraryFilename, IEnumerable extraAssemblies) - { - this.classLibraryFilename = classLibraryFilename; - this.extraAssemblies = extraAssemblies; - - assemblySource = new Lazy(AssemblySourceFactory); - } - - - public void Dispose() - { - if (assemblySource.IsValueCreated) - assemblySource.Value.Dispose(); - - GC.SuppressFinalize(this); - } - - - public IExampleFolder GetRootFolder() - { - return assemblySource.Value.RootFolder; - } - - - private AssemblySource AssemblySourceFactory() - { - var runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll"); - - var paths = runtimeAssemblies - .Concat(extraAssemblies) - .Append(classLibraryFilename); - - // TODO can we use a custom resolver to detect missing references? - var resolver = new PathAssemblyResolver(paths); - var loadContext = new MetadataLoadContext(resolver); - try - { - var assembly = loadContext.LoadFromAssemblyPath(classLibraryFilename); - var rootFolder = new Folder(@"Root"); - - - foreach (var assemblyType in assembly.GetTypes()) - AddType(assemblyType, rootFolder); - - - return new AssemblySource - { - LoadContext = loadContext, - RootFolder = rootFolder - }; - } - catch - { - loadContext.Dispose(); - throw; - } - } - - - private void AddType(Type type, Folder rootFolder) - { - if (!type.IsClass) - return; - - var assemblyName = type.Assembly.GetName().Name + "."; - var typeNamespace = type.Namespace ?? ""; - - if (typeNamespace.StartsWith(assemblyName)) - typeNamespace = typeNamespace.Substring(assemblyName.Length); - - var folder = CreateFolder(rootFolder, typeNamespace); - folder.AddMessage(new Message(type)); - } - - - private static Folder CreateFolder(Folder rootFolder, string typeNamespace) - { - var parts = typeNamespace.Split('.'); - if (parts.Length == 0) - return rootFolder; - - var folder = rootFolder; - - foreach (var part in parts) - folder = folder.CreateFolder(part); - - return folder; - } - - - private class Folder : IExampleFolder - { - private readonly List folders = new(); - private readonly List messages = new(); - - - public string Name { get; } - public IReadOnlyList Folders => folders; - public IReadOnlyList Messages => messages; - - - public Folder(string name) - { - Name = name; - } - - - public Folder CreateFolder(string name) - { - var folder = folders.FirstOrDefault(f => f.Name == name); - if (folder != null) - return folder; - - folder = new Folder(name); - folders.Add(folder); - return folder; - } - - - public void AddMessage(IExampleMessage message) - { - messages.Add(message); - } - } - - - private class Message : IExampleMessage - { - private readonly Type type; - - - public Message(Type type) - { - this.type = type; - } - - - public string Generate() - { - /* - We can't create an instance of the type to serialize easily, as most will depend on - assemblies not included in the NuGet package, so we'll parse the Type ourselves. - This is still much easier than using MetadataReader, as we can more easily check against - standard types like Nullable. - - The only external dependencies should be the attributes, like [RequiredGuid]. The messaging models - themselves should not inherit from classes outside of their assembly, or include properties - with types from other assemblies. With that assumption, walking the class structure should be safe. - The extraAssemblies passed to TapetiClassLibraryExampleSource can also be used to give it a better chance. - */ - var serialized = TypeToJObjectConverter.Convert(type); - return serialized.ToString(Formatting.Indented); - } - } - - - private class AssemblySource : IDisposable - { - public MetadataLoadContext LoadContext { get; init; } - public IExampleFolder RootFolder { get; init; } - - - public void Dispose() - { - LoadContext.Dispose(); - } - } - } -} diff --git a/PettingZoo.Tapeti/TypeToJObjectConverter.cs b/PettingZoo.Tapeti/TypeToJObjectConverter.cs index d807501..5742475 100644 --- a/PettingZoo.Tapeti/TypeToJObjectConverter.cs +++ b/PettingZoo.Tapeti/TypeToJObjectConverter.cs @@ -6,18 +6,47 @@ using Newtonsoft.Json.Linq; namespace PettingZoo.Tapeti { - internal class TypeToJObjectConverter + public class TypeToJObjectConverter { public static JObject Convert(Type type) { + if (!type.IsClass) + throw new ArgumentException($"TypeToJObjectConverter.Convert expects a class, got {type.Name}"); + + return ClassToJToken(type, Array.Empty()); + } + + + private static readonly Dictionary TypeEquivalenceMap = new() + { + { typeof(uint), typeof(int) }, + { typeof(long), typeof(int) }, + { typeof(ulong), typeof(int) }, + { typeof(short), typeof(int) }, + { typeof(ushort), typeof(int) }, + { typeof(float), typeof(decimal) } + }; + + + private static readonly Dictionary TypeValueMap = new() + { + { typeof(int), 0 }, + { typeof(decimal), 0.0 }, + { typeof(bool), false } + }; + + + private static JObject ClassToJToken(Type classType, IEnumerable typesEncountered) + { + var newTypesEncountered = typesEncountered.Append(classType).ToArray(); var result = new JObject(); - foreach (var propertyInfo in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + foreach (var propertyInfo in classType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { - // Note: unfortunately we can not call GetCustomAttributes here, as that would - // trigger assemblies not included in the package to be loaded + // Note: unfortunately we can not call GetCustomAttributes here for now, as that would + // trigger assemblies not included in the package to be loaded, which may not exist - var value = PropertyToJToken(propertyInfo.PropertyType); + var value = TypeToJToken(propertyInfo.PropertyType, newTypesEncountered); result.Add(propertyInfo.Name, value); } @@ -25,23 +54,12 @@ namespace PettingZoo.Tapeti } - private static readonly Dictionary TypeMap = new() + private static JToken TypeToJToken(Type type, ICollection typesEncountered) { - { typeof(short), 0 }, - { typeof(ushort), 0 }, - { typeof(int), 0 }, - { typeof(uint), 0 }, - { typeof(long), 0 }, - { typeof(ulong), 0 }, - { typeof(decimal), 0.0 }, - { typeof(float), 0.0 }, - { typeof(bool), false } - }; + var actualType = Nullable.GetUnderlyingType(type) ?? type; - - private static JToken PropertyToJToken(Type propertyType) - { - var actualType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + if (TypeEquivalenceMap.TryGetValue(actualType, out var equivalentType)) + actualType = equivalentType; // String is also a class @@ -56,14 +74,13 @@ namespace PettingZoo.Tapeti .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); if (enumerableInterface != null) - return new JArray(Convert(enumerableInterface.GetGenericArguments()[0])); + return new JArray(TypeToJToken(enumerableInterface.GetGenericArguments()[0], typesEncountered)); - - return Convert(actualType); + return typesEncountered.Contains(actualType) ? new JValue((object?)null) : ClassToJToken(actualType, typesEncountered); } if (actualType.IsArray) - return new JArray(Convert(actualType.GetElementType())); + return new JArray(TypeToJToken(actualType.GetElementType()!, typesEncountered)); if (actualType.IsEnum) return Enum.GetNames(actualType).FirstOrDefault(); @@ -80,11 +97,9 @@ namespace PettingZoo.Tapeti if (actualType == typeof(Guid)) return Guid.NewGuid().ToString(); - return TypeMap.TryGetValue(actualType, out var mappedToken) + return TypeValueMap.TryGetValue(actualType, out var mappedToken) ? mappedToken : $"(unknown type: {actualType.Name})"; } - - } } diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs new file mode 100644 index 0000000..e3ccd23 --- /dev/null +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PettingZoo.Tapeti.UI.ClassSelection { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class ClassSelectionStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ClassSelectionStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PettingZoo.Tapeti.UI.ClassSelection.ClassSelectionStrings", typeof(ClassSelectionStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Cancel. + /// + public static string ButtonCancel { + get { + return ResourceManager.GetString("ButtonCancel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select. + /// + public static string ButtonSelect { + get { + return ResourceManager.GetString("ButtonSelect", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select class. + /// + public static string WindowTitle { + get { + return ResourceManager.GetString("WindowTitle", resourceCulture); + } + } + } +} diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx new file mode 100644 index 0000000..9c4c9a0 --- /dev/null +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionStrings.resx @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cancel + + + Select + + + Select class + + \ No newline at end of file diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs new file mode 100644 index 0000000..5cecfbb --- /dev/null +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionViewModel.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Input; +using PettingZoo.Core.Generator; +using PettingZoo.WPF.ViewModel; + +namespace PettingZoo.Tapeti.UI.ClassSelection +{ + public class ClassSelectionViewModel : BaseViewModel + { + private BaseClassTreeItem? selectedItem; + private readonly DelegateCommand selectCommand; + + public ObservableCollection Examples { get; } = new(); + + public BaseClassTreeItem? SelectedItem + { + get => selectedItem; + set + { + if (!SetField(ref selectedItem, value)) + return; + + selectCommand.RaiseCanExecuteChanged(); + } + } + + + public ICommand SelectCommand => selectCommand; + + + public event EventHandler? Select; + + + public ClassSelectionViewModel(IEnumerable examples) + { + selectCommand = new DelegateCommand(SelectExecute, SelectCanExecute); + + TreeFromExamples(examples); + } + + + private void SelectExecute() + { + if (SelectedItem is not ExampleTreeItem exampleTreeItem) + return; + + Select?.Invoke(this, exampleTreeItem.Example); + } + + + private bool SelectCanExecute() + { + return SelectedItem is ExampleTreeItem; + } + + + private void TreeFromExamples(IEnumerable examples) + { + var root = new NamespaceFolderClassTreeItem(string.Empty); + + foreach (var example in examples) + { + var folder = !string.IsNullOrEmpty(example.Namespace) + ? CreateFolder(root, example.Namespace.Split('.')) + : root; + + folder.AddChild(new ExampleTreeItem(example)); + } + + + // If the first levels only consist of one child folder, collapse them into one entry + var collapsedRoot = root; + while (collapsedRoot.Children.Count == 1 && collapsedRoot.Children.First() is NamespaceFolderClassTreeItem newRoot) + collapsedRoot = newRoot.Collapse(collapsedRoot.Name); + + if (ReferenceEquals(collapsedRoot, root)) + { + foreach (var rootItem in root.Children) + Examples.Add(rootItem); + } + else + Examples.Add(collapsedRoot); + } + + + private static NamespaceFolderClassTreeItem CreateFolder(NamespaceFolderClassTreeItem root, IEnumerable parts) + { + var parent = root; + + foreach (var part in parts) + { + if (parent.Children.FirstOrDefault(c => c is NamespaceFolderClassTreeItem && c.Name == part) is NamespaceFolderClassTreeItem child) + { + parent = child; + continue; + } + + child = new NamespaceFolderClassTreeItem(part); + parent.AddChild(child); + + parent = child; + } + + return parent; + } + } + + + public class BaseClassTreeItem + { + private readonly SortedSet children = new(new BaseClassTreeItemComparer()); + + public string Name { get; protected set; } + public IReadOnlyCollection Children => children; + + + public BaseClassTreeItem(string name) + { + Name = name; + } + + + public void AddChild(BaseClassTreeItem item) + { + children.Add(item); + } + } + + + public class NamespaceFolderClassTreeItem : BaseClassTreeItem + { + public NamespaceFolderClassTreeItem(string name) : base(name) + { + } + + + public NamespaceFolderClassTreeItem Collapse(string parentFolderName) + { + Name = string.IsNullOrEmpty(parentFolderName) ? Name : parentFolderName + "." + Name; + return this; + } + } + + + public class ExampleTreeItem : BaseClassTreeItem + { + public IClassTypeExample Example { get; } + + + public ExampleTreeItem(IClassTypeExample example) : base(example.ClassName) + { + Example = example; + } + } + + + public class BaseClassTreeItemComparer : IComparer + { + public int Compare(BaseClassTreeItem? x, BaseClassTreeItem? y) + { + if (ReferenceEquals(x, y)) return 0; + if (y == null) return 1; + if (x == null) return -1; + + if (x.GetType() != y.GetType()) + return x is NamespaceFolderClassTreeItem ? -1 : 1; + + return string.Compare(x.Name, y.Name, StringComparison.Ordinal); + } + } + + + public class DesignTimeClassSelectionViewModel : ClassSelectionViewModel + { + public DesignTimeClassSelectionViewModel() + : base(new IClassTypeExample[] + { + new DesignTimeExample("Messaging.Test", "Messaging.Test", "TestMessage"), + new DesignTimeExample("Messaging.Test", "Messaging.Test", "SomeRequestMessage"), + new DesignTimeExample("Messaging.Test", "Messaging.Test.Model", "SomeViewModel") + }) + { + + } + + + private class DesignTimeExample : IClassTypeExample + { + public string AssemblyName { get; } + public string? Namespace { get; } + public string ClassName { get; } + + + public DesignTimeExample(string assemblyName, string? ns, string className) + { + AssemblyName = assemblyName; + Namespace = ns; + ClassName = className; + } + + + public string Generate() + { + return ""; + } + } + } +} diff --git a/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml new file mode 100644 index 0000000..c692ef6 --- /dev/null +++ b/PettingZoo.Tapeti/UI/ClassSelection/ClassSelectionWindow.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +