Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I detect unused imports in a Script (rather than a Document) with Roslyn?

I'm writing a system to process snippets written as unit tests for Noda Time, so I can include the snippets in the documentation. I've got a first pass working, but I wanted to tidy up the code. One of the things this needs to do when processing a snippet is work out which of the using directives are actually required for that snippet. (There can be multiple snippets in a single source file, but each snippet will appear separately in the documentation - I don't want imports from one snippet affecting another.)

The working code deals with Document instances - I create a separate Document per snippet containing a single method and all the potential imports, add it to the project, and then remove unnecessary using directives like this:

private async static Task<Document> RemoveUnusedImportsAsync(Document document)
{
    var compilation = await document.Project.GetCompilationAsync();
    var tree = await document.GetSyntaxTreeAsync();
    var root = tree.GetRoot();
    var unusedImportNodes = compilation.GetDiagnostics()
        .Where(d => d.Id == "CS8019")
        .Where(d => d.Location?.SourceTree == tree)
        .Select(d => root.FindNode(d.Location.SourceSpan))
        .ToList();
    return document.WithSyntaxRoot(
        root.RemoveNodes(unusedImportNodes, SyntaxRemoveOptions.KeepNoTrivia));
}

I've since learned that I could use the IOrganizeImportsService when working with a document, but I'd like to just write it as a Script, as that feels much cleaner in various ways.

Creating the script is easy, so I'd like to just analyze that for unused imports (after some earlier cleanup steps). Here's code I'd hoped would work for a script:

private static Script RemoveUnusedImports(Script script)
{
    var compilation = script.GetCompilation();
    var tree = compilation.SyntaxTrees.Single();
    var root = tree.GetRoot();
    var unusedImportNodes = compilation.GetDiagnostics()
        .Where(d => d.Id == "CS8019")
        .Where(d => d.Location?.SourceTree == tree)
        .Select(d => root.FindNode(d.Location.SourceSpan))
        .ToList();
    var newRoot = root.RemoveNodes(unusedImportNodes, SyntaxRemoveOptions.KeepNoTrivia);
    return CSharpScript.Create(newRoot.ToFullString(), script.Options);
}

Unfortunately, that doesn't find any diagnostics at all - they're just not produced in the compilation :(

Here's a short sample app demonstrating that:

using System;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;

class Program
{
    static void Main(string[] args)
    {
        string text = @"
using System;
using System.Collections.Generic;
Console.WriteLine(""I only need to use System"");";

        Script script = CSharpScript.Create(text);
        // Not sure whether this *should* be required, but it doesn't help...
        script.Compile();
        var compilation = script.GetCompilation();
        foreach (var d in compilation.GetDiagnostics())
        {
            Console.WriteLine($"{d.Id}: {d.GetMessage()}");
        }
    }
}

Required package: Microsoft.CodeAnalysis.CSharp.Scripting (e.g. v2.1.0)

This produces no output :(

My guess is that this is intended, because scripting usually has different use cases. But is there any way of enabling more diagnostics for scripting purposes? Or is there some alternative way of detecting unused imports in a Script? If not, I'll go back to my Document-based approach - which would be a pity, as everything else seems to work quite nicely with scripts...

like image 604
Jon Skeet Avatar asked May 18 '17 21:05

Jon Skeet


1 Answers

As far as I know, the default compilation in the scripting engine doesn't configure diagnostics for anything but syntax errors. Unfortunately the scripting engine only has limited options to configure the underlying compilation yourself.

However, you can probably achieve what you're after by skipping the scripting engine and directly creating the compilation yourself. This is essentially what the script host does behind the scenes with the addition of some of the defaults for the compilation as well as a few fancy things like lifting class declarations. The code to skip the script host and create the compilation yourself would look something like:

using System;
using System.IO;
using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

class Program
{
    static void Main(string[] args)
    {
        string text = @"
using System;
using System.Collections.Generic;
Console.WriteLine(""I only need to use System"");";

        SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(kind: SourceCodeKind.Script));
        var coreDir = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
        var mscorlib = MetadataReference.CreateFromFile(Path.Combine(coreDir, "mscorlib.dll"));
        var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
        var compilation = CSharpCompilation.Create("MyAssembly")
            .AddSyntaxTrees(syntaxTree)
            .AddReferences(mscorlib)
            .WithOptions(options);
        foreach (var d in compilation.GetDiagnostics())
        {
            Console.WriteLine($"{d.Id}: {d.GetMessage()}");
        }
    }
}

You'll notice this produces some undesirable diagnostics about missing references and such - the compilation references need to be tweaked a little to include the default libraries (you can see the pattern with mscorlib above). You should see the desired diagnostics about unused using statements as well.

like image 167
daveaglick Avatar answered Nov 09 '22 18:11

daveaglick