Фон

HighlightJS — один из самых популярных пакетов для подсветки кода, который используется на многих веб-сайтах, где можно увидеть фрагменты кода. В Аквелоне нам нужно было то же самое для приложений, написанных на Dart.

Существует пакет Dart highlight, созданный путем портирования HighlightJS, однако он был заброшен и не поддерживался в течение 2 лет. Нам нужна была свежая версия с исправленными ошибками, поэтому мы решили разветвить и возродить этот пакет, а затем догнать изменения HighlightJS.

В результате появились пакеты highlighting и flutter_highlighting, которые теперь можете использовать и вы. В этой статье мы поделимся с вами подробностями о том, как мы их сделали.

Архитектура HighlightJS

Исходный пакет JavaScript состоит из ядра и 197 языковых определений:

Каждый язык объявлен как экземпляр интерфейса Language. Он не содержит всего определения синтаксического дерева, а только правила разбора комментариев, строковых литералов и некоторых других вещей, достаточных для выделения. Это более эффективно, чем анализатор всего синтаксического дерева.

Каждое такое правило называется Mode. Существуют режимы для комментариев в стиле C, для строковых литералов, для ключевых слов и т. д. Режим может содержать другие режимы. Например, комментарий документа может содержать ссылки на переменные, а строковый литерал может содержать интерполяцию переменных. Режим может даже содержать часть на другом языке: подумайте о комментарии к документу с кодом Markdown. Для этого интерфейс Language расширяет интерфейс Mode.

Ядро пакета содержит парсер и некоторые другие обработчики Language объектов. Вывод парсера — это объект Result, который можно преобразовать в строку HTML.

Дартс Архитектура

Архитектура HighlightJS отражена в Dart для простоты обслуживания. У нас есть ядро, определения языка и класс Result с теми же полями.

Кроме того, у нас есть пакет Flutter, который оборачивает все это в виджет. Он принимает строку кода и создает RichText виджет с деревом цветных TextSpan объектов.

Сбор брошенного пакета

Инструмент переноса

Оригинальный пакет Dart был портирован с HighlightJS 9.18, а текущая версия — 11.7, так что многое изменилось.

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

Ядро

Для ядра нет инструмента переноса, потому что он не соответствует простому формату, подобному определениям языка, и нет общего транспилятора из JavaScript в Dart.

Ядро HighlightJS разошлось до такой степени, что проще было отказаться от старого ядра Dart и написать новое. Вот почему мы решили перенести ядро ​​из JavaScript в Dart вручную. Мы просто воспроизвели все классы и функции, в основном построчно.

Мы полагаемся на тесты, чтобы увидеть, изменилось ли что-то в ядре JavaScript, что должно быть отражено в ядре Dart.

Перенос определений языка

Пример языка

В HighlightJS каждый язык определяется в отдельном файле как фабричная функция, которая возвращает объект, соответствующий интерфейсу Language. Например, вот определение синтаксиса Dockerfile:

/** @type LanguageFn */
export default function(hljs) {
  const KEYWORDS = [
    "from",
    "maintainer",
    "expose",
    "env",
    "arg",
    "user",
    "onbuild",
    "stopsignal"
  ];
  return {
    name: 'Dockerfile',
    aliases: [ 'docker' ],
    case_insensitive: true,
    keywords: KEYWORDS,
    contains: [
      hljs.HASH_COMMENT_MODE,
      hljs.APOS_STRING_MODE,
      hljs.QUOTE_STRING_MODE,
      hljs.NUMBER_MODE,
      {
        beginKeywords: 'run cmd entrypoint volume add copy workdir label healthcheck shell',
        starts: {
          end: /[^\\]$/,
          subLanguage: 'bash'
        }
      }
    ],
    illegal: '</'
  };
}

Ключевым свойством здесь является contains, который представляет собой список Mode объектов, которые можно проанализировать из текста. В этом примере только последний элемент специфичен для языка, а 4 других являются повторно используемыми константами, поскольку они являются общими для многих языков:

  • HASH_COMMENT_MODE соответствует комментариям, начинающимся с #
  • APOS_STRING_MODE соответствует строковым литералам в одинарных кавычках
  • QUOTE_STRING_MODE соответствует строковым литералам в двойных кавычках
  • NUMBER_MODE соответствует числовым литералам

Например, это определение для строковых литералов в двойных кавычках:

export const QUOTE_STRING_MODE = {
  scope: 'string',
  begin: '"',
  end: '"',
  illegal: '\\n',
  contains: [BACKSLASH_ESCAPE]
};

Здесь:

  • scope будет переведено в имя класса CSS цветного span
  • begin так начинается матч
  • end так заканчивается матч

