如何让 CompletionService 知道项目中的其他文档?

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

我正在构建一个允许用户定义、编辑和执行 C# 脚本的应用程序。

定义由方法名、参数名数组和方法内部代码组成,例如:

基于这个定义可以生成以下代码:

public static object Script1(object arg1, object arg2)
{
return $"Arg1: {arg1}, Arg2: {arg2}";
}

我已经成功设置了 AdhocWorkspaceProject,如下所示:

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;
}

我可以这样创建文档:

var document = _workspace.AddDocument(_projectId, "MyFile.cs", SourceText.From(code)).WithSourceCodeKind(SourceCodeKind.Script);

对于用户定义的每个脚本,我目前正在创建一个单独的 Document

执行代码也可以,使用以下方法:

首先编译所有文件:

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);
}

然后,从编译中创建程序集:

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;
        }

    }
    

最后,执行脚本:

    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;
        }
    }
    

到目前为止,还不错。这允许用户定义脚本可以相互

为了编辑,我想提供代码补全,目前正在这样做:

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);
    }

注意: 上面的代码片段已更新,以修复 Jason 在他的回答中提到的关于使用 docdocument 的错误。事实上,这是因为这里显示的代码是从我的实际应用程序代码中提取(并因此修改)的。您可以在他的回答中找到我 post 编辑的原始错误片段,此外,还有一个新版本,它解决了导致我出现问题的实际问题。

现在的问题是 GetCompletionsAsync 只知道同一个 Document 中的定义以及创建工作区和项目时使用的引用,但它显然没有任何对其他引用的引用同一项目中的文档。所以 CompletionList 不包含其他用户脚本的符号。

这看起来很奇怪,因为在“实时”Visual Studio 项目中,当然,项目中的所有文件都知道彼此。

我错过了什么?项目 and/or 工作区是否设置不正确?还有另一种调用 CompletionService 的方法吗?生成的文档代码是否缺少某些内容,例如公共名称空间?

我最后的办法是将所有从用户脚本定义生成的方法合并到一个文件中 - 还有其他方法吗?

仅供参考,这里有一些有用的链接帮助我走到这一步:

https://www.strathweb.com/2018/12/using-roslyn-c-completion-service-programmatically/

Updating AdHocWorkspace is slow

更新 1: 感谢 Jason 的回答,我更新了 GetCompletionList 方法如下:

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);
}

正如 Jason 所指出的,主要错误是没有充分考虑项目及其文档的 不变性。我调用 CompletionService.GetService(doc) 所需的 Document 实例必须是 当前解决方案 中包含的实际实例 - 而不是 doc.WithText(...) 创建的实例,因为该实例不知道任何东西

通过存储原始实例的 DocumentId 并使用它在解决方案、currentDoc 中检索更新的实例 ,在应用更改后,完成服务可以(如在“实时”解决方案中)参考其他文档。

更新 2: 在我最初的问题中,代码片段使用了 SourceCodeKind.Regular,但是 - 至少在这种情况下 - 它必须是 SourceCodeKind.Script,因为否则编译器会抱怨不允许使用顶级静态方法(使用 C# 7.3 时)。我现在更新了 post.

这里有一件事看起来有点可疑:

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);
}

(注意:您的参数名称是“doc”,但您使用的是“document”,所以我猜这段代码是您从完整示例中删减的内容。但只是想调用它,因为您可能这样做时引入了错误。)

所以主要的可疑点:Roslyn 文档是快照;文档是整个解决方案 entire 快照中的指针。您的“newDoc”是一个新文档,其中包含您已替换的文本,并且您正在更新工作区以包含它。但是,您仍在将 原始 文档交给 GetCompletionsAsync,这意味着在这种情况下您仍在请求旧文档,其中可能包含陈旧代码。此外,因为它都是快照,所以通过调用 TryApplyChanges 对主工作区所做的更改不会以任何方式反映在您的新文档对象中。所以我猜这里可能发生的事情是你传递了一个 Document 对象,它实际上并没有立即更新所有文本文档,但其中大部分仍然是空的或类似的东西。