Packages

Clarity is an interactive introspection and visualization tool for Elixir projects, providing navigable graphs and diagrams for frameworks like Ash, Phoenix, and Ecto.

Current section

Files

Jump to
clarity lib clarity open_editor.ex
Raw

lib/clarity/open_editor.ex

defmodule Clarity.OpenEditor do
@moduledoc """
Handles opening files in editors or browsers based on configuration.
This module provides a flexible editor integration system that supports:
- Local editor commands with template variable substitution
- URL mode for opening files in browsers via source URLs
- Case-insensitive template variables (`__FILE__`, `__LINE__`, `__COLUMN__`)
- Multiple configuration sources with priority hierarchy
## Configuration
Editor configuration is managed by `Clarity.Config`. See the documentation
for `Clarity.Config` for detailed configuration options and examples.
## Template Variables
When using system command mode, the following case-insensitive template variables
are supported:
- `__FILE__` - replaced with the file path
- `__LINE__` - replaced with the line number
- `__COLUMN__` - replaced with the column number
## URL Mode
To enable URL mode, set the editor configuration to `"__URL__"`
(case-insensitive) or the atom `:url`. In URL mode, the module will:
1. Determine which application/dependency the file belongs to
2. Extract the `source_url` from that application's configuration
3. Generate a platform-specific URL using ExDoc-style patterns
4. Return the URL for opening in a browser
> #### Command Execution {: .warning}
>
> This module executes system commands based on user configuration.
> Ensure that the configured commands are safe and trusted to avoid security
> risks. If you run this in an untrusted environment, set the editor to `:url`
> or `"__URL__"` to avoid executing arbitrary commands.
>
> Clarity does not validate or sanitize the commands; it simply substitutes
> the template variables and executes them as-is.
>
> Variable substitution does not escape special characters, so be cautious
> if file paths may contain spaces or shell-sensitive characters.
"""
require Logger
@type action_result ::
:editor_not_available
| :action_not_available
| {:url, String.t()}
| {:execute, (-> :ok | {:error, term()})}
@doc """
Determines the action available for a given source location.
Returns the appropriate action based on editor configuration and the ability
to process the source location. This is the main entry point for editor
integration.
## Returns
- `:editor_not_available` - No editor is configured
- `:action_not_available` - Editor is configured but action cannot be determined
(e.g., URL building failed, command preparation failed)
- `{:url, url}` - URL mode with successfully built URL
- `{:execute, function}` - System command mode with executable function
## Examples
case Clarity.OpenEditor.action(source_location) do
:editor_not_available ->
# Don't show editor button
:action_not_available ->
# Don't show editor button
{:url, url} ->
# Show button that opens URL in browser
{:execute, execute_fn} ->
# Show button that calls execute_fn.()
end
"""
@spec action(Clarity.SourceLocation.t()) :: action_result()
def action(%Clarity.SourceLocation{} = source_location) do
case Clarity.Config.fetch_editor_config() do
:error ->
:editor_not_available
{:ok, config} ->
build_action(config, source_location)
end
end
# Private functions
@spec build_action(String.t() | atom(), Clarity.SourceLocation.t()) :: action_result()
defp build_action(:url, %Clarity.SourceLocation{application: application} = source_location) do
with {:ok, source_info} <- get_application_source_info(application),
{:ok, relative_path} <- get_relative_path_for_source_location(source_location),
{:ok, url_pattern} <- get_git_platform_pattern(source_info.source_url, source_info.ref) do
line = Clarity.SourceLocation.line(source_location)
url =
url_pattern
|> String.replace("%{path}", relative_path)
|> String.replace("%{line}", Integer.to_string(line))
full_url = source_info.source_url |> URI.merge(url) |> URI.to_string()
{:url, full_url}
else
{:error, _reason} -> :action_not_available
end
end
defp build_action(editor_command, source_location) do
file_path = Clarity.SourceLocation.file_path(source_location)
line = Clarity.SourceLocation.line(source_location)
column = Clarity.SourceLocation.column(source_location) || 1
case file_path do
nil ->
:action_not_available
path ->
substituted_command = substitute_template_vars(editor_command, path, line, column)
execute_fn = fn -> execute_system_command(substituted_command) end
{:execute, execute_fn}
end
end
@spec substitute_template_vars(String.t(), String.t(), pos_integer(), pos_integer()) ::
String.t() | [String.t()]
defp substitute_template_vars(command, file_path, line, column) do
replacements = %{
~r/__file__/i => file_path,
~r/__line__/i => Integer.to_string(line),
~r/__column__/i => Integer.to_string(column)
}
if Enum.any?(replacements, fn {regex, _} -> Regex.match?(regex, command) end) do
Enum.reduce(replacements, command, fn {regex, replacement}, acc ->
String.replace(acc, regex, replacement)
end)
else
[command, file_path]
end
end
@spec execute_system_command(String.t() | [String.t()]) :: :ok | {:error, term()}
defp execute_system_command(command)
defp execute_system_command(command) when is_binary(command) do
case System.cmd("sh", ["-c", command], stderr_to_stdout: true) do
{_output, 0} ->
:ok
{error_output, exit_code} ->
{:error, {:command_failed, exit_code, error_output}}
end
end
defp execute_system_command([editor | args]) do
case System.cmd(editor, args, stderr_to_stdout: true) do
{_output, 0} ->
:ok
{error_output, exit_code} ->
{:error, {:command_failed, exit_code, error_output}}
end
end
@spec get_relative_path_for_source_location(Clarity.SourceLocation.t()) ::
{:ok, String.t()} | {:error, term()}
defp get_relative_path_for_source_location(source_location) do
case Clarity.SourceLocation.file_path(source_location, :app) do
nil -> {:error, :no_file_path}
path -> {:ok, path}
end
end
@spec get_application_source_info(atom() | nil) ::
{:ok, %{source_url: String.t(), ref: String.t()}} | {:error, term()}
defp get_application_source_info(nil), do: {:error, :no_application}
defp get_application_source_info(app) do
with :ok <- ensure_mix_running(),
{:ok, source_info} <- mix_source_info(app) do
{:ok, source_info}
else
{:error, _reason} = error -> error
end
end
@spec ensure_mix_running() :: :ok | {:error, :mix_not_running}
defp ensure_mix_running do
Application.started_applications()
|> Enum.any?(&match?({:mix, _description, _version}, &1))
|> if do
:ok
else
{:error, :mix_not_running}
end
end
@spec mix_source_info(Application.app()) ::
{:ok, %{source_url: String.t(), ref: String.t()}} | {:error, term()}
defp mix_source_info(app) do
with {:ok, project_module} <- mix_project_module(app) do
project_info = project_module.project()
docs_info =
case Keyword.fetch(project_info, :docs) do
{:ok, docs} when is_list(docs) -> docs
{:ok, docs} when is_function(docs, 0) -> docs.()
_ -> []
end
source_url = Keyword.get(docs_info, :source_url) || Keyword.get(project_info, :source_url)
ref =
Keyword.get(docs_info, :source_ref) || Keyword.get(project_info, :source_ref) || "main"
if source_url do
source_url =
if String.ends_with?(source_url, "/"), do: source_url, else: source_url <> "/"
{:ok, %{source_url: source_url, ref: ref}}
else
{:error, {:no_source_url, app}}
end
end
end
@spec mix_project_module(Application.app()) :: {:ok, module()} | {:error, term()}
defp mix_project_module(app) do
project =
if Mix.Project.config()[:app] == app do
Mix.Project.get()
else
case Mix.Project.deps_paths() do
%{^app => path} ->
Mix.Project.in_project(app, path, fn _ -> Mix.Project.get() end)
_ ->
nil
end
end
case project do
nil -> {:error, {:no_source_url, app}}
module -> {:ok, module}
end
end
@spec get_git_platform_pattern(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
defp get_git_platform_pattern(source_url, ref) do
uri = URI.parse(source_url)
case uri.host do
"github.com" ->
{:ok, "blob/#{ref}/%{path}#L%{line}"}
"gitlab.com" ->
{:ok, "blob/#{ref}/%{path}#L%{line}"}
"bitbucket.org" ->
{:ok, "src/#{ref}/%{path}#cl-%{line}"}
_ ->
# Generic fallback for other platforms
{:ok, "tree/#{ref}/%{path}#L%{line}"}
end
end
end