Автоматизация

Самый простой способ разобрать и транспилировать такие языковые определения — написать инструмент на TypeScript. Этот инструмент является клиентом библиотеки HighlightJS. Он перебирает определения языка и вызывает фабричную функцию каждого из них, чтобы получить объект JavaScript. Этот объект станет длиннее, потому что все константы для общих режимов будут расширены.

Для приведенного выше определения синтаксиса Dockerfile мы получаем этот объект Language во время выполнения:

{
  "name": "Dockerfile",
  "aliases": ["docker"],
  "case_insensitive": true,
  "keywords": ["from", "maintainer", "expose", "env", "arg", "user", "onbuild", "stopsignal"],
  "contains": [
    {
      "scope": "comment",
      "begin": "#",
      "end": "$",
      "contains": [
        {
          "scope": "doctag",
          "begin": "[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",
          "end": {},
          "excludeBegin": true,
          "relevance": 0
        },
        {
          "begin": "[ ]+((?:I|a|is|so|us|to|at|if|in|it|on|[A-Za-z]+['](d|ve|re|ll|t|s|n)|[A-Za-z]+[-][a-z]+|[A-Za-z][a-z]{2,})[.]?[:]?([.][ ]|[ ])){3}"
        }
      ]
    },
    {
      "scope": "string",
      "begin": "'",
      "end": "'",
      "illegal": "\\n",
      "contains": [
        {
          "begin": "\\\\[\\s\\S]",
          "relevance": 0
        }
      ]
    },
    {
      "scope": "string",
      "begin": "\"",
      "end": "\"",
      "illegal": "\\n",
      "contains": [
        {
          "begin": "\\\\[\\s\\S]",
          "relevance": 0
        }
      ]
    },
    {
      "scope": "number",
      "begin": "\\b\\d+(\\.\\d+)?",
      "relevance": 0
    },
    {
      "beginKeywords": "run cmd entrypoint volume add copy workdir label healthcheck shell",
      "starts": {
        "end": {},
        "subLanguage": "bash"
      }
    }
  ],
  "illegal": "</"
}

Затем идея состоит в том, чтобы пройтись по этому объекту JavaScript и написать определение объекта Language в Dart на его основе.

Портирование общих режимов

Если мы просто сгенерируем Dart-эквивалент этого определения объекта, он будет таким же длинным. Мы можем упростить это, если обнаружим те общие режимы, которые были только что расширены JavaScript.

В случае Dockerfile мы должны идентифицировать эти HASH_COMMENT_MODE, APOS_STRING_MODE, QUOTE_STRING_MODE и NUMBER_MODE.

Чтобы иметь возможность использовать эти строительные блоки в определениях языка Dart, мы должны перенести их в Dart.

Мы делаем это, проверяя глобальный объект hljs, потому что каждый общий режим является экспортируемой константой, которая в конечном итоге становится свойством hljs.

Затем мы записываем файл common_modes.dart с такими определениями:

final QUOTE_STRING_MODE = Mode(
  scope: "string",
  begin: "\"",
  end: "\"",
  illegal: "\\n",
  contains: [
    Mode(
      begin: "\\\\[\\s\\S]",
      relevance: 0,
    ),
  ],
);

Или с пропуском для обнаружения вложенных общих режимов:

final QUOTE_STRING_MODE = Mode(
  scope: "string",
  begin: "\"",
  end: "\"",
  illegal: "\\n",
  contains: [BACKSLASH_ESCAPE],
);

Перенос фактических определений языка

Теперь, когда мы перенесли общие режимы, которые являются строительными блоками языка, мы можем сделать то же самое с определениями языка.

Однако огромная разница заключается в том, что определения языка могут иметь циклические ссылки.

Хорошим примером является язык Dart, который позволяет интерполировать строки произвольными выражениями, которые, в свою очередь, могут содержать строки и т.д.

Это определение создает циклическую ссылку (комментарии мои):

const BRACED_SUBST = {
  className: 'subst',
  variants: [
    {
      begin: /\$\{/,
      end: /\}/
    }
  ],
  keywords: 'true false null this is new super'
};

const STRING = {
  className: 'string',
  variants: [
    // (Skipping 4 other literal variants.)
    // We are interested in those allowing interpolation like this one:
    {
      begin: '\'\'\'',
      end: '\'\'\'',
      contains: [
        hljs.BACKSLASH_ESCAPE,
        SUBST,
        BRACED_SUBST        // This allows us to parse ${something}
      ]
    },
    // (Skipping 3 other literal variants.)
  ]
};

