Недавно заинтересовался, как устроена подсветка кода изнутри. Сначала казалось, что там все дико сложно — синтаксическое дерево, рекурсия и вот это все. Однако при более близком рассмотрении оказалось, что ничего трудного здесь нет. Всю работу можно проделать в одном цикле с заглядываниями вперед и назад, более того, в получившемся скрипте почти не используются регулярные выражения.
Демо-страница: Javascript Code Highlighter
Основная идея
Объявляем переменную state, в которой будет храниться информация о том, в какой части кода мы находимся. Если, например, state равен единице, то это значит, что мы внутри строки с одинарными кавычками. Скрипт будет ждать закрывающую кавычку и игнорировать все остальное. То же самое с подсветкой комментариев, регэкспов и других элементов, для каждого свое значение state. Таким образом, разные открывающие и закрывающие символы не будут конфликтовать; иначе говоря, код наподобие такого:
let a = '"'"';
будет правильно подсвечен, а именно такие случаи вызывали больше всего затруднений.
Начало работы
Определяем возможные значения переменной state, а также цвет, в который будет раскрашена та или иная часть кода, а также список ключевых слов языка Javascript (которые тоже будут подсвечены):
const states = {
NONE : 0,
SINGLE_QUOTE : 1, // 'string'
DOUBLE_QUOTE : 2, // "string"
ML_QUOTE : 3, // `string`
REGEX_LITERAL : 4, // /regex/
SL_COMMENT : 5, // // single line comment
ML_COMMENT : 6, // /* multiline comment */
NUMBER_LITERAL : 7, // 123
KEYWORD : 8 // function, var etc.
};
const colors = {
NONE : '#000',
SINGLE_QUOTE : '#aaa', // 'string'
DOUBLE_QUOTE : '#aaa', // "string"
ML_QUOTE : '#aaa', // `string`
REGEX_LITERAL : '#707', // /regex/
SL_COMMENT : '#0a0', // // single line comment
ML_COMMENT : '#0a0', // /* multiline comment */
NUMBER_LITERAL : '#a00', // 123
KEYWORD : '#00a', // function, var etc.
OPERATOR : '#07f' // null, true etc.
};
const keywords = 'async|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|of|package|private|protected|public|return|set|static|super|switch|throw|try|typeof|var|void|while|with|yield|catch|finally'.split('|');
Далее создаем функцию, которая будет принимать строку с кодом и возвращать готовый HTML с подсвеченным кодом. Для подсветки символы будут оборачиваться в SPAN с цветом, указанным в переменной colors.
Функция будет иметь всего один цикл, который анализирует каждый символ и добавляет открывающие/закрывающие SPAN'-ы, когда это нужно.
function highlight(code) {
let output = '';
let state = states.NONE;
for (let i = 0; i < code.length; i++) {
let char = code[i], prev = code[i-1], next = code[i+1];
// здесь будет анализ кода
}
return output;
}
Для начала подсветим комментарии: однострочные и многострочные. Если текущий и следующий символ — слэш, и они не находятся внутри строки (state равна 0, то есть states.NONE), то это начало комментария. Меняем state и открываем SPAN с нужным цветом:
if (state == states.NONE && char == '/' && next == '/') {
state = states.SL_COMMENT;
output += '<span style="color: ' + colors.SL_COMMENT + '">' + char;
continue;
}
continue нужен для того, чтобы не сработали следующие проверки и не получилось конфликта.
Далее ждем конца строки: если текущий символ — перенос строки и в state однострочный комментарий, закрываем SPAN и меняем state на ноль:
if (state == states.SL_COMMENT && char == 'n') {
state = states.NONE;
output += char + '</span>';
continue;
}
Аналогично ищем многострочные комментарии, алгоритм точно такой же, только искомые символы другие:
if (state == states.NONE && char == '/' && next == '*') {
state = states.ML_COMMENT;
output += '<span style="color: ' + colors.ML_COMMENT + '">' + char;
continue;
}
if (state == states.ML_COMMENT && char == '/' && prev == '*') {
state = states.NONE;
output += char + '</span>';
continue;
}
Подсветка строк происходит похожим образом, только надо учитывать, что закрывающая кавычка может быть экранирована обратным слэшем, и таким образом, она уже перестает быть закрывающей.
if (state == states.NONE && char == ''') {
state = states.SINGLE_QUOTE;
output += '<span style="color: ' + colors.SINGLE_QUOTE + '">' + char;
continue;
}
if (state == states.SINGLE_QUOTE && char == ''' && prev != '\') {
state = states.NONE;
output += char + '</span>';
continue;
}
Код похож на то, что уже было выше, только теперь мы не регистрируем конец строки, если перед кавычкой был обратный слэш.
Определение строк с двойными и обратными кавычками происходит точно таким же способом, и не имеет особого смысла разбирать их подробно. Для полноты картины все же размещу их под спойлером.
if (state == states.NONE && char == '"') {
state = states.DOUBLE_QUOTE;
output += '<span style="color: ' + colors.DOUBLE_QUOTE + '">' + char;
continue;
}
if (state == states.DOUBLE_QUOTE && char == '"' && prev != '\') {
state = states.NONE;
output += char + '</span>';
continue;
}
if (state == states.NONE && char == '`') {
state = states.ML_QUOTE;
output += '<span style="color: ' + colors.ML_QUOTE + '">' + char;
continue;
}
if (state == states.ML_QUOTE && char == '`' && prev != '\') {
state = states.NONE;
output += char + '</span>';
continue;
}
Отдельного рассмотрения стоят регэксп-литералы, которые легко спутать со знаком деления. К этой проблеме мы вернемся к концу статьи, а пока что делаем с регэкспами то же, что со строками.
if (state == states.NONE && char == '/') {
state = states.REGEX_LITERAL;
output += '<span style="color: ' + colors.REGEX_LITERAL + '">' + char;
continue;
}
if (state == states.REGEX_LITERAL && char == '/' && prev != '\') {
state = states.NONE;
output += char + '</span>';
continue;
}
На этом заканчиваются простые случаи, когда начало и конец литерала можно определить по 1-2 символам. Приступим к подсветке чисел: как известно, они всегда начинаются на цифру, но могут иметь буквы в составе (0xFF, 123n).
if (state == states.NONE && /[0-9]/.test(char) && !/[0-9a-z$_]/i.test(prev)) {
state = states.NUMBER_LITERAL;
output += '<span style="color: ' + colors.NUMBER_LITERAL + '">' + char;
continue;
}
if (state == states.NUMBER_LITERAL && !/[0-9a-fnx]/i.test(char)) {
state = states.NONE;
output += '</span>'
}
Здесь мы ищем начало числа: предыдущий символ не должен быть цифрой или буквой, иначе будут подсвечиваться цифры в названиях переменных. Как только текущий символ не является цифрой или буквой, которая может содержаться в литерале числа, закрываем SPAN и присваиваем state ноль.
Все возможные виды литералов подсвечены, остался поиск ключевых слов. Для этого потребуется вложенный цикл, который заглядывает вперед и определяет, является ли текущий символ началом ключевого слова.
if (state == states.NONE && !/[a-z0-9$_]/i.test(prev)) {
let word = '', j = 0;
while (code[i + j] && /[a-z]/i.test(code[i + j])) {
word += code[i + j];
j++;
}
if (keywords.includes(word)) {
state = states.KEYWORD;
output += '<span style="color: ' + colors.KEYWORD + '">';
}
}
Здесь мы смотрим, не может предыдущий символ быть в названии переменной, иначе в слове outlet будет подсвечиваться let как ключевое слово. Затем вложенный цикл собирает максимально длинное слово, пока не встретится неалфавитный символ. Если полученное слово есть в массиве keywords, то открываем SPAN и начинаем подсветку слова. Как только встретился неалфавитный символ, это означает конец слова — соответственно, закрываем SPAN:
if (state == states.KEYWORD && !/[a-z]/i.test(char)) {
state = states.NONE;
output += '</span>';
}
Осталось самое простое — подсветка операторов, здесь можно просто сравнивать с набором символов, которые могут встречаться в операторах:
if (state == states.NONE && '+-/*=&|%!<>?:'.indexOf(char) != -1) {
output += '<span style="color: ' + colors.OPERATOR + '">' + char + '</span>';
continue;
}
В конце цикла, если не сработало ни одно из условий, которое вызывает continue, просто добавляем текущий символ в результирующую переменную. Когда встречается начало или конец литерала или ключевого слова, мы открываем/закрываем SPAN с цветом; во всех остальных случаях — например, когда строка уже открыта, мы просто перекидываем по одному символу. Также стоит экранировать открывающие угловые скобки, иначе они могут поломать верстку.
output += char.replace('<', '&' + 'lt;'); // через + потому что хабр заменяет на <
Исправление багов
Все казалось каким-то уж слишком простым, и не напрасно: при более тщательном тестировании нашлись случаи, когда подсветка работает неправильно.
Деление распознается как регэксп, чтобы отличить одно от другого, потребуется изменить способ определения регэкспа. Объявим переменную isRegex = true, после чего попытаемся «доказать», что это не регэксп, а знак деления. Перед операцией деления не может быть ключевых слов и открывающих скобок — поэтому создаем вложенный цикл и смотрим, что стоит перед слэшем.
if (state == states.NONE && char == '/') {
state = states.REGEX_LITERAL;
output += '<span style="color: ' + colors.REGEX_LITERAL + '">' + char;
continue;
}
if (state == states.NONE && char == '/') {
let word = '', j = 0, isRegex = true;
while (i + j >= 0) {
j--;
// перед делением не может быть другого оператора
if ('+/-*=|&<>%,({[?:;'.indexOf(code[i+j]) != -1) break;
// пытаемся собрать слово; неалфавитный символ - прерываем цикл
if (!/[0-9a-z$_]/i.test(code[i+j]) && word.length > 0) break;
// собираем слово, которое идет перед слэшем
if (/[0-9a-z$_]/i.test(code[i+j])) word = code[i+j] + word;
// закрывающая скобка - деление, а не начало регэкспа
if (')]}'.indexOf(code[i+j]) != -1) {
isRegex = false;
break;
}
}
// если перед слэшем ключевое слово - это однозначно регэксп
// для сравнения: return /test/g - регэксп, plainWord /test/g - деление
if (word.length > 0 && !keywords.includes(word)) isRegex = false;
if (isRegex) {
state = states.REGEX_LITERAL;
output += '<span style="color: ' + colors.REGEX_LITERAL + '">' + char;
continue;
}
}
Такой подход хоть и решает проблему, но все равно не лишен изъянов. Можно подстроить так, чтобы и этот алгоритм подсветил неправильно, например так: if (a) /regex/ или так: 1 / /regex/ / 2. Зачем человеку, делящему числа на регэкспы, нужна подсветка кода — это другой вопрос; конструкция синтаксически правильная, хотя и не встречается в реальной жизни. Проблемы с раскраской регэкспов есть во многих работах, например в prism.js. Судя по всему, для правильной подсветки регэкспов придется полноценно разбирать синтаксис, как это делают браузеры.
Второй баг, с которым пришлось столкнуться, был связан с обратными слэшами. В строке вида 'test\' не распознавалась закрывающая кавычка из-за наличия обратного слэша перед ней. Вернемся к условию, которое отлавливает конец строки:
if (state == states.SINGLE_QUOTE && char == ''' && prev != '\')
Последнюю часть условия требуется изменить: если обратный слэш экранирован (т.е. перед ним идет еще один обратный слэш), то регистрируем конец строки.
const closingCharNotEscaped = prev != '\' || prev == '\' && code[i-2] == '\';
// ...
if (state == states.SINGLE_QUOTE && char == ''' && closingCharNotEscaped)
Такие же замены необходимо произвести в поиске строк с двойными и обратными кавычками, а также в поиске регэкспов.
На этом все, потестить подсветку можно по ссылке в начале статьи.
Автор: DiphenylOxalate