Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
# Changelog

## Unreleased

### Added

- A module's `defrustmod` mappings come with it under the alias naming it, and
`defrustmod __MODULE__, as: [...]` maps the module itself, so a library maps
the Rust modules its generated code defines once for every module using it.
- `MapSet` lowers on `HashSet`s, `MapSet.t(item)` is a `HashSet`, and `Map`
lowers on `HashMap`s as well as structs: `get`, `fetch!`, `has_key?`, `put`,
`delete`, `merge`, `update/4`, `keys`, `values`, `to_list`, and `new`, and
`MapSet.new`, `put`, `delete`, `member?`, `union`, `difference`,
`intersection`, `subset?`, `disjoint?`, `size`, and `to_list`. Updates take
the collection only at its last use.
- `Enum.into`, `group_by`, `with_index`, `find_index`, `find_value`,
`reduce_while`, `sort_by`, `max_by`, `min_by`, `max`, `min` (with an empty
fallback), `uniq`, `uniq_by`, `dedup`, `split_with`, `at`, `slice`,
`take_while`, `drop_while`, `zip_with`, `chunk_every`, `join`, and
`map_join`. A comprehension over `Enum.with_index(list)` enumerates the list
in place, and `into: HashMap.new()` or `MapSet.new()` collects into that
collection where no type is expected.
- Rebinding a list, map, or set to its update, as `scores = Map.put(scores,
name, 1)` or `layers = Enum.sort_by(layers, ...)`, changes it in place:
`scores.insert(name, 1);`.
- A comprehension generator over one value, as
`{:some, id} <- [record_id(record)]`, matches the value beside the item
instead of iterating a list made for it.

### Changed

- `Enum` functions take an owned list only at its last use. A list used
afterwards, by the call's closure, or by a value built from it is borrowed,
so code using the list again compiles. Questions such as `any?` and `all?`
always borrow, items kept from a borrowed list are cloned, and numbers and
atoms are copied out.
- A new value such as `HashSet.new()` passed where a reference is expected is
borrowed.
- A list reached through a reference, as `binding.superseded` for a borrowed
`binding`, is never taken: `Enum` borrows it and a comprehension copies its
items, as a comprehension also does for an owned list still in use.
- `Enum.filter/2`, `reject/2`, `find`, and `count/2` lower to Rust's `filter`
and `find`, as clippy prefers to `filter_map` returning the item; a number or
atom item is matched as `|&value|`. `Enum` iterates slices with `iter()`.
`RustQ.Rust.AST.PatRef` renders `&pattern`.

## 1.0.0-rc.16 - 2026-10-25

### Added
Expand Down
58 changes: 58 additions & 0 deletions guides/using-rustq-well.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ is. Other aliases stay Rust paths: `SkiaSafe.Canvas.t()` is still
`skia_safe::Canvas`. `callable_modules` remains for modules a body calls
without naming them.

The Rust modules a module's `defrustmod`s map come with it, under the alias
naming it. A module maps itself with `defrustmod __MODULE__`:

```elixir
defmodule MyLib.Runtime do
use RustQ.Meta

defrustmod __MODULE__, as: [:my_lib_records]
defrustmod Limits, as: [:my_lib_limits]
end
```

Then `Runtime.Record.FIRST` is `my_lib_records::Record::FIRST` and
`Runtime.Limits.MAX` is `my_lib_limits::MAX` in a module that aliases it.

## Keep Rusty-Elixir functional

Write implementation logic as valid Elixir-shaped control flow:
Expand Down Expand Up @@ -303,6 +318,49 @@ closure is cloned rather than moved, and a later generator over a list from
outside the comprehension copies its items rather than consuming it. Indexing a
map borrows the key, and `%{key => value}` in a spec is a `HashMap`.

Elixir never changes a list, so an `Enum` function takes an owned list only at
its last use. A list the function's closure, a later expression, or a value
still in use was built from is borrowed instead: `Enum.map(records, ...)` after
`by_id = index(records)` iterates `records.iter()`. Questions such as
`Enum.any?/2` borrow regardless, items a filter keeps from a borrowed list are
cloned, and numbers and atoms are copied out. A list reached through a
reference, as a field of a borrowed struct, is always borrowed, and a
comprehension over it, or over an owned list still in use, copies its items. A
new value passed where a reference is expected, as `HashSet.new()`, is
borrowed.

