Extendendo o syntax highlighter Rouge para uma nova linguagem!
Estava eu feliz escrevendo o post de programação linear e descobri que não tinha syntax-highlighter padrão para a linguagem GMPL! Então, o que eu fiz? Fui atrás de fazer o meu!
Achando o syntax-highlighter
Vamos começar pelo básico. Eu uso o Jekyll. Tem algumas customizações? Tem, mas
a maioria das coisas são de fábrica. É tanto que o único plugin externo que eu
coloquei foi o jekyll-katex da época da
criação do blog.
Depois disso, adicionei algumas funcionalidades extras através de plugins
internos, como o normalizador de tags
normalize_tags.
Dito isso, e vendo que não achei um caminho direto para o syntax highlighter,
fui olhar o padrão que o Jekyll usa para Markdown. E temos resposta para isso
em doc oficial. E essa doc
mostra como personalizar o kramdown, que é o processador padrão de markdown
do Jekyll. Ainda não fala quem é que faz o syntax highlighting, mas ao menos já
restringe bastante as opções! E também significa que não é o CodeRay!
Olhando pra documentação do kramdown, mexendo aqui e ali finalmente paramos
na parte da
documentação sobre syntax highlighter.
Finalmente nossa busca está chegado ao fim! E aqui descobri que é a gem chamada
de rouge.
Originalmente ela era hospedada no GitHub, mas recentemente o código foi totalmente migrado para Codeberg.
Bem, agora sabemos qual o syntax-highlighter que se usa.
Antes da primeira escrita
O primeiro experimento foi simplesmente fazer uma leitura do Lexer de Brainfuck. Em cima dele, algumas coisas se destacam:
- é um lexer baseado em regex (herda diretamente
Brainfuck < RegexLexer) - tem diversos estados
- cada estado permite uma interpretação específica
Um cuidado para se tomar é que, como a ideia é um syntax highlighter e não um validador do programa, eu posso passar um simples trecho e o syntax highlighter precisa aceitar. Por exemplo:
s.t. azul11 <= 1;
subject to c_azules: azul11 + azul12 - azul >= 0;
Independente de que eu preciso primeiro passar pelo trecho de declaração de variáveis.
Outra coisa relacionada a estados, que vou precisar tomar cuidado quando
estiver lidando com Kof: abrir aspas eu passo a
estar no estado de string, então não devo considerar que "class Abc" vá fazer
destaque sintático porque class é uma palavra-chave da linguagem. Afinal,
estarei dentro do estado de string, preciso levar em consideração tudo nas
aspas como se fosse uma string.
Além disso, percebi que tag vai criar a tag que identifica no bloco sintático
que faz o highlight. Por exemplo:
```brainfuck
+++++[-]
```
Além da tag, também tem o aliases que permite que eu faça com o bloco bf:
```bf
+++++[-]
```
Existe também o estado inicial via start.
Olhando a hierarquia, encontrei em
Rouge::Lexer
o método register, porém não distingui como que ele era de fato chamado na
hierarquia de carregamento das classes. Fica no automágico.
Curiosidade sobre fenced blocks
Eu precisei escrever o como se faz um fenced block para o exemplo do Brainfuck, né? Para isso, precisei deixar visível o triplo backtick. A questão é, como que faz isso?
No padrão, o markdown (e nisso o kramdown segue) que usamos no dia-a-dia permite que eu defina o como vou começar o trecho de bloco de código. Só tem uma limitação: que sejam pelo menos 3 backticks. Ou seja: posso usar mais!
No caso específico dos examples acima, usei blocos com 5 backticks para iniciar
e fechar, usando a linguagem md para fazer o destaque sintático correto. Vou
até ilustrar abaixo como que eu fiz (e, por sinal, para poder colocar o exemplo
com 5 backticks estou usando blocos também de md com 7 backticks):
# Tag
`````md
```brainfuck
+++++[-]
```
`````
# Alias
`````md
```bf
+++++[-]
```
`````
Tem alguma convenção seguir números ímpares? Não, nenhuma. Apenas me apetece visualmente. A diferença de apenas um backtick não me é tão facilmente perceptível.
O primeiro lexer
Antes de tentar fazer funcionar para GMPL, defini uma linguagem minha:
abobora. Essa linguagem basicamente tem uma única palavra chave, chamada de
abobora:
abobora
abobra
E eis o código para fazer rodar:
require "rouge"
require "rouge/lexer"
module Rouge
module Lexers
class Abobora < RegexLexer
tag 'abobora'
start do
push :root
end
state :inline_whitespace do
rule %r/[ \t\r]+/, Text
rule %r/\\\n/, Text # line continuation
end
state :whitespace do
rule %r/\n+/m, Text
mixin :inline_whitespace
end
state :root do
mixin :whitespace
rule %r/abobora/, Keyword
end
end
end
end
Ao subir o blog tem warning sobre redefinição de módulos? Tem…
~/.asdf/installs/ruby/4.0.1/lib/ruby/gems/4.0.0/gems/rouge-4.7.0/lib/rouge/lexer.rb:528: warning: already initialized constant Rouge::Lexers::BASE_DIR
~/.asdf/installs/ruby/4.0.1/lib/ruby/gems/4.0.0/gems/rouge-4.7.0/lib/rouge/lexer.rb:528: warning: previous definition of BASE_DIR was here
Mas subiu.
Ah, por que você está importando o
rouge/lexer?
Sendo sincero? Só tava tentando fazer funcionar. Meu primeiro teste foi
simplesmente ver se eu encontrava o lexer de abobora, então adicionei a busca
pelo lexer no meu plugin:
require "rouge"
require "rouge/lexer"
module Rouge
module Lexers
class Abobora < RegexLexer
# o resto do lexer...
end
end
end
p Rouge::Lexer.find("abobora")
E eis que imprime:
Rouge::Lexers::Abobora
O que significa que sim, estamos encontrando o lexer e o register funciona
magicamente!
As primeiras tentativas de fazer o lexer funcionar fora do módulo
Rouge::Lexers falharam e eu desisti nesse instante… então, vamos para a
implementação?
Lexer de GMPL
O primeiro lexer de uma lang de verdade que eu fui fazer agora peguei o exemplo do lexer C. Por que o lexer de C? Porque foi o que eu lembrei com menos palavras chaves. E eis que fiz o seguinte código:
require "rouge"
require "rouge/lexer"
module Rouge
module Lexers
class GMPL < RegexLexer
# baseado em https://github.com/rouge-ruby/rouge/blob/v4.7.0/lib/rouge/lexers/c.rb
tag 'gmpl'
desc 'GNU MathProg Lang'
ws = %r((?:\s|//.*?\n|/[*].*?[*]/)+)
id = /[a-zA-Z_][a-zA-Z0-9_]*/
def self.keywords
@keywords ||= Set.new %w(
"subject to"
"subj to"
"s.t."
var
min
minimize
max
maximize
end
integer
solve
display
printf
)
end
start { push :bol }
state :expr_bol do
mixin :inline_whitespace
rule %r/#if\s0/, Comment, :if_0
rule %r/#/, Comment::Preproc, :macro
rule(//) { pop! }
end
state :bol do
rule %r/#{id}:(?!:)/, Name::Label
mixin :expr_bol
end
state :inline_whitespace do
rule %r/[ \t\r]+/, Text
rule %r/\\\n/, Text # line continuation
rule %r(/(\\\n)?[*].*?[*](\\\n)?/)m, Comment::Multiline
end
state :whitespace do
rule %r/\n+/m, Text, :bol
rule %r(//(\\.|.)*?$), Comment::Single, :bol
mixin :inline_whitespace
end
state :root do
mixin :whitespace
rule %r((\d+[.]\d*|[.]?\d+)e[+-]?\d+[lu]*)i, Num::Float
rule %r/\d+[lu]*/i, Num::Integer
rule %r([+-/*<>=]), Operator
rule %r([:;]), Punctuation
rule %r/subject to/, Keyword
rule %r/subj to/, Keyword
keywords id do
rule :keywords, Keyword
default Name
end
end
end
end
end
E… isso quebrou imediatamente. A versão do rouge que estava sendo usada era
a 4.7.0, e eu peguei o exemplo do C na versão 5.1.0, que adicionava a API
keywords. Então eu voltei no tempo e peguei da tag 4.7.0:
require "rouge"
require "rouge/lexer"
module Rouge
module Lexers
class GMPL < RegexLexer
# baseado em https://github.com/rouge-ruby/rouge/blob/v4.7.0/lib/rouge/lexers/c.rb
tag 'gmpl'
desc 'GNU MathProg Lang'
ws = %r((?:\s|//.*?\n|/[*].*?[*]/)+)
id = /[a-zA-Z_][a-zA-Z0-9_]*/
def self.keywords
@keywords ||= Set.new %w(
"subject to"
"subj to"
"s.t."
var
min
minimize
max
maximize
end
integer
solve
display
printf
)
end
start { push :bol }
state :expr_bol do
mixin :inline_whitespace
rule %r/#if\s0/, Comment, :if_0
rule %r/#/, Comment::Preproc, :macro
rule(//) { pop! }
end
state :bol do
rule %r/#{id}:(?!:)/, Name::Label
mixin :expr_bol
end
state :inline_whitespace do
rule %r/[ \t\r]+/, Text
rule %r/\\\n/, Text # line continuation
rule %r(/(\\\n)?[*].*?[*](\\\n)?/)m, Comment::Multiline
end
state :whitespace do
rule %r/\n+/m, Text, :bol
rule %r(//(\\.|.)*?$), Comment::Single, :bol
mixin :inline_whitespace
end
state :root do
mixin :whitespace
rule %r((\d+[.]\d*|[.]?\d+)e[+-]?\d+[lu]*)i, Num::Float
rule %r/\d+[lu]*/i, Num::Integer
rule %r([+-/*<>=]), Operator
rule %r([:;]), Punctuation
rule %r/subject to/, Keyword
rule %r/subj to/, Keyword
rule id do |m|
name = m[0]
if self.class.keywords.include? name
token Keyword
else
token Name
end
end
end
end
end
end
E pronto, funcionou!
Ajustes
Bem, após bater cabeça contra o lexer… achei mais recursos sobre o assunto. Entre eles:
Mas principalmente, esse achei esse aqui: rouge-plugin-example.
Em cima disso percebi que estava cometendo alguns erros. Um deles era
requisitar rouge/lexer no lugar de importar apenas o rouge e deixar a gem
se resolver.
Outro ponto, que inclusive eu desconhecia completamente, é que eu posso fazer
o require condicionado em não existir um módulo:
require "rouge" unless defined?(Rouge)
E, finalmente, necessitar usar o módulo Rouge::Lexers era decorrente da minha
bagunça ao fazer os require. Usando o padrão do exemplo de
plugin para Magritte:
require 'rouge' unless defined?(Rouge)
module RougeMagritte
class Magritte < Rouge::RegexLexer
# provide a unique tag, lower case with underscores
tag 'magritte'
# ...
end
end
Eu consegui transformar o lexer de modo muito mais simples:
require "rouge" unless defined?(Rouge)
module RougeGMPL
class GMPL < Rouge::RegexLexer
# baseado em https://github.com/rouge-ruby/rouge/blob/v4.7.0/lib/rouge/lexers/c.rb
tag 'gmpl'
desc 'GNU MathProg Lang'
# ...
end
end
Sem precisar me preocupar em colocar em um módulo específico.
Além disso, ao tentar usar comentários no esquema original, eu percebi que fiz
uma besteira… porque mapeava para um estado que não existia! O comentário em
GMPL é usando #, onde o resto da linha se torna um comentário. Mas do jeito
que estava feito entrava em um estado inválido:
state :expr_bol do
mixin :inline_whitespace
rule %r/#if\s0/, Comment, :if_0
rule %r/#/, Comment::Preproc, :macro
rule(//) { pop! }
end
No estado :expre_bol, ao encontrar o #, entra no estado :macro. Porém,
esse estado jamais foi descrito. Isso deu problema ao tentar inserir um
comentário no código. Isso me fez reexaminar a questão dos blocos de declaração
sintática. Então resolvi limpar para aquilo que realmente importa:
- geral
- espaço em branco
- comentário
- ajuste nas palavras chaves
subject to, como tinha o espaço separando, acabou não sendo detectado
corretamente nas primeiras implementações. Então acabei retirando ele da lista
de palavras chaves e passei a tratá-lo de modo especial no estado geral:
rule %r/subject#{ws}to/, Keyword
rule %r/subj#{ws}to/, Keyword
rule %r/s\.t\./, Keyword
Comentário acabei usando a lógica usada pelo //:
state :comment_line do
rule %r(#(.*)?\n?), Comment::Single
end
Após bastante tempo eu fui finalmente atrás de ler para entender o propósito dessas linhas abaixo:
rule id do |m|
name = m[0]
if self.class.keywords.include? name
token Keyword
else
token Name
end
end
Aqui, está comparando um id que é basicamente uma letra ou underscore _
seguido de letras, underscores ou dígitos: /[a-zA-Z_][a-zA-Z_]*/. Se casar
com essa regex, as palavras-chaves também vão estar aí nesse balaio. Logo,
precisa definir se é um nome normal (a string indicada por m[0]) ou se é
realmente um identificador normal. Caso seja uma palavra0chave, vai retornar
que é um Keyword, caso contrário será só um nome normal mesmo Name.