Current section
Files
Jump to
Current section
Files
lib/ragex/analysis/metastatic_bridge.ex
defmodule Ragex.Analysis.MetastaticBridge do
@moduledoc """
Bridge to Metastatic analysis capabilities.
Delegates all AST-level analysis to Metastatic's analysis modules,
providing a unified interface for Ragex to access:
- Complexity metrics (cyclomatic, cognitive, nesting, Halstead, LoC, function metrics)
- Purity analysis (side effect detection)
## Usage
alias Ragex.Analysis.MetastaticBridge
# Analyze single file
{:ok, result} = MetastaticBridge.analyze_file("lib/my_module.ex")
# Analyze with specific metrics
{:ok, result} = MetastaticBridge.analyze_file("lib/my_module.ex",
metrics: [:cyclomatic, :cognitive, :purity])
# Analyze directory
{:ok, results} = MetastaticBridge.analyze_directory("lib/")
## Result Format
Analysis results include:
- `:path` - File path
- `:language` - Detected language
- `:complexity` - Complexity metrics map
- `:purity` - Purity analysis map
- `:warnings` - List of warning strings
- `:timestamp` - Analysis timestamp
"""
alias Metastatic.{Adapter, Document}
alias Metastatic.Analysis.{Complexity, Purity}
alias Metastatic.Semantic.{Enricher, OpKind}
require Logger
@type analysis_result :: %{
path: String.t(),
language: atom(),
complexity: map(),
purity: map(),
warnings: [String.t()],
timestamp: DateTime.t()
}
@type analysis_error :: %{
path: String.t(),
error: term(),
timestamp: DateTime.t()
}
@supported_metrics [
:cyclomatic,
:cognitive,
:nesting,
:halstead,
:loc,
:function_metrics,
:purity
]
@doc """
Returns the list of supported metrics.
## Examples
iex> Ragex.Analysis.MetastaticBridge.supported_metrics()
[:cyclomatic, :cognitive, :nesting, :halstead, :loc, :function_metrics, :purity]
"""
@spec supported_metrics() :: [atom()]
def supported_metrics, do: @supported_metrics
@doc """
Parses a file and returns a Metastatic Document.
This function is useful for accessing Metastatic's low-level analysis
capabilities directly, such as dead code detection.
## Options
- `:language` - Explicit language (default: auto-detect)
- `:enrich` - Apply semantic enrichment with OpKind metadata (default: false)
## Semantic Enrichment
When `enrich: true` is passed, the AST is enriched with OpKind semantic
metadata that tags function calls with their semantic meaning:
- Domain: `:db`, `:http`, `:auth`, `:cache`, `:queue`, `:file`, `:external_api`
- Operation: `:retrieve`, `:create`, `:update`, `:delete`, `:query`, etc.
- Framework: `:ecto`, `:django`, `:requests`, etc.
## Examples
# Basic parsing
{:ok, doc} = MetastaticBridge.parse_file("lib/my_module.ex")
# With semantic enrichment
{:ok, doc} = MetastaticBridge.parse_file("lib/my_module.ex", enrich: true)
Metastatic.Analysis.DeadCode.analyze(doc)
"""
@spec parse_file(path :: String.t(), opts :: keyword()) ::
{:ok, Document.t()} | {:error, term()}
def parse_file(path, opts \\ []) do
language = Keyword.get(opts, :language, detect_language(path))
enrich = Keyword.get(opts, :enrich, false)
with {:ok, content} <- File.read(path),
{:ok, adapter} <- get_adapter(language),
{:ok, doc} <- parse_document(adapter, content, language) do
if enrich do
enriched_ast = Enricher.enrich_tree(doc.ast, language)
{:ok, %{doc | ast: enriched_ast}}
else
{:ok, doc}
end
else
{:error, reason} = error ->
Logger.warning("Failed to parse #{path}: #{inspect(reason)}")
error
end
end
@doc """
Extracts semantic operations from a document.
Returns a list of operations found in the AST, each tagged with:
- `:domain` - The operation domain (`:db`, `:http`, `:auth`, etc.)
- `:operation` - The specific operation (`:retrieve`, `:create`, etc.)
- `:target` - The target entity (e.g., "User" for `Repo.get(User, id)`)
- `:framework` - The detected framework (`:ecto`, `:django`, etc.)
The document should be parsed with `enrich: true` for best results.
## Options
- `:domain` - Filter by specific domain (default: all)
## Examples
{:ok, doc} = MetastaticBridge.parse_file("lib/my_module.ex", enrich: true)
{:ok, ops} = MetastaticBridge.extract_operations(doc)
{:ok, db_ops} = MetastaticBridge.extract_operations(doc, domain: :db)
"""
@spec extract_operations(Document.t(), keyword()) :: {:ok, [map()]} | {:error, term()}
def extract_operations(%Document{ast: ast}, opts \\ []) do
domain_filter = Keyword.get(opts, :domain)
operations = extract_ops_from_ast(ast, [])
filtered =
if domain_filter do
Enum.filter(operations, &(&1.domain == domain_filter))
else
operations
end
{:ok, filtered}
rescue
e -> {:error, {:extraction_failed, e}}
end
@doc """
Returns the list of supported semantic domains.
## Examples
iex> MetastaticBridge.domains()
[:db, :http, :auth, :cache, :queue, :file, :external_api]
"""
@spec domains() :: [atom()]
def domains, do: [:db, :http, :auth, :cache, :queue, :file, :external_api]
@doc """
Analyzes a single file for code quality metrics.
Delegates to Metastatic for all analysis operations.
## Options
- `:metrics` - List of metrics to calculate (default: all)
- `:thresholds` - Custom threshold map for complexity warnings
- `:language` - Explicit language (default: auto-detect)
## Examples
{:ok, result} = MetastaticBridge.analyze_file("lib/my_module.ex")
result.complexity.cyclomatic # => 5
result.purity.pure? # => false
"""
@spec analyze_file(path :: String.t(), opts :: keyword()) ::
{:ok, analysis_result()} | {:error, term()}
def analyze_file(path, opts \\ []) do
metrics = Keyword.get(opts, :metrics, :all)
thresholds = Keyword.get(opts, :thresholds, %{})
language = Keyword.get(opts, :language, detect_language(path))
with {:ok, content} <- File.read(path),
{:ok, adapter} <- get_adapter(language),
{:ok, doc} <- parse_document(adapter, content, language),
{:ok, complexity_result} <- analyze_complexity(doc, metrics, thresholds),
{:ok, purity_result} <- analyze_purity(doc, metrics) do
{:ok, build_result(path, language, complexity_result, purity_result)}
else
{:error, reason} = error ->
Logger.warning("Analysis failed for #{path}: #{inspect(reason)}")
error
end
end
@doc """
Analyzes all files in a directory.
## Options
- `:recursive` - Recursively analyze subdirectories (default: true)
- `:metrics` - List of metrics to calculate (default: all)
- `:thresholds` - Custom threshold map
- `:parallel` - Use parallel processing (default: true)
- `:max_concurrency` - Maximum concurrent analyses (default: System.schedulers_online())
## Examples
{:ok, results} = MetastaticBridge.analyze_directory("lib/")
Enum.each(results, fn result ->
IO.puts("\#{result.path}: cyclomatic=\#{result.complexity.cyclomatic}")
end)
"""
@spec analyze_directory(path :: String.t(), opts :: keyword()) ::
{:ok, [analysis_result()]} | {:error, term()}
def analyze_directory(path, opts \\ []) do
recursive = Keyword.get(opts, :recursive, true)
parallel = Keyword.get(opts, :parallel, true)
max_concurrency = Keyword.get(opts, :max_concurrency, System.schedulers_online())
case find_source_files(path, recursive) do
{:ok, files} when files == [] ->
{:ok, []}
{:ok, files} ->
results =
if parallel do
analyze_files_parallel(files, opts, max_concurrency)
else
analyze_files_sequential(files, opts)
end
{:ok, results}
{:error, reason} = error ->
Logger.error("Failed to list directory #{path}: #{inspect(reason)}")
error
end
end
# Private functions
defp detect_language(path), do: Ragex.LanguageSupport.detect_language(path)
defp get_adapter(lang), do: Ragex.LanguageSupport.get_adapter(lang)
defp parse_document(adapter, content, language) do
case Adapter.abstract(adapter, content, language) do
{:ok, %Document{} = doc} -> {:ok, doc}
{:error, _} = error -> error
end
end
defp analyze_complexity(_doc, metrics, _thresholds) when is_list(metrics) and metrics == [] do
{:ok, nil}
end
defp analyze_complexity(doc, metrics, thresholds) do
needs_complexity =
metrics == :all or
Enum.any?([:cyclomatic, :cognitive, :nesting, :halstead, :loc, :function_metrics], fn m ->
m in metrics
end)
if needs_complexity do
complexity_metrics = if metrics == :all, do: :all, else: filter_complexity_metrics(metrics)
Complexity.analyze(doc, thresholds: thresholds, metrics: complexity_metrics)
else
{:ok, nil}
end
end
defp analyze_purity(doc, metrics) when is_list(metrics) do
if :purity in metrics do
Purity.analyze(doc)
else
{:ok, nil}
end
end
defp analyze_purity(doc, :all) do
Purity.analyze(doc)
end
defp analyze_purity(_doc, _metrics), do: {:ok, nil}
defp filter_complexity_metrics(metrics) do
Enum.filter(metrics, fn m ->
m in [:cyclomatic, :cognitive, :nesting, :halstead, :loc, :function_metrics]
end)
end
defp build_result(path, language, complexity_result, purity_result) do
%{
path: path,
language: language,
complexity: format_complexity(complexity_result),
purity: format_purity(purity_result),
warnings: collect_warnings(complexity_result, purity_result),
timestamp: DateTime.utc_now()
}
end
defp format_complexity(nil), do: %{}
defp format_complexity(result) do
%{
cyclomatic: result.cyclomatic,
cognitive: result.cognitive,
max_nesting: result.max_nesting,
halstead: result.halstead,
loc: result.loc,
function_metrics: result.function_metrics,
per_function: result.per_function || %{},
summary: result.summary
}
end
defp format_purity(nil), do: %{}
defp format_purity(result) do
%{
pure?: result.pure?,
effects: result.effects,
confidence: result.confidence,
summary: result.summary,
unknown_calls: result.unknown_calls || []
}
end
defp collect_warnings(complexity_result, purity_result) do
complexity_warnings = if complexity_result, do: complexity_result.warnings, else: []
purity_warnings =
if purity_result && !purity_result.pure? do
effects = Enum.join(purity_result.effects, ", ")
["Code is impure: #{effects}"]
else
[]
end
complexity_warnings ++ purity_warnings
end
defp find_source_files(path, recursive) do
Ragex.LanguageSupport.find_source_files(path, recursive: recursive, metastatic_only: true)
end
defp analyze_files_sequential(files, opts) do
Enum.reduce(files, [], fn file, acc ->
case analyze_file(file, opts) do
{:ok, result} -> [result | acc]
{:error, reason} -> [build_error_result(file, reason) | acc]
end
end)
|> Enum.reverse()
end
defp analyze_files_parallel(files, opts, max_concurrency) do
files
|> Task.async_stream(
fn file ->
case analyze_file(file, opts) do
{:ok, result} -> result
{:error, reason} -> build_error_result(file, reason)
end
end,
max_concurrency: max_concurrency,
timeout: 30_000
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, reason} -> build_error_result("unknown", {:task_exit, reason})
end)
end
defp build_error_result(path, error) do
%{
path: path,
language: :unknown,
complexity: %{},
purity: %{},
warnings: ["Analysis failed: #{inspect(error)}"],
timestamp: DateTime.utc_now(),
error: error
}
end
# Extract semantic operations from AST
defp extract_ops_from_ast(ast, acc) do
case ast do
{:function_call, meta, args} when is_list(meta) ->
op = extract_op_from_meta(meta)
new_acc = if op, do: [op | acc], else: acc
Enum.reduce(args, new_acc, &extract_ops_from_ast/2)
{:attribute_access, meta, children} when is_list(meta) ->
op = extract_op_from_meta(meta)
new_acc = if op, do: [op | acc], else: acc
Enum.reduce(children, new_acc, &extract_ops_from_ast/2)
{_type, _meta, children} when is_list(children) ->
Enum.reduce(children, acc, &extract_ops_from_ast/2)
list when is_list(list) ->
Enum.reduce(list, acc, &extract_ops_from_ast/2)
_ ->
acc
end
end
defp extract_op_from_meta(meta) do
case Keyword.get(meta, :op_kind) do
nil ->
nil
op_kind when is_list(op_kind) ->
%{
domain: OpKind.domain(op_kind),
operation: OpKind.operation(op_kind),
target: OpKind.target(op_kind),
framework: Keyword.get(op_kind, :framework),
async: Keyword.get(op_kind, :async, false),
line: Keyword.get(meta, :line)
}
end
end
end