# Custom Analyzer Guide
Learn how to create custom analyzers that integrate with Metastatic's analyzer plugin system. Write analysis rules once and apply them across all supported languages.
## Anatomy of an Analyzer
Every analyzer is a module that implements the `Metastatic.Analysis.Analyzer` behaviour:
```elixir
defmodule MyApp.Analysis.MyAnalyzer do
@behaviour Metastatic.Analysis.Analyzer
alias Metastatic.Analysis.Analyzer
# Required: Return metadata about this analyzer
@impl true
def info do
%{
name: :my_analyzer,
category: :correctness,
description: "Brief description",
severity: :warning,
explanation: "Detailed explanation...",
configurable: false
}
end
# Required: Analyze individual nodes
@impl true
def analyze(node, context) do
# Return list of issues found in this node
[]
end
# Optional: Run before traversal starts
# def run_before(context) do
# {:ok, context}
# end
# Optional: Run after traversal completes
# def run_after(context, issues) do
# issues
# end
end
```
## Step 1: Define Analyzer Metadata
The `info/0` function returns a map with required fields:
```elixir
@impl true
def info do
%{
name: :no_magic_numbers, # Unique identifier (atom)
category: :style, # One of: readability, maintainability, performance, security, correctness, style, refactoring
description: "Flags magic numbers", # One-line summary
severity: :warning, # One of: error, warning, info, refactoring_opportunity
explanation: """
Magic numbers (hardcoded literals) reduce readability and maintainability.
Extract them to named constants instead.
""",
configurable: true # Whether analyzer accepts configuration
}
end
```
## Step 2: Implement Node Analysis
The `analyze/2` callback is called for each AST node during traversal:
```elixir
@impl true
def analyze(node, context) do
case node do
{:literal, :integer, value} when value > 1000 ->
[
Analyzer.issue(
analyzer: __MODULE__,
category: :style,
severity: :warning,
message: "Magic number #{value} - extract to constant",
node: node,
suggestion: Analyzer.suggestion(
type: :replace,
replacement: {:variable, "CONSTANT_NAME"},
message: "Replace with named constant"
),
metadata: %{value: value}
)
]
_ ->
[]
end
end
```
### Analyzer Context
The context passed to `analyze/2` contains:
```elixir
%{
document: Metastatic.Document.t(), # The document being analyzed
config: map() | list(), # Analyzer configuration
parent_stack: [meta_ast()], # Stack of parent nodes
depth: non_neg_integer(), # Current depth in AST
scope: map(), # Custom analyzer state (if using run_before)
# Your custom fields from run_before:
# custom_field: value
}
```
Use context to:
- Access the document and its language
- Check configuration options
- Traverse parent nodes
- Track depth
- Access analyzer-specific state
## Step 3: Optional - Setup and Teardown
For analyzers that need initialization or final processing:
### run_before/1: Initialize State
Called once before AST traversal:
```elixir
@impl true
def run_before(context) do
# Initialize analyzer state
context = Map.put(context, :defined_constants, MapSet.new())
context = Map.put(context, :undefined_constants, MapSet.new())
# Or skip this analyzer
if context.document.language == :unsupported_language do
{:skip, :unsupported_language}
else
{:ok, context}
end
end
```
### run_after/2: Final Analysis
Called once after traversal with all collected issues:
```elixir
@impl true
def run_after(context, issues) do
# Generate additional issues from collected state
new_issues = Enum.map(context.undefined_constants, fn const ->
Analyzer.issue(
analyzer: __MODULE__,
category: :correctness,
severity: :error,
message: "Constant #{const} used but not defined",
node: {:variable, const},
metadata: %{constant: const}
)
end)
issues ++ new_issues
end
```
## Example 1: Simple Pattern Detector
Detect division by zero potential:
```elixir
defmodule MyApp.Analysis.DivisionByZero do
@behaviour Metastatic.Analysis.Analyzer
alias Metastatic.Analysis.Analyzer
@impl true
def info do
%{
name: :division_by_zero,
category: :correctness,
description: "Flags potential division by zero",
severity: :warning,
explanation: "Division by literals that are zero will always fail",
configurable: false
}
end
@impl true
def analyze({:binary_op, :arithmetic, :/, _left, {:literal, :integer, 0}}, _context) do
[
Analyzer.issue(
analyzer: __MODULE__,
category: :correctness,
severity: :error,
message: "Division by zero",
node: {:literal, :integer, 0}
)
]
end
def analyze(_node, _context), do: []
end
```
## Example 2: Stateful Analysis
Detect inconsistent naming conventions:
```elixir
defmodule MyApp.Analysis.NamingConvention do
@behaviour Metastatic.Analysis.Analyzer
alias Metastatic.Analysis.Analyzer
@impl true
def info do
%{
name: :naming_convention,
category: :style,
description: "Checks naming conventions",
severity: :info,
explanation: "Variables should use snake_case",
configurable: false
}
end
@impl true
def run_before(context) do
context = Map.put(context, :variables, MapSet.new())
{:ok, context}
end
@impl true
def analyze({:variable, name}, context) do
# Track variables
context = update_in(context, [:variables], &MapSet.put(&1, name))
# Check naming
if String.contains?(name, "_") and String.match?(name, ~r/[A-Z]/) do
[
Analyzer.issue(
analyzer: __MODULE__,
category: :style,
severity: :info,
message: "Variable '#{name}' should be snake_case",
node: {:variable, name},
metadata: %{variable: name}
)
]
else
[]
end
end
def analyze(_node, _context), do: []
@impl true
def run_after(_context, issues), do: issues
end
```
## Example 3: Configurable Analyzer
Create an analyzer that respects configuration:
```elixir
defmodule MyApp.Analysis.MaxLineLengthChecker do
@behaviour Metastatic.Analysis.Analyzer
alias Metastatic.Analysis.Analyzer
@impl true
def info do
%{
name: :max_line_length,
category: :readability,
description: "Checks maximum line length",
severity: :warning,
explanation: "Lines exceeding max length reduce readability",
configurable: true
}
end
@impl true
def analyze(_node, context) do
max_length = get_max_length(context.config)
# Note: MetaAST doesn't include line info, this is pseudocode
# In real analyzers, you'd track or estimate line length differently
[]
end
defp get_max_length(config) do
cond do
is_list(config) -> Keyword.get(config, :max_length, 80)
is_map(config) -> Map.get(config, :max_length, 80)
true -> 80
end
end
end
```
## Testing Your Analyzer
```elixir
defmodule MyApp.Analysis.MyAnalyzerTest do
use ExUnit.Case, async: true
alias Metastatic.{Document, Analysis.Runner}
alias MyApp.Analysis.MyAnalyzer
describe "info/0" do
test "returns valid metadata" do
info = MyAnalyzer.info()
assert info.name == :my_analyzer
assert info.category in [:readability, :maintainability, :performance, :security, :correctness, :style, :refactoring]
assert info.severity in [:error, :warning, :info, :refactoring_opportunity]
end
end
describe "analyze/2" do
test "detects problematic pattern" do
ast = {:literal, :integer, 42}
doc = Document.new(ast, :python)
context = %{
document: doc,
config: %{},
parent_stack: [],
depth: 0,
scope: %{}
}
issues = MyAnalyzer.analyze(ast, context)
assert length(issues) >= 0
Enum.each(issues, fn issue ->
assert issue.analyzer == MyAnalyzer
assert issue.category == :style
end)
end
end
describe "integration with Runner" do
test "works as plugin" do
ast = {:literal, :integer, 42}
doc = Document.new(ast, :python)
{:ok, report} = Runner.run(doc, analyzers: [MyAnalyzer])
assert is_list(report.issues)
Enum.each(report.issues, fn issue ->
assert issue.analyzer == MyAnalyzer
end)
end
end
end
```
## Registering Your Analyzer
### Runtime Registration
```elixir
alias Metastatic.Analysis.Registry
Registry.register(MyApp.Analysis.MyAnalyzer)
```
### Application Configuration
```elixir
# config/config.exs
config :metastatic, :analyzers,
auto_register: [
MyApp.Analysis.MyAnalyzer,
MyApp.Analysis.MyOtherAnalyzer
],
config: %{
my_analyzer: %{option: value},
my_other_analyzer: %{threshold: 10}
}
```
## Working with MetaAST Nodes
Common node patterns to match:
```elixir
# Literals
{:literal, :integer, 42}
{:literal, :string, "hello"}
{:literal, :boolean, true}
# Variables
{:variable, "name"}
# Binary operations
{:binary_op, :arithmetic, :+, left, right}
{:binary_op, :comparison, :>, left, right}
# Control flow
{:conditional, condition, then_branch, else_branch}
{:loop, :while, condition, body}
# Functions
{:function_call, "name", [arg1, arg2]}
{:lambda, [param1, param2], body}
# Blocks
{:block, [stmt1, stmt2]}
# Assignment
{:assignment, {:variable, "x"}, value}
{:early_return, value}
```
See the MetaAST documentation for the complete node specification.
## Best Practices
1. **Be Specific** - Match specific patterns, not overly broad ones
2. **Provide Suggestions** - Include actionable refactoring suggestions when possible
3. **Use Metadata** - Include analyzer-specific data in metadata for filtering
4. **Handle Edge Cases** - Consider nil values, empty collections, etc.
5. **Document Configuration** - Clearly document all configuration options
6. **Test Thoroughly** - Test normal cases, edge cases, and error conditions
7. **Consider Performance** - Avoid expensive operations in `analyze/2`
8. **Use run_before/1 for Setup** - Do expensive initialization once, not per-node
9. **Maintain State Immutably** - Use update_in or Map.put for state updates
10. **Be Language Agnostic** - Avoid language-specific assumptions
## Common Mistakes
1. **Modifying context** - Create new maps, don't mutate
2. **Not handling nil** - Always handle nil values safely
3. **Complex analyze/2** - Keep per-node analysis simple, use hooks for complexity
4. **Over-matching** - Be specific with patterns to avoid false positives
5. **Missing tests** - Test with real AST structures
6. **Assuming location info** - MetaAST doesn't always include line/column data
7. **Not closing issues properly** - Ensure every branch returns a list
## Integration Examples
See the `examples/` directory for complete working examples of:
- Running all analyzers on a module
- Creating and registering custom analyzers
- Processing and filtering analysis results
- Building custom report formatters