Как работает завершение кода?

У многих редакторов и IDE есть завершение кода. Некоторые из них очень "умные", другие на самом деле не такие. Меня интересует более интеллектуальный тип. Например, я видел IDE, которые предлагают только функцию, если она a) доступна в текущей области b) ее возвращаемое значение действительно. (Например, после "5 + foo [tab]" он предлагает только функции, которые возвращают то, что может быть добавлено к целым числам или именам переменных правильного типа.) Я также видел, что они помещают чаще используемый или самый длинный вариант вперед списка.

Я понимаю, что вам нужно разобрать код. Но обычно при редактировании текущего кода недопустимы, в нем есть синтаксические ошибки. Как вы разбираете что-то, когда оно неполное и содержит ошибки?

Существует также ограничение по времени. Завершение бесполезно, если потребуется несколько секунд, чтобы составить список. Иногда алгоритм завершения имеет дело с тысячами классов.

Каковы хорошие алгоритмы и структуры данных для этого?

Ответ 1

Механизм IntelliSense в моем сервисном продукте для языка UnrealScript является сложным, но я расскажу здесь как можно лучше. Служба языка С# в VS2008 SP1 - это моя цель производительности (по уважительной причине). Это еще не так, но это быстро/достаточно точно, что я могу смело предлагать предложения после ввода одного символа, не дожидаясь ctrl + space или пользователя, набравшего . (точка). Чем больше информации люди [работающие над лингвистическими услугами] об этом узнают, тем лучше опыт конечного пользователя, который я получаю, должен ли я когда-либо использовать их продукты. Есть ряд продуктов, с которыми я столкнулся с неудачным опытом работы с ними, которые не уделяли такого пристального внимания деталям, и в результате я боролся с IDE больше, чем я был.

В моем языковом сервисе он выглядит следующим образом:

  • Получить выражение в курсоре. Это происходит с начала выражения доступа к члену до конца идентификатора, по которому курсор завершен. Выражение доступа к члену обычно находится в форме aa.bb.cc, но также может содержать вызовы методов, как в aa.bb(3+2).cc.
  • Получить контекст, окружающий курсор. Это очень сложно, потому что это не всегда соответствует тем же правилам, что и компилятор (длинный рассказ), но, предположим, это так. Как правило, это означает получение кэшированной информации о методе/классе, в котором находится курсор.
  • Скажите, что объект контекста реализует IDeclarationProvider, где вы можете вызвать GetDeclarations(), чтобы получить IEnumerable<IDeclaration> всех элементов, видимых в области. В моем случае этот список содержит locals/parameters (если в методе), члены (поля и методы, статические, только если в методе экземпляра и без частных членов базовых типов), глобальные (типы и константы для языка я "Я работаю" ) и ключевые слова. В этом списке будет элемент с именем aa. В качестве первого шага оценки выражения в # 1 мы выбираем элемент из перечисления контекста с именем aa, предоставляя нам IDeclaration для следующего шага.
  • Затем я применяю оператор к IDeclaration, представляющему aa, чтобы получить еще один IEnumerable<IDeclaration>, содержащий "членов" (в некотором смысле) aa. Поскольку оператор . отличается от оператора ->, я вызываю declaration.GetMembers(".") и ожидаю, что объект IDeclaration правильно применит указанный оператор.
  • Это продолжается до тех пор, пока я не удалю cc, где список объявлений может содержать или не содержать объект с именем cc. Как я уверен, вы знаете, что если несколько элементов начинаются с cc, они также должны появиться. Я решаю это, беря окончательное перечисление и передавая его через мой документированный алгоритм, чтобы предоставить пользователю самую полезную информацию.

Вот некоторые дополнительные примечания для бэкэнд IntelliSense:

  • Я широко использую ленивые механизмы оценки LINQ при реализации GetMembers. Каждый объект в моем кеше способен обеспечить функтор, который оценивает его членов, поэтому выполнение сложных действий с деревом почти тривиально.
  • Вместо каждого объекта, содержащего List<IDeclaration> его членов, я сохраняю a List<Name>, где Name - это структура, содержащая хэш специально отформатированной строки, описывающей элемент. Там огромный кеш, который отображает имена объектов. Таким образом, при повторном анализе файла я могу удалить все элементы, объявленные в файле из кэша, и повторно заполнить его обновленными членами. Благодаря тому, как сконфигурированы функторы, все выражения сразу же оценивают новые элементы.

Интерфейс IntelliSense

