¿Cómo funciona la finalización de código?

84

Muchos editores e IDE completan el código. Algunos de ellos son muy "inteligentes", otros no lo son realmente. Me interesa el tipo más inteligente. Por ejemplo, he visto IDE que solo ofrecen una función si a) está disponible en el alcance actual b) su valor de retorno es válido. (Por ejemplo, después de "5 + foo [tab]", solo ofrece funciones que devuelven algo que se puede agregar a un número entero o nombres de variable del tipo correcto). También he visto que colocan la opción más usada o más larga delante de la lista.

Me doy cuenta de que necesitas analizar el código. Pero normalmente, aunque la edición del código actual no es válida, hay errores de sintaxis en él. ¿Cómo se analiza algo cuando está incompleto y contiene errores?

También existe una limitación de tiempo. La finalización es inútil si se necesitan segundos para hacer una lista. A veces, el algoritmo de finalización se ocupa de miles de clases.

¿Cuáles son los buenos algoritmos y estructuras de datos para esto?

stribika
fuente
1
Buena pregunta. Es posible que desee echar un vistazo al código de algunos de los IDE de código abierto que implementan esto, como Code :: Blocks en codeblocks.org .
1
Aquí está el artículo para crear finalización de código en C # Creación de finalización de código en C #
Pritam Zope

Respuestas:

64

El motor IntelliSense en mi producto de servicio de lenguaje UnrealScript es complicado, pero daré una descripción general lo mejor que pueda aquí. El servicio de lenguaje C # en VS2008 SP1 es mi objetivo de rendimiento (por una buena razón). Todavía no está ahí, pero es lo suficientemente rápido / preciso como para ofrecer sugerencias de forma segura después de escribir un solo carácter, sin esperar a que ctrl + espacio o que el usuario escriba un .(punto). Cuanta más información reciba la gente [que trabaja en servicios lingüísticos] sobre este tema, mejor será la experiencia del usuario final si alguna vez utilizo sus productos. Hay una serie de productos con los que he tenido la desafortunada experiencia de trabajar que no prestaron tanta atención a los detalles y, como resultado, estaba peleando con el IDE más de lo que estaba codificando.

En mi servicio de idiomas, se presenta de la siguiente manera:

  1. Obtenga la expresión en el cursor. Esto va desde el principio de la expresión de acceso a miembros hasta el final del identificador sobre el que se encuentra el cursor. La expresión de acceso a miembros generalmente tiene el formato aa.bb.cc, pero también puede contener llamadas a métodos como en aa.bb(3+2).cc.
  2. Obtenga el contexto que rodea al cursor. Esto es muy complicado, porque no siempre sigue las mismas reglas que el compilador (historia larga), pero aquí se supone que sí. Generalmente, esto significa obtener la información en caché sobre el método / clase dentro del cual se encuentra el cursor.
  3. Digamos que el objeto de contexto se implementa IDeclarationProvider, donde puede llamar GetDeclarations()para obtener uno IEnumerable<IDeclaration>de todos los elementos visibles en el alcance. En mi caso, esta lista contiene los parámetros locales (si están en un método), miembros (campos y métodos, solo estáticos a menos que estén en un método de instancia, y no miembros privados de tipos base), globales (tipos y constantes para el lenguaje que en el que estoy trabajando) y palabras clave. En esta lista habrá un elemento con el nombre aa. Como primer paso para evaluar la expresión en el n. ° 1, seleccionamos el elemento de la enumeración de contexto con el nombre aa, lo que nos da un IDeclarationpara el siguiente paso.
  4. A continuación, aplico el operador a la IDeclarationrepresentación aapara obtener otro que IEnumerable<IDeclaration>contenga los "miembros" (en cierto sentido) de aa. Dado que el .operador es diferente del ->operador, llamo declaration.GetMembers(".")y espero que el IDeclarationobjeto aplique correctamente el operador enumerado.
  5. Esto continúa hasta que golpeo cc, donde la lista de declaraciones puede contener o no un objeto con el nombre cc. Como estoy seguro de que sabe, si varios elementos comienzan con cc, también deberían aparecer. Resuelvo esto tomando la enumeración final y pasándola a través de mi algoritmo documentado para proporcionar al usuario la información más útil posible.

Aquí hay algunas notas adicionales para el backend de IntelliSense:

  • Hago un uso extensivo de los mecanismos de evaluación perezosa de LINQ en la implementación GetMembers. Cada objeto en mi caché puede proporcionar un functor que evalúa a sus miembros, por lo que realizar acciones complicadas con el árbol es casi trivial.
  • En lugar de que cada objeto mantenga uno List<IDeclaration>de sus miembros, mantengo un List<Name>, donde Namees una estructura que contiene el hash de una cadena con formato especial que describe el miembro. Hay un caché enorme que asigna nombres a objetos. De esta forma, cuando vuelvo a analizar un archivo, puedo eliminar todos los elementos declarados en el archivo del caché y volver a llenarlo con los miembros actualizados. Debido a la forma en que se configuran los functores, todas las expresiones se evalúan inmediatamente a los nuevos elementos.

IntelliSense "interfaz"

