| 1 | require "rouge" |
| 2 | require "cgi" |
| 3 | |
| 4 | # Server-side syntax highlighting for the blob view. Returns one HTML fragment |
| 5 | # per source line so each line can be numbered and individually anchored. |
| 6 | # |
| 7 | # The whole file is tokenized in a single pass (so stateful constructs such as |
| 8 | # heredocs and multi-line strings highlight correctly), then the token stream |
| 9 | # is split on newline boundaries. Unknown languages fall back to a plain-text |
| 10 | # lexer; any lexer error falls back to HTML-escaped plain lines. |
| 11 | class SyntaxHighlighter |
| 12 | def self.lines(content, filename:) |
| 13 | new(content, filename: filename).lines |
| 14 | end |
| 15 | |
| 16 | def initialize(content, filename:) |
| 17 | @content = content |
| 18 | @filename = filename |
| 19 | end |
| 20 | |
| 21 | def lines |
| 22 | lexer = Rouge::Lexer.guess(filename: @filename, source: @content) || Rouge::Lexers::PlainText |
| 23 | formatter = Rouge::Formatters::HTML.new |
| 24 | |
| 25 | rows = [ [] ] |
| 26 | lexer.lex(@content).each do |token, value| |
| 27 | value.split(/(\n)/, -1).each do |part| |
| 28 | if part == "\n" |
| 29 | rows << [] |
| 30 | elsif !part.empty? |
| 31 | rows.last << [ token, part ] |
| 32 | end |
| 33 | end |
| 34 | end |
| 35 | rows.pop if rows.last&.empty? |
| 36 | |
| 37 | rows.map { |tokens| formatter.format(tokens) } |
| 38 | rescue StandardError |
| 39 | @content.lines.map { |l| CGI.escapeHTML(l.chomp) } |
| 40 | end |
| 41 | end |