BRACED_SUBST.contains = [
  hljs.C_NUMBER_MODE,
  STRING                    // This circles the reference.
];

Это означает, что мы не можем просто повторить этот объект, чтобы написать определение Dart, потому что рекурсия будет бесконечной.

Чтобы разорвать циклические ссылки, мы будем использовать пакет circular-json. Это сериализатор объектов, который определяет, повторяется ли какой-либо объект в структуре.

Этот простой фрагмент показывает, что делает пакет:

const object = {};
object.arr = [
  object, object
];
object.arr.push(object.arr);
object.obj = object;

var serialized = CircularJSON.stringify(object);
// '{"arr":["~","~","~arr"],"obj":"~"}'

В этом примере:

  • Циклические ссылки на корневой объект сериализуются как "~" строк.
  • Циклические ссылки на свойство arr сериализуются как строка "~arr".

Как правило, он заменяет все повторяющиеся объекты путем их первого появления в структуре.

Когда мы сериализуем определение языка Dart таким образом, мы получаем этот JSON ниже (комментарии мои):

{
  "name": "Dart",
  "keywords": { /* Skipping a lot here */ },
  "contains": [
    {
      "className": "string",
      "variants": [
        // (Skipping the 4 variants of raw strings.)
        // The definition of a multiline single-quoted string:
        {
          "begin": "'''",
          "end": "'''",
          "contains": [
            // (Skipping 2 non-important elements.)
            // This is "BRACED_SUBST" constant:
            {
              "className": "subst",
              "variants": [
                {
                  "begin": "\\$\\{",
                  "end": "\\}"
                }
              ],
              "keywords": "true false null this is new super",
              "contains": [
                // (Skipping 1 non-important element.)
                // This is the reference to the 'all strings' definition.
                // This path reads as:
                //  1. Take the root object.
                //  2. Take its 'contains' property.
                //  3. Take its 0th array element.
                "~contains~0"
              ]
            }
          ]
        },
        // The definition of a multiline double-quoted string is much
        // shorter because it references the objects encountered before:
        {
          "begin": "\"\"\"",
          "end": "\"\"\"",
          "contains": [
            // (Skipping 2 non-important elements.)
            // This is the reference to BRACED_SUBST constant.
            // This path reads as:
            //  1. Take the 4th variant of string (multiline single-quote).
            //  2. Take its 'contains' property.
            //  3. Take its 2nd array element.
            "~contains~0~variants~4~contains~2"
          ]
        },
        // (Skipping 2 other string definitions, single-lined)
      ]
    },
    // (Skipping 8 things other than string literals highlighted in Dart).
  ]
}

В этом JSON мы получаем много таких токенов: ~contains~0~variants~4~contains~2

Иногда они спасают нас от циклических ссылок. В других случаях они просто сокращают определение языка, избегая повторения длинных JSON.

Также при сериализации мы заменяем вхождения общих режимов их именами, чтобы сделать определение еще короче:

{
  "name": "Dart",
  "keywords": { /* Skipped a lot here */ },
  "contains": [
    // (Skipped all string definitions.)
    "C_LINE_COMMENT_MODE",
    "C_BLOCK_COMMENT_MODE",
    "C_NUMBER_MODE",
    // (Skipped some more.)
  ]
}

Все это языковое определение теперь можно разобрать с помощью обычного JSON.parse() в некруговой объект. Мы можем пройтись по нему и написать эквивалент Dart:

final dart = Language(
  // This is the dictionary of all repeated parts.
  refs: {
    // This is the "BRACED_SUBST" construct:
    '~contains~0~variants~4~contains~2': Mode(
      className: "subst",
      variants: [
        Mode(begin: "\\\$\\{", end: "\\}"),
      ],
      keywords: "true false null this is new super",
      contains: [
        C_NUMBER_MODE,
        Mode(ref: '~contains~0'),
      ],
    ),
    '~contains~0~variants~4~contains~1': Mode( /* Skipping */),
    '~contains~0': Mode(
      className: "string",
      variants: [
        // (Skipping the 4 variants of raw strings.)
        // The definition of a multiline single-quoted string:
        Mode(
          begin: "'''",
          end: "'''",
          contains: [
            BACKSLASH_ESCAPE,
            Mode(ref: '~contains~0~variants~4~contains~1'),
            Mode(ref: '~contains~0~variants~4~contains~2'),
          ],
        ),
        // The definition of a multiline double-quoted string:
        Mode(
          begin: "\"\"\"",
          end: "\"\"\"",
          contains: [
            BACKSLASH_ESCAPE,
            Mode(ref: '~contains~0~variants~4~contains~1'),
            Mode(ref: '~contains~0~variants~4~contains~2'),
          ],
        ),
        // (Skipping 2 other string definitions, single-lined.)
      ],
    ),
  },
  // End of the dictionary.
  // Below is mostly the equivalent of the original definition:
  name: "Dart",
  keywords: { /* Skipping */ },
  contains: [
    Mode(ref: '~contains~0'),
    // (Skipping 8 things other than string literals highlighted in Dart).
  ],
);

