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

1

19

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...

Maximilien answered 18/5, 2017 at 21:44 Comment(0)
A
13

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.

Ayrshire answered 18/5, 2017 at 22:23 Comment(5)
Thanks - with a bit of tweaking, that appears to work. Oddly enough, SyntaxNode.ReplaceNodes appears to change the kind of the SyntaxTree options from "Script" to "Regular", so I have to tweak it back again after my replacements, but then it seems okay...Maximilien
note that I opened an issue on Roslyn for this very thing a while ago github.com/dotnet/roslyn/issues/19329Theomancy
@JonSkeet How exactly are you updating the tree? As far as I can tell, you can't use ReplaceNodes directly on a SyntaxTree, because it's not a SyntaxNode.Cyanine
@JonSkeet Ok, what confused me is that you talk about ReplaceNodes, but your code uses RemoveNodes. Anyway, I still don't see any code that would indicate how you create the new SyntaxTree, but all the reasonable approaches I tried work for me. That is, except if you forgot to pass the options, e.g. SyntaxFactory.SyntaxTree(newRoot). See this gist.Cyanine
@svick: I&amp;#39;ve never called SyntaxFactory.SyntaxTree as I&amp;#39;ve never needed to as far as I&amp;#39;m aware. But see my other question shortly after this one around the options - it looks like the options being lost is just a bug. (The new syntax tree is implicitly created by calling RemoveNodes or ReplaceNodes. After all, the returned syntax root has to be in a tree...)Maximilien

© 2022 - 2024 — McMap. All rights reserved.