Lição para a noite: realçando a sintaxe da escrita

Recentemente, fiquei interessado em como o destaque do código é organizado por dentro. A princípio, parecia que tudo era muito complicado lá - uma árvore de sintaxe, recursão, e isso era tudo. No entanto, após uma inspeção mais detalhada, verificou-se que não há nada difícil aqui. Todo o trabalho pode ser feito em um ciclo com espiadas, além disso, expressões regulares quase nunca são usadas no script resultante.

Página de demonstração: Marcador de código Javascript

Ideia principal


Declaramos a variável state , que armazenará informações sobre em que parte do código estamos. Se, por exemplo, state for igual a um, isso significa que estamos dentro de uma string com aspas simples. O script aguardará a citação de fechamento e ignorará todo o resto. A mesma coisa ao destacar comentários, regexp e outros elementos, cada um tem seu próprio valor de estado . Assim, diferentes caracteres de abertura e fechamento não entrarão em conflito; em outras palavras, um código como este:

let a = '"\'"'; 

serão corretamente destacados, ou seja, esses casos causaram mais dificuldades.

Introdução


Determinamos os possíveis valores da variável state, bem como a cor na qual essa ou aquela parte do código será colorida, bem como uma lista de palavras-chave Javascript (que também serão destacadas):

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('|'); 


Em seguida, criamos uma função que pegará uma linha com o código e retornará o HTML final com o código destacado. Para realçar, os caracteres serão agrupados em SPAN com a cor especificada na variável colors .

A função terá apenas um ciclo, que analisa cada caractere e adiciona SPANs de abertura / fechamento quando necessário.

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

Primeiro, destaque os comentários: linha única e multilinha. Se o caractere atual e o próximo for uma barra, e eles não estiverem dentro da linha ( estado é 0, ou seja, states.NONE ), então este é o começo do comentário. Mude o estado e abra o SPAN com a cor desejada:

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

continue é necessário para que as seguintes verificações não funcionem e não ocorra um conflito.

Em seguida, aguardamos o final da linha: se o caractere atual for uma quebra de linha e no estado um comentário de linha única, feche o SPAN e mude o estado para zero:

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

Da mesma forma, estamos procurando comentários com várias linhas, o algoritmo é exatamente o mesmo, apenas os caracteres que você procura são 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; } 

O realce de strings ocorre de maneira semelhante, mas é necessário levar em consideração que as aspas finais podem ser escapadas com uma barra invertida e, portanto, já deixam de ser uma barra final.

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

O código é semelhante ao que já estava acima, só que agora não registramos o final da linha se houver uma barra invertida antes da cotação.

A definição de cadeias de caracteres duplas ocorre exatamente da mesma maneira e faz pouco sentido analisá-las em detalhes. Para completar a imagem, colocarei sob o 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; } 


Literais regexp, que são facilmente confundidos com o sinal de divisão, merecem uma consideração separada. Voltaremos a esse problema no final do artigo, mas por enquanto estamos fazendo o mesmo com regexps e com strings.

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

Isso termina casos simples quando o início e o fim de um literal podem ser determinados por um a dois caracteres. Vamos começar a destacar números: como você sabe, eles sempre começam com um número, mas podem ter letras na composição ( 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>' } 

Aqui estamos procurando o início de um número: o caractere anterior não deve ser um número ou letra, caso contrário, os números nos nomes das variáveis ​​serão destacados. Assim que o caractere atual não for um número ou uma letra que possa estar contida no literal de um número, feche o SPAN e defina o estado como zero.

Todos os tipos possíveis de literais são destacados, a pesquisa por palavras-chave permanece. Para fazer isso, você precisa de um loop aninhado que olhe para o futuro e determine se o caractere atual é o início da palavra-chave.

 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 + '">'; } } 

Aqui nós olhamos, o caractere anterior não pode estar no nome da variável; caso contrário, deixe que a palavra-chave seja destacada na saída de palavras. Em seguida, o loop aninhado coleta a palavra mais longa possível até que um caractere não alfabético seja encontrado. Se a palavra recebida estiver na matriz de palavras - chave , abra o SPAN e comece a destacar a palavra. Assim que um caractere não alfabético for encontrado, isso significa o fim da palavra - portanto, feche o SPAN:

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

A coisa mais simples permanece - o destaque dos operadores, aqui você pode simplesmente comparar com o conjunto de caracteres que pode ocorrer nos operadores:

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

No final do loop, se nenhuma das condições que continuar continua a causa for acionada, simplesmente adicionamos o caractere atual à variável resultante. Quando ocorre o início ou o fim de um literal ou palavra-chave, abrimos / fechamos o SPAN com cores; em todos os outros casos - por exemplo, quando a linha já está aberta, jogamos apenas um caractere de cada vez. Também vale a pena proteger os colchetes angulares de abertura, caso contrário, eles podem quebrar o layout.

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

Bug fix


De alguma forma, tudo parecia simples demais, e não em vão: com testes mais detalhados, houve casos em que a luz de fundo não funcionava corretamente.

A divisão é reconhecida como regexp. Para diferenciar uma da outra, será necessário alterar a maneira como a regexp é determinada. Declaramos a variável isRegex = true , após o que tentaremos "provar" que isso não é regexp, mas um sinal de divisão. Não pode haver palavras-chave ou colchetes antes da operação de divisão - portanto, criamos um loop aninhado e vemos o que a barra enfrenta.

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

Embora essa abordagem resolva o problema, ainda não está isenta de falhas. Você pode ajustá-lo para que esse algoritmo também destaque incorretamente, por exemplo: if (a) / regex / ou mais: 1 / / regex / / 2 . Por que uma pessoa que divide números em regexp precisa de destaque de código - essa é outra questão; o design é sintaticamente correto, embora não ocorra na vida real.

Existem problemas com a coloração regexp em muitos trabalhos, por exemplo, em prism.js . Aparentemente, para o realce correto dos regexps, você precisa entender completamente a sintaxe, como os navegadores.

O segundo bug com o qual eu tive que lidar estava relacionado a barras invertidas. As aspas de fechamento não foram reconhecidas em uma sequência do formulário 'test \\' devido à presença de uma barra invertida na frente dele. De volta à condição que captura o final da linha:

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

A última parte da condição precisa ser alterada: se a barra invertida for escapada (ou seja, houver outra barra invertida antes dela), registre o final da linha.

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

As mesmas substituições devem ser feitas na pesquisa de cadeias com aspas duplas e reversas, bem como na pesquisa de regexp.

Isso é tudo, você pode testar o destaque pelo link no início do artigo.

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


All Articles