Программирование — это не только алгоритмы и логика, но и удивительное разнообразие синтаксиса языков. Работая над новым средством подсветки синтаксиса для llamafile, разработчик Justine Tunney* исследовала 42 языка программирования — от классического C и экзотического Tcl до мощного Ruby.
Justine делится своими открытиями о том, насколько причудливым и непредсказуемым может быть лексический синтаксис. Например, триграфы в C — устаревший инструмент для поддержки клавиатур с ограниченными символами, фиксированные длины строк в FORTRAN, вложенные комментарии в Haskell или строки с двойными квадратными скобками в Lua. Ruby вообще оказался чуть ли не самым сложным языком для подсветки из-за его контекстно-зависимого синтаксиса.
Под катом вы найдете описание разработки инструмента подсветки и исследование того, как языки программирования решают одни и те же задачи по-разному. Если вам интересны синтаксис, языковые особенности и сложности лексического анализа – эта статья для вас.
*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис
В этом месяце я исследовала 42 языка программирования, чтобы создать новое средство подсветки синтаксиса для llamafile. И сегодня я поделюсь некоторыми примерами самых удивительных, а иногда и жутковатых синтаксисов, которые я когда-либо видела.
Я выбрала следующие языки: Ada, Assembly, BASIC, C, C#, C++, COBOL, CSS, D, FORTH, FORTRAN, Go, Haskell, HTML, Java, JavaScript, Julia, JSON, Kotlin, ld, LISP, Lua, m4, Make, Markdown, MATLAB, Pascal, Perl, PHP, Python, R, Ruby, Rust, Scala, Shell, SQL, Swift, Tcl, TeX, TXT, TypeScript и Zig. Этот список покрывает почти все языки в индексе TIOBE, кроме Scratch, который нельзя подсветить, поскольку в нем вместо текста используются блоки.
Как написать код для подсветки синтаксиса
Реализовать подсветку синтаксиса на самом деле несложно. Вы, вероятно, сможете написать такой код очень быстро — например, за время собеседования при приеме на работу. Мои любимые инструменты для этого C++ и GNU gperf. Самая сложная проблема здесь — избежать необходимости выполнять кучу сравнений строк, чтобы определить, является ли что-то ключевым словом или нет. Большинство разработчиков просто используют хэш-таблицу, но gperf позволяет создать её идеальный вариант. Например:
gperf был первоначально разработан для GCC, и это отличный способ выжать из него максимум производительности. Если вы выполните команду gperf для приведенного выше кода, она сгенерирует этот.c-файл. Вы заметите, что его хэш-функция должна учитывать только один символ, чтобы получить поиск без коллизий. Именно это делает его совершенным в плане производительности. Не уверена, что кому-то может понадобиться подсвечивать синтаксис языка C со скоростью 35 МБ в секунду, но я теперь могу это делать, даже несмотря на то, что определила около 4000 ключевых слов для этого языка. Благодаря gperf эти слова не замедляют работу.
Остальное сводится к конечным автоматам. Для создания базового инструмента подсветки синтаксиса вам не нужны ни flex, ни bison, ни ragel. Вам просто нужен цикл for
и оператор switch
. По крайней мере, в моем случае, когда я обращала внимание только на строки, комментарии и ключевые слова. Если бы я хотела подсвечивать такие вещи, как имена функций языка C, то, вероятно, мне пришлось бы делать настоящий синтаксический разбор. Но если сосредоточиться на самом главном, то мы выполняем только лексический анализ. В качестве примера смотрите файл highlight_ada.cpp.
Демо
Все исследования, о которых вы прочтете на этой странице, были направлены на одно — создание нового инструмента подсветки синтаксиса llamafile. Это, пожалуй, самое сильное преимущество llamafile перед ollama на сегодняшний день, поскольку ollama вообще не делает подсветку синтаксиса. Вот демонстрация ее работы на Windows 10 с моделью Meta LLaMA 3.2 3B Instruct. Обратите внимание, что эти llamafiles будут работать и на MacOS, Linux, FreeBSD и NetBSD.
Новый интерфейс с подсветкой и чат-ботом сделал llamafile настолько приятным в использовании (при том, что модели с открытым весом, такие как gemma 27b, сейчас весьма неплохие), что мне теперь все реже хочется обращаться к Claude.
Примеры удивительного лексического синтаксиса
Итак, давайте поговорим о тех видах лексического синтаксиса, которые удивили меня при написании моего средства подсветки.
C
Несмотря на то, что язык С претендует на простоту, он содержит несколько самых странных лексических элементов среди всех языков. Для начала, у нас есть триграфы, которые, вероятно, были придуманы для того, чтобы помочь европейцам использовать язык C при работе с клавиатурой, на которой не было символов #
,`[`, \
, ^
, {
, |
,`}` и ~
. Вы можете заменить эти символы на ??=
, ??(
, ??/,??)
, `??',??<`, ??!
, ??>
и ??-
. Согласитесь, очень интуитивные обозначения. Это означает, что, например, такой код на языке C — вполне корректен.
Так было, по крайней мере, до того, как в стандарте С23 были удалены триграфы. Однако компиляторы всегда будут поддерживать этот синтаксис для устаревшего ПО, поэтому хороший инструмент подсветки синтаксиса тоже должен его поддерживать. Но если триграфы официально мертвы, это не значит, что комитеты по стандартизации не придумали взамен другой странный синтаксис.
Рассмотрим универсальные символы:
Эта возможность пригодится тем, кто хочет, например, использовать имена переменных с арабскими символами, сохраняя при этом чистоту исходного кода в ASCII. Не совсем понимаю, зачем это может кому-либо понадобится. Я надеялась, что смогу воспользоваться этим и написать:
Но увы, если универсальные символы не используются в определенных плоскостях UNICODE, одобренных комитетом по стандартам, GCC выдает ошибку.
Следующий пример — один из моих любимых. Знаете ли вы, что однострочный комментарий в C может занимать несколько строк, если в конце строки использовать обратный слэш?
Большинство других языков этой возможности не поддерживают. Даже языки, которые в своем исходном коде допускают экранирование обратным слэшем (например, Perl, Ruby и Shell), не поддерживают этой особенности C. Насколько мне известно, ее поддерживают Tcl и GNU Make. Инструменты для подсветки синтаксиса, такие как Emacs и Pygments, зачастую делают это неправильно. Хотя Vim, похоже, всегда обрабатывает обратный слэш правильно.
Вот еще один хороший пример ошибки Emacs: директива препроцессора null. Одна из первых вещей, которую можно заметить при чтении исходного кода v6, — это то, что большинство файлов .c начинаются так:
Предположительно, это было сделано для того, чтобы обойти некоторые странности, но этот код остается актуальным и по сей день. Его даже можно использовать для каких-нибудь полезных целей, например, чтобы скрывать комментарии от cc -C -E
:
Haskell
Каждый программист на C знает, что в многострочный комментарий нельзя вставлять многострочный комментарий. Например:
Однако это возможно в Haskell. Они наконец-то исправили ошибку. Хотя и приняли для этого другой синтаксис.
D
Можно подумать, что в языке D впервые исправлена ошибка рекурсивных комментариев языка C. Но вместо этого D принял обе формы синтаксиса комментариев C как есть:
При этом вводится третий тип синтаксиса — для рекурсии комментариев.
На мой взгляд, из всех языков D лучше всего документировал свой лексический синтаксис. Он формализован, и в нем изложены все подробности, которые мне нужно было знать, например, шестнадцатеричные строки и heredoc.
Tcl
Больше всего в Tcl меня удивило то, что идентификаторы могут содержать в себе кавычки. Например, эта программа выведет a"b
:
Вы даже можете вставлять кавычки внутрь имен переменных, однако ссылаться на них можно будет только в том случае, если вы используете нотацию ${a"b}
, а не $a"b
.
JavaScript
В JavaScript есть встроенный лексический синтаксис для регулярных выражений. Однако при невнимательности легко ошибиться. Рассмотрим следующий пример:
Когда я впервые писала свой лексический анализатор, я просто находила закрывающий слэш и полагала, что все слэши внутри регулярного выражения будут экранированы. Но когда я попыталась подсветить минифицированный код, оказалось, что это не так. Если слэш стоит внутри квадратных кавычек для набора символов, то его не нужно экранировать.
Теперь перейдем к еще более странным вещам.
Есть несколько невидимых символов UNICODE, которые называются РАЗДЕЛИТЕЛЬ СТРОК (u2028) и РАЗДЕЛИТЕЛЬ ПАРАГРАФОВ (u2029). Я не знаю, где эти кодовые точки могут пригодиться, но стандарт ECMAScript определяет их как символы конца строки, что фактически приравнивает их к \n
. Поскольку это символы Trojan Source, я настроила свой Emacs на отображение их как ↵
и ¶
. Однако большинство ПО не учитывает эти символы и часто отображает их как вопросительные знаки. Также, насколько я знаю, ни один язык, кроме D, не делает этого. Я смогла воспользоваться этим для SectorLISP, поскольку это позволило мне создать полиглоты C + JavaScript.
подсветка синтаксиса javascript//¶`
Вот так я вставляю код на C в файлы JavaScript.
подсветка синтаксиса С//¶`
А вот так я вставляю JavaScript в исходный код на C. Примером части рабочего кода, в котором я это сделала, является файл lisp.js, на который я ссылаюсь в своем посте в блоге SectorLISP. Он запускается в браузере, и его можно также скомпилировать с помощью GCC и запустить локально. llamafile умеет правильно подсвечивать синтаксис для такого кода, но мне еще предстоит найти другое средство подсветки синтаксиса, которое тоже так делает. Вряд ли это имеет значение, поскольку я сомневаюсь, что какая-либо LLM когда-нибудь сгенерирует такой код. Но размышлять об этих заковыристых случаях, конечно, интересно.
Shell
Мы все знакомы с синтаксисом heredoc в скриптах Shell, например
Этот синтаксис позволяет поместить $foo
в строку heredoc, хотя существует синтаксис с кавычками, который отключает подстановку переменных.
Если вы хотите запутать своих коллег, то отличный способ злоупотребить этим синтаксисом — это заменить маркер heredoc на пустую строку. В таком случае heredoc будет заканчиваться на следующей пустой строке. Например, эта программа выведет "hello" и "world" в двух строках:
В языках, поддерживающих heredoc (Shell, Ruby и Perl), также возможно вставлять несколько heredoc в одну строку.
Еще один момент, на который следует обратить внимание при работе с языком shell, — он похож на Tcl в том смысле, что специальные символы вроде #, с которых, как вы можете подумать, всегда начинается комментарий, на самом деле могут быть корректным кодом в зависимости от контекста. Например, внутри ссылки на переменную # может использоваться для удаления префикса. Следующая программа выведет слово "there".
Интерполяция строк
Знаете ли вы, что с точки зрения подсветки синтаксиса строка языка Kotlin может начинаться с "
, а заканчиваться символом {
? Так работает синтаксис интерполяции строк этого языка. Многие языки позволяют вставлять ссылки на имена переменных в строки, но TypeScript, Swift, Kotlin и Scala доводят интерполяцию строк до крайности, поощряя встраивание реального кода внутрь строк.
Поэтому, для подсветки строк в Kotlin, Scala и TypeScript, нужно учитывать фигурные скобки и поддерживать стек состояний парсера. В TypeScript это относительно просто, и требуется лишь добавить пару состояний в ваш конечный автомат. Однако в Kotlin и Scala все гораздо сложнее, поскольку они поддерживают синтаксис как двойных, так и тройных кавычек, и в любой из них могут быть интерполированные значения. В итоге получилось около 13 независимых состояний, необходимых конечному автомату только для лексического анализа строк. Swift также поддерживает тройные кавычки для интерполированного синтаксиса "\(var)"
, однако для его поддержки потребовалось всего 10 состояний.
Swift
У Swift свой уникальный подход к проблеме встраивания строк внутрь строки. Он позволяет окружать строки "двойная кавычка", """тройная кавычка""" и /регулярное-выражение/ произвольным количеством знаков #hash#, которые должны быть зеркально отображены с каждой стороны. Это позволяет писать вот такой код:
C#
C# поддерживает синтаксис многострочных строк Python с тройными кавычками, но с одной изюминкой, присущей только этому языку. C# решает проблему «встраивания строки в строку», позволяя вам делать строки с четверными или даже с пятерными кавычками, если хотите. Сколько бы кавычек вы ни поставили с левой стороны, именно столько будет использоваться для завершения строки на другом конце.
По моему мнению, так и должно быть, потому что для конечного автомата так проще декодировать. Для классических строк Python с тройными кавычками нужны дополнительные правила, чтобы убедиться, что это либо один символ двойной кавычки, либо ровно три. Если допустить произвольное число кавычек, то потребуется меньше правил для проверки. В итоге вы получаете более мощный и выразительный язык, который проще реализовать. Именно такое мы привыкли ожидать от Microsoft.
Что еще они придумают?
FORTH
Обычно, чем код проще для компьютера, тем сложнее понять его человеку, и FORTH — яркое тому подтверждение. FORTH, вероятно, самый простой язык из всех существующих, потому что он выделяет в качестве лексем все, что ограничено символами пробела. Даже синтаксис начала строки — это лексема. Например:
означает то же самое, что сказать "hello world" на любом другом языке.
FORTRAN и COBOL
Один из вариантов использования llamafile, который я себе представляю, заключается в том, что он поможет банковской системе не рухнуть, когда все программисты на FORTRAN и COBOL уйдут на пенсию. Допустим, вас только что наняли для поддержки секретного мейнфрейма, полного конфиденциальной информации, написанной на языке COmmon Business-Oriented Language. Благодаря llamafile вы можете попросить управляемую вами в автономном режиме систему ИИ, например, Gemma 27b, написать за вас код на COBOL и FORTRAN. Она не может печатать перфокарты, но может выделять синтаксис перфокарт. Вот как выглядит код на FORTRAN с правильно подсвеченным синтаксисом:
В FORTRAN существуют следующие правила фиксированных столбцов:
Если в столбце 1 поставить *, c или C, то строка станет комментарием
Помещение символа, отличного от пробела, в столбец 6 позволяет расширить строку за пределы 80 символов
Метки создаются путем размещения цифр в столбцах 1-5.
Теперь приведем несколько кодов COBOL с правильно подсвеченным синтаксисом.
В COBOL действуют следующие правила:
Поместив * в столбец 7, вы превращаете строку в комментарий
Знак - в столбце 7 позволяет расширить строку за пределы 80 символов
Номера строк указаны в столбцах 1-6.
Zig
Zig имеет уникальное решение для многострочных строк, которые снабжаются двумя обратными слэшами.
Что мне нравится в этом синтаксисе — он избавляет нас от необходимости вызывать textwrap.dedent()
, как это приходилось делать при работе со строками Python в тройных кавычках. Недостаток заключается в том, что точка с запятой выглядит некрасиво. Это синтаксис строк, который на самом деле следует рассмотреть в одном из языков, не требующих точки с запятой, например, в Go, Scala, Python и т.д.
Lua
В Lua уникальный синтаксис многострочных строк, и он использует подход, похожий на C# и Swift, когда дело доходит до решения проблемы «встраивания строки внутрь строки». Для этого используются двойные квадратные скобки, между которыми можно поместить произвольное количество знаков равенства.
Что действительно интересно – он позволяет делать это и с комментариями.
Ассемблер
Одним из самых сложных языков для подсветки синтаксиса – это ассемблер, из-за фрагментарности всех его различных диалектов. Я пыталась создать нечто в llamafile, что неплохо справлялось бы с синтаксисом AT&T, nasm и т.д. Вот синтаксис nasm:
А вот синтаксис AT&T:
И вот синтаксис GNU:
При работе с ключевыми словами я обнаружила, что проще всего рассматривать первый идентификатор в строке (за которым не следует двоеточие), как ключевое слово. Благодаря этому большинство кодов ассемблера, которые я пробовала, выглядят вполне понятно.
Синтаксис комментариев очень сложный. Мне нравятся оригинальные комментарии UNIX, в которых требовался только один слэш. Ассемблер GNU поддерживает их и по сей день, но только если они находятся в начале строки (в ассемблере UNIX их изначально можно было поместить куда угодно, поскольку в то время у ассемблера не было возможности выполнять арифметические действия). Clang вообще не поддерживает фиксированные комментарии, так что их использование в открытом исходном коде, к сожалению, уже нецелесообразно.
Но дальше все становится еще интереснее. Дополнительная странность оригинального ассемблера UNIX заключается в том, что он не использовал для символьных литералов закрывающую кавычку. Поэтому там, где мы указываем 'x'
, чтобы получить код 0x78 для символа x, в оригинальном исходном коде UNIX вы указываете 'x
. Это еще одна особенность, которую продолжает поддерживать ассемблер GNU, но, к сожалению, не LLVM. В любом случае, поскольку есть большой объем кода, использующего этот синтаксис, любой хороший инструмент для подсветки синтаксиса должен его поддерживать.
Ассемблер GNU позволяет заключать идентификаторы в кавычки, поэтому вы можете поместить в идентификатор практически любой символ.
Наконец, при подсвечивании кода ассемблера недостаточно просто подсветить код ассемблера. Ассемблер обычно используется в сочетании с препроцессором C или m4. Поверьте, это встречается часто во множестве программ с открытым исходным кодом. Поэтому строки, начинающиеся с dnl
, m4_dnl
или C
, также должны считаться комментариями.
Ada
Ada — удивительно простой язык для лексического анализа. Но есть одна вещь, которую я еще не до конца поняла — это использование одинарных кавычек. В Ada могут быть символьные литералы, как в C, например, 'x'
. Но одинарная кавычка может использоваться и для ссылки на атрибуты, например, Foo'Size
. Одинарная кавычка позволяет даже вставлять выражения и вызывать функции. Например, следующий код:
выведет:
потому что мы объявляем символ, присваиваем ему значение, а затем передаем его через функцию Image, которая преобразует его в представление String
.
BASIC
Давайте поговорим о BASIC (Универсальном коде символических инструкций для начинающих/ Beginner's All-purpose Symbolic Instruction Code). Копаясь в репозиториях, которые я клонировала в git, я наткнулась на эту старую программу на Commodore BASIC, которая разрушила многие мои представления о подсветке синтаксиса.
Заметим, что эта конкретная реализация BASIC не требует закрывающей кавычки в строках, имена переменных снабжены этими странными сигилами, а ключевые слова типа goto
легко выделяются из идентификаторов.
В Visual BASIC также есть странный синтаксис литерала даты:
Это сложно разобрать лексически, потому что в VB есть даже директивы препроцессора.
Perl
Одним из самых сложных языков для подсветки является Perl. Он существует как бы между оболочками и языками программирования и наследует сложность обоих. Сегодня Perl не так популярен, как раньше, но его влияние все еще велико. В Perl регулярные выражения (regex) сделались объектом первого класса, и то, как работают регулярные выражения в Perl, с тех пор переняли многие другие языки программирования, такие как Python. Однако сам лексический синтаксис регулярных выражений по-прежнему остается достаточно уникальным.
Например, в Perl вы можете заменить текст, подобно редактору потоков sed, следующим образом:
Как и sed, Perl позволяет заменять слеши произвольными символами пунктуации, что упрощает вставку слэшей в регулярном выражении.
Возможно, вы не знали, что это можно сделать и с зеркальными символами; в этом случае вам нужно вставить дополнительный символ:
Однако s///
— не единственная странная вещь, которую нужно выделять как строку. В Perl есть множество других магических префиксов.
Сложность выделения таких элементов состоит в том, что вам нужно учитывать контекст, чтобы случайно не перепутать, что y/x/y/
— это формула деления. К счастью, в Perl это сделать довольно просто, поскольку переменные всегда могут быть снабжены сигилами, которые обычно равны $
для скаляров, @
для массивов и %
для хэшей.
Это помогает нам избежать необходимости парсинга грамматики языка.
В Perl также есть странное соглашение о написании man-страниц внутри исходного кода. В принципе, любое =слово
в начале строки отметит начало страницы, а =cut
— ее конец.
Ruby
Из всех языков я оставила на закуску самый интересный — Ruby. Перед вами язык, синтаксис которого ускользает от всех попыток его понять. Ruby — это объединение всех более ранних языков, и он даже формально не документирован. В руководстве по этому языку есть раздел о синтаксисе Ruby, но он весьма краток. Когда бы я ни пыталась протестировать подсветку синтаксиса, объединяя все файлы .rb на жестком диске, всегда найдется тот или иной файл, который найдет способ сломать ее.
Поскольку Ruby поддерживает синтаксис обратных кавычек, например, var =`echo hello`
, я не совсем понимаю, как определить, что приведенная выше обратная кавычка не должна подсвечиваться как строка. А вот еще один пример:
В Ruby есть оператор <<
, а также поддержка heredoc (как в Perl и Shell). Поэтому я не совсем понимаю, как определить, что приведенный выше код не является heredoc. И да, этот код действительно существует. Даже Emacs неправильно его обрабатывает. Из всех 42 языков, которые я исследовала, этот, пожалуй, шокировал меня сильнее всего. Возможно, это тот случай, когда нельзя произвести лексический анализ Ruby без синтаксического разбора (парсинга). Не уверена, что даже с разбором в этом можно разобраться.
Но подождите, дальше будет еще интереснее. На самом деле это правильный код Ruby:
Вот так.
Сложность поддерживаемых языков
Если бы я оценивала сложность языков программирования по тому, сколько строк кода нужно для подсветки синтаксиса, то FORTH был бы самым простым языком, а Ruby — самым сложным.
132 highlight_m4.cpp
219 highlight.cpp
220 highlight_go.cpp
239 highlight_ld.cpp
263 highlight_r.cpp
337 highlight_js.cpp
449 highlight_c.cpp
521 highlight_d.cpp
1042 highlight_ruby.cpp
aelaa
Статья занимательная, но есть вопрос: как автор, работая над подсветкой синтаксиса, может не заглянуть хотя бы в лексер соответствующего языка?
artptr86
Да она даже свой же C++ не посчитала, хотя он для подсветки синтаксиса тоже сложный.
DespInding
C и C++ у нее одинаково обрабатываются, только список ключевых слов разный