Share via


Guest Post: .NET Compiler Platform, creare un analyzer + code fix

 

Questo post è stato scritto da Alessandro Del Sole, Microsoft MVP e Solutions Developer Expert presso Brain-Sys .

 

Nel post precedente abbiamo introdotto .NET Compiler Platform, nota anche come Project “Roslyn”, e abbiamo visto di cosa si tratti, a cosa serve e come la utilizzi Visual Studio 2015. In questo post facciamo un esempio pratico creando un analyzer.

 

Analyzer, Code Fix, Refactoring

 

Visual Studio ha sempre integrato l’analisi in real time del codice mentre si digita, ma fino alla versione 2013 c’erano due grosse limitazioni: le regole di analisi erano ristrette a quelle codificate da Microsoft e parecchi problemi nel codice venivano rilevati solamente dopo una compilazione. In Visual Studio 2015, le cose cambiano poiché tutte le codifiche sono in librerie esterne che l’editor di codice è in grado di recepire. Ciò implica che possiamo anche noi scrivere le nostre regole, compilarle in librerie .dll e integrarle nell’editor. Queste librerie sono essenzialmente di due tipi: analyzer e refactoring. Gli analyzer rilevano errori all’interno di porzioni del sorgente secondo regole prestabilite, mentre i refactoring consentono di riscrivere un blocco di codice in modo diverso, mantenendone però inalterato il comportamento. Per quanto riguarda gli analyzer, questi sono solitamente accompagnati dai code fix; un code fix non è altro che una possibile soluzione alla violazione rilevata nel codice ed è quello che in Visual Studio 2015 conosciamo come Quick Action. In questo post impareremo a scrivere un semplice analyzer + code fix che si occupa di rilevare l’utilizzo del tipo DateTime in applicazioni, tipo Universal Windows Platform, dove magari è preferibile utilizzare DateTimeOffset soprattutto in binding con controlli dell’interfaccia. Prima, però, è necessario imparare alcuni concetti.

La sintassi in Roslyn

 

Roslyn lavora con elementi sintattici, ciascuno dei quali rappresenta uno o più parti nel codice sorgente, in ottica gerarchica. L’elemento radice è il Syntax Tree, che rappresenta l’intero blocco di codice che intendiamo analizzare, come ad esempio un namespace con i suoi membri, o una classe con i suoi membri. Al livello inferiore ci sono i Syntax Node, ognuno dei quali rappresenta un blocco ben preciso, ad esempio un metodo, una proprietà, una classe con i suoi membri che sia definita come parte del Syntax Tree. Altri elementi importanti sono i Syntax Token, tipicamente identificatori, e i Syntax Trivia, che rappresentano commenti e spazi bianchi, quindi parti del codice ininfluenti ai fini della compilazione ma che comunque compongono il codice. Roslyn, infatti, deve sapere con estrema esattezza come il sorgente è composto per poterlo rappresentare con oggetti .NET. Se voglio creare una regola di codice e correggere un errore, in sostanza andrò a sostituire un Syntax Node esistente con uno nuovo. Il punto fondamentale è che in Roslyn gli elementi sintattici sono immutabili, quindi, ogni volta che voglio sostituire un nodo, in realtà ne creo uno nuovo che prenderà il posto del precedente.

Creazione di un progetto

 

Se non lo avete già fatto, la prima cosa da fare è installare il .NET Compiler Platform SDK, che mette a disposizione i template di progetto che ci interessano e che si trovano nel nodo Extensibility di Visual Basic e C# nella finestra New Project. In particolare, ne creiamo uno di tipo Analyzer + Code Fix (NuGet + VSIX) chiamato DateTimeAnalyzer:

image

 

