Skip to content

Instantly share code, notes, and snippets.

@sdcondon
Created November 12, 2017 11:11
Show Gist options
  • Save sdcondon/338b64dbc3863c9962e89dc399e04e99 to your computer and use it in GitHub Desktop.
Save sdcondon/338b64dbc3863c9962e89dc399e04e99 to your computer and use it in GitHub Desktop.
Basic example of using MEF and the Roslyn scripting API to create a scriptable extension system.
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using System;
using System.Collections.Generic;
using System.Composition;
using System.Composition.Hosting;
using System.Composition.Hosting.Core;
using System.IO;
using System.Linq;
using System.Reflection;
/// <summary>
/// Basic example of using MEF and the Roslyn scripting API to create a scriptable extension system.
/// After compilation, try adding a file called print.csx with the content 'Console.WriteLine(Arguments[0])'
/// to the directory containing the exe.
/// </summary>
class Program
{
private CompositionHost container; // NB: IDisposable - but won't bother dealing with this gracefully here.
private Program()
{
var containerConfiguration = new ContainerConfiguration()
.WithProvider(new ScriptedCommandProvider(AppContext.BaseDirectory));
container = containerConfiguration.CreateContainer();
container.SatisfyImports(this);
}
[ImportMany]
public IEnumerable<Lazy<ICommand, CommandMetadata>> Commands
{
get;
set;
}
public static void Main(string[] args)
{
Program p = new Program(); // Composition is performed in the constructor
Console.WriteLine("Loaded commands: {0}", string.Join(", ", p.Commands.Select(c => c.Metadata.Keyword)));
string commandLine = null;
do
{
Console.Write("Enter Command: ");
commandLine = Console.ReadLine();
var commandLineParts = commandLine.Split(' ');
var command = p.Commands.SingleOrDefault(x => commandLineParts[0].StartsWith(x.Metadata.Keyword));
if (command != null)
{
command.Value.Execute(commandLineParts.Skip(1).ToArray());
}
else
{
Console.WriteLine("[Command '{0}' not recognized]", commandLineParts[0]);
}
}
while (!string.IsNullOrWhiteSpace(commandLine));
}
}
/// <summary>
/// Interface that we want to allow scripted implementations of. Obviously this'd get more
/// comlicated for interfaces with more than a single method..
/// </summary>
public interface ICommand
{
/// <summary>
/// Execute the command, doing some useful work.
/// </summary>
/// <param name="arguments">The arguments to pass to the command.</param>
void Execute(string[] arguments);
}
/// <summary>
/// Class for our console app to use to interpret the MEF metadata accompanying the scripted commands.
/// </summary>
public sealed class CommandMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="CommandMetadata"/> class from an MEF-provided metadata dictionary.
/// </summary>
/// <param name="metadata">Metadata dictionary.</param>
public CommandMetadata(IDictionary<string, object> metadata)
{
Keyword = (string)metadata[nameof(Keyword)];
}
/// <summary>
/// The keyword used to invoke the command.
/// </summary>
public string Keyword { get; }
}
/// <summary>
/// MEF ExportDescriptorProvider that provides descriptors for ICommand implementations from scripts found in a particular directory.
/// </summary>
public sealed class ScriptedCommandProvider : ExportDescriptorProvider
{
private string scriptDirectory;
/// <summary>
/// Initializes a new instance of the <see cref="ScriptProvider{T}"/> class.
/// </summary>
/// <param name="scriptDirectory">The directory in which all the scripts can be found.</param>
public ScriptedCommandProvider(string scriptDirectory)
{
this.scriptDirectory = scriptDirectory;
}
/// <inheritdoc />
public override IEnumerable<ExportDescriptorPromise> GetExportDescriptors(CompositionContract contract, DependencyAccessor descriptorAccessor)
{
if (contract.ContractType != typeof(ICommand))
{
return NoExportDescriptors;
}
return Directory
.EnumerateFiles(scriptDirectory, "*.csx")
.Select(p => new ExportDescriptorPromise(
contract,
p,
false,
NoDependencies,
_ => MakeScriptedCommandDescriptor(p)));
}
private ExportDescriptor MakeScriptedCommandDescriptor(string scriptPath)
{
return ExportDescriptor.Create(
(context, operation) => new ScriptedCommand(scriptPath),
new Dictionary<string, object>()
{
{ nameof(CommandMetadata.Keyword), Path.GetFileNameWithoutExtension(scriptPath) }
});
}
}
/// <summary>
/// Implementation of <see cref="ICommand"/> that gets its logic from a script.
/// </summary>
internal sealed class ScriptedCommand : ICommand
{
private Script script;
/// <summary>
/// Initializes a new instance of the <see cref="ScriptedCommand"/> class.
/// </summary>
/// <param name="path">The full path of the file where the logic of the command can be found.</param>
public ScriptedCommand(string path)
{
var opts = ScriptOptions.Default
.AddReferences(typeof(object).GetTypeInfo().Assembly)
.AddReferences(typeof(ICommand).GetTypeInfo().Assembly)
.AddImports("System");
script = CSharpScript.Create(File.ReadAllText(path), opts, typeof(ScriptedCommandGlobals));
}
/// <inheritdoc />
public void Execute(string[] arguments)
{
// NB: As implemented, first invocation will be slow as it compiles.
// Can mitigate by invoking Compile - and this could happen asynchronously -
// but Lazy nature of MEF ImportMany means compilation would have to be initiated
// by the provider, not in the command object itself. Which wouldn't make for as
// graceful an example, so not bothering here.
script.RunAsync(new ScriptedCommandGlobals() { Arguments = arguments });
}
}
/// <summary>
/// Globals object for scripted commands - essentially the interface between the app and the scripts.
/// </summary>
public class ScriptedCommandGlobals
{
public string[] Arguments { get; set; }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment