Несмотря на проблемы с Koka, о которых можно почитать в первой части, я все-таки решил продолжить :/

Поддержка Юникода и сишная вставка #

Начал я с потрахушек с сишной вставкой для поддержки Юникода — она не работала из-за undefined behavior, потому что я пытался использовать ICU библиотеку для изменения строки “на месте”, а чатгпт мне врал и газлайтил про сломанное окружение. Пока сам внимательно не посмотрел на код и не подумал, что может пойти не так, ничего не получалось. Справедливости ради, сама библиотека для Юникода, такое ощущение, тоже меня газлайтила — API у нее, мягко говоря, не очень, и пару раз я получал ошибки весьма сомнительного качества, которые скорее запутывали, чем давали понять, что не так. Но после того, как сделал микро-файлик с изолированной проблемой и догадался про UB, все относительно быстро решилось. Пиковый опыт разработки на сях + пиковый опыт общения с “ИИ” помощником.

Были еще проблемы с тем, чтобы понять, что нужна статическая линковка, откуда взять библиотеки, откуда взять C++ библиотеку, можно ли как-нибудь это не руками делать и т.д. Отдельной историей была попытка разобраться с менеджерами пакетов, conan и vcpkg, но это какая-то своя вселенная с типичным (херовым) UX, и я быстро решил эту идею свернуть, так как еще разбираться подробно с ними и их интеграцией в Koka — нет, спасибо. Даже по вайбу — в одном тебя лягуха JFrog встречает с пикой точеной с установкой через pip, в другом — мелкомягкие с установкой через git clone и несколько ручных действий.

Я еще подумывал о том, чтобы поправить пулл-реквест по итогам обратной связи, которую мне все-таки дали, но решил, что покрывать все нюансы практически вслепую — так себе опыт, и отложил это дело когда-нибудь “на потом”. Тем более не могу сказать, что почувствовал четкость и уверенность в ответе — как бы потом еще раз переделывать не пришлось.

Парсер JSON #

В момент написания собственно поиска я осознал, что в ни в стандартной библиотеке, ни в библиотеке от сообщества нет парсера JSON. Только писатель.

Ладно, ладно, что-то есть, но это выглядит настолько не очень, что лучше бы его и не было.

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

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

Не очень понравилось, что нет толкового копирования структур. Есть немного сахара, однако, как я понял, это не мой случай из-за того, что у меня типы-объединения. Хотя в каком-нибудь Котлине с sealed-классом и выводом типов это сработало бы без копипасты. Позже я немного отрефакторил код и копипасты стало не так много.

Фронтенд #

Моя любимая тема:) Следующий челлендж — сделать что-то интерактивное, чтобы поиском можно было пользоваться на сайте. Для интеграции с браузером нет библиотек из коробки — ну, ничего, уже умеем делать внешние вызовы :)

Думал было побыть модным и молодежным и скомпилировать все в WASM, однако для него на кой-то ляд нужны сишные библиотеки. Честно, даже не стал вникать, решил, что

Такое ощущение, что JS для фронта был сделал по остаточному принципу. По ходу выяснились какие-то достаточно детские болячки: например, при создании библиотеки игнорируется флаг пути вывода — алло, как мне ее использовать? Или, например, если вдруг у обертки и реального вызова в JS одно имя — здравствуйте, бесконечная рекурсия.

Состояние #

Одним из проблемых вопросов, который возник почти сразу при написании морды — как хранить состояние (загруженный индекс)? Мы же в чистом ФП, при каждом запросе запрашивать и парсить JSON — отвратительно, т.е. его надо передавать как параметр к фунции. Вот только кто это будет делать и откуда? Функция по идее будет вызываться только при изменении поля ввода, т.е. вызываться браузером.

Я находил какие-то ошметки доков про то, как сделать что-то подобное в общем случае, но… ничего толком не понял, потратив кучу времени :/ Даже думал тупо забить: пусть передается вызывающим кодом, т.е. оставить небольшую обертку из JS, в которой будет изолировано все состояние и все эффекты. Задал вопрос на форуме, ни на что не надеясь, однако мне ответили быстро и по делу. Стало понятнее, но тогда я временно отложил это дело. А в итоге советами не воспользовался и выкрутился путем рефакторинга — состояние (индекс) в итоге хранится в замыкании, без дополнительных фишек языка и всего такого.