В этом определении режимы бывают разными:

  • Mode(begin: "...", end: "...", ...) — это традиционное определение, отражающее то, что было в JavaScript.
  • Mode(ref: "~contains~...") является ссылкой на словарную статью
  • C_NUMBER_MODE, BACKSLASH_ESCAPE и им подобные — константы из common_modes.dart

Восстановление циклических ссылок

Во время выполнения язык должен быть «скомпилирован», прежде чем его можно будет использовать для подсветки.

Это означает, что все «опорные» режимы должны быть заменены их словарными записями: каждый Mode(ref: "~contains~...") заменяется соответствующим Mode объектом из Language.refs карты.

Вернув циклические ссылки, мы можем выполнять рекурсивное выделение строковых литералов, содержащих интерполяцию, содержащую строковые литералы и т. д.

На данный момент мы готовы выделить большинство языков, которые поддерживает HighlightJS. Фактический анализ кода и сопоставление с режимами выполняется в ядре пакета, которое мы перенесли в Dart вручную.

Тестирование

У HighlightJS есть золотые тесты. Для каждого поддерживаемого языка у него есть фрагменты входного кода и справочный HTML-код, который будет создан при их выделении.

Нам нужно взять эти фрагменты и передать их пакету Dart, чтобы увидеть, выделяет ли он их в том же HTML, что и исходный HighlightJS.

Это так же просто, как:

  1. Клонируйте репозиторий HighlightJS
  2. Проверьте тег с версией, которую мы портируем
  3. Найти все входные фрагменты
  4. Запустите подсветку Dart
  5. Сравните с эталонным HTML

Эта работа является разовой, поэтому не требуется никакой работы по конкретному языку.

Оригинальный пакет Dart сделал это. Мы дополнительно заставили инструмент записывать фактический вывод, когда он не соответствует золотому. Итак, для каждого несоответствия у нас есть каталог:

Для каждого сломанного языка у нас есть каталог со всеми сломанными тестами. Для каждого из них у нас есть три файла: исходный фрагмент кода, фактически выделенный HTML-код и ожидаемый выделенный HTML-код, поэтому мы можем легко сравнить:

Исправление сложных языков

На тот момент большинство языков работали, и тесты показали нам, что именно не работает. Мы проверили каждый сломанный язык, чтобы выявить и портировать редко используемые механизмы в HighlightJS.

Обратные вызовы

К сожалению, не все определения языка являются декларативными. Некоторые режимы используют обратные вызовы при сопоставлении с кодом.

Например, в PHP многострочная строка начинается с токена, который также завершает ее (так называемый синтаксис «Heredoc»):

<?php
echo <<<THIS_TOKEN_BEGINS_AND_ENDS_THE_STRING
a
b
c
THIS_TOKEN_BEGINS_AND_ENDS_THE_STRING;

Трудно придумать декларативное решение для соответствия таким шаблонам. Вот как HighlightJS определяет Mode для такого синтаксиса:

const HEREDOC = hljs.END_SAME_AS_BEGIN({
  begin: /<<<[ \t]*(\w+)\n/,
  end: /[ \t]*(\w+)\b/,
  contains: hljs.QUOTE_STRING_MODE.contains.concat(SUBST),
});

Функция END_SAME_AS_BEGIN добавляет два обратных вызова к переданному Mode:

/**
 * Adds end same as begin mechanics to a mode
 *
 * Your mode must include at least a single () match group as that first match
 * group is what is used for comparison
 * @param {Partial<Mode>} mode
 */
export const END_SAME_AS_BEGIN = function(mode) {
  return Object.assign(mode,
    {
      /** @type {ModeCallback} */
      'on:begin': (m, resp) => { resp.data._beginMatch = m[1]; },
      /** @type {ModeCallback} */
      'on:end': (m, resp) => { if (resp.data._beginMatch !== m[1]) resp.ignoreMatch(); }
    });
};

Это приводит к следующему эффективному определению этого Mode:

const HEREDOC = {
  begin: /<<<[ \t]*(\w+)\n/,
  end: /[ \t]*(\w+)\b/,
  'on:begin': (m, resp) => {
    resp.data._beginMatch = m[1];
  },
  'on:end': (m, resp) => {
    if (resp.data._beginMatch !== m[1])
      resp.ignoreMatch();
  }
  contains: [ /* Skipping complex things here. */ ],
};

