4.2 KiB · text 5af55d9
defmodule GitGud.SyntaxHighlight do
@moduledoc """
Rust-backed syntax highlighting via [Lumis](https://lumis.sh) (a
tree-sitter highlighter pulled in transitively through MDEx).
Used for:
* source-file rendering on the Blob view
* future: inline diffs, snippets
Results are cached in `GitGud.Cache` keyed by content hash + language
hint + theme. Since the key includes the SHA-256 of the input, entries
never go stale and are reusable across repos and refs.
Falls back to HTML-escaped `<pre>` if Lumis fails or the language is
unrecognized — never raises out of band.
"""
alias GitGud.Cache
alias GitGud.Themes.Editor, as: EditorTheme
@default_theme EditorTheme.default()
# Cap the input size we'll attempt to highlight. Anything bigger and
# the highlight pass dominates page-render time without much benefit
# for the reader; we fall back to plain `<pre>`.
@max_bytes 250_000
@ttl :timer.hours(24)
@doc """
Highlight `code` and return safe HTML. Returns a string that can be
rendered directly with `Phoenix.HTML.raw/1`.
Options:
* `:filename` — used as a language hint when there's no explicit
`:language`
* `:language` — explicit language id (e.g. "elixir", "rs")
* `:theme` — Lumis theme name; default `#{@default_theme}`
"""
def highlight(code, opts \\ []) when is_binary(code) do
cond do
byte_size(code) > @max_bytes ->
plain(code)
true ->
theme = opts |> Keyword.get(:theme, @default_theme) |> EditorTheme.resolve()
language = resolve_language(opts)
cache_get_or_compute(code, language, theme)
end
end
defp cache_get_or_compute(code, language, theme) do
key = cache_key(code, language, theme)
case Cache.get(key) do
nil ->
html = compute(code, language, theme)
Cache.put(key, html, ttl: @ttl)
html
cached ->
cached
end
end
defp compute(code, language, theme) do
if Code.ensure_loaded?(Lumis) do
formatter_opts =
case language do
nil -> [theme: theme]
lang -> [language: lang, theme: theme]
end
try do
Lumis.highlight!(code, formatter: {:html_inline, formatter_opts})
rescue
_ -> plain(code)
catch
_, _ -> plain(code)
end
else
plain(code)
end
end
defp plain(code) do
escaped = code |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
~s(<pre class="lumis-fallback"><code>#{escaped}</code></pre>)
end
defp cache_key(code, language, theme) do
hash = :crypto.hash(:sha256, code) |> Base.encode16(case: :lower)
{:syntax, hash, language, theme}
end
defp resolve_language(opts) do
case Keyword.get(opts, :language) do
lang when is_binary(lang) and lang != "" ->
lang
_ ->
opts
|> Keyword.get(:filename)
|> language_from_filename()
end
end
@ext_to_lang %{
".ex" => "elixir",
".exs" => "elixir",
".eex" => "html",
".heex" => "html",
".rs" => "rust",
".go" => "go",
".py" => "python",
".js" => "javascript",
".jsx" => "javascript",
".ts" => "typescript",
".tsx" => "typescript",
".rb" => "ruby",
".sh" => "bash",
".bash" => "bash",
".zsh" => "bash",
".c" => "c",
".h" => "c",
".cpp" => "cpp",
".cc" => "cpp",
".hpp" => "cpp",
".java" => "java",
".kt" => "kotlin",
".swift" => "swift",
".lua" => "lua",
".sql" => "sql",
".md" => "markdown",
".html" => "html",
".htm" => "html",
".css" => "css",
".scss" => "scss",
".yaml" => "yaml",
".yml" => "yaml",
".toml" => "toml",
".json" => "json",
".xml" => "xml"
}
@basename_to_lang %{
"Dockerfile" => "dockerfile",
"Makefile" => "make",
"mix.lock" => "elixir",
"mix.exs" => "elixir",
".gitignore" => "gitignore"
}
defp language_from_filename(nil), do: nil
defp language_from_filename(name) when is_binary(name) do
basename = Path.basename(name)
case Map.get(@basename_to_lang, basename) do
nil ->
ext = name |> Path.extname() |> String.downcase()
Map.get(@ext_to_lang, ext)
lang ->
lang
end
end
end