Превозмогаем fetch #

Первая попытка запуска поиска была, разумеется, неуспешая, потому что

created : .koka/v3.1.2/js-debug-4de7e1/search__main.mjs
error  : command failed (exit code -11)
command: node --stack-size=100000 .koka/v3.1.2/js-debug-4de7e1/search__main.mjs

А потом еще и

...
 node --stack-size=100000 .koka/v3.1.2/js-debug-4de7e1/search__main.mjs
Segmentation fault (core dumped)

В итоге всемогущий print-debug вывел меня на fetch, который безуспешно пытался загрузить JSON-файлик с индексом.

Кажется, одна из проблем была связана с асинхронностью, поэтому я сначала решил пойти по “простому пути” и сделать вызов fetch синхронным. “Вертел я еще в этом зоопарке с асинхронностью разбираться”, — подумалось мне. Разумеется, так делать нельзя, зря что ли два цвета у функций? А синхронный метод (XMLHttpRequest) устарел (да и выглядит уродско).

В итоге нашел адекватный способ и адаптировал его под свой случай. Поковырялся немного, чтобы использовать голый async и не использовать unsafe-as-string, но это был провал.

Попробовал сделать еще нормальную ошибку при отсутствии файла. Тут интересно, что расширяется стандартный интерфейс exception-info:

abstract extend type exception-info
  con JSError(error: jsobject<any>)

Это круто, вот только доступа к типу нет, и его нельзя никак проверить/показать, потому что он не публичный. Уже когда писал эту статью, отправил PR с исправлением, вмержили сразу.

Верстаем #

Когда разбирался с fetch, решил попробовать просто что-то абстрактное в браузере запустить, прямо из консоли, но не тут-то было: библиотеки компилируются в модули, к функциям внутри них получить доступ из консоли — нетривиально.

Штош… придется писать SPA и генерить HTML кодом (sic!).

По умолчанию вывод main идет в

<div id="koka-console" style="...">
  <div id="koka-console-out" style="...">
    &lt;a href="https://ya.ru"&gt;test&lt;/a&gt;
    <br>
  </div>
</div>

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

В итоге решил, что не стоит это того, и решил через нативную функцию заменять код страницы. Это сработало. Получается, не просто будем генерить HTML кодом, но еще будем это делать в стиле реакта, когда весь HTML генериться бразуером на лету. Best practice, однако (устаревший).

Пока разбирался с изменением кода, попутно нашел и библиотеку для генерации HTML. Вот тут (наконец-то!) смог оценить подход с эффектами. В компонентах с их помощью собираются элементы: билдер создает эффект, а build все “ловит” — выглядит очень круто, абсолютно ничего лишнего! Из минусов — не очень расширяемо: например, на input нельзя добавить атрибуты и нужно использовать более низкоуровневое API. Однако это можно исправить, не меняя сам подход, и скорее проблема малого использования библиотеки и обратной связи для нее.

С точки зрения стилей — сначала хотел сделать все с нуля, а потом подумал: нахера изобретать велосипед?

Переделал тупо теги и классы под то, что и так у меня на сайте, и загрузил CSS оттуда ужаснейшим методом. А единственное непокрытое место, ширину поля ввода 100%, тупо сделал inline-стилем: не делать же ради этого дополнительный файл.

Интеграция с Web API #