Nota bene: tutto ciò che viene descritto in questo post si applica si a C# che a Visual Basic, sebbene descriverò solo C# per brevità. Più avanti vi spiegherò anche cosa significa NuGet + VSIX. La nuova solution contiene tre progetti: una libreria di classi di tipo Portable, che conterrà il nostro analyzer, un progetto per unit test (che non descriveremo) e un progetto extensibility che genera un pacchetto .vsix per poter debuggare (ma non solo) l’analyzer nell’Experimental instance di Visual Studio 2015. La nostra attenzione sarà rivolta al progetto DateTimeAnalyzer (Portable) in cui ci sono due file: DiagnosticAnalyzer.cs e CodeFixProvider.cs. Il primo conterrà la logica di analisi del codice, il secondo conterrà la possibile soluzione alla violazione della regola. Entrambi i nomi sono totalmente convenzionali e possono essere cambiati a piacimento. Provate anche ad espandere l’elemento References in Solution Explorer per vedere come Visual Studio 2015 abbia scaricato da NuGet tutti gli assembly necessari per lavorare con Roslyn e il cui nome inizia con Microsoft.CodeAnalysis. Prima di mettere le mani sul codice è necessario capire cosa dobbiamo correggere e come il compilatore si comporta al riguardo. Lo facciamo attraverso la finestra Syntax Visualizer.

 

Capire cosa analizzare e come: la finestra Syntax Visualizer

 

Ricorderete che ogni elemento del codice sorgente viene letto dal compilatore con oggetti .NET che rappresentano un determinato elemento sintattico. La nostra logica di analisi del codice ci richiede di rilevare tutte le occorrenze del tipo System.DateTime e di riportarlo come violazione di una regola. In questo esempio rileveremo indistintamente tutte le occorrenze di DateTime, ma in scenari reali il tutto andrebbe contestualizzato. Procediamo in questo modo perché siamo agli inizi con Roslyn e dobbiamo prenderci confidenza. Come vedete dalla seguente figura, i compilatori stabiliscono che DateTime sia rappresentato da un oggetto di tipo IdentifierNameSyntax:

 

image

 

Questo nodo sintattico ha anche un identificatore, degli spazi bianchi e dei nodi posti a livello gerarchicamente superiore (VariableDeclarationSyntax, FieldDeclarationSyntax, ecc.). Per semplificare le cose, non ha senso investigare tutti i nodi della gerarchia, semplicemente ci interessa focalizzare l’attenzione su quello visualizzato nel Syntax Visualizer. Stabilito che lavoreremo con IdentifierNameSyntax, passiamo al codice.

Implementare un analyzer

 

Aprite il file DiagnosticAnalyzer.cs ed eliminate il metodo AnalyzeSymbol che Visual Studio genera per finalità dimostrative. Concentriamoci sul primo blocco (nel quale ci sono un paio di modifiche rispetto a quello auto-generato e che vi spiego a breve):

