Lección para la noche: escribir resaltado de sintaxis

Recientemente me interesé en cómo se organiza el resaltado del código desde adentro. Al principio parecía que todo era muy complicado allí: un árbol de sintaxis, recursión, y eso era todo. Sin embargo, después de una inspección más cercana, resultó que no hay nada difícil aquí. Todo el trabajo se puede hacer en un ciclo con miradas de un lado a otro, además, las expresiones regulares casi nunca se usan en el script resultante.

Página de demostración: resaltador de código Javascript

Idea principal


Declaramos la variable de estado , que almacenará información sobre en qué parte del código estamos. Si, por ejemplo, el estado es igual a uno, esto significa que estamos dentro de una cadena con comillas simples. El script esperará la cita de cierre e ignorará todo lo demás. Lo mismo con resaltar comentarios, expresiones regulares y otros elementos, cada uno tiene su propio valor de estado . Por lo tanto, diferentes caracteres de apertura y cierre no entrarán en conflicto; en otras palabras, un código como este:

let a = '"\'"'; 

se resaltará correctamente, es decir, tales casos causaron la mayoría de las dificultades.

Empezando


Determinamos los posibles valores de la variable de estado, así como el color en el que se pintará esta o aquella parte del código, así como una lista de palabras clave de Javascript (que también se resaltará):

estados const = {...
 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('|'); 


A continuación, creamos una función que tomará una línea con el código y devolverá el HTML terminado con el código resaltado. Para resaltar, los caracteres se envolverán en SPAN con el color especificado en la variable de colores .

La función tendrá solo un ciclo, que analiza cada carácter y agrega SPAN de apertura / cierre cuando sea necesario.

 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; } 

Primero, resalte los comentarios: una línea y varias líneas. Si el carácter actual y el siguiente es una barra diagonal y no están dentro de la línea (el estado es 0, es decir, estados . NINGUNO ), entonces este es el comienzo del comentario. Cambiar estado y abrir SPAN con el color deseado:

 if (state == states.NONE && char == '/' && next == '/') { state = states.SL_COMMENT; output += '<span style="color: ' + colors.SL_COMMENT + '">' + char; continue; } 

continuar es necesario para que las siguientes comprobaciones no funcionen y no se produzca un conflicto.

A continuación, esperamos el final de la línea: si el carácter actual es un salto de línea y en estado un comentario de una sola línea, cierre el SPAN y cambie el estado a cero:

 if (state == states.SL_COMMENT && char == '\n') { state = states.NONE; output += char + '</span>'; continue; } 

Del mismo modo, estamos buscando comentarios de varias líneas, el algoritmo es exactamente el mismo, solo los caracteres que está buscando son diferentes:

 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; } 

El resaltado de las cadenas se produce de manera similar, solo debe tenerse en cuenta que la comilla de cierre se puede escapar con una barra diagonal inversa y, por lo tanto, ya no es una barra diagonal.

 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; } 

El código es similar a lo que ya estaba arriba, solo que ahora no registramos el final de la línea si hubo una barra invertida antes de la cotización.

La definición de cadenas entre comillas dobles ocurre exactamente de la misma manera, y tiene poco sentido analizarlas en detalle. Para completar la imagen, los colocaré debajo del spoiler.

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; } 


Los literales Regexp, que se confunden fácilmente con el signo de división, merecen una consideración aparte. Volveremos a este problema al final del artículo, pero por ahora estamos haciendo lo mismo con expresiones regulares que con cadenas.

 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; } 

