Каждый русскоязычный разработчик хотя бы раз набирал ghbdtn. Это «привет» с неверной раскладкой: те же шесть физических клавиш, прочитанные другой таблицей символов. Задача выглядит простой: собрать соответствие, применить, готово. Но перед конвертацией возникает вопрос, на который у самого текста ответа нет. Была ли это ошибка?

ghbdtn может быть опечаткой, а может быть валидным идентификатором, префиксом хеша, веткой в git или куском пароля. Нажатия клавиш в обоих случаях одинаковы. Нужная информация никогда не лежала в буфере обмена, она была в голове человека в момент набора и не сохранилась. Автор плагина keyboard-magic для IntelliJ IDEA, публикующийся на Habr как i_alakey, разобрал этот тупик в статье «Раскладку нельзя определить. Можно только сделать ошибку обратимой». На момент проверки Habr показывал у публикации отметку «6 часов назад»; абсолютная дата в доступном тексте не указана. Материал представляет собой инженерный разбор личного проекта, а не исследование с выборкой или измерением точности.

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

Вместо этого плагин не спрашивает, была ли ошибка. Он задаёт вопрос, у которого есть ответ: каким алфавитом написан выделенный текст? Детектор нарочито прост. Он считает кириллические и латинские буквы, победившее большинство определяет направление, при ничьей и на пустом множестве выбирается английская раскладка. Пунктуация и цифры игнорируются, потому что они есть в обеих раскладках и не несут сигнала о намерении. Главное свойство, на котором держится дизайн, — инволюция: повторное применение операции должно вернуть исходный текст. Формально это записывается как switch(switch(x)) == x.

Здесь появляется тонкость, которую стоит разобрать отдельно. Автор связывает инволюцию со свойством таблицы, а не алгоритма. Таблица строится от физической клавиатуры: восемь строк, каждая задаёт соответствие ряда клавиш без Shift и с Shift, например qwertyuiop[]\ соответствует йцукенгшщзхъ\. Каждая физическая клавиша даёт две пары символов, и поскольку источник правды — клавиши, а не символы, получающиеся отображения являются биекцией по построению. Обе стороны собираются одним циклом из одних и тех же рядов, поэтому разъехаться не могут. Охранная строка require(enRow.length == ruRow.length) превращает ошибку выравнивания рядов в падение на инициализации класса вместо тихой порчи текста. Автор утверждает, что пересобрал таблицу из исходника: 94 символа в каждую сторону, каждый round-trip-ится, без исключений.

Но заявленная универсальная гарантия switch(switch(x)) == x не следует из описанного алгоритма выбора направления. При равном числе латинских и кириллических букв детектор возвращает EN и до, и после преобразования. Возьмём строку, где букв двух алфавитов поровну: после первого нажатия ничья сохраняется, второе нажатие снова выбирает EN, и обратного преобразования не происходит. Это не ошибка таблицы, а ограничение правила выбора направления. README проекта обещает, что повторное нажатие переводит фрагмент обратно, и одновременно описывает выбор по большинству и фолбэк EN для фрагментов без букв; эти два утверждения несовместимы для ничьих. Показанного теста на round-trip для каждого отображённого символа недостаточно, чтобы проверить свойство на всех строках. Нужен тест, включающий смешанные строки с равным числом букв двух алфавитов и непустые строки без букв, на которых преобразование меняет символы.

Самое интересное следствие поклавишного отображения автор показывает на пунктуации. ghbdtn, vbh! превращается в приветб мир!, а ghbdtn? vbh! — в привет, мир!. Запятая в двух раскладках не является одной и той же клавишей. В русской раскладке запятая набирается клавишей, на которой в английской живёт вопросительный знак, а английская запятая соответствует русской букве «б». Таблица является биекцией над клавишами, а не над смыслами. Сопоставление двух запятых друг другу сломало бы инволюцию, отняв гарантию round-trip у вопросительного знака и буквы «б». Автор называет такой обмен плохим: свойство, выполняющееся всегда, меняется на интуицию, выполняющуюся иногда.

Настоящие сложности проекта оказались не в отображении символов, а в редакторе. Слово под кареткой определяется расширением в обе стороны от позиции курсора, поэтому каретка, касающаяся любого края слова, это слово выбирает. Работает и сразу после набора, и при клике перед словом. Символы слова включают буквы, цифры и подчёркивание, но исключают пунктуацию. Поэтому end; конвертируется как end и сохраняет точку с запятой, что правильно в исходниках, но ,jkm («боль») от голой каретки превращается только в оль: ведущая запятая в слово не входит. Такое выделяют руками. Автор называет дефолт, верный в коде и слегка неверный в прозе, правильным для плагина, живущего в IDE.

