Search code examples
c#roslyncode-analysis

How can I make the CompletionService aware of other documents in the project?


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

Roslyn service is null

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.


Solution

  • So one thing looks a bit fishy here:

    public async Task<CompletionList> GetCompletionList(Document doc, string code, int offset)
    {
        var newDoc = document.WithText(SourceText.From(code));
        _workspace.TryApplyChanges(newDoc.Project.Solution);
        
        var completionService = CompletionService.GetService(newDoc);
                    
        return await completionService.GetCompletionsAsync(document, offset);
    }
    

    (Note: your parameter name is "doc" but you're using "document" so I'm guessing this code is something you pared down from the full example. But just wanted to call that out since you might have introduced errors while doing that.)

    So main fishy bit: Roslyn Documents are snapshots; a document is a pointer within the entire snapshot of the entire solution. Your "newDoc" is a new document with the text that you've substituted, and you're updating thew workspace to contain that. You're however still handing in the original document to GetCompletionsAsync, which means you're still asking for the old document in that case, which might have stale code. Furthermore, because it's all a snapshot, the changes made to the main workspace by calling TryApplyChanges won't in any way be reflected in your new document objects. So what I'm guessing might be happening here is you're passing in a Document object that doesn't actually have all the text documents updated at once, but most of them are still empty or something similar.