Translate

miércoles, 4 de enero de 2023

Uso de Roslyn C# Completion Service programáticamente


Roslyn es un compilador C# como servicio seria CaaS 

El CompletionService de Roslyn inicialmente no estaba disponible públicamente, y la única forma de lograr mediante programación una experiencia similar a Intellisense era utilizar el servicio de recomendación bastante limitado. CompletionService finalmente se expuso públicamente en Roslyn 1.3.0.

Vamos a hacer un ejemplo para ver lo poderoso que puede ser esto. Para empezar, necesitaremos hacer referencia a 2 paquetes de Roslyn Nuget, como se muestra a continuación (nuestro archivo csproj de prueba):


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>

    <OutputType>Exe</OutputType>

    <LangVersion>latest</LangVersion>

    <TargetFramework>netcoreapp2.1</TargetFramework>

  </PropertyGroup>


  <ItemGroup>

    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="2.10.0" />

    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" />

  </ItemGroup>

</Project>


Y necesitamos algún código para probar nuestras funciones, el siguiente ejemplo sirve: 


using System;


public class MyClass

{

    public static void MyMethod(int value)

    {

        Guid.

    }

}


Entonces, si imaginamos la experiencia del usuario aquí, nos ponemos en el lugar del usuario, escribimos Guid, presionamos un . carácter y ahora están esperando todos los métodos relevantes en este contexto particular.

En Visual Studio/Visual Studio Code (a través de OmniSharp), esta lista se mostraría automáticamente. En caso de que se haya cerrado, por ejemplo, al presionar la tecla ESC, siempre puede volver a activarlo presionando ctrl+j (VS) o ctrl/cmd+espacio (VS Code).

Para empezar, conectemos los servicios MEF de Roslyn, ya que deben completarse para que los servicios del lenguaje C# funcionen correctamente.

La forma más fácil es simplemente usar el conjunto predeterminado:

var host = MefHostServices.Create(MefHostServices.DefaultAssemblies);