Две каретки внутри одного слова попадают в один и тот же диапазон, и без защиты слово конвертировалось бы дважды, то есть возвращалось бы к исходному виду. Автор добавляет множество уже обработанных диапазонов, чтобы действие не выглядело пустым. Пакет правок применяется справа налево, потому что правка на смещении 100 делает недействительными все смещения после неё. В текущем коде замены случайно оказались одинаковой длины, так что сработал бы и прямой порядок, но автор настаивает на корректности по построению, а не по совпадению. Диапазоны собираются до входа в действие записи, потому что каретки нужно читать из документа, который под тобой не меняется.

Отдельно выделен один Ctrl+Z. Все изменения происходят внутри одной команды редактирования, поэтому откат занимает один шаг. Автор описывает это как вопрос доверия: исправляющий инструмент, откат которого требует пяти нажатий, перестаёт вызывать доверие сразу после первого случая, когда пользователь откатил три конвертации из пяти и сидит разбирается, в каком он состоянии. Для выделенного фрагмента плагин восстанавливает выделение после конвертации, чтобы следующее нажатие снова применилось к тому же тексту. Без этого математическая гарантия существует, но пользователь до неё не дотягивается.

Автор также описывает три практические ловушки платформы IntelliJ. Версия Kotlin API должна совпадать с той, которую везёт IDE, а не с той, что стоит у разработчика: IDEA 2022.2 поставляется со стандартной библиотекой Kotlin 1.6, и пиннинг apiVersion = 1.6 превращает NoSuchMethodError в чужой IDE в ошибку компиляции на своей машине. Параметр platformLocalPath не работает под C:\Program Files, потому что gradle-intellij-plugin кладёт рядом с IDE Ivy-дескриптор, а каталог недоступен на запись. Дефолтный хоткей Ctrl+Shift+R уже занят командой Replace in Files, а Cmd+Shift+R на macOS — командой Run, поэтому IDEA показывает диалог выбора. README документирует конфликт и объясняет, как перевесить сочетание.

В конце автор ставит вопрос, на который сам отвечает неуверенно. IDE знает больше, чем буфер: PSI-дерево, находится ли каретка в строковом литерале или идентификаторе, язык проекта. Контекстно-зависимая версия, которая конвертирует внутри строк и комментариев и никогда внутри идентификаторов, была бы права чаще. Но она перестаёт быть инволюцией, на которую можно опереться, и превращается в инструмент, ведущий себя по-разному в зависимости от того, где стоит каретка. Автор прямо признаёт: «держусь я за это некрепко».

Важно не принять этот инженерный блог за эмпирическое исследование. В источнике нет данных о тестировании с пользователями, измерений точности эвристик или сопоставления с вероятностной детекцией. Преимущество дешёвого отката сформулировано как проектный тезис: угадывать хорошо — интересная инженерная задача, но инструмент, правый в 95% случаев и молча неправый в 5%, оптимизирует частоту правильности, тогда как приятность определяется ценой ошибки. Это аргумент о дизайне взаимодействия, а не доказанное превосходство одного подхода. Показательный контраст — Punto Switcher, который с 2001 года делает противоположный выбор: работает в фоновом режиме, проводит статистический анализ последовательностей вводимых символов и, если сочетание нетипично для текущего языка, переключает раскладку, стирает напечатанное эмуляцией Backspace и вводит текст заново. Эта программа принадлежит «Яндексу» с 2008 года и бесплатна для некоммерческого использования, как указано в её описании в русской Википедии. Оба подхода решают одну проблему, но по-разному распределяют ошибку: один прячет неверную догадку, другой делает её обратимой одним нажатием.

Для читателя, который сталкивается с неоднозначностью в собственных проектах — будь то автозамена, нормализация данных или любая эвристика над пользовательским вводом, — урок здесь не в конкретном плагине. Когда намерение восстановить нельзя, можно перестать его выводить и сделать операцию гарантированно обратимой. Но даже это решение требует проверки на всех входах, включая ничьи, которые кажутся экзотикой до первого же смешанного идентификатора. Таблица может быть биекцией, а вся система всё равно не быть инволюцией, если направление выбирается правилом с фолбэком. Свойство, о котором заявляют как о гарантии, надо проверять не только на отдельных символах, но и на границах правил. Похожий урок о том, как легко принять удобное правило за доказанную закономерность, можно увидеть в разборе округлённых процентов, сумма которых даёт 100,1%: видимая аккуратность таблицы не отменяет необходимости проверять краевые случаи.

Источники и литература

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

Фото: cottonbro studio / Pexels