[DiagnosticAnalyzer(LanguageNames.CSharp)]

    public class DateTimeAnalyzerAnalyzer : DiagnosticAnalyzer

    {

        public const string DiagnosticId = "DTA001";

 

        // You can change these strings in the Resources.resx file. If you do not want your analyzer

        // to be localize-able, you can use regular strings for Title and MessageFormat.

        private static readonly LocalizableString Title =

            new LocalizableResourceString(nameof(Resources.AnalyzerTitle),

                Resources.ResourceManager, typeof(Resources));

        private static readonly LocalizableString MessageFormat =

            new LocalizableResourceString(nameof(Resources.AnalyzerMessageFormat),

                Resources.ResourceManager, typeof(Resources));

        private static readonly LocalizableString Description =

            new LocalizableResourceString(nameof(Resources.AnalyzerDescription),

                Resources.ResourceManager, typeof(Resources));

        private const string Category = "Naming";

 

        private static DiagnosticDescriptor Rule =

                new DiagnosticDescriptor(DiagnosticId,

                    Title, MessageFormat, Category,

                    DiagnosticSeverity.Warning,

                    isEnabledByDefault: true,

                    description: Description,

                    helpLinkUri: "https://miosito.com/miohelp");

 

        public override ImmutableArray<DiagnosticDescriptor>

            SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

 

Una classe analyzer è decorate con l’attributo DiagnosticAnalyzer a cui si passa il linguaggio destinatario dell’analyzer. In realtà esistono delle tecniche per la cosiddetta language-agnostic analysis, quindi indipendente dal linguaggio, ma che non tratteremo qui. La classe inoltre deve ereditare da Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer. Ciò significa che abbiamo a che fare con le Diagnostic API. Il campo DiagnosticId contiene un identificatore che verrà visualizzato nella finestra Error List e nella Live Preview; in questo caso ho modificato quello di default, troppo lungo, in un codice identificativo vero e proprio. Poi ci sono i campi Title, MessageFormat e Description che rappresentano, rispettivamente, il titolo, il messaggio diagnostico completo e la descrizione per l’analyzer. Per default sono di tipo LocalizableResourceString, per automatizzare il supporto alla localizzazione. Le relative stringhe sono definite nel file Resources.resx, ma potete anche utilizzare oggetti String normali. Ecco un possibile esempio di come definire le stringhe nelle risorse:

 

image

 

L’elemento successivo è un campo Rule di tipo DiagnosticDescriptor, che contiene le informazioni sulla regola di analisi, tra cui è importante evidenziare la DiagnosticSeverity che può essere Error, Warning, Info o Hidden. Warning è il default e per noi va bene. Notate anche come sia possibile impostare un URL a cui puntare per ottenere informazioni sull’analyzer. E’ ciò che avviene normalmente con le regole dei compilatori Microsoft quando cliccate sul codice di errore nella finestra Error List. Se non specificato, Visual Studio 2015 avvia una ricerca su Bing basata sul DiagnosticId. L’elemento successivo è una proprietà chiamata SupportedDiagnostics, che è di tipo ImmutableArray<DiagnosticDescriptor> e che espone al mondo esterno la nostra regola. Passiamo ora alla logica di analisi. Dobbiamo implementare un metodo che lavori sui Syntax Node e che, quindi, riceva un argomento di tipo SyntaxNodeAnalysisContext (oggetto che rappresenta il contesto di un syntax node. Procediamo per frammenti, ecco il primo:

 

private void AnalyzeDateTime(SyntaxNodeAnalysisContext context)

        {

            // Get the syntax node to analyze

            var root = context.Node;

 

            // If it's not an IdentifierName syntax node,

            // return

            if (!(root is IdentifierNameSyntax))

            {

                return;

            }

Otteniamo il nodo corrente tramite la proprietà Node dell’istanza di SyntaxNodeAnalysisContext. Se il nodo non è di tipo IdentifierNameSyntax, usciamo immediatamente perché non è un nodo di nostro interesse. Il trucco sta nel fare piccoli test uno dietro l’altro, in modo da rendere l’analyzer sempre efficiente. Andiamo avanti:

 

// Convert to IdentifierNameSyntax

            root = (IdentifierNameSyntax)context.Node;

 

            // Get the symbol info for

            // the DateTime type declaration

            var dateSymbol = context.SemanticModel.

                GetSymbolInfo(root).Symbol as INamedTypeSymbol;

 

Se l’oggetto è quello che ci interessa ne otteniamo l’istanza mediante cast appropriato, dopodiché ne otteniamo le informazioni simboliche. SemanticModel è una proprietà che ci consente di ottenere le informazioni semantiche su un syntax node e il suo metodo GetSymbolInfo ci permette di ottenere le informazioni relative ai simboli (per un compilatore, i simboli sono quegli identificatori associati ad un blocco sintattico che abbia senso per lui). In questo caso, dobbiamo capire se l’identificatore analizzato corrisponde ad un simbolo e non ad un semplice identificatore testuale, mediante conversione in INamedTypeSymbol. Analizziamo il risultato di questa conversione:

 

            // If no symbol info, return

            if (dateSymbol == null)

            {

                return;

            }

 

            // If the name of the symbol is not

            // DateTime, return

            if (!(dateSymbol.MetadataName == "DateTime"))

            {

                return;

            }

Se il simbolo è nullo, interrompiamo. Vuol dire che non si tratta di un simbolo come lo interpreterebbe il compilatore. Se invece si tratta di un simbolo, ma la sua proprietà MetadataName non contiene l’identificatore di nostro interesse, usciamo dall’analisi. Ricordate, piccoli test uno dietro l’altro per mantenere l’efficienza. Se tutti questi test danno esito positivo, vuol dire che siamo in presenza di un syntax node di tipo IdentifierNameSyntax il cui testo è DateTime, quindi creiamo una regola:

 

// Create a diagnostic at the node location

            // with the specified message and rule info

            var diagn = Diagnostic.Create(Rule,

                root.GetLocation(),

                "Consider replacing with DateTimeOffset");

 

            // Report the diagnostic

            context.ReportDiagnostic(diagn);

        }

La classe Diagnostic ha un metodo statico Create, che genera una regola diagnostica partendo dal campo Rule indicato all’inizio. Viene specificata la posizione all’interno del codice sorgente (linea, riga e posizione fisica) che altri tool come l’editor di Visual Studio potranno usare per evidenziare il problema, infine viene passato il messaggio di testo da visualizzare. Il metodo ReportDiagnostic sul contesto di analisi invia l’esito dell’analisi al chiamante (che può essere Visual Studio come un altro tool). Come viene inizializzata l’analisi? Mediante il metodo Initialize:

 

public override void Initialize(AnalysisContext context)

        {

                ctx.RegisterSyntaxNodeAction(AnalyzeDateTime,

                    SyntaxKind.IdentifierName);

        }

L’oggetto AnalysisContext espone diversi metodi, ognuno dei quali consente una diversa tipologia di analisi. In questo caso ci interessa l’analisi di syntax node, quindi utilizziamo il metodo RegisterSyntaxNodeAction, al quale passiamo il delegate che eseguirà l’azione e la tipologia di nodo che intendiamo analizzare mediante uno dei (tanti) valori dell’enumerazione SyntaxKind, in questo caso IdentifierName. Questo approccio però va bene per regole di analisi disponibili sempre e comunque. Noi vogliamo che l’analisi sia ristretta solo alla piattaforma Universal Windows, quindi lo riscriviamo così:

 

public override void Initialize(AnalysisContext context)

        {

            context.

            RegisterCompilationStartAction((CompilationStartAnalysisContext ctx) =>

            {

                var requestedType =

                    ctx.Compilation.

                    GetTypeByMetadataName("Windows.Storage.StorageFile");

 

                if (requestedType == null)

                    return;

 

                ctx.RegisterSyntaxNodeAction(AnalyzeDateTime,

                    SyntaxKind.IdentifierName);

            });

        }

Il metodo RegisterCompilationStartAction stabilisce un’azione che deve essere eseguita in fase di startup. In particolare, la classe AnalysisContext, attraverso la proprietà Compilation, espone un metodo chiamato GetTypeByMetadataName al quale passeremo il fully qualified name di un oggetto che sicuramente sappiamo essere presente nella piattaforma di nostro interesse, diversamente il codice esce e l’analisi non verrà mai eseguita. Questo serve a migliorare l’efficienza dell’analisi e ad applicarla solo quando serve.

Implementare un Code Fix

 

Sebbene non sia affatto obbligatorio, ci sono scenari in cui suggerire le soluzioni al problema sia la cosa più indicata. Nel caso di Visual Studio 2015, le soluzioni vengono offerte tramite il Light Bulb e le Quick Actions. Lo facciamo attraverso un code fix, quindi ci spostiamo nel file CodeFixProvider.cs. Ecco la prima parte:

 

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DateTimeAnalyzerCodeFixProvider)), Shared]

    public class DateTimeAnalyzer_CSCodeFixProvider : CodeFixProvider

    {

        private const string title = "Replace with DateTimeOffset";

 

        public sealed override ImmutableArray<string> FixableDiagnosticIds

        {

            get { return ImmutableArray.Create(DateTimeAnalyzerAnalyzer.DiagnosticId); }

        }

 

        public sealed override FixAllProvider GetFixAllProvider()

        {

            return WellKnownFixAllProviders.BatchFixer;

        }

 

Un code fix dev’essere decorato con l’attributo ExportCodeFixProvider, passando il nome del linguaggio destinatario e il suo stesso nome (che sarà visibile all’esterno), inoltre deve ereditare da Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider. Il campo title rappresenta la dicitura che vedremo nelle Quick Actions. La proprietà FixableDiagnosticIds e il metodo GetFixAllProvider restituiscono, rispettivamente, un array di analyzer (tramite DiagnosticId) risolvibili con questo code fix e un’implementazione standard per la risoluzione di tutti i conflitti diagnostici. La parte successiva è costituita da un metodo chiamato RegisterCodeFixesAsync, all’interno del quale posso registrare una (o più) quick action. Eccolo:

 

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)

        {

            // Get the root syntax node for the current document

            var root = await context.Document.

                GetSyntaxRootAsync(context.CancellationToken).

                ConfigureAwait(false);

 

            // Get a reference to the diagnostic to fix

            var diagnostic = context.Diagnostics.First();

            // Get the location in the code editor for the diagnostic

            var diagnosticSpan = diagnostic.Location.SourceSpan;

 

            // Find the syntax node on the span

            // where there is a squiggle

            var node = root.FindNode(context.Span);

 

            // If the syntax node is not an IdentifierName

            // return

            if (node is IdentifierNameSyntax == false)

            {

                return;

            }

 

            // Register a code action that invokes the fix

            // on the current document

            context.RegisterCodeFix(

            CodeAction.Create(title:title,

                              createChangedDocument:

                              c=> ReplaceDateTimeAsync(context.Document,

                              node, c),

                              equivalenceKey:title), diagnostic);

        }

