I'm building an application that allows users to define, edit and execute C# scripts.
The definition consists of a method name, an array of parameter names and the method's inner code, e.g:
- Name: Script1
- Parameter Names: arg1, arg2
- Code: return $"Arg1: {arg1}, Arg2: {arg2}";
Based on this definition the following code can be generated:
public static object Script1(object arg1, object arg2)
{
return $"Arg1: {arg1}, Arg2: {arg2}";
}
I've successfully set up an AdhocWorkspace
and a Project
like this:
private readonly CSharpCompilationOptions _options = new CSharpCompilationOptions(OutputKind.ConsoleApplication,
moduleName: "MyModule",
mainTypeName: "MyMainType",
scriptClassName: "MyScriptClass"
)
.WithUsings("System");
private readonly MetadataReference[] _references = {
MetadataReference.CreateFromFile(typeof(object).Assembly.Location)
};
private void InitializeWorkspaceAndProject(out AdhocWorkspace ws, out ProjectId projectId)
{
var assemblies = new[]
{
Assembly.Load("Microsoft.CodeAnalysis"),
Assembly.Load("Microsoft.CodeAnalysis.CSharp"),
Assembly.Load("Microsoft.CodeAnalysis.Features"),
Assembly.Load("Microsoft.CodeAnalysis.CSharp.Features")
};
var partTypes = MefHostServices.DefaultAssemblies.Concat(assemblies)
.Distinct()
.SelectMany(x => x.GetTypes())
.ToArray();
var compositionContext = new ContainerConfiguration()
.WithParts(partTypes)
.CreateContainer();
var host = MefHostServices.Create(compositionContext);
ws = new AdhocWorkspace(host);
var projectInfo = ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Create(),
"MyProject",
"MyProject",
LanguageNames.CSharp,
compilationOptions: _options, parseOptions: new CSharpParseOptions(LanguageVersion.CSharp7_3, DocumentationMode.None, SourceCodeKind.Script)).
WithMetadataReferences(_references);
projectId = ws.AddProject(projectInfo).Id;
}
And I can create documents like this:
var document = _workspace.AddDocument(_projectId, "MyFile.cs", SourceText.From(code)).WithSourceCodeKind(SourceCodeKind.Script);
For each script the user defines, I'm currently creating a separate Document
.
Executing the code works as well, using the following methods:
First, to compile all documents:
public async Task<Compilation> GetCompilations(params Document[] documents)
{
var treeTasks = documents.Select(async (d) => await d.GetSyntaxTreeAsync());
var trees = await Task.WhenAll(treeTasks);
return CSharpCompilation.Create("MyAssembly", trees, _references, _options);
}
Then, to create an assembly out of the compilation:
public Assembly GetAssembly(Compilation compilation)
{
try
{
using (MemoryStream ms = new MemoryStream())
{
var emitResult = compilation.Emit(ms);
if (!emitResult.Success)
{
foreach (Diagnostic diagnostic in emitResult.Diagnostics)
{
Console.Error.WriteLine("{0}: {1}", diagnostic.Id, diagnostic.GetMessage());
}
}
else
{
ms.Seek(0, SeekOrigin.Begin);
var buffer = ms.GetBuffer();
var assembly = Assembly.Load(buffer);
return assembly;
}
return null;
}
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
And, finally, to execute the script:
public async Task<object> Execute(string method, object[] params)
{
var compilation = await GetCompilations(_documents);
var a = GetAssembly(compilation);
try
{
Type t = a.GetTypes().First();
var res = t.GetMethod(method)?.Invoke(null, params);
return res;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
So far, so good. This allows users to define scripts that can all each other.
For editing I would like to offer code completion and am currently doing this:
public async Task<CompletionList> GetCompletionList(Document doc, string code, int offset)
{
var newDoc = doc.WithText(SourceText.From(code));
_workspace.TryApplyChanges(newDoc.Project.Solution);
var completionService = CompletionService.GetService(newDoc);
return await completionService.GetCompletionsAsync(newDoc, offset);
}
NOTE: The code snippet above was updated to fix the error Jason mentioned in his answer regarding the use of doc
and document
. That was, indeed, due to the fact that the code shown here was extracted (and thereby modified) from my actual application code. You can find the original erroneous snippet I posted in his answer and, also, further below a new version which addresses the actual issue that was causing my problems.
The problem now is that GetCompletionsAsync
is only aware of definitions within the same Document
and the references used when creating the workspace and project, but it apparently does not have any reference to the other documents within the same project. So the CompletionList
does not contain symbols for the other user scripts.
This seems strange, because in a "live" Visual Studio project, of course, all files within a project are aware of each other.
What am I missing? Are the project and/or workspace set up incorrectly? Is there another way of calling the CompletionService
? Are the generated document codes missing something, like a common namespace?
My last resort would be to merge all methods generated from users' script definitions into one file - is there another way?
FYI, here are a few useful links that helped me get this far:
https://www.strathweb.com/2018/12/using-roslyn-c-completion-service-programmatically/
Roslyn throws The language 'C#' is not supported
Updating AdHocWorkspace is slow
Roslyn: is it possible to pass variables to documents (with SourceCodeKind.Script)
UPDATE 1:
Thanks to Jason's answer I've updated the GetCompletionList
method as follows:
public async Task<CompletionList> GetCompletionList(Document doc, string code, int offset)
{
var docId = doc.Id;
var newDoc = doc.WithText(SourceText.From(code));
_workspace.TryApplyChanges(newDoc.Project.Solution);
var currentDoc = _workspace.CurrentSolution.GetDocument(docId);
var completionService = CompletionService.GetService(currentDoc);
return await completionService.GetCompletionsAsync(currentDoc, offset);
}
As Jason pointed out, the cardinal error was not fully taking the immutability of the project and it's documents into account. The Document
instance I need for calling CompletionService.GetService(doc)
must be the actual instance contained in the current solution - and not the instance created by doc.WithText(...)
, because that instance has no knowledge of anything.
By storing the DocumentId
of the original instance and using it to retrieve the updated instance within the solution, currentDoc
, after applying the changes, the completion service can (as in "live" solutions) reference the other documents.
UPDATE 2: In my original question the code snippets used SourceCodeKind.Regular
, but - at least in this case - it must be SourceCodeKind.Script
, because otherwise the compiler will complain that top-level static methods are not allowed (when using C# 7.3). I've now updated the post.