tree_sitter/lib/tree_sitter.ex
2023-09-22 09:42:35 +00:00

336 lines
8.9 KiB
Elixir

defmodule TreeSitter do
# https://registry.npmjs.org/tree-sitter/latest
@latest_version "0.20.8"
@moduledoc """
TreeSitter is an installer and runner for [tree_sitter](https://tree-sitter.github.io/tree-sitter/).
## TreeSitter configuration
There are two global configurations for the tree_sitter application:
* `:version` - the expected tree_sitter version
* `:cacerts_path` - the directory to find certificates for
https connections
* `:path` - the path to find the tree_sitter executable at. By
default, it is automatically downloaded and placed inside
the `_build` directory of your current app
Overriding the `:path` is not recommended, as we will automatically
download and manage `tree-sitter` for you. But in case you can't download
it (for example, the npm registry is behind a proxy), you may want to
set the `:path` to a configurable system location.
For instance, you can install `tree-sitter` globally with `npm`:
$ npm install -g tree-sitter
On Unix, the executable will be at:
NPM_ROOT/tree-sitter/node_modules/@tree-sitter/TARGET/bin/tree_sitter
On Windows, it will be at:
NPM_ROOT/tree-sitter/node_modules/@tree-sitter/win32-x(32|64)/tree_sitter.exe
Where `NPM_ROOT` is the result of `npm root -g` and `TARGET` is your system
target architecture.
Once you find the location of the executable, you can store it in a
`MIX_TREE_SITTER_PATH` environment variable, which you can then read in
your configuration file:
config :tree_sitter, path: System.get_env("MIX_TREE_SITTER_PATH")
"""
use Application
require Logger
@doc false
def start(_, _) do
unless Application.get_env(:tree_sitter, :version) do
Logger.warning("""
tree_sitter version is not configured. Please set it in your config files:
config :tree_sitter, :version, "#{latest_version()}"
""")
end
configured_version = configured_version()
case bin_version() do
{:ok, version} ->
if version =~ configured_version do
:ok
else
Logger.warning("""
Outdated tree_sitter version. Expected #{configured_version}, got #{version}. \
Please run `mix tree_sitter.install` or update the version in your config files.\
""")
end
:error ->
:ok
end
Supervisor.start_link([], strategy: :one_for_one, name: __MODULE__.Supervisor)
end
@doc false
# Latest known version at the time of publishing.
def latest_version, do: @latest_version
@doc """
Returns the configured tree_sitter version.
"""
def configured_version do
Application.get_env(:tree_sitter, :version, latest_version())
end
@doc """
Returns the path to the executable.
The executable may not be available if it was not yet installed.
"""
def bin_path do
name = "tree_sitter-#{target()}"
Application.get_env(:tree_sitter, :path) ||
if Code.ensure_loaded?(Mix.Project) do
Path.join(Path.dirname(Mix.Project.build_path()), name)
else
Path.expand("_build/#{name}")
end
end
@doc """
Returns the version of the tree_sitter executable.
Returns `{:ok, version_string}` on success or `:error` when the executable
is not available.
"""
def bin_version do
path = bin_path()
with true <- File.exists?(path),
{result, 0} <- System.cmd(path, ["--version"]) do
{:ok, String.trim(result)}
else
_ -> :error
end
end
@doc """
Runs the given command with `args`.
The given args will be appended to the configured args.
The task output will be streamed directly to stdio. It
returns the status of the underlying call.
"""
def run(extra_args) when is_list(extra_args) do
opts = [
into: IO.stream(:stdio, :line),
stderr_to_stdout: true
]
bin_path()
|> System.cmd(extra_args, opts)
|> elem(1)
end
defp start_unique_install_worker() do
ref =
__MODULE__.Supervisor
|> Supervisor.start_child(
Supervisor.child_spec({Task, &install/0}, restart: :transient, id: __MODULE__.Installer)
)
|> case do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
|> Process.monitor()
receive do
{:DOWN, ^ref, _, _, _} -> :ok
end
end
@doc """
Installs, if not available, and then runs `tree_sitter`.
Returns the same as `run/1`.
"""
def install_and_run(args) do
File.exists?(bin_path()) || start_unique_install_worker()
run(args)
end
@doc """
Installs tree_sitter with `configured_version/0`.
"""
def install do
version = configured_version()
tmp_opts = if System.get_env("MIX_XDG"), do: %{os: :linux}, else: %{}
tmp_dir =
freshdir_p(:filename.basedir(:user_cache, "tree_sitter", tmp_opts)) ||
freshdir_p(Path.join(System.tmp_dir!(), "tree_sitter")) ||
raise "could not install tree-sitter. Set MIX_XGD=1 and then set XDG_CACHE_HOME to the path you want to use as cache"
target = target()
url =
"https://github.com/tree-sitter/tree-sitter/releases/download/v#{version}/tree-sitter-#{target}.gz"
gz = fetch_body!(url)
result_path =
case :os.type() do
{:win32, _} ->
Path.join([tmp_dir, "tree_sitter.exe"])
_ ->
Path.join([tmp_dir, "tree_sitter"])
end
gz
|> :zlib.gunzip()
|> then(fn data ->
File.write!(result_path, data)
end)
File.chmod!(result_path, 0o700)
bin_path = bin_path()
File.mkdir_p!(Path.dirname(bin_path))
File.cp!(result_path, bin_path)
end
defp freshdir_p(path) do
with {:ok, _} <- File.rm_rf(path),
:ok <- File.mkdir_p(path) do
path
else
_ -> nil
end
end
# Available targets: https://github.com/evanw/tree_sitter/tree/main/npm/@tree_sitter
defp target do
case :os.type() do
# Assuming it's an x86 CPU
{:win32, _} ->
wordsize = :erlang.system_info(:wordsize)
if wordsize == 8 do
"windows-x64"
else
"windows-x86"
end
{:unix, osname} ->
arch_str = :erlang.system_info(:system_architecture)
[arch | _] = arch_str |> List.to_string() |> String.split("-")
osname =
if osname == :darwin do
"macos"
else
"linux"
end
case arch do
"amd64" -> "#{osname}-arm64"
"x86_64" -> "#{osname}-x64"
"i686" -> "#{osname}-x86"
"i386" -> "#{osname}-x86"
"aarch64" -> "#{osname}-arm64"
"arm" when osname == "macos" -> "darwin-arm64"
"arm" -> "#{osname}-arm"
"armv7" <> _ -> "#{osname}-arm"
_ -> raise "tree_sitter is not available for architecture: #{arch_str}"
end
end
end
defp fetch_body!(url) do
scheme = URI.parse(url).scheme
url = String.to_charlist(url)
Logger.debug("Downloading tree_sitter from #{url}")
{:ok, _} = Application.ensure_all_started(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
if proxy = proxy_for_scheme(scheme) do
%{host: host, port: port} = URI.parse(proxy)
Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}")
set_option = if "https" == scheme, do: :https_proxy, else: :proxy
:httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}])
end
# https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets
cacertfile = cacertfile() |> String.to_charlist()
http_options =
[
ssl: [
verify: :verify_peer,
cacertfile: cacertfile,
depth: 2,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
]
|> maybe_add_proxy_auth(scheme)
options = [body_format: :binary]
case :httpc.request(:get, {url, []}, http_options, options) do
{:ok, {{_, 200, _}, _headers, body}} ->
body
other ->
raise """
couldn't fetch #{url}: #{inspect(other)}
You may also install the "tree_sitter" executable manually, \
see the docs: https://hexdocs.pm/tree_sitter
"""
end
end
defp proxy_for_scheme("http") do
System.get_env("HTTP_PROXY") || System.get_env("http_proxy")
end
defp proxy_for_scheme("https") do
System.get_env("HTTPS_PROXY") || System.get_env("https_proxy")
end
defp maybe_add_proxy_auth(http_options, scheme) do
case proxy_auth(scheme) do
nil -> http_options
auth -> [{:proxy_auth, auth} | http_options]
end
end
defp proxy_auth(scheme) do
with proxy when is_binary(proxy) <- proxy_for_scheme(scheme),
%{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy),
[username, password] <- String.split(userinfo, ":") do
{String.to_charlist(username), String.to_charlist(password)}
else
_ -> nil
end
end
defp cacertfile() do
Application.get_env(:tree_sitter, :cacerts_path) || CAStore.file_path()
end
end