Il primo passo è ottenere il syntax node radice del file di codice corrente. Il file di codice è un oggetto Document e il suo metodo GetSyntaxRootAsync ne ottiene la radice. Si ottiene poi un riferimento al diagnostic, ossia la regola inviata dall’analyzer e rilevata come violata nel codice, e si ottiene la sua posizione all’interno del sorgente. Lo span, che rappresenta la posizione del diagnostic nel codice, viene poi utilizzato per ottenere il nodo sintattico in quella specifica posizione. Se tale nodo non è quello di nostro interesse, ossia un IdentifierNameSyntax, usciamo dal metodo. Diversamente registriamo un code fix mediante RegisterCodeFix, al quale viene passato il metodo CodeAction.Create che genera una quick action con il titolo, un delegate che esegue la correzione e un identificatore univoco (equivalenceKey). Ed eccoci ora al punto cruciale, l’esecuzione della correzione, che viene eseguita da un metodo che ho chiamato ReplaceDateTimeAsync. Vediamone la prima parte:

 

private async Task<Document> ReplaceDateTimeAsync(Document document,

                         SyntaxNode node,

                         CancellationToken cancellationToken)

        {

            // Get the root syntax node for the current document

            var root = await document.GetSyntaxRootAsync();

 

            // Convert the syntax node into the specialized kind

            var convertedNode = (IdentifierNameSyntax)node;

Si lavora sull’intero file di codice, per questo un argomento è di tipo Document e il tipo di ritorno pure. Poi si lavora sul nodo sintattico da sostituire, per questo prendiamo un argomento di tipo SyntaxNode. Otteniamo dapprima il SyntaxNode radice (GetSyntaxRootAsync) e poi convertiamo il nodo che arriva al metodo in un IdentifierNameSyntax (ci aspettiamo che sia questo il tipo che arriva). Proseguiamo con la parte centrale:

 

// Create a new syntax node

            var newNode = convertedNode?.WithIdentifier(SyntaxFactory.

                          ParseToken("DateTimeOffset")).

                          WithLeadingTrivia(node.GetLeadingTrivia()).

                          WithTrailingTrivia(node.GetTrailingTrivia());

 

            // Create a new root syntax node for the current document

            // replacing the syntax node that has diagnostic with

            // a new syntax node

            var newRoot = root.ReplaceNode(node, newNode);

 

            // Generate a new document

            var newDocument = document.WithSyntaxRoot(newRoot);

            return newDocument;

        }

    }