Los siguientes asemblies están incluidos en el conjunto predeterminado:

  • “Microsoft.CodeAnalysis.Workspaces”,
  • “Microsoft.CodeAnalysis.CSharp.Workspaces”,
  • “Microsoft.CodeAnalysis.VisualBasic.Workspaces”,
  • “Microsoft.CodeAnalysis.Features”,
  • “Microsoft.CodeAnalysis.CSharp.Features”,
  • “Microsoft.CodeAnalysis.VisualBasic.Features”
  • Si tiene alguna extensión para Roslyn o características personalizadas de Roslyn de terceros que le gustaría incluir, deberá pasar esos ensamblajes adicionales manualmente.

    Una vez que los servicios de MEF están conectados, el siguiente paso es crear un espacio de trabajo. En esta demostración, solo usaremos un AdHocWorkspace.

    Para escenarios más complicados, como, por ejemplo, poder proporcionar inteligencia completa a una solución del mundo real o proyecto (s) de csproj, deberíamos de usar Microsoft.CodeAnalysis.Workspaces.MSBuild 

    Así que para nosotros el código será bastante simple:

    var workspace = new AdhocWorkspace(host);

    Una vez que el espacio de trabajo está allí, debemos crear un documento que contenga nuestro código y luego un proyecto al que agregaremos nuestro documento (o documentos, si tenemos más). Luego, el proyecto debe agregarse al espacio de trabajo. Nuevamente, en el caso de MsBuildWorkspace, esto se puede hacer automáticamente leyendo los archivos del proyecto y su estructura.

    En nuestro ejemplo, las cosas se verían así:


    var code = @"using System;


    public class MyClass

    {

        public static void MyMethod(int value)

        {

            Guid.

        }

    }";


    var projectInfo = ProjectInfo.Create(ProjectId.CreateNewId(), VersionStamp.Create(), "MyProject", "MyProject", LanguageNames.CSharp).

       WithMetadataReferences(new[]

       { 

           MetadataReference.CreateFromFile(typeof(object).Assembly.Location)

       });

    var project = workspace.AddProject(projectInfo);

    var document = workspace.AddDocument(project.Id, "MyFile.cs", SourceText.From(code));


    Para crear un proyecto, primero creamos un ProjectInfo, donde debemos pasar cosas como el nombre del proyecto, el nombre del ensamblado (en nuestro caso, "MyProject"), el tipo de lenguaje y un par de cosas más relevantes para la compilación, principalmente las MetadataReferences necesarias para que se construya nuestro código. En nuestro caso, es realmente solo el corlib, o en otras palabras, el ensamblaje del objeto.

    Se crea una instancia de Proyecto para nosotros como resultado de agregar ProjectInfo al espacio de trabajo. De manera similar, también se crea una instancia de documento para nosotros cuando solicitamos que se agregue un código basado en cadenas determinado al área de trabajo, en el contexto de una ID de proyecto determinada.

    Una vez que tenemos el Documento que es parte del Área de trabajo, podemos comenzar a usar el Servicio. Tiene un método de fábrica que podemos usar, y solo necesitamos pasar en la posición relevante en el código (para nosotros Guid.) para que las terminaciones comiencen a aparecer:


    // position is the last occurrence of "Guid." in our test code

    // in real life scenarios the editor surface should inform us

    // about the current cursor position

    var position = code.LastIndexOf("Guid.") + 5;

    var completionService = CompletionService.GetService(document);

    var results = await completionService.GetCompletionsAsync(document, position);


    En ese punto obtenemos nulo si no hay finalizaciones que tengan sentido para el compilador, o una buena lista de elementos de finalizaciones que podemos inspeccionar y mostrar al usuario.

    Los elementos se agrupan en diferentes categorías, representadas por etiquetas. Esto puede ayudar al editor a visualizar con qué tipo de elemento de finalización se trata. Estos podrían ser, por ejemplo, el tipo de símbolo (campo, método, etc.), el nivel de accesibilidad (público, interno, etc.) o cualquier otra información (¿es una palabra clave del lenguaje? ¿Quizás por un parámetro en el ámbito dado? etc.).

    Cada elemento de finalización se nos proporciona con DisplayText, que el editor debe usar para presentar la sugerencia al usuario, y SortText, que se puede usar para clasificar, así como otros metadatos útiles adicionales.

    Cada elemento de finalización se origina en un determinado CompletionProvider, porque el propio servicio de finalización en realidad agrega los resultados de un conjunto de esos proveedores que están conectados al host MEF. Algunos de ellos son muy especializados, por ejemplo, OverrideCompletionProvider, que permite proporcionar terminaciones cuando el usuario escribe anular.

    Dado que CompletionProvider es público, también puede implementar el suyo propio y personalizar completamente el proceso de finalización en Roslyn.

    De todos modos, por ahora, imprimamos lo que obtuvimos de Roslyn en esta configuración de demostración en particular. 


    foreach (var i in results.Items)

    {

        Console.WriteLine(i.DisplayText);


        foreach (var prop in i.Properties)

        {

            Console.Write($"{prop.Key}:{prop.Value}  ");

        }


        Console.WriteLine();

        foreach (var tag in i.Tags)

        {

            Console.Write($"{tag}  ");

        }


        Console.WriteLine();

        Console.WriteLine();

    }


    Si ejecutamos nuestro programa ahora, la salida debería verse así:


    Empty

    ContextPosition:166  SymbolKind:6  InsertionText:Empty  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:Empty

    Field  Public


    Equals

    ContextPosition:166  SymbolKind:9  InsertionText:Equals  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:Equals

    Method  Public


    NewGuid

    ContextPosition:166  SymbolKind:9  InsertionText:NewGuid  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:NewGuid

    Method  Public


    Parse

    ContextPosition:166  SymbolKind:9  InsertionText:Parse  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:Parse

    Method  Public


    ParseExact

    ContextPosition:166  SymbolKind:9  InsertionText:ParseExact  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:ParseExact

    Method  Public


    ReferenceEquals

    ContextPosition:166  SymbolKind:9  InsertionText:ReferenceEquals  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:ReferenceEquals

    Method  Public


    TryParse

    ContextPosition:166  SymbolKind:9  InsertionText:TryParse  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:TryParse

    Method  Public


    TryParseExact

    ContextPosition:166  SymbolKind:9  InsertionText:TryParseExact  Provider:Microsoft.CodeAnalysis.CSharp.Completion.Providers.SymbolCompletionProvider  SymbolName:TryParseExact

    Method  Public


    Tenga en cuenta que obtenemos todos los métodos/propiedades/campos relevantes que esperaríamos. Cada elemento de finalización tiene la información adicional en las etiquetas. Además, hay algunos metadatos adicionales en el diccionario de propiedades, como por ejemplo el tipo de proveedor que se usó para proporcionar esta finalización (aunque en nuestro caso, todos terminan siendo de SymbolCompletionProvider).

    Hasta ahora, el enfoque se basaba en código C# completo. Esto funciona para la mayoría de los escenarios, sin embargo, para situaciones en las que no se trata de proyectos de C# completos o archivos de código, sino que le gustaría completar fragmentos ligeros, no sería aplicable.

    Imagine que queremos obtener exactamente el mismo conjunto de resultados de finalización que el anterior, pero ya cuando el usuario simplemente escribió:

    Guid.

    Entonces, sin clase, sin método, sin usos, solo 5 caracteres. Esto es especialmente atractivo para experiencias similares a REPL o pequeños fragmentos de código incrustados en alguna parte.

    Resulta que este nivel de finalización también se puede lograr con Roslyn, cuando pasamos a su modo de "guión".

    Fundamentalmente, nuestro código sería el mismo, crearíamos el host MEF y el espacio de trabajo de la misma manera. Sin embargo, al crear el Proyecto y el Documento, debemos decirle al compilador que analice nuestro código como el llamado SourceCodeKind.Script. Esto se muestra a continuación:


    var scriptCode = "Guid.N";


    var compilationOptions = new CSharpCompilationOptions(

       OutputKind.DynamicallyLinkedLibrary,

       usings: new[] { "System" });

                       

    var scriptProjectInfo = ProjectInfo.Create(ProjectId.CreateNewId(), VersionStamp.Create(), "Script", "Script", LanguageNames.CSharp, isSubmission: true)

       .WithMetadataReferences(new[] 

       { 

           MetadataReference.CreateFromFile(typeof(object).Assembly.Location) 

       })

       .WithCompilationOptions(compilationOptions);


    var scriptProject = workspace.AddProject(scriptProjectInfo);

    var scriptDocumentInfo = DocumentInfo.Create(

        DocumentId.CreateNewId(scriptProject.Id), "Script",

        sourceCodeKind: SourceCodeKind.Script,

        loader: TextLoader.From(TextAndVersion.Create(SourceText.From(scriptCode), VersionStamp.Create())));

    var scriptDocument = workspace.AddDocument(scriptDocumentInfo);


    // cursor position is at the end

    var position = scriptCode.Length - 1;


    var completionService = CompletionService.GetService(scriptDocument);

    var results = await completionService.GetCompletionsAsync(scriptDocument, position);


    Las API que llamamos son en su mayoría las mismas, con las mismas pequeñas diferencias que son las siguientes:

    – necesitamos crear una versión personalizada de CSharpCompilationOptions, que luego pasamos a nuestro ProjectInfo. Esto es necesario para que configuremos un proyecto con un conjunto global de instrucciones de uso. En nuestro caso, es solo System (para que el usuario no tenga que escribir System.Guid.), pero podría ser más para permitir esta experiencia de finalización sin importación para una gama más amplia de tipos.

    – al crear nuestro Documento, configuramos un DocumentInfo extra. La relación entre ellos es la misma que entre Project y ProjectInfo. Esto será necesario para pasar del analizador C# normal al script. Nos permitirá procesar nuestro código sintácticamente relajado sin encontrarnos con los errores del compilador que de otro modo nos bloquearían.

    Y eso es todo: el resultado aquí es exactamente el mismo que antes, excepto que logramos proporcionar las terminaciones utilizando la entrada de C# de una sola línea, liviana y de poca ceremonia, que creo que es bastante impresionante.

    Espero que esto haya sido interesante, y tal vez incluso un poco útil. Avíseme si desea profundizar en este tema o conocer otros componentes básicos de Roslyn, especialmente en el área de servicios de lenguaje C#.