Esto finaliza casos simples cuando el principio y el final de un literal se pueden determinar con 1-2 caracteres. Comencemos por resaltar números: como saben, siempre comienzan con un número, pero pueden tener letras en la composición ( 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>' } 

Aquí estamos buscando el comienzo de un número: el carácter anterior no debe ser un número o letra, de lo contrario, se resaltarán los números en los nombres de las variables. Tan pronto como el carácter actual no sea un número o una letra que pueda estar contenida en el literal de un número, cierre el SPAN y establezca el estado en cero.

Todos los tipos posibles de literales están resaltados, la búsqueda de palabras clave permanece. Para hacer esto, necesita un bucle anidado que mire hacia adelante y determine si el carácter actual es el comienzo de la palabra clave.

 if (state == states.NONE && !/[a-z0-9$_]/i.test(prev)) { let word = '', j = 0; while (code[i + j] && /[az]/i.test(code[i + j])) { word += code[i + j]; j++; } if (keywords.includes(word)) { state = states.KEYWORD; output += '<span style="color: ' + colors.KEYWORD + '">'; } } 

Aquí miramos, el carácter anterior no puede estar en el nombre de la variable; de ​​lo contrario, deje que la palabra clave se resalte en la salida de la palabra. Luego, el bucle anidado recopila la palabra más larga posible hasta que se encuentre un carácter no alfabético. Si la palabra recibida está en la matriz de palabras clave , abra el SPAN y comience a resaltar la palabra. Tan pronto como se encuentre un carácter no alfabético, esto significa el final de la palabra; en consecuencia, cierre el SPAN:

 if (state == states.KEYWORD && !/[az]/i.test(char)) { state = states.NONE; output += '</span>'; } 

Lo más simple sigue siendo: el resaltado de operadores, aquí simplemente puede comparar con el conjunto de caracteres que pueden aparecer en los operadores:

 if (state == states.NONE && '+-/*=&|%!<>?:'.indexOf(char) != -1) { output += '<span style="color: ' + colors.OPERATOR + '">' + char + '</span>'; continue; } 

Al final del ciclo, si no se activa ninguna de las condiciones que continúan la causa continua , simplemente agregamos el carácter actual a la variable resultante. Cuando se produce el comienzo o el final de un literal o palabra clave, abrimos / cerramos el SPAN con color; en todos los demás casos, por ejemplo, cuando la línea ya está abierta, solo lanzamos un carácter a la vez. También vale la pena proteger los soportes de ángulo de apertura, de lo contrario pueden romper el diseño.

 output += char.replace('<', '&' + 'lt;'); //  +      < 

Corrección de errores


Todo parecía de alguna manera demasiado simple, y no en vano: con pruebas más exhaustivas, había casos en los que la luz de fondo no funcionaba correctamente.

La división se reconoce como regexp, para distinguir una de la otra, será necesario cambiar la forma en que se determina regexp. Declaramos que la variable esRegex = true , después de lo cual trataremos de "probar" que esto no es regexp, sino un signo de división. No puede haber palabras clave o corchetes de apertura antes de la operación de división; por lo tanto, creamos un bucle anidado y vemos a qué se enfrenta la barra diagonal.

Como era antes
 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; } } 

Aunque este enfoque resuelve el problema, todavía no está exento de defectos. Puede ajustarlo para que este algoritmo también se resalte incorrectamente, por ejemplo: if (a) / regex / or so: 1 / / regex / / 2 . ¿Por qué una persona que divide los números en expresiones regulares necesita resaltar el código? Esta es otra pregunta; El diseño es sintácticamente correcto, aunque no ocurre en la vida real.

Hay problemas con la coloración regexp en muchas obras, por ejemplo en prism.js . Aparentemente, para resaltar correctamente las expresiones regulares, debe comprender completamente la sintaxis, como lo hacen los navegadores.

El segundo error con el que tuve que lidiar estaba relacionado con las barras invertidas. No se reconoció una comilla de cierre en una cadena de la forma 'test \\' debido a la presencia de una barra invertida frente a ella. Volver a la condición que atrapa el final de la línea:

 if (state == states.SINGLE_QUOTE && char == '\'' && prev != '\\') 

La última parte de la condición debe cambiarse: si la barra invertida se escapa (es decir, hay otra barra invertida antes), entonces registre el final de la línea.

 const closingCharNotEscaped = prev != '\\' || prev == '\\' && code[i-2] == '\\'; // ... if (state == states.SINGLE_QUOTE && char == '\'' && closingCharNotEscaped) 

Se deben hacer los mismos reemplazos en la búsqueda de cadenas con comillas dobles e inversas, así como en la búsqueda de expresiones regulares.

Eso es todo, puede probar el resaltado mediante el enlace al comienzo del artículo.

Source: https://habr.com/ru/post/482884/


All Articles