Come detto, un syntax node è immutabile quindi non posso modificarlo, ma devo crearne uno nuovo e sostituire il vecchio. In questo caso ne creo uno nuovo chiamato newNode partendo dal vecchio, convertedNode. A seconda del tipo di nodo (in questo caso IdentifierNameSyntax), ci sono dei metodi che mi permettono di riprendere membri del vecchio nodo senza doverli riscrivere e che tipicamente iniziano con With. In questo caso sto dicendo di riprendere l’intero nodo vecchio, con la posizione degli spazi bianchi iniziali e intermedi (WithLeadingTrivia e WithTrailingTrivia), ma con un nuovo identificatore. Questo nuovo identificatore lo genero mediante una classe chiamata SyntaxFactory, che vi invito fortemente a studiare insieme a un’altra classe chiamata SyntaxGenerator. Sono queste due classi, infatti, che ci permettono di geneare nuovi syntax node nel modo corretto per il compilatore. In questo caso sto usando un metodo chiamato ParseToken, che converte il testo passato sotto forma di oggetto SyntaxToken. Quest’ultimo è accettato da WithIdentifier per generare un nuovo IdentifierNameSyntax. In questo esempio abbiamo fatto una code generation con SyntaxFactory davvero molto semplice ed essenziale, ma sappiate che nella maggior parte dei casi è davvero complessa e articolata. Il beneficio, però, è quello di poter sfruttare la potenza dei compilatori e magari l’integrazione con Visual Studio.

 

