checkpoint

checkpoint

failing test

after fixing tests

checkpoint

checkpoint

checkpoint

re-work

asd

checkpoint

checkpoint

checkpoint

mix proj

checkpoint mix

first parser impl

checkpoint

fix tests

re-org parser

checkpoint strings

fix multiline strings

tuples

checkpoint maps

checkpoint

checkpoint

checkpoint

checkpoint

fix weird eof expression parse error

checkpoint before typing

checkpoint

checpoint

checkpoint

checkpoint

checkpoint ids in primitive types

checkpoint

checkpoint

fix tests

initial annotation

checkpoint

checkpoint

checkpoint

union subtyping

conventions

refactor - split typer

typing tuples

checkpoint test refactor

checkpoint test refactor

parsing atoms

checkpoint atoms

wip lists

checkpoint typing lists

checkopint

checkpoint

wip fixing

correct list typing

map discussion

checkpoint map basic typing

fix tests checkpoint

checkpoint

checkpoint

checkpoint

fix condition typing

fix literal keys in map types

checkpoint union types

checkpoint union type

checkpoint row types discussion & bidirectional typecheck

checkpoint

basic lambdas

checkpoint lambdas typing application

wip function application

checkpoint

checkpoint

checkpoint cduce

checkpoint

checkpoint

checkpoint

checkpoint

checkpoint

checkpoint

checkpoint
This commit is contained in:
Kacper Marzecki 2025-05-29 19:13:51 +02:00
parent 4f13a98189
commit 748f87636a
64 changed files with 78579 additions and 608 deletions

56999
.aider.chat.history.md Normal file

File diff suppressed because it is too large Load Diff

4160
.aider.input.history Normal file

File diff suppressed because it is too large Load Diff

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
pl-*.tar
# Temporary files, for example, from tests.
/tmp/

6
Conventions.md Normal file
View File

@ -0,0 +1,6 @@
Be concise in Your implementation, do not add more comments than an experienced developer would need.
IF You know an some logic needs to be extended at some point - leave a comment.
Be smart and use the existing code in the project, where it makes sense.
Write clear functional code.
If not specified, don't remove existing functionality, but adapt it to the new requirements.

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# Til
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `pl` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:pl, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/pl>.

6589
cduce_code.md Normal file

File diff suppressed because it is too large Load Diff

18
lib/til.ex Normal file
View File

@ -0,0 +1,18 @@
defmodule Til do
@moduledoc """
Documentation for `Til`.
"""
@doc """
Hello world.
## Examples
iex> Til.hello()
:world
"""
def hello do
:world
end
end

246
lib/til/ast_utils.ex Normal file
View File

@ -0,0 +1,246 @@
defmodule Til.AstUtils do
@moduledoc """
Utility functions for working with Til AST node maps.
"""
@doc """
Retrieves a node from the nodes_map by its ID.
## Examples
iex> nodes = %{1 => %{id: 1, name: "node1"}, 2 => %{id: 2, name: "node2"}}
iex> Til.AstUtils.get_node(nodes, 1)
%{id: 1, name: "node1"}
iex> nodes = %{1 => %{id: 1, name: "node1"}}
iex> Til.AstUtils.get_node(nodes, 3)
nil
"""
def get_node(nodes_map, node_id) when is_map(nodes_map) and is_integer(node_id) do
Map.get(nodes_map, node_id)
end
@doc """
Retrieves the child nodes of a given parent node.
The parent can be specified by its ID or as a node map.
Assumes child IDs are stored in the parent's `:children` field.
## Examples
iex> node1 = %{id: 1, children: [2, 3]}
iex> node2 = %{id: 2, value: "child1"}
iex> node3 = %{id: 3, value: "child2"}
iex> nodes = %{1 => node1, 2 => node2, 3 => node3}
iex> Til.AstUtils.get_child_nodes(nodes, 1)
[%{id: 2, value: "child1"}, %{id: 3, value: "child2"}]
iex> Til.AstUtils.get_child_nodes(nodes, node1)
[%{id: 2, value: "child1"}, %{id: 3, value: "child2"}]
iex> node4 = %{id: 4} # No children field
iex> nodes_no_children = %{4 => node4}
iex> Til.AstUtils.get_child_nodes(nodes_no_children, 4)
[]
"""
def get_child_nodes(nodes_map, parent_node_id)
when is_map(nodes_map) and is_integer(parent_node_id) do
case get_node(nodes_map, parent_node_id) do
nil -> []
parent_node -> get_child_nodes_from_node(nodes_map, parent_node)
end
end
def get_child_nodes(nodes_map, parent_node) when is_map(nodes_map) and is_map(parent_node) do
get_child_nodes_from_node(nodes_map, parent_node)
end
defp get_child_nodes_from_node(nodes_map, parent_node) do
parent_node
|> Map.get(:children, [])
|> Enum.map(&get_node(nodes_map, &1))
# Filter out if a child_id doesn't resolve to a node
|> Enum.reject(&is_nil(&1))
end
@doc """
Retrieves the parent node of a given child node.
The child can be specified by its ID or as a node map.
Assumes parent ID is stored in the child's `:parent_id` field.
## Examples
iex> parent = %{id: 1, name: "parent"}
iex> child = %{id: 2, parent_id: 1, name: "child"}
iex> nodes = %{1 => parent, 2 => child}
iex> Til.AstUtils.get_parent_node(nodes, 2)
%{id: 1, name: "parent"}
iex> Til.AstUtils.get_parent_node(nodes, child)
%{id: 1, name: "parent"}
iex> root_node = %{id: 3, parent_id: nil}
iex> nodes_with_root = %{3 => root_node}
iex> Til.AstUtils.get_parent_node(nodes_with_root, 3)
nil
"""
def get_parent_node(nodes_map, child_node_id)
when is_map(nodes_map) and is_integer(child_node_id) do
case get_node(nodes_map, child_node_id) do
nil -> nil
child_node -> get_parent_node_from_node(nodes_map, child_node)
end
end
def get_parent_node(nodes_map, child_node) when is_map(nodes_map) and is_map(child_node) do
get_parent_node_from_node(nodes_map, child_node)
end
defp get_parent_node_from_node(nodes_map, child_node) do
case Map.get(child_node, :parent_id) do
nil -> nil
parent_id -> get_node(nodes_map, parent_id)
end
end
@doc """
Generates a string representation of the AST for pretty printing.
This is a basic implementation and can be expanded for more detail.
"""
def pretty_print_ast(nodes_map) when is_map(nodes_map) do
all_node_ids = Map.keys(nodes_map)
root_nodes =
nodes_map
|> Map.values()
|> Enum.filter(fn node ->
parent_id = Map.get(node, :parent_id)
is_nil(parent_id) or not Enum.member?(all_node_ids, parent_id)
end)
|> Enum.sort_by(fn node ->
case Map.get(node, :location) do
[start_offset | _] when is_integer(start_offset) -> start_offset
# Fallback to id if location is not as expected or not present
_ -> node.id
end
end)
Enum.map_join(root_nodes, "\n", fn root_node ->
do_pretty_print_node(nodes_map, root_node, 0)
end)
end
defp do_pretty_print_node(nodes_map, node, indent_level) do
prefix = String.duplicate(" ", indent_level)
node_id = node.id
ast_type = Map.get(node, :ast_node_type, :unknown)
raw_string = Map.get(node, :raw_string, "") |> String.replace("\n", "\\n")
details =
case ast_type do
:literal_integer -> "value: #{Map.get(node, :value)}"
# Consider truncating
:literal_string -> "value: \"#{Map.get(node, :value)}\""
:symbol -> "name: #{Map.get(node, :name)}"
_ -> ""
end
error_info =
if parsing_error = Map.get(node, :parsing_error) do
" (ERROR: #{parsing_error})"
else
""
end
current_node_str =
"#{prefix}Node #{node_id} [#{ast_type}] raw: \"#{raw_string}\" #{details}#{error_info}"
children_str =
node
|> Map.get(:children, [])
|> Enum.map(fn child_id ->
case get_node(nodes_map, child_id) do
# Should not happen in valid AST
nil -> "#{prefix} Child ID #{child_id} (not found)"
child_node -> do_pretty_print_node(nodes_map, child_node, indent_level + 1)
end
end)
|> Enum.join("\n")
if String.trim(children_str) == "" do
current_node_str
else
current_node_str <> "\n" <> children_str
end
end
@doc """
Generates a nested data structure representation of the AST for debugging.
"""
def build_debug_ast_data(nodes_map) when is_map(nodes_map) do
all_node_ids = Map.keys(nodes_map)
root_nodes =
nodes_map
|> Map.values()
|> Enum.filter(fn node ->
parent_id = Map.get(node, :parent_id)
is_nil(parent_id) or not Enum.member?(all_node_ids, parent_id)
end)
|> Enum.sort_by(fn node ->
case Map.get(node, :location) do
[start_offset | _] when is_integer(start_offset) -> start_offset
_ -> node.id
end
end)
Enum.map(root_nodes, fn root_node ->
do_build_debug_node_data(nodes_map, root_node)
end)
end
defp do_build_debug_node_data(nodes_map, node) do
node_id = node.id
ast_type = Map.get(node, :ast_node_type, :unknown)
raw_string = Map.get(node, :raw_string, "")
details =
case ast_type do
:literal_integer -> %{value: Map.get(node, :value)}
:literal_string -> %{value: Map.get(node, :value)}
:symbol -> %{name: Map.get(node, :name)}
_ -> %{}
end
error_info = Map.get(node, :parsing_error)
base_node_data = %{
id: node_id,
ast_node_type: ast_type,
raw_string: raw_string,
details: details
}
node_data_with_error =
if error_info do
Map.put(base_node_data, :parsing_error, error_info)
else
base_node_data
end
children_data =
node
|> Map.get(:children, [])
|> Enum.map(fn child_id ->
case get_node(nodes_map, child_id) do
nil -> %{error: "Child ID #{child_id} (not found)"}
child_node -> do_build_debug_node_data(nodes_map, child_node)
end
end)
if Enum.empty?(children_data) do
node_data_with_error
else
Map.put(node_data_with_error, :children, children_data)
end
end
end

963
lib/til/parser.ex Normal file
View File

@ -0,0 +1,963 @@
defmodule Til.Parser do
@moduledoc """
Parser for the Tilly Lisp dialect.
It transforms source code into a collection of Node Maps.
"""
# Represents the current parsing position
defstruct offset: 0, line: 1, col: 1, file_name: "unknown", nodes: %{}
@doc """
Parses a source string into a map of AST nodes.
"""
def parse(source_string, file_name \\ "unknown") do
file_node_id = System.unique_integer([:monotonic, :positive])
# Initial location for the file node (starts at the beginning)
file_start_offset = 0
file_start_line = 1
file_start_col = 1
# End location and raw_string will be finalized after parsing all content
prelim_file_node = %{
id: file_node_id,
type_id: nil,
# File node is the root
parent_id: nil,
file: file_name,
# End TBD
location: [file_start_offset, file_start_line, file_start_col, 0, 0, 0],
# TBD
raw_string: "",
ast_node_type: :file,
# TBD
children: [],
parsing_error: nil
}
initial_state = %__MODULE__{
file_name: file_name,
# Add prelim file node
nodes: %{file_node_id => prelim_file_node},
# Initial state offset should be 0 for the file
offset: 0,
# Initial state line should be 1
line: 1,
# Initial state col should be 1
col: 1
}
# Pass original_source_string for raw_string extraction, and file_node_id as parent for top-level exprs
final_state_after_expressions =
parse_all_expressions(source_string, source_string, initial_state, file_node_id)
# Finalize the file node
# Calculate end position of the entire source string
{file_end_line, file_end_col} = calculate_new_line_col(source_string, 1, 1)
# Offset is 0-indexed, length is the count of characters, so end_offset is length.
file_end_offset = String.length(source_string)
# Collect children of the file node
file_children_ids =
final_state_after_expressions.nodes
|> Map.values()
|> Enum.filter(&(&1.parent_id == file_node_id))
# Sort by start offset to maintain order of appearance in the source
|> Enum.sort_by(fn node -> hd(node.location) end)
|> Enum.map(& &1.id)
updated_file_node =
final_state_after_expressions.nodes
|> Map.get(file_node_id)
|> Map.merge(%{
location: [
file_start_offset,
file_start_line,
file_start_col,
file_end_offset,
file_end_line,
file_end_col
],
# The entire source is the raw string of the file node
raw_string: source_string,
children: file_children_ids
})
final_nodes =
Map.put(final_state_after_expressions.nodes, file_node_id, updated_file_node)
{:ok, final_nodes}
end
# --- Main Parsing Logic ---
# original_source_string is the complete initial source, source_string is the current remainder
# parent_id_for_top_level_expressions is the ID of the node that top-level expressions should be parented to (e.g., the :file node)
defp parse_all_expressions(
original_source_string,
source_string,
state,
parent_id_for_top_level_expressions
) do
case skip_whitespace(source_string, state) do
{:eos, final_state} ->
final_state
{:ok, remaining_source, current_state} ->
if remaining_source == "" do
# All content parsed, nothing left after skipping whitespace.
# This is a successful termination of parsing for the current branch.
current_state
else
# There's actual content to parse.
case parse_datum(
original_source_string,
remaining_source,
current_state,
parent_id_for_top_level_expressions
) do
{:ok, _node_id, next_source, next_state} ->
parse_all_expressions(
original_source_string,
next_source,
next_state,
parent_id_for_top_level_expressions
)
{:error_node, _node_id, _reason, next_source, next_state} ->
# An error node was created by parse_datum.
# Input was consumed. Continue parsing from next_source.
parse_all_expressions(
original_source_string,
next_source,
next_state,
parent_id_for_top_level_expressions
)
# NOTE: This relies on parse_datum and its components (like create_error_node_and_advance)
# to always consume input if source_string is not empty. If parse_datum could return
# :error_node without consuming input on a non-empty string, an infinite loop is possible.
# Current implementation of create_error_node_and_advance consumes 1 char.
end
end
end
end
# Parses a single datum: an atom (integer, symbol) or a list.
defp parse_datum(original_source_string, source, state, parent_id) do
# Peek for multi-character tokens first
cond do
String.starts_with?(source, "m{") ->
# Returns {:ok | :error_node, ...}
parse_map_expression(original_source_string, source, state, parent_id)
# Fallback to single character dispatch
true ->
char = String.first(source)
cond do
char == "(" ->
# Returns {:ok | :error_node, ...}
parse_s_expression(original_source_string, source, state, parent_id)
char == ")" ->
# Unexpected closing parenthesis, consume 1 char for the error token ')'
# Returns {:error_node, ...}
create_error_node_and_advance(source, state, parent_id, 1, "Unexpected ')'")
char == "[" ->
# Returns {:ok | :error_node, ...}
parse_list_expression(original_source_string, source, state, parent_id)
char == "]" ->
# Unexpected closing square bracket, consume 1 char for the error token ']'
# Returns {:error_node, ...}
create_error_node_and_advance(source, state, parent_id, 1, "Unexpected ']'")
# For tuples
char == "{" ->
# Returns {:ok | :error_node, ...}
parse_tuple_expression(original_source_string, source, state, parent_id)
char == "}" ->
# Unexpected closing curly brace
# Returns {:error_node, ...}
create_error_node_and_advance(source, state, parent_id, 1, "Unexpected '}'")
char == "'" ->
# Returns {:ok | :error_node, ...}
parse_string_datum(original_source_string, source, state, parent_id)
char == ":" ->
# If the first char is ':', try to parse as an atom like :foo
case parse_atom_datum(source, state, parent_id) do
{:ok, node_id, rest, new_state} ->
{:ok, node_id, rest, new_state}
{:error, :not_atom} ->
# Failed to parse as a specific atom (e.g. ":foo").
# It could be a symbol that starts with ':' (e.g. if we allow ":" as a symbol).
# Fallback to general symbol parsing. Integer parsing won't match if it starts with ':'.
case parse_symbol_datum(source, state, parent_id) do
{:ok, node_id, rest, new_state} ->
{:ok, node_id, rest, new_state}
{:error, :not_symbol} ->
# If it started with ':' but wasn't a valid atom and also not a valid symbol
create_error_node_and_advance(source, state, parent_id, 1, "Unknown token starting with ':'")
end
end
true ->
# Default case for other characters
# Try parsing as an integer first
case parse_integer_datum(source, state, parent_id) do
{:ok, node_id, rest, new_state} ->
{:ok, node_id, rest, new_state}
{:error, :not_integer} ->
# Not an integer, try parsing as a symbol
case parse_symbol_datum(source, state, parent_id) do
{:ok, node_id, rest, new_state} ->
{:ok, node_id, rest, new_state}
{:error, :not_symbol} ->
# Not a symbol either. Consume 1 char for the unknown token.
create_error_node_and_advance(source, state, parent_id, 1, "Unknown token")
end
end
end # end inner cond
end # end outer cond
end
# --- Datum Parsing Helpers --- (parse_string_datum, process_string_content)
defp parse_string_datum(_original_source_string, source, state, parent_id) do
# state is before consuming "'"
initial_state_for_token = state
strip_indent = initial_state_for_token.col - 1
# Consume opening "'"
{opening_tick, source_after_opening_tick} = String.split_at(source, 1)
case :binary.match(source_after_opening_tick, "'") do
:nomatch ->
# Unclosed string
content_segment = source_after_opening_tick
raw_token = opening_tick <> content_segment
state_at_node_end = advance_pos(initial_state_for_token, raw_token)
location = [
initial_state_for_token.offset,
initial_state_for_token.line,
initial_state_for_token.col,
state_at_node_end.offset,
state_at_node_end.line,
state_at_node_end.col
]
processed_value = process_string_content(content_segment, strip_indent)
{node_id, state_with_error_node} =
add_node(
initial_state_for_token,
parent_id,
location,
raw_token,
:literal_string,
%{value: processed_value, parsing_error: "Unclosed string literal"}
)
final_state = %{
state_with_error_node
| offset: state_at_node_end.offset,
line: state_at_node_end.line,
col: state_at_node_end.col
}
{:error_node, node_id, "Unclosed string literal", "", final_state}
# _tick_length will be 1 for "`"
{idx_closing_tick_in_segment, _tick_length} ->
content_segment =
String.slice(source_after_opening_tick, 0, idx_closing_tick_in_segment)
closing_tick = "'"
raw_token = opening_tick <> content_segment <> closing_tick
rest_of_source =
String.slice(source_after_opening_tick, (idx_closing_tick_in_segment + 1)..-1)
state_at_node_end = advance_pos(initial_state_for_token, raw_token)
location = [
initial_state_for_token.offset,
initial_state_for_token.line,
initial_state_for_token.col,
state_at_node_end.offset,
state_at_node_end.line,
state_at_node_end.col
]
processed_value = process_string_content(content_segment, strip_indent)
{new_node_id, state_with_node} =
add_node(
initial_state_for_token,
parent_id,
location,
raw_token,
:literal_string,
%{value: processed_value}
)
final_state = %{
state_with_node
| offset: state_at_node_end.offset,
line: state_at_node_end.line,
col: state_at_node_end.col
}
{:ok, new_node_id, rest_of_source, final_state}
end
end
defp process_string_content(content_str, strip_indent) when strip_indent >= 0 do
lines = String.split(content_str, "\n", trim: false)
# Will always exist, even for empty content_str -> ""
first_line = List.first(lines)
rest_lines =
if length(lines) > 1 do
List.delete_at(lines, 0)
else
[]
end
processed_rest_lines =
Enum.map(rest_lines, fn line ->
current_leading_spaces_count =
Regex.run(~r/^(\s*)/, line)
|> List.first()
|> String.length()
spaces_to_remove = min(current_leading_spaces_count, strip_indent)
String.slice(line, spaces_to_remove..-1)
end)
all_processed_lines = [first_line | processed_rest_lines]
Enum.join(all_processed_lines, "\n")
end
# --- Datum Parsing Helpers --- (parse_string_datum, process_string_content)
# (parse_string_datum remains unchanged)
defp parse_atom_datum(source, state, parent_id) do
# Atom is a colon followed by one or more non-delimiter characters.
# Delimiters are whitespace, (, ), [, ], {, }.
# The colon itself is part of the atom's raw string.
# The `atom_name_part` is what comes after the colon.
case Regex.run(~r/^:([^\s\(\)\[\]\{\}]+)/, source) do
[raw_atom_str, atom_name_part] -> # raw_atom_str is like ":foo", atom_name_part is "foo"
# The regex [^...]+ ensures atom_name_part is not empty.
rest_after_atom = String.slice(source, String.length(raw_atom_str)..-1)
start_offset = state.offset
start_line = state.line
start_col = state.col
state_after_token = advance_pos(state, raw_atom_str)
end_offset = state_after_token.offset
end_line = state_after_token.line
end_col = state_after_token.col
location = [start_offset, start_line, start_col, end_offset, end_line, end_col]
# Convert the name part (e.g., "foo") to an Elixir atom (e.g., :foo)
atom_value = String.to_atom(atom_name_part)
{new_node_id, state_with_node} =
add_node(
state,
parent_id,
location,
raw_atom_str,
:literal_atom,
%{value: atom_value}
)
final_state = %{
state_with_node
| offset: end_offset,
line: end_line,
col: end_col
}
{:ok, new_node_id, rest_after_atom, final_state}
_ -> # No match (nil or list that doesn't conform, e.g., just ":" or ": followed by space/delimiter")
{:error, :not_atom}
end
end
defp parse_integer_datum(source, state, parent_id) do
case Integer.parse(source) do
{int_val, rest_after_int} ->
raw_int =
String.slice(source, 0, String.length(source) - String.length(rest_after_int))
start_offset = state.offset
start_line = state.line
start_col = state.col
state_after_token = advance_pos(state, raw_int)
end_offset = state_after_token.offset
end_line = state_after_token.line
end_col = state_after_token.col
location = [start_offset, start_line, start_col, end_offset, end_line, end_col]
{new_node_id, state_with_node} =
add_node(state, parent_id, location, raw_int, :literal_integer, %{value: int_val})
# Update state to reflect consumed token
final_state = %{state_with_node | offset: end_offset, line: end_line, col: end_col}
{:ok, new_node_id, rest_after_int, final_state}
:error ->
# Indicates failure, source and state are unchanged by this attempt
{:error, :not_integer}
end
end
defp parse_symbol_datum(source, state, parent_id) do
# Regex excludes common delimiters. `m{` is handled before symbol parsing.
case Regex.run(~r/^([^\s\(\)\[\]\{\}]+)/, source) do
[raw_symbol | _] ->
rest_after_symbol = String.slice(source, String.length(raw_symbol)..-1)
start_offset = state.offset
start_line = state.line
start_col = state.col
state_after_token = advance_pos(state, raw_symbol)
end_offset = state_after_token.offset
end_line = state_after_token.line
end_col = state_after_token.col
location = [start_offset, start_line, start_col, end_offset, end_line, end_col]
{new_node_id, state_with_node} =
add_node(state, parent_id, location, raw_symbol, :symbol, %{name: raw_symbol})
# Update state to reflect consumed token
final_state = %{
state_with_node
| offset: end_offset,
line: end_line,
col: end_col
}
{:ok, new_node_id, rest_after_symbol, final_state}
nil ->
# Indicates failure, source and state are unchanged by this attempt
{:error, :not_symbol}
end
end
defp create_error_node_and_advance(
source_for_token,
state_before_token,
parent_id,
num_chars_for_token,
error_message
) do
{raw_token, rest_of_source} = String.split_at(source_for_token, num_chars_for_token)
start_offset = state_before_token.offset
start_line = state_before_token.line
start_col = state_before_token.col
state_after_token_consumed = advance_pos(state_before_token, raw_token)
end_offset = state_after_token_consumed.offset
end_line = state_after_token_consumed.line
end_col = state_after_token_consumed.col
location = [start_offset, start_line, start_col, end_offset, end_line, end_col]
{error_node_id, state_with_error_node} =
add_node(state_before_token, parent_id, location, raw_token, :unknown, %{
parsing_error: error_message
})
# The state for further parsing must reflect the consumed token's position and include the new error node
final_error_state = %{
state_with_error_node
| offset: end_offset,
line: end_line,
col: end_col
}
{:error_node, error_node_id, error_message, rest_of_source, final_error_state}
end
defp parse_s_expression(original_source_string, source, state, parent_id) do
# Standard S-expression parsing via parse_collection
result = parse_collection(
original_source_string,
source,
state,
parent_id,
"(",
")",
:s_expression,
"Unclosed S-expression",
"Error parsing element in S-expression. Content might be incomplete."
)
# After parsing, check if it's an 'fn' expression
case result do
{:ok, collection_node_id, rest_after_collection, state_after_collection} ->
collection_node = Map.get(state_after_collection.nodes, collection_node_id)
if is_fn_expression?(collection_node, state_after_collection.nodes) do
transformed_node =
transform_to_lambda_expression(collection_node, state_after_collection.nodes)
final_state = %{
state_after_collection
| nodes:
Map.put(state_after_collection.nodes, transformed_node.id, transformed_node)
}
{:ok, transformed_node.id, rest_after_collection, final_state}
else
# Not an fn expression, return as is
result
end
_error_or_other ->
# Propagate errors or other results from parse_collection
result
end
end
# Helper to check if an S-expression node is an 'fn' expression
defp is_fn_expression?(s_expr_node, nodes_map) do
if s_expr_node.ast_node_type == :s_expression && !Enum.empty?(s_expr_node.children) do
first_child_id = hd(s_expr_node.children)
first_child_node = Map.get(nodes_map, first_child_id)
first_child_node && first_child_node.ast_node_type == :symbol &&
first_child_node.name == "fn"
else
false
end
end
# Helper to transform a generic S-expression node (known to be an 'fn' form)
# into a :lambda_expression node.
defp transform_to_lambda_expression(s_expr_node, nodes_map) do
# s_expr_node.children = [fn_symbol_id, params_s_expr_id, body_form1_id, ...]
_fn_symbol_id = Enum.at(s_expr_node.children, 0) # Already checked
if length(s_expr_node.children) < 2 do
%{s_expr_node | parsing_error: "Malformed 'fn' expression: missing parameters list."}
else
params_s_expr_id = Enum.at(s_expr_node.children, 1)
params_s_expr_node = Map.get(nodes_map, params_s_expr_id)
if !(params_s_expr_node && params_s_expr_node.ast_node_type == :s_expression) do
Map.put(s_expr_node, :parsing_error, "Malformed 'fn' expression: parameters list is not an S-expression.")
else
# Children of the parameters S-expression, e.g. for (fn ((a integer) (b atom) atom) ...),
# param_s_expr_children_ids would be IDs of [(a integer), (b atom), atom]
all_param_children_ids = Map.get(params_s_expr_node, :children, [])
{arg_spec_node_ids, return_type_spec_node_id} =
if Enum.empty?(all_param_children_ids) do
# Case: (fn () body) -> No args, nil (inferred) return type spec
{[], nil}
else
# Case: (fn (arg1 type1 ... ret_type) body)
# Last element is return type spec, rest are arg specs.
args = Enum.take(all_param_children_ids, length(all_param_children_ids) - 1)
ret_type_id = List.last(all_param_children_ids)
{args, ret_type_id}
end
# Validate arg_spec_node_ids: each must be a symbol or an S-expr (param_symbol type_spec)
all_arg_specs_valid =
Enum.all?(arg_spec_node_ids, fn arg_id ->
arg_node = Map.get(nodes_map, arg_id)
case arg_node do
%{ast_node_type: :symbol} -> true # e.g. x
%{ast_node_type: :s_expression, children: s_children} -> # e.g. (x integer)
if length(s_children) == 2 do
param_sym_node = Map.get(nodes_map, hd(s_children))
type_spec_node = Map.get(nodes_map, hd(tl(s_children)))
param_sym_node && param_sym_node.ast_node_type == :symbol &&
type_spec_node && (type_spec_node.ast_node_type == :symbol || type_spec_node.ast_node_type == :s_expression)
else
false # Not a valid (param_symbol type_spec) structure
end
_ -> false # Not a symbol or valid S-expression for arg spec
end
end)
# Validate return_type_spec_node_id: must be nil or a valid type specifier node
return_type_spec_valid =
if is_nil(return_type_spec_node_id) do
true # Inferred return type is valid
else
ret_type_node = Map.get(nodes_map, return_type_spec_node_id)
ret_type_node && (ret_type_node.ast_node_type == :symbol || ret_type_node.ast_node_type == :s_expression)
end
if all_arg_specs_valid && return_type_spec_valid do
body_node_ids = Enum.drop(s_expr_node.children, 2) # Body starts after 'fn' and params_s_expr
Map.merge(s_expr_node, %{
:ast_node_type => :lambda_expression,
:params_s_expr_id => params_s_expr_id,
:arg_spec_node_ids => arg_spec_node_ids,
:return_type_spec_node_id => return_type_spec_node_id,
:body_node_ids => body_node_ids
})
else
# Determine more specific error message
error_message =
cond do
!all_arg_specs_valid -> "Malformed 'fn' expression: invalid argument specification(s)."
!return_type_spec_valid -> "Malformed 'fn' expression: invalid return type specification."
true -> "Malformed 'fn' expression." # Generic fallback
end
Map.put(s_expr_node, :parsing_error, error_message)
end
end
end
end
defp parse_list_expression(original_source_string, source, state, parent_id) do
parse_collection(
original_source_string,
source,
state,
parent_id,
"[",
"]",
:list_expression,
"Unclosed list",
"Error parsing element in list. Content might be incomplete."
)
end
defp parse_map_expression(original_source_string, source, state, parent_id) do
parse_collection(
original_source_string,
source,
state,
parent_id,
# Opening token
"m{",
# Closing token
"}",
:map_expression,
"Unclosed map",
"Error parsing element in map. Content might be incomplete."
)
end
defp parse_tuple_expression(original_source_string, source, state, parent_id) do
parse_collection(
original_source_string,
source,
state,
parent_id,
"{",
"}",
:tuple_expression,
"Unclosed tuple",
"Error parsing element in tuple. Content might be incomplete."
)
end
defp parse_collection(
original_source_string,
source,
state,
parent_id,
open_char_str,
# Used by parse_collection_elements
close_char_str,
ast_node_type,
# Used by parse_collection_elements
unclosed_error_msg,
# Used by parse_collection_elements
element_error_msg
) do
# Consume opening token (e.g. '(', '[', 'm{')
collection_start_offset = state.offset
collection_start_line = state.line
collection_start_col = state.col
open_char_len = String.length(open_char_str)
{_opening_token, rest_after_opening_token} = String.split_at(source, open_char_len)
current_state = advance_pos(state, open_char_str)
collection_node_id = System.unique_integer([:monotonic, :positive])
prelim_collection_node = %{
id: collection_node_id,
type_id: nil,
parent_id: parent_id,
file: current_state.file_name,
# End TBD
location: [collection_start_offset, collection_start_line, collection_start_col, 0, 0, 0],
# TBD
raw_string: "",
ast_node_type: ast_node_type,
children: [],
parsing_error: nil
}
current_state_with_prelim_node = %{
current_state
| nodes: Map.put(current_state.nodes, collection_node_id, prelim_collection_node)
}
collection_start_pos_for_children =
{collection_start_offset, collection_start_line, collection_start_col}
# Pass all necessary params to the generalized element parser
result =
parse_collection_elements(
original_source_string,
rest_after_opening_token,
current_state_with_prelim_node,
collection_node_id,
[],
collection_start_pos_for_children,
# Parameters for generalization, passed from parse_collection's arguments:
# Used by parse_collection_elements
close_char_str,
# Used by parse_collection_elements
unclosed_error_msg,
# Passed to parse_collection_elements (might be unused there now)
element_error_msg
)
# Adapt result to {:ok, node_id, ...} or {:error_node, node_id, ...}
case result do
{:ok, returned_collection_node_id, rest, state_after_elements} ->
{:ok, returned_collection_node_id, rest, state_after_elements}
{:error, reason, rest, state_after_elements} ->
# The collection_node_id is the ID of the node that has the error.
# This 'reason' is typically for unclosed collections or fatal element errors.
{:error_node, collection_node_id, reason, rest, state_after_elements}
end
end
# Generalized from parse_s_expression_elements
defp parse_collection_elements(
original_source_string,
source,
state,
collection_node_id,
children_ids_acc,
collection_start_pos_tuple,
# New parameters for generalization:
# e.g., ")" or "]"
closing_char_str,
# e.g., "Unclosed S-expression"
unclosed_error_message,
# e.g., "Error parsing element in S-expression..."
# Now potentially unused, marked with underscore
element_error_message
) do
case skip_whitespace(source, state) do
{:eos, current_state_at_eos} ->
# Unclosed collection
collection_node = Map.get(current_state_at_eos.nodes, collection_node_id)
start_offset = elem(collection_start_pos_tuple, 0)
end_offset = current_state_at_eos.offset
actual_raw_string =
String.slice(original_source_string, start_offset, end_offset - start_offset)
updated_collection_node = %{
collection_node
| # Use generalized message
parsing_error: unclosed_error_message,
children: Enum.reverse(children_ids_acc),
location: [
start_offset,
elem(collection_start_pos_tuple, 1),
elem(collection_start_pos_tuple, 2),
end_offset,
current_state_at_eos.line,
current_state_at_eos.col
],
raw_string: actual_raw_string
}
final_state = %{
current_state_at_eos
| nodes:
Map.put(current_state_at_eos.nodes, collection_node_id, updated_collection_node)
}
# This error is for the collection itself being unclosed.
# The collection_node_id is implicitly the ID of this error node.
{:error, unclosed_error_message, "", final_state}
{:ok, remaining_source, current_state} ->
# Check if the remaining source starts with the closing token string
if String.starts_with?(remaining_source, closing_char_str) do
# End of collection
closing_char_len = String.length(closing_char_str)
{_closing_token, rest_after_closing_token} =
String.split_at(remaining_source, closing_char_len)
final_collection_state = advance_pos(current_state, closing_char_str)
collection_node = Map.get(final_collection_state.nodes, collection_node_id)
coll_final_start_offset = elem(collection_start_pos_tuple, 0)
coll_final_start_line = elem(collection_start_pos_tuple, 1)
coll_final_start_col = elem(collection_start_pos_tuple, 2)
coll_final_end_offset = final_collection_state.offset
coll_final_end_line = final_collection_state.line
coll_final_end_col = final_collection_state.col
actual_raw_string =
String.slice(
original_source_string,
coll_final_start_offset,
coll_final_end_offset - coll_final_start_offset
)
updated_collection_node = %{
collection_node
| children: Enum.reverse(children_ids_acc),
location: [
coll_final_start_offset,
coll_final_start_line,
coll_final_start_col,
coll_final_end_offset,
coll_final_end_line,
coll_final_end_col
],
raw_string: actual_raw_string
}
final_state_with_collection = %{
final_collection_state
| nodes:
Map.put(
final_collection_state.nodes,
collection_node_id,
updated_collection_node
)
}
{:ok, collection_node_id, rest_after_closing_token, final_state_with_collection}
else
# Parse an element
case parse_datum(
original_source_string,
remaining_source,
current_state,
# parent_id for the element
collection_node_id
) do
{:ok, child_node_id, next_source_after_elem, next_state_after_elem} ->
parse_collection_elements(
original_source_string,
next_source_after_elem,
next_state_after_elem,
collection_node_id,
# Add successful child's ID
[child_node_id | children_ids_acc],
collection_start_pos_tuple,
closing_char_str,
unclosed_error_message,
# Pass through, though may be unused
element_error_message
)
{:error_node, child_error_node_id, _child_reason, next_source_after_elem,
next_state_after_elem} ->
# An error node was created for the child element. Add its ID and continue.
parse_collection_elements(
original_source_string,
next_source_after_elem,
next_state_after_elem,
collection_node_id,
# Add error child's ID
[child_error_node_id | children_ids_acc],
collection_start_pos_tuple,
closing_char_str,
unclosed_error_message,
# Pass through
element_error_message
)
# No other return types are expected from parse_datum if it always creates a node on error
# or succeeds. If parse_datum could fail without creating a node and without consuming input,
# that would be an issue here, potentially leading to infinite loops if not handled.
# The current changes aim for parse_datum to always return :ok or :error_node.
end
end
end
end
# --- Utility Functions ---
# Note: The `extra_fields` argument was changed from optional to required
# as the default value was never used according to compiler warnings.
defp add_node(state, parent_id, location, raw_string, ast_node_type, extra_fields) do
node_id = System.unique_integer([:monotonic, :positive])
node =
%{
id: node_id,
type_id: nil,
parent_id: parent_id,
file: state.file_name,
# [start_offset, start_line, start_col, end_offset, end_line, end_col]
location: location,
raw_string: raw_string,
ast_node_type: ast_node_type
}
|> Map.merge(extra_fields)
{node_id, %{state | nodes: Map.put(state.nodes, node_id, node)}}
end
defp skip_whitespace(source, state = %__MODULE__{offset: o, line: l, col: c}) do
whitespace_match = Regex.run(~r/^\s+/, source)
if whitespace_match do
[ws | _] = whitespace_match
new_offset = o + String.length(ws)
{new_line, new_col} = calculate_new_line_col(ws, l, c)
remaining_source = String.slice(source, String.length(ws)..-1)
{:ok, remaining_source, %{state | offset: new_offset, line: new_line, col: new_col}}
else
if String.length(source) == 0 do
{:eos, state}
else
# No leading whitespace
{:ok, source, state}
end
end
end
defp calculate_new_line_col(string_segment, start_line, start_col) do
string_segment
|> String.codepoints()
|> Enum.reduce({start_line, start_col}, fn char, {line, col} ->
if char == "\n" do
{line + 1, 1}
else
{line, col + 1}
end
end)
end
defp advance_pos(state = %__MODULE__{offset: o, line: l, col: c}, consumed_string) do
new_offset = o + String.length(consumed_string)
{new_line, new_col} = calculate_new_line_col(consumed_string, l, c)
%{state | offset: new_offset, line: new_line, col: new_col}
end
end

504
lib/til/typer.ex Normal file
View File

@ -0,0 +1,504 @@
defmodule Til.Typer do
@moduledoc """
Handles type checking and type inference for the Tilly Lisp dialect.
It processes the AST (Node Maps) generated by the parser and annotates
nodes with their inferred or checked types.
"""
# alias Til.AstUtils # Removed as it's not used yet and causes a warning.
# alias MapSet, as: Set # No longer directly used here, moved to specialized modules
alias Til.Typer.Types
alias Til.Typer.Interner
alias Til.Typer.ExpressionTyper
# alias Til.Typer.SubtypeChecker # Not directly used in this module after refactor
alias Til.Typer.Environment
@doc """
Performs type checking and inference on a map of AST nodes.
It iterates through the nodes, infers their types, and updates the
`:type_id` field in each node map with a reference to its type.
Returns a new map of nodes with type information.
"""
def type_check(nodes_map) when is_map(nodes_map) do
initial_env = %{}
pre_populated_nodes_map = Interner.populate_known_types(nodes_map)
# Find the main file node to start traversal.
# Assumes parser always generates a :file node as the root of top-level expressions.
case Enum.find(Map.values(pre_populated_nodes_map), &(&1.ast_node_type == :file)) do
nil ->
# Should not happen with current parser, but handle defensively.
# Or an error: {:error, :no_file_node_found}
# Return map with known types at least
{:ok, pre_populated_nodes_map}
file_node ->
# Start recursive typing from the file node.
# The environment modifications will propagate through the traversal.
# The result is {:ok, final_nodes_map, _final_env}. We only need final_nodes_map here.
case type_node_recursively(file_node.id, pre_populated_nodes_map, initial_env) do
{:ok, final_nodes_map, final_env} ->
# IO.inspect(final_env, label: "Final Environment after Typing (should show type keys)")
# IO.inspect(final_nodes_map, label: "Final Nodes Map (should contain type definitions)")
{:ok, final_nodes_map}
# Propagate other return values (e.g., errors) if they occur,
# though current implementation of type_node_recursively always returns {:ok, _, _}.
other_result ->
other_result
end
end
end
# Main recursive function for typing nodes.
# Handles node lookup and delegates to do_type_node for actual processing.
defp type_node_recursively(node_id, nodes_map, env) do
case Map.get(nodes_map, node_id) do
nil ->
# This case should ideally not be reached if node_ids are always valid.
# Consider logging an error here.
# IO.inspect("Warning: Node ID #{node_id} not found in nodes_map during typing.", label: "Typer")
# No change if node_id is invalid
{:ok, nodes_map, env}
node_data ->
# Delegate to the worker function that processes the node.
do_type_node(node_data, nodes_map, env)
end
end
# Worker function to process a single node.
# Orchestrates typing children, inferring current node's type, and updating environment.
defp do_type_node(node_data, nodes_map, env) do
# Determine the environment and children to type based on node type
{children_to_process_ids, env_for_children, nodes_map_after_pre_processing} =
if node_data.ast_node_type == :lambda_expression do
# For lambdas: (fn params_s_expr body...)
# The 'fn' symbol (child 0) and 'params_s_expr' (child 1) are typed with the outer env.
# The body_node_ids are typed with the inner lambda_body_env.
# Type 'fn' symbol (first child of the original S-expression)
fn_op_child_id = hd(node_data.children)
{:ok, nmap_after_fn_op, env_after_fn_op} =
type_node_recursively(fn_op_child_id, nodes_map, env)
# Type params_s_expr (second child of the original S-expression)
# This node (node_data) has `params_s_expr_id` from the parser.
params_s_expr_node_id = node_data.params_s_expr_id
{:ok, nmap_after_params_s_expr, env_after_params_s_expr} =
type_node_recursively(params_s_expr_node_id, nmap_after_fn_op, env_after_fn_op)
# Create lambda body environment using arg_spec_node_ids.
# The lambda_expression node has `arg_spec_node_ids` and `return_type_spec_node_id`.
# Argument types need to be resolved and interned here to populate the env.
# nodes_map is nmap_after_params_s_expr at this point.
{lambda_body_env, nmap_after_arg_type_resolution} =
Enum.reduce(
node_data.arg_spec_node_ids,
{env_after_params_s_expr, nmap_after_params_s_expr},
fn arg_spec_id, {acc_env, acc_nodes_map} ->
arg_spec_node = Map.get(acc_nodes_map, arg_spec_id)
case arg_spec_node.ast_node_type do
# Unannotated param, e.g., x
:symbol ->
param_name = arg_spec_node.name
param_type_key = Types.primitive_type_key(:any)
{Map.put(acc_env, param_name, param_type_key), acc_nodes_map}
# Annotated param, e.g., (x integer)
:s_expression ->
param_symbol_node_id = hd(arg_spec_node.children)
type_spec_node_id = hd(tl(arg_spec_node.children))
param_symbol_node = Map.get(acc_nodes_map, param_symbol_node_id)
type_spec_node = Map.get(acc_nodes_map, type_spec_node_id)
param_name = param_symbol_node.name
# Resolve and intern the type specifier
{raw_type_def, nmap_after_resolve} =
ExpressionTyper.resolve_type_specifier_node(type_spec_node, acc_nodes_map)
{param_type_key, nmap_after_intern} =
Interner.get_or_intern_type(raw_type_def, nmap_after_resolve)
{Map.put(acc_env, param_name, param_type_key), nmap_after_intern}
end
end
)
# Children to process with this new env are the body_node_ids
{node_data.body_node_ids, lambda_body_env, nmap_after_arg_type_resolution}
else
# Default: type all children with the current environment
{Map.get(node_data, :children, []), env, nodes_map}
end
# 1. Recursively type the identified children with the determined environment.
{nodes_map_after_children, env_after_children} =
Enum.reduce(
children_to_process_ids,
{nodes_map_after_pre_processing, env_for_children},
fn child_id, {acc_nodes_map, acc_env} ->
{:ok, next_nodes_map, next_env} =
type_node_recursively(child_id, acc_nodes_map, acc_env)
{next_nodes_map, next_env}
end
)
# Retrieve the current node's data from the potentially updated nodes_map.
# More importantly, infer_type_for_node_ast needs the nodes_map_after_children
# to look up typed children.
current_node_from_map = Map.get(nodes_map_after_children, node_data.id)
# 2. Infer type for the current node.
# infer_type_for_node_ast now returns {type_definition_map, possibly_updated_nodes_map}.
{type_definition_for_current_node, nodes_map_after_inference_logic} =
infer_type_for_node_ast(
current_node_from_map,
nodes_map_after_children,
env_after_children
)
# Intern this type definition to get a key and update nodes_map.
{type_key_for_current_node, nodes_map_after_interning} =
Interner.get_or_intern_type(
type_definition_for_current_node,
nodes_map_after_inference_logic
)
# Update current node with the type key.
# Ensure we are updating the version of the node from nodes_map_after_interning
# (which is based on nodes_map_after_children).
re_fetched_current_node_data = Map.get(nodes_map_after_interning, current_node_from_map.id)
updated_current_node =
Map.put(re_fetched_current_node_data, :type_id, type_key_for_current_node)
nodes_map_with_typed_node =
Map.put(nodes_map_after_interning, updated_current_node.id, updated_current_node)
# 3. Update environment based on the current typed node (e.g., for assignments).
# update_env_from_node now returns {updated_env, updated_nodes_map}.
{env_after_current_node, nodes_map_after_env_update} =
Environment.update_env_from_node(
updated_current_node,
nodes_map_with_typed_node,
env_after_children
)
{:ok, nodes_map_after_env_update, env_after_current_node}
end
# Infers the type for a node based on its AST type and current environment.
# `nodes_map` contains potentially typed children (whose :type_id is a key) and canonical type definitions.
# `env` is the current typing environment (symbol names to type keys).
# Returns {type_definition_map, possibly_updated_nodes_map}.
defp infer_type_for_node_ast(node_data, nodes_map, env) do
case node_data.ast_node_type do
:literal_integer ->
{%{type_kind: :literal, value: node_data.value}, nodes_map}
:literal_string ->
{%{type_kind: :literal, value: node_data.value}, nodes_map}
# Atoms are parsed as :literal_atom with a :value field containing the Elixir atom (as per parser.ex)
:literal_atom ->
{%{type_kind: :literal, value: node_data.value}, nodes_map}
:symbol ->
case node_data.name do
"nil" ->
{Types.get_literal_type(:nil_atom), nodes_map}
"true" ->
{Types.get_literal_type(:true_atom), nodes_map}
"false" ->
{Types.get_literal_type(:false_atom), nodes_map}
_ ->
# Look up symbol in the environment. env stores type keys.
case Map.get(env, node_data.name) do
nil ->
# Symbol not found. Default to :any type definition.
# TODO: Handle unresolved symbols more robustly (e.g., specific error type).
{Types.get_primitive_type(:any), nodes_map}
found_type_key ->
# Resolve the key to its definition from nodes_map.
case Map.get(nodes_map, found_type_key) do
nil ->
# This indicates an inconsistency if a key from env isn't in nodes_map.
# Default to :any or an error type.
# IO.warn("Type key #{inspect(found_type_key)} for symbol '#{node_data.name}' not found in nodes_map.")
# Or a specific error type definition
{Types.get_primitive_type(:any), nodes_map}
type_definition ->
{type_definition, nodes_map}
end
end
end
:s_expression ->
ExpressionTyper.infer_s_expression_type(node_data, nodes_map, env)
:list_expression ->
children_ids = Map.get(node_data, :children, [])
num_children = length(children_ids)
element_type_definition =
cond do
num_children == 0 ->
Types.get_primitive_type(:nothing)
true ->
# Children are already typed. Get their type definitions.
child_type_defs =
Enum.map(children_ids, fn child_id ->
# nodes_map is nodes_map_after_children
child_node = Map.get(nodes_map, child_id)
type_key_for_child = child_node.type_id
# Resolve the type key to its definition.
type_def_for_child = Map.get(nodes_map, type_key_for_child)
if is_nil(type_def_for_child) do
# Fallback, should ideally not happen if children are correctly typed.
Types.get_primitive_type(:any)
else
type_def_for_child
end
end)
# Determine a common element type.
distinct_child_type_defs = Enum.uniq(child_type_defs)
cond do
length(distinct_child_type_defs) == 1 ->
# All elements effectively have the same type definition (e.g., [1, 1, 1] -> Literal 1).
List.first(distinct_child_type_defs)
true ->
# Form a union of the distinct child types.
# E.g., [1, 2, 3] -> (Union (Literal 1) (Literal 2) (Literal 3))
# E.g., [1, "a"] -> (Union (Literal 1) (Literal "a"))
# The types in distinct_child_type_defs are already resolved definitions.
# The interner will handle canonicalizing this union type.
%{type_kind: :union, types: MapSet.new(distinct_child_type_defs)}
end
end
list_type_def = %{
type_kind: :list,
# This is the full def; interner will use its key.
element_type: element_type_definition,
length: num_children
}
{list_type_def, nodes_map}
:file ->
# The :file node itself doesn't have a typical "type".
{Types.get_special_type(:file_marker), nodes_map}
:map_expression ->
children_ids = Map.get(node_data, :children, [])
# Children are [key1, value1, key2, value2, ...]
known_elements_raw =
children_ids
# [[k1,v1], [k2,v2]]
|> Enum.chunk_every(2)
|> Enum.reduce_while(%{}, fn [key_node_id, value_node_id], acc_known_elements ->
key_node = Map.get(nodes_map, key_node_id)
value_node = Map.get(nodes_map, value_node_id)
# Key's type must be a literal type for it to be used in known_elements.
# Child nodes (keys and values) are already typed at this stage.
key_type_def =
if key_node && key_node.type_id do
Map.get(nodes_map, key_node.type_id)
else
# Key node or its type_id is missing
nil
end
cond do
key_type_def && key_type_def.type_kind == :literal && value_node ->
literal_key_value = key_type_def.value
# Value node should have been typed, its type_id points to its definition
value_type_def =
Map.get(nodes_map, value_node.type_id, Types.get_primitive_type(:any))
updated_elements =
Map.put(
acc_known_elements,
literal_key_value,
%{value_type: value_type_def, optional: false}
)
{:cont, updated_elements}
true ->
# If a key's type is not a literal, or key/value nodes are missing,
# this map literal cannot be precisely typed with known_elements.
# Halt and return empty known_elements, leading to a less specific type.
# IO.warn(
# "Map literal key is not a literal type or node data missing. Key node: #{inspect(key_node)}, Key type: #{inspect(key_type_def)}"
# )
{:halt, %{}}
end
end)
# Default index signature for map literals: any other key maps to any value.
default_index_signature = %{
key_type: Types.get_primitive_type(:any),
value_type: Types.get_primitive_type(:any)
}
map_type_def = %{
type_kind: :map,
known_elements: known_elements_raw,
index_signature: default_index_signature
}
{map_type_def, nodes_map}
:tuple_expression ->
children_ids = Map.get(node_data, :children, [])
element_type_defs =
Enum.map(children_ids, fn child_id ->
# nodes_map is nodes_map_after_children
child_node = Map.get(nodes_map, child_id)
# This should be set from prior typing.
type_key_for_child = child_node.type_id
# Resolve the type key to its definition.
type_def_for_child = Map.get(nodes_map, type_key_for_child)
if is_nil(type_def_for_child) do
# This case indicates an internal inconsistency:
# a child node has a type_id, but that ID doesn't resolve to a type definition.
# This shouldn't happen in a correctly functioning typer.
# Fallback to :any for robustness, but log or signal error if possible.
# IO.warn("Tuple element #{child_id} (in node #{node_data.id}) has type_id #{type_key_for_child} but no definition in nodes_map.")
Types.get_primitive_type(:any)
else
type_def_for_child
end
end)
tuple_type_def = %{type_kind: :tuple, element_types: element_type_defs}
# nodes_map is unchanged here; interning of this new tuple_type_def happens later.
{tuple_type_def, nodes_map}
:lambda_expression ->
# node_data is the :lambda_expression node.
# Its body_node_ids have been typed using the lambda_body_env.
# nodes_map is nodes_map_after_children.
# Resolve argument types for the function signature
{raw_arg_type_defs, nodes_map_after_args} =
Enum.map_reduce(
node_data.arg_spec_node_ids,
# This is nodes_map_after_children from do_type_node
nodes_map,
fn arg_spec_id, acc_nodes_map ->
arg_spec_node = Map.get(acc_nodes_map, arg_spec_id)
case arg_spec_node.ast_node_type do
# Unannotated param
:symbol ->
{Types.get_primitive_type(:any), acc_nodes_map}
# Annotated param (param_symbol type_spec)
:s_expression ->
type_spec_node_id = hd(tl(arg_spec_node.children))
type_spec_node = Map.get(acc_nodes_map, type_spec_node_id)
ExpressionTyper.resolve_type_specifier_node(type_spec_node, acc_nodes_map)
end
end
)
# Resolve/Infer return type for the function signature
{return_type_def_for_signature, nodes_map_after_return} =
if node_data.return_type_spec_node_id do
# Explicit return type annotation
return_type_spec_node =
Map.get(nodes_map_after_args, node_data.return_type_spec_node_id)
{expected_return_raw_def, nmap_after_ret_resolve} =
ExpressionTyper.resolve_type_specifier_node(
return_type_spec_node,
nodes_map_after_args
)
# Intern the expected return type to get its canonical form for checks
{expected_return_key, nmap_after_ret_intern} =
Interner.get_or_intern_type(expected_return_raw_def, nmap_after_ret_resolve)
expected_return_interned_def = Map.get(nmap_after_ret_intern, expected_return_key)
# Check if actual body return type is subtype of annotated return type
_actual_body_return_interned_def =
if Enum.empty?(node_data.body_node_ids) do
# Raw, but interner handles it
Types.get_literal_type(:nil_atom)
else
last_body_expr_node =
Map.get(nmap_after_ret_intern, List.last(node_data.body_node_ids))
# Already interned
Map.get(nmap_after_ret_intern, last_body_expr_node.type_id)
end
# Perform subtype check if needed (for error reporting, not changing signature type yet)
# if !SubtypeChecker.is_subtype?(_actual_body_return_interned_def, expected_return_interned_def, nmap_after_ret_intern) do
# IO.warn("Lambda body return type mismatch with annotation.") # Placeholder for error
# end
{expected_return_interned_def, nmap_after_ret_intern}
else
# Infer return type from body
inferred_return_def =
if Enum.empty?(node_data.body_node_ids) do
Types.get_literal_type(:nil_atom)
else
last_body_expr_node =
Map.get(nodes_map_after_args, List.last(node_data.body_node_ids))
# Already interned
Map.get(nodes_map_after_args, last_body_expr_node.type_id)
end
{inferred_return_def, nodes_map_after_args}
end
function_type_raw_def = %{
type_kind: :function,
arg_types: raw_arg_type_defs,
# This is an interned def or raw primitive/literal
return_type: return_type_def_for_signature,
type_params: []
}
{function_type_raw_def, nodes_map_after_return}
# Default for other AST node types
_ ->
# Placeholder: return :any type definition.
{Types.get_primitive_type(:any), nodes_map}
end
end
end

View File

@ -0,0 +1,59 @@
defmodule Til.Typer.Environment do
@moduledoc """
Manages updates to the typing environment (symbol name to type key mappings).
"""
# Updates the environment based on the current typed node.
# Returns {updated_env, updated_nodes_map}. nodes_map is usually unchanged by this function
# unless a construct like (deftype ...) is processed that adds to type definitions.
def update_env_from_node(typed_node, nodes_map, env) do
case typed_node.ast_node_type do
:s_expression ->
new_env = update_env_for_s_expression(typed_node, nodes_map, env)
# nodes_map typically unchanged by simple assignment.
{new_env, nodes_map}
_ ->
# Most nodes don't modify the environment by default.
{env, nodes_map}
end
end
# Handles environment updates for S-expressions (e.g., assignments).
# Returns the updated environment.
defp update_env_for_s_expression(s_expr_node, nodes_map, env) do
children_ids = Map.get(s_expr_node, :children, [])
# Basic check for assignment: (= symbol value), three children.
if length(children_ids) == 3 do
operator_node_id = List.first(children_ids)
operator_node = Map.get(nodes_map, operator_node_id)
if operator_node && operator_node.ast_node_type == :symbol && operator_node.name == "=" do
symbol_to_assign_id = Enum.at(children_ids, 1)
value_expr_id = Enum.at(children_ids, 2)
symbol_to_assign_node = Map.get(nodes_map, symbol_to_assign_id)
# This node is already typed.
value_expr_node = Map.get(nodes_map, value_expr_id)
if symbol_to_assign_node && symbol_to_assign_node.ast_node_type == :symbol &&
value_expr_node && value_expr_node.type_id do
# value_expr_node.type_id is the type key of the value.
type_key_of_value = value_expr_node.type_id
# Update environment with the new symbol binding (symbol name -> type key).
Map.put(env, symbol_to_assign_node.name, type_key_of_value)
else
# Malformed assignment or missing type info, no env change.
env
end
else
# Not an assignment, env unchanged by this s-expression itself.
env
end
else
# Not a 3-element s-expression, cannot be our simple assignment.
env
end
end
end

View File

@ -0,0 +1,506 @@
defmodule Til.Typer.ExpressionTyper do
@moduledoc """
Handles type inference for specific AST expression types (e.g., S-expressions, if, the).
"""
alias Til.Typer.Types
alias Til.Typer.SubtypeChecker
alias Til.Typer.Interner
alias MapSet, as: Set
# Infers the type of an S-expression.
# Returns {type_definition_map, possibly_updated_nodes_map}.
def infer_s_expression_type(s_expr_node, nodes_map, env) do
children_ids = Map.get(s_expr_node, :children, [])
if Enum.empty?(children_ids) do
# Type of empty s-expression '()' is nil.
{Types.get_literal_type(:nil_atom), nodes_map}
else
operator_node_id = List.first(children_ids)
operator_node = Map.get(nodes_map, operator_node_id)
arg_node_ids = Enum.drop(children_ids, 1)
if is_nil(operator_node) do
# This should ideally not be reached if parser ensures children_ids are valid.
# Fallback or error type.
{Types.get_primitive_type(:any), nodes_map}
else
# Check if the operator is a symbol for a known special form first.
if operator_node.ast_node_type == :symbol do
case operator_node.name do
"=" ->
# Assignment: (= sym val) - expects 2 arguments (symbol and value)
# Total children_ids for s_expr_node should be 3.
if length(arg_node_ids) == 2 do
# value_expr_node is the second argument to '=', which is the third child of s_expr_node
value_expr_node = Map.get(nodes_map, Enum.at(children_ids, 2))
if value_expr_node && value_expr_node.type_id do
value_type_key = value_expr_node.type_id
case Map.get(nodes_map, value_type_key) do
nil ->
{Types.get_error_type_definition(:missing_value_type_in_assignment),
nodes_map}
value_type_definition ->
{value_type_definition, nodes_map}
end
else
{Types.get_error_type_definition(:missing_value_type_in_assignment), nodes_map}
end
else
# Malformed assignment (e.g. (= x) or (= x y z))
# TODO: Specific error type for malformed assignment arity
{Types.get_primitive_type(:any), nodes_map}
end
"if" ->
infer_if_expression_type(s_expr_node, nodes_map, env)
"the" ->
infer_the_expression_type(s_expr_node, nodes_map, env)
_ ->
# Not a special form symbol, attempt to treat as a regular function call.
type_function_call(operator_node, arg_node_ids, nodes_map)
end
else
# Operator is not a symbol (e.g., a lambda expression itself, or an S-expression like (if ...)).
# Attempt to treat as a regular function call.
type_function_call(operator_node, arg_node_ids, nodes_map)
end
end
end
end
# Helper function to type a function call.
# operator_node is the node representing the function/operator.
# arg_node_ids is a list of IDs for the argument nodes.
# nodes_map contains all typed nodes and type definitions.
# Returns {type_definition_for_call, nodes_map}.
defp type_function_call(operator_node, arg_node_ids, nodes_map) do
operator_type_id = operator_node.type_id
# The operator_node should have been typed by the main Typer loop before this.
operator_type_def = if operator_type_id, do: Map.get(nodes_map, operator_type_id), else: nil
cond do
is_nil(operator_type_def) ->
# This means operator_type_id was nil (operator node not typed) or
# operator_type_id was not a valid key in nodes_map.
# This indicates an issue prior to attempting the call.
# For (1 2 3), '1' is typed as literal 1. Its type_id is valid.
# This path is more for unexpected states.
# Defaulting to :not_a_function with nil actual_operator_type_id.
{Types.get_error_type_definition(:not_a_function, nil), nodes_map}
operator_type_def.type_kind == :function ->
expected_arity = length(operator_type_def.arg_types)
actual_arity = length(arg_node_ids)
if expected_arity != actual_arity do
{Types.get_error_type_definition(
:arity_mismatch,
expected_arity,
actual_arity,
operator_type_id # key of the function type itself
), nodes_map}
else
# TODO: Implement argument type checking loop here when needed.
# For Phase 3, if lambdas take 'any' args, this check might not be strictly necessary
# for current tests, but the structure should be prepared.
# If an argument type mismatch is found:
# return {Types.get_error_type_definition(:argument_type_mismatch, ...), nodes_map}
# If arity matches and (for now) args are assumed compatible,
# the type of the call is the function's return type.
return_type_key = operator_type_def.return_type
return_type_def = Map.get(nodes_map, return_type_key)
if return_type_def do
{return_type_def, nodes_map}
else
# Return type key from function definition was invalid. Internal error.
# Fallback to :any or a specific error.
{Types.get_primitive_type(:any), nodes_map}
end
end
true ->
# Operator is typed, but its type_kind is not :function.
{Types.get_error_type_definition(
:not_a_function,
operator_type_id # key of the operator's actual (non-function) type
), nodes_map}
end
end
# Infers the type of a (the <type-specifier> <expression>) S-expression.
# Returns {type_definition_map, possibly_updated_nodes_map}.
def infer_the_expression_type(the_s_expr_node, nodes_map, _env) do
children_ids = Map.get(the_s_expr_node, :children, [])
# (the type-specifier actual-expression) -> 3 children
# children_ids are [the_op_id, type_spec_id, expr_id]
if length(children_ids) == 3 do
type_spec_node_id = Enum.at(children_ids, 1)
expr_node_id = Enum.at(children_ids, 2)
type_spec_node = Map.get(nodes_map, type_spec_node_id)
expr_node = Map.get(nodes_map, expr_node_id)
# Resolve the type specifier node (e.g., symbol 'integer') to a raw type definition
# nodes_map is the input to infer_the_expression_type
{raw_annotated_def, nodes_map_after_resolve} =
resolve_type_specifier_node(type_spec_node, nodes_map)
# Intern the resolved annotated type definition to get its canonical, interned form
{annotated_type_key, current_nodes_map} =
Interner.get_or_intern_type(raw_annotated_def, nodes_map_after_resolve)
# This is the interned definition, e.g. %{... element_type_id: ..., id: ...}
annotated_def = Map.get(current_nodes_map, annotated_type_key)
# The expr_node should have been typed by the recursive call to type_children.
# Its type_id points to its actual inferred type definition (which is already interned).
actual_expr_type_def =
if expr_node && expr_node.type_id do
# Fetch using the most up-to-date nodes_map
Map.get(current_nodes_map, expr_node.type_id)
else
nil
end
if expr_node && expr_node.type_id && actual_expr_type_def do
# Both actual_expr_type_def and annotated_def are now interned forms.
is_compatible =
SubtypeChecker.is_subtype?(
actual_expr_type_def,
# Use interned form
annotated_def,
# Use most up-to-date nodes_map
current_nodes_map
)
if is_compatible do
# The type of the 'the' expression is the raw annotated type.
# The caller (Typer.do_type_node) will intern this.
{raw_annotated_def, current_nodes_map}
else
# Type mismatch: actual type is not a subtype of the annotated type.
actual_id = actual_expr_type_def.id
# This is annotated_def.id
expected_id = annotated_type_key
{Types.get_error_type_definition(:type_annotation_mismatch, actual_id, expected_id),
current_nodes_map}
end
else
# This 'else' covers cases where the inner expression could not be typed:
# 1. expr_node is nil
# 2. expr_node.type_id is nil
# 3. actual_expr_type_def (resolved from expr_node.type_id) is nil
# In these cases, the annotation cannot be validated. Return an error type.
# IO.warn("Could not determine actual type of expression in 'the' form: #{inspect(expr_node)}")
# actual_type_id is nil as it couldn't be determined. expected_type_id is from annotation.
# annotated_type_key might not be set if raw_annotated_def was an error
expected_id =
if annotated_type_key, do: annotated_type_key, else: nil
{Types.get_error_type_definition(:type_annotation_mismatch, nil, expected_id),
current_nodes_map}
end
else
# Malformed 'the' expression (e.g., wrong number of children).
# IO.warn("Malformed 'the' expression: #{the_s_expr_node.raw_string}")
# If a 'the' expression is malformed, both actual and expected types are indeterminate in this context.
# nodes_map is original from caller here
{Types.get_error_type_definition(:type_annotation_mismatch, nil, nil), nodes_map}
end
end
# Infers the type of an IF S-expression.
# Returns {type_definition_map, possibly_updated_nodes_map}.
def infer_if_expression_type(if_s_expr_node, nodes_map, _env) do
children_ids = Map.get(if_s_expr_node, :children, [])
num_children_in_sexpr = length(children_ids)
# Used as fallback
canonical_any_type = Map.get(nodes_map, Types.any_type_key())
canonical_nil_type = Map.get(nodes_map, Types.nil_literal_type_key())
_literal_true_type = Map.get(nodes_map, Types.literal_type_key(:true_atom))
_literal_false_type = Map.get(nodes_map, Types.literal_type_key(:false_atom))
# primitive_boolean_type = Map.get(nodes_map, Types.primitive_type_key(:boolean)) # If we add :boolean
# Check for malformed 'if' (less than 2 parts: 'if' and condition)
if num_children_in_sexpr < 2 do
# IO.warn("Malformed 'if' expression (too few parts): #{if_s_expr_node.raw_string}")
# Malformed if results in :any for now.
{Types.get_primitive_type(:any), nodes_map}
else
condition_id = Enum.at(children_ids, 1)
condition_node = Map.get(nodes_map, condition_id)
condition_type_def =
SubtypeChecker.get_type_definition_from_node(
condition_node,
nodes_map,
canonical_any_type
)
# Condition type is now evaluated based on truthiness/falsiness,
# not strict boolean type. The :invalid_if_condition_type error is removed.
# Proceed with branch typing based on static truthiness/falsiness.
cond do
# (if condition then_branch)
num_children_in_sexpr == 3 ->
then_branch_id = Enum.at(children_ids, 2)
then_branch_node = Map.get(nodes_map, then_branch_id)
then_type_def =
SubtypeChecker.get_type_definition_from_node(
then_branch_node,
nodes_map,
canonical_any_type
)
cond do
SubtypeChecker.is_statically_truthy?(condition_type_def) ->
{then_type_def, nodes_map}
SubtypeChecker.is_statically_falsy?(condition_type_def) ->
# Implicit else is nil
{canonical_nil_type, nodes_map}
true ->
# Condition is ambiguous, form union of then and nil
union_members = Set.new([then_type_def, canonical_nil_type])
if Set.size(union_members) == 1 do
{hd(Set.to_list(union_members)), nodes_map}
else
{%{type_kind: :union, types: union_members}, nodes_map}
end
end
# (if condition then_branch else_branch)
num_children_in_sexpr == 4 ->
then_branch_id = Enum.at(children_ids, 2)
else_branch_id = Enum.at(children_ids, 3)
then_branch_node = Map.get(nodes_map, then_branch_id)
else_branch_node = Map.get(nodes_map, else_branch_id)
then_type_def =
SubtypeChecker.get_type_definition_from_node(
then_branch_node,
nodes_map,
canonical_any_type
)
else_type_def =
SubtypeChecker.get_type_definition_from_node(
else_branch_node,
nodes_map,
canonical_any_type
)
cond do
SubtypeChecker.is_statically_truthy?(condition_type_def) ->
{then_type_def, nodes_map}
SubtypeChecker.is_statically_falsy?(condition_type_def) ->
{else_type_def, nodes_map}
true ->
# Condition is ambiguous, form union of then and else
union_members = Set.new([then_type_def, else_type_def])
if Set.size(union_members) == 1 do
{hd(Set.to_list(union_members)), nodes_map}
else
{%{type_kind: :union, types: union_members}, nodes_map}
end
end
true ->
# Malformed 'if' (e.g. (if c t e extra) or already handled (if), (if c))
# IO.warn("Malformed 'if' expression (incorrect number of parts): #{if_s_expr_node.raw_string}")
# Malformed if results in :any
{Types.get_primitive_type(:any), nodes_map}
end
# Removed: end of 'if not is_valid_condition_type else ...'
end
end
# Resolves a type specifier AST node (e.g., a symbol like 'integer')
# to its corresponding type definition map.
# Returns {type_definition_map, nodes_map}. nodes_map can be updated if element type resolution updates it.
def resolve_type_specifier_node(type_spec_node, nodes_map) do
# Fallback for unknown type specifiers
default_type = Types.get_primitive_type(:any)
cond do
type_spec_node && type_spec_node.ast_node_type == :symbol ->
type_name_str = type_spec_node.name
# Map common type names to their definitions.
case type_name_str do
"integer" ->
{Types.get_primitive_type(:integer), nodes_map}
"string" ->
{Types.get_primitive_type(:string), nodes_map}
"atom" ->
{Types.get_primitive_type(:atom), nodes_map}
"number" ->
{Types.get_primitive_type(:number), nodes_map}
"any" ->
{Types.get_primitive_type(:any), nodes_map}
"nothing" ->
{Types.get_primitive_type(:nothing), nodes_map}
# TODO: Add other built-in types like boolean, map, etc.
_ ->
# IO.warn("Unknown type specifier symbol: '#{type_name_str}', defaulting to :any.")
{default_type, nodes_map}
end
type_spec_node && type_spec_node.ast_node_type == :s_expression ->
# Handle S-expression type specifiers like (list ...), (map ...), (union ...)
s_expr_children_ids = Map.get(type_spec_node, :children, [])
if length(s_expr_children_ids) >= 1 do
op_node_id = List.first(s_expr_children_ids)
op_node = Map.get(nodes_map, op_node_id)
if op_node && op_node.ast_node_type == :symbol do
case op_node.name do
"list" ->
if length(s_expr_children_ids) == 2 do
element_type_spec_id = Enum.at(s_expr_children_ids, 1)
element_type_spec_node = Map.get(nodes_map, element_type_spec_id)
if element_type_spec_node do
# Recursively resolve the element type specifier
{resolved_element_type_def, nodes_map_after_element_resolve} =
resolve_type_specifier_node(element_type_spec_node, nodes_map)
list_type_def = %{
type_kind: :list,
element_type: resolved_element_type_def,
length: nil
}
{list_type_def, nodes_map_after_element_resolve}
else
# Malformed (list ...), missing element type specifier
{default_type, nodes_map}
end
else
# Malformed (list ...), wrong arity
{default_type, nodes_map}
end
"map" ->
if length(s_expr_children_ids) == 3 do
key_type_spec_id = Enum.at(s_expr_children_ids, 1)
value_type_spec_id = Enum.at(s_expr_children_ids, 2)
key_type_spec_node = Map.get(nodes_map, key_type_spec_id)
value_type_spec_node = Map.get(nodes_map, value_type_spec_id)
if key_type_spec_node && value_type_spec_node do
{resolved_key_type_def, nodes_map_after_key_resolve} =
resolve_type_specifier_node(key_type_spec_node, nodes_map)
{resolved_value_type_def, nodes_map_after_value_resolve} =
resolve_type_specifier_node(
value_type_spec_node,
nodes_map_after_key_resolve
)
map_type_def = %{
type_kind: :map,
known_elements: %{},
index_signature: %{
key_type: resolved_key_type_def,
value_type: resolved_value_type_def
}
}
{map_type_def, nodes_map_after_value_resolve}
else
# Malformed (map ...), missing key/value type specifiers
{default_type, nodes_map}
end
else
# Malformed (map ...), wrong arity
{default_type, nodes_map}
end
"union" ->
# (union typeA typeB ...)
member_type_spec_ids = Enum.drop(s_expr_children_ids, 1)
cond do
length(member_type_spec_ids) == 0 ->
# (union) -> nothing
{Types.get_primitive_type(:nothing), nodes_map}
length(member_type_spec_ids) == 1 ->
# (union typeA) -> typeA
single_member_spec_node_id = List.first(member_type_spec_ids)
single_member_spec_node = Map.get(nodes_map, single_member_spec_node_id)
resolve_type_specifier_node(single_member_spec_node, nodes_map)
true ->
# (union typeA typeB ...) -> resolve each and form union
{resolved_member_defs, final_nodes_map} =
Enum.map_reduce(
member_type_spec_ids,
nodes_map,
fn member_id, acc_nodes_map ->
member_node = Map.get(acc_nodes_map, member_id)
{resolved_def, updated_nodes_map} =
resolve_type_specifier_node(member_node, acc_nodes_map)
{resolved_def, updated_nodes_map}
end
)
union_type_def = %{
type_kind: :union,
types: MapSet.new(resolved_member_defs)
}
{union_type_def, final_nodes_map}
end
_ ->
# Unknown S-expression operator for type specifier
# IO.warn("Unknown S-expression type specifier operator: #{op_node.name}")
{default_type, nodes_map}
end
else
# First child of S-expression type specifier is not a symbol
# IO.warn("S-expression type specifier does not start with a symbol: #{type_spec_node.raw_string}")
{default_type, nodes_map}
end
else
# Empty S-expression as type specifier `()` or malformed (e.g. `(list)`)
# IO.warn("Empty or malformed S-expression type specifier: #{type_spec_node.raw_string}")
{default_type, nodes_map}
end
true ->
# Type specifier is not a symbol, not a recognized s-expression, or is nil.
# IO.warn("Invalid type specifier node: #{inspect(type_spec_node)}, defaulting to :any.")
{default_type, nodes_map}
end
end
end

499
lib/til/typer/interner.ex Normal file
View File

@ -0,0 +1,499 @@
defmodule Til.Typer.Interner do
@moduledoc """
Handles the interning of type definitions into a nodes_map.
Ensures that identical type definitions (especially predefined ones)
map to canonical keys.
"""
alias Til.Typer.Types
def populate_known_types(nodes_map) do
initial_map_with_primitives =
Enum.reduce(Types.primitive_types(), nodes_map, fn {name, def}, acc_map ->
key = Types.primitive_type_key(name)
Map.put(acc_map, key, Map.put(def, :id, key))
end)
map_with_literals =
Enum.reduce(Types.literal_types(), initial_map_with_primitives, fn {name, def}, acc_map ->
key = Types.literal_type_key(name)
Map.put(acc_map, key, Map.put(def, :id, key))
end)
initial_map_with_specials =
Enum.reduce(Types.special_types(), map_with_literals, fn {name, def}, acc_map ->
key = Types.special_type_key(name)
Map.put(acc_map, key, Map.put(def, :id, key))
end)
# Filter out :type_annotation_mismatch as it's dynamic and not in error_type_keys_map anymore
# Pre-populate other static error types.
static_error_defs =
Types.error_type_definitions()
|> Enum.filter(fn {name, _def} ->
# Check if a key exists for this error name (type_annotation_mismatch won't have one)
Types.error_type_keys_map()[name] != nil
end)
Enum.reduce(static_error_defs, initial_map_with_specials, fn {name, def}, acc_map ->
key = Types.error_type_key(name) # This will only work for names still in error_type_keys_map
Map.put(acc_map, key, Map.put(def, :id, key))
end)
end
# Helper to get a canonical key for a type definition, storing it in nodes_map if new.
def get_or_intern_type(type_definition_map, nodes_map) do
# Normalize incoming type_definition_map by removing :id for matching against base definitions
type_def_for_matching = Map.delete(type_definition_map, :id)
primitive_match =
Enum.find(Types.primitive_types(), fn {_name, def} -> def == type_def_for_matching end)
literal_match =
Enum.find(Types.literal_types(), fn {_name, def} -> def == type_def_for_matching end)
special_match =
Enum.find(Types.special_types(), fn {_name, def} -> def == type_def_for_matching end)
error_match =
Enum.find(Types.error_type_definitions(), fn {_name, def} ->
def == type_def_for_matching
end)
cond do
primitive_match ->
{name, _def} = primitive_match
{Types.primitive_type_key(name), nodes_map}
literal_match ->
{name, _def} = literal_match
{Types.literal_type_key(name), nodes_map}
special_match ->
{name, _def} = special_match
{Types.special_type_key(name), nodes_map}
# Handle specific error types first, then general error_match for predefined ones
type_definition_map.type_kind == :error and
type_definition_map.reason == :type_annotation_mismatch ->
# Dynamic error type: %{type_kind: :error, reason: :type_annotation_mismatch, actual_type_id: key1, expected_type_id: key2}
# Search for existing based on all relevant fields (excluding :id itself)
error_def_for_matching = Map.delete(type_definition_map, :id)
existing_error_match =
Enum.find(nodes_map, fn {_key, existing_def} ->
# Ensure it's a type definition (has :type_kind) before checking its properties
Map.has_key?(existing_def, :type_kind) &&
existing_def.type_kind == :error &&
Map.delete(existing_def, :id) == error_def_for_matching
end)
cond do
existing_error_match ->
{_key, existing_def} = existing_error_match
{existing_def.id, nodes_map}
true ->
# Create a new key for this specific instance of type_annotation_mismatch error
actual_id_str = to_string(type_definition_map.actual_type_id || "nil")
expected_id_str = to_string(type_definition_map.expected_type_id || "nil")
new_error_key =
:"type_error_tam_#{actual_id_str}_exp_#{expected_id_str}_#{System.unique_integer([:monotonic, :positive])}"
final_error_def = Map.put(type_definition_map, :id, new_error_key)
{new_error_key, Map.put(nodes_map, new_error_key, final_error_def)}
end
type_definition_map.type_kind == :error and
type_definition_map.reason == :invalid_if_condition_type ->
# Dynamic error type: %{type_kind: :error, reason: :invalid_if_condition_type, actual_condition_type_id: key}
error_def_for_matching = Map.delete(type_definition_map, :id)
existing_error_match =
Enum.find(nodes_map, fn {_key, existing_def} ->
Map.has_key?(existing_def, :type_kind) &&
existing_def.type_kind == :error &&
Map.delete(existing_def, :id) == error_def_for_matching
end)
cond do
existing_error_match ->
{_key, existing_def} = existing_error_match
{existing_def.id, nodes_map}
true ->
actual_id_str = to_string(type_definition_map.actual_condition_type_id || "nil")
new_error_key =
:"type_error_iict_#{actual_id_str}_#{System.unique_integer([:monotonic, :positive])}"
final_error_def = Map.put(type_definition_map, :id, new_error_key)
{new_error_key, Map.put(nodes_map, new_error_key, final_error_def)}
end
type_definition_map.type_kind == :error and
type_definition_map.reason == :not_a_function ->
# Dynamic error type: %{type_kind: :error, reason: :not_a_function, actual_operator_type_id: key}
error_def_for_matching = Map.delete(type_definition_map, :id)
existing_error_match =
Enum.find(nodes_map, fn {_key, existing_def} ->
Map.has_key?(existing_def, :type_kind) &&
existing_def.type_kind == :error &&
Map.delete(existing_def, :id) == error_def_for_matching
end)
cond do
existing_error_match ->
{_key, existing_def} = existing_error_match
{existing_def.id, nodes_map}
true ->
actual_op_id_str = to_string(type_definition_map.actual_operator_type_id || "nil")
new_error_key =
:"type_error_naf_#{actual_op_id_str}_#{System.unique_integer([:monotonic, :positive])}"
final_error_def = Map.put(type_definition_map, :id, new_error_key)
{new_error_key, Map.put(nodes_map, new_error_key, final_error_def)}
end
type_definition_map.type_kind == :error and
type_definition_map.reason == :arity_mismatch ->
# Dynamic error type: %{type_kind: :error, reason: :arity_mismatch, expected_arity: int, actual_arity: int, function_type_id: key}
error_def_for_matching = Map.delete(type_definition_map, :id)
existing_error_match =
Enum.find(nodes_map, fn {_key, existing_def} ->
Map.has_key?(existing_def, :type_kind) &&
existing_def.type_kind == :error &&
Map.delete(existing_def, :id) == error_def_for_matching
end)
cond do
existing_error_match ->
{_key, existing_def} = existing_error_match
{existing_def.id, nodes_map}
true ->
exp_arity_str = to_string(type_definition_map.expected_arity)
act_arity_str = to_string(type_definition_map.actual_arity)
func_id_str = to_string(type_definition_map.function_type_id || "nil")
new_error_key =
:"type_error_am_#{exp_arity_str}_#{act_arity_str}_#{func_id_str}_#{System.unique_integer([:monotonic, :positive])}"
final_error_def = Map.put(type_definition_map, :id, new_error_key)
{new_error_key, Map.put(nodes_map, new_error_key, final_error_def)}
end
type_definition_map.type_kind == :error and
type_definition_map.reason == :argument_type_mismatch ->
# Dynamic error type: %{type_kind: :error, reason: :argument_type_mismatch, arg_position: int, expected_arg_type_id: key, actual_arg_type_id: key, function_type_id: key}
error_def_for_matching = Map.delete(type_definition_map, :id)
existing_error_match =
Enum.find(nodes_map, fn {_key, existing_def} ->
Map.has_key?(existing_def, :type_kind) &&
existing_def.type_kind == :error &&
Map.delete(existing_def, :id) == error_def_for_matching
end)
cond do
existing_error_match ->
{_key, existing_def} = existing_error_match
{existing_def.id, nodes_map}
true ->
pos_str = to_string(type_definition_map.arg_position)
exp_id_str = to_string(type_definition_map.expected_arg_type_id || "nil")
act_id_str = to_string(type_definition_map.actual_arg_type_id || "nil")
func_id_str = to_string(type_definition_map.function_type_id || "nil")
new_error_key =
:"type_error_atm_#{pos_str}_#{exp_id_str}_#{act_id_str}_#{func_id_str}_#{System.unique_integer([:monotonic, :positive])}"
final_error_def = Map.put(type_definition_map, :id, new_error_key)
{new_error_key, Map.put(nodes_map, new_error_key, final_error_def)}
end
error_match -> # Handles other predefined errors
{name, _def} = error_match
{Types.error_type_key(name), nodes_map}
type_definition_map.type_kind == :list ->
# type_definition_map is like %{type_kind: :list, element_type: <full_def_or_key>, length: L}
%{element_type: element_full_def_or_key, length: len} = type_definition_map
# Recursively get/intern the element type to get its key, if it's not already a key
{element_type_key, nodes_map_after_element_intern} =
if is_map(element_full_def_or_key) do
# It's a raw definition, intern it
get_or_intern_type(element_full_def_or_key, nodes_map)
else
# It's already a key
{element_full_def_or_key, nodes_map}
end
# Canonical form for searching/storing uses the element type's key
canonical_list_struct = %{
type_kind: :list,
element_type_id: element_type_key,
length: len
}
# Search for an existing identical list type definition
existing_list_type_match =
Enum.find(nodes_map_after_element_intern, fn {_key, existing_def} ->
# Compare structure, excluding the :id field of the existing_def
Map.delete(existing_def, :id) == canonical_list_struct
end)
cond do
existing_list_type_match ->
# Found an identical list type, reuse its key
{_key, existing_def} = existing_list_type_match
{existing_def.id, nodes_map_after_element_intern}
true ->
# No existing identical list type, create a new one
new_list_key = :"type_list_#{System.unique_integer([:monotonic, :positive])}"
final_list_def = Map.put(canonical_list_struct, :id, new_list_key)
{new_list_key, Map.put(nodes_map_after_element_intern, new_list_key, final_list_def)}
end
type_definition_map.type_kind == :union ->
# type_definition_map is %{type_kind: :union, types: set_of_raw_member_defs}
%{types: raw_member_defs_set} = type_definition_map
# Recursively get/intern each member type to get its interned definition.
# Thread nodes_map through these calls.
{interned_member_defs_list, nodes_map_after_members_intern} =
Enum.map_reduce(
MapSet.to_list(raw_member_defs_set),
nodes_map,
fn raw_member_def_or_key, acc_nodes_map ->
if is_map(raw_member_def_or_key) do
# It's a map (raw definition or already interned definition).
# get_or_intern_type will handle it and return its key.
{member_key, updated_nodes_map} =
get_or_intern_type(raw_member_def_or_key, acc_nodes_map)
# Fetch the interned definition using the key for the canonical set.
interned_member_def = Map.get(updated_nodes_map, member_key)
{interned_member_def, updated_nodes_map}
else
# It's an atom (a key). Fetch its definition.
interned_member_def = Map.get(acc_nodes_map, raw_member_def_or_key)
if is_nil(interned_member_def) do
# This should not happen if keys are always valid.
raise "Interner: Union member key #{inspect(raw_member_def_or_key)} not found in nodes_map."
end
{interned_member_def, acc_nodes_map}
end
end
)
interned_member_defs_set = MapSet.new(interned_member_defs_list)
# Canonical form for searching/storing uses the set of interned member definitions.
canonical_union_struct = %{
type_kind: :union,
types: interned_member_defs_set
}
# Search for an existing identical union type definition.
existing_union_type_match =
Enum.find(nodes_map_after_members_intern, fn {_key, existing_def} ->
Map.delete(existing_def, :id) == canonical_union_struct
end)
cond do
existing_union_type_match ->
{_key, existing_def} = existing_union_type_match
{existing_def.id, nodes_map_after_members_intern}
true ->
new_union_key = :"type_union_#{System.unique_integer([:monotonic, :positive])}"
final_union_def = Map.put(canonical_union_struct, :id, new_union_key)
{new_union_key,
Map.put(nodes_map_after_members_intern, new_union_key, final_union_def)}
end
type_definition_map.type_kind == :map ->
# type_definition_map is %{type_kind: :map, known_elements: KE_raw, index_signature: IS_raw}
%{known_elements: ke_raw, index_signature: is_raw} = type_definition_map
# Intern value types in known_elements
{ke_interned_values, nodes_map_after_ke_values} =
Enum.map_reduce(
ke_raw,
nodes_map,
fn {_literal_key, %{value_type: raw_value_def_or_key, optional: opt}},
acc_nodes_map ->
{value_type_key, updated_nodes_map} =
if is_map(raw_value_def_or_key) do
get_or_intern_type(raw_value_def_or_key, acc_nodes_map)
else
{raw_value_def_or_key, acc_nodes_map}
end
{%{value_type_id: value_type_key, optional: opt}, updated_nodes_map}
end
)
# Reconstruct known_elements with interned value_type_ids
ke_interned =
Enum.zip(Map.keys(ke_raw), ke_interned_values)
|> Map.new(fn {key, val_map} -> {key, val_map} end)
# Intern key_type and value_type in index_signature, if they are not already keys
{is_key_type_id, nodes_map_after_is_key} =
if is_map(is_raw.key_type) do
get_or_intern_type(is_raw.key_type, nodes_map_after_ke_values)
else
{is_raw.key_type, nodes_map_after_ke_values}
end
{is_value_type_id, nodes_map_after_is_value} =
if is_map(is_raw.value_type) do
get_or_intern_type(is_raw.value_type, nodes_map_after_is_key)
else
{is_raw.value_type, nodes_map_after_is_key}
end
is_interned = %{
key_type_id: is_key_type_id,
value_type_id: is_value_type_id
}
canonical_map_struct = %{
type_kind: :map,
known_elements: ke_interned,
index_signature: is_interned
}
# Search for an existing identical map type definition
existing_map_type_match =
Enum.find(nodes_map_after_is_value, fn {_key, existing_def} ->
Map.delete(existing_def, :id) == canonical_map_struct
end)
cond do
existing_map_type_match ->
{_key, existing_def} = existing_map_type_match
{existing_def.id, nodes_map_after_is_value}
true ->
new_map_key = :"type_map_#{System.unique_integer([:monotonic, :positive])}"
final_map_def = Map.put(canonical_map_struct, :id, new_map_key)
{new_map_key, Map.put(nodes_map_after_is_value, new_map_key, final_map_def)}
end
type_definition_map.type_kind == :function ->
# type_definition_map is %{type_kind: :function, arg_types: [RawDef1, RawDef2], return_type: RawReturnDef, type_params: [RawParamDef1]}
%{
arg_types: raw_arg_defs_or_keys,
return_type: raw_return_def_or_key,
type_params: raw_type_param_defs_or_keys
} = type_definition_map
# Intern argument types
{interned_arg_type_keys, nodes_map_after_args} =
Enum.map_reduce(
raw_arg_defs_or_keys,
nodes_map,
fn def_or_key, acc_nodes_map ->
if is_map(def_or_key) do
# It's a raw definition, intern it
get_or_intern_type(def_or_key, acc_nodes_map)
else
# It's already a key
{def_or_key, acc_nodes_map}
end
end
)
# Intern return type
{interned_return_type_key, nodes_map_after_return} =
if is_map(raw_return_def_or_key) do
# It's a raw definition, intern it
get_or_intern_type(raw_return_def_or_key, nodes_map_after_args)
else
# It's already a key
{raw_return_def_or_key, nodes_map_after_args}
end
# Intern type parameters (for polymorphism, initially likely empty)
{interned_type_param_keys, nodes_map_after_params} =
Enum.map_reduce(
raw_type_param_defs_or_keys || [], # Ensure it's a list
nodes_map_after_return,
fn def_or_key, acc_nodes_map ->
if is_map(def_or_key) do
get_or_intern_type(def_or_key, acc_nodes_map)
else
{def_or_key, acc_nodes_map}
end
end
)
canonical_function_struct = %{
type_kind: :function,
arg_types: interned_arg_type_keys,
return_type: interned_return_type_key,
type_params: interned_type_param_keys
}
# Search for an existing identical function type definition
existing_function_type_match =
Enum.find(nodes_map_after_params, fn {_key, existing_def} ->
Map.delete(existing_def, :id) == canonical_function_struct
end)
cond do
existing_function_type_match ->
{_key, existing_def} = existing_function_type_match
{existing_def.id, nodes_map_after_params}
true ->
new_function_key = :"type_function_#{System.unique_integer([:monotonic, :positive])}"
final_function_def = Map.put(canonical_function_struct, :id, new_function_key)
{new_function_key, Map.put(nodes_map_after_params, new_function_key, final_function_def)}
end
true ->
# This is for types not predefined and not list/union/map/function (e.g., literals like 1, "a", or other complex types).
# Search for an existing identical type definition.
# type_def_for_matching was defined at the start of the function: Map.delete(type_definition_map, :id)
existing_match =
Enum.find(nodes_map, fn {_key, existing_def} ->
# Ensure we are only comparing against actual type definitions
Map.has_key?(existing_def, :type_kind) &&
Map.delete(existing_def, :id) == type_def_for_matching
end)
cond do
existing_match ->
# Found an identical type, reuse its key.
{_key, existing_def_found} = existing_match
{existing_def_found.id, nodes_map} # Return existing key and original nodes_map
true ->
# No existing identical type, create a new one.
# Make the key slightly more descriptive by including the type_kind.
kind_prefix = Atom.to_string(type_definition_map.type_kind)
new_key =
String.to_atom(
"type_#{kind_prefix}_#{System.unique_integer([:monotonic, :positive])}"
)
type_definition_with_id = Map.put(type_definition_map, :id, new_key)
{new_key, Map.put(nodes_map, new_key, type_definition_with_id)}
end
end
end
end

View File

@ -0,0 +1,355 @@
defmodule Til.Typer.SubtypeChecker do
@moduledoc """
Handles subtyping checks and related type utility functions.
"""
alias Til.Typer.Types
# alias MapSet, as: Set # MapSet functions are not directly called with `Set.` prefix here
# Helper to get a type definition from a node, with a fallback default.
# `default_type_definition` should be the actual map, not a key.
def get_type_definition_from_node(node, nodes_map, default_type_definition) do
if node && node.type_id && Map.has_key?(nodes_map, node.type_id) do
Map.get(nodes_map, node.type_id)
else
# Used if node is nil, node.type_id is nil, or type_id is not in nodes_map.
default_type_definition
end
end
# Helper to determine if a type definition guarantees a truthy value.
# In Tilly Lisp, nil and false are falsy; everything else is truthy.
def is_statically_truthy?(type_definition) do
case type_definition do
%{type_kind: :literal, value: val} ->
not (val == nil or val == false)
# Future: Other types that are guaranteed non-falsy (e.g., non-nullable string)
_ ->
# Cannot statically determine for :any, :union, etc. by default
false
end
end
# Helper to determine if a type definition guarantees a falsy value.
def is_statically_falsy?(type_definition) do
case type_definition do
%{type_kind: :literal, value: val} ->
val == nil or val == false
# Future: Other types that are guaranteed falsy (e.g. a union of only nil and false)
_ ->
# Cannot statically determine for :any, :union, etc. by default
false
end
end
# Checks if subtype_def is a subtype of supertype_def.
# nodes_map is needed for resolving further type information if types are complex.
def is_subtype?(subtype_def, supertype_def, nodes_map) do
# Fetch primitive types from nodes_map to ensure they include their :id field,
# consistent with other type definitions retrieved from nodes_map.
# Assumes nodes_map is populated with canonical types.
any_type = Map.get(nodes_map, Types.primitive_type_key(:any))
nothing_type = Map.get(nodes_map, Types.primitive_type_key(:nothing))
cond do
# Ensure any_type and nothing_type were found, otherwise subtyping involving them is problematic.
# This check is more of a safeguard; they should always be in a correctly initialized nodes_map.
is_nil(any_type) or is_nil(nothing_type) ->
# Consider this an internal error or handle as 'not subtype' for safety.
# For now, let it proceed; if they are nil, comparisons will likely fail as expected (false).
# However, this could mask initialization issues.
# A more robust approach might be to raise if they are not found.
# For tests and current flow, they are expected to be present.
# Placeholder for potential error logging/raising
nil
is_nil(subtype_def) or is_nil(supertype_def) ->
# Consider nil to be a subtype of nil, but not of others unless specified.
subtype_def == supertype_def
# Rule 1: Identity (covers identical complex types if canonicalized)
subtype_def == supertype_def ->
true
# Rule 2: Anything is a subtype of :any
supertype_def == any_type ->
true
# Rule 3: :nothing is a subtype of everything
subtype_def == nothing_type ->
true
# Rule 4: Literal to Primitive
# e.g., literal 42 is subtype of primitive integer
# e.g., literal 42 is subtype of primitive number
match?(
{%{type_kind: :literal, value: _val}, %{type_kind: :primitive, name: _prim_name}},
{subtype_def, supertype_def}
) ->
# Deconstruct inside the block for clarity if the pattern matches
{%{value: val}, %{name: prim_name}} = {subtype_def, supertype_def}
cond do
prim_name == :integer && is_integer(val) -> true
prim_name == :string && is_binary(val) -> true
prim_name == :atom && is_atom(val) -> true
prim_name == :number && (is_integer(val) || is_float(val)) -> true
# No specific literal to primitive match
true -> false
end
# Rule 5: Primitive to Primitive Subtyping
# e.g., integer is subtype of number
match?(
{%{type_kind: :primitive, name: :integer}, %{type_kind: :primitive, name: :number}},
{subtype_def, supertype_def}
) ->
true
# Rule 6: Handling Union Types
# Case 6.1: Subtype is a Union Type. (A | B) <: C iff A <: C and B <: C
is_map(subtype_def) and subtype_def.type_kind == :union ->
# subtype_def is %{type_kind: :union, types: sub_types_set}
sub_types_set = subtype_def.types
Enum.all?(sub_types_set, fn sub_member_type ->
is_subtype?(sub_member_type, supertype_def, nodes_map)
end)
# Case 6.2: Supertype is a Union Type. A <: (B | C) iff A <: B or A <: C
is_map(supertype_def) and supertype_def.type_kind == :union ->
# supertype_def is %{type_kind: :union, types: super_types_set}
super_types_set = supertype_def.types
Enum.any?(super_types_set, fn super_member_type ->
is_subtype?(subtype_def, super_member_type, nodes_map)
end)
# Rule 7: Tuple Subtyping
# A tuple type T_sub = %{element_types: SubElements} is a subtype of
# T_super = %{element_types: SuperElements} iff they have the same arity
# and each element type in SubElements is a subtype of the corresponding
# element type in SuperElements.
match?(
{%{type_kind: :tuple, element_types: _sub_elements},
%{type_kind: :tuple, element_types: _super_elements}},
{subtype_def, supertype_def}
) ->
sub_elements = subtype_def.element_types
super_elements = supertype_def.element_types
if length(sub_elements) == length(super_elements) do
# Check subtyping for each pair of corresponding elements.
Enum.zip(sub_elements, super_elements)
|> Enum.all?(fn {sub_element_type, super_element_type} ->
is_subtype?(sub_element_type, super_element_type, nodes_map)
end)
else
# Tuples have different arities, so not a subtype.
false
end
# Rule 8: List Subtyping
# L1 = (List E1 Len1) is subtype of L2 = (List E2 Len2) iff
# E1 is subtype of E2 (covariance) AND
# (Len2 is nil (any length) OR Len1 == Len2)
match?(
{%{type_kind: :list, element_type_id: _sub_elem_id, length: _sub_len},
%{type_kind: :list, element_type_id: _super_elem_id, length: _super_len}},
{subtype_def, supertype_def}
) ->
# Deconstruct for clarity
%{element_type_id: sub_elem_id, length: sub_len} = subtype_def
%{element_type_id: super_elem_id, length: super_len} = supertype_def
sub_elem_type_def = Map.get(nodes_map, sub_elem_id)
super_elem_type_def = Map.get(nodes_map, super_elem_id)
# Ensure element type definitions were found (keys were valid)
if sub_elem_type_def && super_elem_type_def do
elements_are_subtypes =
is_subtype?(sub_elem_type_def, super_elem_type_def, nodes_map)
# Supertype list can be any length
# Or lengths must be identical and known
lengths_compatible =
is_nil(super_len) or
(!is_nil(sub_len) and sub_len == super_len)
elements_are_subtypes && lengths_compatible
else
# If element type keys don't resolve, implies an issue. Treat as not subtype.
false
end
# Rule 9: Map Subtyping
match?(
{%{type_kind: :map, known_elements: _sub_ke, index_signature: _sub_is},
%{type_kind: :map, known_elements: _super_ke, index_signature: _super_is}},
{subtype_def, supertype_def}
) ->
sub_ke = subtype_def.known_elements
# %{key_type_id: Ksub, value_type_id: Vsub}
sub_is = subtype_def.index_signature
super_ke = supertype_def.known_elements
# %{key_type_id: Ksuper, value_type_id: Vsuper}
super_is = supertype_def.index_signature
# 1. Known Elements (Required in Supertype)
all_required_super_keys_compatible =
Enum.all?(super_ke, fn {super_key, super_key_details} ->
# super_key_details is %{value_type_id: super_val_id, optional: opt_val}
if super_key_details.optional == false do
case Map.get(sub_ke, super_key) do
nil ->
# Required key in supertype not found in subtype
false
sub_key_details when sub_key_details.optional == false ->
# Subtype's value for this key must be a subtype of supertype's value type
sub_val_type_def = Map.get(nodes_map, sub_key_details.value_type_id)
super_val_type_def = Map.get(nodes_map, super_key_details.value_type_id)
is_subtype?(sub_val_type_def, super_val_type_def, nodes_map)
_ ->
# Key found in subtype but is optional or missing, while supertype requires it non-optional
false
end
else
# This specific super_key is optional, handled by the next block
true
end
end)
# 2. Known Elements (Optional in Supertype)
all_optional_super_keys_compatible =
Enum.all?(super_ke, fn {super_key, super_key_details} ->
if super_key_details.optional == true do
case Map.get(sub_ke, super_key) do
nil ->
# Optional key in supertype not present in subtype is fine
true
# Optionality in subtype doesn't matter here for compatibility
sub_key_details ->
sub_val_type_def = Map.get(nodes_map, sub_key_details.value_type_id)
super_val_type_def = Map.get(nodes_map, super_key_details.value_type_id)
is_subtype?(sub_val_type_def, super_val_type_def, nodes_map)
end
else
# This specific super_key is required, handled by the previous block
true
end
end)
# 3. Index Signature Compatibility
# Ksuper <: Ksub (contravariance for key types)
# Vsub <: Vsuper (covariance for value types)
super_is_key_type_def = Map.get(nodes_map, super_is.key_type_id)
sub_is_key_type_def = Map.get(nodes_map, sub_is.key_type_id)
index_sig_keys_compatible =
is_subtype?(super_is_key_type_def, sub_is_key_type_def, nodes_map)
sub_is_value_type_def = Map.get(nodes_map, sub_is.value_type_id)
super_is_value_type_def = Map.get(nodes_map, super_is.value_type_id)
index_sig_values_compatible =
is_subtype?(sub_is_value_type_def, super_is_value_type_def, nodes_map)
index_signatures_compatible = index_sig_keys_compatible && index_sig_values_compatible
# 4. Width Subtyping: Keys in sub.known_elements not in super.known_elements
# must conform to super.index_signature.
extra_sub_keys_compatible =
Enum.all?(sub_ke, fn {sub_k_literal, %{value_type_id: sub_k_val_id}} ->
if Map.has_key?(super_ke, sub_k_literal) do
# Already checked by rules 1 and 2
true
else
# Key sub_k_literal is in sub_ke but not super_ke.
# Its type must conform to super_is.key_type_id
# Its value's type must conform to super_is.value_type_id
# Create a literal type for sub_k_literal to check against super_is.key_type_id
# This requires interning the literal type of sub_k_literal on the fly.
# For simplicity here, we assume sub_k_literal's type can be directly checked.
# A full implementation would intern {type_kind: :literal, value: sub_k_literal}
# and then use that definition.
# Let's assume for now that if sub_k_literal is e.g. :foo, its type is literal :foo.
# This part is tricky without a helper to get/intern literal type on the fly.
# For now, we'll simplify: if super_is.key_type_id is :any, it's fine.
# A proper check needs to construct the literal type for sub_k_literal.
# For now, let's assume this check is more complex and might need refinement.
# A placeholder for the check:
# literal_sub_k_type_def = create_and_intern_literal_type(sub_k_literal, nodes_map)
# key_conforms = is_subtype?(literal_sub_k_type_def, super_is_key_type_def, nodes_map)
# Simplified: if super_is key type is :any, it allows any extra keys.
# This is not fully correct but a step.
# key_conforms = super_is.key_type_id == Types.any_type_key() # Simplified check - REMOVED as unused
# Value type check
sub_k_val_type_def = Map.get(nodes_map, sub_k_val_id)
value_conforms =
is_subtype?(sub_k_val_type_def, super_is_value_type_def, nodes_map)
# A more accurate key_conforms:
# 1. Create raw literal type for sub_k_literal
# raw_lit_type = %{type_kind: :literal, value: sub_k_literal} # REMOVED as unused
# 2. Intern it (this might modify nodes_map, but is_subtype? shouldn't modify nodes_map)
# This is problematic. is_subtype? should be pure.
# The types passed to is_subtype? should already be fully resolved/interned.
# This implies that the literal keys themselves should perhaps be thought of as types.
# For now, we'll stick to the simplified check or assume this needs a helper
# that doesn't modify nodes_map or that nodes_map is pre-populated with all possible literal types.
# Let's refine the key_conforms check slightly.
# We need to check if the type of the literal key `sub_k_literal` is a subtype of `super_is_key_type_def`.
# We can construct the raw literal type and check it.
# This requires `is_subtype?` to handle raw type definitions for its first arg if not careful.
# However, `is_subtype?` expects canonical defs.
# This is a known challenge. For now, we'll assume a helper or a specific way to handle this.
# Let's assume `super_is_key_type_def` is general enough, e.g. :atom if sub_k_literal is an atom.
# This part needs the most careful thought for full correctness.
# A pragmatic approach: if super_is.key_type is :any, it's true.
# If super_is.key_type is :atom and sub_k_literal is an atom, true. etc.
key_type_of_sub_k_literal =
cond do
is_atom(sub_k_literal) ->
Map.get(nodes_map, Types.primitive_type_key(:atom))
is_binary(sub_k_literal) ->
Map.get(nodes_map, Types.primitive_type_key(:string))
is_integer(sub_k_literal) ->
Map.get(nodes_map, Types.primitive_type_key(:integer))
# Add other literal types if necessary
# Fallback
true ->
Map.get(nodes_map, Types.any_type_key())
end
key_conforms_refined =
is_subtype?(key_type_of_sub_k_literal, super_is_key_type_def, nodes_map)
key_conforms_refined && value_conforms
end
end)
all_required_super_keys_compatible &&
all_optional_super_keys_compatible &&
index_signatures_compatible &&
extra_sub_keys_compatible
# TODO: Add more subtyping rules (e.g., for intersection)
true ->
# Default: not a subtype
false
end
end
end

192
lib/til/typer/types.ex Normal file
View File

@ -0,0 +1,192 @@
defmodule Til.Typer.Types do
@moduledoc """
Defines and provides access to predefined type structures and their canonical keys
for the Tilly type system.
"""
# --- Predefined Type Keys ---
@integer_type_key :til_type_integer
@string_type_key :til_type_string
@atom_type_key :til_type_atom # Added for primitive atom type
@number_type_key :til_type_number
@any_type_key :til_type_any
@nothing_type_key :til_type_nothing
@file_marker_type_key :til_type_file_marker
@nil_literal_type_key :til_type_literal_nil_atom
@true_literal_type_key :til_type_literal_true_atom
@false_literal_type_key :til_type_literal_false_atom
@error_missing_value_type_in_assignment_key :til_type_error_missing_value_type_in_assignment
@error_not_a_function_key :til_type_error_not_a_function
@error_arity_mismatch_key :til_type_error_arity_mismatch
@error_argument_type_mismatch_key :til_type_error_argument_type_mismatch
# @error_invalid_if_condition_type_key :til_type_error_invalid_if_condition_type # Removed as unused
# @type_annotation_mismatch_error_key is removed as this error is now dynamic
@primitive_types %{
integer: %{type_kind: :primitive, name: :integer},
string: %{type_kind: :primitive, name: :string},
atom: %{type_kind: :primitive, name: :atom}, # Added primitive atom
number: %{type_kind: :primitive, name: :number},
any: %{type_kind: :primitive, name: :any},
nothing: %{type_kind: :primitive, name: :nothing}
}
@primitive_type_keys %{
integer: @integer_type_key,
string: @string_type_key,
atom: @atom_type_key, # Added key for primitive atom
number: @number_type_key,
any: @any_type_key,
nothing: @nothing_type_key
}
@literal_types %{
nil_atom: %{type_kind: :literal, value: nil},
true_atom: %{type_kind: :literal, value: true},
false_atom: %{type_kind: :literal, value: false}
}
@literal_type_keys %{
nil_atom: @nil_literal_type_key,
true_atom: @true_literal_type_key,
false_atom: @false_literal_type_key
}
@special_types %{
file_marker: %{type_kind: :special, name: :file_marker}
}
@special_type_keys %{
file_marker: @file_marker_type_key
}
@error_type_definitions %{
missing_value_type_in_assignment: %{
type_kind: :error,
reason: :missing_value_type_in_assignment
},
not_a_function: %{
type_kind: :error,
reason: :not_a_function
# actual_operator_type_id is dynamic
},
arity_mismatch: %{
type_kind: :error,
reason: :arity_mismatch
# expected_arity, actual_arity, function_type_id are dynamic
},
argument_type_mismatch: %{
type_kind: :error,
reason: :argument_type_mismatch
# arg_position, expected_arg_type_id, actual_arg_type_id, function_type_id are dynamic
}
# :invalid_if_condition_type is dynamically generated with actual_condition_type_id
# No static definition here, but we need a key for the reason atom.
# The dynamic error interner will use the reason atom.
}
@error_type_keys %{
missing_value_type_in_assignment: @error_missing_value_type_in_assignment_key,
not_a_function: @error_not_a_function_key,
arity_mismatch: @error_arity_mismatch_key,
argument_type_mismatch: @error_argument_type_mismatch_key
# :type_annotation_mismatch key is removed
# :invalid_if_condition_type does not have a static key for a full definition,
# but the reason :invalid_if_condition_type is used.
}
def primitive_types, do: @primitive_types
def primitive_type_key(name), do: @primitive_type_keys[name]
def primitive_type_keys_map, do: @primitive_type_keys
def literal_types, do: @literal_types
def literal_type_key(name), do: @literal_type_keys[name]
def literal_type_keys_map, do: @literal_type_keys
def special_types, do: @special_types
def special_type_key(name), do: @special_type_keys[name]
def special_type_keys_map, do: @special_type_keys
def error_type_definitions, do: @error_type_definitions
def error_type_key(name), do: @error_type_keys[name]
def error_type_keys_map, do: @error_type_keys
# Accessors for specific type definitions
def get_primitive_type(:any), do: @primitive_types[:any]
def get_primitive_type(:integer), do: @primitive_types[:integer]
def get_primitive_type(:string), do: @primitive_types[:string]
def get_primitive_type(:atom), do: @primitive_types[:atom] # Accessor for primitive atom
def get_primitive_type(:number), do: @primitive_types[:number]
def get_primitive_type(:nothing), do: @primitive_types[:nothing]
def get_literal_type(:nil_atom), do: @literal_types[:nil_atom]
def get_literal_type(:true_atom), do: @literal_types[:true_atom]
def get_literal_type(:false_atom), do: @literal_types[:false_atom]
def get_special_type(:file_marker), do: @special_types[:file_marker]
def get_error_type_definition(:missing_value_type_in_assignment),
do: @error_type_definitions[:missing_value_type_in_assignment]
# For type_annotation_mismatch, we now expect actual_type_id and expected_type_id
def get_error_type_definition(:type_annotation_mismatch, actual_type_id, expected_type_id) do
%{
type_kind: :error,
reason: :type_annotation_mismatch,
actual_type_id: actual_type_id,
expected_type_id: expected_type_id
}
end
def get_error_type_definition(:invalid_if_condition_type, actual_condition_type_id) do
%{
type_kind: :error,
reason: :invalid_if_condition_type,
actual_condition_type_id: actual_condition_type_id
}
end
def get_error_type_definition(:not_a_function, actual_operator_type_id) do
%{
type_kind: :error,
reason: :not_a_function,
actual_operator_type_id: actual_operator_type_id
}
end
def get_error_type_definition(
:arity_mismatch,
expected_arity,
actual_arity,
function_type_id
) do
%{
type_kind: :error,
reason: :arity_mismatch,
expected_arity: expected_arity,
actual_arity: actual_arity,
function_type_id: function_type_id
}
end
def get_error_type_definition(
:argument_type_mismatch,
arg_position,
expected_arg_type_id,
actual_arg_type_id,
function_type_id
) do
%{
type_kind: :error,
reason: :argument_type_mismatch,
arg_position: arg_position,
expected_arg_type_id: expected_arg_type_id,
actual_arg_type_id: actual_arg_type_id,
function_type_id: function_type_id
}
end
# Accessors for specific type keys
def any_type_key, do: @any_type_key
def nil_literal_type_key, do: @nil_literal_type_key
end

146
lib/tilly/bdd.ex Normal file
View File

@ -0,0 +1,146 @@
defmodule Tilly.BDD do
@moduledoc """
Manages the BDD store, including hash-consing of BDD nodes.
The BDD store is expected to be part of a `typing_ctx` map under the key `:bdd_store`.
"""
alias Tilly.BDD.Node
@false_node_id 0
@true_node_id 1
@initial_next_node_id 2
@universal_ops_module :universal_ops
@doc """
Initializes the BDD store within the typing context.
Pre-interns canonical `false` and `true` BDD nodes.
"""
def init_bdd_store(typing_ctx) when is_map(typing_ctx) do
false_structure = Node.mk_false()
true_structure = Node.mk_true()
bdd_store = %{
nodes_by_structure: %{
{false_structure, @universal_ops_module} => @false_node_id,
{true_structure, @universal_ops_module} => @true_node_id
},
structures_by_id: %{
@false_node_id => %{structure: false_structure, ops_module: @universal_ops_module},
@true_node_id => %{structure: true_structure, ops_module: @universal_ops_module}
},
next_node_id: @initial_next_node_id,
ops_cache: %{} # Cache for BDD operations {op_key, id1, id2} -> result_id
}
Map.put(typing_ctx, :bdd_store, bdd_store)
end
@doc """
Gets an existing BDD node ID or interns a new one if it's not already in the store.
Returns a tuple `{new_typing_ctx, node_id}`.
The `typing_ctx` is updated if a new node is interned.
"""
def get_or_intern_node(typing_ctx, logical_structure, ops_module_atom) do
bdd_store = Map.get(typing_ctx, :bdd_store)
unless bdd_store do
raise ArgumentError, "BDD store not initialized in typing_ctx. Call init_bdd_store first."
end
key = {logical_structure, ops_module_atom}
case Map.get(bdd_store.nodes_by_structure, key) do
nil ->
# Node not found, intern it
node_id = bdd_store.next_node_id
new_nodes_by_structure = Map.put(bdd_store.nodes_by_structure, key, node_id)
node_data = %{structure: logical_structure, ops_module: ops_module_atom}
new_structures_by_id = Map.put(bdd_store.structures_by_id, node_id, node_data)
new_next_node_id = node_id + 1
new_bdd_store =
%{
bdd_store
| nodes_by_structure: new_nodes_by_structure,
structures_by_id: new_structures_by_id,
next_node_id: new_next_node_id
}
new_typing_ctx = Map.put(typing_ctx, :bdd_store, new_bdd_store)
{new_typing_ctx, node_id}
existing_node_id ->
# Node found
{typing_ctx, existing_node_id}
end
end
@doc """
Retrieves the node's structure and ops_module from the BDD store.
Returns `%{structure: logical_structure_tuple, ops_module: ops_module_atom}` or `nil` if not found.
"""
def get_node_data(typing_ctx, node_id) do
with %{bdd_store: %{structures_by_id: structures_by_id}} <- typing_ctx,
data when not is_nil(data) <- Map.get(structures_by_id, node_id) do
data
else
_ -> nil
end
end
@doc """
Checks if the given node ID corresponds to the canonical `false` BDD node.
"""
def is_false_node?(typing_ctx, node_id) do
# Optimized check for the predefined ID
if node_id == @false_node_id do
true
else
# Fallback for cases where a node might be structurally false but not have the canonical ID.
# This should ideally not happen with proper interning of Node.mk_false() via get_or_intern_node.
case get_node_data(typing_ctx, node_id) do
%{structure: structure, ops_module: @universal_ops_module} ->
structure == Node.mk_false()
_ ->
false
end
end
end
@doc """
Checks if the given node ID corresponds to the canonical `true` BDD node.
"""
def is_true_node?(typing_ctx, node_id) do
# Optimized check for the predefined ID
if node_id == @true_node_id do
true
else
# Fallback for cases where a node might be structurally true but not have the canonical ID.
case get_node_data(typing_ctx, node_id) do
%{structure: structure, ops_module: @universal_ops_module} ->
structure == Node.mk_true()
_ ->
false
end
end
end
@doc """
Returns the canonical ID for the `false` BDD node.
"""
def false_node_id(), do: @false_node_id
@doc """
Returns the canonical ID for the `true` BDD node.
"""
def true_node_id(), do: @true_node_id
@doc """
Returns the atom used as the `ops_module` for universal nodes like `true` and `false`.
"""
def universal_ops_module(), do: @universal_ops_module
end

View File

@ -0,0 +1,89 @@
defmodule Tilly.BDD.AtomBoolOps do
@moduledoc """
BDD operations module for sets of atoms.
Elements are atoms, and leaf values are booleans.
"""
@doc """
Compares two atoms.
Returns `:lt`, `:eq`, or `:gt`.
"""
def compare_elements(elem1, elem2) when is_atom(elem1) and is_atom(elem2) do
cond do
elem1 < elem2 -> :lt
elem1 > elem2 -> :gt
true -> :eq
end
end
@doc """
Checks if two atoms are equal.
"""
def equal_element?(elem1, elem2) when is_atom(elem1) and is_atom(elem2) do
elem1 == elem2
end
@doc """
Hashes an atom.
"""
def hash_element(elem) when is_atom(elem) do
# erlang.phash2 is suitable for term hashing
:erlang.phash2(elem)
end
@doc """
The leaf value representing an empty set of atoms (false).
"""
def empty_leaf(), do: false
@doc """
The leaf value representing the universal set of atoms (true).
This is used if a BDD simplifies to a state where all atoms of this kind are included.
"""
def any_leaf(), do: true
@doc """
Checks if a leaf value represents an empty set.
"""
def is_empty_leaf?(leaf_val) when is_boolean(leaf_val) do
leaf_val == false
end
@doc """
Computes the union of two leaf values.
`typing_ctx` is included for interface consistency, but not used for boolean leaves.
"""
def union_leaves(_typing_ctx, leaf1, leaf2) when is_boolean(leaf1) and is_boolean(leaf2) do
leaf1 or leaf2
end
@doc """
Computes the intersection of two leaf values.
`typing_ctx` is included for interface consistency, but not used for boolean leaves.
"""
def intersection_leaves(_typing_ctx, leaf1, leaf2)
when is_boolean(leaf1) and is_boolean(leaf2) do
leaf1 and leaf2
end
@doc """
Computes the negation of a leaf value.
`typing_ctx` is included for interface consistency, but not used for boolean leaves.
"""
def negation_leaf(_typing_ctx, leaf) when is_boolean(leaf) do
not leaf
end
# def difference_leaves(_typing_ctx, leaf1, leaf2) when is_boolean(leaf1) and is_boolean(leaf2) do
# leaf1 and (not leaf2)
# end
@doc """
Tests a leaf value to determine if it represents an empty, full, or other set.
Returns `:empty`, `:full`, or `:other`.
"""
def test_leaf_value(true), do: :full
def test_leaf_value(false), do: :empty
# Add a clause for other types if atoms could have non-boolean leaf values
# def test_leaf_value(_other), do: :other
end

View File

@ -0,0 +1,87 @@
defmodule Tilly.BDD.IntegerBoolOps do
@moduledoc """
BDD Operations module for BDDs where elements are integers and leaves are booleans.
"""
@doc """
Compares two integer elements.
Returns `:lt`, `:eq`, or `:gt`.
"""
def compare_elements(elem1, elem2) when is_integer(elem1) and is_integer(elem2) do
cond do
elem1 < elem2 -> :lt
elem1 > elem2 -> :gt
true -> :eq
end
end
@doc """
Checks if two integer elements are equal.
"""
def equal_element?(elem1, elem2) when is_integer(elem1) and is_integer(elem2) do
elem1 == elem2
end
@doc """
Hashes an integer element.
"""
def hash_element(elem) when is_integer(elem) do
elem
end
@doc """
Returns the leaf value representing emptiness (false).
"""
def empty_leaf(), do: false
@doc """
Returns the leaf value representing universality (true).
"""
def any_leaf(), do: true
@doc """
Checks if the leaf value represents emptiness.
"""
def is_empty_leaf?(leaf_val) when is_boolean(leaf_val) do
leaf_val == false
end
@doc """
Computes the union of two boolean leaf values.
The `_typing_ctx` is ignored for this simple ops module.
"""
def union_leaves(_typing_ctx, leaf1, leaf2) when is_boolean(leaf1) and is_boolean(leaf2) do
leaf1 or leaf2
end
@doc """
Computes the intersection of two boolean leaf values.
The `_typing_ctx` is ignored for this simple ops module.
"""
def intersection_leaves(_typing_ctx, leaf1, leaf2)
when is_boolean(leaf1) and is_boolean(leaf2) do
leaf1 and leaf2
end
@doc """
Computes the negation of a boolean leaf value.
The `_typing_ctx` is ignored for this simple ops module.
"""
def negation_leaf(_typing_ctx, leaf) when is_boolean(leaf) do
not leaf
end
# def difference_leaves(_typing_ctx, leaf1, leaf2) when is_boolean(leaf1) and is_boolean(leaf2) do
# leaf1 and (not leaf2)
# end
@doc """
Tests a leaf value to determine if it represents an empty, full, or other set.
For boolean leaves with integers, this mirrors AtomBoolOps and StringBoolOps.
Returns `:empty`, `:full`, or `:other`.
"""
def test_leaf_value(true), do: :full
def test_leaf_value(false), do: :empty
# If integer BDDs could have non-boolean leaves that are not empty/full:
# def test_leaf_value(_other_leaf_value), do: :other
end

124
lib/tilly/bdd/node.ex Normal file
View File

@ -0,0 +1,124 @@
defmodule Tilly.BDD.Node do
@moduledoc """
Defines the structure of BDD nodes and provides basic helper functions.
BDD nodes can be one of the following Elixir terms:
- `true`: Represents the universal set BDD.
- `false`: Represents the empty set BDD.
- `{:leaf, leaf_value_id}`: Represents a leaf node.
`leaf_value_id`'s interpretation depends on the specific BDD's `ops_module`.
- `{:split, element_id, positive_child_id, ignore_child_id, negative_child_id}`:
Represents an internal decision node.
`element_id` is the value being split upon.
`positive_child_id`, `ignore_child_id`, `negative_child_id` are IDs of other BDD nodes.
"""
@typedoc "A BDD node representing the universal set."
@type true_node :: true
@typedoc "A BDD node representing the empty set."
@type false_node :: false
@typedoc "A BDD leaf node."
@type leaf_node(leaf_value) :: {:leaf, leaf_value}
@typedoc "A BDD split node."
@type split_node(element, node_id) ::
{:split, element, node_id, node_id, node_id}
@typedoc "Any valid BDD node structure."
@type t(element, leaf_value, node_id) ::
true_node()
| false_node()
| leaf_node(leaf_value)
| split_node(element, node_id)
# --- Smart Constructors (Low-Level) ---
@doc "Creates a true BDD node."
@spec mk_true() :: true_node()
def mk_true, do: true
@doc "Creates a false BDD node."
@spec mk_false() :: false_node()
def mk_false, do: false
@doc "Creates a leaf BDD node."
@spec mk_leaf(leaf_value :: any()) :: leaf_node(any())
def mk_leaf(leaf_value_id), do: {:leaf, leaf_value_id}
@doc "Creates a split BDD node."
@spec mk_split(
element_id :: any(),
positive_child_id :: any(),
ignore_child_id :: any(),
negative_child_id :: any()
) :: split_node(any(), any())
def mk_split(element_id, positive_child_id, ignore_child_id, negative_child_id) do
{:split, element_id, positive_child_id, ignore_child_id, negative_child_id}
end
# --- Predicates ---
@doc "Checks if the node is a true node."
@spec is_true?(node :: t(any(), any(), any())) :: boolean()
def is_true?(true), do: true
def is_true?(_other), do: false
@doc "Checks if the node is a false node."
@spec is_false?(node :: t(any(), any(), any())) :: boolean()
def is_false?(false), do: true
def is_false?(_other), do: false
@doc "Checks if the node is a leaf node."
@spec is_leaf?(node :: t(any(), any(), any())) :: boolean()
def is_leaf?({:leaf, _value}), do: true
def is_leaf?(_other), do: false
@doc "Checks if the node is a split node."
@spec is_split?(node :: t(any(), any(), any())) :: boolean()
def is_split?({:split, _el, _p, _i, _n}), do: true
def is_split?(_other), do: false
# --- Accessors ---
@doc """
Returns the value of a leaf node.
Raises an error if the node is not a leaf node.
"""
@spec value(leaf_node :: leaf_node(any())) :: any()
def value({:leaf, value_id}), do: value_id
def value(other), do: raise(ArgumentError, "Not a leaf node: #{inspect(other)}")
@doc """
Returns the element of a split node.
Raises an error if the node is not a split node.
"""
@spec element(split_node :: split_node(any(), any())) :: any()
def element({:split, element_id, _, _, _}), do: element_id
def element(other), do: raise(ArgumentError, "Not a split node: #{inspect(other)}")
@doc """
Returns the positive child ID of a split node.
Raises an error if the node is not a split node.
"""
@spec positive_child(split_node :: split_node(any(), any())) :: any()
def positive_child({:split, _, p_child_id, _, _}), do: p_child_id
def positive_child(other), do: raise(ArgumentError, "Not a split node: #{inspect(other)}")
@doc """
Returns the ignore child ID of a split node.
Raises an error if the node is not a split node.
"""
@spec ignore_child(split_node :: split_node(any(), any())) :: any()
def ignore_child({:split, _, _, i_child_id, _}), do: i_child_id
def ignore_child(other), do: raise(ArgumentError, "Not a split node: #{inspect(other)}")
@doc """
Returns the negative child ID of a split node.
Raises an error if the node is not a split node.
"""
@spec negative_child(split_node :: split_node(any(), any())) :: any()
def negative_child({:split, _, _, _, n_child_id}), do: n_child_id
def negative_child(other), do: raise(ArgumentError, "Not a split node: #{inspect(other)}")
end

347
lib/tilly/bdd/ops.ex Normal file
View File

@ -0,0 +1,347 @@
defmodule Tilly.BDD.Ops do
@moduledoc """
Generic BDD algorithms and smart constructors.
These functions operate on BDD node IDs and use an `ops_module`
to dispatch to specific element/leaf operations.
"""
alias Tilly.BDD
alias Tilly.BDD.Node
@doc """
Smart constructor for leaf nodes.
Uses the `ops_module` to test if the `leaf_value` corresponds to
an empty or universal set for that module.
Returns `{new_typing_ctx, node_id}`.
"""
def leaf(typing_ctx, leaf_value, ops_module) do
case apply(ops_module, :test_leaf_value, [leaf_value]) do
:empty ->
{typing_ctx, BDD.false_node_id()}
:full ->
{typing_ctx, BDD.true_node_id()}
:other ->
logical_structure = Node.mk_leaf(leaf_value)
BDD.get_or_intern_node(typing_ctx, logical_structure, ops_module)
end
end
@doc """
Smart constructor for split nodes. Applies simplification rules.
Returns `{new_typing_ctx, node_id}`.
"""
def split(typing_ctx, element, p_id, i_id, n_id, ops_module) do
# Apply simplification rules. Order can be important.
cond do
# If ignore and negative children are False, result is positive child.
BDD.is_false_node?(typing_ctx, i_id) and
BDD.is_false_node?(typing_ctx, n_id) ->
{typing_ctx, p_id}
# If ignore child is True, the whole BDD is True.
BDD.is_true_node?(typing_ctx, i_id) ->
{typing_ctx, BDD.true_node_id()}
# If positive and negative children are the same.
p_id == n_id ->
if p_id == i_id do
# All three children are identical.
{typing_ctx, p_id}
else
# Result is p_id (or n_id) unioned with i_id.
# This creates a potential mutual recursion with union_bdds
# which needs to be handled by the apply_op cache.
union_bdds(typing_ctx, p_id, i_id)
end
# TODO: Add more simplification rules from CDuce bdd.ml `split` as needed.
# e.g. if p=T, i=F, n=T -> True
# e.g. if p=F, i=F, n=T -> not(x) relative to this BDD's element universe (complex)
true ->
# No further simplification rule applied, intern the node.
logical_structure = Node.mk_split(element, p_id, i_id, n_id)
BDD.get_or_intern_node(typing_ctx, logical_structure, ops_module)
end
end
@doc """
Computes the union of two BDDs.
Returns `{new_typing_ctx, result_node_id}`.
"""
def union_bdds(typing_ctx, bdd1_id, bdd2_id) do
apply_op(typing_ctx, :union, bdd1_id, bdd2_id)
end
@doc """
Computes the intersection of two BDDs.
Returns `{new_typing_ctx, result_node_id}`.
"""
def intersection_bdds(typing_ctx, bdd1_id, bdd2_id) do
apply_op(typing_ctx, :intersection, bdd1_id, bdd2_id)
end
@doc """
Computes the negation of a BDD.
Returns `{new_typing_ctx, result_node_id}`.
"""
def negation_bdd(typing_ctx, bdd_id) do
# The second argument to apply_op is nil for unary operations like negation.
apply_op(typing_ctx, :negation, bdd_id, nil)
end
@doc """
Computes the difference of two BDDs (bdd1 - bdd2).
Returns `{new_typing_ctx, result_node_id}`.
Implemented as `bdd1 INTERSECTION (NEGATION bdd2)`.
"""
def difference_bdd(typing_ctx, bdd1_id, bdd2_id) do
{ctx, neg_bdd2_id} = negation_bdd(typing_ctx, bdd2_id)
intersection_bdds(ctx, bdd1_id, neg_bdd2_id)
end
# Internal function to handle actual BDD operations, bypassing cache for direct calls.
defp do_union_bdds(typing_ctx, bdd1_id, bdd2_id) do
# Ensure canonical order for commutative operations if not handled by apply_op key
# For simplicity, apply_op will handle canonical key generation.
# 1. Handle terminal cases
cond do
bdd1_id == bdd2_id -> {typing_ctx, bdd1_id}
BDD.is_true_node?(typing_ctx, bdd1_id) -> {typing_ctx, BDD.true_node_id()}
BDD.is_true_node?(typing_ctx, bdd2_id) -> {typing_ctx, BDD.true_node_id()}
BDD.is_false_node?(typing_ctx, bdd1_id) -> {typing_ctx, bdd2_id}
BDD.is_false_node?(typing_ctx, bdd2_id) -> {typing_ctx, bdd1_id}
true -> perform_union(typing_ctx, bdd1_id, bdd2_id)
end
end
defp perform_union(typing_ctx, bdd1_id, bdd2_id) do
%{structure: s1, ops_module: ops_m1} = BDD.get_node_data(typing_ctx, bdd1_id)
%{structure: s2, ops_module: ops_m2} = BDD.get_node_data(typing_ctx, bdd2_id)
# For now, assume ops_modules must match for simplicity.
# Production systems might need more complex logic or type errors here.
if ops_m1 != ops_m2 do
raise ArgumentError,
"Cannot union BDDs with different ops_modules: #{inspect(ops_m1)} and #{inspect(ops_m2)}"
end
ops_m = ops_m1
case {s1, s2} do
# Both are leaves
{{:leaf, v1}, {:leaf, v2}} ->
new_leaf_val = apply(ops_m, :union_leaves, [typing_ctx, v1, v2])
leaf(typing_ctx, new_leaf_val, ops_m)
# s1 is split, s2 is leaf
{{:split, x1, p1_id, i1_id, n1_id}, {:leaf, _v2}} ->
# CDuce: split x1 p1 (i1 ++ b) n1
{ctx, new_i1_id} = union_bdds(typing_ctx, i1_id, bdd2_id)
split(ctx, x1, p1_id, new_i1_id, n1_id, ops_m)
# s1 is leaf, s2 is split
{{:leaf, _v1}, {:split, x2, p2_id, i2_id, n2_id}} ->
# CDuce: split x2 p2 (i2 ++ a) n2 (symmetric to above)
{ctx, new_i2_id} = union_bdds(typing_ctx, i2_id, bdd1_id)
split(ctx, x2, p2_id, new_i2_id, n2_id, ops_m)
# Both are splits
{{:split, x1, p1_id, i1_id, n1_id}, {:split, x2, p2_id, i2_id, n2_id}} ->
# Compare elements using the ops_module
comp_result = apply(ops_m, :compare_elements, [x1, x2])
cond do
comp_result == :eq ->
# Elements are equal, merge children
{ctx0, new_p_id} = union_bdds(typing_ctx, p1_id, p2_id)
{ctx1, new_i_id} = union_bdds(ctx0, i1_id, i2_id)
{ctx2, new_n_id} = union_bdds(ctx1, n1_id, n2_id)
split(ctx2, x1, new_p_id, new_i_id, new_n_id, ops_m)
comp_result == :lt ->
# x1 < x2
# CDuce: split x1 p1 (i1 ++ b) n1
{ctx, new_i1_id} = union_bdds(typing_ctx, i1_id, bdd2_id)
split(ctx, x1, p1_id, new_i1_id, n1_id, ops_m)
comp_result == :gt ->
# x1 > x2
# CDuce: split x2 p2 (i2 ++ a) n2
{ctx, new_i2_id} = union_bdds(typing_ctx, i2_id, bdd1_id)
split(ctx, x2, p2_id, new_i2_id, n2_id, ops_m)
end
end
end
defp do_intersection_bdds(typing_ctx, bdd1_id, bdd2_id) do
# Canonical order handled by apply_op key generation.
# Fast path for disjoint singleton BDDs
case {BDD.get_node_data(typing_ctx, bdd1_id), BDD.get_node_data(typing_ctx, bdd2_id)} do
{%{structure: {:split, x1, t, f, f}, ops_module: m},
%{structure: {:split, x2, t, f, f}, ops_module: m}}
when x1 != x2 ->
{typing_ctx, BDD.false_node_id()}
_ ->
# 1. Handle terminal cases
cond do
bdd1_id == bdd2_id -> {typing_ctx, bdd1_id}
BDD.is_false_node?(typing_ctx, bdd1_id) -> {typing_ctx, BDD.false_node_id()}
BDD.is_false_node?(typing_ctx, bdd2_id) -> {typing_ctx, BDD.false_node_id()}
BDD.is_true_node?(typing_ctx, bdd1_id) -> {typing_ctx, bdd2_id}
BDD.is_true_node?(typing_ctx, bdd2_id) -> {typing_ctx, bdd1_id}
true -> perform_intersection(typing_ctx, bdd1_id, bdd2_id)
end
end
end
defp perform_intersection(typing_ctx, bdd1_id, bdd2_id) do
%{structure: s1, ops_module: ops_m1} = BDD.get_node_data(typing_ctx, bdd1_id)
%{structure: s2, ops_module: ops_m2} = BDD.get_node_data(typing_ctx, bdd2_id)
if ops_m1 != ops_m2 do
raise ArgumentError,
"Cannot intersect BDDs with different ops_modules: #{inspect(ops_m1)} and #{inspect(ops_m2)}"
end
ops_m = ops_m1
case {s1, s2} do
# Both are leaves
{{:leaf, v1}, {:leaf, v2}} ->
new_leaf_val = apply(ops_m, :intersection_leaves, [typing_ctx, v1, v2])
leaf(typing_ctx, new_leaf_val, ops_m)
# s1 is split, s2 is leaf
{{:split, x1, p1_id, i1_id, n1_id}, {:leaf, _v2}} ->
{ctx0, new_p1_id} = intersection_bdds(typing_ctx, p1_id, bdd2_id)
{ctx1, new_i1_id} = intersection_bdds(ctx0, i1_id, bdd2_id)
{ctx2, new_n1_id} = intersection_bdds(ctx1, n1_id, bdd2_id)
split(ctx2, x1, new_p1_id, new_i1_id, new_n1_id, ops_m)
# s1 is leaf, s2 is split
{{:leaf, _v1}, {:split, x2, p2_id, i2_id, n2_id}} ->
{ctx0, new_p2_id} = intersection_bdds(typing_ctx, bdd1_id, p2_id)
{ctx1, new_i2_id} = intersection_bdds(ctx0, bdd1_id, i2_id)
{ctx2, new_n2_id} = intersection_bdds(ctx1, bdd1_id, n2_id)
split(ctx2, x2, new_p2_id, new_i2_id, new_n2_id, ops_m)
# Both are splits
{{:split, x1, p1_id, i1_id, n1_id}, {:split, x2, p2_id, i2_id, n2_id}} ->
comp_result = apply(ops_m, :compare_elements, [x1, x2])
cond do
comp_result == :eq ->
# CDuce: split x1 ((p1**(p2++i2))++(p2**i1)) (i1**i2) ((n1**(n2++i2))++(n2**i1))
{ctx0, p2_u_i2} = union_bdds(typing_ctx, p2_id, i2_id)
{ctx1, n2_u_i2} = union_bdds(ctx0, n2_id, i2_id)
{ctx2, p1_i_p2ui2} = intersection_bdds(ctx1, p1_id, p2_u_i2)
{ctx3, p2_i_i1} = intersection_bdds(ctx2, p2_id, i1_id)
{ctx4, new_p_id} = union_bdds(ctx3, p1_i_p2ui2, p2_i_i1)
{ctx5, new_i_id} = intersection_bdds(ctx4, i1_id, i2_id)
{ctx6, n1_i_n2ui2} = intersection_bdds(ctx5, n1_id, n2_u_i2)
{ctx7, n2_i_i1} = intersection_bdds(ctx6, n2_id, i1_id)
{ctx8, new_n_id} = union_bdds(ctx7, n1_i_n2ui2, n2_i_i1)
split(ctx8, x1, new_p_id, new_i_id, new_n_id, ops_m)
# x1 < x2
comp_result == :lt ->
# CDuce: split x1 (p1 ** b) (i1 ** b) (n1 ** b) where b is bdd2
{ctx0, new_p1_id} = intersection_bdds(typing_ctx, p1_id, bdd2_id)
{ctx1, new_i1_id} = intersection_bdds(ctx0, i1_id, bdd2_id)
{ctx2, new_n1_id} = intersection_bdds(ctx1, n1_id, bdd2_id)
split(ctx2, x1, new_p1_id, new_i1_id, new_n1_id, ops_m)
# x1 > x2
comp_result == :gt ->
# CDuce: split x2 (a ** p2) (a ** i2) (a ** n2) where a is bdd1
{ctx0, new_p2_id} = intersection_bdds(typing_ctx, bdd1_id, p2_id)
{ctx1, new_i2_id} = intersection_bdds(ctx0, bdd1_id, i2_id)
{ctx2, new_n2_id} = intersection_bdds(ctx1, bdd1_id, n2_id)
split(ctx2, x2, new_p2_id, new_i2_id, new_n2_id, ops_m)
end
end
end
defp do_negation_bdd(typing_ctx, bdd_id) do
# 1. Handle terminal cases
cond do
BDD.is_true_node?(typing_ctx, bdd_id) -> {typing_ctx, BDD.false_node_id()}
BDD.is_false_node?(typing_ctx, bdd_id) -> {typing_ctx, BDD.true_node_id()}
true -> perform_negation(typing_ctx, bdd_id)
end
end
defp perform_negation(typing_ctx, bdd_id) do
%{structure: s, ops_module: ops_m} = BDD.get_node_data(typing_ctx, bdd_id)
case s do
# Leaf
{:leaf, v} ->
neg_leaf_val = apply(ops_m, :negation_leaf, [typing_ctx, v])
leaf(typing_ctx, neg_leaf_val, ops_m)
# Split
{:split, x, p_id, i_id, n_id} ->
# CDuce: ~~i ** split x (~~p) (~~(p++n)) (~~n)
{ctx0, neg_i_id} = negation_bdd(typing_ctx, i_id)
{ctx1, neg_p_id} = negation_bdd(ctx0, p_id)
{ctx2, p_u_n_id} = union_bdds(ctx1, p_id, n_id)
{ctx3, neg_p_u_n_id} = negation_bdd(ctx2, p_u_n_id)
{ctx4, neg_n_id} = negation_bdd(ctx3, n_id)
{ctx5, split_part_id} = split(ctx4, x, neg_p_id, neg_p_u_n_id, neg_n_id, ops_m)
intersection_bdds(ctx5, neg_i_id, split_part_id)
end
end
# --- Caching Wrapper for BDD Operations ---
defp apply_op(typing_ctx, op_key, bdd1_id, bdd2_id) do
cache_key = make_cache_key(op_key, bdd1_id, bdd2_id)
bdd_store = Map.get(typing_ctx, :bdd_store)
case Map.get(bdd_store.ops_cache, cache_key) do
nil ->
# Not in cache, compute it
{new_typing_ctx, result_id} =
case op_key do
:union -> do_union_bdds(typing_ctx, bdd1_id, bdd2_id)
:intersection -> do_intersection_bdds(typing_ctx, bdd1_id, bdd2_id)
# bdd2_id is nil here
:negation -> do_negation_bdd(typing_ctx, bdd1_id)
_ -> raise "Unsupported op_key: #{op_key}"
end
# Store in cache
# IMPORTANT: Use new_typing_ctx (from the operation) to get the potentially updated bdd_store
current_bdd_store_after_op = Map.get(new_typing_ctx, :bdd_store)
new_ops_cache = Map.put(current_bdd_store_after_op.ops_cache, cache_key, result_id)
final_bdd_store_with_cache = %{current_bdd_store_after_op | ops_cache: new_ops_cache}
# And put this updated bdd_store back into new_typing_ctx
final_typing_ctx_with_cache =
Map.put(new_typing_ctx, :bdd_store, final_bdd_store_with_cache)
{final_typing_ctx_with_cache, result_id}
cached_result_id ->
{typing_ctx, cached_result_id}
end
end
defp make_cache_key(:negation, bdd_id, nil), do: {:negation, bdd_id}
defp make_cache_key(op_key, id1, id2) when op_key in [:union, :intersection] do
# Canonical order for commutative binary operations
if id1 <= id2, do: {op_key, id1, id2}, else: {op_key, id2, id1}
end
defp make_cache_key(op_key, id1, id2), do: {op_key, id1, id2}
end

View File

@ -0,0 +1,87 @@
defmodule Tilly.BDD.StringBoolOps do
@moduledoc """
BDD operations module for sets of strings.
Elements are strings, and leaf values are booleans.
"""
@doc """
Compares two strings.
Returns `:lt`, `:eq`, or `:gt`.
"""
def compare_elements(elem1, elem2) when is_binary(elem1) and is_binary(elem2) do
cond do
elem1 < elem2 -> :lt
elem1 > elem2 -> :gt
true -> :eq
end
end
@doc """
Checks if two strings are equal.
"""
def equal_element?(elem1, elem2) when is_binary(elem1) and is_binary(elem2) do
elem1 == elem2
end
@doc """
Hashes a string.
"""
def hash_element(elem) when is_binary(elem) do
# erlang.phash2 is suitable for term hashing
:erlang.phash2(elem)
end
@doc """
The leaf value representing an empty set of strings (false).
"""
def empty_leaf(), do: false
@doc """
The leaf value representing the universal set of strings (true).
"""
def any_leaf(), do: true
@doc """
Checks if a leaf value represents an empty set.
"""
def is_empty_leaf?(leaf_val) when is_boolean(leaf_val) do
leaf_val == false
end
@doc """
Computes the union of two leaf values.
`typing_ctx` is included for interface consistency, but not used for boolean leaves.
"""
def union_leaves(_typing_ctx, leaf1, leaf2) when is_boolean(leaf1) and is_boolean(leaf2) do
leaf1 or leaf2
end
@doc """
Computes the intersection of two leaf values.
`typing_ctx` is included for interface consistency, but not used for boolean leaves.
"""
def intersection_leaves(_typing_ctx, leaf1, leaf2)
when is_boolean(leaf1) and is_boolean(leaf2) do
leaf1 and leaf2
end
@doc """
Computes the negation of a leaf value.
`typing_ctx` is included for interface consistency, but not used for boolean leaves.
"""
def negation_leaf(_typing_ctx, leaf) when is_boolean(leaf) do
not leaf
end
# def difference_leaves(_typing_ctx, leaf1, leaf2) when is_boolean(leaf1) and is_boolean(leaf2) do
# leaf1 and (not leaf2)
# end
@doc """
Tests a leaf value to determine if it represents an empty, full, or other set.
Returns `:empty`, `:full`, or `:other`.
"""
def test_leaf_value(true), do: :full
def test_leaf_value(false), do: :empty
# def test_leaf_value(_other), do: :other
end

57
lib/tilly/type.ex Normal file
View File

@ -0,0 +1,57 @@
defmodule Tilly.Type do
@moduledoc """
Defines the structure of a Type Descriptor (`Descr`) and provides
helper functions for creating fundamental type descriptors.
A Type Descriptor is a map representing a type. Each field in the map
corresponds to a basic kind of type component (e.g., atoms, integers, pairs)
and holds a BDD node ID. These BDDs represent the set of values
allowed for that particular component of the type.
"""
alias Tilly.BDD
@doc """
Returns a `Descr` map representing the empty type (Nothing).
All BDD IDs in this `Descr` point to the canonical `false` BDD node.
The `typing_ctx` is passed for consistency but not modified by this function.
"""
def empty_descr(_typing_ctx) do
false_id = BDD.false_node_id()
%{
atoms_bdd_id: false_id,
integers_bdd_id: false_id,
strings_bdd_id: false_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
# Add other kinds as needed, e.g., for abstract types
}
end
@doc """
Returns a `Descr` map representing the universal type (Any).
All BDD IDs in this `Descr` point to the canonical `true` BDD node.
The `typing_ctx` is passed for consistency but not modified by this function.
"""
def any_descr(_typing_ctx) do
true_id = BDD.true_node_id()
%{
atoms_bdd_id: true_id,
integers_bdd_id: true_id,
strings_bdd_id: true_id,
pairs_bdd_id: true_id,
records_bdd_id: true_id,
functions_bdd_id: true_id,
# For 'Any', absence is typically not included unless explicitly modeled.
# If 'Any' should include the possibility of absence, this would be true_id.
# For now, let's assume 'Any' means any *value*, so absence is false.
# This can be refined based on the desired semantics of 'Any'.
# CDuce 'Any' does not include 'Absent'.
absent_marker_bdd_id: BDD.false_node_id()
}
end
end

305
lib/tilly/type/ops.ex Normal file
View File

@ -0,0 +1,305 @@
defmodule Tilly.Type.Ops do
@moduledoc """
Implements set-theoretic operations on Type Descriptors (`Descr` maps)
and provides helper functions for constructing specific types.
Operations work with interned `Descr` IDs.
"""
alias Tilly.BDD
alias Tilly.Type
alias Tilly.Type.Store
# Defines the fields in a Descr map that hold BDD IDs.
# Order can be relevant if specific iteration order is ever needed, but for field-wise ops it's not.
defp descr_fields do
[
:atoms_bdd_id,
:integers_bdd_id,
:strings_bdd_id,
:pairs_bdd_id,
:records_bdd_id,
:functions_bdd_id,
:absent_marker_bdd_id
]
end
# --- Core Set Operations ---
@doc """
Computes the union of two types represented by their `Descr` IDs.
Returns `{new_typing_ctx, result_descr_id}`.
"""
def union_types(typing_ctx, descr1_id, descr2_id) do
apply_type_op(typing_ctx, :union, descr1_id, descr2_id)
end
@doc """
Computes the intersection of two types represented by their `Descr` IDs.
Returns `{new_typing_ctx, result_descr_id}`.
"""
def intersection_types(typing_ctx, descr1_id, descr2_id) do
apply_type_op(typing_ctx, :intersection, descr1_id, descr2_id)
end
@doc """
Computes the negation of a type represented by its `Descr` ID.
Returns `{new_typing_ctx, result_descr_id}`.
"""
def negation_type(typing_ctx, descr_id) do
apply_type_op(typing_ctx, :negation, descr_id, nil)
end
defp do_union_types(typing_ctx, descr1_id, descr2_id) do
descr1 = Store.get_descr_by_id(typing_ctx, descr1_id)
descr2 = Store.get_descr_by_id(typing_ctx, descr2_id)
{final_ctx, result_fields_map} =
Enum.reduce(descr_fields(), {typing_ctx, %{}}, fn field, {current_ctx, acc_fields} ->
bdd1_id = Map.get(descr1, field)
bdd2_id = Map.get(descr2, field)
{new_ctx, result_bdd_id} = BDD.Ops.union_bdds(current_ctx, bdd1_id, bdd2_id)
{new_ctx, Map.put(acc_fields, field, result_bdd_id)}
end)
Store.get_or_intern_descr(final_ctx, result_fields_map)
end
defp do_intersection_types(typing_ctx, descr1_id, descr2_id) do
descr1 = Store.get_descr_by_id(typing_ctx, descr1_id)
descr2 = Store.get_descr_by_id(typing_ctx, descr2_id)
{final_ctx, result_fields_map} =
Enum.reduce(descr_fields(), {typing_ctx, %{}}, fn field, {current_ctx, acc_fields} ->
bdd1_id = Map.get(descr1, field)
bdd2_id = Map.get(descr2, field)
{new_ctx, result_bdd_id} = BDD.Ops.intersection_bdds(current_ctx, bdd1_id, bdd2_id)
{new_ctx, Map.put(acc_fields, field, result_bdd_id)}
end)
Store.get_or_intern_descr(final_ctx, result_fields_map)
end
defp do_negation_type(typing_ctx, descr_id) do
descr = Store.get_descr_by_id(typing_ctx, descr_id)
{final_ctx, result_fields_map} =
Enum.reduce(descr_fields(), {typing_ctx, %{}}, fn field, {current_ctx, acc_fields} ->
bdd_id = Map.get(descr, field)
{ctx_after_neg, result_bdd_id} =
if field == :absent_marker_bdd_id do
{current_ctx, BDD.false_node_id()}
else
BDD.Ops.negation_bdd(current_ctx, bdd_id)
end
{ctx_after_neg, Map.put(acc_fields, field, result_bdd_id)}
end)
# Re-evaluate context threading if BDD ops significantly alter it beyond caching during reduce.
# The primary context update happens with Store.get_or_intern_descr.
# The reduce passes current_ctx, which accumulates cache updates from BDD ops.
Store.get_or_intern_descr(final_ctx, result_fields_map)
end
# --- Caching Wrapper for Type Operations ---
defp apply_type_op(typing_ctx, op_key, descr1_id, descr2_id) do
cache_key = make_type_op_cache_key(op_key, descr1_id, descr2_id)
type_store = Map.get(typing_ctx, :type_store)
case Map.get(type_store.ops_cache, cache_key) do
nil ->
# Not in cache, compute it
{new_typing_ctx, result_id} =
case op_key do
:union -> do_union_types(typing_ctx, descr1_id, descr2_id)
:intersection -> do_intersection_types(typing_ctx, descr1_id, descr2_id)
:negation -> do_negation_type(typing_ctx, descr1_id) # descr2_id is nil here
_ -> raise "Unsupported type op_key: #{op_key}"
end
# Store in cache (important: use new_typing_ctx to get potentially updated type_store)
current_type_store_after_op = Map.get(new_typing_ctx, :type_store)
new_ops_cache = Map.put(current_type_store_after_op.ops_cache, cache_key, result_id)
final_type_store_with_cache = %{current_type_store_after_op | ops_cache: new_ops_cache}
# And put this updated type_store back into new_typing_ctx
final_typing_ctx_with_cache = Map.put(new_typing_ctx, :type_store, final_type_store_with_cache)
{final_typing_ctx_with_cache, result_id}
cached_result_id ->
{typing_ctx, cached_result_id}
end
end
defp make_type_op_cache_key(:negation, descr_id, nil), do: {:negation, descr_id}
defp make_type_op_cache_key(op_key, id1, id2) when op_key in [:union, :intersection] do
if id1 <= id2, do: {op_key, id1, id2}, else: {op_key, id2, id1}
end
defp make_type_op_cache_key(op_key, id1, id2), do: {op_key, id1, id2}
# --- Utility Functions ---
@doc """
Checks if a type represented by its `Descr` ID is the empty type (Nothing).
Does not modify `typing_ctx`.
"""
def is_empty_type?(typing_ctx, descr_id) do
descr_map = Store.get_descr_by_id(typing_ctx, descr_id)
Enum.all?(descr_fields(), fn field ->
bdd_id = Map.get(descr_map, field)
BDD.is_false_node?(typing_ctx, bdd_id)
end)
end
# --- Construction Helper Functions ---
@doc """
Gets the `Descr` ID for the canonical 'Nothing' type.
"""
def get_type_nothing(typing_ctx) do
empty_descr_map = Type.empty_descr(typing_ctx)
Store.get_or_intern_descr(typing_ctx, empty_descr_map)
end
@doc """
Gets the `Descr` ID for the canonical 'Any' type.
"""
def get_type_any(typing_ctx) do
any_descr_map = Type.any_descr(typing_ctx)
Store.get_or_intern_descr(typing_ctx, any_descr_map)
end
@doc """
Creates a type `Descr` ID representing a single atom literal.
"""
def create_atom_literal_type(typing_ctx, atom_value) when is_atom(atom_value) do
false_id = BDD.false_node_id()
true_id = BDD.true_node_id()
# Create a BDD for the single atom: Split(atom_value, True, False, False)
# The ops_module Tilly.BDD.AtomBoolOps is crucial here.
{ctx1, atom_bdd_id} =
BDD.Ops.split(typing_ctx, atom_value, true_id, false_id, false_id, Tilly.BDD.AtomBoolOps)
descr_map = %{
atoms_bdd_id: atom_bdd_id,
integers_bdd_id: false_id,
strings_bdd_id: false_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
}
Store.get_or_intern_descr(ctx1, descr_map)
end
@doc """
Creates a type `Descr` ID representing a single integer literal.
"""
def create_integer_literal_type(typing_ctx, integer_value) when is_integer(integer_value) do
false_id = BDD.false_node_id()
true_id = BDD.true_node_id()
{ctx1, integer_bdd_id} =
BDD.Ops.split(typing_ctx, integer_value, true_id, false_id, false_id, Tilly.BDD.IntegerBoolOps)
descr_map = %{
atoms_bdd_id: false_id,
integers_bdd_id: integer_bdd_id,
strings_bdd_id: false_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
}
Store.get_or_intern_descr(ctx1, descr_map)
end
@doc """
Creates a type `Descr` ID representing a single string literal.
"""
def create_string_literal_type(typing_ctx, string_value) when is_binary(string_value) do
false_id = BDD.false_node_id()
true_id = BDD.true_node_id()
{ctx1, string_bdd_id} =
BDD.Ops.split(typing_ctx, string_value, true_id, false_id, false_id, Tilly.BDD.StringBoolOps)
descr_map = %{
atoms_bdd_id: false_id,
integers_bdd_id: false_id,
strings_bdd_id: string_bdd_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
}
Store.get_or_intern_descr(ctx1, descr_map)
end
@doc """
Gets the `Descr` ID for the type representing all atoms.
"""
def get_primitive_type_any_atom(typing_ctx) do
false_id = BDD.false_node_id()
true_id = BDD.true_node_id() # This BDD must be interned with :atom_bool_ops if it's not universal
# For a BDD representing "all atoms", its structure is simply True,
# but it must be associated with :atom_bool_ops.
# BDD.true_node_id() is universal. If we need a specific "true for atoms",
# we'd intern it: BDD.get_or_intern_node(ctx, Node.mk_true(), :atom_bool_ops)
# However, BDD.Ops functions fetch ops_module from operands.
# Universal true/false should work correctly.
descr_map = %{
atoms_bdd_id: true_id,
integers_bdd_id: false_id,
strings_bdd_id: false_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
}
Store.get_or_intern_descr(typing_ctx, descr_map)
end
@doc """
Gets the `Descr` ID for the type representing all integers.
"""
def get_primitive_type_any_integer(typing_ctx) do
false_id = BDD.false_node_id()
true_id = BDD.true_node_id()
descr_map = %{
atoms_bdd_id: false_id,
integers_bdd_id: true_id,
strings_bdd_id: false_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
}
Store.get_or_intern_descr(typing_ctx, descr_map)
end
@doc """
Gets the `Descr` ID for the type representing all strings.
"""
def get_primitive_type_any_string(typing_ctx) do
false_id = BDD.false_node_id()
true_id = BDD.true_node_id()
descr_map = %{
atoms_bdd_id: false_id,
integers_bdd_id: false_id,
strings_bdd_id: true_id,
pairs_bdd_id: false_id,
records_bdd_id: false_id,
functions_bdd_id: false_id,
absent_marker_bdd_id: false_id
}
Store.get_or_intern_descr(typing_ctx, descr_map)
end
end

79
lib/tilly/type/store.ex Normal file
View File

@ -0,0 +1,79 @@
defmodule Tilly.Type.Store do
@moduledoc """
Manages the interning (hash-consing) of Type Descriptor maps (`Descr` maps).
Ensures that for any unique `Descr` map, there is one canonical integer ID.
The type store is expected to be part of a `typing_ctx` map under the key `:type_store`.
"""
@initial_next_descr_id 0
@doc """
Initializes the type store within the typing context.
"""
def init_type_store(typing_ctx) when is_map(typing_ctx) do
type_store = %{
descrs_by_structure: %{},
structures_by_id: %{},
next_descr_id: @initial_next_descr_id,
ops_cache: %{} # Cache for type operations {op_key, descr_id1, descr_id2} -> result_descr_id
}
Map.put(typing_ctx, :type_store, type_store)
end
@doc """
Gets an existing Type Descriptor ID or interns a new one if it's not already in the store.
All BDD IDs within the `descr_map` must already be canonical integer IDs.
Returns a tuple `{new_typing_ctx, descr_id}`.
The `typing_ctx` is updated if a new `Descr` is interned.
"""
def get_or_intern_descr(typing_ctx, descr_map) do
type_store = Map.get(typing_ctx, :type_store)
unless type_store do
raise ArgumentError, "Type store not initialized in typing_ctx. Call init_type_store first."
end
# The descr_map itself is the key for interning.
# Assumes BDD IDs within descr_map are already canonical.
case Map.get(type_store.descrs_by_structure, descr_map) do
nil ->
# Descr not found, intern it
descr_id = type_store.next_descr_id
new_descrs_by_structure = Map.put(type_store.descrs_by_structure, descr_map, descr_id)
new_structures_by_id = Map.put(type_store.structures_by_id, descr_id, descr_map)
new_next_descr_id = descr_id + 1
new_type_store =
%{
type_store
| descrs_by_structure: new_descrs_by_structure,
structures_by_id: new_structures_by_id,
next_descr_id: new_next_descr_id
}
new_typing_ctx = Map.put(typing_ctx, :type_store, new_type_store)
{new_typing_ctx, descr_id}
existing_descr_id ->
# Descr found
{typing_ctx, existing_descr_id}
end
end
@doc """
Retrieves the `Descr` map from the type store given its ID.
Returns the `Descr` map or `nil` if not found.
"""
def get_descr_by_id(typing_ctx, descr_id) do
with %{type_store: %{structures_by_id: structures_by_id}} <- typing_ctx,
descr when not is_nil(descr) <- Map.get(structures_by_id, descr_id) do
descr
else
_ -> nil
end
end
end

67
log.pl
View File

@ -1,67 +0,0 @@
:- module(log,
[ log/1,
debug/1,
log/2,
explain_error/2,
why_failed/2,
set_verbosity/1,
if_verbose/1
]).
:- dynamic verbose_level/1.
verbose_level(0). % Default verbosity: 0 (errors only), 1 (log), 2 (debug)
set_verbosity(Level) :-
retractall(verbose_level(_)),
assertz(verbose_level(Level)).
if_verbose(Goal) :-
verbose_level(Level),
Level > 0,
call(Goal).
log(Message) :-
verbose_level(Level),
Level >= 1,
format(user_error, '[LOG] ~w~n', [Message]).
debug(Message) :-
verbose_level(Level),
Level >= 2,
format(user_error, '[DEBUG] ~w~n', [Message]).
log(Phase, Term) :-
verbose_level(Level),
Level >= 1,
format(user_error, '[LOG][~w] ~w~n', [Phase, Term]).
explain_error(ErrorTerm, Explanation) :-
% Basic error explanation, to be expanded
( ErrorTerm = type_mismatch(Expected, Actual, Location) ->
format(string(Explanation), "Type mismatch at ~w: Expected ~w, but got ~w.", [Location, Expected, Actual])
; ErrorTerm = unification_failure(Term1, Term2, Context) ->
format(string(Explanation), "Unification failed between ~w and ~w in context: ~w.", [Term1, Term2, Context])
; ErrorTerm = unbound_variable(Var, Location) ->
format(string(Explanation), "Unbound variable ~w at ~w.", [Var, Location])
; ErrorTerm = unknown_predicate(Pred, Location) ->
format(string(Explanation), "Unknown predicate ~w at ~w.", [Pred, Location])
; format(string(Explanation), "An error occurred: ~w", [ErrorTerm])
),
log(error, Explanation).
why_failed(ErrorTerm, Details) :-
% Provide more context or steps leading to failure
( ErrorTerm = type_mismatch(Expected, Actual, _Location) ->
% Placeholder for more detailed logic, e.g., tracing type inference steps
format(string(Details), "Failure due to ~w not being compatible with ~w.", [Actual, Expected])
; format(string(Details), "Details for ~w not yet implemented.", [ErrorTerm])
),
log(error_details, Details).
% Example usage (can be removed or moved to tests.pl)
% :- set_verbosity(2).
% :- log('Logging system initialized.').
% :- debug('This is a debug message.').
% :- log(parser, 'Parsing phase started.').
% :- explain_error(type_mismatch(number, string, 'line 5'), Explanation), writeln(Explanation).
% :- why_failed(type_mismatch(number, string, 'line 5'), Details), writeln(Details).

31
mix.exs Normal file
View File

@ -0,0 +1,31 @@
defmodule Til.MixProject do
use Mix.Project
def project do
[
app: :pl,
version: "0.1.0",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps()
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
end

1
notes Normal file
View File

@ -0,0 +1 @@
'

148
parser.pl
View File

@ -1,148 +0,0 @@
:- module(parser,
[ parse/2 % parse(-CodeString, -AST)
]).
:- use_module(library(dcg/basics)).
:- use_module(log).
% --- Entry point ---
parse(CodeString, AST) :-
string_codes(CodeString, Codes),
phrase(s_expression(AST), Codes),
log(parser, ast(AST)).
% --- S-expression parsing ---
s_expression(Ast) -->
ws, "(", ws, s_expression_items(Items), ws, ")", ws,
{ build_ast(Items, Ast) }.
s_expression(int(N)) --> integer(N), ws.
s_expression(string_val(S)) --> string_literal(S), ws.
s_expression(bool(true)) --> "true", ws. % Must be before id(Atom)
s_expression(bool(false)) --> "false", ws. % Must be before id(Atom)
s_expression(id(Atom)) --> identifier(Atom), ws.
s_expression(list_nil) --> ws, "(", ws, ")", ws. % Special case for empty list '()'
s_expression_items([Item | Rest]) -->
s_expression(Item), ws,
( s_expression_items(Rest)
| {Rest = []} ).
s_expression_items([]) --> []. % For items inside '(...)'
% --- AST Construction from S-expression items ---
% build_ast(ItemsList, AST)
% ItemsList is a Prolog list of ASTs from inside the parens.
% E.g., for (if c t e), ItemsList = [id(if), AST_c, AST_t, AST_e]
% Keywords
build_ast([id(if), Cond, Then, Else], if(Cond, Then, Else)) :- !.
build_ast([id(let), id(Var), Value, Body], let(Var, Value, Body)) :- !.
% Lambda: (lambda (params...) body) -> (lambda (p1 p2) body)
% ParamsSExpr is the AST for (p1 p2 ...), e.g. generic_list([id(p1), id(p2)])
build_ast([id(lambda), ParamsSExpr, Body], lambda(Params, Body)) :-
extract_lambda_params(ParamsSExpr, Params), !.
% Match: (match Expr ((Pat1 Body1) (Pat2 Body2) ...))
% ClausesSExpr is the AST for ((Pat1 Body1) (Pat2 Body2) ...),
% e.g. generic_list([generic_list([Pat1AST, Body1AST]), ...])
build_ast([id(match), Expr, ClausesSExpr], match(Expr, Clauses)) :-
extract_match_clauses(ClausesSExpr, Clauses), !.
% Data constructors
build_ast([id(tuple) | Elements], tuple(Elements)) :- !.
build_ast([id(list) | Elements], list_val(Elements)) :- !.
% Function application (must be last among id-starting rules for simple names)
build_ast([id(FunctorName) | Args], Application) :-
atom(FunctorName), % Ensure FunctorName is an atom, not a complex term
Application =.. [FunctorName | Args], !.
% Higher-order function application: ((lambda (x) x) 10) or (VarHoldingLambda 10)
% Head of ItemsList is a complex AST (e.g., lambda(...), id(Var))
build_ast([FunctorSExpr | Args], apply(FunctorSExpr, Args)) :- Args \= [], !. % Ensure there are arguments
% Generic list structure if not a keyword or application, e.g. for parameters or clause pairs
% Also handles (X) where X is a complex term, parsing to generic_list([X])
build_ast(Items, generic_list(Items)) :- Items \= [], !.
build_ast([], list_nil) :- !. % Should have been caught by s_expression(list_nil) if it's top-level ()
% --- Helpers for AST construction ---
% extract_lambda_params(SExpr_representing_param_list, PrologListOfParamNames)
% SExpr for (p1 p2 ...): generic_list([id(p1), id(p2), ...])
extract_lambda_params(generic_list(IdASTs), ParamNames) :-
maplist(get_id_name_from_ast, IdASTs, ParamNames), !.
extract_lambda_params(list_nil, []) :- !. % (lambda () body)
extract_lambda_params(id(ParamAST_single_param), [ParamName]) :- % (lambda x body)
get_id_name_from_ast(id(ParamAST_single_param), ParamName), !.
get_id_name_from_ast(id(Name), Name).
% extract_match_clauses(SExpr_representing_list_of_clauses, PrologListOfClauseASTs)
% SExpr for ((p1 b1) (p2 b2)): generic_list([ ClauseSExpr1, ClauseSExpr2, ... ])
% ClauseSExpr1 is generic_list([Pat1AST, Body1AST])
extract_match_clauses(generic_list(ClauseSExprs), ClauseASTs) :-
maplist(parse_one_match_clause, ClauseSExprs, ClauseASTs), !.
extract_match_clauses(list_nil, []) :- !. % (match expr ()) - no clauses
% parse_one_match_clause(SExpr_for_one_clause, clause(PatternAST, true, BodyAST))
% SExpr for (pat body): generic_list([RawPatternAST, BodyAST])
parse_one_match_clause(generic_list([RawPatternAST, BodyAST]), clause(Pattern, true, BodyAST)) :-
ast_to_pattern(RawPatternAST, Pattern).
ast_to_pattern(id(Name), pvar(Name)) :- Name \= '_', !.
ast_to_pattern(id('_'), pwild) :- !.
ast_to_pattern(int(N), pint(N)) :- !.
ast_to_pattern(string_val(S), pstring(S)) :- !.
ast_to_pattern(bool(B), pbool(B)) :- !. % Pattern for true/false
ast_to_pattern(list_nil, plist([])) :- !. % Pattern for ()
ast_to_pattern(tuple(ElementASTs), ptuple(PatternElements)) :- % (tuple p1 p2)
maplist(ast_to_pattern, ElementASTs, PatternElements), !.
ast_to_pattern(list_val(ElementASTs), plist(PatternElements)) :- % (list p1 p2)
maplist(ast_to_pattern, ElementASTs, PatternElements), !.
% For more complex list patterns like (cons head tail) or (list-star p1 p2 ... rest)
% ast_to_pattern(generic_list([id(cons), HAST, TAST]), pcons(HPatt, TPatt)) :- !,
% ast_to_pattern(HAST, HPatt), ast_to_pattern(TAST, TPatt).
% This requires 'cons' to be parsed into generic_list([id(cons),...]) in pattern context.
% --- Low-level parsers (mostly unchanged) ---
identifier(Atom) -->
[C], { code_type(C, alpha) }, % Simplified: starts with alpha
string_without(" ()", Codes), % Simplified: no spaces or parens in identifier
{ atom_codes(Atom, [C|Codes]) }.
string_literal(String) -->
"\"", string_without("\"", Codes), "\"",
{ atom_codes(String, Codes) }.
ws --> white, ws.
ws --> [].
% --- Example Usage (for testing in REPL) ---
% ?- parse("(if (is_number x) x 0)", AST).
% AST = if(is_number(id(x)), id(x), int(0))
%
% ?- parse("(let y 10 y)", AST).
% AST = let(y, int(10), id(y))
%
% ?- parse("(match input ((list x y) x))", AST).
% AST = match(id(input), [clause(plist([pvar(x), pvar(y)]), true, id(x))])
%
% ?- parse("(match data ((tuple a _) a))", AST).
% AST = match(id(data), [clause(ptuple([pvar(a), pwild]), true, id(a))])
%
% ?- parse("(lambda (x y) (add x y))", AST).
% AST = lambda([x,y], add(id(x),id(y)))
%
% ?- parse("((lambda (x) x) 10)", AST).
% AST = apply(lambda([x],id(x)),[int(10)])
%
% ?- parse("(list 1 2 3)", AST).
% AST = list_val([int(1), int(2), int(3)])
%
% ?- parse("(tuple \"a\" true)", AST).
% AST = tuple([string_val("a"), bool(true)])
%
% ?- parse("()", AST).
% AST = list_nil

428
project.md Normal file
View File

@ -0,0 +1,428 @@
# Typed Lisp to Elixir Compiler (codename Tilly)
## Project Goals
To build a Lisp dialect with a strong, expressive type system that compiles to readable Elixir code. The type system will support advanced features like type inference, union, intersection, negation, refinement, and elements of dependent typing. The long-term vision includes support for compiling to other target languages.
## Features
- **Core Language:**
- Lisp syntax and semantics.
- Basic data structures (lists, atoms, numbers, etc.).
- numbers
1 2 3 4
- strings
'' 'string' 'other string'
- lists
[] [1 2 3 4]
- tuples
{} {1 2 3 4}
- maps
m{} m{:a 1 :b 2}
- Functions:
- only fixed arity functions, no variadic arg lists
- structure of the function definition
```
(defn
name
(list of parameters with the return type at the end)
'optional docstring'
...forms for the function body
)
```
- function definition where one parameter doesnt have a type -> its to be inferred from usage inside function_body
`(defn name (arg1 (arg2 type_2) return_type) function_body)`
- function definition where one parameter doesnt have a type -> its to be inferred, but it is pattern-matched in the function head
`(defn name (m{:a a_field} (arg2 type_2) return_type) function_body)`
- function definition where one parameter doesnt have a type -> its to be inferred, but it is pattern-matched and bound to a name
`(defn name ((= arg1 m{:a a_field :b (= b_list [])}) (arg2 type_2) return_type) function_body)`
- function definition where one some parameters are generic | universally quantified
`(defn map_head ( (coll (list ~a)) (mapper (function ~a ~b)) (union ~b nil)) function_body)`
- function definition guards, simillar to elixir (additional `where` s-expr after the return type)
`(defn map_head ( (coll (list ~a)) (mapper (function ~a ~b)) ~b (where (some_guard coll))) function_body)`
- Lambdas:
- lone lambda form
`(fn (elem) (+ elem 1)`
- lambda used as a parameter
`(Enum.map collection (fn (elem) (+ elem 1))`
- **Type System:** (See "Type Representation" under "Key Implementation Areas" for detailed structures)
- **Type Inference:** Automatically deduce types where possible.
- **Union Types:** `A | B` (e.g., `%{type_kind: :union, types: Set.new([type_A, type_B])}`).
- **Intersection Types:** `A & B` (e.g., `%{type_kind: :intersection, types: Set.new([type_A, type_B])}`).
- **Negation Types:** `!A` (e.g., `%{type_kind: :negation, negated_type: type_A}`).
- **Refinement Types:** Types refined by predicates (e.g., `%{type_kind: :refinement, base_type: %{type_kind: :primitive, name: :integer}, var_name: :value, predicate_expr_id: <node_id>}`).
- **Dependent Types (Elements of):** Types that can depend on values.
- **Length-Indexed Data Structures:**
- e.g., A list of 3 integers: `%{type_kind: :list, element_type: %{type_kind: :primitive, name: :integer}, length: 3}`.
- A tuple of specific types: `%{type_kind: :tuple, element_types: [type_A, type_B, type_C]}`.
- Benefits: Enables safer operations like `nth`, `take`, `drop`, and can ensure arity for functions expecting fixed-length lists/tuples.
- **Types Dependent on Literal Values:**
- Function return types or argument types can be specialized based on a *literal value* argument by using `%{type_kind: :literal, value: <actual_value>}` in type rules.
- **Refinement Types (as a key form of dependent type):**
- e.g., `%{type_kind: :refinement, base_type: %{type_kind: :primitive, name: :integer}, var_name: :value, predicate_expr_id: <node_id_of_gt_0_expr>}`.
- Initial implementation would focus on simple, evaluable predicates.
- **Value-Types (Typed Literals):**
- e.g., `:some_atom` has type `%{type_kind: :literal, value: :some_atom}`.
- `42` has type `%{type_kind: :literal, value: 42}`.
- **Heterogeneous List/Tuple Types:**
- Handled by `%{type_kind: :tuple, element_types: [type_A, type_B, ...]}`.
- **Structural Map Types:**
- **Key-Specific Types:** Defined via the `known_elements` field in the map type representation. Example: `%{type_kind: :map, known_elements: %{:name => %{value_type: %{type_kind: :primitive, name: :string}, optional: false}, :age => %{value_type: %{type_kind: :primitive, name: :integer}, optional: false}}, index_signature: nil}`.
- **Optional Keys:** Indicated by `optional: true` for an entry in `known_elements`.
- **Key/Value Constraints (Open Maps):** Defined by the `index_signature` field. Example: `%{type_kind: :map, known_elements: %{}, index_signature: %{key_type: %{type_kind: :primitive, name: :atom}, value_type: %{type_kind: :primitive, name: :any}}}`.
- **Type Transformation/Refinement:** To be handled by type system rules for map operations.
- **Compilation:**
- **Target: Elixir:** Generate readable and idiomatic Elixir code.
- **Future Targets:** Design with extensibility in mind for other languages (e.g., JavaScript).
- **Tooling:**
- Clear error messages from the type checker and compiler.
### Key Implementation Areas
1. **Parser:**
- Implement an S-expression parser for the Lisp dialect.
2. **Type System Core:**
- **Type Representation:** Types are represented as Elixir maps, each with a `:type_kind` atom (in `:snake_case`) and other fields specific to that kind.
- **Primitive Types:**
- `%{type_kind: :primitive, name: <atom>}`
- Examples: `%{type_kind: :primitive, name: :any}`, `%{type_kind: :primitive, name: :nothing}`, `%{type_kind: :primitive, name: :integer}`, `%{type_kind: :primitive, name: :float}`, `%{type_kind: :primitive, name: :number}`, `%{type_kind: :primitive, name: :boolean}`, `%{type_kind: :primitive, name: :string}`, `%{type_kind: :primitive, name: :atom}`.
- **Literal Types (Value Types):**
- `%{type_kind: :literal, value: <any_elixir_literal>}`
- Examples: `%{type_kind: :literal, value: 42}`, `%{type_kind: :literal, value: :my_atom}`, `%{type_kind: :literal, value: "hello"}`.
- strings are started and ended with single quote e.g. 'string'
- **Union Types:**
- `%{type_kind: :union, types: Set.new([<type_map>])}`
- `types`: A set of type maps.
- **Intersection Types:**
- `%{type_kind: :intersection, types: Set.new([<type_map>])}`
- `types`: A set of type maps.
- **Negation Types:**
- `%{type_kind: :negation, negated_type: <type_map>}`
- **Function Types:**
- `%{type_kind: :function, arg_types: [<type_map>], return_type: <type_map>, rest_arg_type: <type_map> | nil}`
- `arg_types`: Ordered list of type maps.
- `rest_arg_type`: Type map for variadic arguments, or `nil`.
- **List Types:**
- `%{type_kind: :list, element_type: <type_map>, length: <non_neg_integer | type_variable_map | nil>}`
- `length`: `nil` for any length, an integer for fixed length, or a type variable map for generic/inferred length.
- **Tuple Types:**
- `%{type_kind: :tuple, element_types: [<type_map>]}`
- `element_types`: Ordered list of type maps.
- **Map Types (Structural):**
Maps in Tilly Lisp are inherently open, meaning they can contain any keys beyond those explicitly known at compile time. The type system aims to provide as much precision as possible for known keys while defining a general pattern for all other keys.
- **Representation:**
- **Raw Form (before interning):**
`%{type_kind: :map, known_elements: KE_raw, index_signature: IS_raw}`
- `known_elements` (KE_raw): An Elixir map where keys are literal Elixir terms (e.g., `:name`, `"id"`) and values are `%{value_type: <type_map_for_value>, optional: <boolean>}`.
- `index_signature` (IS_raw): Always present. A map `%{key_type: <type_map_for_key>, value_type: <type_map_for_value>}` describing the types for keys not in `known_elements`.
- **Interned Form (stored in `nodes_map`):**
`%{type_kind: :map, id: <unique_type_key>, known_elements: KE_interned, index_signature: IS_interned}`
- `id`: A unique atom key identifying this canonical map type definition (e.g., `:type_map_123`).
- `known_elements` (KE_interned): An Elixir map where keys are literal Elixir terms and values are `%{value_type_id: <type_key_for_value>, optional: <boolean>}`.
- `index_signature` (IS_interned): Always present. A map `%{key_type_id: <type_key_for_general_keys>, value_type_id: <type_key_for_general_values>}`.
- **Use Case Scenarios & Typing Approach:**
1. **Map Literals:**
- Example: `m{:a "hello" :b 1}`
- Inferred Type:
- `known_elements`: `%{ :a => %{value_type: <type_for_"hello">, optional: false}, :b => %{value_type: <type_for_1>, optional: false} }`
- `index_signature`: Defaults to `%{key_type: <any_type>, value_type: <any_type>}`. This signifies that any other keys of any type can exist and map to values of any type.
- Keys in map literals must be literal values to contribute to `known_elements`.
2. **Type Annotations:**
- Example: `(the (map string integer) my-var)`
- The type `(map string integer)` resolves to:
- `known_elements`: `{}` (empty, as the annotation describes a general pattern, not specific known keys).
- `index_signature`: `%{key_type: <string_type>, value_type: <integer_type>}`.
3. **Core Map Operations (Language Constructs):**
The type system will define rules for the following fundamental runtime operations:
- `(map-get map key)`:
- If `key` is a literal (e.g., `:a`):
- If `:a` is in `map_type.known_elements`: Result is `map_type.known_elements[:a].value_type_id`. If optional, result is union with `nil_type`.
- If `:a` is not in `known_elements` but matches `map_type.index_signature.key_type_id`: Result is `map_type.index_signature.value_type_id` unioned with `nil_type` (as the specific key might not exist at runtime).
- If `key`'s type is general (e.g., `atom`):
- Collect types from all matching `known_elements` (e.g., if `:a` and `:b` are known atom keys).
- Include `map_type.index_signature.value_type_id` if `atom` is a subtype of `map_type.index_signature.key_type_id`.
- Union all collected types with `nil_type`.
- Example: `(map-get m{:a "s" :b 1} some_atom_var)` could result in type `(union string integer nil)`.
- `(map-put map key value)`:
- If `key` is a literal (e.g., `:a`):
- Resulting map type updates or adds `:a` to `known_elements` with `value`'s type. `index_signature` is generally preserved.
- Example: `(map-put m{:b 1} :a "s")` results in type for `m{:a "s" :b 1}`.
- If `key`'s type is general (e.g., `atom`):
- `known_elements` of the input `map` type remain unchanged.
- The `index_signature` of the resulting map type may become more general. E.g., if `map` is `(map string any)` and we `map-put` with `key_type=atom` and `value_type=integer`, the new `index_signature` might be `%{key_type: (union string atom), value_type: (union any integer)}`. This is complex and requires careful rule definition.
- `(map-delete map key)`:
- If `key` is a literal (e.g., `:a`):
- Resulting map type removes `:a` from `known_elements`. `index_signature` is preserved.
- Example: `(map-delete m{:a "s" :b 1} :a)` results in type for `m{:b 1}`.
- If `key`'s type is general (e.g., `atom`):
- This is complex. Deleting by a general key type doesn't easily translate to a precise change in `known_elements`. The `index_signature` might remain, or the operation might be disallowed or result in a very general map type. (Further thought needed for precise semantics).
- `(map-merge map1 map2)`:
- Resulting map type combines `known_elements` from `map1` and `map2`.
- For keys only in `map1` or `map2`, they are included as is.
- For keys in both: The value type from `map2` takes precedence (last-one-wins semantics for types).
- The `index_signature` of the resulting map type will be the most general combination of `map1.index_signature` and `map2.index_signature`. (e.g., union of key types, union of value types).
- **Known Limitations:**
- Typing `map-put` and `map-delete` with non-literal (general type) keys precisely is challenging and may result in less specific types or require advanced type system features not yet planned (e.g., negation types for keys).
- Duplicate literal keys in a map literal: The parser/typer will likely adopt a "last one wins" semantic for the value and its type.
- **Required Future Steps & Prerequisites:**
1. **Parser Support for `(map K V)` Annotations:**
- Modify `Til.Typer.ExpressionTyper.resolve_type_specifier_node` to parse S-expressions like `(map <key-type-spec> <value-type-spec>)` into a raw map type definition.
- Prerequisite: Basic type specifier resolution (for K and V).
2. **Typing Map Literals:**
- In `Til.Typer.infer_type_for_node_ast` (for `:map_expression`):
- Construct `known_elements` from literal keys and inferred value types.
- Assign a default `index_signature` (e.g., `key_type: any, value_type: any`).
- Prerequisite: Recursive typing of child nodes (values in the map literal).
3. **Interning Map Types:**
- In `Til.Typer.Interner.get_or_intern_type`:
- Add logic for `type_kind: :map`.
- Recursively intern types within `known_elements` values and `index_signature` key/value types to create a canonical, interned map type definition.
- Store/retrieve these canonical map types.
- Prerequisite: Interning for primitive and other relevant types.
4. **Subtyping Rules for Maps:**
- In `Til.Typer.SubtypeChecker.is_subtype?`:
- Implement rules for `map_subtype <?: map_supertype`. This involves checking:
- Compatibility of `known_elements` (required keys in super must be present and non-optional in sub, with compatible value types).
- Compatibility of `index_signatures` (contravariant key types, covariant value types).
- Keys in `sub.known_elements` not in `super.known_elements` must conform to `super.index_signature`.
- Prerequisite: Interned map type representation.
5. **Typing Map Operations:**
- In `Til.Typer.ExpressionTyper` (or a new `MapExpressionTyper` module):
- Define S-expression forms for `(map-get map key)`, `(map-put map key value)`, `(map-delete map key)`, `(map-merge map1 map2)`.
- Implement type inference rules for each of these operations based on the principles outlined in "Use Case Scenarios".
- Prerequisite: Subtyping rules, interned map types, ability to type map literals and resolve map type annotations.
- **Advanced Map Typing and Row Polymorphism Considerations:**
While the current map type system provides flexibility with `known_elements` and an `index_signature`, a future enhancement could be the introduction of **row polymorphism**. This would allow for more precise typing of functions that operate on maps with a common set of known fields while allowing other fields to vary.
- **Conceptualization:** In Tilly's context, a row variable in a map type (e.g., `m{ :name string | r }`) could represent "the rest of the map fields." Given Tilly's rich map keys (literals of various types, not just simple labels) and the existing `index_signature` concept, the row variable `r` could itself be considered a placeholder for another Tilly map type. This means `r` could have its own `known_elements` and `index_signature`, making it more expressive than traditional record-based row polymorphism.
- **Requirements for Implementation:**
- **Type Representation:** Extend the map type definition to include a row variable (e.g., `%{type_kind: :map, known_elements: KE, row_variable_id: <type_key_for_row_var>}`). The interaction between a row variable and the `index_signature` would need careful definition; they might be mutually exclusive or complementary.
- **Unification for Rows:** Develop a unification algorithm capable of solving constraints involving row variables (e.g., `m{a: T1 | r1} = m{a: T1, b: T2 | r2}` implies `r1` must unify with or extend `m{b: T2 | r2}`).
- **Subtyping for Rows:** Define subtyping rules (e.g., `m{a: T1, b: T2}` is a subtype of `m{a: T1 | r}` if `r` can be instantiated with `m{b: T2}`).
- **Generalization & Instantiation:** Implement mechanisms for generalizing functions over row variables and instantiating them at call sites.
- **Syntax:** Design user-facing syntax for map types with row variables (e.g., `(map :key1 type1 ... | row_var_name)`).
Implementing full row polymorphism is a significant undertaking and would build upon the existing map typing foundations. It is currently not in the immediate plan but represents a valuable direction for enhancing the type system's expressiveness for structural data.
- **Refinement Types:**
- `%{type_kind: :refinement, base_type: <type_map>, var_name: <atom>, predicate_expr_id: <integer_node_id>}`
- `var_name`: Atom used to refer to the value within the predicate.
- `predicate_expr_id`: AST node ID of the predicate expression.
- **Type Variables:**
- `%{type_kind: :type_variable, id: <any_unique_id>, name: <String.t | nil>}`
- `id`: Unique identifier.
- `name`: Optional human-readable name.
- **Alias Types (Named Types):**
- `%{type_kind: :alias, name: <atom_alias_name>, parameters: [<atom_param_name>], definition: <type_map>}`
- `name`: The atom for the alias (e.g., `:positive_integer`).
- `parameters`: List of atoms for generic type parameter names (e.g., `[:T]`).
- `definition`: The type map this alias expands to (may contain type variables from `parameters`).
- Function types
- (fn (arg_1_type arg_2_type) return_type)
- **Type Checking Algorithm:** Develop the core logic for verifying type correctness. This will likely involve algorithms for:
- Unification.
- Subtyping (e.g., `%{type_kind: :primitive, name: :integer}` is a subtype of `%{type_kind: :primitive, name: :number}`).
- Constraint solving for inference and refinement types.
- **Type Inference Engine:** Implement the mechanism to infer types of expressions and definitions.
- **Bidirectional Type Inference:**
To enhance type inference capabilities, reduce the need for explicit annotations, and provide more precise error messages, the type system could be evolved to use bidirectional type inference. This approach distinguishes between two modes of operation:
- **Synthesis Mode (`=>`):** Infers or synthesizes the type of an expression from its constituent parts. For example, `infer(e)` yields type `T`.
- **Checking Mode (`<=`):** Checks if an expression conforms to an *expected type* provided by its context. For example, `check(e, T_expected)` verifies `e` has type `T_expected`.
- **Benefits:**
- More precise type error reporting (e.g., "expected type X, got type Y in context Z").
- Reduced annotation burden, as types can flow top-down into expressions.
- Better handling of polymorphic functions and complex type constructs.
- **Implementation Requirements:**
- **Explicit Modes:** The core typing algorithm (currently in `Til.Typer`) would need to be refactored to explicitly support and switch between synthesis and checking modes.
- **Top-Down Type Flow:** The `expected_type` must be propagated downwards during AST traversal in checking mode.
- **Dual Typing Rules:** Each language construct (literals, variables, function calls, conditionals, `lambda`s, etc.) would require distinct typing rules for both synthesis and checking. For instance:
- A `lambda` expression, when checked against an expected function type `(TA -> TR)`, can use `TA` for its parameter types and then check its body against `TR`. In synthesis mode, parameter annotations might be required.
- A function application `(f arg)` would typically synthesize `f`'s type, then check `arg` against the expected parameter type, and the function's return type becomes the synthesized type of the application.
- **Integration with Polymorphism:** Rules for instantiating polymorphic types (when checking) and generalizing types (e.g., for `let`-bound expressions in synthesis) are crucial.
Adopting bidirectional type inference would be a significant architectural evolution of the `Til.Typer` module, moving beyond the current primarily bottom-up synthesis approach.
- **Environment Management:** Handle scopes and bindings of names to types (type maps).
- **Function Types, `defn`, and `fn` Implementation Plan:**
This section outlines the plan for introducing function types, user-defined functions (`defn`), and lambdas (`fn`) into Tilly.
**1. Type Representation for Functions:**
* **Structure:** `%{type_kind: :function, arg_types: [<type_map_key>], return_type: <type_map_key>, type_params: [<type_variable_key>] | nil}`
* `arg_types`: An ordered list of type keys for each argument.
* `return_type`: A type key for the return value.
* `type_params`: (Optional, for polymorphic functions) An ordered list of type keys for universally quantified type variables (e.g., for `~a`, `~b`). Initially `nil` for monomorphic functions.
* **Note:** Variadic functions (`rest_arg_type`) are not planned, aligning with Elixir's fixed arity.
**2. Parser Modifications (`Til.Parser`):**
* **`defn` (User-Defined Function):**
* **Syntax:** `(defn name (arg_spec1 arg_spec2 ... return_type_spec) 'optional_docstring' body_forms...)`
* The `return_type_spec` is the last element in the parameter S-expression.
* `'optional_docstring'` is a string literal between the parameter S-expression and the body.
* **AST Node (`:defn_expression`):**
* `name_node_id`: ID of the function name symbol.
* `params_and_return_s_expr_id`: ID of the S-expression node `(arg_spec1 ... return_type_spec)`.
* `arg_spec_node_ids`: List of IDs of argument specifier nodes (derived from children of `params_and_return_s_expr_id`, excluding the last).
* `return_type_spec_node_id`: ID of the return type specifier node (last child of `params_and_return_s_expr_id`).
* `docstring_node_id`: Optional ID of the docstring node.
* `body_node_ids`: List of IDs for body expressions.
* **`fn` (Lambda):**
* **Syntax:** `(fn (arg_spec1 ...) body_forms...)`
* **AST Node (`:lambda_expression`):**
* `params_s_expr_id`: ID of the S-expression node `(arg_spec1 ...)`.
* `arg_spec_node_ids`: List of IDs of argument specifier nodes.
* `body_node_ids`: List of IDs for body expressions.
* **Argument Specifications (`arg_spec`):**
* Initially, `arg_spec` nodes will represent simple symbols (for lambda arguments) or `(symbol type_spec)` (for `defn` arguments).
* More complex patterns (`m{:key val}`, `(= symbol pattern)`) and type variables (`~a`) will be introduced in later phases.
**3. Phased Implementation Plan:**
* **Phase 1: Core Function Type Representation & Interning. (Completed)**
* Defined the `%{type_kind: :function, ...}` structure.
* Implemented interning logic for this type in `Til.Typer.Interner`.
* **Phase 2: Basic Lambdas (`fn`). (In Progress)**
* Parser: Implement parsing for `(fn (arg_name1 ...) body_forms...)`. Argument specs are simple symbols.
* Typer (`infer_type_for_node_ast`): For `:lambda_expression`, argument types default to `any`. Infer return type from the last body expression. Construct raw function type for interning.
* **Lambda Argument Typing Strategy:** Defaulting to `any` initially. Later, with bidirectional type inference, argument types will be inferred more precisely from usage within the body (potentially using type intersection for multiple constraints) or from the context in which the lambda is used (checking mode).
* **Phase 3: Basic Monomorphic Function Calls.**
* Typer (`ExpressionTyper.infer_s_expression_type`): Handle S-expressions where the operator's type is a function type. Perform arity checks and subtype checks for arguments. The S-expression's type is the function's return type.
* **Phase 4: Monomorphic `defn`.**
* Parser: Implement parsing for `(defn name (arg_spec1 type_spec1 ... return_type_spec) 'optional_docstring' body_forms...)`. Require explicit `(symbol type_spec)` for arguments.
* Typer (`infer_type_for_node_ast` and `Environment`): For `:defn_expression`, resolve explicit types, construct/intern the function type, update environment for recursion, type body in new lexical scope, and validate return type. The `:defn_expression` node's type is its interned function type. `Til.Typer.Environment.update_env_from_node` will add the function to the environment.
* **Phase 5: Introduce Polymorphism (Type Variables, `~a`).**
* Update type representations, parser, interner, and typer for type variables and polymorphic function types. Implement unification for function calls.
* **Phase 6: Advanced Argument/Inference Features.**
* Allow `defn` arguments as `symbol` (type to be inferred).
* More sophisticated type inference for lambda arguments.
* (Later) Pattern matching in arguments, typing for `where` guards.
3. **Compiler Backend (Elixir):**
- **AST Transformation:** Transform the Lisp AST (potentially type-annotated) into an Elixir-compatible AST or directly to Elixir code.
- **Mapping Lisp Constructs:** Define how Lisp functions, data structures, control flow, and type information translate to Elixir equivalents.
- **Code Generation:** Produce Elixir source files.
- **Interop:** Consider how the Lisp code will call Elixir code and vice-versa.
4. **Standard Library:**
- Define and implement a basic set of core functions and their types (e.g., list operations, arithmetic, type predicates).
5. **Error Reporting Infrastructure:**
- Design a system for collecting and presenting type errors, compiler errors, and runtime errors (if applicable during compilation phases).
6. **Testing Framework:**
- Develop a comprehensive suite of tests covering:
- Parser correctness.
- Type checker correctness (valid and invalid programs).
- Compiler output (comparing generated Elixir against expected output or behavior).
7. **CLI / Build Tool Integration (Future):**
- A command-line interface for the compiler.
- Potential integration with build tools like Mix.
## Main Data Structure: Node Maps
The core data structure for representing code throughout the parsing, type checking, and transpiling phases will be a collection of "Node Maps." Each syntactic element or significant semantic component of the source code will be represented as an Elixir map.
**Structure of a Node Map:**
Each node map will contain a set of common fields and a set of fields specific to the kind of AST element it represents.
* **Common Fields (present in all node maps, based on `lib/til/parser.ex`):**
* `id`: A unique integer (generated by `System.unique_integer([:monotonic, :positive])`) for this node.
* `type_id`: Initially `nil`. After type checking/inference, this field will store or reference the type map (as defined in "Type Representation") associated with this AST node.
* `parent_id`: The `id` of the parent node in the AST, or `nil` if it's a root node or an orphaned element (e.g. an element of an unclosed collection).
* `file`: A string indicating the source file name (defaults to "unknown").
* `location`: A list: `[start_offset, start_line, start_col, end_offset, end_line, end_col]`.
* `raw_string`: The literal string segment from the source code that corresponds to this node.
* `ast_node_type`: An atom identifying the kind of AST node.
* `parsing_error`: `nil` if parsing was successful for this node, or a string message if an error occurred specific to this node (e.g., "Unclosed string literal"). For collection nodes, this can indicate issues like being unclosed.
* **AST-Specific Fields & Node Types (current implementation in `lib/til/parser.ex`):**
* `ast_node_type: :literal_integer`
* `value`: The integer value (e.g., `42`).
* `ast_node_type: :symbol`
* `name`: The string representation of the symbol (e.g., `"my-symbol"`).
* `ast_node_type: :literal_string`
* `value`: The processed string content (escape sequences are not yet handled, but leading whitespace on subsequent lines is stripped based on the opening quote's column).
* `parsing_error`: Can be `"Unclosed string literal"`.
* `ast_node_type: :s_expression`
* `children`: A list of `id`s of the child nodes within the S-expression.
* `parsing_error`: Can be `"Unclosed S-expression"`.
* `ast_node_type: :list_expression` (parsed from `[...]`)
* `children`: A list of `id`s of the child nodes within the list.
* `parsing_error`: Can be `"Unclosed list"`.
* `ast_node_type: :map_expression` (parsed from `m{...}`)
* `children`: A list of `id`s of the child nodes (key-value pairs) within the map.
* `parsing_error`: Can be `"Unclosed map"`.
* `ast_node_type: :tuple_expression` (parsed from `{...}`)
* `children`: A list of `id`s of the child nodes within the tuple.
* `parsing_error`: Can be `"Unclosed tuple"`.
* `ast_node_type: :unknown` (used for tokens that couldn't be parsed into a more specific type, or for unexpected characters)
* `parsing_error`: A string describing the error (e.g., "Unexpected ')'", "Unknown token").
* `ast_node_type: :file`
* `children`: A list of `id`s of the top-level expression nodes in the file, in order of appearance.
* `raw_string`: The entire content of the parsed file.
* `parsing_error`: Typically `nil` for the file node itself, errors would be on child nodes or during parsing of specific structures.
* **Note on `children` field:** For collection types (`:s_expression`, `:list_expression`, `:map_expression`, `:tuple_expression`, `:file`), this field holds a list of child node `id`s in the order they appear in the source.
* **Pseudo-code example of a parsed integer node:**
```elixir
%{
id: 1,
type_id: nil,
parent_id: nil, # Assuming it's a top-level expression
file: "input.til",
location: [0, 1, 1, 2, 1, 3], # [offset_start, line_start, col_start, offset_end, line_end, col_end]
raw_string: "42",
ast_node_type: :literal_integer,
value: 42,
parsing_error: nil
}
```
* **Pseudo-code example of a parsed S-expression node:**
```elixir
%{
id: 2,
type_id: nil,
parent_id: nil,
file: "input.til",
location: [4, 1, 5, 15, 1, 16], # Location spans the entire "(add 1 2)"
raw_string: "(add 1 2)",
ast_node_type: :s_expression,
children: [3, 4, 5], # IDs of :symbol "add", :literal_integer 1, :literal_integer 2
parsing_error: nil
}
```
* **Pseudo-code example of an unclosed string node:**
```elixir
%{
id: 6,
type_id: nil,
parent_id: nil,
file: "input.til",
location: [17, 2, 1, 25, 2, 9], # Spans from opening ' to end of consumed input for the error
raw_string: "'unclosed",
ast_node_type: :literal_string,
value: "unclosed", # The content parsed so far
parsing_error: "Unclosed string literal"
}
```
**Intended Use:**
This collection of interconnected node maps forms a graph (specifically, a tree for the basic AST structure, with additional edges for type references, variable bindings, etc.).
1. **Parsing:** The parser will transform the source code into this collection of node maps.
2. **Type Checking/Inference:** The type system will operate on these node maps. Type information (`type_id`) will be populated or updated. Constraints for type inference can be associated with node `id`s. The immutability of Elixir maps means that updating a node's type information creates a new version of that node map, facilitating the tracking of changes during constraint resolution.
3. **Transpiling:** The transpiler will traverse this graph of node maps (potentially enriched with type information) to generate the target Elixir code.
A central registry or context (e.g., a map of `id => node_map_data`) might be used to store and access all node maps, allowing for efficient lookup and modification (creation of new versions) of individual nodes during various compiler phases.

9
session Normal file
View File

@ -0,0 +1,9 @@
/drop
/add lib/til/typer.ex
/add lib/til/typer/environment.ex
/add lib/til/typer/expression_typer.ex
/add lib/til/typer/interner.ex
/add lib/til/typer/subtype_checker.ex
/add lib/til/typer/types.ex
/add project.md
/read-only Conventions.md

99
set_types Normal file
View File

@ -0,0 +1,99 @@
The core idea is to replace the current map-based type representations with a system centered around **canonicalized type descriptors (`Tilly.Type.Descr`)**, where each descriptor internally uses **Binary Decision Diagrams (BDDs)** to represent sets of values for different kinds of types (integers, atoms, pairs, etc.).
Here's a plan for adaptation:
** overarching concerns:
- lets use bare maps instead of structs. Structs are closed, and I want to keep the option of adding new fields later by the user of the compiler
**I. Foundational BDD and Type Descriptor Infrastructure (New Modules)**
This phase focuses on building the core components described in `set_types.md`. These will mostly be new modules.
1. **`Tilly.BDD.Node` (New):**
* Define the Elixir tagged tuples for BDD nodes: `false`, `true`, `{:leaf, leaf_value_id}`, `{:split, element_id, p_child_id, i_child_id, n_child_id}`.
* include smart constructors
2. **`Tilly.BDD.Store` (New):**
* Implement the hash-consing mechanism for BDD nodes.
* This store will be part of the compiler `ctx` map.
* Key function: `get_or_intern_node(typing_ctx, logical_structure_tuple) :: {new_typing_ctx, node_id}`.
3. **`Tilly.BDD.ElementProtocol` and `Tilly.BDD.LeafProtocol` (New):**
* Define these protocols. Implementations will be provided for basic elements (e.g., atoms, integers for splitting) and basic leaves (e.g., booleans for simple set membership).
4. **`Tilly.BDD.Ops` (New):**
* Implement core BDD operations: `union_bdds`, `intersection_bdds`, `negation_bdd`, `difference_bdd`.
* These functions will take BDD node IDs and the `typing_ctx`, returning a new BDD node ID.
* Implement smart constructors for `Split` and `Leaf` nodes that use the `Tilly.BDD.Store` and apply simplification rules.
5. **`Tilly.Type.Descr` (New):**
* Define the Elixir struct. It will hold IDs of canonical BDDs for each basic kind (e.g., `:atoms_bdd_id`, `:integers_bdd_id`, `:pairs_bdd_id`, etc., mirroring CDuce's `descr` fields).
6. **`Tilly.Type.Store` (New or Replaces `Til.Typer.Interner` logic for types):**
* Manages the interning of `Tilly.Type.Descr` structs. A `Descr` is canonical if its constituent BDD IDs are canonical and the combination is unique.
* This store will also be part of the `typing_ctx`.
7. **`Tilly.Type.Ops` (New):**
* Implement `union_types(descr1_id, descr2_id, typing_ctx)`, `intersection_types`, `negation_type`. These operate on `Descr` IDs by performing field-wise BDD operations using `Tilly.BDD.Ops`, then interning the resulting `Descr`.
**II. Adapting Existing Typer Modules**
This phase involves refactoring existing modules to use the new infrastructure. The `typing_ctx` will need to be threaded through all relevant type-checking functions.
1. **`Til.Typer.Types` (Major Refactor):**
* Functions like `get_primitive_type(:integer)` will now use the new infrastructure to construct/retrieve an interned `Tilly.Type.Descr` ID for the integer type (e.g., a `Descr` where `integers_bdd_id` points to a "true" BDD for integers, and others are "false").
* The concept of predefined type *maps* will be replaced by predefined canonical `Descr` IDs.
2. **`Til.Typer.Interner` (Major Refactor/Integration with `Tilly.Type.Store` and `Tilly.BDD.Store`):**
* Its role in interning type *maps* will be replaced. The new stores will handle BDD node and `Descr` interning.
* `populate_known_types` will pre-populate the `typing_ctx` with canonical `Descr` IDs for base types like `any`, `integer`, `atom`, `nothing`.
3. **`Til.Typer` and `Til.Typer.ExpressionTyper` (Significant Refactor):**
* `infer_type_for_node_ast` (in `ExpressionTyper` or similar):
* For literals (e.g., `123`, `:foo`), it will construct the corresponding `Descr` ID (e.g., an integer `Descr` with a BDD representing only `123`).
* For operations that combine types (e.g., if an `if` expression's branches have types A and B, the result is `A | B`), it will use `Tilly.Type.Ops.union_types`.
* `resolve_type_specifier_node`:
* For basic type names (`Integer`, `Atom`), it will fetch their canonical `Descr` IDs.
* For type expressions like `(or TypeA TypeB)`, it will recursively resolve `TypeA` and `TypeB` to `Descr` IDs, then use `Tilly.Type.Ops.union_types`. Similarly for `(and ...)` and `(not ...)`.
* The `type_id` field in AST nodes will now store the ID of an interned `Tilly.Type.Descr`.
4. **`Til.Typer.SubtypeChecker` (Complete Rewrite):**
* `is_subtype?(descr_a_id, descr_b_id, typing_ctx)` will be implemented as:
1. `descr_not_b_id = Tilly.Type.Ops.negation_type(descr_b_id, typing_ctx)`.
2. `intersection_id = Tilly.Type.Ops.intersection_types(descr_a_id, descr_not_b_id, typing_ctx)`.
3. `is_empty_type(intersection_id, typing_ctx)`.
* `is_empty_type(descr_id, typing_ctx)` checks if all BDDs within the `Descr` are the canonical `False` BDD.
**III. Handling Specific Type Kinds from `project.md`**
The existing type kinds in `project.md` need to be mapped to the new `Descr` model:
* **Primitive Types:** `%{type_kind: :primitive, name: :integer}` becomes a canonical `Descr` ID where `integers_bdd_id` is "all integers" and other BDDs are empty.
* **Literal Types:** `%{type_kind: :literal, value: 42}` becomes a `Descr` ID where `integers_bdd_id` represents only `42`.
* **Union/Intersection/Negation Types:** These are no longer explicit `type_kind`s. They are results of operations in `Tilly.Type.Ops` combining other `Descr` objects.
* **Function, List, Tuple, Map Types:**
* These will be represented by `Descr` objects where the corresponding BDD (e.g., `functions_bdd_id`, `pairs_bdd_id` for lists/tuples, `records_bdd_id` for maps) is non-empty.
* The structure of these BDDs will be more complex, as outlined in CDuce (e.g., BDDs whose leaves are other BDDs, or BDDs splitting on type variables/labels). This is a more advanced step.
* Initially, `list_type(element_descr_id)` might create a `Descr` where `pairs_bdd_id` points to a BDD representing pairs whose first element matches `element_descr_id` and second is recursively a list or nil.
* The detailed map type representation from `project.md` (`known_elements`, `index_signature`) will need to be encoded into the structure of the `records_bdd_id`. This will be a complex part, drawing heavily from how CDuce handles record types with BDDs.
**IV. Parser (`lib/til/parser.ex`)**
* No immediate changes are required for the AST structure itself.
* If new syntax for type operations (e.g., `(or Int Atom)`) is desired directly in source code (as opposed to only being used by `resolve_type_specifier_node`), the parser will need to be updated to produce AST nodes that `resolve_type_specifier_node` can interpret.
**V. Documentation (`project.md`)**
* The "Type Representation" sections will need a complete overhaul to describe the new `Tilly.Type.Descr` and BDD-based system.
**Suggested Phased Approach:**
1. **Phase 0: Core BDD Infrastructure:** Implement `Tilly.BDD.{Node, Store, Ops, Protocols}`. Focus on BDDs for simple sets first (e.g., sets of atoms, integers).
2. **Phase 1: `Tilly.Type.Descr` and Basic Types:** Implement `Tilly.Type.Descr`, `Tilly.Type.Store`, and `Tilly.Type.Ops`. Implement construction of `Descr` IDs for primitives (`any`, `nothing`, `integer`, `atom`) and literals.
3. **Phase 2: Typer Integration for Primitives:** Refactor `Til.Typer.Types`, `Til.Typer.Interner`, and parts of `Til.Typer.ExpressionTyper` to use the new system for primitive and literal types. Update `resolve_type_specifier_node` for basic type names and simple `(or)`, `(and)`, `(not)` forms.
4. **Phase 3: Subtyping:** Implement the new `Til.Typer.SubtypeChecker`.
5. **Phase 4: Constructed Types (Iterative):**
* Start with pairs/tuples, then lists. Design their BDD representations within `Tilly.Type.Descr`.
* Tackle records/maps. This will be challenging and require careful mapping of `project.md`'s map features to CDuce-style record BDDs.
* Address function types.
6. **Phase 5: Advanced Features:** Polymorphism (type variables in BDDs), refinement types.
This is a substantial refactoring. The key is to build the BDD foundation correctly, as everything else will depend on it. The `typing_ctx` will become a critical piece of data passed throughout the type system.
> Tokens: 77k sent, 2.3k received. Cost: $0.12 message, $0.15 session.
#### /edit

169
set_types.md Normal file
View File

@ -0,0 +1,169 @@
# Tilly Set-Theoretic Type System: Design and CDuce Reference
## Objective:
Implement a CDuce-style set-theoretic type system in Elixir for the Tilly compiler. This document outlines the core principles adapted from CDuce and the planned Elixir implementation strategy.
Okay, let's lay out a revised and extensive implementation plan for adapting Tilly to a set-theoretic type system.
**Overarching Concern:**
* **Bare Maps for Extensibility:** We will prioritize using bare Elixir maps for representations like Type Descriptors (`Descr`) instead of structs. This approach maintains flexibility for future extensions, allowing new fields to be added without altering struct definitions. BDD node structures themselves will be represented by simple Elixir terms (atoms or tuples).
**I. Foundational BDD and Type Descriptor Infrastructure (New Modules)**
This phase focuses on building the core components for the set-theoretic type system. These will primarily be new modules. The `typing_ctx` map will be a central piece of data, passed through most functions in this layer, containing the necessary stores and configurations.
1. **`Tilly.BDD.Node` (Conceptual Definition & Basic Helpers):**
* **Node Structures:** We define the canonical Elixir terms representing BDD nodes:
* `true`: The atom representing the universal set BDD.
* `false`: The atom representing the empty set BDD.
* `{:leaf, leaf_value_id}`: Represents a leaf node. `leaf_value_id` can be any term, its interpretation depends on the `ops_module` (e.g., a boolean for simple sets, or another BDD node ID for nested structures).
* `{:split, element_id, positive_child_id, ignore_child_id, negative_child_id}`: Represents an internal decision node.
* `element_id`: The value being split upon.
* `positive_child_id`, `ignore_child_id`, `negative_child_id`: IDs of other BDD nodes in the store.
* **Smart Constructors (Low-Level):** This module might contain very basic helper functions for creating these structural tuples if needed, but the primary "smart" construction (with simplification and interning) will occur in `Tilly.BDD.Ops` and use `Tilly.BDD.get_or_intern_node`.
* **Purpose:** To establish the fundamental building blocks of BDDs.
2. **`Tilly.BDD` (BDD Store Management):**
* **BDD Store (`typing_ctx.bdd_store`):** This will be a map within the `typing_ctx` responsible for hash-consing BDD nodes. It will contain:
* `nodes_by_structure`: A map where keys are `{logical_structure_tuple, ops_module_atom}` and values are `unique_node_id` (integer). The `ops_module_atom` is crucial for canonicalization, ensuring structurally identical nodes intended for different operational semantics are distinct.
* `structures_by_id`: A map where keys are `unique_node_id` and values are `%{structure: logical_structure_tuple, ops_module: ops_module_atom}`. This allows retrieval of both the node's structure and its associated operations module.
* `next_node_id`: An integer for generating new unique node IDs.
* **Key Function: `get_or_intern_node(typing_ctx, logical_structure_tuple, ops_module_atom)`:**
* Takes the current `typing_ctx`, the node's structural representation (e.g., `{:split, ...}`), and the atom identifying its operations module (e.g., `:integer_bool_bdd_ops`).
* Checks `typing_ctx.bdd_store.nodes_by_structure` for an existing node.
* If not found, creates a new `unique_node_id`, updates both `nodes_by_structure` and `structures_by_id`, and increments `next_node_id`.
* Returns `{new_typing_ctx, node_id}`.
* **Helper Functions:**
* `get_node_data(typing_ctx, node_id)`: Returns `%{structure: ..., ops_module: ...}`.
* `is_false_node?(node_id, typing_ctx)`, `is_true_node?(node_id, typing_ctx)`.
* Functions to extract parts of a node structure if needed (e.g., `get_element_from_split_node_id(typing_ctx, node_id)`).
3. **BDD Operations Modules (e.g., `Tilly.BDD.IntegerBoolOps`, `Tilly.BDD.AtomSetOps`, `Tilly.BDD.RecursivePairOps`):**
* **Purpose:** These modules provide the specific logic for how BDD elements are compared/hashed and how leaf values are combined/negated for a particular kind of BDD. They are the "plugins" for the generic BDD algorithms.
* **Interface Convention (Informal or via Elixir `behaviour`):** Each module will implement a consistent set of functions:
* `compare_elements(elem1, elem2)`: Returns `:lt`, `:eq`, or `:gt`.
* `equal_element?(elem1, elem2)`: Returns boolean.
* `hash_element(elem)`: Returns an integer.
* `empty_leaf()`: Returns the leaf value for emptiness.
* `any_leaf()`: Returns the leaf value for universality.
* `is_empty_leaf?(leaf_val)`: Returns boolean.
* `union_leaves(leaf1, leaf2, typing_ctx)`: Combines leaves for union. May need `typing_ctx` for recursive BDD operations if leaves are BDD IDs.
* `intersection_leaves(leaf1, leaf2, typing_ctx)`: Combines for intersection.
* `negation_leaf(leaf, typing_ctx)`: Negates a leaf.
* **Example (`Tilly.BDD.IntegerBoolOps`):**
* `compare_elements/2`: Uses standard integer comparison.
* `empty_leaf/0`: Returns `false`.
* `union_leaves/3`: `fn l1, l2, _ctx -> l1 or l2 end`.
4. **`Tilly.BDD.Ops` (Generic BDD Algorithms):**
* **Core Operations:** Implements `union_bdds(node1_id, node2_id, typing_ctx)`, `intersection_bdds/3`, `negation_bdd/2`, `difference_bdd/3`.
* These functions take BDD node IDs and the `typing_ctx`.
* They fetch node data (structure and `ops_module`) using `Tilly.BDD.get_node_data/2`.
* They use `apply(ops_module, :function_name, args)` to invoke element/leaf operations from the appropriate `ops_module`.
* They return a `node_id` for the resulting BDD.
* **Smart Constructors (Algebraic):** Implements functions like `leaf(leaf_value, ops_module, typing_ctx)` and `split(element, p_id, i_id, n_id, ops_module, typing_ctx)`.
* These are the CDuce-style smart constructors that apply simplification rules (e.g., if `p_id == n_id` in `split`, simplify to `p_id ++ i_id`).
* After simplification, they call `Tilly.BDD.get_or_intern_node/3` to get/create the canonical node ID.
* Example `split/6`:
```elixir
def split(x, p_id, i_id, n_id, ops_module, ctx) do
# Apply simplification rules based on CDuce bdd.ml, e.g.:
# if i_id represents True for ops_module, return True node_id.
# if p_id == n_id, result is union_bdds(p_id, i_id, ctx_with_ops_module).
# ... other rules ...
# If no simplification, create logical_structure:
logical_structure = {:split, x, p_id, i_id, n_id}
Tilly.BDD.get_or_intern_node(ctx, logical_structure, ops_module)
end
```
5. **`Tilly.Type` (Type Descriptor Definition):**
* **`Descr` Map Structure:** Defines the structure of a Type Descriptor map. This map holds IDs of canonical BDDs for each basic kind of type.
Example:
```elixir
%{
atoms_bdd_id: node_id, # BDD for specific atoms (elements: atoms, leaves: booleans)
integers_bdd_id: node_id, # BDD for specific integers (elements: integers, leaves: booleans)
strings_bdd_id: node_id, # BDD for specific strings
tuples_bdd_id: node_id, # BDD for pairs/tuples. Elements might be type vars or first component types; leaves might be BDD IDs for rest of tuple.
maps_bdd_id: node_id, # BDD for maps. Elements might be labels or key types; leaves might be BDD IDs for value types or record structures.
functions_bdd_id: node_id,# BDD for functions.
# ... other kinds as needed, e.g., for abstract types ...
absent_marker_bdd_id: node_id # BDD representing if 'absent' or 'nil' is part of the type
}
```
* **Helper Functions:** May include functions like `empty_descr(typing_ctx)` which creates a `Descr` map where all `*_bdd_id` fields point to the `false` BDD node ID, and `any_descr(typing_ctx)` where they point to `true` BDDs (configured with appropriate `ops_module` for each field).
6. **`Tilly.Type.Store` (Type Descriptor Interning):**
* **Type Store (`typing_ctx.type_store`):** A map within `typing_ctx` for interning `Descr` maps.
* `descrs_by_structure`: Maps a canonical `Descr` map (where all BDD IDs are already canonical) to a `unique_descr_id` (integer).
* `structures_by_id`: Maps `unique_descr_id` back to the canonical `Descr` map.
* `next_descr_id`: For generating new unique `Descr` IDs.
* **Key Function: `get_or_intern_descr(typing_ctx, descr_map)`:**
* Ensures all `*_bdd_id` values in `descr_map` are canonical.
* Looks up `descr_map` in `descrs_by_structure`.
* If not found, creates a new ID, updates stores, and increments `next_descr_id`.
* Returns `{new_typing_ctx, descr_id}`.
7. **`Tilly.Type.Ops` (Operations on Type Descriptors):**
* **Set Operations:** Implements `union_types(typing_ctx, descr1_id, descr2_id)`, `intersection_types/3`, `negation_type/2`.
* These operate on `Descr` IDs.
* Example `union_types/3`:
1. Fetch `descr_map1` and `descr_map2` from `typing_ctx.type_store` using their IDs.
2. For each corresponding field (e.g., `atoms_bdd_id`):
* Call `Tilly.BDD.Ops.union_bdds(descr_map1.atoms_bdd_id, descr_map2.atoms_bdd_id, typing_ctx)`. The `typing_ctx` is passed along; the `ops_module` for these BDDs is retrieved from the `bdd_store` by `union_bdds`.
3. Construct a `result_descr_map` using the new BDD IDs for each field.
4. Intern this `result_descr_map` using `Tilly.Type.Store.get_or_intern_descr(typing_ctx, result_descr_map)` to get the final `result_descr_id`.
5. Return `{new_typing_ctx, result_descr_id}`.
* **Construction Helpers:** Functions to create specific types, e.g., `create_integer_type(typing_ctx, integer_value)` or `create_atom_type(typing_ctx, atom_value)`. These would construct the appropriate `Descr` map (with a BDD representing only that literal) and intern it.
**II. Adapting Existing Typer Modules**
This phase involves refactoring existing modules to use the new BDD-based type infrastructure. The `typing_ctx` (initialized with empty stores) will be threaded through all relevant type-checking functions.
1. **`Til.Typer.Types` (Major Refactor):**
* Functions like `get_primitive_type(:integer)` will now use `Tilly.Type.Ops` or dedicated constructors (e.g., `Tilly.Type.integer_singleton(typing_ctx, value)` or `Tilly.Type.integer_any(typing_ctx)`) to get/create an interned `Descr` ID.
* For example, `get_primitive_type(:integer)` would return the `Descr` ID for the type "all integers". This `Descr` would have its `integers_bdd_id` pointing to a `true` BDD (interned with an `ops_module` like `:integer_bool_bdd_ops`), and other fields like `atoms_bdd_id` pointing to `false` BDDs.
* The concept of predefined type *maps* (as Elixir maps with `:type_kind`) is replaced by predefined canonical `Descr` IDs, likely initialized once and stored or retrieved via helper functions.
2. **`Til.Typer.Interner` (Major Refactor/Integration):**
* Its current role of interning Elixir map-based type representations is entirely replaced by `Tilly.BDD.get_or_intern_node` and `Tilly.Type.Store.get_or_intern_descr`.
* `populate_known_types` will be responsible for initializing the `typing_ctx` by creating and interning the `Descr` IDs for fundamental base types (e.g., `any`, `nothing`, the general `integer` type, `atom` type). This involves creating the necessary BDDs (often just `true` or `false` BDDs for the respective fields in the `Descr`) with their appropriate `ops_module`s.
3. **`Til.Typer` and `Til.Typer.ExpressionTyper` (Significant Refactor):**
* **`typing_ctx` Threading:** All type inference and checking functions will now take `typing_ctx` as an argument and return an updated `typing_ctx` along with their results.
* `infer_type_for_node_ast` (or similar functions):
* For literals (e.g., `123`, `:foo`): Will call functions like `Tilly.Type.Ops.create_integer_literal_type(typing_ctx, 123)` which constructs a `Descr` map where `integers_bdd_id` points to a BDD representing only `123` (and other fields are empty BDDs), interns it, and returns the `{new_ctx, descr_id}`.
* For operations that combine types (e.g., an `if` expression's branches have types `descr_id_A` and `descr_id_B`): Will use `Tilly.Type.Ops.union_types(typing_ctx, descr_id_A, descr_id_B)` to get the `{new_ctx, result_descr_id}`.
* `resolve_type_specifier_node`:
* For basic type names (`Integer`, `Atom`): Will fetch their canonical `Descr` IDs (e.g., from helper functions in `Til.Typer.Types` that retrieve pre-interned IDs).
* For type expressions like `(or TypeA TypeB)`: Will recursively call itself to get `descr_id_A` and `descr_id_B`, then use `Tilly.Type.Ops.union_types(typing_ctx, descr_id_A, descr_id_B)`. Similarly for `(and ...)` and `(not ...)`.
* The `type_id` field in AST nodes will now store the `Descr` ID (an integer) obtained from `Tilly.Type.Store`.
4. **`Til.Typer.SubtypeChecker` (Complete Rewrite):**
* `is_subtype?(typing_ctx, descr_a_id, descr_b_id)`:
1. `{typing_ctx, descr_not_b_id} = Tilly.Type.Ops.negation_type(typing_ctx, descr_b_id)`.
2. `{typing_ctx, intersection_id} = Tilly.Type.Ops.intersection_types(typing_ctx, descr_a_id, descr_not_b_id)`.
3. `Tilly.Type.Ops.is_empty_type?(typing_ctx, intersection_id)`.
* `Tilly.Type.Ops.is_empty_type?(typing_ctx, descr_id)`:
1. Fetches `descr_map` from `typing_ctx.type_store`.
2. Checks if all `*_bdd_id` fields in `descr_map` point to the canonical `false` BDD node ID (by calling `Tilly.BDD.is_false_node?/2` for each).
**III. Handling Specific Type Kinds from `project.md`**
The existing type kinds in `project.md` will be mapped to specific configurations of `Descr` maps and their constituent BDDs.
* **Primitive Types:** `%{type_kind: :primitive, name: :integer}` becomes a canonical `Descr` ID. For "all integers", its `integers_bdd_id` points to a `true` BDD (interned with `ops_module: :integer_bool_bdd_ops`), and other fields (like `atoms_bdd_id`) point to `false` BDDs.
* **Literal Types:** `%{type_kind: :literal, value: 42}` becomes a `Descr` ID. Its `integers_bdd_id` points to a BDD representing *only* the value `42` (e.g., a single `Split` node leading to a `true` leaf, or a more optimized representation if available), and other fields are `false` BDDs.
* **Union/Intersection/Negation Types:** These are no longer explicit `type_kind`s in the `Descr` map itself. They are the *result* of applying `Tilly.Type.Ops.union_types`, `intersection_types`, or `negation_type` to other `Descr` IDs. The resulting `Descr` ID represents the union/intersection/negation.
* **Function, List, Tuple, Map Types:**
* These will be represented by `Descr` maps where the corresponding BDD field is non-empty and structured appropriately. For example, a list of integers would have a non-empty `pairs_bdd_id`.
* The BDDs for these types will be more complex:
* **Lists/Tuples (`pairs_bdd_id`):** The BDD might split on the type of the first element, with leaves being BDD IDs representing the type of the rest of the list/tuple. The `ops_module` for this BDD would handle element types and BDD ID leaves (e.g., `:recursive_pair_ops`).
* **Maps/Records (`records_bdd_id`):** This BDD would split on record labels or key types. Leaves could be BDD IDs for value types or more complex record structures. This is a significant area requiring careful design inspired by CDuce's record BDDs. The `project.md` details on `known_elements` and `index_signature` will need to be encoded into the BDD structure and its `ops_module`.
* **Functions (`functions_bdd_id`):** BDDs might split on argument types or arity, with leaves representing return types (possibly as BDD IDs).
* This is an advanced stage. Initially, constructing these might involve specific helper functions in `Tilly.Type.Ops` (e.g., `create_list_type(typing_ctx, element_descr_id)`).
---

195
test/support/test_helper.ex Normal file
View File

@ -0,0 +1,195 @@
defmodule Til.TestHelpers do
import ExUnit.Assertions
alias Til.Parser
# For type-checking related helpers
alias Til.Typer
alias Til.AstUtils
# --- Node Access Helpers ---
def get_file_node_from_map(nodes_map) do
file_node =
Enum.find(Map.values(nodes_map), fn node ->
# Ensure the map is an AST node before checking its type
Map.has_key?(node, :ast_node_type) && node.ast_node_type == :file
end)
refute is_nil(file_node), "File node not found in #{inspect(nodes_map)}"
file_node
end
def get_node_by_id(nodes_map, node_id) do
node = Map.get(nodes_map, node_id)
refute is_nil(node), "Node with ID #{inspect(node_id)} not found in nodes_map."
node
end
def get_first_child_node(nodes_map, parent_node_id \\ nil) do
parent_node =
if is_nil(parent_node_id) do
get_file_node_from_map(nodes_map)
else
get_node_by_id(nodes_map, parent_node_id)
end
children_ids = Map.get(parent_node, :children, [])
if Enum.empty?(children_ids) do
flunk("Parent node #{parent_node.id} has no children, cannot get first child node.")
end
get_node_by_id(nodes_map, hd(children_ids))
end
def get_nth_child_node(nodes_map, index, parent_node_id \\ nil) do
parent_node =
if is_nil(parent_node_id) do
get_file_node_from_map(nodes_map)
else
get_node_by_id(nodes_map, parent_node_id)
end
children_ids = Map.get(parent_node, :children, [])
unless index >= 0 && index < length(children_ids) do
flunk(
"Child node at index #{index} not found for parent #{parent_node.id}. Parent has #{length(children_ids)} children. Children IDs: #{inspect(children_ids)}"
)
end
get_node_by_id(nodes_map, Enum.at(children_ids, index))
end
# --- Combined Parse/TypeCheck and Get Node Helpers ---
# These return {node, nodes_map}
def parse_and_get_first_node(source_string, file_name \\ "test_source") do
{:ok, parsed_nodes_map} = Parser.parse(source_string, file_name)
{get_first_child_node(parsed_nodes_map), parsed_nodes_map}
end
def parse_and_get_nth_node(source_string, index, file_name \\ "test_source") do
{:ok, parsed_nodes_map} = Parser.parse(source_string, file_name)
{get_nth_child_node(parsed_nodes_map, index), parsed_nodes_map}
end
def typecheck_and_get_first_node(source_string, file_name \\ "test_source") do
{:ok, parsed_nodes_map} = Parser.parse(source_string, file_name)
{:ok, typed_nodes_map} = Typer.type_check(parsed_nodes_map)
{get_first_child_node(typed_nodes_map), typed_nodes_map}
end
def typecheck_and_get_nth_node(source_string, index, file_name \\ "test_source") do
{:ok, parsed_nodes_map} = Parser.parse(source_string, file_name)
{:ok, typed_nodes_map} = Typer.type_check(parsed_nodes_map)
{get_nth_child_node(typed_nodes_map, index), typed_nodes_map}
end
# --- Type Assertion Helpers ---
# Strips :id fields recursively and resolves _id suffixed keys
# from a type definition structure, using nodes_map for lookups.
def deep_strip_id(type_definition, nodes_map) do
cond do
is_struct(type_definition, MapSet) ->
type_definition
|> MapSet.to_list()
# Recursively call on elements
|> Enum.map(&deep_strip_id(&1, nodes_map))
|> MapSet.new()
is_map(type_definition) ->
# Remove its own :id.
map_without_id = Map.delete(type_definition, :id)
# Recursively process its values, resolving _id keys.
# Special handling for type_annotation_mismatch error details
# Check if this map is a type definition itself (has :type_kind) before specific error checks
if Map.has_key?(type_definition, :type_kind) &&
type_definition.type_kind == :error &&
type_definition.reason == :type_annotation_mismatch do
actual_type_clean =
case Map.get(type_definition, :actual_type_id) do
nil -> nil
id -> deep_strip_id(Map.get(nodes_map, id), nodes_map)
end
expected_type_clean =
case Map.get(type_definition, :expected_type_id) do
nil -> nil
id -> deep_strip_id(Map.get(nodes_map, id), nodes_map)
end
%{
type_kind: :error,
reason: :type_annotation_mismatch,
actual_type: actual_type_clean,
expected_type: expected_type_clean
}
else
# Standard processing for other maps
Enum.reduce(map_without_id, %{}, fn {original_key, original_value}, acc_map ->
{final_key, final_value} =
cond do
# Handle :element_type_id -> :element_type
original_key == :element_type_id && is_atom(original_value) ->
resolved_def = Map.get(nodes_map, original_value)
{:element_type, deep_strip_id(resolved_def, nodes_map)}
# Handle :key_type_id -> :key_type
original_key == :key_type_id && is_atom(original_value) ->
resolved_def = Map.get(nodes_map, original_value)
{:key_type, deep_strip_id(resolved_def, nodes_map)}
# Handle :value_type_id -> :value_type (e.g., in index_signature or %{value_type_id: ..., optional: ...})
original_key == :value_type_id && is_atom(original_value) ->
resolved_def = Map.get(nodes_map, original_value)
{:value_type, deep_strip_id(resolved_def, nodes_map)}
is_map(original_value) ->
{original_key, deep_strip_id(original_value, nodes_map)}
is_list(original_value) ->
{original_key, deep_strip_id(original_value, nodes_map)} # Handles lists of type defs
true ->
{original_key, original_value}
end
Map.put(acc_map, final_key, final_value)
end)
end
is_list(type_definition) ->
# Recursively call on elements for lists of type definitions
Enum.map(type_definition, &deep_strip_id(&1, nodes_map))
true -> # Literals, atoms, numbers, nil, etc. (leaf nodes in the type structure)
type_definition
end
end
def assert_node_typed_as(node, typed_nodes_map, expected_type_definition_clean) do
type_key = node.type_id
assert is_atom(type_key),
"Type ID for node #{inspect(node.id)} (#{node.raw_string}) should be an atom key, got: #{inspect(type_key)}"
actual_type_definition_from_map = Map.get(typed_nodes_map, type_key)
refute is_nil(actual_type_definition_from_map),
"Type definition for key #{inspect(type_key)} (from node #{node.id}, raw: '#{node.raw_string}') not found in nodes_map."
actual_type_definition_clean =
deep_strip_id(actual_type_definition_from_map, typed_nodes_map)
assert actual_type_definition_clean == expected_type_definition_clean,
"Type mismatch for node #{node.id} ('#{node.raw_string}').\nExpected (clean):\n#{inspect(expected_type_definition_clean, pretty: true, limit: :infinity)}\nGot (clean):\n#{inspect(actual_type_definition_clean, pretty: true, limit: :infinity)}"
end
def inspect_nodes(node_map) do
AstUtils.build_debug_ast_data(node_map)
|> IO.inspect(pretty: true, limit: :infinity)
end
end

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()

38
test/til/adhoc_tests.exs Normal file
View File

@ -0,0 +1,38 @@
defmodule Til.AdhocTest do
@moduledoc """
Adhoc tests for quick syntax checking and compiler features.
These tests are not part of the main test suite and are used for
quick manual checks.
"""
use ExUnit.Case, async: true
alias Til.TestHelpers
describe "Adhoc tests for quick syntax checking and compiler features" do
# test "pretty_print_ast with nested structures and errors" do
# source = """
# (defun my_func [a b]
# (add a m{key 'val})
# (some_list 1 2
# """
#
# {:ok, nodes_map} =
# Parser.parse(source, "adhoc_error_test.til")
#
# AstUtils.pretty_print_ast(nodes_map)
# |> IO.puts()
#
# AstUtils.build_debug_ast_data(nodes_map)
# |> IO.inspect(label: "AST Nodes", pretty: true, limit: :infinity)
# end
# end
test "asdasd" do
source = """
(defun :asd)
"""
TestHelpers.typecheck_and_get_first_node(source)
# |> IO.inspect(label: "First Node", pretty: true, limit: :infinity)
end
end
end

View File

@ -0,0 +1,21 @@
defmodule Til.FileParseTest do
use ExUnit.Case, async: true
alias Til.Parser
alias Til.AstUtils
alias Til.TestHelpers
describe "test File parsing features" do
test "parse multiple top-level s-exps into 1 `file` ast node" do
source = """
(defun)
(defun)
"""
{:ok, nodes_map} =
Parser.parse(source, "top-level-sexps.til")
assert TestHelpers.get_file_node_from_map(nodes_map).ast_node_type == :file
end
end
end

View File

@ -0,0 +1,167 @@
defmodule Til.ListParserTest do
use ExUnit.Case, async: true
alias Til.Parser
import Til.TestHelpers
describe "List Parsing" do
test "parses an empty list []" do
source = "[]"
{:ok, nodes_map} = Parser.parse(source)
file_node = get_file_node_from_map(nodes_map)
list_node_id = hd(file_node.children)
list_node = Map.get(nodes_map, list_node_id)
assert list_node.ast_node_type == :list_expression
assert list_node.raw_string == "[]"
assert list_node.children == []
assert list_node.parsing_error == nil
# Location check
# "[]"
# ^ offset 0, line 1, col 1
# ^ offset 1, line 1, col 2
# ^ offset 2, line 1, col 3 (end position, exclusive for offset, inclusive for col)
assert list_node.location == [0, 1, 1, 2, 1, 3]
# file_node is already fetched and used to get list_node
assert file_node.children == [list_node.id]
end
test "parses a list of integers [1 2 3]" do
source = "[1 2 3]"
{:ok, nodes_map} = Parser.parse(source)
file_node = get_file_node_from_map(nodes_map)
list_node_id = hd(file_node.children)
list_node = Map.get(nodes_map, list_node_id)
assert list_node.ast_node_type == :list_expression
assert list_node.raw_string == "[1 2 3]"
assert list_node.parsing_error == nil
assert length(list_node.children) == 3
# Location check
# "[1 2 3]"
# ^ offset 0, line 1, col 1
# ^ offset 7, line 1, col 8
assert list_node.location == [0, 1, 1, 7, 1, 8]
# Check children
child1_id = Enum.at(list_node.children, 0)
child2_id = Enum.at(list_node.children, 1)
child3_id = Enum.at(list_node.children, 2)
child1 = Map.get(nodes_map, child1_id)
child2 = Map.get(nodes_map, child2_id)
child3 = Map.get(nodes_map, child3_id)
assert child1.ast_node_type == :literal_integer
assert child1.value == 1
assert child1.raw_string == "1"
assert child1.parent_id == list_node.id
# "[1 2 3]"
# ^ offset 1, line 1, col 2
# ^ offset 2, line 1, col 3
assert child1.location == [1, 1, 2, 2, 1, 3]
assert child2.ast_node_type == :literal_integer
assert child2.value == 2
assert child2.raw_string == "2"
assert child2.parent_id == list_node.id
# "[1 2 3]"
# ^ offset 3, line 1, col 4
# ^ offset 4, line 1, col 5
assert child2.location == [3, 1, 4, 4, 1, 5]
assert child3.ast_node_type == :literal_integer
assert child3.value == 3
assert child3.raw_string == "3"
assert child3.parent_id == list_node.id
# "[1 2 3]"
# ^ offset 5, line 1, col 6
# ^ offset 6, line 1, col 7
assert child3.location == [5, 1, 6, 6, 1, 7]
# file_node is already fetched and used to get list_node
assert file_node.children == [list_node.id]
end
test "parses an unclosed list [1 2" do
source = "[1 2"
{:ok, nodes_map} = Parser.parse(source)
file_node = get_file_node_from_map(nodes_map)
list_node_id = hd(file_node.children)
list_node = Map.get(nodes_map, list_node_id)
assert list_node.ast_node_type == :list_expression
assert list_node.raw_string == "[1 2" # Raw string is what was consumed for the list
assert list_node.parsing_error == "Unclosed list"
assert length(list_node.children) == 2 # Children that were successfully parsed
# Location check for the unclosed list node
# "[1 2"
# ^ offset 0, line 1, col 1
# ^ offset 4, line 1, col 5 (end of consumed input for this node)
assert list_node.location == [0, 1, 1, 4, 1, 5]
child1 = get_node_by_id(nodes_map, Enum.at(list_node.children, 0))
child2 = get_node_by_id(nodes_map, Enum.at(list_node.children, 1))
assert child1.value == 1
assert child2.value == 2
# file_node is already fetched and used to get list_node
assert file_node.children == [list_node.id]
end
test "parses an unexpected closing bracket ] at top level" do
source = "]"
{:ok, nodes_map} = Parser.parse(source)
file_node = get_file_node_from_map(nodes_map)
error_node_id = hd(file_node.children)
error_node = Map.get(nodes_map, error_node_id)
assert error_node.ast_node_type == :unknown # Or a more specific error type if desired
assert error_node.raw_string == "]"
assert error_node.parsing_error == "Unexpected ']'"
# Location check
# "]"
# ^ offset 0, line 1, col 1
# ^ offset 1, line 1, col 2
assert error_node.location == [0, 1, 1, 1, 1, 2]
# file_node is already fetched and used to get error_node
assert file_node.children == [error_node.id]
end
test "parses an unexpected closing bracket ] inside S-expression (foo ])" do
source = "(foo ])"
{:ok, nodes_map} = Parser.parse(source)
file_node = get_file_node_from_map(nodes_map)
sexpr_node_id = hd(file_node.children) # S-expression is the top-level
sexpr_node = Map.get(nodes_map, sexpr_node_id)
assert sexpr_node.ast_node_type == :s_expression
assert sexpr_node.raw_string == "(foo ])"
assert sexpr_node.parsing_error == nil # The S-expression itself is not unclosed
assert length(sexpr_node.children) == 2 # 'foo' and the error node for ']'
# First child 'foo'
foo_node = get_node_by_id(nodes_map, Enum.at(sexpr_node.children, 0))
assert foo_node.ast_node_type == :symbol
assert foo_node.name == "foo"
# Second child is the error node for ']'
error_node = get_node_by_id(nodes_map, Enum.at(sexpr_node.children, 1))
assert error_node.ast_node_type == :unknown
assert error_node.raw_string == "]"
assert error_node.parsing_error == "Unexpected ']'"
assert error_node.parent_id == sexpr_node.id
# Location check for ']'
# "(foo ])"
# ^ offset 5, line 1, col 6
# ^ offset 6, line 1, col 7
assert error_node.location == [5, 1, 6, 6, 1, 7]
end
end
end

241
test/til/map_test.exs Normal file
View File

@ -0,0 +1,241 @@
defmodule Til.MapParserTest do
use ExUnit.Case, async: true
alias Til.Parser
alias Til.TestHelpers
describe "parse/2 - Map Expressions" do
test "parses an empty map" do
source = "m{}"
{:ok, nodes_map} = Parser.parse(source)
# file_node + map_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
map_node = TestHelpers.get_first_child_node(nodes_map)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == file_node.id
assert map_node.children == []
# "m{}"
assert map_node.location == [0, 1, 1, 3, 1, 4]
assert map_node.raw_string == "m{}"
end
test "parses a simple map with symbol keys and values" do
source = "m{key1 val1 key2 val2}"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node + 1 map_expression node + 4 symbol nodes = 6 nodes
assert map_size(nodes_map) == 6
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
map_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(map_node)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == file_node.id
assert length(map_node.children) == 4
children_nodes = Enum.map(map_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.name) == ["key1", "val1", "key2", "val2"]
Enum.each(children_nodes, fn child ->
assert child.parent_id == map_node.id
assert child.ast_node_type == :symbol
end)
# "m{key1 val1 key2 val2}"
assert map_node.location == [0, 1, 1, 22, 1, 23]
assert map_node.raw_string == "m{key1 val1 key2 val2}"
end
test "parses a map with mixed type values" do
source = "m{s 'a string' i 123 sym value}"
{map_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# 1 file_node + 1 map, 3 keys (symbols), 1 string val, 1 int val, 1 symbol val = 1 + 1 + 3 + 3 = 8 nodes
assert map_size(nodes_map) == 8
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(map_node)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == file_node.id
assert length(map_node.children) == 6
children_nodes = Enum.map(map_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
# s
assert Enum.at(children_nodes, 0).ast_node_type == :symbol
assert Enum.at(children_nodes, 0).name == "s"
# 'a string'
assert Enum.at(children_nodes, 1).ast_node_type == :literal_string
assert Enum.at(children_nodes, 1).value == "a string"
# i
assert Enum.at(children_nodes, 2).ast_node_type == :symbol
assert Enum.at(children_nodes, 2).name == "i"
# 123
assert Enum.at(children_nodes, 3).ast_node_type == :literal_integer
assert Enum.at(children_nodes, 3).value == 123
# sym
assert Enum.at(children_nodes, 4).ast_node_type == :symbol
assert Enum.at(children_nodes, 4).name == "sym"
# value
assert Enum.at(children_nodes, 5).ast_node_type == :symbol
assert Enum.at(children_nodes, 5).name == "value"
end
test "parses nested maps" do
source = "m{outer_key m{inner_key inner_val}}"
{:ok, nodes_map} = Parser.parse(source)
# Nodes: 1 file_node, outer_map, outer_key, inner_map, inner_key, inner_val => 6 nodes
assert map_size(nodes_map) == 6
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
outer_map = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(outer_map)
assert outer_map.ast_node_type == :map_expression
assert outer_map.parent_id == file_node.id
# outer_key, inner_map
assert length(outer_map.children) == 2
outer_key_node = TestHelpers.get_nth_child_node(nodes_map, 0, outer_map.id)
inner_map_node = TestHelpers.get_nth_child_node(nodes_map, 1, outer_map.id)
assert outer_key_node.ast_node_type == :symbol
assert outer_key_node.name == "outer_key"
assert inner_map_node.ast_node_type == :map_expression
assert inner_map_node.parent_id == outer_map.id
# inner_key, inner_val
assert length(inner_map_node.children) == 2
inner_key_node = TestHelpers.get_nth_child_node(nodes_map, 0, inner_map_node.id)
inner_val_node = TestHelpers.get_nth_child_node(nodes_map, 1, inner_map_node.id)
assert inner_key_node.ast_node_type == :symbol
assert inner_key_node.name == "inner_key"
assert inner_val_node.ast_node_type == :symbol
assert inner_val_node.name == "inner_val"
end
test "parses map with varied spacing" do
source = "m{ key1 val1\n key2 val2 }"
{map_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(map_node)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == file_node.id
assert length(map_node.children) == 4
children_names_values =
Enum.map(map_node.children, fn id ->
node = TestHelpers.get_node_by_id(nodes_map, id)
if node.ast_node_type == :symbol, do: node.name, else: node.value
end)
assert children_names_values == ["key1", "val1", "key2", "val2"]
end
test "handles unclosed map" do
source = "m{key1 val1"
{map_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# Expect 1 file_node, 1 map_expression node (error), 2 symbol nodes = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(map_node)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == file_node.id
assert map_node.parsing_error == "Unclosed map"
# key1, val1
assert length(map_node.children) == 2
# "m{key1 val1"
assert map_node.location == [0, 1, 1, 11, 1, 12]
assert map_node.raw_string == "m{key1 val1"
end
test "handles unexpected closing curly brace at top level (not map specific, but related)" do
# This } is not part of m{}
source = "foo } bar"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node + 3 items
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
top_level_children = Enum.map(file_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
error_node =
Enum.find(top_level_children, &(&1.ast_node_type == :unknown && &1.raw_string == "}"))
refute is_nil(error_node)
assert error_node.parent_id == file_node.id
assert error_node.parsing_error == "Unexpected '}'"
# location of "}"
assert error_node.location == [4, 1, 5, 5, 1, 6]
end
test "parses map with odd number of elements (parser accepts, semantic check later)" do
source = "m{key1 val1 key2}"
{map_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(map_node)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == file_node.id
# key1, val1, key2
assert length(map_node.children) == 3
children_nodes = Enum.map(map_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.name) == ["key1", "val1", "key2"]
end
test "map within an S-expression" do
source = "(do-something m{a 1 b 2})"
{s_expr_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# 1 file_node, s-expr, do-something, map, a, 1, b, 2 => 8 nodes
assert map_size(nodes_map) == 8
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
# do-something, map_node
assert length(s_expr_node.children) == 2
map_node = TestHelpers.get_nth_child_node(nodes_map, 1, s_expr_node.id)
assert map_node.ast_node_type == :map_expression
assert map_node.parent_id == s_expr_node.id
# a, 1, b, 2
assert length(map_node.children) == 4
end
test "map as a value in another map" do
source = "m{data m{x 10 y 20}}"
{outer_map_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# 1 file_node, outer_map, data_symbol, inner_map, x_symbol, 10_int, y_symbol, 20_int => 8 nodes
assert map_size(nodes_map) == 8
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(outer_map_node)
assert outer_map_node.ast_node_type == :map_expression
assert outer_map_node.parent_id == file_node.id
# data_symbol, inner_map_node
assert length(outer_map_node.children) == 2
inner_map_node = TestHelpers.get_nth_child_node(nodes_map, 1, outer_map_node.id)
assert inner_map_node.ast_node_type == :map_expression
assert inner_map_node.parent_id == outer_map_node.id
# x, 10, y, 20
assert length(inner_map_node.children) == 4
end
end
end

View File

@ -0,0 +1,149 @@
defmodule Til.ParseAtomTest do
use ExUnit.Case, async: true
alias Til.Parser
import Til.TestHelpers
describe "Atom parsing" do
test "parses a simple atom" do
source = ":hello"
{:ok, _nodes_map} = Parser.parse(source)
{atom_node, _map} = parse_and_get_first_node(source)
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :hello
assert atom_node.raw_string == ":hello"
assert atom_node.location == [0, 1, 1, 6, 1, 7]
end
test "parses an atom with numbers and underscores" do
source = ":foo_123_bar"
{:ok, _nodes_map} = Parser.parse(source)
{atom_node, _map} = parse_and_get_first_node(source)
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :foo_123_bar
assert atom_node.raw_string == ":foo_123_bar"
end
test "parses an atom within an s-expression" do
source = "(:an_atom)"
{:ok, nodes_map} = Parser.parse(source)
s_expr_node = get_first_child_node(nodes_map)
atom_node_id = hd(s_expr_node.children)
atom_node = get_node_by_id(nodes_map, atom_node_id)
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :an_atom
assert atom_node.raw_string == ":an_atom"
# Location of :an_atom within ()
assert atom_node.location == [1, 1, 2, 9, 1, 10]
end
test "parses multiple atoms in an s-expression" do
source = "(:first :second)"
{:ok, nodes_map} = Parser.parse(source)
s_expr_node = get_first_child_node(nodes_map)
first_atom_node = get_node_by_id(nodes_map, Enum.at(s_expr_node.children, 0))
assert first_atom_node.ast_node_type == :literal_atom
assert first_atom_node.value == :first
assert first_atom_node.raw_string == ":first"
second_atom_node = get_node_by_id(nodes_map, Enum.at(s_expr_node.children, 1))
assert second_atom_node.ast_node_type == :literal_atom
assert second_atom_node.value == :second
assert second_atom_node.raw_string == ":second"
end
test "parses an atom followed immediately by an opening parenthesis (delimiter)" do
source = ":atom_name(foo)"
{:ok, nodes_map} = Parser.parse(source)
# First child of the file node should be the atom
atom_node = get_nth_child_node(nodes_map, 0)
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :atom_name
assert atom_node.raw_string == ":atom_name"
assert atom_node.location == [0, 1, 1, 10, 1, 11]
# Second child should be the s-expression
s_expr_node = get_nth_child_node(nodes_map, 1)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.raw_string == "(foo)"
end
test "parses an atom at the end of input" do
source = " :last_atom "
{:ok, nodes_map} = Parser.parse(source)
# Use trimmed for helper
{atom_node, _map} = parse_and_get_first_node(String.trim(source))
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :last_atom
assert atom_node.raw_string == ":last_atom"
# Location needs to be checked against the original source with whitespace
file_node = get_file_node_from_map(nodes_map)
actual_atom_node_id = hd(file_node.children)
actual_atom_node = get_node_by_id(nodes_map, actual_atom_node_id)
# " :last_atom "
assert actual_atom_node.location == [2, 1, 3, 12, 1, 13]
end
test "parses atom within a list expression" do
source = "[:my_list_atom]"
{:ok, nodes_map} = Parser.parse(source)
list_expr_node = get_first_child_node(nodes_map)
atom_node_id = hd(list_expr_node.children)
atom_node = get_node_by_id(nodes_map, atom_node_id)
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :my_list_atom
assert atom_node.raw_string == ":my_list_atom"
end
test "parses atom within a tuple expression" do
source = "{:my_tuple_atom}"
{:ok, nodes_map} = Parser.parse(source)
tuple_expr_node = get_first_child_node(nodes_map)
atom_node_id = hd(tuple_expr_node.children)
atom_node = get_node_by_id(nodes_map, atom_node_id)
assert atom_node.ast_node_type == :literal_atom
assert atom_node.value == :my_tuple_atom
assert atom_node.raw_string == ":my_tuple_atom"
end
test "parses atom as a key in a map expression" do
source = "m{:key 1}"
{:ok, nodes_map} = Parser.parse(source)
map_expr_node = get_first_child_node(nodes_map)
key_node_id = Enum.at(map_expr_node.children, 0)
key_node = get_node_by_id(nodes_map, key_node_id)
assert key_node.ast_node_type == :literal_atom
assert key_node.value == :key
assert key_node.raw_string == ":key"
value_node_id = Enum.at(map_expr_node.children, 1)
value_node = get_node_by_id(nodes_map, value_node_id)
assert value_node.ast_node_type == :literal_integer
assert value_node.value == 1
end
test "parses atom as a value in a map expression" do
source = "m{'string_key' :atom_value}"
{:ok, nodes_map} = Parser.parse(source)
map_expr_node = get_first_child_node(nodes_map)
# string_key_node is child 0
value_node_id = Enum.at(map_expr_node.children, 1)
value_node = get_node_by_id(nodes_map, value_node_id)
assert value_node.ast_node_type == :literal_atom
assert value_node.value == :atom_value
assert value_node.raw_string == ":atom_value"
end
end
end

View File

@ -0,0 +1,868 @@
defmodule Til.ParserTest do
use ExUnit.Case, async: true
alias Til.Parser
alias Til.TestHelpers
describe "parse/2 - Basic Atoms" do
test "parses a simple integer literal" do
source = "42"
file_name = "test.tly"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source, file_name)
# file_node + integer_node
assert map_size(nodes_map) == 2
# Still need file_node for parent_id check
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert is_integer(node.id)
assert node.type_id == nil
assert node.parent_id == file_node.id
assert node.file == file_name
# "42"
assert node.location == [0, 1, 1, 2, 1, 3]
assert node.raw_string == source
assert node.ast_node_type == :literal_integer
assert node.value == 42
end
test "parses a negative integer literal" do
source = "-123"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# file_node + integer_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.parent_id == file_node.id
# "-123"
assert node.location == [0, 1, 1, 4, 1, 5]
assert node.raw_string == source
assert node.ast_node_type == :literal_integer
assert node.value == -123
end
test "parses a simple symbol" do
source = "foo"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# file_node + symbol_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.parent_id == file_node.id
# "foo"
assert node.location == [0, 1, 1, 3, 1, 4]
assert node.raw_string == source
assert node.ast_node_type == :symbol
assert node.name == "foo"
end
test "parses a symbol with hyphens and numbers" do
source = "my-var-123"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.parent_id == file_node.id
assert node.raw_string == source
assert node.ast_node_type == :symbol
assert node.name == "my-var-123"
end
test "parses operator-like symbols" do
source = "+"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.parent_id == file_node.id
assert node.raw_string == source
assert node.ast_node_type == :symbol
assert node.name == "+"
end
test "parses a sequence of integers and symbols" do
source = "10 foo -20 bar+"
{:ok, nodes_map} = Parser.parse(source)
# 4 items + 1 file_node
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
# Children are already sorted by the parser
[n1_id, n2_id, n3_id, n4_id] = file_node.children
n1 = TestHelpers.get_node_by_id(nodes_map, n1_id)
n2 = TestHelpers.get_node_by_id(nodes_map, n2_id)
n3 = TestHelpers.get_node_by_id(nodes_map, n3_id)
n4 = TestHelpers.get_node_by_id(nodes_map, n4_id)
assert n1.ast_node_type == :literal_integer
assert n1.value == 10
assert n1.raw_string == "10"
assert n1.location == [0, 1, 1, 2, 1, 3]
assert n1.parent_id == file_node.id
assert n2.ast_node_type == :symbol
assert n2.name == "foo"
assert n2.raw_string == "foo"
# after "10 "
assert n2.location == [3, 1, 4, 6, 1, 7]
assert n2.parent_id == file_node.id
assert n3.ast_node_type == :literal_integer
assert n3.value == -20
assert n3.raw_string == "-20"
# after "foo "
assert n3.location == [7, 1, 8, 10, 1, 11]
assert n3.parent_id == file_node.id
assert n4.ast_node_type == :symbol
assert n4.name == "bar+"
assert n4.raw_string == "bar+"
# after "-20 "
assert n4.location == [11, 1, 12, 15, 1, 16]
assert n4.parent_id == file_node.id
end
test "uses 'unknown' as default file_name" do
source = "7"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.file == "unknown"
assert file_node.file == "unknown"
assert node.parent_id == file_node.id
end
end
describe "parse/2 - S-expressions" do
test "parses an empty S-expression" do
source = "()"
{:ok, nodes_map} = Parser.parse(source)
# file_node + s_expr_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
s_expr_node = TestHelpers.get_first_child_node(nodes_map)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
assert s_expr_node.children == []
# "()"
assert s_expr_node.location == [0, 1, 1, 2, 1, 3]
# Raw string for S-expressions is tricky, current impl might be placeholder like "()" or "(...)"
# For now, let's assert it starts with ( and ends with ) if not placeholder
assert String.starts_with?(s_expr_node.raw_string, "(") &&
String.ends_with?(s_expr_node.raw_string, ")")
# if it's "()"
assert String.length(s_expr_node.raw_string) == 2
end
test "parses a simple S-expression with integers" do
source = "(1 22 -3)"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node + 1 S-expr node + 3 integer nodes = 5 nodes
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
s_expr_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
assert length(s_expr_node.children) == 3
children_nodes = Enum.map(s_expr_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.value) == [1, 22, -3]
Enum.each(children_nodes, fn child ->
assert child.parent_id == s_expr_node.id
assert child.ast_node_type == :literal_integer
end)
# Source "(1 22 -3)" has length 9. Start offset 0, end offset 9. Start col 1, end col 10.
# "(1 22 -3)"
assert s_expr_node.location == [0, 1, 1, 9, 1, 10]
end
test "parses a simple S-expression with symbols" do
source = "(foo bar baz)"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node, 1 s-expr, 3 symbols = 5 nodes
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
s_expr_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
assert length(s_expr_node.children) == 3
children_nodes = Enum.map(s_expr_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.name) == ["foo", "bar", "baz"]
Enum.each(children_nodes, fn child ->
assert child.parent_id == s_expr_node.id
assert child.ast_node_type == :symbol
end)
end
test "parses nested S-expressions" do
# outer: a, (b 1), c | inner: b, 1
source = "(a (b 1) c)"
{:ok, nodes_map} = Parser.parse(source)
# Nodes: 1 file_node, outer_s_expr, a, inner_s_expr, c, b, 1 => 7 nodes
assert map_size(nodes_map) == 7
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
outer_s_expr = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(outer_s_expr)
assert outer_s_expr.ast_node_type == :s_expression
assert outer_s_expr.parent_id == file_node.id
assert length(outer_s_expr.children) == 3
# 'a'
child1 = TestHelpers.get_nth_child_node(nodes_map, 0, outer_s_expr.id)
# '(b 1)'
inner_s_expr = TestHelpers.get_nth_child_node(nodes_map, 1, outer_s_expr.id)
# 'c'
child3 = TestHelpers.get_nth_child_node(nodes_map, 2, outer_s_expr.id)
assert child1.ast_node_type == :symbol && child1.name == "a"
assert inner_s_expr.ast_node_type == :s_expression
assert child3.ast_node_type == :symbol && child3.name == "c"
assert inner_s_expr.parent_id == outer_s_expr.id
assert length(inner_s_expr.children) == 2
# 'b'
grandchild1 = TestHelpers.get_nth_child_node(nodes_map, 0, inner_s_expr.id)
# '1'
grandchild2 = TestHelpers.get_nth_child_node(nodes_map, 1, inner_s_expr.id)
assert grandchild1.ast_node_type == :symbol && grandchild1.name == "b"
assert grandchild1.parent_id == inner_s_expr.id
assert grandchild2.ast_node_type == :literal_integer && grandchild2.value == 1
assert grandchild2.parent_id == inner_s_expr.id
end
test "parses S-expressions with varied spacing" do
source = "( foo 1\nbar )"
{s_expr_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
assert length(s_expr_node.children) == 3
children_names_values =
Enum.map(s_expr_node.children, fn id ->
node = TestHelpers.get_node_by_id(nodes_map, id)
if node.ast_node_type == :symbol, do: node.name, else: node.value
end)
assert children_names_values == ["foo", 1, "bar"]
end
end
describe "parse/2 - Error Handling" do
test "handles unclosed S-expression" do
source = "(foo bar"
{:ok, nodes_map} = Parser.parse(source)
# Expect 1 file_node, 1 S-expr node (marked with error), 2 symbol nodes = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
s_expr_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
assert s_expr_node.parsing_error == "Unclosed S-expression"
# foo, bar
assert length(s_expr_node.children) == 2
# Location should span till end of source
# "(foo bar"
assert s_expr_node.location == [0, 1, 1, 8, 1, 9]
end
test "handles unexpected closing parenthesis at top level" do
# foo, error_node_for_), bar
source = "foo ) bar"
{:ok, nodes_map} = Parser.parse(source)
# 3 items + 1 file_node
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
top_level_children =
Enum.map(file_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
error_node =
Enum.find(top_level_children, &(&1.ast_node_type == :unknown && &1.raw_string == ")"))
refute is_nil(error_node)
assert error_node.parent_id == file_node.id
assert error_node.parsing_error == "Unexpected ')'"
# location of ")"
assert error_node.location == [4, 1, 5, 5, 1, 6]
symbol_foo =
Enum.find(top_level_children, &(&1.ast_node_type == :symbol && &1.name == "foo"))
symbol_bar =
Enum.find(top_level_children, &(&1.ast_node_type == :symbol && &1.name == "bar"))
refute is_nil(symbol_foo)
assert symbol_foo.parent_id == file_node.id
refute is_nil(symbol_bar)
assert symbol_bar.parent_id == file_node.id
end
test "handles unknown token inside S-expression (partial, basic)" do
source = "(foo 123"
{:ok, nodes_map} = Parser.parse(source)
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
s_expr_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
assert s_expr_node.parsing_error == "Unclosed S-expression"
assert length(s_expr_node.children) == 2
child1 = TestHelpers.get_nth_child_node(nodes_map, 0, s_expr_node.id)
child2 = TestHelpers.get_nth_child_node(nodes_map, 1, s_expr_node.id)
assert child1.name == "foo"
assert child2.value == 123
end
test "parses multiple top-level S-expressions" do
source = "() (1 2) (foo)"
{:ok, nodes_map} = Parser.parse(source)
# () -> 1 node
# (1 2) -> 1 s-expr, 2 ints = 3 nodes
# (foo) -> 1 s-expr, 1 symbol = 2 nodes
# Total items = 1 + 3 + 2 = 6 nodes. Plus 1 file_node = 7 nodes.
assert map_size(nodes_map) == 7
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
top_level_s_expr_nodes =
file_node.children
|> Enum.map(&TestHelpers.get_node_by_id(nodes_map, &1))
|> Enum.filter(&(&1.ast_node_type == :s_expression))
assert length(top_level_s_expr_nodes) == 3
Enum.each(top_level_s_expr_nodes, fn node -> assert node.parent_id == file_node.id end)
# Check children counts or specific content if necessary
# For example, the S-expression for (1 2)
s_expr_1_2 =
Enum.find(top_level_s_expr_nodes, fn node ->
children = Enum.map(node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
Enum.map(children, & &1.value) == [1, 2]
end)
refute is_nil(s_expr_1_2)
end
# Test for raw_string of S-expression (this is a known tricky part in the impl)
# The current implementation has a placeholder for S-expression raw_string.
# This test will likely fail or need adjustment based on how raw_string is actually captured.
# For now, I'll skip a very precise raw_string test for S-expressions until it's robustly implemented.
# test "S-expression raw_string is captured correctly" do
# source = "( add 1 2 )" # Note spaces
# {:ok, nodes_map} = Parser.parse(source)
# s_expr_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :s_expression))
# assert s_expr_node.raw_string == source
# end
end
describe "parse/2 - String Literals" do
test "parses a simple single-line string" do
source = "'hello world'"
file_name = "test_str.tly"
{:ok, nodes_map} = Parser.parse(source, file_name)
# file_node + string_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
node = TestHelpers.get_first_child_node(nodes_map)
assert node.ast_node_type == :literal_string
assert node.value == "hello world"
assert node.raw_string == "'hello world'"
assert node.location == [0, 1, 1, 13, 1, 14]
assert node.file == file_name
assert node.parent_id == file_node.id
assert node.type_id == nil
end
test "parses an empty string" do
source = "''"
{:ok, nodes_map} = Parser.parse(source)
# file_node + string_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
node = TestHelpers.get_first_child_node(nodes_map)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.value == ""
assert node.raw_string == "''"
assert node.location == [0, 1, 1, 2, 1, 3]
end
test "parses a multiline string with no initial indent" do
# ' is at col 1, strip 0 spaces
source = "'hello\nworld'"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.value == "hello\nworld"
assert node.raw_string == "'hello\nworld'"
assert node.location == [0, 1, 1, 13, 2, 7]
end
test "parses a multiline string with initial indent" do
# ' is at col 3, strip 2 spaces
source = " 'hello\n world\n end'"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.value == "hello\nworld\nend"
assert node.raw_string == "'hello\n world\n end'"
# Location of ' is [2,1,3]. Raw string length is 21.
# End location: offset 2+21=23. Line 3, Col 7.
assert node.location == [2, 1, 3, 23, 3, 7]
end
test "parses a multiline string with varied subsequent indents" do
# ' is at col 3, strip 2 spaces
source = " 'hello\n world\n more'"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
# " world" -> "world", " more" -> " more"
assert node.value == "hello\nworld\n more"
assert node.raw_string == "'hello\n world\n more'"
# Location of ' is [2,1,3]. Raw string length is 22.
# End location: offset 2+22=24. Line 3, Col 9.
assert node.location == [2, 1, 3, 24, 3, 9]
end
test "parses a string containing parentheses and other special chars" do
source = "' (foo) [bar] - 123 '"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.value == " (foo) [bar] - 123 "
assert node.raw_string == "' (foo) [bar] - 123 '"
end
test "parses a string within an S-expression" do
source = "('str' 1)"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node, s-expr, string, integer = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
s_expr_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(s_expr_node)
assert s_expr_node.ast_node_type == :s_expression
assert s_expr_node.parent_id == file_node.id
children_nodes = Enum.map(s_expr_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
string_node = Enum.find(children_nodes, &(&1.ast_node_type == :literal_string))
integer_node = Enum.find(children_nodes, &(&1.ast_node_type == :literal_integer))
refute is_nil(string_node)
refute is_nil(integer_node)
assert string_node.value == "str"
assert string_node.raw_string == "'str'"
assert string_node.parent_id == s_expr_node.id
# Location: `(` is [0,1,1]. ` ` is [1,1,2]. `s` is [2,1,3]. `t` is [3,1,4]. `r` is [4,1,5]. ` ` is [5,1,6].
# Token "'str'" starts at offset 1, line 1, col 2. Length 5. Ends offset 6, line 1, col 7.
assert string_node.location == [1, 1, 2, 6, 1, 7]
assert integer_node.value == 1
end
test "handles unclosed string literal" do
source = "'abc"
{:ok, nodes_map} = Parser.parse(source)
# file_node + string_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
node = TestHelpers.get_first_child_node(nodes_map)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.parsing_error == "Unclosed string literal"
# Content up to EOF, processed
assert node.value == "abc"
assert node.raw_string == "'abc"
# Location: ' is [0,1,1]. Raw string "'abc" length 4. Ends offset 4, line 1, col 5.
assert node.location == [0, 1, 1, 4, 1, 5]
end
test "handles unclosed string literal with newlines" do
# ' at col 3, strip 2
source = " 'hello\n world"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.parsing_error == "Unclosed string literal"
assert node.value == "hello\nworld"
assert node.raw_string == "'hello\n world"
# Location of ' is [2,1,3]. Raw string "'hello\n world" length 14.
# ' (2,1,3) -> (3,1,4)
# hello (3,1,4) -> (8,1,9)
# \n (8,1,9) -> (9,2,1)
# world (9,2,1) -> (16,2,8)
# End location: offset 2+14=16. Line 2, Col 8.
assert node.location == [2, 1, 3, 16, 2, 8]
end
test "string with only newlines and spaces, respecting indent" do
# ' at col 3, strip 2
source = " '\n \n '"
# Content: "\n \n "
# Lines: ["", " ", " "]
# Processed: "", " " (strip 2 from " "), "" (strip 2 from " ")
# Value: "\n \n"
{node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
assert node.ast_node_type == :literal_string
assert node.parent_id == file_node.id
assert node.value == "\n \n"
assert node.raw_string == "'\n \n '"
end
test "large s-expression parse test" do
# ' at col 3, strip 2
source = """
(defn my-function (x y)
(= asd 'first line
second line
third line
asdasd')
(+ x y))
"""
{:ok, nodes_map} = Parser.parse(source)
assert map_size(nodes_map) > 0
# IO.inspect(nodes_map, limit: :infinity)
end
end
describe "parse/2 - List Expressions" do
test "parses an empty list" do
source = "[]"
{:ok, nodes_map} = Parser.parse(source)
# file_node + list_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
list_node = TestHelpers.get_first_child_node(nodes_map)
assert list_node.ast_node_type == :list_expression
assert list_node.parent_id == file_node.id
assert list_node.children == []
# "[]"
assert list_node.location == [0, 1, 1, 2, 1, 3]
assert list_node.raw_string == "[]"
end
test "parses a simple list with integers" do
source = "[1 22 -3]"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node + 1 list_expression node + 3 integer nodes = 5 nodes
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
list_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(list_node)
assert list_node.ast_node_type == :list_expression
assert list_node.parent_id == file_node.id
assert length(list_node.children) == 3
children_nodes = Enum.map(list_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.value) == [1, 22, -3]
Enum.each(children_nodes, fn child ->
assert child.parent_id == list_node.id
assert child.ast_node_type == :literal_integer
end)
# "[1 22 -3]"
assert list_node.location == [0, 1, 1, 9, 1, 10]
end
test "parses a simple list with symbols" do
source = "[foo bar baz]"
{list_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# 1 file_node, 1 list_expr, 3 symbols = 5 nodes
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(list_node)
assert list_node.ast_node_type == :list_expression
assert list_node.parent_id == file_node.id
assert length(list_node.children) == 3
children_nodes = Enum.map(list_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.name) == ["foo", "bar", "baz"]
end
test "parses nested lists" do
source = "[a [b 1] c]"
{outer_list, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# Nodes: 1 file_node, outer_list, a, inner_list, c, b, 1 => 7 nodes
assert map_size(nodes_map) == 7
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(outer_list)
assert outer_list.ast_node_type == :list_expression
assert outer_list.parent_id == file_node.id
assert length(outer_list.children) == 3
# 'a'
child1 = TestHelpers.get_nth_child_node(nodes_map, 0, outer_list.id)
# '[b 1]'
inner_list = TestHelpers.get_nth_child_node(nodes_map, 1, outer_list.id)
# 'c'
child3 = TestHelpers.get_nth_child_node(nodes_map, 2, outer_list.id)
assert child1.ast_node_type == :symbol && child1.name == "a"
assert inner_list.ast_node_type == :list_expression
assert child3.ast_node_type == :symbol && child3.name == "c"
assert inner_list.parent_id == outer_list.id
assert length(inner_list.children) == 2
# 'b'
grandchild1 = TestHelpers.get_nth_child_node(nodes_map, 0, inner_list.id)
# '1'
grandchild2 = TestHelpers.get_nth_child_node(nodes_map, 1, inner_list.id)
assert grandchild1.ast_node_type == :symbol && grandchild1.name == "b"
assert grandchild2.ast_node_type == :literal_integer && grandchild2.value == 1
end
test "parses lists with varied spacing" do
source = "[ foo 1\nbar ]"
{list_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(list_node)
assert list_node.ast_node_type == :list_expression
assert list_node.parent_id == file_node.id
assert length(list_node.children) == 3
children_names_values =
Enum.map(list_node.children, fn id ->
node = TestHelpers.get_node_by_id(nodes_map, id)
if node.ast_node_type == :symbol, do: node.name, else: node.value
end)
assert children_names_values == ["foo", 1, "bar"]
end
test "handles unclosed list" do
source = "[foo bar"
{list_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# Expect 1 file_node, 1 list_expression node (error), 2 symbol nodes = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(list_node)
assert list_node.ast_node_type == :list_expression
assert list_node.parent_id == file_node.id
assert list_node.parsing_error == "Unclosed list"
# foo, bar
assert length(list_node.children) == 2
# "[foo bar"
assert list_node.location == [0, 1, 1, 8, 1, 9]
end
test "handles unexpected closing square bracket at top level" do
source = "foo ] bar"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node, foo, error_node_for_], bar = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
top_level_children =
Enum.map(file_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
error_node =
Enum.find(top_level_children, &(&1.ast_node_type == :unknown && &1.raw_string == "]"))
refute is_nil(error_node)
assert error_node.parent_id == file_node.id
assert error_node.parsing_error == "Unexpected ']'"
# location of "]"
assert error_node.location == [4, 1, 5, 5, 1, 6]
symbol_foo =
Enum.find(top_level_children, &(&1.ast_node_type == :symbol && &1.name == "foo"))
refute is_nil(symbol_foo)
assert symbol_foo.parent_id == file_node.id
symbol_bar =
Enum.find(top_level_children, &(&1.ast_node_type == :symbol && &1.name == "bar"))
refute is_nil(symbol_bar)
assert symbol_bar.parent_id == file_node.id
end
test "parses a list with mixed elements including strings, S-expressions, and other lists" do
source = "[1 'hello' (a b) [x y] 'end']"
{:ok, nodes_map} = Parser.parse(source)
# Expected items: 1 outer list, 1 int, 1 str, 1 s-expr (with 2 sym children), 1 inner list (with 2 sym children), 1 str
# Node counts: outer_list (1) + int (1) + str (1) + s-expr (1) + sym_a (1) + sym_b (1) + inner_list (1) + sym_x (1) + sym_y (1) + str_end (1) = 10 nodes
# Plus 1 file_node = 11 nodes
assert map_size(nodes_map) == 11
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
outer_list_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(outer_list_node)
assert outer_list_node.ast_node_type == :list_expression
assert outer_list_node.parent_id == file_node.id
assert length(outer_list_node.children) == 5
children = Enum.map(outer_list_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
# Child 1: Integer 1
assert Enum.at(children, 0).ast_node_type == :literal_integer
assert Enum.at(children, 0).value == 1
# Child 2: String 'hello'
assert Enum.at(children, 1).ast_node_type == :literal_string
assert Enum.at(children, 1).value == "hello"
# Child 3: S-expression (a b)
s_expr_child = Enum.at(children, 2)
assert s_expr_child.ast_node_type == :s_expression
assert length(s_expr_child.children) == 2
s_expr_children =
Enum.map(s_expr_child.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(s_expr_children, & &1.name) == ["a", "b"]
# Child 4: List [x y]
inner_list_child = Enum.at(children, 3)
assert inner_list_child.ast_node_type == :list_expression
assert length(inner_list_child.children) == 2
inner_list_children =
Enum.map(inner_list_child.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(inner_list_children, & &1.name) == ["x", "y"]
# Child 5: String 'end'
assert Enum.at(children, 4).ast_node_type == :literal_string
assert Enum.at(children, 4).value == "end"
end
test "symbol cannot contain brackets" do
# This should be parsed as symbol "foo[bar]" with current regex
source = "foo[bar]"
# After regex change to exclude brackets from symbols:
# It should be symbol "foo", then an unclosed list "[", then symbol "bar", then error for "]"
# Or, if we want `foo[bar]` to be an error or specific construct, tokenizer needs more rules.
# For now, with `[^\s\(\)\[\]]+`, "foo[bar]" is not a single symbol.
# It would be: "foo" (symbol), then "[" (start list), then "bar" (symbol), then error (unclosed list).
# Let's test "foo["
source1 = "foo["
{:ok, nodes_map1} = Parser.parse(source1)
# 1 file_node, "foo" symbol, "[" (unclosed list_expression) = 3 nodes
assert map_size(nodes_map1) == 3
file_node1 = Enum.find(Map.values(nodes_map1), &(&1.ast_node_type == :file))
refute is_nil(file_node1)
children1 = Enum.map(file_node1.children, &TestHelpers.get_node_by_id(nodes_map1, &1))
foo_node = Enum.find(children1, &(&1.name == "foo"))
list_node = Enum.find(children1, &(&1.ast_node_type == :list_expression))
refute is_nil(foo_node)
assert foo_node.parent_id == file_node1.id
refute is_nil(list_node)
assert list_node.parent_id == file_node1.id
assert list_node.parsing_error == "Unclosed list"
assert list_node.children == []
# Test "foo[bar"
source2 = "foo[bar"
{:ok, nodes_map2} = Parser.parse(source2)
# 1 file_node, "foo" symbol, "[" (list_expression), "bar" symbol inside list = 4 nodes
assert map_size(nodes_map2) == 4
file_node2 = Enum.find(Map.values(nodes_map2), &(&1.ast_node_type == :file))
refute is_nil(file_node2)
children2 = Enum.map(file_node2.children, &TestHelpers.get_node_by_id(nodes_map2, &1))
foo_node2 = Enum.find(children2, &(&1.name == "foo"))
list_node2 = Enum.find(children2, &(&1.ast_node_type == :list_expression))
refute is_nil(foo_node2)
assert foo_node2.parent_id == file_node2.id
refute is_nil(list_node2)
assert list_node2.parent_id == file_node2.id
assert list_node2.parsing_error == "Unclosed list"
assert length(list_node2.children) == 1
bar_node_id = hd(list_node2.children)
bar_node = TestHelpers.get_node_by_id(nodes_map2, bar_node_id)
assert bar_node.name == "bar"
assert bar_node.parent_id == list_node2.id
end
end
end

267
test/til/tuple_test.exs Normal file
View File

@ -0,0 +1,267 @@
defmodule Til.TupleParserTest do
use ExUnit.Case, async: true
alias Til.Parser
alias Til.TestHelpers
describe "parse/2 - Tuple Expressions" do
test "parses an empty tuple" do
source = "{}"
{:ok, nodes_map} = Parser.parse(source)
# file_node + tuple_node
assert map_size(nodes_map) == 2
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
tuple_node = TestHelpers.get_first_child_node(nodes_map)
assert tuple_node.ast_node_type == :tuple_expression
assert tuple_node.parent_id == file_node.id
assert tuple_node.children == []
# "{}"
assert tuple_node.location == [0, 1, 1, 2, 1, 3]
assert tuple_node.raw_string == "{}"
end
test "parses a simple tuple with integers" do
source = "{1 22 -3}"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node + 1 tuple_expression node + 3 integer nodes = 5 nodes
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
tuple_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(tuple_node)
assert tuple_node.ast_node_type == :tuple_expression
assert tuple_node.parent_id == file_node.id
assert length(tuple_node.children) == 3
children_nodes = Enum.map(tuple_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.value) == [1, 22, -3]
Enum.each(children_nodes, fn child ->
assert child.parent_id == tuple_node.id
assert child.ast_node_type == :literal_integer
end)
# "{1 22 -3}"
assert tuple_node.location == [0, 1, 1, 9, 1, 10]
end
test "parses a simple tuple with symbols" do
source = "{foo bar baz}"
{tuple_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# 1 file_node, 1 tuple_expr, 3 symbols = 5 nodes
assert map_size(nodes_map) == 5
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(tuple_node)
assert tuple_node.ast_node_type == :tuple_expression
assert tuple_node.parent_id == file_node.id
assert length(tuple_node.children) == 3
children_nodes = Enum.map(tuple_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(children_nodes, & &1.name) == ["foo", "bar", "baz"]
end
test "parses nested tuples" do
source = "{a {b 1} c}"
{outer_tuple, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# Nodes: 1 file_node, outer_tuple, a, inner_tuple, c, b, 1 => 7 nodes
assert map_size(nodes_map) == 7
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(outer_tuple)
assert outer_tuple.ast_node_type == :tuple_expression
assert outer_tuple.parent_id == file_node.id
assert length(outer_tuple.children) == 3
child1 = TestHelpers.get_nth_child_node(nodes_map, 0, outer_tuple.id)
inner_tuple = TestHelpers.get_nth_child_node(nodes_map, 1, outer_tuple.id)
child3 = TestHelpers.get_nth_child_node(nodes_map, 2, outer_tuple.id)
assert child1.ast_node_type == :symbol && child1.name == "a"
assert inner_tuple.ast_node_type == :tuple_expression
assert child3.ast_node_type == :symbol && child3.name == "c"
assert inner_tuple.parent_id == outer_tuple.id
assert length(inner_tuple.children) == 2
grandchild1 = TestHelpers.get_nth_child_node(nodes_map, 0, inner_tuple.id)
grandchild2 = TestHelpers.get_nth_child_node(nodes_map, 1, inner_tuple.id)
assert grandchild1.ast_node_type == :symbol && grandchild1.name == "b"
assert grandchild2.ast_node_type == :literal_integer && grandchild2.value == 1
end
test "parses tuples with varied spacing" do
source = "{ foo 1\nbar }"
{tuple_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(tuple_node)
assert tuple_node.ast_node_type == :tuple_expression
assert tuple_node.parent_id == file_node.id
assert length(tuple_node.children) == 3
children_names_values =
Enum.map(tuple_node.children, fn id ->
node = TestHelpers.get_node_by_id(nodes_map, id)
if node.ast_node_type == :symbol, do: node.name, else: node.value
end)
assert children_names_values == ["foo", 1, "bar"]
end
test "handles unclosed tuple" do
source = "{foo bar"
{tuple_node, nodes_map} = TestHelpers.parse_and_get_first_node(source)
# Expect 1 file_node, 1 tuple_expression node (error), 2 symbol nodes = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
refute is_nil(tuple_node)
assert tuple_node.ast_node_type == :tuple_expression
assert tuple_node.parent_id == file_node.id
assert tuple_node.parsing_error == "Unclosed tuple"
# foo, bar
assert length(tuple_node.children) == 2
# "{foo bar"
assert tuple_node.location == [0, 1, 1, 8, 1, 9]
end
test "handles unexpected closing curly brace at top level" do
source = "foo } bar"
{:ok, nodes_map} = Parser.parse(source)
# 1 file_node, foo, error_node_for_}, bar = 4 nodes
assert map_size(nodes_map) == 4
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
top_level_children =
Enum.map(file_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
error_node =
Enum.find(top_level_children, &(&1.ast_node_type == :unknown && &1.raw_string == "}"))
refute is_nil(error_node)
assert error_node.parent_id == file_node.id
assert error_node.parsing_error == "Unexpected '}'"
# location of "}"
assert error_node.location == [4, 1, 5, 5, 1, 6]
symbol_foo =
Enum.find(top_level_children, &(&1.ast_node_type == :symbol && &1.name == "foo"))
refute is_nil(symbol_foo)
assert symbol_foo.parent_id == file_node.id
symbol_bar =
Enum.find(top_level_children, &(&1.ast_node_type == :symbol && &1.name == "bar"))
refute is_nil(symbol_bar)
assert symbol_bar.parent_id == file_node.id
end
test "parses a tuple with mixed elements including strings, S-expressions, lists, and other tuples" do
source = "{1 'hello' (a b) [x y] {z} 'end'}"
{:ok, nodes_map} = Parser.parse(source)
# Expected items: 1 outer tuple, 1 int, 1 str, 1 s-expr (2 children), 1 list (2 children), 1 inner tuple (1 child), 1 str_end
# Node counts: outer_tuple (1) + int (1) + str (1) + s-expr (1) + sym_a (1) + sym_b (1) + list (1) + sym_x (1) + sym_y (1) + inner_tuple (1) + sym_z (1) + str_end (1) = 12 nodes
# Plus 1 file_node = 13 nodes
assert map_size(nodes_map) == 13
file_node = Enum.find(Map.values(nodes_map), &(&1.ast_node_type == :file))
refute is_nil(file_node)
outer_tuple_node = TestHelpers.get_first_child_node(nodes_map)
refute is_nil(outer_tuple_node)
assert outer_tuple_node.ast_node_type == :tuple_expression
assert outer_tuple_node.parent_id == file_node.id
assert length(outer_tuple_node.children) == 6
children = Enum.map(outer_tuple_node.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.at(children, 0).ast_node_type == :literal_integer
assert Enum.at(children, 0).value == 1
assert Enum.at(children, 1).ast_node_type == :literal_string
assert Enum.at(children, 1).value == "hello"
s_expr_child = Enum.at(children, 2)
assert s_expr_child.ast_node_type == :s_expression
assert length(s_expr_child.children) == 2
s_expr_children =
Enum.map(s_expr_child.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(s_expr_children, & &1.name) == ["a", "b"]
list_child = Enum.at(children, 3)
assert list_child.ast_node_type == :list_expression
assert length(list_child.children) == 2
list_children = Enum.map(list_child.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(list_children, & &1.name) == ["x", "y"]
inner_tuple_child = Enum.at(children, 4)
assert inner_tuple_child.ast_node_type == :tuple_expression
assert length(inner_tuple_child.children) == 1
inner_tuple_children =
Enum.map(inner_tuple_child.children, &TestHelpers.get_node_by_id(nodes_map, &1))
assert Enum.map(inner_tuple_children, & &1.name) == ["z"]
assert Enum.at(children, 5).ast_node_type == :literal_string
assert Enum.at(children, 5).value == "end"
end
test "symbol cannot contain curly braces" do
# Test "foo{"
source1 = "foo{"
{:ok, nodes_map1} = Parser.parse(source1)
# 1 file_node, "foo" symbol, "{" (unclosed tuple_expression) = 3 nodes
assert map_size(nodes_map1) == 3
file_node1 = Enum.find(Map.values(nodes_map1), &(&1.ast_node_type == :file))
refute is_nil(file_node1)
children1 = Enum.map(file_node1.children, &TestHelpers.get_node_by_id(nodes_map1, &1))
foo_node = Enum.find(children1, &(&1.name == "foo"))
tuple_node = Enum.find(children1, &(&1.ast_node_type == :tuple_expression))
refute is_nil(foo_node)
assert foo_node.parent_id == file_node1.id
refute is_nil(tuple_node)
assert tuple_node.parent_id == file_node1.id
assert tuple_node.parsing_error == "Unclosed tuple"
assert tuple_node.children == []
# Test "foo{bar"
source2 = "foo{bar"
{:ok, nodes_map2} = Parser.parse(source2)
# 1 file_node, "foo" symbol, "{" (tuple_expression), "bar" symbol inside tuple = 4 nodes
assert map_size(nodes_map2) == 4
file_node2 = Enum.find(Map.values(nodes_map2), &(&1.ast_node_type == :file))
refute is_nil(file_node2)
children2 = Enum.map(file_node2.children, &TestHelpers.get_node_by_id(nodes_map2, &1))
foo_node2 = Enum.find(children2, &(&1.name == "foo"))
tuple_node2 = Enum.find(children2, &(&1.ast_node_type == :tuple_expression))
refute is_nil(foo_node2)
assert foo_node2.parent_id == file_node2.id
refute is_nil(tuple_node2)
assert tuple_node2.parent_id == file_node2.id
assert tuple_node2.parsing_error == "Unclosed tuple"
assert length(tuple_node2.children) == 1
bar_node_id = hd(tuple_node2.children)
bar_node = TestHelpers.get_node_by_id(nodes_map2, bar_node_id)
assert bar_node.name == "bar"
assert bar_node.parent_id == tuple_node2.id
end
end
end

View File

@ -0,0 +1,291 @@
defmodule Til.TypeAnnotationTest do
use ExUnit.Case, async: true
alias Til.AstUtils
# alias Til.Parser # Unused
# alias Til.Typer # Unused
# Added alias
alias Til.TestHelpers
# --- Predefined Type Definitions for Assertions (without :id) ---
# These must match the definitions in Typer, excluding the :id field
# that Typer might add when interning.
@type_integer %{type_kind: :primitive, name: :integer}
@type_string %{type_kind: :primitive, name: :string}
@type_number %{type_kind: :primitive, name: :number}
@type_any %{type_kind: :primitive, name: :any}
# @type_nothing %{type_kind: :primitive, name: :nothing} # Unused
# @type_annotation_mismatch_error is replaced by a helper function
# Helper to create the expected *cleaned* type_annotation_mismatch error structure
defp type_error_type_annotation_mismatch(actual_type_clean, expected_type_clean) do
%{
type_kind: :error,
reason: :type_annotation_mismatch,
actual_type: actual_type_clean,
expected_type: expected_type_clean
}
end
# Union type for testing
# @type_integer_or_string %{ # Unused
# type_kind: :union,
# types: MapSet.new([@type_integer, @type_string])
# }
defp type_literal_int(val), do: %{type_kind: :literal, value: val}
defp type_literal_string(val), do: %{type_kind: :literal, value: val}
defp type_union(type_defs_list) do
%{type_kind: :union, types: MapSet.new(type_defs_list)}
end
describe "(the <type> <expr>) annotation" do
test "annotates a literal integer with its correct type" do
source = "(the integer 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
assert the_expr_node.ast_node_type == :s_expression
# The 'the' expression itself should have the annotated type
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, @type_integer)
# The inner literal should still have its literal type
literal_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, the_expr_node.id)
TestHelpers.assert_node_typed_as(literal_node, typed_nodes_map, type_literal_int(42))
end
test "annotates a literal integer with a supertype (number)" do
source = "(the number 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, @type_number)
end
test "annotates a literal string with its correct type" do
source = "(the string 'hello')"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, @type_string)
end
test "annotation mismatch: annotating integer literal as string results in error type" do
source = "(the string 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
# The 'the' expression should now be typed with an error type
# because 42 is not a subtype of string.
expected_error_def =
type_error_type_annotation_mismatch(type_literal_int(42), @type_string)
TestHelpers.assert_node_typed_as(
the_expr_node,
typed_nodes_map,
expected_error_def
)
end
test "assignment with annotated value: (= x (the integer 42))" do
source = "(= x (the integer 42))"
{assignment_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
assert assignment_node.ast_node_type == :s_expression
# The assignment expression's type is the type of its RHS (the annotated value)
TestHelpers.assert_node_typed_as(assignment_node, typed_nodes_map, @type_integer)
# The 'the' sub-expression
the_expr_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, assignment_node.id)
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, @type_integer)
# Check symbol 'x' in the environment (indirectly by typing another expression)
source_with_x = """
(= x (the integer 42))
x
"""
# 0 is assignment, 1 is 'x'
{x_usage_node, typed_nodes_map_vx} =
TestHelpers.typecheck_and_get_nth_node(source_with_x, 1)
assert x_usage_node.ast_node_type == :symbol
TestHelpers.assert_node_typed_as(x_usage_node, typed_nodes_map_vx, @type_integer)
end
test "annotating with 'any' type" do
source = "(the any 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, @type_any)
end
test "annotating with 'nothing' type (implies contradiction if expr is not nothing)" do
# 42 is not 'nothing'
source = "(the nothing 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
AstUtils.build_debug_ast_data(typed_nodes_map)
# The expression results in a type annotation mismatch error
# because 'literal 42' is not a subtype of 'nothing'.
type_nothing_clean = %{type_kind: :primitive, name: :nothing}
expected_error_def =
type_error_type_annotation_mismatch(type_literal_int(42), type_nothing_clean)
TestHelpers.assert_node_typed_as(
the_expr_node,
typed_nodes_map,
expected_error_def
)
end
test "unknown type symbol in annotation defaults to 'any'" do
# 'foobar' is not a known type
source = "(the foobar 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
# Defaulting to 'any' for unknown type specifiers for now
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, @type_any)
end
test "annotating an (if true 1 's') expression with (integer | string) union type" do
_source = "(the (union integer string) (if true 1 's'))"
# For the Typer to resolve (union integer string) correctly, we need to implement
# parsing/handling of such type specifiers first.
# For now, this test assumes `resolve_type_specifier_node` can handle it or we mock it.
# Let's adjust the test to use a known type that an `if` can produce,
# and then check subtyping against a broader union.
# Test: integer is subtype of (integer | string)
_source_int_subtype_of_union = "(the (union integer string) 42)"
# This requires `resolve_type_specifier_node` to handle `(union ...)` forms.
# For now, let's test the subtyping logic more directly by constructing types.
# The tests below will focus on `if` expressions producing unions and checking them.
end
test "if expression producing (literal 1 | literal 's') is subtype of (integer | string)" do
_source = """
(the (union integer string)
(if some_condition 1 's'))
"""
# This test requires:
# 1. `(union integer string)` to be resolvable by `resolve_type_specifier_node`.
# For now, we'll assume `Typer` needs to be taught to parse this.
# Let's simplify the annotation to a known primitive that acts as a supertype for the union.
# e.g. (the any (if ...))
#
# Let's test the type inference of `if` and then `(the ...)` with it.
# `(if x 1 "s")` should infer to `(union (literal 1) (literal "s"))` if x is unknown.
# Then `(the (union integer string) (if x 1 "s"))` should work.
# To make this testable *now* without changing `resolve_type_specifier_node` for `(union ...)`:
# We need a way to introduce a union type into the system that `is_subtype?` can then check.
# The `if` expression itself is the source of the union type.
# `(if some_condition 1 "foo")` will have type `Union(Literal(1), Literal("foo"))`
# We want to check if `Union(Literal(1), Literal("foo"))` is a subtype of `Union(Integer, String)`
# And also if `Union(Literal(1), Literal("foo"))` is a subtype of `Any`
# Setup:
# (= cond_val true) ; or some unknown symbol to make the `if` branch ambiguous
# (the some_union_type (if cond_val 1 "hello"))
# where `some_union_type` is a type that `Union(Literal(1), Literal("hello"))` is a subtype of.
# Test 1: Union is subtype of Any
source_if_any = """
(= cond some_unknown_symbol_for_ambiguity)
(the any (if cond 1 "hello"))
"""
{the_expr_node_any, typed_nodes_map_any} =
TestHelpers.typecheck_and_get_nth_node(source_if_any, 1)
TestHelpers.assert_node_typed_as(the_expr_node_any, typed_nodes_map_any, @type_any)
# Test 2: Literal integer is subtype of (integer | string)
# This requires `(union integer string)` to be a recognized type specifier.
# We will add this to `resolve_type_specifier_node` in Typer later.
# For now, let's assume we have a way to define such a type.
# The following tests are more conceptual for the subtyping logic itself,
# rather than the full `(the (union ...) ...)` syntax.
# Let's test the `if` expression's inferred type first.
source_if_expr = "(= cond some_ambiguous_val) (if cond 1 'one')"
{if_node, typed_nodes_map_if} = TestHelpers.typecheck_and_get_nth_node(source_if_expr, 1)
expected_if_type =
type_union([type_literal_int(1), type_literal_string("one")])
TestHelpers.assert_node_typed_as(if_node, typed_nodes_map_if, expected_if_type)
# Now, let's assume `(the (union integer string) ...)` works by enhancing `resolve_type_specifier_node`
# This part of the test will *fail* until `resolve_type_specifier_node` is updated.
# We'll mark it as a future improvement or adjust `Typer` in a subsequent step.
# For now, we are testing the subtyping rules given the types are correctly resolved.
# To test subtyping with (the X Y) where Y is a union:
# (the super_type (if cond val1 val2))
# Example: (the any (if cond 1 "s")) -- already covered, works.
# Example: (the number (if cond 1 2.0))
# (if cond 1 2.0) -> Union(Literal(1), Literal(2.0))
# Union(Literal(1), Literal(2.0)) <: Number ?
# Literal(1) <: Number (true) AND Literal(2.0) <: Number (true) -> true
source_if_number = """
(= cond some_ambiguous_val)
(the number (if cond 1 2))
"""
{the_expr_node_num, typed_nodes_map_num} =
TestHelpers.typecheck_and_get_nth_node(source_if_number, 1)
TestHelpers.assert_node_typed_as(the_expr_node_num, typed_nodes_map_num, @type_number)
# Example: (the integer (if cond 1 2))
# (if cond 1 2) -> Union(Literal(1), Literal(2))
# Union(Literal(1), Literal(2)) <: Integer ?
# Literal(1) <: Integer (true) AND Literal(2) <: Integer (true) -> true
source_if_integer = """
(= cond some_ambiguous_val)
(the integer (if cond 1 2))
"""
{the_expr_node_int, typed_nodes_map_int} =
TestHelpers.typecheck_and_get_nth_node(source_if_integer, 1)
TestHelpers.assert_node_typed_as(the_expr_node_int, typed_nodes_map_int, @type_integer)
# Example: (the string (if cond 1 "s")) -> should be error
# (if cond 1 "s") -> Union(Literal(1), Literal("s"))
# Union(Literal(1), Literal("s")) <: String ?
# Literal(1) <: String (false) -> false
source_if_string_error = """
(= cond some_ambiguous_val)
(the string (if cond 1 's'))
"""
{the_expr_node_str_err, typed_nodes_map_str_err} =
TestHelpers.typecheck_and_get_nth_node(source_if_string_error, 1)
# actual type of (if cond 1 "s") is Union(Literal(1), Literal("s"))
# expected type is String
actual_type_clean = type_union([type_literal_int(1), type_literal_string("s")])
expected_annotated_clean = @type_string
expected_error_def =
type_error_type_annotation_mismatch(actual_type_clean, expected_annotated_clean)
TestHelpers.assert_node_typed_as(
the_expr_node_str_err,
typed_nodes_map_str_err,
expected_error_def
)
end
# Test for: A <: (A | B)
test "integer is subtype of (integer | string) - requires (union ...) type specifier" do
source = "(the (union integer string) 42)"
{the_expr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
expected_union_type = type_union([@type_integer, @type_string])
TestHelpers.assert_node_typed_as(the_expr_node, typed_nodes_map, expected_union_type)
end
end
end

257
test/til/type_atom_test.exs Normal file
View File

@ -0,0 +1,257 @@
defmodule Til.TypeAtomTest do
use ExUnit.Case, async: true
# alias Til.Parser # Unused
# alias Til.Typer # Unused
alias Til.Typer.Types
alias Til.Typer.SubtypeChecker
alias Til.Typer.Interner
import Til.TestHelpers
# Helper for literal atom type definition (cleaned, without :id)
defp type_literal_atom(val) when is_atom(val) do
%{type_kind: :literal, value: val}
end
# Helper for primitive atom type definition (cleaned, without :id)
defp type_primitive_atom do
%{type_kind: :primitive, name: :atom}
end
# Helper for primitive integer type definition (cleaned, without :id)
defp type_primitive_integer do
%{type_kind: :primitive, name: :integer}
end
# Helper for type annotation mismatch error (cleaned structure)
defp type_error_type_annotation_mismatch(actual_type_clean, expected_type_clean) do
%{
type_kind: :error,
reason: :type_annotation_mismatch,
actual_type: actual_type_clean,
expected_type: expected_type_clean
}
end
describe "Atom Literal Typing" do
test "a literal atom is typed as a literal atom" do
source = ":my_atom"
{node, typed_nodes_map} = typecheck_and_get_first_node(source)
assert_node_typed_as(node, typed_nodes_map, type_literal_atom(:my_atom))
end
test "a different literal atom is typed correctly" do
source = ":another"
{node, typed_nodes_map} = typecheck_and_get_first_node(source)
assert_node_typed_as(node, typed_nodes_map, type_literal_atom(:another))
end
end
describe "'the' expression with 'atom' type specifier" do
test "(the atom :some_atom) is typed as primitive atom" do
source = "(the atom :some_atom)"
{the_expr_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# The 'the' expression itself should have the annotated type
assert_node_typed_as(the_expr_node, typed_nodes_map, type_primitive_atom())
# Verify the inner atom is typed as literal
# Children of s_expr: 'the' symbol, 'atom' symbol, ':some_atom' atom_literal
inner_atom_node_id = Enum.at(the_expr_node.children, 2)
inner_atom_node = get_node_by_id(typed_nodes_map, inner_atom_node_id)
assert_node_typed_as(inner_atom_node, typed_nodes_map, type_literal_atom(:some_atom))
end
test "(the atom 123) results in a type annotation mismatch error" do
source = "(the atom 123)"
{the_expr_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# Literal integer
actual_clean = %{type_kind: :literal, value: 123}
# Primitive atom
expected_clean = type_primitive_atom()
expected_error_def = type_error_type_annotation_mismatch(actual_clean, expected_clean)
assert_node_typed_as(the_expr_node, typed_nodes_map, expected_error_def)
end
test "(the atom \"a string\") results in a type annotation mismatch error" do
source = "(the atom 'a string')"
{the_expr_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# Literal string
actual_clean = %{type_kind: :literal, value: "a string"}
# Primitive atom
expected_clean = type_primitive_atom()
expected_error_def = type_error_type_annotation_mismatch(actual_clean, expected_clean)
assert_node_typed_as(the_expr_node, typed_nodes_map, expected_error_def)
end
end
describe "Atom Subtyping" do
# Setup a nodes_map with predefined types for subtyping checks
defp setup_nodes_map_for_subtyping do
Interner.populate_known_types(%{})
end
test "literal atom is a subtype of primitive atom" do
nodes_map_initial = setup_nodes_map_for_subtyping()
# This is %{type_kind: :literal, value: :foo}
literal_foo_raw = type_literal_atom(:foo)
# Intern the literal type
{foo_key, nodes_map_after_foo} =
Interner.get_or_intern_type(literal_foo_raw, nodes_map_initial)
literal_foo_interned = Map.get(nodes_map_after_foo, foo_key)
refute is_nil(literal_foo_interned), "Interned literal :foo type should exist"
# Get the interned primitive type from the map
primitive_atom_interned = Map.get(nodes_map_after_foo, Types.primitive_type_key(:atom))
refute is_nil(primitive_atom_interned), "Primitive atom type should be in nodes_map"
assert SubtypeChecker.is_subtype?(
literal_foo_interned,
primitive_atom_interned,
nodes_map_after_foo
)
end
test "literal atom is a subtype of itself" do
nodes_map_initial = setup_nodes_map_for_subtyping()
literal_foo_raw = type_literal_atom(:foo)
{foo_key, nodes_map_after_foo} =
Interner.get_or_intern_type(literal_foo_raw, nodes_map_initial)
literal_foo_interned = Map.get(nodes_map_after_foo, foo_key)
refute is_nil(literal_foo_interned), "Interned literal :foo type should exist"
assert SubtypeChecker.is_subtype?(
literal_foo_interned,
literal_foo_interned,
nodes_map_after_foo
)
end
test "literal atom :foo is not a subtype of literal atom :bar" do
nodes_map_initial = setup_nodes_map_for_subtyping()
literal_foo_raw = type_literal_atom(:foo)
literal_bar_raw = type_literal_atom(:bar)
{foo_key, nodes_map_temp} = Interner.get_or_intern_type(literal_foo_raw, nodes_map_initial)
literal_foo_interned = Map.get(nodes_map_temp, foo_key)
refute is_nil(literal_foo_interned)
{bar_key, nodes_map_final} = Interner.get_or_intern_type(literal_bar_raw, nodes_map_temp)
literal_bar_interned = Map.get(nodes_map_final, bar_key)
refute is_nil(literal_bar_interned)
refute SubtypeChecker.is_subtype?(
literal_foo_interned,
literal_bar_interned,
nodes_map_final
)
end
test "primitive integer is not a subtype of primitive atom" do
nodes_map = setup_nodes_map_for_subtyping()
primitive_int_interned = Map.get(nodes_map, Types.primitive_type_key(:integer))
refute is_nil(primitive_int_interned), "Primitive integer type should be in nodes_map"
primitive_atom_interned = Map.get(nodes_map, Types.primitive_type_key(:atom))
refute is_nil(primitive_atom_interned), "Primitive atom type should be in nodes_map"
refute SubtypeChecker.is_subtype?(
primitive_int_interned,
primitive_atom_interned,
nodes_map
)
end
test "primitive atom is not a subtype of primitive integer" do
nodes_map = setup_nodes_map_for_subtyping()
primitive_atom_interned = Map.get(nodes_map, Types.primitive_type_key(:atom))
refute is_nil(primitive_atom_interned), "Primitive atom type should be in nodes_map"
primitive_int_interned = Map.get(nodes_map, Types.primitive_type_key(:integer))
refute is_nil(primitive_int_interned), "Primitive integer type should be in nodes_map"
refute SubtypeChecker.is_subtype?(
primitive_atom_interned,
primitive_int_interned,
nodes_map
)
end
test "literal atom is a subtype of :any" do
nodes_map_initial = setup_nodes_map_for_subtyping()
literal_foo_raw = type_literal_atom(:foo)
{foo_key, nodes_map_after_foo} =
Interner.get_or_intern_type(literal_foo_raw, nodes_map_initial)
literal_foo_interned = Map.get(nodes_map_after_foo, foo_key)
refute is_nil(literal_foo_interned), "Interned literal :foo type should exist"
any_type_interned = Map.get(nodes_map_after_foo, Types.any_type_key())
refute is_nil(any_type_interned), ":any type should be in nodes_map"
assert SubtypeChecker.is_subtype?(
literal_foo_interned,
any_type_interned,
nodes_map_after_foo
)
end
test "primitive atom is a subtype of :any" do
nodes_map = setup_nodes_map_for_subtyping()
primitive_atom_interned = Map.get(nodes_map, Types.primitive_type_key(:atom))
refute is_nil(primitive_atom_interned), "Primitive atom type should be in nodes_map"
any_type_interned = Map.get(nodes_map, Types.any_type_key())
refute is_nil(any_type_interned), ":any type should be in nodes_map"
assert SubtypeChecker.is_subtype?(primitive_atom_interned, any_type_interned, nodes_map)
end
test ":nothing is a subtype of primitive atom" do
nodes_map = setup_nodes_map_for_subtyping()
nothing_type_interned = Map.get(nodes_map, Types.primitive_type_key(:nothing))
refute is_nil(nothing_type_interned), ":nothing type should be in nodes_map"
primitive_atom_interned = Map.get(nodes_map, Types.primitive_type_key(:atom))
refute is_nil(primitive_atom_interned), "Primitive atom type should be in nodes_map"
assert SubtypeChecker.is_subtype?(nothing_type_interned, primitive_atom_interned, nodes_map)
end
test ":nothing is a subtype of literal atom :foo (this is debatable, but current rule is :nothing <: all)" do
nodes_map_initial = setup_nodes_map_for_subtyping()
nothing_type_interned = Map.get(nodes_map_initial, Types.primitive_type_key(:nothing))
refute is_nil(nothing_type_interned), ":nothing type should be in nodes_map"
literal_foo_raw = type_literal_atom(:foo)
{foo_key, nodes_map_after_foo} =
Interner.get_or_intern_type(literal_foo_raw, nodes_map_initial)
literal_foo_interned = Map.get(nodes_map_after_foo, foo_key)
refute is_nil(literal_foo_interned), "Interned literal :foo type should exist"
# According to Rule 3: :nothing is a subtype of everything
assert SubtypeChecker.is_subtype?(
nothing_type_interned,
literal_foo_interned,
nodes_map_after_foo
)
end
end
end

View File

@ -0,0 +1,135 @@
# defmodule Til.TypeFunctionAllocationTest do
# use ExUnit.Case, async: true
#
# alias Til.Typer.Interner
# alias Til.Parser
# alias Til.Typer
# # alias Til.Typer.SubtypeChecker # Not directly used in all tests yet
# import Til.TestHelpers
#
# # Helper functions for expected error types
# defp type_error_not_a_function(actual_operator_type_id_clean) do
# %{
# type_kind: :error,
# reason: :not_a_function,
# actual_operator_type_id: actual_operator_type_id_clean
# }
# end
#
# defp type_error_arity_mismatch(expected_arity, actual_arity, function_type_id_clean) do
# %{
# type_kind: :error,
# reason: :arity_mismatch,
# expected_arity: expected_arity,
# actual_arity: actual_arity,
# function_type_id: function_type_id_clean
# }
# end
#
# describe "Phase 3: Basic Monomorphic Function Calls" do
# test "types a call to a literal lambda with no args: ((fn () 1))" do
# {call_node, typed_nodes_map} = typecheck_and_get_first_node("((fn () 1))")
# expected_return_type_raw = %{type_kind: :literal, value: 1}
# assert_node_typed_as(call_node, typed_nodes_map, expected_return_type_raw)
# end
#
# test "types a call to a literal lambda with one arg: ((fn ((x string) string) x) 'hello')" do
# source = "((fn ((x string) string) x) 'hello')"
# {call_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# expected_return_type_raw = %{type_kind: :literal, value: "hello"}
# assert_node_typed_as(call_node, typed_nodes_map, expected_return_type_raw)
# end
#
# test "types a call to a literal lambda with multiple args: ((fn ((a integer) (b atom) atom) b) 10 :foo)" do
# source = "((fn ((a integer) (b atom) atom) b) 10 :foo)"
# {call_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# expected_return_type_raw = %{type_kind: :literal, value: :foo}
# assert_node_typed_as(call_node, typed_nodes_map, expected_return_type_raw)
# end
#
# test "error: calling a non-function (integer): (1 2 3)" do
# {call_node, typed_nodes_map} = typecheck_and_get_first_node("(1 2 3)")
# raw_literal_int_1_type = %{type_kind: :literal, value: 1}
# # Interner.populate_known_types is called by typecheck_and_get_first_node
# # so typed_nodes_map should already have primitives.
# # We need to intern the specific literal type to get its key.
# {literal_int_1_key, _final_map_after_intern} =
# Interner.get_or_intern_type(raw_literal_int_1_type, typed_nodes_map)
#
# expected_error_raw = type_error_not_a_function(literal_int_1_key)
# assert_node_typed_as(call_node, typed_nodes_map, expected_error_raw)
# end
#
# test "error: arity mismatch - too many arguments: ((fn () 1) 123)" do
# {call_node, typed_nodes_map} = typecheck_and_get_first_node("((fn () 1) 123)")
# # The S-expression is the call_node. Its first child is the lambda S-expression.
# # The lambda S-expression node itself is what gets typed as a function.
# # The parser creates a :lambda_expression node from the (fn () 1) S-expression.
# # So, the first child of call_node is the :lambda_expression node.
# lambda_node = get_first_child_node(typed_nodes_map, call_node.id)
# function_type_id = lambda_node.type_id
#
# expected_error_raw = type_error_arity_mismatch(0, 1, function_type_id)
# assert_node_typed_as(call_node, typed_nodes_map, expected_error_raw)
# end
#
# test "error: arity mismatch - too few arguments: ((fn (x y) x) 1)" do
# {call_node, typed_nodes_map} = typecheck_and_get_first_node("((fn (x y integer) x) 1)")
# lambda_node = get_first_child_node(typed_nodes_map, call_node.id)
# function_type_id = lambda_node.type_id
#
# expected_error_raw = type_error_arity_mismatch(2, 1, function_type_id)
# assert_node_typed_as(call_node, typed_nodes_map, expected_error_raw)
# end
#
# test "types a call where operator is an S-expression evaluating to a function: ((if true (fn () :ok) (fn () :err)))" do
# source = "((if true (fn () :ok) (fn () :err)))"
# {call_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# expected_return_type_raw = %{type_kind: :literal, value: :ok}
# assert_node_typed_as(call_node, typed_nodes_map, expected_return_type_raw)
# end
#
# test "types a call where operator is a symbol bound to a function: (= id (fn ((z integer) integer) z)) (id 42)" do
# source = """
# (= id (fn ((z integer) integer) z))
# (id 42)
# """
#
# {:ok, parsed_map} = Parser.parse(source)
# {:ok, typed_map} = Typer.type_check(parsed_map)
#
# file_node = get_file_node_from_map(typed_map)
# call_node_id = Enum.at(file_node.children, 1)
# call_node = get_node_by_id(typed_map, call_node_id)
#
# expected_return_type_raw = %{type_kind: :literal, value: 42}
# assert_node_typed_as(call_node, typed_map, expected_return_type_raw)
# end
#
# # Optional: Argument type mismatch test (deferred as lambdas currently take `any`)
# # test "error: argument type mismatch: ((the (function integer string) (fn (x) (the string x))) 'bad_arg')" do
# # source = "((the (function integer string) (fn (x) (the string x))) 'bad_arg')"
# # {call_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# #
# # operator_node = get_first_child_node(typed_nodes_map, call_node.id) # This is the 'the' expression
# # function_type_id = operator_node.type_id # Type of the 'the' expression is the function type
# #
# # # Get interned key for literal string 'bad_arg'
# # raw_actual_arg_type = %{type_kind: :literal, value: "bad_arg"}
# # {actual_arg_type_key, map_with_actual_arg} = Interner.get_or_intern_type(raw_actual_arg_type, typed_nodes_map)
# #
# # # Expected arg type is integer (from the (function integer string) annotation)
# # raw_expected_arg_type = Types.get_primitive_type(:integer)
# # {expected_arg_type_key, _final_map} = Interner.get_or_intern_type(raw_expected_arg_type, map_with_actual_arg)
# #
# # expected_error_raw =
# # type_error_argument_type_mismatch(
# # 0, # First argument
# # expected_arg_type_key,
# # actual_arg_type_key,
# # function_type_id
# # )
# # assert_node_typed_as(call_node, typed_nodes_map, expected_error_raw)
# # end
# end
# end

View File

@ -0,0 +1,332 @@
# defmodule Til.TypeFunctionTest do
# use ExUnit.Case, async: true
#
# alias Til.Typer.Types
# alias Til.Typer.Interner
#
# # Helper to create a raw function type definition
# defp type_function_raw(arg_type_defs, return_type_def, type_param_defs \\ []) do
# %{
# type_kind: :function,
# arg_types: arg_type_defs,
# return_type: return_type_def,
# # Initially empty for monomorphic
# type_params: type_param_defs
# }
# end
#
# # Helper to create an interned function type definition
# # (what we expect after interning the raw definition's components)
# defp type_function_interned(id_key, arg_type_ids, return_type_id, type_param_ids \\ []) do
# %{
# type_kind: :function,
# id: id_key,
# arg_types: arg_type_ids,
# return_type: return_type_id,
# type_params: type_param_ids
# }
# end
#
# describe "Phase 1: Function Type Representation & Interning" do
# test "interns a basic monomorphic function type (integer -> string)" do
# nodes_map = Interner.populate_known_types(%{})
#
# raw_integer_type = Types.get_primitive_type(:integer)
# raw_string_type = Types.get_primitive_type(:string)
#
# # Intern primitive types first to get their keys
# {integer_type_key, nodes_map_after_int} =
# Interner.get_or_intern_type(raw_integer_type, nodes_map)
#
# {string_type_key, nodes_map_after_str} =
# Interner.get_or_intern_type(raw_string_type, nodes_map_after_int)
#
# # Raw function type using the *definitions* of its components
# raw_func_type = type_function_raw([raw_integer_type], raw_string_type)
#
# # Intern the function type
# {func_type_key, final_nodes_map} =
# Interner.get_or_intern_type(raw_func_type, nodes_map_after_str)
#
# refute is_nil(func_type_key)
# assert func_type_key != integer_type_key
# assert func_type_key != string_type_key
#
# interned_func_def = Map.get(final_nodes_map, func_type_key)
#
# expected_interned_func_def =
# type_function_interned(
# func_type_key,
# # Expects keys of interned arg types
# [integer_type_key],
# # Expects key of interned return type
# string_type_key
# )
#
# assert interned_func_def == expected_interned_func_def
# end
#
# test "interning identical function type definitions yields the same key" do
# nodes_map = Interner.populate_known_types(%{})
#
# raw_integer_type = Types.get_primitive_type(:integer)
# raw_string_type = Types.get_primitive_type(:string)
# raw_atom_type = Types.get_primitive_type(:atom)
#
# # Intern components
# {_int_key, nodes_map} = Interner.get_or_intern_type(raw_integer_type, nodes_map)
# {_str_key, nodes_map} = Interner.get_or_intern_type(raw_string_type, nodes_map)
# {_atom_key, nodes_map} = Interner.get_or_intern_type(raw_atom_type, nodes_map)
#
# # Define two structurally identical raw function types
# raw_func_type_1 = type_function_raw([raw_integer_type, raw_atom_type], raw_string_type)
# raw_func_type_2 = type_function_raw([raw_integer_type, raw_atom_type], raw_string_type)
#
# {key1, nodes_map_after_1} = Interner.get_or_intern_type(raw_func_type_1, nodes_map)
# {key2, _final_nodes_map} = Interner.get_or_intern_type(raw_func_type_2, nodes_map_after_1)
#
# assert key1 == key2
# end
#
# test "interns a function type with no arguments (void -> atom)" do
# nodes_map = Interner.populate_known_types(%{})
# raw_atom_type = Types.get_primitive_type(:atom)
#
# {atom_type_key, nodes_map_after_atom} =
# Interner.get_or_intern_type(raw_atom_type, nodes_map)
#
# raw_func_type = type_function_raw([], raw_atom_type)
#
# {func_type_key, final_nodes_map} =
# Interner.get_or_intern_type(raw_func_type, nodes_map_after_atom)
#
# refute is_nil(func_type_key)
# interned_func_def = Map.get(final_nodes_map, func_type_key)
#
# expected_interned_func_def =
# type_function_interned(func_type_key, [], atom_type_key)
#
# assert interned_func_def == expected_interned_func_def
# end
#
# test "interns a function type whose argument is a complex (list) type" do
# nodes_map = Interner.populate_known_types(%{})
#
# raw_integer_type = Types.get_primitive_type(:integer)
# raw_string_type = Types.get_primitive_type(:string)
#
# # Intern primitive types
# # Prefixed integer_type_key as it's not used directly later
# {_integer_type_key, nodes_map_after_primitives} =
# Interner.get_or_intern_type(raw_integer_type, nodes_map)
# |> then(fn {key_param, map} ->
# {key_param, Interner.get_or_intern_type(raw_string_type, map) |> elem(1)}
# end)
#
# # Define a raw list type: (list integer)
# raw_list_of_int_type = %{
# type_kind: :list,
# # Use raw def here
# element_type: raw_integer_type,
# length: nil
# }
#
# # Intern the list type to get its key and canonical definition
# {list_of_int_key, nodes_map_after_list} =
# Interner.get_or_intern_type(raw_list_of_int_type, nodes_map_after_primitives)
#
# # Raw function type: ((list integer)) -> string
# # Its argument type is the *raw* list type definition
# raw_func_type = type_function_raw([raw_list_of_int_type], raw_string_type)
#
# {func_type_key, final_nodes_map} =
# Interner.get_or_intern_type(raw_func_type, nodes_map_after_list)
#
# refute is_nil(func_type_key)
# interned_func_def = Map.get(final_nodes_map, func_type_key)
#
# # The interned function type should refer to the *key* of the interned list type
# # and the *key* of the interned string type.
# string_type_key = Map.get(final_nodes_map, Types.primitive_type_key(:string)).id
#
# expected_interned_func_def =
# type_function_interned(func_type_key, [list_of_int_key], string_type_key)
#
# assert interned_func_def == expected_interned_func_def
# end
# end
#
# describe "Phase 2: Basic Lambdas (fn)" do
# alias Til.Parser
# # alias Til.Typer # Typer is used via typecheck_and_get_first_node from TestHelpers
# # Added for subtype checking
# alias Til.Typer.SubtypeChecker
# import Til.TestHelpers
#
# # --- Parsing Tests for fn ---
# test "parses a lambda with no arguments and one body expression: (fn () 1)" do
# source = "(fn () 1)"
# {lambda_node, nodes_map} = parse_and_get_first_node(source)
#
# assert lambda_node.ast_node_type == :lambda_expression
# # Check params_s_expr_id and its structure
# params_s_expr_node = get_node_by_id(nodes_map, lambda_node.params_s_expr_id)
# assert params_s_expr_node.ast_node_type == :s_expression
# # The () for parameters
# assert params_s_expr_node.children == []
#
# # Check arg_spec_node_ids
# assert lambda_node.arg_spec_node_ids == []
#
# # Check body_node_ids
# assert length(lambda_node.body_node_ids) == 1
# body_expr_node = get_node_by_id(nodes_map, hd(lambda_node.body_node_ids))
# assert body_expr_node.ast_node_type == :literal_integer
# assert body_expr_node.value == 1
# end
#
# test "parses a lambda with one argument: (fn (x) x)" do
# source = "(fn (x) x)"
# {lambda_node, nodes_map} = parse_and_get_first_node(source)
#
# assert lambda_node.ast_node_type == :lambda_expression
#
# params_s_expr_node = get_node_by_id(nodes_map, lambda_node.params_s_expr_id)
# assert params_s_expr_node.ast_node_type == :s_expression
# assert length(params_s_expr_node.children) == 1
#
# assert length(lambda_node.arg_spec_node_ids) == 1
# arg_spec_node = get_node_by_id(nodes_map, hd(lambda_node.arg_spec_node_ids))
# # Initially, arg_specs for lambdas are just symbols
# assert arg_spec_node.ast_node_type == :symbol
# assert arg_spec_node.name == "x"
#
# assert length(lambda_node.body_node_ids) == 1
# body_expr_node = get_node_by_id(nodes_map, hd(lambda_node.body_node_ids))
# assert body_expr_node.ast_node_type == :symbol
# assert body_expr_node.name == "x"
# end
#
# test "parses a lambda with multiple arguments and multiple body forms: (fn (a b) \"doc\" a)" do
# source = "(fn (a b) 'doc' a)"
# {lambda_node, nodes_map} = parse_and_get_first_node(source)
#
# assert lambda_node.ast_node_type == :lambda_expression
#
# params_s_expr_node = get_node_by_id(nodes_map, lambda_node.params_s_expr_id)
# # a, b
# assert length(params_s_expr_node.children) == 2
#
# assert length(lambda_node.arg_spec_node_ids) == 2
# arg1_node = get_node_by_id(nodes_map, Enum.at(lambda_node.arg_spec_node_ids, 0))
# arg2_node = get_node_by_id(nodes_map, Enum.at(lambda_node.arg_spec_node_ids, 1))
# assert arg1_node.name == "a"
# assert arg2_node.name == "b"
#
# assert length(lambda_node.body_node_ids) == 2
# body1_node = get_node_by_id(nodes_map, Enum.at(lambda_node.body_node_ids, 0))
# body2_node = get_node_by_id(nodes_map, Enum.at(lambda_node.body_node_ids, 1))
# assert body1_node.ast_node_type == :literal_string
# assert body1_node.value == "doc"
# assert body2_node.ast_node_type == :symbol
# assert body2_node.name == "a"
# end
#
# # --- Typing Tests for fn ---
#
# test "types a lambda with no arguments: (fn () 1) as (-> integer)" do
# {lambda_node, typed_nodes_map} = typecheck_and_get_first_node("(fn () 1)")
# func_type_def = Map.get(typed_nodes_map, lambda_node.type_id)
# assert func_type_def.type_kind == :function
# assert func_type_def.arg_types == []
#
# # Check that the return type is a subtype of integer
# actual_return_type_def = Map.get(typed_nodes_map, func_type_def.return_type)
# expected_super_type_def = Map.get(typed_nodes_map, Types.primitive_type_key(:integer))
#
# assert SubtypeChecker.is_subtype?(
# actual_return_type_def,
# expected_super_type_def,
# typed_nodes_map
# ),
# "Expected return type #{inspect(actual_return_type_def)} to be a subtype of #{inspect(expected_super_type_def)}"
#
# assert func_type_def.type_params == []
# end
#
# test "types a lambda with one argument: (fn (x) x) as (any -> any)" do
# {lambda_node, typed_nodes_map} = typecheck_and_get_first_node("(fn (x) x)")
# func_type_def = Map.get(typed_nodes_map, lambda_node.type_id)
#
# assert func_type_def.type_kind == :function
# assert func_type_def.arg_types == [Types.primitive_type_key(:any)]
# assert func_type_def.return_type == Types.primitive_type_key(:any)
# assert func_type_def.type_params == []
# end
#
# test "types a lambda with multiple arguments: (fn (x y) y) as (any any -> any)" do
# {lambda_node, typed_nodes_map} = typecheck_and_get_first_node("(fn (x y) y)")
# func_type_def = Map.get(typed_nodes_map, lambda_node.type_id)
#
# any_key = Types.primitive_type_key(:any)
# assert func_type_def.type_kind == :function
# assert func_type_def.arg_types == [any_key, any_key]
# assert func_type_def.return_type == any_key
# assert func_type_def.type_params == []
# end
#
# test "types a lambda with an empty body: (fn ()) as (-> nil)" do
# {lambda_node, typed_nodes_map} = typecheck_and_get_first_node("(fn ())")
# func_type_def = Map.get(typed_nodes_map, lambda_node.type_id)
#
# assert func_type_def.type_kind == :function
# assert func_type_def.arg_types == []
# assert func_type_def.return_type == Types.literal_type_key(:nil_atom)
# assert func_type_def.type_params == []
# end
#
# test "types a lambda with multiple body forms: (fn () \"doc\" :foo) as (-> atom)" do
# {lambda_node, typed_nodes_map} = typecheck_and_get_first_node("(fn () 'doc' :foo)")
# func_type_def = Map.get(typed_nodes_map, lambda_node.type_id)
#
# assert func_type_def.type_kind == :function
# assert func_type_def.arg_types == []
# # The type of :foo is literal :foo, which is a subtype of primitive atom.
# # The inferred return type should be the specific literal type.
# # We need to get the key for the interned literal type :foo.
# # For simplicity in this test, we'll check against primitive atom,
# # assuming the typer might generalize or that literal atom is subtype of primitive atom.
# # A more precise test would check for the literal type of :foo.
# # Let's assume the typer returns the most specific type, the literal :foo.
# # To get its key, we'd intern it.
# raw_foo_literal_type = %{type_kind: :literal, value: :foo}
# {foo_literal_key, _} = Interner.get_or_intern_type(raw_foo_literal_type, typed_nodes_map)
#
# assert func_type_def.return_type == foo_literal_key
# assert func_type_def.type_params == []
# end
#
# test "types a lambda where an argument is shadowed by a local binding (behavior check)" do
# # Source: (fn (x) (let ((x 1)) x))
# # Expected type: (any -> integer)
# # The inner x (integer) determines the return type.
# # The outer x (any) is the parameter type.
# # This test requires `let` to be implemented and typed correctly.
# # For now, we'll use an assignment which is simpler: (= x 1)
# source = "(fn (x) (= x 1) x)"
# {lambda_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# func_type_def = Map.get(typed_nodes_map, lambda_node.type_id)
#
# assert func_type_def.type_kind == :function
# # Param x is any
# assert func_type_def.arg_types == [Types.primitive_type_key(:any)]
#
# # The return type is the type of the final 'x', which is bound to 1 (integer)
# # Intern the literal 1 type to get its key
# raw_int_1_type = %{type_kind: :literal, value: 1}
# {int_1_key, _} = Interner.get_or_intern_type(raw_int_1_type, typed_nodes_map)
# assert func_type_def.return_type == int_1_key
# assert func_type_def.type_params == []
# end
# end
# end

253
test/til/type_list_test.exs Normal file
View File

@ -0,0 +1,253 @@
# defmodule Til.TypeListTest do
# use ExUnit.Case, async: true
#
# import Til.TestHelpers
# alias Til.Typer.Types
# alias Til.Typer.Interner
# alias Til.Typer.SubtypeChecker
#
# # Helper to create a primitive type definition (without :id)
# defp type_primitive_integer, do: Types.get_primitive_type(:integer)
# defp type_primitive_string, do: Types.get_primitive_type(:string)
# defp type_primitive_atom, do: Types.get_primitive_type(:atom)
# defp type_primitive_any, do: Types.get_primitive_type(:any)
# defp type_primitive_nothing, do: Types.get_primitive_type(:nothing)
#
# # Helper to create a list type definition (without :id, element_type is full def)
# # This is what the typer's infer_type_for_node_ast or resolve_type_specifier_node would produce
# # before interning resolves element_type to element_type_id.
# # For direct assertion against interned types, we'll need a different helper or to use keys.
# defp type_list_raw(element_type_def, length) do
# %{type_kind: :list, element_type: element_type_def, length: length}
# end
#
# # Helper to create the expected *interned* list type definition (without :id)
# # `element_type_id` is the key of the interned element type.
# defp type_list_interned_form(element_type_id, length) do
# %{type_kind: :list, element_type_id: element_type_id, length: length}
# end
#
# # Helper to create the expected *cleaned* type_annotation_mismatch error structure
# defp type_error_type_annotation_mismatch(actual_type_clean, expected_type_clean) do
# %{
# type_kind: :error,
# reason: :type_annotation_mismatch,
# actual_type: actual_type_clean,
# expected_type: expected_type_clean
# }
# end
#
# describe "List Literal Type Inference" do
# test "empty list [] is (List Nothing) with length 0" do
# source = "[]"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# # This is the "clean" structure that deep_strip_id produces
# expected_type_def = type_list_raw(type_primitive_nothing(), 0)
# assert_node_typed_as(node, typed_nodes_map, expected_type_def)
# end
#
# test "list of distinct integer literals [1, 2, 3] is (List (Union (Literal 1) (Literal 2) (Literal 3))) with length 3" do
# source = "[1 2 3]"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# lit_1 = %{type_kind: :literal, value: 1}
# lit_2 = %{type_kind: :literal, value: 2}
# lit_3 = %{type_kind: :literal, value: 3}
# union_type = %{type_kind: :union, types: MapSet.new([lit_1, lit_2, lit_3])}
# expected_type_def = type_list_raw(union_type, 3)
# assert_node_typed_as(node, typed_nodes_map, expected_type_def)
# end
#
# test "list of distinct string literals ['a' 'b'] is (List (Union (Literal \"a\") (Literal \"b\"))) with length 2" do
# # Using single quotes for strings as per parser
# source = "['a' 'b']"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# lit_a = %{type_kind: :literal, value: "a"}
# lit_b = %{type_kind: :literal, value: "b"}
# union_type = %{type_kind: :union, types: MapSet.new([lit_a, lit_b])}
# expected_type_def = type_list_raw(union_type, 2)
# assert_node_typed_as(node, typed_nodes_map, expected_type_def)
# end
#
# test "list of distinct atom literals [:foo :bar] is (List (Union (Literal :foo) (Literal :bar))) with length 2" do
# source = "[:foo :bar]"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# lit_foo = %{type_kind: :literal, value: :foo}
# lit_bar = %{type_kind: :literal, value: :bar}
# union_type = %{type_kind: :union, types: MapSet.new([lit_foo, lit_bar])}
# expected_type_def = type_list_raw(union_type, 2)
# assert_node_typed_as(node, typed_nodes_map, expected_type_def)
# end
#
# test "list of mixed literals [1 'a'] has type (List (Union (Literal 1) (Literal \"a\"))) length 2" do
# source = "[1 'a']"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# # Define the clean union type for the elements.
# clean_literal_1_def = %{type_kind: :literal, value: 1}
# # Parser turns 'a' into "a"
# clean_literal_a_def = %{type_kind: :literal, value: "a"}
#
# clean_element_union_type_def = %{
# type_kind: :union,
# types: MapSet.new([clean_literal_1_def, clean_literal_a_def])
# }
#
# expected_list_type_clean = type_list_raw(clean_element_union_type_def, 2)
# assert_node_typed_as(node, typed_nodes_map, expected_list_type_clean)
# end
# end
#
# describe "List Type Annotation `(the (list <type>) ...)`" do
# test "(the (list integer) []) has type (List Integer) with length nil" do
# source = "(the (list integer) [])"
# # This gets the 'the' node
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# expected_type_def = type_list_raw(type_primitive_integer(), nil)
# assert_node_typed_as(node, typed_nodes_map, expected_type_def)
# end
#
# test "(the (list string) ['a' 'b']) has type (List String) with length nil" do
# source = "(the (list string) ['a' 'b'])"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
# # inspect_nodes(typed_nodes_map)
# expected_type_def = type_list_raw(type_primitive_string(), nil)
# assert_node_typed_as(node, typed_nodes_map, expected_type_def)
# end
#
# test "(the (list integer) [1 'a']) results in type_annotation_mismatch" do
# source = "(the (list integer) [1 'a'])"
# {node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# # Expected actual type: (List (Union (Literal 1) (Literal "a"))) length 2
# lit_1_clean = %{type_kind: :literal, value: 1}
# lit_a_str_clean = %{type_kind: :literal, value: "a"}
# union_elements_clean = %{
# type_kind: :union,
# types: MapSet.new([lit_1_clean, lit_a_str_clean])
# }
# expected_actual_clean_type = type_list_raw(union_elements_clean, 2)
#
# # Expected annotated type: (List Integer) length nil
# expected_annotated_clean_type = type_list_raw(type_primitive_integer(), nil)
#
# expected_error_def =
# type_error_type_annotation_mismatch(
# expected_actual_clean_type,
# expected_annotated_clean_type
# )
#
# assert_node_typed_as(node, typed_nodes_map, expected_error_def)
# end
#
# test "(the (list (list integer)) [[] [1]]) has type (List (List Integer)) with length nil" do
# source = "(the (list (list integer)) [[] [1]])"
# {the_node, typed_nodes_map} = typecheck_and_get_first_node(source)
#
# # Expected inner list type: (List Integer), length nil (from annotation (list integer))
# # This needs to be interned to get its key.
# # Manually construct the expected structure for assertion.
# # 1. Define the raw inner list type: (List Integer, nil)
# # `type_primitive_integer()` gets the base definition of Integer.
# # `typed_nodes_map` (from typechecking the source) will contain the interned primitive types.
# clean_inner_list_type = type_list_raw(type_primitive_integer(), nil)
#
# # Expected outer list type: (List <clean_inner_list_type>), length nil
# expected_outer_list_type_def = type_list_raw(clean_inner_list_type, nil)
#
# assert_node_typed_as(the_node, typed_nodes_map, expected_outer_list_type_def)
# end
# end
#
# describe "List Subtyping" do
# # Helper to check subtyping
# defp check_subtype(subtype_raw, supertype_raw, expected_result) do
# # Populate a base nodes_map with primitive types
# nodes_map = Interner.populate_known_types(%{})
#
# # Intern subtype and supertype to get their canonical forms and keys
# {subtype_key, nodes_map_after_sub} = Interner.get_or_intern_type(subtype_raw, nodes_map)
#
# {supertype_key, final_nodes_map} =
# Interner.get_or_intern_type(supertype_raw, nodes_map_after_sub)
#
# subtype_def = Map.get(final_nodes_map, subtype_key)
# supertype_def = Map.get(final_nodes_map, supertype_key)
#
# assert SubtypeChecker.is_subtype?(subtype_def, supertype_def, final_nodes_map) ==
# expected_result
# end
#
# test "(List Integer, 3) is subtype of (List Integer, 3)" do
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# check_subtype(list_int_3, list_int_3, true)
# end
#
# test "(List Integer, 3) is subtype of (List Integer, nil)" do
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# list_int_nil = type_list_raw(type_primitive_integer(), nil)
# check_subtype(list_int_3, list_int_nil, true)
# end
#
# test "(List Integer, 3) is subtype of (List Number, 3)" do
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# list_num_3 = type_list_raw(Types.get_primitive_type(:number), 3)
# check_subtype(list_int_3, list_num_3, true)
# end
#
# test "(List Integer, 3) is subtype of (List Number, nil)" do
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# list_num_nil = type_list_raw(Types.get_primitive_type(:number), nil)
# check_subtype(list_int_3, list_num_nil, true)
# end
#
# test "(List Integer, 3) is NOT subtype of (List Integer, 2)" do
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# list_int_2 = type_list_raw(type_primitive_integer(), 2)
# check_subtype(list_int_3, list_int_2, false)
# end
#
# test "(List Integer, 3) is NOT subtype of (List String, 3)" do
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# list_str_3 = type_list_raw(type_primitive_string(), 3)
# check_subtype(list_int_3, list_str_3, false)
# end
#
# test "(List Integer, nil) is NOT subtype of (List Integer, 3)" do
# list_int_nil = type_list_raw(type_primitive_integer(), nil)
# list_int_3 = type_list_raw(type_primitive_integer(), 3)
# check_subtype(list_int_nil, list_int_3, false)
# end
#
# test "(List Nothing, 0) is subtype of (List Integer, nil) (empty list compatibility)" do
# # Type of [] is (List Nothing, 0)
# empty_list_type = type_list_raw(type_primitive_nothing(), 0)
# # Target type (e.g. from an annotation (list integer))
# list_int_nil = type_list_raw(type_primitive_integer(), nil)
# check_subtype(empty_list_type, list_int_nil, true)
# end
#
# test "(List Nothing, 0) is subtype of (List Any, nil)" do
# empty_list_type = type_list_raw(type_primitive_nothing(), 0)
# list_any_nil = type_list_raw(type_primitive_any(), nil)
# check_subtype(empty_list_type, list_any_nil, true)
# end
#
# test "(List Nothing, 0) is subtype of (List Nothing, nil)" do
# empty_list_type = type_list_raw(type_primitive_nothing(), 0)
# list_nothing_nil = type_list_raw(type_primitive_nothing(), nil)
# check_subtype(empty_list_type, list_nothing_nil, true)
# end
#
# test "(List Nothing, 0) is NOT subtype of (List Integer, 0) unless Nothing is subtype of Integer" do
# # This depends on whether Nothing is a subtype of Integer. It is.
# empty_list_type = type_list_raw(type_primitive_nothing(), 0)
# list_int_0 = type_list_raw(type_primitive_integer(), 0)
# check_subtype(empty_list_type, list_int_0, true)
# end
# end
# end

533
test/til/type_map_test.exs Normal file
View File

@ -0,0 +1,533 @@
defmodule Til.TypeMapTest do
use ExUnit.Case, async: true
alias Til.Parser
alias Til.Typer
alias Til.Typer.Types
alias Til.Typer.Interner
alias Til.Typer.SubtypeChecker
import Til.TestHelpers
# Helper to create a raw map type definition for assertions/setup
defp type_map_raw(known_elements_raw, index_signature_raw) do
%{
type_kind: :map,
known_elements: known_elements_raw,
index_signature: index_signature_raw
}
end
# Unused helper removed.
# defp type_map_interned_form(known_elements_interned, index_signature_interned) do
# %{
# type_kind: :map,
# # :id field is omitted as it's dynamic
# known_elements: known_elements_interned,
# index_signature: index_signature_interned
# }
# end
defp type_primitive_any, do: Types.get_primitive_type(:any)
defp type_primitive_integer, do: Types.get_primitive_type(:integer)
defp type_primitive_string, do: Types.get_primitive_type(:string)
defp type_primitive_atom, do: Types.get_primitive_type(:atom)
defp type_primitive_number, do: Types.get_primitive_type(:number)
defp type_literal_int(val), do: %{type_kind: :literal, value: val}
defp type_literal_string(val), do: %{type_kind: :literal, value: val}
defp type_literal_atom(val), do: %{type_kind: :literal, value: val}
# Helper to create the expected *cleaned* type_annotation_mismatch error structure
defp type_error_type_annotation_mismatch(actual_type_clean, expected_type_clean) do
%{
type_kind: :error,
reason: :type_annotation_mismatch,
actual_type: actual_type_clean,
expected_type: expected_type_clean
}
end
describe "Map Literal Typing" do
test "empty map m{} is typed correctly" do
source = "m{}"
{map_node, typed_nodes_map} = typecheck_and_get_first_node(source)
expected_raw_type =
type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_any()})
assert_node_typed_as(map_node, typed_nodes_map, expected_raw_type)
end
test "map with literal keys and values m{:a 1 :b 's'}" do
source = "m{:a 1 :b 's'}"
{map_node, typed_nodes_map} = typecheck_and_get_first_node(source)
expected_raw_type =
type_map_raw(
%{
a: %{value_type: type_literal_int(1), optional: false},
b: %{value_type: type_literal_string("s"), optional: false}
},
%{key_type: type_primitive_any(), value_type: type_primitive_any()}
)
assert_node_typed_as(map_node, typed_nodes_map, expected_raw_type)
end
test "map with duplicate literal key (last one wins)" do
source = "m{:a 1 :a 2}"
{map_node, typed_nodes_map} = typecheck_and_get_first_node(source)
expected_raw_type =
type_map_raw(
%{a: %{value_type: type_literal_int(2), optional: false}},
%{key_type: type_primitive_any(), value_type: type_primitive_any()}
)
assert_node_typed_as(map_node, typed_nodes_map, expected_raw_type)
end
test "map with a symbol key that resolves to a literal atom" do
source = """
(= my-key :c)
m{:a 1 my-key 2}
"""
# We are interested in the type of the map expression, which is the second node.
{:ok, parsed_map} = Parser.parse(source)
{:ok, typed_nodes_map} = Typer.type_check(parsed_map)
# 0 is assignment node, 1 is the map node
map_node = get_nth_child_node(typed_nodes_map, 1)
expected_raw_type =
type_map_raw(
# my-key is typed as (literal :c), so :c is used in known_elements.
%{
a: %{value_type: type_literal_int(1), optional: false},
c: %{value_type: type_literal_int(2), optional: false}
},
# Index signature remains default for map literals.
%{key_type: type_primitive_any(), value_type: type_primitive_any()}
)
assert_node_typed_as(map_node, typed_nodes_map, expected_raw_type)
end
test "map with various literal keys (true, false, nil, integer, string, atom)" do
source = "m{true 1 false 2 nil 3 4 'four' :five 5}"
{map_node, typed_nodes_map} = typecheck_and_get_first_node(source)
expected_raw_type =
type_map_raw(
%{
true => %{value_type: type_literal_int(1), optional: false},
false => %{value_type: type_literal_int(2), optional: false},
nil => %{value_type: type_literal_int(3), optional: false},
4 => %{value_type: type_literal_string("four"), optional: false},
# Note: parser turns :five into a literal atom node,
# typer infers its type as %{type_kind: :literal, value: :five}
# The key in known_elements should be the atom :five itself.
five: %{value_type: type_literal_int(5), optional: false}
},
%{key_type: type_primitive_any(), value_type: type_primitive_any()}
)
assert_node_typed_as(map_node, typed_nodes_map, expected_raw_type)
end
end
describe "Map Type Annotation Resolution" do
test "(map atom integer) annotation" do
source = "(the (map atom integer) m{})"
{the_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# m{}'s default index signature (any -> any) is not a subtype of (atom -> integer)
# because 'any' (value) is not a subtype of 'integer'.
# Thus, the 'the' expression should result in a type annotation mismatch error.
# Actual type of m{} (cleaned)
actual_m_empty_clean =
type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_any()})
# Expected annotated type (cleaned)
expected_annotated_clean =
type_map_raw(
%{},
%{key_type: type_primitive_atom(), value_type: type_primitive_integer()}
)
expected_error_def =
type_error_type_annotation_mismatch(actual_m_empty_clean, expected_annotated_clean)
assert_node_typed_as(the_node, typed_nodes_map, expected_error_def)
end
test "(map (list string) (map string any)) annotation" do
source = "(the (map (list string) (map string any)) m{})"
{the_node, typed_nodes_map} = typecheck_and_get_first_node(source)
# Similar to the above, m{}'s default index signature (any -> any)
# will likely fail the subtyping check against the complex map value type in the annotation.
# Actual type of m{} (cleaned)
actual_m_empty_clean =
type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_any()})
# Expected annotated type (cleaned)
# Key type: (List String)
key_type_annotated_clean = %{
type_kind: :list,
element_type: type_primitive_string(),
length: nil
}
# Value type: (Map String Any)
value_type_annotated_clean =
type_map_raw(
%{},
%{key_type: type_primitive_string(), value_type: type_primitive_any()}
)
expected_annotated_clean =
type_map_raw(
%{},
%{key_type: key_type_annotated_clean, value_type: value_type_annotated_clean}
)
expected_error_def =
type_error_type_annotation_mismatch(actual_m_empty_clean, expected_annotated_clean)
assert_node_typed_as(the_node, typed_nodes_map, expected_error_def)
end
end
describe "Map Type Interning" do
test "identical map type definitions intern to the same key" do
nodes_map = Interner.populate_known_types(%{})
raw_map_def1 =
type_map_raw(
%{a: %{value_type: type_literal_int(1), optional: false}},
%{key_type: type_primitive_atom(), value_type: type_primitive_any()}
)
raw_map_def2 =
type_map_raw(
%{a: %{value_type: type_literal_int(1), optional: false}},
%{key_type: type_primitive_atom(), value_type: type_primitive_any()}
)
{key1, nodes_map_after_1} = Interner.get_or_intern_type(raw_map_def1, nodes_map)
{key2, _nodes_map_after_2} = Interner.get_or_intern_type(raw_map_def2, nodes_map_after_1)
assert key1 == key2
end
test "structurally different map type definitions intern to different keys" do
nodes_map = Interner.populate_known_types(%{})
raw_map_def1 =
type_map_raw(
# key :a
%{a: %{value_type: type_literal_int(1), optional: false}},
%{key_type: type_primitive_atom(), value_type: type_primitive_any()}
)
raw_map_def2 =
type_map_raw(
# key :b
%{b: %{value_type: type_literal_int(1), optional: false}},
%{key_type: type_primitive_atom(), value_type: type_primitive_any()}
)
{key1, nodes_map_after_1} = Interner.get_or_intern_type(raw_map_def1, nodes_map)
{key2, _nodes_map_after_2} = Interner.get_or_intern_type(raw_map_def2, nodes_map_after_1)
refute key1 == key2
end
end
describe "Map Subtyping" do
# Helper to check subtyping for maps
defp check_map_subtype(subtype_raw, supertype_raw, expected_result) do
nodes_map = Interner.populate_known_types(%{})
{subtype_key, nodes_map_after_sub} = Interner.get_or_intern_type(subtype_raw, nodes_map)
{supertype_key, final_nodes_map} =
Interner.get_or_intern_type(supertype_raw, nodes_map_after_sub)
subtype_def = Map.get(final_nodes_map, subtype_key)
supertype_def = Map.get(final_nodes_map, supertype_key)
assert SubtypeChecker.is_subtype?(subtype_def, supertype_def, final_nodes_map) ==
expected_result
end
# Test cases based on todo.md logic for map subtyping
# 1. Known Elements (Required in Supertype)
test "subtype has required key with correct type" do
sub =
type_map_raw(%{a: %{value_type: type_literal_int(1), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup =
type_map_raw(%{a: %{value_type: type_primitive_integer(), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
check_map_subtype(sub, sup, true)
end
test "subtype missing required key" do
sub = type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_any()})
sup =
type_map_raw(%{a: %{value_type: type_primitive_integer(), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
check_map_subtype(sub, sup, false)
end
test "subtype has required key but wrong type" do
sub =
type_map_raw(%{a: %{value_type: type_literal_string("s"), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup =
type_map_raw(%{a: %{value_type: type_primitive_integer(), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
check_map_subtype(sub, sup, false)
end
test "subtype has required key as optional" do
sub =
type_map_raw(%{a: %{value_type: type_literal_int(1), optional: true}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup =
type_map_raw(%{a: %{value_type: type_primitive_integer(), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
check_map_subtype(sub, sup, false)
end
# 2. Known Elements (Optional in Supertype)
test "subtype has optional key with correct type" do
sub =
type_map_raw(%{a: %{value_type: type_literal_int(1), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup =
type_map_raw(%{a: %{value_type: type_primitive_integer(), optional: true}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
check_map_subtype(sub, sup, true)
end
test "subtype missing optional key" do
sub = type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_any()})
sup =
type_map_raw(%{a: %{value_type: type_primitive_integer(), optional: true}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
check_map_subtype(sub, sup, true)
end
# 3. Index Signature Compatibility
test "compatible index signatures" do
# Ksuper <: Ksub (contravariant), Vsub <: Vsuper (covariant)
# Ksuper=atom, Ksub=any. Vsub=int, Vsuper=number
sub = type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_literal_int(1)})
sup =
type_map_raw(%{}, %{key_type: type_primitive_atom(), value_type: type_primitive_number()})
check_map_subtype(sub, sup, true)
end
test "incompatible index signature (key type wrong variance)" do
# Ksuper=any, Ksub=atom (any is not subtype of atom)
sub = type_map_raw(%{}, %{key_type: type_primitive_atom(), value_type: type_literal_int(1)})
sup =
type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_number()})
check_map_subtype(sub, sup, false)
end
test "incompatible index signature (value type wrong variance)" do
# Vsub=number, Vsuper=int (number is not subtype of int)
sub =
type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_number()})
sup = type_map_raw(%{}, %{key_type: type_primitive_atom(), value_type: type_literal_int(1)})
check_map_subtype(sub, sup, false)
end
# 4. Width Subtyping (Extra keys in subtype conform to supertype's index signature)
test "extra key in subtype conforms to supertype index signature" do
# Subtype has :b (atom -> int(10)).
# Supertype index signature is atom -> number.
# Subtype index signature is any -> int.
# Check:
# 1. Required/Optional super keys: Super has no known keys, so true.
# 2. Index Signature Compatibility:
# K_super(atom) <: K_sub(any) -> true (contravariance)
# V_sub(int) <: V_super(number) -> true (covariance)
# So, index signatures are compatible.
# 3. Width: Extra key :b (atom) with value int(10) in subtype.
# Must conform to super_is (atom -> number).
# Key :b (atom) <: super_is.key_type (atom) -> true.
# Value int(10) <: super_is.value_type (number) -> true.
# All conditions should pass.
sub =
type_map_raw(
# Extra key
%{b: %{value_type: type_literal_int(10), optional: false}},
# Sub IS
%{key_type: type_primitive_any(), value_type: type_primitive_integer()}
)
sup =
type_map_raw(
# No known keys
%{},
# Super IS
%{key_type: type_primitive_atom(), value_type: type_primitive_number()}
)
check_map_subtype(sub, sup, true)
end
test "extra key in subtype, key type does not conform to supertype index signature" do
# Subtype has "b" (string key), supertype index expects atom keys
sub =
type_map_raw(%{"b" => %{value_type: type_literal_int(10), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup =
type_map_raw(%{}, %{key_type: type_primitive_atom(), value_type: type_primitive_integer()})
check_map_subtype(sub, sup, false)
end
test "extra key in subtype, value type does not conform to supertype index signature" do
# Subtype has :b -> "s" (string value), supertype index expects integer values
sub =
type_map_raw(%{b: %{value_type: type_literal_string("s"), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup =
type_map_raw(%{}, %{key_type: type_primitive_atom(), value_type: type_primitive_integer()})
check_map_subtype(sub, sup, false)
end
test "map is subtype of any" do
sub =
type_map_raw(%{a: %{value_type: type_literal_int(1), optional: false}}, %{
key_type: type_primitive_any(),
value_type: type_primitive_any()
})
sup = type_primitive_any()
check_map_subtype(sub, sup, true)
end
test "nothing is subtype of map" do
# Raw form
sub = Types.get_primitive_type(:nothing)
sup = type_map_raw(%{}, %{key_type: type_primitive_any(), value_type: type_primitive_any()})
check_map_subtype(sub, sup, true)
end
test "complex map subtyping: required, optional, index, and width" do
sub_map =
type_map_raw(
%{
# Matches required
req_present: %{value_type: type_literal_int(1), optional: false},
# Matches optional
opt_present: %{value_type: type_literal_string("sub"), optional: false},
# Width, must match super index
extra_key: %{value_type: type_literal_atom(:sub_atom), optional: false}
},
# Sub index (Ksuper <: Ksub)
%{key_type: type_primitive_any(), value_type: type_primitive_any()}
)
super_map =
type_map_raw(
%{
req_present: %{value_type: type_primitive_integer(), optional: false},
opt_present: %{value_type: type_primitive_string(), optional: true},
opt_absent: %{value_type: type_primitive_atom(), optional: true}
},
# Super index (Vsub <: Vsuper)
%{key_type: type_primitive_atom(), value_type: type_primitive_atom()}
)
# Sub index key: any, value: any
# Super index key: atom, value: atom
# Ksuper (atom) <: Ksub (any) -> true
# Vsub (any) <: Vsuper (atom) -> false. This should make it false.
# Let's adjust sub index to make it pass:
# Vsub (literal :sub_atom) <: Vsuper (atom) -> true
# extra_key (:extra_key, type literal :sub_atom) must conform to super_is (atom -> atom)
# :extra_key (atom) <: super_is.key (atom) -> true
# literal :sub_atom <: super_is.value (atom) -> true
# Corrected sub_map for a true case:
sub_map_corrected =
type_map_raw(
%{
# val_type: int(1) <: integer (super) -> true
req_present: %{value_type: type_literal_int(1), optional: false},
# val_type: string("sub") <: string (super) -> true
opt_present: %{value_type: type_literal_string("sub"), optional: false},
# extra key, val_type: atom(:sub_atom)
extra_key: %{value_type: type_literal_atom(:sub_atom), optional: false}
},
# Sub index signature:
%{key_type: type_primitive_any(), value_type: type_literal_atom(:another_sub_atom)}
)
# Super index signature: %{key_type: type_primitive_atom(), value_type: type_primitive_atom()}
# Index Sig Check: Ksuper(atom) <: Ksub(any) -> true
# Vsub(literal :another_sub_atom) <: Vsuper(atom) -> true
# Width Check for :extra_key (atom) with value (literal :sub_atom):
# key :extra_key (atom) <: super_is.key_type (atom) -> true
# value (literal :sub_atom) <: super_is.value_type (atom) -> true
check_map_subtype(sub_map_corrected, super_map, true)
# Original sub_map that should fail due to index signature value type Vsub(any) not <: Vsuper(atom)
check_map_subtype(sub_map, super_map, false)
end
end
end

View File

@ -0,0 +1,92 @@
defmodule Til.TypeTupleTest do
use ExUnit.Case, async: true
alias Til.Parser
alias Til.Typer
alias Til.Typer.Types
alias Til.TestHelpers # Added alias
test "empty tuple {}" do
{tuple_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node("{}")
assert tuple_node.ast_node_type == :tuple_expression
expected_type = %{type_kind: :tuple, element_types: []}
TestHelpers.assert_node_typed_as(tuple_node, typed_nodes_map, expected_type)
end
test "tuple with one integer {1}" do
{tuple_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node("{1}")
assert tuple_node.ast_node_type == :tuple_expression
expected_type = %{
type_kind: :tuple,
element_types: [%{type_kind: :literal, value: 1}]
}
TestHelpers.assert_node_typed_as(tuple_node, typed_nodes_map, expected_type)
end
test "tuple with integer and string {42 \"hi\"}" do
{tuple_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node("{42 'hi'}")
assert tuple_node.ast_node_type == :tuple_expression
expected_type = %{
type_kind: :tuple,
element_types: [
%{type_kind: :literal, value: 42},
%{type_kind: :literal, value: "hi"}
]
}
TestHelpers.assert_node_typed_as(tuple_node, typed_nodes_map, expected_type)
end
test "tuple with nil and boolean {nil true}" do
{tuple_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node("{nil true}")
assert tuple_node.ast_node_type == :tuple_expression
expected_type = %{
type_kind: :tuple,
element_types: [
Types.get_literal_type(:nil_atom), # This specific type structure comes from Types module
Types.get_literal_type(:true_atom) # This specific type structure comes from Types module
]
}
# Note: deep_strip_id in TestHelpers will handle the :id field if present in Types.get_literal_type results
TestHelpers.assert_node_typed_as(tuple_node, typed_nodes_map, expected_type)
end
test "nested tuple {{1} 'a'}" do
{tuple_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node("{{1} 'a'}")
assert tuple_node.ast_node_type == :tuple_expression
expected_type = %{
type_kind: :tuple,
element_types: [
%{type_kind: :tuple, element_types: [%{type_kind: :literal, value: 1}]},
%{type_kind: :literal, value: "a"}
]
}
TestHelpers.assert_node_typed_as(tuple_node, typed_nodes_map, expected_type)
end
test "tuple with a typed symbol after assignment" do
# Two top-level expressions
source = "(= x 10) {x 'str'}"
# Get the second top-level expression (the tuple)
{tuple_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
assert tuple_node.ast_node_type == :tuple_expression
expected_type_clean = %{
type_kind: :tuple,
element_types: [
# Type of x is literal 10
%{type_kind: :literal, value: 10},
%{type_kind: :literal, value: "str"}
]
}
TestHelpers.assert_node_typed_as(tuple_node, typed_nodes_map, expected_type_clean)
end
end

View File

@ -0,0 +1,264 @@
# defmodule Til.TypeUnionTest do
# use ExUnit.Case, async: true
#
# alias Til.Typer.Types
# alias Til.TestHelpers
# alias MapSet
#
# # --- Predefined Type Definitions for Assertions (Raw Forms) ---
# defp type_primitive_integer, do: Types.get_primitive_type(:integer)
# defp type_primitive_string, do: Types.get_primitive_type(:string)
# defp type_primitive_atom, do: Types.get_primitive_type(:atom)
# defp type_primitive_any, do: Types.get_primitive_type(:any)
# defp type_primitive_nothing, do: Types.get_primitive_type(:nothing)
#
# defp type_literal_int(val), do: %{type_kind: :literal, value: val}
# defp type_literal_string(val), do: %{type_kind: :literal, value: val}
# defp type_literal_atom(val), do: %{type_kind: :literal, value: val}
#
# defp type_list_raw(element_type_def, length \\ nil) do
# %{type_kind: :list, element_type: element_type_def, length: length}
# end
#
# defp type_map_raw(known_elements_raw, index_signature_raw) do
# %{
# type_kind: :map,
# known_elements: known_elements_raw,
# index_signature: index_signature_raw
# }
# end
#
# defp type_union_raw(type_defs_list) do
# %{type_kind: :union, types: MapSet.new(type_defs_list)}
# end
#
# # Helper to create the expected *cleaned* type_annotation_mismatch error structure
# defp type_error_type_annotation_mismatch(actual_type_clean, expected_type_clean) do
# %{
# type_kind: :error,
# reason: :type_annotation_mismatch,
# actual_type: actual_type_clean,
# expected_type: expected_type_clean
# }
# end
#
# describe "(union ...) type specifier resolution and usage in (the ...)" do
# test "(the (union integer string) 42) - integer matches" do
# source = "(the (union integer string) 42)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# expected_annotation_type =
# type_union_raw([type_primitive_integer(), type_primitive_string()])
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
#
# # Inner literal
# literal_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, the_node.id)
# TestHelpers.assert_node_typed_as(literal_node, typed_nodes_map, type_literal_int(42))
# end
#
# test "(the (union integer string) \"hello\") - string matches" do
# source = "(the (union integer string) 'hello')"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# expected_annotation_type =
# type_union_raw([type_primitive_integer(), type_primitive_string()])
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
#
# # Inner literal
# literal_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, the_node.id)
#
# TestHelpers.assert_node_typed_as(
# literal_node,
# typed_nodes_map,
# type_literal_string("hello")
# )
# end
#
# test "(the (union integer string) :some_atom) - type mismatch" do
# source = "(the (union integer string) :some_atom)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# actual_type = type_literal_atom(:some_atom)
#
# expected_annotated_type =
# type_union_raw([type_primitive_integer(), type_primitive_string()])
#
# expected_error =
# type_error_type_annotation_mismatch(actual_type, expected_annotated_type)
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_error)
# end
#
# test "(the (union integer) 42) - single member union resolves to member type" do
# source = "(the (union integer) 42)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# # The annotation (union integer) should resolve to just integer.
# # So, the 'the' expression's type is integer.
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, type_primitive_integer())
# end
#
# test "(the (union integer) \"s\") - single member union type mismatch" do
# source = "(the (union integer) 's')"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# actual_type = type_literal_string("s")
# # (union integer) resolves to integer
# expected_annotated_type = type_primitive_integer()
#
# expected_error =
# type_error_type_annotation_mismatch(actual_type, expected_annotated_type)
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_error)
# end
#
# test "(the (union) 42) - empty union resolves to nothing, causes mismatch" do
# source = "(the (union) 42)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# actual_type = type_literal_int(42)
# # (union) resolves to nothing
# expected_annotated_type = type_primitive_nothing()
#
# expected_error =
# type_error_type_annotation_mismatch(actual_type, expected_annotated_type)
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_error)
# end
#
# test "(the (union) nil) - empty union resolves to nothing, nil is not nothing" do
# # In Tilly, nil is a literal atom, not the type :nothing.
# # :nothing is the bottom type, no values inhabit it.
# source = "(the (union) nil)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# # type of 'nil' symbol
# actual_type = %{type_kind: :literal, value: nil}
# expected_annotated_type = type_primitive_nothing()
#
# expected_error =
# type_error_type_annotation_mismatch(actual_type, expected_annotated_type)
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_error)
# end
#
# test "nested unions: (the (union atom (union integer string)) :foo)" do
# source = "(the (union atom (union integer string)) :foo)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# # (union integer string)
# inner_union = type_union_raw([type_primitive_integer(), type_primitive_string()])
# # (union atom inner_union)
# expected_annotation_type = type_union_raw([type_primitive_atom(), inner_union])
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
# end
#
# test "nested unions: (the (union (union integer string) atom) 1)" do
# source = "(the (union (union integer string) atom) 1)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# inner_union = type_union_raw([type_primitive_integer(), type_primitive_string()])
# expected_annotation_type = type_union_raw([inner_union, type_primitive_atom()])
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
# end
#
# test "unions with complex types: (the (union (list integer) (map atom string)) [1 2])" do
# source = "(the (union (list integer) (map atom string)) [1 2])"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# # Length is dynamic for annotation
# list_int_type = type_list_raw(type_primitive_integer())
#
# map_atom_string_type =
# type_map_raw(
# %{},
# %{key_type: type_primitive_atom(), value_type: type_primitive_string()}
# )
#
# expected_annotation_type = type_union_raw([list_int_type, map_atom_string_type])
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
#
# # Check inner list type
# # [1,2] -> type is (list (union (literal 1) (literal 2)) 2)
# # This is a subtype of (list integer)
# inner_list_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, the_node.id)
#
# # The type of [1,2] is (list (union (literal 1) (literal 2)) length: 2)
# # which simplifies to (list (literal 1 | literal 2) length: 2)
# # This is a subtype of (list integer).
# # The assertion on `the_node` already confirms the subtyping worked.
# # We can assert the specific type of the inner list if needed for clarity.
# expected_inner_list_element_type =
# type_union_raw([type_literal_int(1), type_literal_int(2)])
#
# expected_inner_list_type = type_list_raw(expected_inner_list_element_type, 2)
# TestHelpers.assert_node_typed_as(inner_list_node, typed_nodes_map, expected_inner_list_type)
# end
#
# test "unions with complex types: (the (union (list integer) (map atom string)) m{:a \"b\"})" do
# source = "(the (union (list integer) (map atom string)) m{:a 'b'})"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# list_int_type = type_list_raw(type_primitive_integer())
#
# map_atom_string_type =
# type_map_raw(
# %{},
# %{key_type: type_primitive_atom(), value_type: type_primitive_string()}
# )
#
# expected_annotation_type = type_union_raw([list_int_type, map_atom_string_type])
#
# # The actual type of m{:a 'b'}
# actual_map_type =
# type_map_raw(
# %{a: %{value_type: type_literal_string("b"), optional: false}},
# %{key_type: type_primitive_any(), value_type: type_primitive_any()}
# )
#
# expected_error =
# type_error_type_annotation_mismatch(actual_map_type, expected_annotation_type)
#
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_error)
#
# # We can still check the inner map node's type independently if desired,
# # as its typing is correct, even if the 'the' expression results in an error.
# inner_map_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, the_node.id)
# TestHelpers.assert_node_typed_as(inner_map_node, typed_nodes_map, actual_map_type)
# end
#
# test "union type specifier with unknown member type defaults to any for that member" do
# source = "(the (union integer foobar) 42)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# # 'foobar' resolves to 'any'. So (union integer any) is effectively 'any'.
# # However, the structure of the union should be (union integer any) before simplification.
# # The subtyping check `literal 42 <: (union integer any)` will be true.
# # The type of the 'the' expression will be `(union integer any)`.
# expected_annotation_type = type_union_raw([type_primitive_integer(), type_primitive_any()])
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
# end
#
# test "union type specifier with all unknown member types" do
# source = "(the (union foo bar) 42)"
# {the_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# # (union foo bar) -> (union any any) -> any (after potential simplification by interner/subtype)
# # For now, let's expect the raw structure (union any any)
# expected_annotation_type = type_union_raw([type_primitive_any(), type_primitive_any()])
# TestHelpers.assert_node_typed_as(the_node, typed_nodes_map, expected_annotation_type)
# end
#
# test "malformed union: (union integer string) - not in 'the'" do
# # This is not a (the ...) expression, so it's just an s-expression.
# # Its type will be 'any' by default for unknown s-expression operators.
# source = "(union integer string)"
# {sexpr_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
# TestHelpers.assert_node_typed_as(sexpr_node, typed_nodes_map, type_primitive_any())
# end
# end
# end

View File

@ -0,0 +1,246 @@
# defmodule Til.TypingSimpleTest do
# use ExUnit.Case, async: true
# # lets always alias AstUtils, Typer and Parser for convenience
# alias Til.AstUtils
# alias Til.Parser
# alias Til.Typer
# # Added alias
# alias Til.TestHelpers
#
# alias MapSet, as: Set
#
# describe "simple type inference tests" do
# test "types a literal integer" do
# source = "42"
# {integer_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# assert integer_node.ast_node_type == :literal_integer
#
# TestHelpers.assert_node_typed_as(integer_node, typed_nodes_map, %{
# type_kind: :literal,
# value: 42
# })
# end
#
# test "types a literal string" do
# source = "'hello'"
# {string_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
#
# assert string_node.ast_node_type == :literal_string
#
# TestHelpers.assert_node_typed_as(string_node, typed_nodes_map, %{
# type_kind: :literal,
# value: "hello"
# })
# end
#
# # test "types a simple s-expression (e.g. function call with literals)" do
# # source = "(add 1 2)"
# # {:ok, nodes_map} = Parser.parse(source)
# #
# # flunk("Test not implemented: #{inspect(nodes_map)}")
# # end
#
# test "types an s-expression with a symbol lookup in an environment" do
# source = """
# (= x 5)
# (= y x)
# """
#
# # Parse once
# {:ok, parsed_nodes_map} = Parser.parse(source)
# # Typecheck once
# {:ok, typed_nodes_map} = Typer.type_check(parsed_nodes_map)
#
# file_node = Enum.find(Map.values(typed_nodes_map), &(&1.ast_node_type == :file))
# refute is_nil(file_node)
# assert length(file_node.children) == 2, "Expected two top-level s-expressions"
#
# # --- First assignment: (= x 5) ---
# s_expr_1_node = TestHelpers.get_nth_child_node(typed_nodes_map, 0, file_node.id)
# assert s_expr_1_node.ast_node_type == :s_expression
# assert length(s_expr_1_node.children) == 3
#
# symbol_x_lhs_node = TestHelpers.get_nth_child_node(typed_nodes_map, 1, s_expr_1_node.id)
# assert symbol_x_lhs_node.ast_node_type == :symbol
# assert symbol_x_lhs_node.name == "x"
#
# literal_5_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, s_expr_1_node.id)
# assert literal_5_node.ast_node_type == :literal_integer
# assert literal_5_node.value == 5
#
# TestHelpers.assert_node_typed_as(literal_5_node, typed_nodes_map, %{
# type_kind: :literal,
# value: 5
# })
#
# TestHelpers.assert_node_typed_as(s_expr_1_node, typed_nodes_map, %{
# type_kind: :literal,
# value: 5
# })
#
# # --- Second assignment: (= y x) ---
# s_expr_2_node = TestHelpers.get_nth_child_node(typed_nodes_map, 1, file_node.id)
# assert s_expr_2_node.ast_node_type == :s_expression
# assert length(s_expr_2_node.children) == 3
#
# symbol_y_lhs_node = TestHelpers.get_nth_child_node(typed_nodes_map, 1, s_expr_2_node.id)
# assert symbol_y_lhs_node.ast_node_type == :symbol
# assert symbol_y_lhs_node.name == "y"
#
# symbol_x_rhs_node = TestHelpers.get_nth_child_node(typed_nodes_map, 2, s_expr_2_node.id)
# assert symbol_x_rhs_node.ast_node_type == :symbol
# assert symbol_x_rhs_node.name == "x"
#
# TestHelpers.assert_node_typed_as(symbol_x_rhs_node, typed_nodes_map, %{
# type_kind: :literal,
# value: 5
# })
#
# TestHelpers.assert_node_typed_as(s_expr_2_node, typed_nodes_map, %{
# type_kind: :literal,
# value: 5
# })
#
# # Assert that 'y' in the environment (and thus its node if we were to look it up again) would be integer.
# # The symbol_y_lhs_node itself might be typed as :any before the assignment's effect is fully "realized" on it.
# # The critical part is that the environment passed forward contains y: :til_type_integer.
# # The final environment is inspected in Typer.type_check, which can be manually verified for now.
# # For this test, checking the type of the assignment expression (s_expr_2_node) and the RHS (symbol_x_rhs_node) is sufficient.
# end
#
# test "types an if expression with same type in both branches, ambiguous condition" do
# source = """
# (= cond_var some_ambiguous_symbol)
# (if cond_var 1 2)
# """
#
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
#
# assert if_node.ast_node_type == :s_expression
#
# expected_type_1 = %{type_kind: :literal, value: 1}
# expected_type_2 = %{type_kind: :literal, value: 2}
#
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, %{
# type_kind: :union,
# types: Set.new([expected_type_1, expected_type_2])
# })
# end
#
# test "types an if expression with different types, ambiguous condition, resulting in a union" do
# source = """
# (= cond_var some_ambiguous_symbol)
# (if cond_var 1 'hello')
# """
#
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
#
# assert if_node.ast_node_type == :s_expression
#
# expected_int_type = %{type_kind: :literal, value: 1}
# expected_str_type = %{type_kind: :literal, value: "hello"}
#
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, %{
# type_kind: :union,
# types: Set.new([expected_int_type, expected_str_type])
# })
# end
#
# test "types an if expression with a missing else branch, ambiguous condition (union with nil type)" do
# source = """
# (= cond_var some_ambiguous_symbol)
# (if cond_var 1)
# """
#
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
#
# assert if_node.ast_node_type == :s_expression
#
# expected_int_type = %{type_kind: :literal, value: 1}
# expected_nil_type = %{type_kind: :literal, value: nil}
#
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, %{
# type_kind: :union,
# types: Set.new([expected_int_type, expected_nil_type])
# })
# end
#
# test "types an if expression where then branch is nil, missing else, condition true (results in nil type)" do
# source = """
# (= x nil)
# (if true x)
# """
#
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
# assert if_node.ast_node_type == :s_expression
# expected_nil_type = %{type_kind: :literal, value: nil}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_nil_type)
# end
#
# test "types an if expression where the condition is true, then branch is 1, missing else (results in `1` type)" do
# source = """
# (= x 1)
# (if true x)
# """
#
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
# assert if_node.ast_node_type == :s_expression
# expected_type_of_1 = %{type_kind: :literal, value: 1}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_type_of_1)
# end
#
# test "types an if expression where then and else are nil, condition true (results in nil type)" do
# source = """
# (= x nil)
# (if true x x)
# """
#
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
# assert if_node.ast_node_type == :s_expression
# expected_nil_type = %{type_kind: :literal, value: nil}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_nil_type)
# end
#
# test "if expression with statically false condition, missing else (results in nil type)" do
# source = "(if false 123)"
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
# assert if_node.ast_node_type == :s_expression
# expected_nil_type = %{type_kind: :literal, value: nil}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_nil_type)
# end
#
# test "if expression with statically false condition, with else branch (results in else branch type)" do
# source = "(if false 123 'else_val')"
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
# assert if_node.ast_node_type == :s_expression
# expected_else_type = %{type_kind: :literal, value: "else_val"}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_else_type)
# end
#
# test "if expression with truthy non-boolean literal condition type (integer)" do
# source = """
# (if 123 'then' 'else')
# """
# # Since 123 is truthy, the type of the if expression should be the type of 'then'.
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_first_node(source)
# assert if_node.ast_node_type == :s_expression
#
# expected_then_type = %{type_kind: :literal, value: "then"}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_then_type)
# end
#
# test "if expression with truthy non-boolean symbol condition type (typed as integer)" do
# source = """
# (= my_int_cond 123)
# (if my_int_cond 'then' 'else')
# """
# # my_int_cond is 123 (truthy), so the type of the if expression should be the type of 'then'.
# {if_node, typed_nodes_map} = TestHelpers.typecheck_and_get_nth_node(source, 1)
# assert if_node.ast_node_type == :s_expression
#
# expected_then_type = %{type_kind: :literal, value: "then"}
# TestHelpers.assert_node_typed_as(if_node, typed_nodes_map, expected_then_type)
# end
# end
# end

8
test/til_test.exs Normal file
View File

@ -0,0 +1,8 @@
defmodule TilTest do
use ExUnit.Case
doctest Til
test "greets the world" do
assert Til.hello() == :world
end
end

View File

@ -0,0 +1,77 @@
defmodule Tilly.BDD.AtomBoolOpsTest do
use ExUnit.Case, async: true
alias Tilly.BDD.AtomBoolOps
describe "compare_elements/2" do
test "correctly compares atoms" do
assert AtomBoolOps.compare_elements(:apple, :banana) == :lt
assert AtomBoolOps.compare_elements(:banana, :apple) == :gt
assert AtomBoolOps.compare_elements(:cherry, :cherry) == :eq
end
end
describe "equal_element?/2" do
test "correctly checks atom equality" do
assert AtomBoolOps.equal_element?(:apple, :apple) == true
assert AtomBoolOps.equal_element?(:apple, :banana) == false
end
end
describe "hash_element/1" do
test "hashes atoms consistently" do
assert is_integer(AtomBoolOps.hash_element(:foo))
assert AtomBoolOps.hash_element(:foo) == AtomBoolOps.hash_element(:foo)
assert AtomBoolOps.hash_element(:foo) != AtomBoolOps.hash_element(:bar)
end
end
describe "leaf operations" do
test "empty_leaf/0 returns false" do
assert AtomBoolOps.empty_leaf() == false
end
test "any_leaf/0 returns true" do
assert AtomBoolOps.any_leaf() == true
end
test "is_empty_leaf?/1" do
assert AtomBoolOps.is_empty_leaf?(false) == true
assert AtomBoolOps.is_empty_leaf?(true) == false
end
test "union_leaves/3" do
assert AtomBoolOps.union_leaves(%{}, false, false) == false
assert AtomBoolOps.union_leaves(%{}, true, false) == true
assert AtomBoolOps.union_leaves(%{}, false, true) == true
assert AtomBoolOps.union_leaves(%{}, true, true) == true
end
test "intersection_leaves/3" do
assert AtomBoolOps.intersection_leaves(%{}, false, false) == false
assert AtomBoolOps.intersection_leaves(%{}, true, false) == false
assert AtomBoolOps.intersection_leaves(%{}, false, true) == false
assert AtomBoolOps.intersection_leaves(%{}, true, true) == true
end
test "negation_leaf/2" do
assert AtomBoolOps.negation_leaf(%{}, false) == true
assert AtomBoolOps.negation_leaf(%{}, true) == false
end
end
describe "test_leaf_value/1" do
test "returns :empty for false" do
assert AtomBoolOps.test_leaf_value(false) == :empty
end
test "returns :full for true" do
assert AtomBoolOps.test_leaf_value(true) == :full
end
# Conceptual test if atoms had other leaf values
# test "returns :other for other values" do
# assert AtomBoolOps.test_leaf_value(:some_other_leaf_marker) == :other
# end
end
end

View File

@ -0,0 +1,67 @@
defmodule Tilly.BDD.IntegerBoolOpsTest do
use ExUnit.Case, async: true
alias Tilly.BDD.IntegerBoolOps
describe "compare_elements/2" do
test "correctly compares integers" do
assert IntegerBoolOps.compare_elements(1, 2) == :lt
assert IntegerBoolOps.compare_elements(2, 1) == :gt
assert IntegerBoolOps.compare_elements(1, 1) == :eq
end
end
describe "equal_element?/2" do
test "correctly checks equality of integers" do
assert IntegerBoolOps.equal_element?(1, 1) == true
assert IntegerBoolOps.equal_element?(1, 2) == false
end
end
describe "hash_element/1" do
test "returns the integer itself as hash" do
assert IntegerBoolOps.hash_element(123) == 123
assert IntegerBoolOps.hash_element(-5) == -5
end
end
describe "leaf operations" do
test "empty_leaf/0 returns false" do
assert IntegerBoolOps.empty_leaf() == false
end
test "any_leaf/0 returns true" do
assert IntegerBoolOps.any_leaf() == true
end
test "is_empty_leaf?/1" do
assert IntegerBoolOps.is_empty_leaf?(false) == true
assert IntegerBoolOps.is_empty_leaf?(true) == false
end
end
describe "union_leaves/3" do
test "computes boolean OR" do
assert IntegerBoolOps.union_leaves(%{}, true, true) == true
assert IntegerBoolOps.union_leaves(%{}, true, false) == true
assert IntegerBoolOps.union_leaves(%{}, false, true) == true
assert IntegerBoolOps.union_leaves(%{}, false, false) == false
end
end
describe "intersection_leaves/3" do
test "computes boolean AND" do
assert IntegerBoolOps.intersection_leaves(%{}, true, true) == true
assert IntegerBoolOps.intersection_leaves(%{}, true, false) == false
assert IntegerBoolOps.intersection_leaves(%{}, false, true) == false
assert IntegerBoolOps.intersection_leaves(%{}, false, false) == false
end
end
describe "negation_leaf/2" do
test "computes boolean NOT" do
assert IntegerBoolOps.negation_leaf(%{}, true) == false
assert IntegerBoolOps.negation_leaf(%{}, false) == true
end
end
end

View File

@ -0,0 +1,123 @@
defmodule Tilly.BDD.NodeTest do
use ExUnit.Case, async: true
alias Tilly.BDD.Node
describe "Smart Constructors" do
test "mk_true/0 returns true" do
assert Node.mk_true() == true
end
test "mk_false/0 returns false" do
assert Node.mk_false() == false
end
test "mk_leaf/1 creates a leaf node" do
assert Node.mk_leaf(:some_value) == {:leaf, :some_value}
assert Node.mk_leaf(123) == {:leaf, 123}
end
test "mk_split/4 creates a split node" do
assert Node.mk_split(:el, :p_id, :i_id, :n_id) == {:split, :el, :p_id, :i_id, :n_id}
end
end
describe "Predicates" do
setup do
%{
true_node: Node.mk_true(),
false_node: Node.mk_false(),
leaf_node: Node.mk_leaf("data"),
split_node: Node.mk_split(1, 2, 3, 4)
}
end
test "is_true?/1", %{true_node: t, false_node: f, leaf_node: l, split_node: s} do
assert Node.is_true?(t) == true
assert Node.is_true?(f) == false
assert Node.is_true?(l) == false
assert Node.is_true?(s) == false
end
test "is_false?/1", %{true_node: t, false_node: f, leaf_node: l, split_node: s} do
assert Node.is_false?(f) == true
assert Node.is_false?(t) == false
assert Node.is_false?(l) == false
assert Node.is_false?(s) == false
end
test "is_leaf?/1", %{true_node: t, false_node: f, leaf_node: l, split_node: s} do
assert Node.is_leaf?(l) == true
assert Node.is_leaf?(t) == false
assert Node.is_leaf?(f) == false
assert Node.is_leaf?(s) == false
end
test "is_split?/1", %{true_node: t, false_node: f, leaf_node: l, split_node: s} do
assert Node.is_split?(s) == true
assert Node.is_split?(t) == false
assert Node.is_split?(f) == false
assert Node.is_split?(l) == false
end
end
describe "Accessors" do
setup do
%{
leaf_node: Node.mk_leaf("leaf_data"),
split_node: Node.mk_split(:elem_id, :pos_child, :ign_child, :neg_child)
}
end
test "value/1 for leaf node", %{leaf_node: l} do
assert Node.value(l) == "leaf_data"
end
test "value/1 raises for non-leaf node" do
assert_raise ArgumentError, ~r/Not a leaf node/, fn -> Node.value(Node.mk_true()) end
assert_raise ArgumentError, ~r/Not a leaf node/, fn ->
Node.value(Node.mk_split(1, 2, 3, 4))
end
end
test "element/1 for split node", %{split_node: s} do
assert Node.element(s) == :elem_id
end
test "element/1 raises for non-split node" do
assert_raise ArgumentError, ~r/Not a split node/, fn -> Node.element(Node.mk_true()) end
assert_raise ArgumentError, ~r/Not a split node/, fn -> Node.element(Node.mk_leaf(1)) end
end
test "positive_child/1 for split node", %{split_node: s} do
assert Node.positive_child(s) == :pos_child
end
test "positive_child/1 raises for non-split node" do
assert_raise ArgumentError, ~r/Not a split node/, fn ->
Node.positive_child(Node.mk_leaf(1))
end
end
test "ignore_child/1 for split node", %{split_node: s} do
assert Node.ignore_child(s) == :ign_child
end
test "ignore_child/1 raises for non-split node" do
assert_raise ArgumentError, ~r/Not a split node/, fn ->
Node.ignore_child(Node.mk_leaf(1))
end
end
test "negative_child/1 for split node", %{split_node: s} do
assert Node.negative_child(s) == :neg_child
end
test "negative_child/1 raises for non-split node" do
assert_raise ArgumentError, ~r/Not a split node/, fn ->
Node.negative_child(Node.mk_leaf(1))
end
end
end
end

191
test/tilly/bdd/ops_test.exs Normal file
View File

@ -0,0 +1,191 @@
defmodule Tilly.BDD.OpsTest do
use ExUnit.Case, async: true
alias Tilly.BDD
alias Tilly.BDD.Node
alias Tilly.BDD.Ops
alias Tilly.BDD.IntegerBoolOps # Using a concrete ops_module for testing
setup do
typing_ctx = BDD.init_bdd_store(%{})
# Pre-intern some common elements for tests if needed, e.g., integers
# For now, rely on ops to intern elements as they are used.
%{initial_ctx: typing_ctx}
end
describe "leaf/3" do
test "interning an empty leaf value returns predefined false_id", %{initial_ctx: ctx} do
{new_ctx, node_id} = Ops.leaf(ctx, false, IntegerBoolOps)
assert node_id == BDD.false_node_id()
assert new_ctx.bdd_store.ops_cache == ctx.bdd_store.ops_cache # Cache not used for this path
end
test "interning a full leaf value returns predefined true_id", %{initial_ctx: ctx} do
{new_ctx, node_id} = Ops.leaf(ctx, true, IntegerBoolOps)
assert node_id == BDD.true_node_id()
assert new_ctx.bdd_store.ops_cache == ctx.bdd_store.ops_cache
end
@tag :skip
test "interning a new 'other' leaf value returns a new ID", %{initial_ctx: _ctx} do
# Assuming IntegerBoolOps.test_leaf_value/1 would return :other for non-booleans
# For this test, we'd need an ops_module where e.g. an integer is an :other leaf.
# Let's simulate with a mock or by extending IntegerBoolOps if it were not read-only.
# For now, this test is conceptual for boolean leaves.
# If IntegerBoolOps was extended:
# defmodule MockIntegerOps do
# defdelegate compare_elements(e1, e2), to: IntegerBoolOps
# defdelegate equal_element?(e1, e2), to: IntegerBoolOps
# # ... other delegates
# def test_leaf_value(10), do: :other # Treat 10 as a specific leaf
# def test_leaf_value(true), do: :full
# def test_leaf_value(false), do: :empty
# end
# {ctx_after_intern, node_id} = Ops.leaf(ctx, 10, MockIntegerOps)
# assert node_id != BDD.true_node_id() and node_id != BDD.false_node_id()
# assert BDD.get_node_data(ctx_after_intern, node_id).structure == Node.mk_leaf(10)
# Placeholder for more complex leaf types. Test is skipped.
end
end
describe "split/6 basic simplifications" do
test "if i_id is true, returns true_id", %{initial_ctx: ctx} do
{_p_ctx, p_id} = Ops.leaf(ctx, false, IntegerBoolOps) # dummy
{_n_ctx, n_id} = Ops.leaf(ctx, false, IntegerBoolOps) # dummy
true_id = BDD.true_node_id()
{new_ctx, result_id} = Ops.split(ctx, 10, p_id, true_id, n_id, IntegerBoolOps)
assert result_id == true_id
assert new_ctx == ctx # No new nodes or cache entries expected for this rule
end
test "if p_id == n_id and p_id == i_id, returns p_id", %{initial_ctx: ctx} do
{ctx, p_id} = BDD.get_or_intern_node(ctx, Node.mk_leaf(false), IntegerBoolOps) # some leaf
i_id = p_id
n_id = p_id
{_new_ctx, result_id} = Ops.split(ctx, 10, p_id, i_id, n_id, IntegerBoolOps)
assert result_id == p_id
# Cache might be touched if union_bdds was called, but this rule is direct.
# For p_id == i_id, it's direct.
end
test "if p_id == n_id and p_id != i_id, returns union(p_id, i_id)", %{initial_ctx: ctx} do
{ctx, p_id} = BDD.get_or_intern_node(ctx, Node.mk_leaf(false), IntegerBoolOps)
{ctx, i_id} = BDD.get_or_intern_node(ctx, Node.mk_leaf(true), IntegerBoolOps) # different leaf
n_id = p_id
# Expected union of p_id (false_leaf) and i_id (true_leaf) is true_id
# This relies on union_bdds working.
{_new_ctx, result_id} = Ops.split(ctx, 10, p_id, i_id, n_id, IntegerBoolOps)
expected_union_id = BDD.true_node_id() # Union of false_leaf and true_leaf
assert result_id == expected_union_id
end
test "interns a new split node if no simplification rule applies", %{initial_ctx: ctx} do
{ctx, p_id} = Ops.leaf(ctx, false, IntegerBoolOps) # false_node_id
{ctx, i_id} = Ops.leaf(ctx, false, IntegerBoolOps) # false_node_id
{ctx, n_id} = Ops.leaf(ctx, true, IntegerBoolOps) # true_node_id (different from p_id)
element = 20
{new_ctx, split_node_id} = Ops.split(ctx, element, p_id, i_id, n_id, IntegerBoolOps)
assert split_node_id != p_id and split_node_id != i_id and split_node_id != n_id
assert split_node_id != BDD.true_node_id() and split_node_id != BDD.false_node_id()
node_data = BDD.get_node_data(new_ctx, split_node_id)
assert node_data.structure == Node.mk_split(element, p_id, i_id, n_id)
assert node_data.ops_module == IntegerBoolOps
assert new_ctx.bdd_store.next_node_id > ctx.bdd_store.next_node_id
end
end
describe "union_bdds/3" do
test "A U A = A", %{initial_ctx: ctx} do
{ctx, a_id} = Ops.leaf(ctx, false, IntegerBoolOps) # false_node_id
{new_ctx, result_id} = Ops.union_bdds(ctx, a_id, a_id)
assert result_id == a_id
assert Map.has_key?(new_ctx.bdd_store.ops_cache, {:union, a_id, a_id})
end
test "A U True = True", %{initial_ctx: ctx} do
{ctx, a_id} = Ops.leaf(ctx, false, IntegerBoolOps)
true_id = BDD.true_node_id()
{_new_ctx, result_id} = Ops.union_bdds(ctx, a_id, true_id)
assert result_id == true_id
end
test "A U False = A", %{initial_ctx: ctx} do
{ctx, a_id} = Ops.leaf(ctx, true, IntegerBoolOps) # true_node_id
false_id = BDD.false_node_id()
{_new_ctx, result_id} = Ops.union_bdds(ctx, a_id, false_id)
assert result_id == a_id
end
test "union of two distinct leaves", %{initial_ctx: ctx} do
# leaf(false) U leaf(true) = leaf(true OR false) = leaf(true) -> true_node_id
{ctx, leaf_false_id} = Ops.leaf(ctx, false, IntegerBoolOps)
{ctx, leaf_true_id} = Ops.leaf(ctx, true, IntegerBoolOps) # This is BDD.true_node_id()
{_new_ctx, result_id} = Ops.union_bdds(ctx, leaf_false_id, leaf_true_id)
assert result_id == BDD.true_node_id()
end
test "union of two simple split nodes with same element", %{initial_ctx: ctx} do
# BDD1: split(10, True, False, False)
# BDD2: split(10, False, True, False)
# Union: split(10, True U False, False U True, False U False)
# = split(10, True, True, False)
true_id = BDD.true_node_id()
false_id = BDD.false_node_id()
{ctx, bdd1_id} = Ops.split(ctx, 10, true_id, false_id, false_id, IntegerBoolOps)
{ctx, bdd2_id} = Ops.split(ctx, 10, false_id, true_id, false_id, IntegerBoolOps)
{final_ctx, union_id} = Ops.union_bdds(ctx, bdd1_id, bdd2_id)
# Expected structure
{_final_ctx, expected_bdd_id} = Ops.split(final_ctx, 10, true_id, true_id, false_id, IntegerBoolOps)
assert union_id == expected_bdd_id
end
test "union of two simple split nodes with different elements (x1 < x2)", %{initial_ctx: ctx} do
# BDD1: split(10, True, False, False)
# BDD2: split(20, False, True, False)
# Union (x1 < x2): split(10, p1, i1 U BDD2, n1)
# = split(10, True, False U BDD2, False)
# = split(10, True, BDD2, False)
{ctx, bdd1_p1_id} = Ops.leaf(ctx, true, IntegerBoolOps)
{ctx, bdd1_i1_id} = Ops.leaf(ctx, false, IntegerBoolOps)
{ctx, bdd1_n1_id} = Ops.leaf(ctx, false, IntegerBoolOps)
{ctx, bdd1_id} = Ops.split(ctx, 10, bdd1_p1_id, bdd1_i1_id, bdd1_n1_id, IntegerBoolOps)
{ctx, bdd2_p2_id} = Ops.leaf(ctx, false, IntegerBoolOps)
{ctx, bdd2_i2_id} = Ops.leaf(ctx, true, IntegerBoolOps)
{ctx, bdd2_n2_id} = Ops.leaf(ctx, false, IntegerBoolOps)
{ctx, bdd2_id} = Ops.split(ctx, 20, bdd2_p2_id, bdd2_i2_id, bdd2_n2_id, IntegerBoolOps)
{final_ctx, union_id} = Ops.union_bdds(ctx, bdd1_id, bdd2_id)
# Expected structure: split(10, True, BDD2, False)
{_final_ctx, expected_bdd_id} = Ops.split(final_ctx, 10, bdd1_p1_id, bdd2_id, bdd1_n1_id, IntegerBoolOps)
assert union_id == expected_bdd_id
end
test "uses cache for repeated union operations", %{initial_ctx: ctx} do
{ctx, a_id} = Ops.leaf(ctx, false, IntegerBoolOps)
{ctx, b_id} = Ops.leaf(ctx, true, IntegerBoolOps)
{ctx_after_first_union, _result1_id} = Ops.union_bdds(ctx, a_id, b_id)
cache_after_first = ctx_after_first_union.bdd_store.ops_cache
{ctx_after_second_union, _result2_id} = Ops.union_bdds(ctx_after_first_union, a_id, b_id)
# The BDD store itself (nodes, next_id) should not change on a cache hit.
# The ops_cache map reference will be the same if the result was cached.
assert ctx_after_second_union.bdd_store.ops_cache == cache_after_first
assert ctx_after_second_union.bdd_store.next_node_id == ctx_after_first_union.bdd_store.next_node_id
end
end
end

View File

@ -0,0 +1,72 @@
defmodule Tilly.BDD.StringBoolOpsTest do
use ExUnit.Case, async: true
alias Tilly.BDD.StringBoolOps
describe "compare_elements/2" do
test "correctly compares strings" do
assert StringBoolOps.compare_elements("apple", "banana") == :lt
assert StringBoolOps.compare_elements("banana", "apple") == :gt
assert StringBoolOps.compare_elements("cherry", "cherry") == :eq
end
end
describe "equal_element?/2" do
test "correctly checks string equality" do
assert StringBoolOps.equal_element?("apple", "apple") == true
assert StringBoolOps.equal_element?("apple", "banana") == false
end
end
describe "hash_element/1" do
test "hashes strings consistently" do
assert is_integer(StringBoolOps.hash_element("foo"))
assert StringBoolOps.hash_element("foo") == StringBoolOps.hash_element("foo")
assert StringBoolOps.hash_element("foo") != StringBoolOps.hash_element("bar")
end
end
describe "leaf operations" do
test "empty_leaf/0 returns false" do
assert StringBoolOps.empty_leaf() == false
end
test "any_leaf/0 returns true" do
assert StringBoolOps.any_leaf() == true
end
test "is_empty_leaf?/1" do
assert StringBoolOps.is_empty_leaf?(false) == true
assert StringBoolOps.is_empty_leaf?(true) == false
end
test "union_leaves/3" do
assert StringBoolOps.union_leaves(%{}, false, false) == false
assert StringBoolOps.union_leaves(%{}, true, false) == true
assert StringBoolOps.union_leaves(%{}, false, true) == true
assert StringBoolOps.union_leaves(%{}, true, true) == true
end
test "intersection_leaves/3" do
assert StringBoolOps.intersection_leaves(%{}, false, false) == false
assert StringBoolOps.intersection_leaves(%{}, true, false) == false
assert StringBoolOps.intersection_leaves(%{}, false, true) == false
assert StringBoolOps.intersection_leaves(%{}, true, true) == true
end
test "negation_leaf/2" do
assert StringBoolOps.negation_leaf(%{}, false) == true
assert StringBoolOps.negation_leaf(%{}, true) == false
end
end
describe "test_leaf_value/1" do
test "returns :empty for false" do
assert StringBoolOps.test_leaf_value(false) == :empty
end
test "returns :full for true" do
assert StringBoolOps.test_leaf_value(true) == :full
end
end
end

163
test/tilly/bdd_test.exs Normal file
View File

@ -0,0 +1,163 @@
defmodule Tilly.BDDTest do
use ExUnit.Case, async: true
alias Tilly.BDD.Node
describe "init_bdd_store/1" do
test "initializes bdd_store in typing_ctx with predefined false and true nodes" do
typing_ctx = %{}
new_ctx = Tilly.BDD.init_bdd_store(typing_ctx)
assert %{bdd_store: bdd_store} = new_ctx
assert is_map(bdd_store.nodes_by_structure)
assert is_map(bdd_store.structures_by_id)
assert bdd_store.next_node_id == 2 # 0 for false, 1 for true
assert bdd_store.ops_cache == %{}
# Check false node
false_id = Tilly.BDD.false_node_id()
false_ops_module = Tilly.BDD.universal_ops_module()
assert bdd_store.nodes_by_structure[{Node.mk_false(), false_ops_module}] == false_id
assert bdd_store.structures_by_id[false_id] == %{structure: Node.mk_false(), ops_module: false_ops_module}
# Check true node
true_id = Tilly.BDD.true_node_id()
true_ops_module = Tilly.BDD.universal_ops_module()
assert bdd_store.nodes_by_structure[{Node.mk_true(), true_ops_module}] == true_id
assert bdd_store.structures_by_id[true_id] == %{structure: Node.mk_true(), ops_module: true_ops_module}
end
end
describe "get_or_intern_node/3" do
setup do
typing_ctx = Tilly.BDD.init_bdd_store(%{})
%{initial_ctx: typing_ctx}
end
test "interning Node.mk_false() returns predefined false_id and doesn't change store", %{initial_ctx: ctx} do
false_ops_module = Tilly.BDD.universal_ops_module()
{new_ctx, node_id} = Tilly.BDD.get_or_intern_node(ctx, Node.mk_false(), false_ops_module)
assert node_id == Tilly.BDD.false_node_id()
assert new_ctx.bdd_store == ctx.bdd_store
end
test "interning Node.mk_true() returns predefined true_id and doesn't change store", %{initial_ctx: ctx} do
true_ops_module = Tilly.BDD.universal_ops_module()
{new_ctx, node_id} = Tilly.BDD.get_or_intern_node(ctx, Node.mk_true(), true_ops_module)
assert node_id == Tilly.BDD.true_node_id()
assert new_ctx.bdd_store == ctx.bdd_store
end
test "interning a new leaf node returns a new ID and updates the store", %{initial_ctx: ctx} do
leaf_structure = Node.mk_leaf("test_leaf")
ops_mod = :my_ops
{ctx_after_intern, node_id} = Tilly.BDD.get_or_intern_node(ctx, leaf_structure, ops_mod)
assert node_id == 2 # Initial next_node_id
assert ctx_after_intern.bdd_store.next_node_id == 3
assert ctx_after_intern.bdd_store.nodes_by_structure[{leaf_structure, ops_mod}] == node_id
assert ctx_after_intern.bdd_store.structures_by_id[node_id] == %{structure: leaf_structure, ops_module: ops_mod}
end
test "interning the same leaf node again returns the same ID and doesn't change store", %{initial_ctx: ctx} do
leaf_structure = Node.mk_leaf("test_leaf")
ops_mod = :my_ops
{ctx_after_first_intern, first_node_id} = Tilly.BDD.get_or_intern_node(ctx, leaf_structure, ops_mod)
{ctx_after_second_intern, second_node_id} = Tilly.BDD.get_or_intern_node(ctx_after_first_intern, leaf_structure, ops_mod)
assert first_node_id == second_node_id
assert ctx_after_first_intern.bdd_store == ctx_after_second_intern.bdd_store
end
test "interning a new split node returns a new ID and updates the store", %{initial_ctx: ctx} do
split_structure = Node.mk_split(:el, Tilly.BDD.true_node_id(), Tilly.BDD.false_node_id(), Tilly.BDD.true_node_id())
ops_mod = :split_ops
{ctx_after_intern, node_id} = Tilly.BDD.get_or_intern_node(ctx, split_structure, ops_mod)
assert node_id == 2 # Initial next_node_id
assert ctx_after_intern.bdd_store.next_node_id == 3
assert ctx_after_intern.bdd_store.nodes_by_structure[{split_structure, ops_mod}] == node_id
assert ctx_after_intern.bdd_store.structures_by_id[node_id] == %{structure: split_structure, ops_module: ops_mod}
end
test "interning structurally identical nodes with different ops_modules results in different IDs", %{initial_ctx: ctx} do
leaf_structure = Node.mk_leaf("shared_leaf")
ops_mod1 = :ops1
ops_mod2 = :ops2
{ctx1, id1} = Tilly.BDD.get_or_intern_node(ctx, leaf_structure, ops_mod1)
{_ctx2, id2} = Tilly.BDD.get_or_intern_node(ctx1, leaf_structure, ops_mod2)
assert id1 != id2
assert id1 == 2
assert id2 == 3
end
test "raises ArgumentError if bdd_store is not initialized" do
assert_raise ArgumentError, ~r/BDD store not initialized/, fn ->
Tilly.BDD.get_or_intern_node(%{}, Node.mk_leaf("foo"), :ops)
end
end
end
describe "get_node_data/2" do
setup do
ctx = Tilly.BDD.init_bdd_store(%{})
leaf_structure = Node.mk_leaf("data")
ops_mod = :leaf_ops
{new_ctx, leaf_id_val} = Tilly.BDD.get_or_intern_node(ctx, leaf_structure, ops_mod)
%{ctx: new_ctx, leaf_structure: leaf_structure, ops_mod: ops_mod, leaf_id: leaf_id_val}
end
test "returns correct data for false node", %{ctx: ctx} do
false_id = Tilly.BDD.false_node_id()
false_ops_module = Tilly.BDD.universal_ops_module()
assert Tilly.BDD.get_node_data(ctx, false_id) == %{structure: Node.mk_false(), ops_module: false_ops_module}
end
test "returns correct data for true node", %{ctx: ctx} do
true_id = Tilly.BDD.true_node_id()
true_ops_module = Tilly.BDD.universal_ops_module()
assert Tilly.BDD.get_node_data(ctx, true_id) == %{structure: Node.mk_true(), ops_module: true_ops_module}
end
test "returns correct data for a custom interned leaf node", %{ctx: ctx, leaf_structure: ls, ops_mod: om, leaf_id: id} do
assert Tilly.BDD.get_node_data(ctx, id) == %{structure: ls, ops_module: om}
end
test "returns nil for an unknown node ID", %{ctx: ctx} do
assert Tilly.BDD.get_node_data(ctx, 999) == nil
end
test "returns nil if bdd_store not in ctx" do
assert Tilly.BDD.get_node_data(%{}, 0) == nil
end
end
describe "is_false_node?/2 and is_true_node?/2" do
setup do
ctx = Tilly.BDD.init_bdd_store(%{})
leaf_structure = Node.mk_leaf("data")
ops_mod = :leaf_ops
{new_ctx, leaf_id_val} = Tilly.BDD.get_or_intern_node(ctx, leaf_structure, ops_mod)
%{ctx: new_ctx, leaf_id: leaf_id_val}
end
test "is_false_node?/2", %{ctx: ctx, leaf_id: id} do
assert Tilly.BDD.is_false_node?(ctx, Tilly.BDD.false_node_id()) == true
assert Tilly.BDD.is_false_node?(ctx, Tilly.BDD.true_node_id()) == false
assert Tilly.BDD.is_false_node?(ctx, id) == false
assert Tilly.BDD.is_false_node?(ctx, 999) == false # Unknown ID
end
test "is_true_node?/2", %{ctx: ctx, leaf_id: id} do
assert Tilly.BDD.is_true_node?(ctx, Tilly.BDD.true_node_id()) == true
assert Tilly.BDD.is_true_node?(ctx, Tilly.BDD.false_node_id()) == false
assert Tilly.BDD.is_true_node?(ctx, id) == false
assert Tilly.BDD.is_true_node?(ctx, 999) == false # Unknown ID
end
end
end

View File

@ -0,0 +1,162 @@
defmodule Tilly.Type.OpsTest do
use ExUnit.Case, async: true
alias Tilly.BDD
alias Tilly.Type.Store
alias Tilly.Type.Ops
defp init_context do
%{}
|> BDD.init_bdd_store()
|> Store.init_type_store()
end
describe "get_type_nothing/1 and get_type_any/1" do
test "get_type_nothing returns an interned Descr ID for the empty type" do
ctx = init_context()
{ctx_after_nothing, nothing_id} = Ops.get_type_nothing(ctx)
assert Ops.is_empty_type?(ctx_after_nothing, nothing_id)
end
test "get_type_any returns an interned Descr ID for the universal type" do
ctx = init_context()
{ctx_after_any, any_id} = Ops.get_type_any(ctx)
refute Ops.is_empty_type?(ctx_after_any, any_id)
# Further check: any type negated should be nothing type
{ctx1, neg_any_id} = Ops.negation_type(ctx_after_any, any_id)
{ctx2, nothing_id} = Ops.get_type_nothing(ctx1)
assert neg_any_id == nothing_id
end
end
describe "literal type constructors" do
test "create_atom_literal_type/2" do
ctx = init_context()
{ctx1, atom_foo_id} = Ops.create_atom_literal_type(ctx, :foo)
{ctx2, atom_bar_id} = Ops.create_atom_literal_type(ctx1, :bar)
{ctx3, atom_foo_again_id} = Ops.create_atom_literal_type(ctx2, :foo)
refute Ops.is_empty_type?(ctx3, atom_foo_id)
refute Ops.is_empty_type?(ctx3, atom_bar_id)
assert atom_foo_id != atom_bar_id
assert atom_foo_id == atom_foo_again_id
# Test intersection: (:foo & :bar) should be Nothing
{ctx4, intersection_id} = Ops.intersection_types(ctx3, atom_foo_id, atom_bar_id)
assert Ops.is_empty_type?(ctx4, intersection_id)
# Test union: (:foo | :bar) should not be empty
{ctx5, union_id} = Ops.union_types(ctx4, atom_foo_id, atom_bar_id)
refute Ops.is_empty_type?(ctx5, union_id)
# Test negation: (not :foo) should not be empty and not be :foo
{ctx6, not_foo_id} = Ops.negation_type(ctx5, atom_foo_id)
refute Ops.is_empty_type?(ctx6, not_foo_id)
{ctx7, intersection_not_foo_and_foo} = Ops.intersection_types(ctx6, atom_foo_id, not_foo_id)
assert Ops.is_empty_type?(ctx7, intersection_not_foo_and_foo)
end
test "create_integer_literal_type/2" do
ctx = init_context()
{ctx1, int_1_id} = Ops.create_integer_literal_type(ctx, 1)
{ctx2, int_2_id} = Ops.create_integer_literal_type(ctx1, 2)
refute Ops.is_empty_type?(ctx2, int_1_id) # Use ctx2
{ctx3, intersection_id} = Ops.intersection_types(ctx2, int_1_id, int_2_id)
assert Ops.is_empty_type?(ctx3, intersection_id)
end
test "create_string_literal_type/2" do
ctx = init_context()
{ctx1, str_a_id} = Ops.create_string_literal_type(ctx, "a")
{ctx2, str_b_id} = Ops.create_string_literal_type(ctx1, "b")
refute Ops.is_empty_type?(ctx2, str_a_id) # Use ctx2
{ctx3, intersection_id} = Ops.intersection_types(ctx2, str_a_id, str_b_id)
assert Ops.is_empty_type?(ctx3, intersection_id)
end
end
describe "primitive type constructors (any_of_kind)" do
test "get_primitive_type_any_atom/1" do
ctx = init_context()
{ctx1, any_atom_id} = Ops.get_primitive_type_any_atom(ctx)
{ctx2, atom_foo_id} = Ops.create_atom_literal_type(ctx1, :foo)
refute Ops.is_empty_type?(ctx2, any_atom_id)
# :foo should be a subtype of AnyAtom (i.e., :foo INTERSECTION (NEGATION AnyAtom) == Empty)
# Or, :foo UNION AnyAtom == AnyAtom
# Or, :foo INTERSECTION AnyAtom == :foo
{ctx3, intersection_foo_any_atom_id} = Ops.intersection_types(ctx2, atom_foo_id, any_atom_id)
assert intersection_foo_any_atom_id == atom_foo_id # Check it simplifies to :foo
# Test original subtype logic: (:foo & (not AnyAtom)) == Empty
{ctx4, not_any_atom_id} = Ops.negation_type(ctx3, any_atom_id) # Use ctx3
{ctx5, intersection_subtype_check_id} = Ops.intersection_types(ctx4, atom_foo_id, not_any_atom_id)
assert Ops.is_empty_type?(ctx5, intersection_subtype_check_id)
# AnyAtom & AnyInteger should be Empty
{ctx6, any_integer_id} = Ops.get_primitive_type_any_integer(ctx5) # Use ctx5
{ctx7, atom_int_intersect_id} = Ops.intersection_types(ctx6, any_atom_id, any_integer_id)
assert Ops.is_empty_type?(ctx7, atom_int_intersect_id)
end
end
describe "union_types, intersection_types, negation_type" do
test "basic set properties" do
ctx0 = init_context()
{ctx1, type_a_id} = Ops.create_atom_literal_type(ctx0, :a)
{ctx2, type_b_id} = Ops.create_atom_literal_type(ctx1, :b)
{ctx3, type_c_id} = Ops.create_atom_literal_type(ctx2, :c)
{ctx4, nothing_id} = Ops.get_type_nothing(ctx3)
# A | Nothing = A
{ctx5, union_a_nothing_id} = Ops.union_types(ctx4, type_a_id, nothing_id)
assert union_a_nothing_id == type_a_id
# A & Nothing = Nothing
{ctx6, intersect_a_nothing_id} = Ops.intersection_types(ctx5, type_a_id, nothing_id)
assert intersect_a_nothing_id == nothing_id
# not (not A) = A
{ctx7, not_a_id} = Ops.negation_type(ctx6, type_a_id)
{ctx8, not_not_a_id} = Ops.negation_type(ctx7, not_a_id)
assert not_not_a_id == type_a_id
# A | B
{ctx9, union_ab_id} = Ops.union_types(ctx8, type_a_id, type_b_id)
# (A | B) & A = A
{ctx10, intersect_union_a_id} = Ops.intersection_types(ctx9, union_ab_id, type_a_id)
assert intersect_union_a_id == type_a_id
# (A | B) & C = Nothing (if A, B, C are distinct atom literals)
{ctx11, intersect_union_c_id} = Ops.intersection_types(ctx10, union_ab_id, type_c_id)
assert Ops.is_empty_type?(ctx11, intersect_union_c_id)
# Commutativity and idempotence of union/intersection are implicitly tested by caching
# and canonical key generation in apply_type_op.
end
test "type operations are cached" do
ctx0 = init_context()
{ctx1, type_a_id} = Ops.create_atom_literal_type(ctx0, :a)
{ctx2, type_b_id} = Ops.create_atom_literal_type(ctx1, :b)
# Perform an operation
{ctx3, union1_id} = Ops.union_types(ctx2, type_a_id, type_b_id)
initial_cache_size = map_size(ctx3.type_store.ops_cache)
assert initial_cache_size > 0 # Ensure something was cached
# Perform the same operation again
{ctx4, union2_id} = Ops.union_types(ctx3, type_a_id, type_b_id)
assert union1_id == union2_id
assert map_size(ctx4.type_store.ops_cache) == initial_cache_size # Cache size should not change
# Perform with swapped arguments (commutative)
{ctx5, union3_id} = Ops.union_types(ctx4, type_b_id, type_a_id)
assert union1_id == union3_id
assert map_size(ctx5.type_store.ops_cache) == initial_cache_size # Cache size should not change
end
end
end

View File

@ -0,0 +1,67 @@
defmodule Tilly.Type.StoreTest do
use ExUnit.Case, async: true
alias Tilly.BDD
alias Tilly.Type
alias Tilly.Type.Store
defp init_context do
%{}
|> BDD.init_bdd_store()
|> Store.init_type_store()
end
describe "init_type_store/1" do
test "initializes an empty type store in the typing_ctx" do
typing_ctx = %{}
new_ctx = Store.init_type_store(typing_ctx)
type_store = Map.get(new_ctx, :type_store)
assert type_store.descrs_by_structure == %{}
assert type_store.structures_by_id == %{}
assert type_store.next_descr_id == 0
end
end
describe "get_or_intern_descr/2 and get_descr_by_id/2" do
test "interns a new Descr map and retrieves it" do
typing_ctx = init_context()
descr_map1 = Type.empty_descr(typing_ctx) # Uses canonical BDD.false_node_id()
# Intern first time
{ctx1, id1} = Store.get_or_intern_descr(typing_ctx, descr_map1)
assert id1 == 0
assert Store.get_descr_by_id(ctx1, id1) == descr_map1
assert ctx1.type_store.next_descr_id == 1
# Retrieve existing
{ctx2, id1_retrieved} = Store.get_or_intern_descr(ctx1, descr_map1)
assert id1_retrieved == id1
assert ctx2 == ctx1 # Context should not change if already interned
# Intern a different Descr map
descr_map2 = Type.any_descr(typing_ctx) # Uses canonical BDD.true_node_id()
{ctx3, id2} = Store.get_or_intern_descr(ctx2, descr_map2)
assert id2 == 1
assert Store.get_descr_by_id(ctx3, id2) == descr_map2
assert ctx3.type_store.next_descr_id == 2
# Ensure original is still retrievable
assert Store.get_descr_by_id(ctx3, id1) == descr_map1
end
test "get_descr_by_id returns nil for non-existent ID" do
typing_ctx = init_context()
assert Store.get_descr_by_id(typing_ctx, 999) == nil
end
test "raises an error if type store is not initialized" do
uninitialized_ctx = %{}
descr_map = Type.empty_descr(uninitialized_ctx)
assert_raise ArgumentError,
"Type store not initialized in typing_ctx. Call init_type_store first.",
fn -> Store.get_or_intern_descr(uninitialized_ctx, descr_map) end
end
end
end

39
test/tilly/type_test.exs Normal file
View File

@ -0,0 +1,39 @@
defmodule Tilly.TypeTest do
use ExUnit.Case, async: true
alias Tilly.BDD
alias Tilly.Type
describe "empty_descr/1" do
test "returns a Descr map with all BDD IDs pointing to false" do
typing_ctx = BDD.init_bdd_store(%{})
descr = Type.empty_descr(typing_ctx)
false_id = BDD.false_node_id()
assert descr.atoms_bdd_id == false_id
assert descr.integers_bdd_id == false_id
assert descr.strings_bdd_id == false_id
assert descr.pairs_bdd_id == false_id
assert descr.records_bdd_id == false_id
assert descr.functions_bdd_id == false_id
assert descr.absent_marker_bdd_id == false_id
end
end
describe "any_descr/1" do
test "returns a Descr map with BDD IDs pointing to true (and absent_marker to false)" do
typing_ctx = BDD.init_bdd_store(%{})
descr = Type.any_descr(typing_ctx)
true_id = BDD.true_node_id()
false_id = BDD.false_node_id()
assert descr.atoms_bdd_id == true_id
assert descr.integers_bdd_id == true_id
assert descr.strings_bdd_id == true_id
assert descr.pairs_bdd_id == true_id
assert descr.records_bdd_id == true_id
assert descr.functions_bdd_id == true_id
assert descr.absent_marker_bdd_id == false_id
end
end
end

139
tests.pl
View File

@ -1,139 +0,0 @@
:- module(tests, [run_tests/0]).
:- use_module(parser).
:- use_module(types).
:- use_module(log).
run_test(TestName, Env, Code, ExpectedTypeResult) :-
format('--- Test: ~w ---~n', [TestName]),
( parse(Code, AST) ->
format('Parsed AST: ~w~n', [AST]),
log(test_setup, env(Env)),
( catch(infer_type(AST, Env, ActualType), Error, (
log(error, caught_error(Error)),
explain_error(Error, Explanation),
format('Type Error: ~w~n', [Explanation]),
ActualType = error(Error) % Represent error for comparison
))
-> true
; ActualType = 'inference_failed_silently' % Should not happen if catch works
),
format('Inferred Type: ~w~n', [ActualType]),
( (ActualType == never, ExpectedTypeResult \== never) -> % Explicitly fail if 'never' is inferred unexpectedly
Pass = false, SubMatch = 'unexpected_never'
; (ExpectedTypeResult = error(_), ActualType = error(_)) -> % Both are errors
Pass = true, SubMatch = 'error_expected_and_received'
; ExpectedTypeResult == ActualType ->
Pass = true, SubMatch = 'exact_match'
; unify_types(ExpectedTypeResult, ActualType, ExpectedTypeResult) -> % Actual is subtype of Expected
Pass = true, SubMatch = 'subtype_match'
; unify_types(ExpectedTypeResult, ActualType, ActualType) -> % Expected is subtype of Actual (and Actual is not 'never' unless Expected is also 'never')
Pass = true, SubMatch = 'supertype_match'
; Pass = false, SubMatch = 'mismatch'
),
( Pass == true ->
format('Status: PASS (~w)~n~n', [SubMatch])
; format('Status: FAIL (~w) - Expected: ~w~n~n', [SubMatch, ExpectedTypeResult])
)
; format('Parse FAILED for code: ~s~n~n', [Code])
).
run_tests :-
set_verbosity(1), % Set verbosity: 0 (errors), 1 (log), 2 (debug)
log(tests, 'Starting test suite...'),
initial_env(EmptyEnv),
run_test('Conditional with is_number/1 (x is number)',
[x:union(number,string)],
"(if (is_number x) x 0)", % x is union(number,string), then branch x is number. else branch x is string.
% 0 is number. So then branch is number, else branch is number.
% Result should be number.
number), % If x is number, then x (number). Else 0 (number). Unified: number.
run_test('Conditional with is_number/1 (x is string)',
[x:union(number,string)],
"(if (is_number x) 1 \"not num\")", % x:union(number,string).
% Cond: (is_number x)
% Then: x refined to number. Body `1` is number.
% Else: x refined to string. Body `"not num"` is string.
% Result: union(number, string)
union(number, string)),
run_test('Pattern match list',
[my_list:list(number)],
"(match my_list (((list a b) a)))", % my_list:list(number). a,b become number. returns a (number).
% Pattern (list a b), body a
number),
run_test('Pattern match tuple',
[my_tuple:tuple([number, string])],
"(match my_tuple (((tuple x y) y)))", % my_tuple:tuple([number, string]). x is number, y is string. returns y (string).
% Pattern (tuple x y), body y
string),
run_test('Let binding', % z:number, trying to assign string. Current 'let' rebinds.
[z:number],
"(let z \"text\" z)",
string), % With current 'let' semantics (rebinding), z will be string. Env [z:number] is shadowed.
run_test('Unification in conditional branches',
EmptyEnv,
"(if true 10 \"text\")", % ThenType=number, ElseType=string. unify_types(number,string) -> union(number,string)
union(number,string)
),
run_test('Successful refinement (simulated validate_user)',
[user_data:any],
% Using is_number to simulate a predicate that refines type.
% Env: [user_data:any]
% (if (is_number user_data) user_data "not a number")
% Cond: (is_number user_data) -> boolean
% Then branch: user_data refined to number. Body: user_data -> number
% Else branch: user_data refined to not(number). Body: "not a number" -> string
% Result: union(number, string)
"(if (is_number user_data) user_data \"not a number\")",
union(number, string)
),
run_test('Lambda expression (syntax check, type not deeply inferred yet)',
EmptyEnv,
"(lambda (x y) (add x y))", % `add` is not a defined function, so type of body is an issue.
% For now, this tests parsing of lambda.
% Expected type depends on how `add` and lambdas are typed.
% Let's expect 'any' or a placeholder function type if types.pl is not updated for lambdas.
% For now, let's assume it's 'any' as `add` is unknown.
any), % Placeholder: Actual type depends on full function type inference.
run_test('Function application of lambda (syntax check)',
EmptyEnv,
"((lambda (x) x) 10)",
any), % Placeholder: Actual type depends on lambda type inference and application rules.
% If lambda is (T->T) and arg is T, result is T. Here, (any->any) and number -> any.
% If (lambda (x) x) is typed as fun_type([any],any), then apply to int(10) (number) -> any.
run_test('Empty list literal',
EmptyEnv,
"()",
list(never) % Or some polymorphic list type list(T) if supported. list(never) is common for empty.
),
run_test('Boolean true literal',
EmptyEnv,
"true",
boolean
),
run_test('Boolean false literal',
EmptyEnv,
"false",
boolean
),
log(tests, 'Test suite finished.').
% To run:
% ?- consult('log.pl').
% ?- consult('parser.pl').
% ?- consult('types.pl').
% ?- consult('tests.pl').
% ?- run_tests.

55
todo.md Normal file
View File

@ -0,0 +1,55 @@
1. **Implement Parsing for `(union <type1> <type2> ...)` Type Specifiers:**
* Modify `Til.Typer.ExpressionTyper.resolve_type_specifier_node` to recognize and parse S-expressions like `(union integer string)`.
* This will involve recursively resolving the inner type specifiers and constructing a raw union type definition. The existing interning and subtyping logic for unions can then be leveraged.
* Add tests for type checking expressions annotated with these explicit union types, e.g., `(the (union integer string) some-expression)`.
3. **Implement Parsing for Basic Function Type Specifiers:**
* Modify `Til.Typer.ExpressionTyper.resolve_type_specifier_node` to parse function type specifiers, e.g., `(function (Arg1Type Arg2Type ...) ReturnType)`.
* Add interning support for function types in `Til.Typer.Interner`.
* Implement basic subtyping rules for function types in `Til.Typer.SubtypeChecker` (initially, arity checking; then contravariant arguments, covariant return).
4. **Implement Basic Function Definition (e.g., `def` or `lambda`):**
* Define syntax (e.g., `(def my-fn (param1 param2) body-expr)`).
* Add parser support for this syntax.
* Typer:
* For this initial step, infer a basic function type (e.g., based on arity, with `any` for parameter and return types if not annotated).
* Add the function name and its inferred type to the environment.
5. **Implement Basic Function Calls:**
* Extend `Til.Typer.ExpressionTyper.infer_s_expression_type` for function calls:
* When the operator of an S-expression is a symbol, look up its type in the environment.
* If it's a function type (from step 4), perform an arity check against the provided arguments.
* The inferred type of the call would be the function's (currently basic) return type.
6. **Enhance Function Definitions with Type Annotations:**
* Extend the function definition syntax to support type annotations for parameters and return types (e.g., `(def my-fn ((p1 P1Type) (p2 P2Type)) :: ReturnType body-expr)`).
* Update the parser for this extended syntax.
* Typer:
* Use these annotations to construct a more precise function type.
* When typing the function body, use the annotated parameter types in the local environment.
* Verify that the inferred type of the function body is a subtype of the annotated return type.
* Update function call typing (from step 5) to use these precise function types for argument type checking and to determine the call's return type.
7. **Implement Type Inference for Core Map Operation: `(map-get map key)`:**
* Define the S-expression syntax `(map-get map-expr key-expr)`.
* In `Til.Typer.ExpressionTyper`, implement type inference rules for `map-get` based on the logic outlined in `todo.md`. This includes:
* Typing `map-expr` and `key-expr`.
* Handling cases where `key-expr`'s type is a literal (allowing lookup in `known_elements`).
* Handling cases where `key-expr`'s type is a general type (using `index_signature` and potentially unioning types from `known_elements`).
8. **Improve User-Facing Type Error Messages:**
* For common errors like `type_annotation_mismatch` or function call argument mismatches, enhance the error reporting.
* Develop a utility to pretty-print type definitions (from their internal map representation or ID) for inclusion in error messages, making them more readable than raw type IDs or structures.
* Ensure source locations (file, line, column) are clearly associated with type errors.
9. **Implement Parsing for `(intersection <type1> <type2> ...)` Type Specifiers:**
* Similar to union types, update `Til.Typer.ExpressionTyper.resolve_type_specifier_node` for intersection type S-expressions.
* Add interning and subtyping rules for intersection types in `Til.Typer.Interner` and `Til.Typer.SubtypeChecker`.
10. **Implement Simple Type Aliases (e.g., `deftype`):**
* Define syntax for non-generic type aliases (e.g., `(deftype PositiveInteger (refinement integer ...))` or `(deftype UserMap (map atom any))`).
* Add parser support.
* Typer:
* Store these alias definitions.
* Modify `Til.Typer.ExpressionTyper.resolve_type_specifier_node` to recognize and expand these aliases when they are used in type annotations.

254
types.pl
View File

@ -1,254 +0,0 @@
:- module(types,
[ infer_type/3, % infer_type(+AST, +Env, -Type)
unify_types/3, % unify_types(+Type1, +Type2, -UnifiedType)
refine_env/4, % refine_env(+Var, +Type, +EnvIn, -EnvOut)
get_type/3, % get_type(+Var, +Env, -Type)
% Type representations (examples)
type_number/0, type_string/0, type_boolean/0, type_list_nil/0,
type_list/1, type_tuple/1, type_union/2, type_intersection/2, type_negation/1,
type_any/0, type_never/0,
initial_env/1
]).
:- use_module(log).
:- discontiguous unify_types/3.
:- discontiguous infer_type/3. % Added to handle infer_type_arg/3 in between
% --- Type Representations (as atoms/compound terms) ---
type_number :- _ = number.
type_string :- _ = string.
type_boolean :- _ = boolean. % Represents the type 'boolean'
type_list_nil :- _ = list_nil. % AST node for empty list literal '()'
type_list(_T) :- _ = list(_). % _T is intentionally a singleton, structure check: list(Anything)
type_tuple(Ts) :- _ = tuple(Ts), is_list(Ts). % Ts is used, not singleton
type_union(T1, T2) :- _ = union(T1, T2). % T1, T2 are used, not singletons
type_intersection(T1, T2) :- _ = intersection(T1, T2). % T1, T2 are used, not singletons
type_negation(T) :- _ = negation(T). % T is used, not singleton
type_any :- _ = any. % Top type
type_never :- _ = never. % Bottom type, result of failed branches or contradictions
% --- Environment ---
% Env is a list of Var:Type pairs.
initial_env([]).
get_type(Var, [Var:Type | _], Type) :- !.
get_type(Var, [_ | RestEnv], Type) :- get_type(Var, RestEnv, Type).
get_type(Var, [], _) :-
log(error, unbound_variable(Var, 'unknown_location')), % Location should be passed
fail.
refine_env(Var, Type, EnvIn, [Var:Type | EnvSansOldBinding]) :-
delete(EnvIn, Var:_, EnvSansOldBinding), % EnvSansOldBinding is EnvIn with all Var:_ bindings removed.
log(type_refinement, env_refined(Var, Type)).
% --- Type Inference ---
infer_type(int(_), _Env, number) :- log(type_inference, 'Integer literal -> number').
infer_type(string_val(_), _Env, string) :- log(type_inference, 'String literal -> string').
infer_type(bool(_), _Env, boolean) :- log(type_inference, 'Boolean literal -> boolean').
infer_type(list_nil, _Env, list(never)) :- log(type_inference, 'Empty list literal -> list(never)'). % Or a polymorphic list type
infer_type(id(Var), Env, Type) :-
( get_type(Var, Env, Type) ->
log(type_inference, id(Var) -> Type)
; log(error, type_error(unbound_variable(Var), id(Var))),
Type = never, % Or fail, depending on error handling strategy
explain_error(unbound_variable(Var, id(Var)), _Msg)
).
infer_type(let(Var, ValueAst, BodyAst), EnvIn, BodyType) :-
log(type_inference, 'Inferring type for let expression'),
infer_type(ValueAst, EnvIn, ValueType),
log(type_inference, let_value(Var, ValueType)),
refine_env(Var, ValueType, EnvIn, EnvMid),
infer_type(BodyAst, EnvMid, BodyType),
log(type_inference, let_body(BodyType)).
infer_type(if(CondAst, ThenAst, ElseAst), EnvIn, IfType) :-
log(type_inference, 'Inferring type for if expression'),
infer_type(CondAst, EnvIn, CondType),
( CondType == boolean -> true
; log(error, type_error(expected_boolean_condition, CondAst)), IfType = never, fail
),
% Flow-sensitive refinement example:
( CondAst = is_number(id(X)) -> % If condition is is_number(X)
log(type_inference, flow_refinement_condition(is_number(id(X)))),
refine_env(X, number, EnvIn, EnvThen) % X is number in Then branch
; EnvThen = EnvIn % No specific refinement from condition structure
),
infer_type(ThenAst, EnvThen, ThenType),
% For Else branch, if CondAst was `is_number(X)`, then X is `not(number)`
( CondAst = is_number(id(X)) ->
get_type(X, EnvIn, OriginalXType), % Get original type of X before refinement
refine_env(X, intersection(OriginalXType, negation(number)), EnvIn, EnvElse)
; EnvElse = EnvIn
),
infer_type(ElseAst, EnvElse, ElseType),
unify_types(ThenType, ElseType, IfType), % Branches must have compatible types
log(type_inference, if_expression(CondType, ThenType, ElseType) -> IfType).
% Example: is_number/1 predicate (built-in)
infer_type(is_number(ArgAst), Env, boolean) :-
log(type_inference, 'Inferring type for is_number/1 call'),
infer_type(ArgAst, Env, _ArgType). % ArgType can be anything, is_number checks it.
% Lambda expressions (placeholder - full function type inference is complex)
infer_type(lambda(_Params, _BodyAst), _Env, any) :- % For (lambda (params...) body)
% A proper implementation would construct a function type: fun_type(ParamTypes, ReturnType)
% This requires inferring types for params (possibly from annotations) and body.
log(type_inference, 'Lambda expression -> any (placeholder)').
% General function application (placeholder - requires function type for FunctorSExpr)
infer_type(apply(FunctorSExpr, ArgsSExprs), Env, any) :- % For ((lambda ...) arg) or (f arg) where f is complex
log(type_inference, 'General application (apply/2) -> any (placeholder)'),
infer_type(FunctorSExpr, Env, FunctorType),
% Infer types of ArgsSExprs
maplist(infer_type_arg(Env), ArgsSExprs, ArgTypes),
log(type_inference, apply_functor_type(FunctorType)),
log(type_inference, apply_arg_types(ArgTypes)).
% A proper implementation would:
% 1. Ensure FunctorType is a function type, e.g., fun_type(ExpectedParamTypes, ReturnType).
% 2. Check Arity and if ArgTypes are subtypes of ExpectedParamTypes.
% 3. Return ReturnType.
% For now, it's 'any'.
infer_type_arg(Env, ArgSExpr, ArgType) :- infer_type(ArgSExpr, Env, ArgType).
% Example: validate_user/1 (hypothetical predicate that narrows type)
% Assume validate_user/1 takes 'any' and if it succeeds, the arg is a 'user_record'.
% This would typically be declared elsewhere (e.g. function signatures)
% For now, we simulate its effect.
infer_type(validate_user(ArgAst), Env, boolean) :- % validate_user returns boolean
log(type_inference, 'Inferring type for validate_user/1 call'),
infer_type(ArgAst, Env, _ArgType).
% The actual refinement happens in the 'then' branch of an 'if' or similar construct
% e.g., if validate_user(x) then ... (x is now user_record)
% Pattern Matching
infer_type(match(ExprAst, Clauses), EnvIn, MatchType) :-
log(type_inference, 'Inferring type for match expression'),
infer_type(ExprAst, EnvIn, ExprType),
infer_clause_types(Clauses, ExprType, EnvIn, ClauseTypes),
( ClauseTypes = [] -> MatchType = never % Or error: non-exhaustive match if not desired
; reduce_types(ClauseTypes, MatchType) % Unify all clause body types
),
log(type_inference, match_result_type(MatchType)).
infer_clause_types([], _ExprType, _EnvIn, []).
infer_clause_types([clause(Pattern, _Guard, BodyAst) | RestClauses], ExprType, EnvIn, [BodyType | RestBodyTypes]) :-
log(type_inference, inferring_clause_pattern(Pattern)),
refine_env_from_pattern(Pattern, ExprType, EnvIn, EnvPattern),
( EnvPattern == fail -> % Pattern doesn't match ExprType or is contradictory
log(type_warning, pattern_will_not_match(Pattern, ExprType)),
BodyType = never % This branch is effectively dead
; infer_type(BodyAst, EnvPattern, BodyType)
),
infer_clause_types(RestClauses, ExprType, EnvIn, RestBodyTypes).
% refine_env_from_pattern/4: (+Pattern, +MatchedExprType, +EnvIn, -EnvOutOrFail)
refine_env_from_pattern(pvar(Name), MatchedExprType, EnvIn, EnvOut) :-
!, refine_env(Name, MatchedExprType, EnvIn, EnvOut).
refine_env_from_pattern(pwild, _MatchedExprType, EnvIn, EnvIn) :- !.
refine_env_from_pattern(pint(_), MatchedExprType, EnvIn, EnvIn) :-
( unify_types(MatchedExprType, number, number) -> true % Check if MatchedExprType is compatible with number
; log(type_error, pattern_type_mismatch(pint, MatchedExprType)), fail
).
refine_env_from_pattern(pstring(_), MatchedExprType, EnvIn, EnvIn) :-
( unify_types(MatchedExprType, string, string) -> true % Check if MatchedExprType is compatible with string
; log(type_error, pattern_type_mismatch(pstring, MatchedExprType)), fail
).
refine_env_from_pattern(pbool(_), MatchedExprType, EnvIn, EnvIn) :- % Added for boolean patterns
( unify_types(MatchedExprType, boolean, boolean) -> true % Check if MatchedExprType is compatible with boolean
; log(type_error, pattern_type_mismatch(pbool, MatchedExprType)), fail
).
refine_env_from_pattern(ptuple(Patterns), MatchedExprType, EnvIn, EnvOut) :-
( MatchedExprType = tuple(ElementTypes) ; MatchedExprType = any ), % Allow matching 'any' as a tuple
( var(ElementTypes) -> % MatchedExprType was 'any' or tuple(_)
length(Patterns, L), length(ElementTypes, L), % Infer arity
maplist(=(any), ElementTypes) % Assume elements are 'any' if not specified
; length(Patterns, L1), length(ElementTypes, L2), L1 == L2 % Check arity
), !,
refine_env_from_patterns(Patterns, ElementTypes, EnvIn, EnvOut).
refine_env_from_pattern(ptuple(_Patterns), MatchedExprType, _EnvIn, fail) :-
log(type_error, pattern_type_mismatch(ptuple, MatchedExprType)), fail.
refine_env_from_pattern(plist(Patterns), MatchedExprType, EnvIn, EnvOut) :-
( MatchedExprType = list(ElementType) ; MatchedExprType = any ),
( var(ElementType) -> ElementType = any ), % If MatchedExprType was 'any' or list(_), treat element type as 'any'
!,
length(Patterns, Len),
length(TypesForPatterns, Len), % Create a list of unbound variables of the same length as Patterns
maplist(=(ElementType), TypesForPatterns), % Unify each variable in TypesForPatterns with ElementType
refine_env_from_patterns(Patterns, TypesForPatterns, EnvIn, EnvOut).
refine_env_from_pattern(plist(_Patterns), MatchedExprType, _EnvIn, fail) :-
\+ (MatchedExprType = list(_); MatchedExprType = any), % Fail only if not a list or any
log(type_error, pattern_type_mismatch(plist, MatchedExprType)),
fail.
refine_env_from_patterns([], [], Env, Env).
refine_env_from_patterns([P|Ps], [T|Ts], EnvIn, EnvOut) :-
refine_env_from_pattern(P, T, EnvIn, EnvMid),
( EnvMid == fail -> EnvOut = fail, !
; refine_env_from_patterns(Ps, Ts, EnvMid, EnvOut)
).
% --- Type Unification ---
unify_types(T, T, T) :- !, log(unification, identical(T)).
unify_types(any, T, T) :- !, log(unification, any_with(T) -> T).
unify_types(T, any, T) :- !, log(unification, T -> T). % Corrected T_with_any to T
unify_types(never, _T, never) :- !, log(unification, 'never involved'). % Or should it be T? Depends on meaning.
unify_types(_T, never, never) :- !, log(unification, 'never involved').
unify_types(list(T1), list(T2), list(TU)) :- !,
unify_types(T1, T2, TU),
log(unification, list(T1, T2) -> list(TU)).
unify_types(tuple(Ts1), tuple(Ts2), tuple(TUs)) :- !,
length(Ts1, L), length(Ts2, L), % Tuples must have same arity
maplist(unify_types, Ts1, Ts2, TUs),
log(unification, tuple(Ts1, Ts2) -> tuple(TUs)).
% Union Type Unification (simplified: create a canonical union)
unify_types(union(A, B), C, union(A, union(B,C))) :- \+ is_union(C), !. % Simplistic, needs canonical form
unify_types(A, union(B, C), union(A, union(B,C))) :- \+ is_union(A), !.
unify_types(union(A1,B1), union(A2,B2), union(A1,union(B1,union(A2,B2)))) :- !. % Very naive
is_union(union(_,_)).
% Intersection (placeholder)
unify_types(intersection(A,B), C, intersection(A,intersection(B,C))) :- !. % Needs proper logic
% Helper to reduce a list of types to a single type (e.g., for match clauses)
reduce_types([T], T) :- !.
reduce_types([T1, T2 | Ts], ResultType) :-
unify_types(T1, T2, UnifiedHead),
( UnifiedHead == never -> ResultType = never, ! % Propagate failure
; reduce_types([UnifiedHead | Ts], ResultType)
).
reduce_types([], never). % Consistent with match behavior for no clauses.
% --- Example Predicate for Type Narrowing ---
% This would be part of function signature definitions.
% For 'validate_user(X)', if it returns true, X's type is refined.
% This is usually handled by the 'if' construct using the boolean result.
% e.g. if validate_user(x) then (env refined for x) else (env not refined or negated refinement)
% Example:
% ?- initial_env(Env), infer_type(if(is_number(id(x)), id(x), string_val("no")), [x:union(number,string)], Type).
% Type = number (because string_val("no") is string, unified with number from then branch, if x is number then x is number, else x is string.
% The unification of number and string should fail, or result in union(number, string).
% The example above needs careful check of unify_types.
% Let's assume unify_types(T1,T2,union(T1,T2)) if they are different base types.
% Corrected unification for disparate types (common supertype or union)
unify_types(T1, T2, union(T1, T2)) :-
% This is a fallback if no other rule matches and T1, T2 are not 'any' or 'never'
T1 \= any, T2 \= any, T1 \= never, T2 \= never,
T1 \= T2, % Not identical
% Ensure not to double-wrap unions unnecessarily (basic check)
\+ (T1 = union(_,_) ; T2 = union(_,_)),
log(unification, disparate_types(T1, T2) -> union(T1,T2)).