Current section

Files

Jump to
metastatic lib metastatic document.ex
Raw

lib/metastatic/document.ex

defmodule Metastatic.Document do
@moduledoc """
A MetaAST Document wraps an M2 AST with metadata and language information.
This structure represents the result of abstraction (M1 → M2) and serves
as input for reification (M2 → M1).
## Fields
- `ast` - The M2 MetaAST representation
- `metadata` - Language-specific information preserved from M1
- `language` - Source language (:python, :javascript, :elixir, etc.)
- `original_source` - Optional: original source code (for debugging/comparison)
## Metadata
Metadata contains M1-specific information that cannot be represented at M2 level:
- Formatting preferences (indentation, spacing)
- Comments and documentation
- Type annotations (TypeScript, Python type hints)
- Language-specific hints (async models, iterator styles, etc.)
This enables high-fidelity M2 → M1 round-trips while maintaining semantic equivalence.
## Examples
# After abstraction from Python (new 3-tuple format)
%Metastatic.Document{
language: :python,
ast: {:binary_op, [category: :arithmetic, operator: :+],
[{:variable, [], "x"}, {:literal, [subtype: :integer], 5}]},
metadata: %{
native_lang: :python,
type_hints: %{"x" => "int"},
formatting: %{indent: 4}
},
original_source: "x + 5"
}
"""
alias Metastatic.AST
@enforce_keys [:ast, :language]
defstruct [:ast, :metadata, :language, :original_source]
@typedoc """
A Document containing M2 AST with associated metadata.
"""
@type t :: %__MODULE__{
ast: AST.meta_ast(),
metadata: map(),
language: atom(),
original_source: String.t() | nil
}
@doc """
Create a new MetaAST document.
## Examples
iex> ast = {:literal, [subtype: :integer], 42}
iex> Metastatic.Document.new(ast, :python)
%Metastatic.Document{
ast: {:literal, [subtype: :integer], 42},
language: :python,
metadata: %{},
original_source: nil
}
iex> ast = {:variable, [], "x"}
iex> metadata = %{type_hint: "str"}
iex> Metastatic.Document.new(ast, :python, metadata, "x")
%Metastatic.Document{
ast: {:variable, [], "x"},
language: :python,
metadata: %{type_hint: "str"},
original_source: "x"
}
"""
@spec new(AST.meta_ast(), atom(), map(), String.t() | nil) :: t()
def new(ast, language, metadata \\ %{}, original_source \\ nil) do
%__MODULE__{
ast: ast,
language: language,
metadata: metadata,
original_source: original_source
}
end
@doc """
Validate that a document's AST conforms to M2 meta-model.
## Examples
iex> doc = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> Metastatic.Document.valid?(doc)
true
iex> doc = %Metastatic.Document{
...> ast: {:invalid_node, [], "data"},
...> language: :python,
...> metadata: %{}
...> }
iex> Metastatic.Document.valid?(doc)
false
"""
@spec valid?(t()) :: boolean()
def valid?(%__MODULE__{ast: ast}) do
AST.conforms?(ast)
end
@doc """
Update the AST in a document while preserving metadata and language.
Useful for transformations that operate on the M2 level.
## Examples
iex> doc = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> new_ast = {:literal, [subtype: :integer], 100}
iex> updated = Metastatic.Document.update_ast(doc, new_ast)
iex> updated.ast
{:literal, [subtype: :integer], 100}
iex> updated.language
:python
"""
@spec update_ast(t(), AST.meta_ast()) :: t()
def update_ast(%__MODULE__{} = doc, new_ast) do
%{doc | ast: new_ast}
end
@doc """
Update document metadata (merges with existing metadata).
## Examples
iex> doc = Metastatic.Document.new({:variable, [], "x"}, :python, %{type: "int"})
iex> updated = Metastatic.Document.update_metadata(doc, %{mutable: false})
iex> updated.metadata
%{type: "int", mutable: false}
"""
@spec update_metadata(t(), map()) :: t()
def update_metadata(%__MODULE__{metadata: metadata} = doc, new_metadata) do
%{doc | metadata: Map.merge(metadata, new_metadata)}
end
@doc """
Get the language of a document.
## Examples
iex> doc = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> Metastatic.Document.language(doc)
:python
"""
@spec language(t()) :: atom()
def language(%__MODULE__{language: lang}), do: lang
@doc """
Check if two documents have semantically equivalent ASTs.
This compares the M2 AST structures, ignoring metadata and original source.
## Examples
iex> doc1 = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> doc2 = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :javascript)
iex> Metastatic.Document.equivalent?(doc1, doc2)
true
iex> doc1 = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> doc2 = Metastatic.Document.new({:literal, [subtype: :string], "42"}, :python)
iex> Metastatic.Document.equivalent?(doc1, doc2)
false
"""
@spec equivalent?(t(), t()) :: boolean()
def equivalent?(%__MODULE__{ast: ast1}, %__MODULE__{ast: ast2}) do
strip_meta(ast1) == strip_meta(ast2)
end
# Strip metadata from AST nodes for structural comparison.
# Preserves type atoms, children structure, and values but replaces
# keyword metadata with only the semantically significant keys.
@semantic_keys [
:subtype,
:category,
:operator,
:name,
:op_type,
:loop_type,
:params,
:captures,
:container_type,
:visibility,
:pattern,
:construct,
:collection_type,
:original_form,
:language,
:hint,
:source,
:names,
:import_type,
:alias
]
defp strip_meta({type, meta, children}) when is_atom(type) and is_list(meta) do
stripped = Keyword.take(meta, @semantic_keys)
stripped_children = strip_meta(children)
{type, stripped, stripped_children}
end
defp strip_meta(list) when is_list(list) do
Enum.map(list, &strip_meta/1)
end
defp strip_meta(other), do: other
@doc """
Extract all variables referenced in the document's AST.
Delegates to `Metastatic.AST.variables/1`.
## Examples
iex> ast = {:binary_op, [category: :arithmetic, operator: :+],
...> [{:variable, [], "x"}, {:variable, [], "y"}]}
iex> doc = Metastatic.Document.new(ast, :python)
iex> Metastatic.Document.variables(doc)
MapSet.new(["x", "y"])
"""
@spec variables(t()) :: MapSet.t(String.t())
def variables(%__MODULE__{ast: ast}) do
AST.variables(ast)
end
@doc """
Normalize input to a Document.
Accepts either:
- A `Metastatic.Document` struct (returned as-is)
- A `{language, native_ast}` tuple (converted to Document using the appropriate adapter)
This enables analyzers to accept both formats for convenience.
## Examples
# Already a Document - returns as-is
iex> doc = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> Metastatic.Document.normalize(doc)
{:ok, doc}
# {lang, native_ast} tuple - converts using adapter
# (Python adapter doctest skipped until adapter is updated to 3-tuple format)
# python_ast = %{"_type" => "Constant", "value" => 42}
# {:ok, doc} = Metastatic.Document.normalize({:python, python_ast})
# doc.ast => {:literal, [subtype: :integer], 42}
"""
@spec normalize(t() | {atom(), term()}) :: {:ok, t()} | {:error, term()}
def normalize(%__MODULE__{} = doc), do: {:ok, doc}
def normalize({language, source}) when is_atom(language) and is_binary(source) do
with {:ok, adapter} <- Metastatic.adapter_for_language(language) do
case adapter.parse(source) do
{:ok, ast} ->
normalize({language, ast})
{:error, reason} ->
{:error, {:parsing_source_failed, reason}}
end
end
end
def normalize({language, native_ast}) when is_atom(language) do
with {:ok, adapter} <- Metastatic.adapter_for_language(language) do
case adapter.to_meta(native_ast) do
{:ok, meta_ast, metadata} ->
{:ok, new(meta_ast, language, metadata)}
{:error, reason} ->
{:error, {:transformation_failed, reason}}
end
end
end
def normalize(other) do
{:error,
{:invalid_input, "Expected Document.t() or {language, native_ast}, got: #{inspect(other)}"}}
end
@doc """
Normalize input to a Document, raising on error.
## Examples
iex> doc = Metastatic.Document.new({:literal, [subtype: :integer], 42}, :python)
iex> Metastatic.Document.normalize!(doc)
doc
"""
@spec normalize!(t() | {atom(), term()}) :: t()
def normalize!(input) do
case normalize(input) do
{:ok, doc} -> doc
{:error, reason} -> raise "Failed to normalize input: #{inspect(reason)}"
end
end
end