Keyed collections follow the same rules. On a `HashMap` (`%{key => value}` in
a spec) or `HashSet` (`MapSet.t(item)`), `Map.get`, `fetch!`, `has_key?`,
`put`, `delete`, `merge`, `update/4`, `keys`, `values`, `to_list`, and `new`,
and `MapSet.new`, `put`, `delete`, `member?`, `union`, `difference`,
`intersection`, `subset?`, `disjoint?`, `size`, and `to_list` keep Elixir's
meaning: values read out are copies, and an update takes the collection only at
its last use, updating a copy otherwise. Rebinding a collection to its update,
as `scores = Map.put(scores, name, 1)`, changes it in place:
`scores.insert(name, 1);`.

`Enum` also lowers `into`, `group_by`, `with_index`, `find_index`,
`find_value`, `reduce_while`, `sort_by`, `max_by`, `min_by`, `max`, `min`,
`uniq`, `uniq_by`, `dedup`, `split_with`, `at`, `slice`, `take_while`,
`drop_while`, `zip_with`, `chunk_every`, `join`, and `map_join`. A few follow
Rust where Elixir cannot be kept exactly:

- `with_index` indexes in `usize`, and a generator over it enumerates in place:
`for {child, index} <- Enum.with_index(children)` is
`for (index, child) in children.iter().enumerate()`.
- `find_value` takes a function returning `some(value)` or `nil`.
- `reduce_while` reads `{:cont, acc}` and `{:halt, acc}` as `try_fold` does.
- `max_by`, `min_by`, `max`, and `min` need an empty fallback, `fn -> nil end`
for an `Option`, since Elixir raises on an empty list.
- Sort and extremum keys are values, as in Elixir: a key read out of an item is
cloned, not borrowed.
- `at`, `slice`, and `chunk_every` take non-negative indexes and counts.

A generator over one value matches it beside the item, so
`for record <- records, {:some, id} <- [record_id(record)], do: {id, record}`
skips records without an id, the way `filter_map` does, without making a list
for each.

Types another module declares or provides are available once the module is
named through `alias` or listed in `callable_modules`. Write them remotely, as `Shapes.shape()`, since Elixir checks local types; they keep
their single Rust declaration in the module that declares them.
Expand Down
20 changes: 15 additions & 5 deletions lib/rustq/binding/source.ex
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,23 @@ defmodule RustQ.Binding.Source do
|> Enum.filter(&rustq_module?/1)
end

# `Runtime.Entry` reaches into `Runtime` when `Runtime` is a RustQ module, as for the Rust
# modules it maps, without waiting on a nested module the compiler may never define.
defp aliased_modules(ast, env) do
ast
|> Macro.prewalk([], fn node, modules ->
case aliased_module(node, env) do
nil -> {node, modules}
module -> {node, [module | modules]}
end
|> Macro.prewalk([], fn
{:__aliases__, meta, [head | _]} = node, modules ->
head_module = aliased_module({:__aliases__, meta, [head]}, env)

module =
if head_module && rustq_module?(head_module),
do: head_module,
else: aliased_module(node, env)

{node, if(module, do: [module | modules], else: modules)}

node, modules ->
{node, modules}
end)
|> elem(1)
end
Expand Down
5 changes: 5 additions & 0 deletions lib/rustq/codegen/decoders/pat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ defmodule RustQ.Codegen.Decoders.Pat do
Super.parse_some_pat(required_pat(term, "pattern"))
end

@spec decode_pat_ref(term()) :: R.nif_result(R.path(:Pat))
defrust decode_pat_ref(term) do
Super.parse_ref_pat(required_pat(term, "pattern"))
end

@spec decode_pat_ok(term()) :: R.nif_result(R.path(:Pat))
defrust decode_pat_ok(term) do
Super.parse_ok_pat(required_pat(term, "pattern"))
Expand Down
3 changes: 2 additions & 1 deletion lib/rustq/codegen/dispatch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ defmodule RustQ.Codegen.Dispatch do
:pat_err,
:pat_path_tuple,
:pat_struct,
:pat_slice
:pat_slice,
:pat_ref
],
do: [pat_decoder(name)]

Expand Down
40 changes: 37 additions & 3 deletions lib/rustq/meta.ex
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,8 @@ defmodule RustQ.Meta do
local_callables: local_callables,
rust_macros: rust_macros,
type_aliases: type_aliases,
rust_modules: rust_modules
rust_modules: rust_modules,
own_rust_modules: own_rust_modules
} = compile_context(env)