Пробую запустить — не работает: не вызывается функция поиска. При отладке выясняю интересную особенность — имена манглируются: do_searchdo__search, search-frontendsearch_dash_frontend. Зачем — не очень ясно. Было ли это проблемой? Нет. Потратил ли я на это время? Да :(

Основная засада — импорты. Наружу торчит только main. Попробовал привязать вызов функции к атрибуту поля ввода oninput — мимо, потому что функция в области видимости модуля. Ладно, попробуем через addEventListener — тоже мимо, потому что непонятно, как пробросить название вызываемой функции правильно. Если пробросить саму функцию — то она как будто не вызывается :(

Нашел как сделать перезагрузку скриптов и вызвал функцию напрямую, получил исключение. Поганый JavaScript не может нормально сказать, в чем проблема :/ Ошибка оказалась где-то в std_core_hnd.mjs — не определена переменная. Т.е. видимо я ухожу из контекста Koka и не получается ничего сделать. Ну, хотя бы научился редактировать скрипты в браузере.

Хотел сделать простенький репродюсер, чтобы отправить баг, а в нем все, сволочь такая, работает! Методом тыка выяснилось, что если в функции есть хоть один сайд эффект, то все, “усе пропало”. Было бы классно получить нормальную ошибку, и это минус интеграции языка с браузером.

Еще и JavaScript поднасрал своими API — потратил время на какую-то ерунду: одна и та же функция работала на кнопке, но не вызывалась на поле ввода. Все для того, чтобы обнаружить, что правильное событие — input, а не oninput. Разумеется хотя бы предупреждение в консоли показать слишком просто :/

Вдобавок есть небольшая проблема с типами: это тебе не TypeScript, в котором определил структуру типов и радуешься. Тут определил структуру — получи [object Object]. Поэтому в коде остался унылый Jsobject(event).get-obj("target").get-string("value")

Все выше перечисленное чатгпт с точки зрения фронта сгенерировал бы за 1 простой промт, конечно.

Отлаживаем сам язык #

В какой-то момент словил баг компилятора:

(1, 1): internal error: unable to read
CallStack (from HasCallStack):
  error, called at src/Common/Range.hs:65:53 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Common.Range
  readInput, called at src/Compile/Build.hs:1317:40 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Compile.Build
  getFileContents, called at src/Compile/Build.hs:644:17 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Compile.Build
  moduleLex, called at src/Compile/Build.hs:631:43 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Compile.Build

(1, 0): build warning: interface async found but no corresponding source module

При этом помогало удалить .koka. Сузить проблему не удалось, поэтому много на нее времени не тратил.

Поборов интеграцию функции поиска с полем ввода, получил новый ворох проблем в лицо. Вот какая-то ошибка в КЧД при десереализации:

_mlift_init_10105/index__size< - search_dash_frontend.mjs:108:21
ReferenceError: $std_data_rbtree is not defined

Ну епрст, если еще и дерево хреновое, то это вообще жопа.

Обложился трейсами — теперь другая ошибка в другом месте, каеф.

string_fs_show - std_core_show.mjs:326:18
ReferenceError: $std_core_vector is not defined

В принципе “логично”, трейс выводит в консоль, функция вывода не работает — получаем ошибку. В процессе дебага увидел, что находится внутри встроенной строки — связные списки (бля, это жесть 🌚). Соответственно, работает это все не медленно, а ПИЗДЕЦ как медленно.

Ошибка с выводом поначалу пофиксилась… убиранием флага -O3. Не, ну я конечно понимаю, что один из таргетов — это Си, но чтобы еще undefined behaviour из него тащить — это смело! Оказалось еще, что даже с -O1 код работает медленнее, чем без оптимизаций вообще.

А вот фикс для работы с КЧД оказался просто конченным: добавить лишний импорт. Это мне еще повезло, что ошибка на верхнем уровне в подконтрольном коде, иначе бы я не знал как это исправить. Предыдущую проблему с show это не решило.

Поборов проблему с импортами, получил следующую ошибку (я уже в шаге от MVP, честно-честно, надо чуток потерпеть…)

Paused on exception
$regexExecAll - std_text_regex.mjs:128:25
TypeError: $std_core_types._vlist is not a function

Оказалось, что проблема даже не в сгенерированном JS, а… в обычном! Это баг стандартной библиотеки языка: метод вызывается из одного модуля, а по факту находится в другом.

Проверил через перегрузку скриптов тупейший фикс — работает :/ В итоге сделал микро-PR, чтобы починить, и его приняли за полчаса, ух!

На этом баги библиотеки не кончились — Юникод нанес еще один удар, теперь по регуляркам! В сях и шарпе они юникодные, а вот в JavaScript — нет, потому что флаг не поставлен. В итоге еще один пулл-реквест, который тоже относительно быстро вмержили.

Организация кода #

До этого времени все было в одной папке и в дополнение к ней была помойка utils. Захотелось более четко разделить ядро, фронт и вещи, специфичные для бложика.

Тут используется хаскелловский подход к импортам. Вроде все ок, но красиво не очень получается: либо добавлять всю папку src, чтобы сохранить иерархию, либо добавлять используемые модули по одному, но тогда теряются префиксы. С core еще ладно, но вот utils хотелось явно оставить. В итоге оставил -isrc, тем более это все-таки best practice, как я понял.

Получил унылое:

src/extractor/extract.kk(44,37): type error: types do not match
  context      :                           serialize(index)
  term         :                                     index
  inferred type: index/index
  expected type: core/index/index

но в итоге порешалось использованием везде правильного префикса для импортов.

Основная логика #

Вы ведь все еще помните, что я поиск пишу, да?

В черновике заметки между пунктами выше у меня было “написал индексер”, “написал поиск” и т.п. Когда находишься в чистой абстракции, то и проблем не так уж и много. Скажем так, собственно логика поиска и “движка” была самой простой и беспроблемной задачей.

В первой итерации я сделал обычный TD-IDF. Во второй итерации решил сделать чуть лучше — BM25. С ним интересная оптимизация получилась: если известно k и b, то можно предвычислить коэффициенты. Ради этого даже поддержку дробных числе в парсер JSON добавил.

Еще одна ошибка с импортами #

После рефакторинга получил еще одну ошибку, но теперь уже в индексаторе:

core/indexer(1, 1): internal error: Core.Parc.getDataDefInfo: cannot find type: std/data/rbtree/rbtree
CallStack (from HasCallStack):
  error, called at src/Common/Failure.hs:46:12 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Common.Failure
  raise, called at src/Common/Failure.hs:32:5 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Common.Failure
  failure, called at src/Backend/C/Parc.hs:988:34 in koka-3.1.2-2NpoSf9Uv2JEsgjfT7jQ0Q:Backend.C.Parc

Failed to compile src/extractor/extract.kk

очень интересно и ничего не понятно :(

Причина оказалась во вспомогательной функции

pub fun values<k, v>(m: map<k, v>): list<v>
  m.rb-map/values

Видимо, совпадающие имена в комбинации с псевдонимом типов не работали. Возможно, из-за подобной ошибки были проблемы с импортами и в JS. Заменить на квалифицированный .map/values не сработало, переименовать в get-values — тоже. В итоге тупо переписал код, чтобы это не использовалось.

Обновление версии языка #

Попробовал зарепортить ошибки с импортами — а они поправлены в последнем мастере! Отлично, попробуем обновить версию языка.

Пробую собрать из исходников — фигушки, не перепробрасываются cclibdir. Пробую собрать из альфа-релиза — фигушки №2, \r в юникс-скрипте (sic!).

Дальше скрипт становится еще лучше!

./install.sh --url https://github.com/koka-lang/koka/releases/download/v3.1.3-alpha17/koka-v3.1.3-linux-x64.tar.gz
Installing koka v3.1.3 for ubuntu linux-x64
warning: unknown option "--url".
Installing dependencies..
Using generic linux bundle
Downloading: https://github.com/koka-lang/koka/releases/download/v3.1.3-alpha17/koka-v3.1.3-linux-x64.tar.gz

Т.е. жалуемся на неправильный аргумент (который есть в --help между прочим), а потом его используем :/

Зато после обновления (с 3.1.2 до 3.1.3):

  • решилась проблема с values (хотя уже не очень-то и нужно было).
  • внезапно отвалился string/replace-all, но просто replace-all норм работает.
  • пофиксился ублюдочный баг с импортами в JS.
  • за счет двух моих вмерженных PR можно было убрать костыли.
  • даже проблема, из-за которой надо было rm -rf .koka делать, стала реже появляться (однако потом снова появилась с большей силой — теперь надо было иногда еще и демон компилятора убивать).
  • добавили метод expect для maybe (правда, имя конченное), удалил свою реализацию в utils.
  • смог вернуть -O3 на фронте.

Bleeding edge разработка, епта!

Потом еще и версия 3.2.0 вышла, пока я сопли жевал со статьей, а потом еще и сразу 3.2.2. Там есть пара интересных фич, но я уже немного устал.

Имя проекта #

Перейдем к самому главному!

Рабочим названием проекта было looKfor. Оно было унылым. Чатгпт предлагал всякую дичь, больше всего понравилось grepka.

А в итоге в Википедии нашел Klava Koka, до этого про нее вообще не слышал. Жена мне запустила 2 “известные” песни с телефона. Мне показалось, что цитата “Че тебе нужно” из “хита” с чумовым названием “ЛА ЛА ЛА” как раз подходит для проекта. Ну и сферическая баба Клава тоже должна знать, что где лежит.

Муки оптимизации JSON парсера #

Моя реализация была медленной, но она работала. Решил попробовать как-то ее оптимизировать, потому что 10+ секунд — как-то совсем не комильфо.

Первую альтернативу попробовал сделать через нативный вызов JSON.parse — уж быстрее этого ничего не будет. Однако поверх него была еще обертка по преобразованию JSON-объекта в JSON-объект Koka — по сути, большой if-else-if с проверкой типов и рекурсивными вызовами с конструкторами оберток. Пробую запускать… и код прерывается в браузере без ошибки :( Пробовал и так и сяк — ну не работает, и все! При этом в консоли все работает нормально. Я конечно понимаю, что JSON размером 1,6 Мб не так-то просто распарсить, но не настолько же:( Убийцей оказался опять -O3.

Попробовал воспроизвести проблему с -O3 в изоляции — получилось плохо. Методом научного тыка выяснил, что виноват async — он плохо взаимодействует с рекурсией. Причем эта падла (эффект async) проникает в main, и как-то изолировать десериализацию, чтобы она была вне пространства с этим эффектом, не получилось.

Наконец, в дебаггере докопал до too much recursion Какого лешего это раньше не всплывало — неясно. Узнал интересности про рекурсию в браузере. И понял, почему в консоли работало: там тупо поставлен больше размер стека вызовов: node --stack-size=100000. Что делать с этим — не очень ясно.

Хотел попробовать использовать sslice (read-only view поверх строки) в первой версии парсера — стало уже не ФП, и все равно медленно. Еще и оказалось, что в мутабельную переменную нельзя присваивать результат match — только “простое выражение”, чтобы это ни значило. Решилось промежуточной переменной, но скорости все это не прибавило.

Еще раз попробовал переписать парсер, теперь уже в мутабельном стиле с sslice и рекурсией. Открыл наконец, как можно сделать паттерн-гарды, однако счастье было недолгим:

 because        : guard expressions must be total

т.е. от мубального зависеть там нельзя. Но в итоге и эта реализация тоже оказалась медленной. Даже в консоли это было медленное говно, медленее любой из предыдущих реализаций.

Пробовал ускорить этот медленный вариант через оптимизацию вставки в списки (вставлять в голову, а не в конец) — стало быстрее, но не существенно. Никогда бы не подумал, что буду думать об этом в нормальном коде за пределами абстрактных лаб.

Попробовал vector-list из библиотеки сообщества — хуже моей обертки сработал. Даже словил

mimalloc: error: buffer overflow in heap block 0x020080000580 of size 65528: write after 65528 bytes

Вернулся к первой попытке, ужасному ФП коду, впендюрил туда свой тупейший хак для списка — и это наконец заработало! Получается, как будто зря писал еще две с половиной реализации. Но благодаря этому узнал что-то новое и смог существенно упростить оригинальную реализацию.

В продакшен #

Попробовал сначала встроить на сайт через iframe — господи иисусе, это такой ящик пандоры с разнообразными проблемами, что мама не горюй. Очень быстро от этого отказался, обошел фиксом, что не тело документа меняется, а выделенный контейнер. Ну и файлик на сайте, который создает контейнер и грузит основной модуль, все-таки пришлось сделать.

GitHub Action для сборки намястрячил примерно так:

На стадии экспериментов выяснил, что индексер работает медленнее при -O3, но это некритично, 20 секунд индексации сайта на холодном старте меня устраивают.

Итого #

В итоге эта лабуда работает!

Это было достаточно больно, но все-таки интересно. Фишку с эффектами таки удалось оценить. Какой-никакой вклад в опенсорс добавил.

Наконец-то, написал свои поиск и JSON-парсер! Еще в изначальном плане был стеммер, это проще чем думается, но решил все-таки отложить на попозже. А то так можно дойти до идей с поиском по префиксу, триграммам и хаком для неправильной раскладки…