Metastatic.Adapter behaviour
(Metastatic v0.10.4)
View Source
Behaviour for language adapters (M1 ↔ M2 transformations).
Language adapters bridge between:
- M1: Language-specific ASTs (Python, JavaScript, Elixir, etc.)
- M2: MetaAST meta-model
Meta-Modeling Operations
parse/1: Source → M1 (language-specific parsing)to_meta/1: M1 → M2 (abstraction to meta-level)from_meta/2: M2 → M1 (reification from meta-level)unparse/1: M1 → Source (language-specific unparsing)
Conformance
Adapters must ensure:
- M1 instances conform to M2 meta-model
- M2 → M1 → M2 round-trips preserve semantics
- Invalid M2 transformations are rejected at M1 level
Theory
In formal terms, a language adapter is a pair of functions:
Adapter_L = ⟨α_L, ρ_L⟩
where:
α_L: AS_L → MetaAST × Metadata (abstraction)
ρ_L: MetaAST × Metadata → AS_L (reification)These functions form a Galois connection between M1 and M2 levels.
Example Implementation
defmodule Metastatic.Adapters.Python do
@behaviour Metastatic.Adapter
@impl true
def parse(source) do
# Call Python AST parser (via port or NIF)
{:ok, python_ast}
end
@impl true
def to_meta(python_ast) do
# Transform Python AST (M1) to MetaAST (M2)
# Example: BinOp(op=Add()) → {:binary_op, :arithmetic, :+, ...}
{:ok, meta_ast, metadata}
end
@impl true
def from_meta(meta_ast, metadata) do
# Transform MetaAST (M2) back to Python AST (M1)
{:ok, python_ast}
end
@impl true
def unparse(python_ast) do
# Convert Python AST back to source code
{:ok, source}
end
@impl true
def file_extensions, do: [".py"]
endSemantic Equivalence
Different M1 models may map to the same M2 instance:
# Python (M1)
BinOp(op=Add(), left=Name('x'), right=Num(5))
# JavaScript (M1)
BinaryExpression(operator: '+', left: Identifier('x'), right: Literal(5))
# Both abstract to the SAME M2 instance:
{:binary_op, :arithmetic, :+, {:variable, "x"}, {:literal, :integer, 5}}This is the foundation of universal transformations.
Summary
Types
MetaAST representation (M2 level).
Metadata preserving M1-specific information.
Native AST in the language-specific format (M1 level).
Source code as a string.
Callbacks
Extract children from a language-specific AST node.
Return the file extensions this adapter handles.
Transform MetaAST back to native AST (M2 → M1 reification).
Parse source code to native AST (Source → M1).
Transform native AST to MetaAST (M1 → M2 abstraction).
Convert native AST back to source code (M1 → Source).
Validate that an M2 transformation produces valid M1.
Functions
Abstraction pipeline: Source → M1 → M2 (Document).
Detect language from file extension.
Get adapter module for a language.
Reification pipeline: M2 (Document) → M1 → Source.
Full round-trip: Source → M1 → M2 → M1 → Source.
Validate that an adapter correctly implements the behaviour.
Types
@type meta_ast() :: Metastatic.AST.meta_ast()
MetaAST representation (M2 level).
@type metadata() :: map()
Metadata preserving M1-specific information.
Contains details that cannot be represented at M2 level but are necessary for high-fidelity round-trips.
@type native_ast() :: term()
Native AST in the language-specific format (M1 level).
This is an opaque term - the structure varies by language.
@type source() :: String.t()
Source code as a string.
Callbacks
@callback extract_children(native_ast()) :: [native_ast()]
Extract children from a language-specific AST node.
Given a language-specific AST node (M1), return a list of its child nodes that should be traversed during analysis.
This is used by analyzers that need to traverse language-specific nodes
embedded in :language_specific MetaAST nodes.
Default Implementation
If not implemented, the runner will use a generic fallback that attempts to extract children based on common AST patterns.
Examples
# Elixir adapter
extract_children({:defmodule, _meta, [_name, [do: body]]})
# => [body]
# Python adapter
extract_children(%{"_type" => "FunctionDef", "body" => statements})
# => statements
@callback file_extensions() :: [String.t()]
Return the file extensions this adapter handles.
Used for automatic language detection.
Examples
# Python adapter
file_extensions()
# => [".py"]
# JavaScript/TypeScript adapter
file_extensions()
# => [".js", ".jsx", ".ts", ".tsx"]
@callback from_meta(meta_ast(), metadata()) :: {:ok, native_ast()} | {:error, reason :: term()}
Transform MetaAST back to native AST (M2 → M1 reification).
This is the reification operation that instantiates the meta-model (M2) into a concrete language AST (M1).
Uses metadata to restore M1-specific information that was preserved during abstraction (e.g., formatting, type annotations, etc.).
Conformance Validation
Implementation must ensure the resulting M1 AST:
- Is valid for the target language
- Preserves the semantics of the M2 instance
- Can be round-tripped (M1 → M2 → M1 ≈ M1)
Examples
# MetaAST to Python BinOp
from_meta(
{:binary_op, :arithmetic, :+, {:variable, "x"}, {:literal, :integer, 5}},
%{native_lang: :python}
)
# => {:ok, %{"_type" => "BinOp", "op" => %{"_type" => "Add"}, ...}}
@callback parse(source()) :: {:ok, native_ast()} | {:error, reason :: term()}
Parse source code to native AST (Source → M1).
This is a language-specific operation that produces the M1 representation.
Implementation Notes
- May spawn external processes (Python, Node.js, etc.)
- May use native Elixir parsing (for Elixir adapter)
- Should validate syntax and return clear error messages
Examples
# Python adapter
parse("x + 5")
# => {:ok, %{"_type" => "BinOp", "op" => %{"_type" => "Add"}, ...}}
# JavaScript adapter
parse("x + 5")
# => {:ok, %{"type" => "BinaryExpression", "operator" => "+", ...}}
@callback to_meta(native_ast()) :: {:ok, meta_ast(), metadata()} | {:error, reason :: term()}
Transform native AST to MetaAST (M1 → M2 abstraction).
This is the abstraction operation that lifts language-specific AST (M1) to the meta-level (M2).
Semantic Equivalence
Different M1 models may map to the same M2 instance:
- Python:
BinOp(op=Add)→{:binary_op, :arithmetic, :+, ...} - JavaScript:
BinaryExpression(operator: '+')→ same M2 instance - Elixir:
{:+, [], [...]}→ same M2 instance
Return Value
Returns a tuple of {meta_ast, metadata} where:
meta_astis the M2 representationmetadatapreserves M1-specific information for round-tripping
Examples
# Python BinOp to MetaAST
to_meta(%{"_type" => "BinOp", "op" => %{"_type" => "Add"}, ...})
# => {:ok,
# {:binary_op, :arithmetic, :+, {:variable, "x"}, {:literal, :integer, 5}},
# %{native_lang: :python, ...}}
@callback unparse(native_ast()) :: {:ok, source()} | {:error, reason :: term()}
Convert native AST back to source code (M1 → Source).
This is the final step in the M2 → M1 → Source pipeline.
Implementation Notes
- May use language-specific unparsers/pretty-printers
- Should attempt to preserve formatting where possible
- May normalize formatting to language conventions
Examples
# Python AST to source
unparse(%{"_type" => "BinOp", ...})
# => {:ok, "x + 5"}
@callback validate_mutation(meta_ast(), metadata()) :: :ok | {:error, validation_error :: String.t()}
Validate that an M2 transformation produces valid M1.
After a mutation at M2 level, validate that the result:
- Conforms to M2 meta-model structurally
- Can be instantiated in this language (M1 conformance)
- Satisfies language-specific constraints
Examples
- Rust: Reject mutations that violate ownership/borrowing
- TypeScript: Reject mutations that violate type constraints
- Python: Most mutations valid (dynamic typing)
This is M2 → M1 semantic conformance validation.
Default Implementation
If not implemented, assumes all M2-valid transformations are M1-valid.
Functions
@spec abstract(module(), source(), atom()) :: {:ok, Metastatic.Document.t()} | {:error, term()}
Abstraction pipeline: Source → M1 → M2 (Document).
Convenience function that combines parse and to_meta.
Examples
iex> Metastatic.Adapter.abstract(MyAdapter, "x + 5")
{:ok, %Metastatic.Document{
ast: {:binary_op, :arithmetic, :+, ...},
language: :python,
metadata: %{...},
original_source: "x + 5"
}}
Detect language from file extension.
Examples
iex> Metastatic.Adapter.detect_language("script.py")
{:ok, :python}
iex> Metastatic.Adapter.detect_language("app.js")
{:ok, :javascript}
iex> Metastatic.Adapter.detect_language("unknown.xyz")
{:error, :unknown_extension}
Get adapter module for a language.
Returns the appropriate adapter module for a given language atom.
Examples
iex> Metastatic.Adapter.for_language(:python)
{:ok, Metastatic.Adapters.Python}
iex> Metastatic.Adapter.for_language(:unknown)
{:error, :no_adapter_found}
@spec reify(module(), Metastatic.Document.t()) :: {:ok, source()} | {:error, term()}
Reification pipeline: M2 (Document) → M1 → Source.
Convenience function that combines from_meta and unparse.
Examples
iex> doc = %Metastatic.Document{
...> ast: {:binary_op, :arithmetic, :+, {:variable, "x"}, {:literal, :integer, 5}},
...> language: :python,
...> metadata: %{}
...> }
iex> Metastatic.Adapter.reify(MyAdapter, doc)
{:ok, "x + 5"}
Full round-trip: Source → M1 → M2 → M1 → Source.
Useful for testing adapter fidelity.
Examples
iex> source = "x + 5"
iex> Metastatic.Adapter.round_trip(MyAdapter, source)
{:ok, "x + 5"} # May have normalized formatting
Validate that an adapter correctly implements the behaviour.
Checks that all required callbacks are defined.
Examples
iex> Metastatic.Adapter.valid_adapter?(MyPythonAdapter)
true
iex> Metastatic.Adapter.valid_adapter?(InvalidModule)
false