Редактор с подсветкой кода. Проблемы и решения

    image
    Большая часть аудитории Хабра регулярно пишет код. В текстовом редакторе или IDE. И сколько бы не было в нем окошек и менюшек, а сердце любого редактора — это компонент (widget), который редактирует и подсвечивает код.

    Больше года назад на Хабре был цикл статей от namespace про компонент QScintilla (1, 2, 3), и моя статья с его критикой. Получилась некоторая недосказанность. Ясно, что все плохо, но не понятно, что делать.

    Сейчас я написал свой велосипед компонент под названием Qutepart, и у цикла есть продолжение.
    Эта статья расскажет о том, как устроена подсветка синтаксиса в моем проекте: какие проблемы возникали, и как они решались. Она про подходы, а не про специфику конкретного GUI-инструментария. Если интересно заглянуть «под капот» текстового редактора, добро пожаловать под кат.

    Сразу оговорюсь, что это не фундаментальная научная статья. Это мой опыт. Будет очень интересно почитать в комментариях про альтернативные подходы и решения.

    Мотивация

    Я делаю текстовый редактор в духе vim и emacs. Универсальный, кроссплатформенный, расширяемый, ориентированный на опытных пользователей. Но с чуть более интуитивным и современным GUI на PyQt. И для этого мне нужен редактор кода.
    Вариантов нашлось всего 2: QScintilla и katepart.
    Изначально я использовал QScintilla, но из-за недостатков, перечисленных здесь, решил от него отказаться.
    katepart очень хорош, но не устраивает тем, что зависит от библиотек KDE. А иметь их в зависимостях не очень удобно, особенно для Windows и MacOS.

    Разбор кода

    Мой проект не ориентирован на какую-то конкретную технологию, языков хочется подсвечивать много. Больше, чем я в состоянии освоить сам. Поэтому было решено использовать существующую базу описаний синтаксиса от katepart.

    Для каждого из поддерживаемых языков программирования в katepart существует Highlight Definition — XML-файл с описанием синтаксиса и подсветки.
    Highlight Definition описывает нечто вроде конечного автомата. Автомат последовательно разбирает текст, меняя при этом свое состояние — «Context». У каждого из контекстов есть набор правил, по которому нужно перейти в другой контекст. Автомат помнит, из какого состояния он перешел в текущее, и может возвращаться в предыдущие контексты (стек контекстов).
    Пример: C++. В контексте код есть правило: если встретился символ " — перейти в контекст «строка». Контекст «строка» подсвечивает символы красным цветом и подержит правило: если встретился символ " — вернуться в предыдущий контекст.
    Для каждого из контекстов задан стиль, которым он подсвечивает текст.

    Система очень удобная и универсальная. Формат файлов хорошо документирован. Наверное, именно поэтому katepart подсвечивает около 2 сотен языков и форматов.
    Минусом, на мой взгляд, является лишь то, что интерпретатор, который разбирает код на основе Syntax Definition, в большинстве случаев будет хуже по производительности, чем парсер для конкретного языка программирования.

    Оптимизация


    Когда я написал подсветку синтаксиса, радости моей не было предела. Все работает, все красиво. Все 59 файликов на разных языках из коллекции katepart выглядят правильно и открываются шустро. Ура! Кто сказал, что Python медленный язык?!
    А потом я попробовал открыть большой файлик. Реально большой. И внезапно оказалось, что мой парсер не так уж и быстр. Пришлось браться за оптимизацию.

    Пару часов с профайлером ускорили подсветку в несколько раз. Но, все равно получалось слишком медленно. А пространство для оптимизации было практически исчерпано. Можно было ускорить парсер еще на пару десятков процентов за счет жутко запутанного кода, но такая оптимизация погоды не делает.

    Я стал осваивать написание модулей на C. Это оказалось совсем не сложно, Python к расширениям очень дружелюбен.
    В процессе написания парсера возникла проблема: в C нет регулярных выражений. А зависимости подключать очень не хотелось. Проблема решилась за счет того, что взаимодействие C-Python работает в 2-х направлениях. Python вызывает C для парсинга, а из С дергается функция на Python для того, чтобы проверить регулярное выражение.

    Когда я начал тестировать парсер с расширением, выяснилось, что производительность не существенно отличается от версии на Python. Снова взял профайлер и пошел искать проблему.
    Оказалось, что 90% времени мой парсер вызывает Python для проверки регулярных выражений. Что же, хак не удался. Пришлось использовать внешнюю библиотеку. Так у компонента появилась единственная зависимость — библиотека обработки регулярных выражений на C pcre. С ней производительность получилась вполне приемлемой (цифры будут ниже).

    В итоге в целесообразности использования Python я не разочаровался. Парсер на C — это примерно 1/3 кодовой базы моего компонента. Думаю, по трудозатратам такой гибридный вариант получился легче, чем решение на С++.

    Асинхронная подсветка

    Большинство текстовых файлов довольно небольшие по размеру. Но иногда мне приходилось редактировать исходники, в которых больше 300К строк. Насколько бы не был крут разработчик парсера, и насколько бы не был быстр его язык, а разбираться до конца такой файл будет дольше, чем пользователь согласен ждать.

    katepart подсвечивает код в потоке GUI. И делает это лениво — подсвечивается столько, сколько нужно отрисовать на экране. Такой подход очень хорошо работает, если файл открывается в начале. Однако если перескочить в конец большого файла — GUI просто зависает. Меня такой подход не устроил.


    vim и emacs при необходимости отрисовать конец большого файла разбирают текст с середины. Подход хорош тем, что позволяет не блокировать GUI надолго при подсветке. Но не все так просто. Языки программирования разбираются последовательно. Например, чтобы правильно обработать символ конца многострочного комментария, нужно знать, начался ли комментарий в предыдущей строчке. Получается, что в некоторых случаях парсинг с середины будет выдавать неправильную подсветку (как на скриншоте из vim).

    Сейчас, когда рост тактовых частот перешел в рост количества ядер, часто актуальна оптимизация вычислений за счет разделения по ядрам. Но и здесь возникают проблемы, и не только потому, что парсить файл нужно последовательно.
    Пользователь постоянно редактирует код. Поток GUI обрабатывает нажатия клавиш и меняет документ. Если при этом документ парсится и подсвечивается в потоках, изменения нужно синхронизировать. Инструментарий Qt пока не везде ориентирован на многопоточность, я не нашел способа надежно синхронизировать доступ к документу. Пришлось от потоков отказаться.

    В итоге экспериментов у меня получилось следующее решение: файл разбирается и подсвечивается в потоке GUI по таймеру. Таймер работает 20 милисекунд, потом возвращает управление в main loop для обработки действий пользователя, потом снова вызывается… Если пользователь открыл огромный файл и сразу перескочил в его конец — файл отображается, но без подсветки.
    Код можно править, а подсветка появится чуть позже.

    Инкрементальная подсветка

    Когда пользователь редактирует код — подсвечивать заново текст нужно с того места, которое редактируется, а не с начала. И, как правило, меняется одна или несколько строк.

    В процессе парсинга к каждой строке файла прикрепляется блок метаданных с состоянием парсера. Если текст был изменен, метаданные используются, чтобы начать разбор с конкретной строки.
    Продолжается инткрементальный парсинг до тех пор, пока не будет найдена строка, метаданные которой не изменились после нового парсинга.

    Сравнение производительности

    Сравнение производительности подсветки — дело не благодарное. Уж очень от многих факторов она зависит: железа, версий софта, языка, содержимого конкретного файла, фазы луны,…
    Однако без него статья была бы не полной. Поэтому придется раздел добавить.
    Отказ от ответственности
    Как уже сказано выше, производительность зависит от многих факторов, и при других обстоятельствах картина может коренным образом отличаться.
    Этот раздел — не основная цель статьи. Измерения производились только на одном файле на одном языке, очень поверхностно. Цифры, наблюдения и выводы могут содержать грубые ошибки.
    Поэтому методику измерений не публикую; предлагаю считать, что информацию, приведенную ниже, я выдумал сам исключительно для развлечения аудитории Хабра.
    Приведенная информация ни в коем случае не может быть использована для выводов о том, что текстовый редактор X лучше, чем текстовый редактор Y.

    Я открыл большой C++ файл (364121 строка) в нескольких редакторах, которые мне интересны, и собрал свои наблюдения в эту таблицу.
    Компонент или редактор Время на подсветку всего файла Блокирует GUI Проблемы подсветки
    Qutepart 44 секунды Никогда Открывает файл 3 секунды
    katepart Был убит через 6 минут Пока не подсветит столько, сколько нужно отобразить
    QScintilla 3 секунды Никогда Тормозит при редактировании
    Scintilla 3 секунды Никогда Тормозит при редактировании
    Sublime Text 23 секунды При редактировании, пока не обновит всю изменившуюся подсветку
    gedit 8 секунд Никогда Тормозит при редактировании
    Qt Creator 20 секунд При редактировании висит, пока не обновит всю изменившуюся подсветку
    Ninja IDE 14 секунд При открытии Подсветил только первые 51 строки. Жутко тормозит при редактировании.
    vim Мгновенно Никогда Парсит файл с середины, в некоторых случаях показывает неправильный результат.
    emacs Мгновенно Никогда Парсит файл с середины, в некоторых случаях показывает неправильный результат. Вешается на время около минуты при перемотке вверх.
    Подробная версия таблицы
    Как видим, Qutepart медленнее всех подсвечивает текст. Это закономерно, поскольку используется интерпретируемый язык, связка Python-Qt и интерпретируемые определения синтаксиса в виде XML.
    С другой стороны, высокоуровневый язык и технологии позволяют подсвечивать много языков, не блокировать GUI и не показывать артефактов.
    При работе с реальными файлами в абсолютном большинстве случаев файл открывается уже подсвеченным, и при редактировании пользователь не видит, как обновляется подсветка. Поэтому текущее положение дел меня устраивает и от дальнейшей оптимизации я отказался.

    И что же получилось


    У меня получился компонент для редактирования кода Qutepart и текстовый редактор на его основе Enki.
    Зависит компонент от PyQt и от pcre. Требует сборки модуля расширения на C. Для небольших файлов можно обойтись без расширения и без pcre.
    Из katepart позаимствованы файлы Syntax Definition и алгоритмы выравнивания кода.
    Как и katepart, проект доступен под LGPL.

    Сегодня я выпустил первую версию Qutepart и Enki на его основе, потому что решил, что текущая версия уже лучше, чем вариант на QScintilla. Функциональности пока не много. TODO-list большой. Периодически пополняется за счет пожеланий от пользователей и становится меньше за счет сделанных фич.

    Буду рад отзывам от Хабра-сообщества!

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 19

      +3
      Спасибо, очень интересно!
      Пойду заценю редактор.

      > Таймер работает 20 милисекунд, потом возвращает управление в main loop для обработки действий пользователя, потом снова вызывается…

      А не тормозит от этого интерфейс? Не лучше ли создать для этой цели отдельный поток?

      P. S. Указали бы в начале статьи, что ваш компонент называется Qutepart.
      А то первый раз название встречается в таблице.
        +2
        > Пойду заценю редактор.

        Я так понимаю, бинарников для винды пока нету? Жаль.
          0
          Указали бы в начале статьи, что ваш компонент называется Qutepart

          Спасибо! Добавил
          +5
          Интересно было бы посмотреть сравнение с подсветкой в редакторах от JetBrains (например, WebStorm) и с Visual Studio.
          +7
          Гораздо проще разбить подсветку на уровни вложенности. Например для «черновой» подсветки, можно не идти дальше первого уровня вложенности оавтомата. Таким образом разобъете весь файл на сегменты. После этого можно уже от того сегмента, который сейчас смотрит пользователь углубляться дальше первого уровня автомата.
            0
            Хмм. Идея хорошая!
            К сожалению в Qutepart реализовать не получится. Как минимум для уже существующих парсеров. Я не стал расписывать в статье, но переходы по контекстам (состояниям) могут быть сложными. Например «подняться на два уровня вверх по стеку, потом перейти в контекст строка».
            Так что, их обрабатывать можно только проходя по всем состояниям.
            Возможно со всеменем я сделаю другую оптимизацию — парсить текст и размечать блоки сразу, а цвета и шрифты применять лениво, при отображении.
              0
              Возможно, я чего-то не понял. Но как можно парсить не углубляясь внутрь блоков? Ведь возможно что закрывающая фигурная скобка без отступа это на самом деле не конец объявления функции, а всего лишь часть строки или блочного комментария, начатого в самой первой функции, но глубоко внутри.
                +1
                Есть такая тема — инкрементальный парсер. Она, собственно, начинает переразбор по кускам кода (скажем, блокам, функциям, методам, классам) только если в этом кусочке что-то изменилось. И соответственно меняет AST.

                Если коротко: штука весьма нетривиальная в реализации. Как правило, так не делают и обходятся регулярками, которые значительно быстрее.
                  0
                  Инкрементальный парсер в этой статье и описан. Он же перепарсивает с точки изменения, это в статье указано. Не думаю, что это как-то связано с упомянутым парсингом «по уровням вложенности».
                    0
                    вот я, если честно, перечитал текст и не совсем понял, корректный ли инкрементальный парсинг используется или что-то вроде хака, очень уж там мало об этом.
              +1
              Посмотрите на QFuture и QFutureWatcher и вообще на всю связку QtConcurent, и второе, если все равно это Qtшный модуль, то что мешает QRegExp взять?
                0
                если все равно это Qtшный модуль, то что мешает QRegExp взять?

                Есть несколько способов скрестить PyQt и бинарный код.

                1. Основной класс пишется на C++ и наследуется от класса Qt. C помощью SIP для него герерируются биндинги. Потом автогенеренные исходники компилируются в модуль расширения Python. Из модуля импортируется класс, доступный в PyQt.
                Так работает, например, уже упомянутая QScintilla.

                2. Основной класс пишется на Python и наследуется от класса PyQt. Вспомогательный модуль пишется на чистом C, компилируется стандартным для Python образом и экспортирует только функции и классы, не связанные с Qt. Класс на Python импортирует модуль и дергает его для выполнения тяжелых вычислений.
                Qutepart собирается так, соответсвенно из модуля расширения Qt не доступна.
                0
                в свое время был в восторге
                  0
                  Интересно, насколько flex будет быстрее pcre?
                    0
                    А можете добавить в сравнение производительности IntelliJ IDEA и, по возможности, Sublime Text 3?
                    В Sublime Text 3 в build 3047 вроде как была улучшена производительность рендеринга.
                      0
                      Добавил в подробную версию таблицы.

                      Разницу в скорости первоначальной подсветки в Sublime можно списать на погрешность моих измерений, а вот обновление подсветки при редактировании определенно стало лучше.
                      +2
                      Я думаю разработчики kate будут рады если вы сними тоже поделитесь своей success story в их списке рассылки kwrite-devel@kde.org Возможно какие-то идеи со временем портируют назад.

                      ЗЫ еще бы неплохо добавить сравнение расходов памяти на подсветку (если они заметно отличаются). Те насколько увеличилось потребление памяти после открытия документа.

                      Only users with full accounts can post comments. Log in, please.