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:

  1. M1 instances conform to M2 meta-model
  2. M2 → M1 → M2 round-trips preserve semantics
  3. 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"]
end

Semantic 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

meta_ast()

@type meta_ast() :: Metastatic.AST.meta_ast()

MetaAST representation (M2 level).

metadata()

@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.

native_ast()

@type native_ast() :: term()

Native AST in the language-specific format (M1 level).

This is an opaque term - the structure varies by language.

source()

@type source() :: String.t()

Source code as a string.

Callbacks

extract_children(native_ast)

(optional)
@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

file_extensions()

@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"]

from_meta(meta_ast, metadata)

@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:

  1. Is valid for the target language
  2. Preserves the semantics of the M2 instance
  3. 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"}, ...}}

parse(source)

@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" => "+", ...}}

to_meta(native_ast)

@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_ast is the M2 representation
  • metadata preserves 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, ...}}

unparse(native_ast)

@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"}

validate_mutation(meta_ast, metadata)

(optional)
@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:

  1. Conforms to M2 meta-model structurally
  2. Can be instantiated in this language (M1 conformance)
  3. 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

abstract(adapter, source, language)

@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(filename)

@spec detect_language(String.t()) :: {:ok, atom()} | {:error, :unknown_extension}

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}

for_language(language)

@spec for_language(atom()) :: {:ok, module()} | {:error, :no_adapter_found}

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}

reify(adapter, document)

@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"}

round_trip(adapter, source)

@spec round_trip(module(), source()) :: {:ok, source()} | {:error, term()}

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

valid_adapter?(adapter)

@spec valid_adapter?(module()) :: boolean()

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