Обратите внимание на записи on:begin и on:end.

Первый обратный вызов вызывается, когда совпадает начало Mode. Он хранит токен, соответствующий регулярному выражению.

Второй обратный вызов вызывается, когда совпадает конец Mode. Это заставляет ядро ​​​​игнорировать совпадение, если конечный токен отличается от того, который указал совпадение.

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

Наиболее распространенные обратные вызовы были созданы такими функциями, как END_SAME_AS_BEGIN. Для них мы составили следующую карту:

const commonCallbacks = new Map<string, string>([
  [hljs.END_SAME_AS_BEGIN({})["on:begin"]!.toString(), "endSameAsBeginOnBegin"],
  [hljs.END_SAME_AS_BEGIN({})["on:end"]!.toString(), "endSameAsBeginOnEnd"],
  // Skipping some more.
]);

В JavaScript function.toString() возвращает код функции. Мы используем этот факт для заполнения карты.

Мы добавили новую проверку при сериализации определения языка в JSON. Мы проверяем все свойства и находим обратные вызовы. Если тело обратного вызова найдено в этой карте, мы заменяем его именем функции Dart.

Другие обратные вызовы специфичны для языков, в которых они используются. Например, в определении языка JavaScript есть огромный обратный вызов, который проверяет, является ли что-то тегом JSX.

Как и в случае любого обратного вызова, мы вручную перенесли его (TODO: LINK) в Dart. Для обратных вызовов, зависящих от языка, мы просто генерируем имена функций Dart из пути, по которому был определен этот обратный вызов:
language_javascript_contains_0_contains_0_variants_0_onBegin

Автоопределение

HighlightJS может автоматически определять язык. Изначально мы хотели пропустить это, чтобы быстрее выполнить явное выделение. Но оказалось, что и для этого нужно автоопределение.

Например, язык XML может обрабатывать тег style двумя способами:

  • Если он содержит CSS, он должен быть выделен как CSS, а вложенный XML не должен анализироваться.
  • В противном случае обработайте его как обычный тег и рекурсивно выделите его содержимое как XML.

Вот как определяются эти правила:

{
  className: 'tag',
  /*
  The lookahead pattern (?=...) ensures that 'begin' only matches
  '<style' as a single word, followed by a whitespace or an
  ending bracket.
  */
  begin: /<style(?=\s|>)/,
  end: />/,
  keywords: { name: 'style' },
  contains: [ TAG_INTERNALS ],
  starts: {
    end: /<\/style>/,
    returnEnd: true,
    subLanguage: [
      'css',
      'xml'
    ]
  }
},

Следовательно, даже для того, чтобы выделить фрагмент кода как явный XML, мы должны иметь возможность определять различные языки в его содержимом.

Мы также портировали часть ядра с автоматическим определением, и это исправило для нас еще несколько языков.

Улучшения по сравнению с исходным пакетом Dart

В результате вот как мы улучшили пакет, которому было 2 года:

  • Улучшенная устойчивость к синтаксическим ошибкам, бонус новой версии HighlightJS. Во многих языках любая отсутствующая кавычка отключала выделение всего документа. Так же как и отсутствующее двоеточие в Python. Это сильно мешало использованию в редакторах кода, где код был неполным при наборе. Теперь это работает:

  • Улучшено выделение деталей на языках с новыми обратными вызовами. В более старых версиях HighlightJS PHP и другие программы должны были давать ложноположительные результаты на концах строк без этой проверки. Это привело к ошибкам, которые было чрезвычайно трудно найти:

  • Обновлены некоторые языки. Например, старая версия Dart не выделяла ключевое слово required, а старая версия Java не поддерживала многострочные строки.
  • Добавлены новые языки: C, LaTeX, NestedText, Node REPL, Python REPL, WebAssembly и Wren.
  • Сделал инструмент переноса на TypeScript вместо JavaScript, чтобы его было проще поддерживать.
  • Получил четкий и задокументированный рабочий процесс для переноса, поэтому пакет больше никогда не будет заброшен.

Команда

Приложения для этого пакета

Многие приложения могут использовать подсветку синтаксиса кода, включая, помимо прочего:

  • Редакторы кода
  • Мессенджеры
  • Просмотрщики документации

Что вы с ним сделаете? Пожалуйста, оставьте ссылку на ваше приложение в комментарии.

Мы работаем над редактором кода, который использует этот пакет для выделения кода по мере его редактирования. Хотите узнать об этом? Подпишитесь на нас, чтобы получать уведомления!