asts = Enum.map(built_asts, & &1.ast)
Expand Down Expand Up @@ -361,6 +362,7 @@ defmodule RustQ.Meta do
type_items: type_items,
items: items,
local_callables: local_callables ++ Source.provided_callables(env.module),
rust_modules: own_rust_modules,
source: source,
nif_stub_keys:
env.module
Expand All @@ -387,6 +389,7 @@ defmodule RustQ.Meta do
{:__rustq_type_items__, values[:type_items]},
{:__rustq_items__, values[:items]},
{:__rustq_callables__, values[:local_callables]},
{:__rustq_rust_modules__, values[:rust_modules]},
{:__rustq_source__, values[:source]}
]

Expand Down Expand Up @@ -446,6 +449,10 @@ defmodule RustQ.Meta do
defp export_doc(:__rustq_type_items__), do: "The Rust items the module's types generate."
defp export_doc(:__rustq_items__), do: "The Rust items the module generates, in order."
defp export_doc(:__rustq_callables__), do: "Callable metadata for the module's definitions."

defp export_doc(:__rustq_rust_modules__),
do: "The Rust modules the module's `defrustmod`s map its aliases, or itself (`[]`), to."

defp export_doc(:__rustq_source__), do: "The Rust source the module generates."

defp compile_context(env) do
Expand Down Expand Up @@ -478,7 +485,10 @@ defmodule RustQ.Meta do

type_aliases = Map.merge(imported_types, own_type_aliases)

rust_modules = env.module |> Module.get_attribute(:rustq_mod_aliases) |> rust_module_map()
own_rust_modules =
env.module |> Module.get_attribute(:rustq_mod_aliases) |> rust_module_map()

rust_modules = Map.merge(imported_rust_modules(env), own_rust_modules)
local_callables = AST.callables_from_specs(specs, type_aliases)

external_callables =
Expand Down Expand Up @@ -513,10 +523,32 @@ defmodule RustQ.Meta do
local_callables: local_callables,
rust_macros: rust_macros,
type_aliases: own_type_aliases,
rust_modules: rust_modules
rust_modules: rust_modules,
own_rust_modules: own_rust_modules
}
end

# A callable module's Rust module mappings are available under each alias naming it, so
# `Kiwi.NodeChange` reaches the module `Records` maps `NodeChange`, or itself, to.
defp imported_rust_modules(env) do
callable_modules =
env.module
|> Module.get_attribute(:rustq_callable_modules)
|> List.flatten()
|> MapSet.new()

for {alias_name, module} <- env.aliases,
MapSet.member?(callable_modules, module),
function_exported?(module, :__rustq_rust_modules__, 0),
{parts, rust_parts} <- module.__rustq_rust_modules__(),
into: %{},
do:
{alias_name
|> Module.split()
|> Enum.map(&String.to_existing_atom/1)
|> Kernel.++(parts), rust_parts}
end

# A RustQ module named through `alias` in a spec, a type, or a body is imported as a callable
# module is.
defp import_aliased_modules(env, defs, macro_defs) do
Expand Down Expand Up @@ -691,6 +723,8 @@ defmodule RustQ.Meta do
end

defp alias_parts!({:__aliases__, _, parts}), do: parts
# The module itself, so modules naming it through `alias` reach its Rust module.
defp alias_parts!({:__MODULE__, _, context}) when is_atom(context), do: []

defp alias_parts!(atom) when is_atom(atom) do
atom
Expand Down
26 changes: 26 additions & 0 deletions lib/rustq/meta/ast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,21 @@ defmodule RustQ.Meta.AST do
%{ast: ast, rust_module: rust_module, rust_impl: rust_impl}
end

# The collection updates that change a collection in place where it is rebound to them.
@in_place_updates [
{:Map, :put},
{:Map, :delete},
{:Map, :merge},
{:Map, :update},
{:MapSet, :put},
{:MapSet, :delete},
{:MapSet, :union},
{:MapSet, :difference},
{:MapSet, :intersection},
{:Enum, :sort_by},
{:Enum, :dedup}
]

defp mark_mutable_owned_args(args, body, arg_names, arg_types, callables) do
types = Map.new(Enum.zip(arg_names, arg_types))
callables = Index.new(callables)
Expand Down Expand Up @@ -603,6 +618,17 @@ defmodule RustQ.Meta.AST do
{:mut_ref, _, [target]} = call, acc ->
{call, put_owned_root(acc, target, types, true)}

# `scores = Map.put(scores, ...)` changes `scores` in place.
{:=, _,
[
{name, _, _} = target,
{{:., _, [{:__aliases__, _, [module]}, function]}, _, [{name, _, _} | _]}
]} =
rebinding,
acc
when {module, function} in @in_place_updates ->
{rebinding, put_owned_root(acc, target, types, true)}

{:assign!, _, [target, _value]} = call, acc ->
{call, put_owned_root(acc, target, types, true)}

Expand Down
Loading
Loading