Test e debug dell’analyzer

 

Per vedere come funziona il nostro analyzer, impostate come progetto di avvio quello chiamato DateTimeAnalyzer.vsix e premete F5. Visual Studio 2015 compilerà un’estensione per l’IDE in formato vsix e avvierà l’istanza sperimentale di Visual Studio 2015, all’interno della quale creerete un nuovo progetto Universal Windows. Provate a dichiarare un oggetto di tipo DateTime e vedrete come la finestra Error List e il Light Bulb vi forniranno rispettivamente informazioni e suggerimenti per la risoluzione del problema:

 

image

 

I code refactoring funzionano in modo analogo, sebbene non ci sarà un analyzer ma piuttosto una speciale regola di riscrittura dei syntax node da noi desiderati.

Cenni sulla pubblicazione e distribuzione

 

Analyzer e refactoring possono chiaramente essere distribuiti e condivisi con altri. Il progetto Analyzer + Code Fix supporta sia la generazione automatizzata di un pacchetto NuGet che la compilazione dell’installer vsix visto per il debug, che non serve solo a questo proposito ma anche per la pubblicazione sulla Visual Studio Gallery. La differenza sta che il pacchetto NuGet sarà disponibile sulla solution o sul progetto, mentre il pacchetto vsix renderà l’analyzer sempre disponibile. Per quanto riguarda i refactoring, il template di progetto automatizza la sola generazione del pacchetto vsix, ma è possibile generare un pacchetto NuGet aggiungendo un item template di tipo Refactoring ad un progetto di tipo Analyzer.

Ulteriori Risorse

 

Abbiamo solamente grattato la superficie di Roslyn, perché c’è un mondo ben più vasto e ben più ricco di possibilità. Ad ogni modo vi lascio un paio di risorse utili:

  • Pagina ufficiale di .NET Compiler Platform su GitHub che contiene il codice dei compilatori, documentazione ed esempi
  • Il mio ebook gratuito Roslyn Succinctly nel quale troverete parecchie informazioni anche sulle Workspace API e sulla distribuzione.

Vi segnalo, infine, un mio articolo apparso su MSDN Magazine e che riguarda la pubblicazione su NuGet di analyzer scritti per le proprie librerie.