A medida que el usuario escribe, el archivo es sintácticamente incorrecto con más frecuencia de lo que es correcto. Como tal, no quiero eliminar al azar secciones de la caché cuando el usuario escribe. Tengo una gran cantidad de reglas de casos especiales para manejar las actualizaciones incrementales lo más rápido posible. La caché incremental solo se mantiene local en un archivo abierto y ayuda a garantizar que el usuario no se dé cuenta de que su escritura está causando que la caché de backend contenga información de línea / columna incorrecta para cosas como cada método en el archivo.

  • Un factor positivo es que mi analizador es rápido . Puede manejar una actualización de caché completa de un archivo de origen de 20000 líneas en 150 ms mientras funciona de forma autónoma en un hilo de fondo de baja prioridad. Siempre que este analizador completa una pasada en un archivo abierto con éxito (sintácticamente), el estado actual del archivo se mueve a la caché global.
  • Si el archivo no es sintácticamente correcto, utilizo un analizador de filtro ANTLR (lo siento por el enlace, la mayoría de la información está en la lista de correo o se recopila al leer la fuente) para analizar el archivo en busca de:
    • Declaraciones de variable / campo.
    • La firma para las definiciones de clase / estructura.
    • La firma de las definiciones de métodos.
  • En la caché local, las definiciones de clase / estructura / método comienzan en la firma y terminan cuando el nivel de anidamiento de llaves vuelve a par. Los métodos también pueden finalizar si se alcanza otra declaración de método (sin métodos de anidamiento).
  • En la caché local, las variables / campos están vinculados al elemento no cerrado inmediatamente anterior . Consulte el breve fragmento de código a continuación para ver un ejemplo de por qué esto es importante.
  • Además, a medida que el usuario escribe, mantengo una tabla de reasignación que marca los rangos de caracteres agregados / eliminados. Esto se usa para:
    • Asegurándome de poder identificar el contexto correcto del cursor, ya que un método puede moverse / se mueve en el archivo entre análisis completos.
    • Asegurarse de que Ir a Declaración / Definición / Referencia ubique los elementos correctamente en archivos abiertos.

Fragmento de código de la sección anterior:

class A
{
    int x; // linked to A

    void foo() // linked to A
    {
        int local; // linked to foo()

    // foo() ends here because bar() is starting
    void bar() // linked to A
    {
        int local2; // linked to bar()
    }

    int y; // linked again to A

Pensé que agregaría una lista de las características de IntelliSense que he implementado con este diseño. Las fotos de cada uno se encuentran aquí.

  • Autocompletar
  • Consejos sobre herramientas
  • Consejos sobre métodos
  • Vista de clase
  • Ventana de definición de código
  • Navegador de llamadas (VS 2010 finalmente agrega esto a C #)
  • Semánticamente correcto Buscar todas las referencias
Sam Harwell
fuente
Esto es genial, gracias. Nunca pensé en el sesgo sensible a mayúsculas y minúsculas al ordenar. Me gusta especialmente que puedas lidiar con aparatos que no coinciden.
stribika
15

No puedo decir exactamente qué algoritmos se utilizan en una implementación en particular, pero puedo hacer algunas conjeturas. Un trie es una estructura de datos muy útil para este problema: el IDE puede mantener un gran trie en la memoria de todos los símbolos de su proyecto, con algunos metadatos adicionales en cada nodo.

Cuando escribe un carácter, recorre un camino en el trie. Todos los descendientes de un nodo trie particular son posibles completaciones. Luego, el IDE solo necesita filtrarlos por aquellos que tengan sentido en el contexto actual, pero solo necesita calcular tantos como se puedan mostrar en la ventana emergente de finalización de pestañas.

La finalización de pestañas más avanzada requiere un proceso más complicado. Por ejemplo, Visual Assist X tiene una función mediante la cual solo necesita escribir las letras mayúsculas de los símbolos de CamelCase; por ejemplo, si escribe SFN, le muestra el símbolo SomeFunctionNameen su ventana de finalización de tabulación.

Calcular el trie (u otras estructuras de datos) requiere analizar todo su código para obtener una lista de todos los símbolos en su proyecto. Visual Studio almacena esto en su base de datos IntelliSense, un .ncbarchivo almacenado junto con su proyecto, para que no tenga que analizar todo cada vez que cierra y vuelve a abrir su proyecto. La primera vez que abre un proyecto grande (digamos, uno que acaba de sincronizar el control de fuente del formulario), VS se tomará el tiempo para analizar todo y generar la base de datos.

No sé cómo maneja los cambios incrementales. Como dijiste, cuando escribes código, la sintaxis es inválida el 90% del tiempo, y analizar todo cada vez que estás inactivo supondría un gran impuesto sobre tu CPU por muy poco beneficio, especialmente si estás modificando un archivo de encabezado incluido por una gran cantidad de archivos fuente.

Sospecho que (a) solo repara cada vez que realmente construye su proyecto (o posiblemente cuando lo cierra / abre), o (b) realiza algún tipo de análisis local donde solo analiza el código donde acaba de editado de forma limitada, solo para obtener los nombres de los símbolos relevantes. Dado que C ++ tiene una gramática tan extraordinariamente complicada, puede comportarse de manera extraña en los rincones oscuros si está utilizando metaprogramación de plantilla pesada y similares.

Adam Rosenfield
fuente
El intento es una muy buena idea. En cuanto a los cambios incrementales, es posible que primero intente volver a analizar el archivo cuando eso no funcione ignore la línea actual y cuando eso no funcione ignore el bloque {...} adjunto. Si todo lo demás falla, use la última base de datos.
stribika