Занятие на вечер: пишем подсветку синтаксиса

в 0:06, , рубрики: highlight, highlighter, highlighting, javascript, JS, syntax, подсветка, подсветка кода, подсветка синтаксиса

Недавно заинтересовался, как устроена подсветка кода изнутри. Сначала казалось, что там все дико сложно — синтаксическое дерево, рекурсия и вот это все. Однако при более близком рассмотрении оказалось, что ничего трудного здесь нет. Всю работу можно проделать в одном цикле с заглядываниями вперед и назад, более того, в получившемся скрипте почти не используются регулярные выражения.

Демо-страница: Javascript Code Highlighter

Основная идея

Объявляем переменную state, в которой будет храниться информация о том, в какой части кода мы находимся. Если, например, state равен единице, то это значит, что мы внутри строки с одинарными кавычками. Скрипт будет ждать закрывающую кавычку и игнорировать все остальное. То же самое с подсветкой комментариев, регэкспов и других элементов, для каждого свое значение state. Таким образом, разные открывающие и закрывающие символы не будут конфликтовать; иначе говоря, код наподобие такого:

let a = '"'"';

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

Начало работы

Определяем возможные значения переменной state, а также цвет, в который будет раскрашена та или иная часть кода, а также список ключевых слов языка Javascript (которые тоже будут подсвечены):

const states = {...

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 == '''') {...

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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js