Current section
Files
Jump to
Current section
Files
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