По мере того, как пользователь вводит данные, файл является синтаксически неправильным чаще, чем правильным. Таким образом, я не хочу случайно удалять разделы кеша, когда пользователь набирает. У меня есть большое количество специальных правил, позволяющих обрабатывать инкрементные обновления как можно быстрее. Инкрементный кеш сохраняется только в открытом файле и помогает гарантировать, что пользователь не понимает, что их ввод приводит к тому, что бэкэнд-кеш содержит неверную информацию о строках и столбцах для таких вещей, как каждый метод в файле.

  • Один фактор погашения - мой парсер быстро. Он может обрабатывать полное обновление кеша 20000 строк исходного файла в 150 мс при автономной работе в фоновом потоке с низким приоритетом. Всякий раз, когда этот парсер успешно завершает передачу открытого файла (синтаксически), текущее состояние файла перемещается в глобальный кеш.
  • Если файл не является синтаксически правильным, я использую ANTLR filter parser (извините за ссылку - большая часть информации находится в списке рассылки или собрана от чтения источник), чтобы пересмотреть файл, который ищет:
    • Объявления переменных/полей.
    • Подпись для определений класса/структуры.
    • Подпись для определений методов.
  • В локальном кэше определения класса/структуры/метода начинаются с подписи и заканчиваются, когда уровень вставки привязки возвращается к четному. Методы также могут закончиться, если достигнуто другое объявление метода (нет методов вложенности).
  • В локальном кэше переменные/поля связаны с непосредственно предшествующим незакрытым элементом. Ниже приведен фрагмент краткого кода ниже для примера того, почему это важно.
  • Кроме того, по мере того, как пользователь вводит, я сохраняю таблицу переназначения, обозначающую добавленные/удаленные диапазоны символов. Это используется для:
    • Убедившись, что я могу определить правильный контекст курсора, поскольку метод может/может перемещаться в файле между полными разборами.
    • Убедитесь, что Go To Declaration/Definition/Reference правильно находит элементы в открытых файлах.

Фрагмент кода для предыдущего раздела:

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

Я решил, что добавлю список функций IntelliSense, которые я реализовал с помощью этого макета. Изображения каждого из них находятся здесь.

  • Автозаполнение
  • Советы по использованию инструментов
  • Советы по методу
  • Просмотр класса
  • Окно определения кода
  • Вызов браузера (VS 2010, наконец, добавляет его в С#)
  • Семантически корректный поиск всех ссылок

Ответ 2

Я не могу точно сказать, какие алгоритмы используются какой-либо конкретной реализацией, но я могу сделать некоторые обоснованные предположения. A trie - очень полезная структура данных для этой проблемы: среда IDE может поддерживать большой объем памяти всех символов в вашем проекте, с некоторыми дополнительными метаданными в каждом node.

Когда вы вводите символ, он переходит по пути в trie. Все потомки конкретного trie node являются возможными пополнениями. После этого IDE нужно просто отфильтровать те, которые имеют смысл в текущем контексте, но нужно всего вычислить столько, сколько может отображаться во всплывающем окне завершения табуляции.

Для более продвинутого заполнения вкладки требуется более сложное управление. Например, Visual Assist X имеет функцию, при которой вам нужно только ввести заглавные буквы символов CamelCase - например, если вы набираете SFN, это показывает вам символ SomeFunctionName в окне завершения табуляции.

Вычисление trie (или других структур данных) требует разбора всего вашего кода, чтобы получить список всех символов в вашем проекте. Visual Studio хранит это в своей базе данных IntelliSense, файле .ncb, хранящемся рядом с вашим проектом, так что ему не нужно повторять все при каждом закрытии и повторном открытии вашего проекта. В первый раз, когда вы открываете большой проект (скажем, один из которых вы только синхронизировали управление исходным кодом формы), VS займет время, чтобы разобрать все и сгенерировать базу данных.

Я не знаю, как он обрабатывает инкрементные изменения. Как вы сказали, когда вы пишете код, это недействительный синтаксис в 90% случаев, и всякий раз, когда вы простаиваете, вы получаете огромный налог на свой процессор за очень небольшую выгоду, особенно если вы изменяете заголовочный файл, включенный в большое количество исходных файлов.

Я подозреваю, что он либо (а) только репарации всякий раз, когда вы действительно строите свой проект (или, возможно, когда вы его закрываете/открываете), или (б) он выполняет какой-то локальный синтаксический анализ, где он только анализирует код, где вы "Мы только что отредактировали ограниченным образом, просто чтобы получить имена соответствующих символов. Поскольку С++ имеет такую ​​чрезвычайно сложную грамматику, она может вести себя странно в темных углах, если вы используете тяжелое метапрограммирование шаблона и т.п.