From ecd5b8058d0c7715e9bd7bd0d6232f67e74e9c11 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 25 Oct 2026 16:24:30 +0400 Subject: [PATCH 1/4] Borrow lists Enum keeps using, and share defrustmod mappings through aliases --- CHANGELOG.md | 21 +++++ guides/using-rustq-well.md | 28 ++++++ lib/rustq/binding/source.ex | 20 +++- lib/rustq/meta.ex | 40 +++++++- lib/rustq/meta/lower.ex | 136 +++++++++++++++++++++++++--- lib/rustq/meta/lower/enum.ex | 118 +++++++++++++++++------- lib/rustq/meta/lower/stdlib.ex | 2 + lib/rustq/meta/typing.ex | 20 +++- test/rustq/meta/lower/enum_test.exs | 78 ++++++++++++++++ test/rustq/meta/provides_test.exs | 20 ++++ test/support/provided_case.ex | 5 +- 11 files changed, 432 insertions(+), 56 deletions(-) create mode 100644 test/rustq/meta/lower/enum_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ee683..573e329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # 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. +- 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. + ## 1.0.0-rc.16 - 2026-10-25 ### Added diff --git a/guides/using-rustq-well.md b/guides/using-rustq-well.md index bad74ee..642390d 100644 --- a/guides/using-rustq-well.md +++ b/guides/using-rustq-well.md @@ -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: @@ -303,6 +318,19 @@ 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 new value passed where a +reference is expected, as `HashSet.new()`, is borrowed. + +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. diff --git a/lib/rustq/binding/source.ex b/lib/rustq/binding/source.ex index f165fc0..19534dc 100644 --- a/lib/rustq/binding/source.ex +++ b/lib/rustq/binding/source.ex @@ -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 diff --git a/lib/rustq/meta.ex b/lib/rustq/meta.ex index 975b155..5303761 100644 --- a/lib/rustq/meta.ex +++ b/lib/rustq/meta.ex @@ -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) @@ -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 @@ -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]} ] @@ -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 @@ -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 = @@ -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 @@ -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 diff --git a/lib/rustq/meta/lower.ex b/lib/rustq/meta/lower.ex index f160780..04cb1e0 100644 --- a/lib/rustq/meta/lower.ex +++ b/lib/rustq/meta/lower.ex @@ -44,7 +44,9 @@ defmodule RustQ.Meta.Lower do rust_modules: %{}, callables: %BindingIndex{}, macro_vars: %{}, - rust_macros: %{} + rust_macros: %{}, + in_use: MapSet.new(), + built_from: %{} ] end @@ -95,13 +97,85 @@ defmodule RustQ.Meta.Lower do defp lower_block(expressions, %Context{} = context) do context = context_with_downstream_let_types(expressions, context) - {statements, final} = split_final(expressions) - statement_context = %{context | position: :statement} - return_context = %{context | position: :return} + {statement_contexts, final_context} = expression_contexts(statements ++ [final], context) + + Enum.zip_with( + statements, + statement_contexts, + &lower_statement(&1, %{&2 | position: :statement}) + ) ++ + [ + lower_final( + final, + %{final_context | position: :statement}, + %{final_context | position: :return} + ) + ] + end + + # Each expression of a block lowers knowing the variables still in use after it, with the + # variables they were built from, which they may borrow from: Elixir never changes a value, + # so lowering takes one only where nothing uses it afterwards. + defp expression_contexts(expressions, %Context{} = context) do + built_from = Enum.reduce(expressions, context.built_from, &record_built_from/2) + + contexts = + expressions + |> Enum.with_index(1) + |> Enum.map(fn {expression, index} -> + later = + expressions |> Enum.drop(index) |> used_variables() |> MapSet.union(context.in_use) + + current = used_variables([expression]) + sources = sources(MapSet.union(later, current), built_from) + + %{context | in_use: MapSet.union(later, sources), built_from: built_from} + end) + + {statement_contexts, [final_context]} = Enum.split(contexts, -1) + {statement_contexts, final_context} + end + + defp record_built_from({:=, _, [pattern, expression]}, built_from) do + from = used_variables([expression]) + pattern |> variables_in() |> Enum.reduce(built_from, &Map.put(&2, &1, from)) + end + + defp record_built_from(_expression, built_from), do: built_from + + # A binding's own name is not in use because its value is being built. + defp used_variables(expressions) do + Enum.reduce(expressions, MapSet.new(), fn + {:=, _, [_pattern, expression]}, used -> MapSet.union(used, used_variables([expression])) + expression, used -> MapSet.union(used, variables_in(expression)) + end) + end + + defp variables_in(ast) do + ast + |> Macro.prewalk(MapSet.new(), fn + {name, _meta, context} = node, variables + when is_atom(name) and is_atom(context) and name not in [:_, :__MODULE__] -> + {node, MapSet.put(variables, name)} - Enum.map(statements, &lower_statement(&1, statement_context)) ++ - [lower_final(final, statement_context, return_context)] + node, variables -> + {node, variables} + end) + |> elem(1) + end + + defp sources(names, built_from) do + Enum.reduce(names, MapSet.new(), fn name, found -> add_sources(name, built_from, found) end) + end + + defp add_sources(name, built_from, found) do + built_from + |> Map.get(name, MapSet.new()) + |> Enum.reject(&MapSet.member?(found, &1)) + |> Enum.reduce(found, fn source, found -> + add_sources(source, built_from, MapSet.put(found, source)) + end) end # A body returning nothing runs its last expression for its effects, as a statement. @@ -114,13 +188,19 @@ defmodule RustQ.Meta.Lower do defp lower_expected_block(expressions, %Context{} = context, %Type{} = expected_type) do context = context_with_downstream_let_types(expressions, context) - {statements, final} = split_final(expressions) - statement_context = %{context | position: :statement} - return_context = %{context | position: :return} + {statement_contexts, final_context} = expression_contexts(statements ++ [final], context) - Enum.map(statements, &lower_statement(&1, statement_context)) ++ - [%AST.Return{expr: lower_expr(final, expected_type, return_context)}] + Enum.zip_with( + statements, + statement_contexts, + &lower_statement(&1, %{&2 | position: :statement}) + ) ++ + [ + %AST.Return{ + expr: lower_expr(final, expected_type, %{final_context | position: :return}) + } + ] end @keyed_collections [:HashMap, :HashSet, :BTreeMap, :BTreeSet] @@ -1953,6 +2033,25 @@ defmodule RustQ.Meta.Lower do [] -> comprehension_map(stage, binder, binder_type, body) + # A generator over one value, as `{:some, id} <- [record_id(record)]`, matches that value + # beside the item instead of iterating a list made for it. + [{:<-, meta, [value_pattern, [value]]} | later] -> + paired = + quote( + do: + unquote(stage) + |> map(fn unquote(typed_arg(binder, binder_type)) -> + {unquote(binder), unquote(value)} + end) + ) + + comprehension_pipeline( + [{:<-, meta, [{binder, value_pattern}, paired]} | later], + body, + bound, + {nested?, generated} + ) + _ -> quote( do: @@ -3327,10 +3426,25 @@ defmodule RustQ.Meta.Lower do lower_capture: &lower_function_capture(&1, context), closure_arg: &closure_arg!/1, type_of: &Typing.synth(&1, typing_env(context)), + in_use?: &in_use?(&1, &2, context), expected: expected } end + # A collection is in use when the expressions after this one use it, a value they use was + # built from it, or the call's other arguments use it. + defp in_use?(collection, other_args, %Context{} = context) do + case root_variable(collection) do + nil -> + false + + name -> + MapSet.member?(context.in_use, name) or + MapSet.member?(sources(used_variables(other_args), context.built_from), name) or + MapSet.member?(used_variables(other_args), name) + end + end + defp lower_binary_operand(expression, %Context{} = context) do if fallible_expression?(expression, context) and propagation_allowed?(context.return_type) do %AST.Try{expr: lower_expr(expression, context)} diff --git a/lib/rustq/meta/lower/enum.ex b/lib/rustq/meta/lower/enum.ex index 69ed583..6691626 100644 --- a/lib/rustq/meta/lower/enum.ex +++ b/lib/rustq/meta/lower/enum.ex @@ -21,7 +21,7 @@ defmodule RustQ.Meta.Lower.Enum do do: {:ok, lower_map(collection, mapper, context)} defp lower_call(%Call{function: :sum, args: [collection]}, %Context{} = context), - do: {:ok, terminal(collection, :sum, context)} + do: {:ok, collection |> iterate(:question, [], context) |> method_chain(:sum)} defp lower_call( %Call{function: :reduce, args: [collection, initial, reducer]}, @@ -40,7 +40,7 @@ defmodule RustQ.Meta.Lower.Enum do do: {:ok, collection |> lower_collection(context) |> method_chain(:len) |> cast_i64()} defp lower_call(%Call{function: :count, args: [collection, predicate]}, %Context{} = context) do - iterator = collection |> lower_collection(context) |> method_chain(:into_iter) + iterator = iterate(collection, :question, [predicate], context) {:ok, iterator |> lower_filter_map(predicate, false, context, :count) |> cast_i64()} end @@ -48,8 +48,7 @@ defmodule RustQ.Meta.Lower.Enum do do: {:ok, collection - |> lower_collection(context) - |> method_chain(:into_iter) + |> iterate(:question, [], context) |> method_chain(:next) |> method_chain(:is_none)} @@ -63,7 +62,7 @@ defmodule RustQ.Meta.Lower.Enum do }} defp lower_call(%Call{function: :find, args: [collection, predicate]}, %Context{} = context) do - iterator = collection |> lower_collection(context) |> method_chain(:into_iter) + iterator = iterate(collection, :keeps_items, [predicate], context) {:ok, lower_filter_map(iterator, predicate, false, context, :find_map)} end @@ -71,7 +70,7 @@ defmodule RustQ.Meta.Lower.Enum do %Call{function: :find, args: [collection, default, predicate]}, %Context{} = context ) do - iterator = collection |> lower_collection(context) |> method_chain(:into_iter) + iterator = iterate(collection, :keeps_items, [default, predicate], context) {:ok, iterator @@ -118,8 +117,7 @@ defmodule RustQ.Meta.Lower.Enum do do: {:ok, collection - |> context.lower.() - |> method_chain(:into_iter) + |> iterate(:keeps_items, [], context) |> method_chain(:rev) |> method_chain(:collect)} @@ -160,8 +158,7 @@ defmodule RustQ.Meta.Lower.Enum do {:ok, collection - |> context.lower.() - |> method_chain(:into_iter) + |> iterate(:keeps_items, [count], context) |> method_chain(method, [lowered_count]) |> method_chain(:collect)} @@ -287,25 +284,26 @@ defmodule RustQ.Meta.Lower.Enum do defp lower_map(collection, {:fn, _, [{:->, _, [args, body]}]}, context) do mapped_type = collection_inner(context.expected) + mapper = {:fn, [], [{:->, [], [args, body]}]} + iteration = iteration(collection, :maps_items, [mapper], context) + args = typed_items(args, collection, iteration, context) closure = if mapped_type do - context.lower_typed_closure.(typed_items(args, collection, context), body, mapped_type) + context.lower_typed_closure.(args, body, mapped_type) else - context.lower_closure.(typed_items(args, collection, context), body) + context.lower_closure.(args, body) end collection - |> lower_collection(context) - |> method_chain(:into_iter) + |> iterate(iteration, context) |> method_chain(:map, [closure]) |> collect(context) end - defp lower_map(collection, {:&, _, [{:/, _, [capture, 1]}]}, context) do + defp lower_map(collection, {:&, _, [{:/, _, [capture, 1]}]} = mapper, context) do collection - |> lower_collection(context) - |> method_chain(:into_iter) + |> iterate(:maps_items, [mapper], context) |> method_chain(:map, [context.lower_capture.(capture)]) |> collect(context) end @@ -319,25 +317,19 @@ defmodule RustQ.Meta.Lower.Enum do ) end - defp terminal(collection, terminal, context) do - collection - |> lower_collection(context) - |> method_chain(:into_iter) - |> method_chain(terminal) - end - defp lower_reduce( collection, initial, {:fn, _, [{:->, _, [[item, accumulator], body]}]}, context ) do - [item] = typed_items([item], collection, context) + reducer = {:fn, [], [{:->, [], [[item, accumulator], body]}]} + iteration = iteration(collection, :maps_items, [initial, reducer], context) + [item] = typed_items([item], collection, iteration, context) closure = context.lower_closure.([accumulator, item], body) collection - |> lower_collection(context) - |> method_chain(:into_iter) + |> iterate(iteration, context) |> method_chain(:fold, [context.lower.(initial), closure]) end @@ -351,12 +343,14 @@ defmodule RustQ.Meta.Lower.Enum do end defp lower_operation(operation, collection, mapper, context) do + iteration = iteration(collection, operation_use(operation), [mapper], context) + mapper = if operation in [:flat_map, :each, :any?, :all?], - do: typed_mapper(mapper, collection, context), + do: typed_mapper(mapper, collection, iteration, context), else: mapper - collection = collection |> lower_collection(context) |> method_chain(:into_iter) + collection = iterate(collection, iteration, context) case operation do :filter -> @@ -379,6 +373,10 @@ defmodule RustQ.Meta.Lower.Enum do end end + defp operation_use(operation) when operation in [:any?, :all?], do: :question + defp operation_use(operation) when operation in [:filter, :reject], do: :keeps_items + defp operation_use(_operation), do: :maps_items + defp lower_filter_map(collection, predicate, negate?, context), do: lower_filter_map(collection, predicate, negate?, context, :collect) @@ -467,22 +465,72 @@ defmodule RustQ.Meta.Lower.Enum do # A list is iterated by value, so a closure given its items may treat each as the list's # element type, as a struct update needs; filters see references and keep untyped arguments. - defp typed_items([item | rest], collection, context) do + defp typed_items([item | rest], collection, iteration, context) do case context.type_of.(collection) do %Type{kind: :vec} = type -> - [{:__rustq_typed_arg__, [], [item, Type.vec_inner(type)]} | rest] + inner = Type.vec_inner(type) + inner = if iteration == :borrowed, do: Type.ref(inner), else: inner + [{:__rustq_typed_arg__, [], [item, inner]} | rest] _unknown -> [item | rest] end end - defp typed_items([], _collection, _context), do: [] + defp typed_items([], _collection, _iteration, _context), do: [] + + defp typed_mapper( + {:fn, meta, [{:->, arrow_meta, [args, body]}]}, + collection, + iteration, + context + ), + do: + {:fn, meta, + [{:->, arrow_meta, [typed_items(args, collection, iteration, context), body]}]} + + defp typed_mapper(mapper, _collection, _iteration, _context), do: mapper + + # Elixir never changes a list, so an operation takes an owned list only where nothing uses it + # afterwards. A question about a list borrows it; so does any other operation on a list still + # in use, copying the items it keeps. + defp iterate(collection, use, others, context), + do: iterate(collection, iteration(collection, use, others, context), context) + + defp iterate(collection, iteration, context) do + receiver = lower_collection(collection, context) + + case iteration do + :owned -> method_chain(receiver, :into_iter) + :borrowed -> method_chain(receiver, :iter) + :copied -> receiver |> method_chain(:iter) |> method_chain(:copied) + :cloned -> receiver |> method_chain(:iter) |> method_chain(:cloned) + end + end + + # A question needs no item it could take, so it borrows other items even from a list nothing + # uses afterwards. Copying out a number or atom costs what borrowing it does, and keeps the + # item's own type. + defp iteration(collection, use, others, context) do + case context.type_of.(collection) do + %Type{kind: :vec} = type -> + copy? = copy?(Type.vec_inner(type)) + in_use? = (use == :question and not copy?) or context.in_use?.(collection, others) + + cond do + not in_use? -> :owned + copy? -> :copied + use == :keeps_items -> :cloned + true -> :borrowed + end - defp typed_mapper({:fn, meta, [{:->, arrow_meta, [args, body]}]}, collection, context), - do: {:fn, meta, [{:->, arrow_meta, [typed_items(args, collection, context), body]}]} + _not_owned -> + :owned + end + end - defp typed_mapper(mapper, _collection, _context), do: mapper + defp copy?(%Type{} = type), do: Type.category(type) in [:integer, :number, :boolean, :atom] + defp copy?(_type), do: false defp lower_collection(collection, context) do case context.type_of.(collection) do diff --git a/lib/rustq/meta/lower/stdlib.ex b/lib/rustq/meta/lower/stdlib.ex index 2d1087a..07356ab 100644 --- a/lib/rustq/meta/lower/stdlib.ex +++ b/lib/rustq/meta/lower/stdlib.ex @@ -26,6 +26,7 @@ defmodule RustQ.Meta.Lower.Stdlib do :lower_capture, :closure_arg, :type_of, + :in_use?, :expected ] @@ -39,6 +40,7 @@ defmodule RustQ.Meta.Lower.Stdlib do lower_capture: (Macro.t() -> term()), closure_arg: (Macro.t() -> atom()), type_of: (Macro.t() -> term()), + in_use?: (Macro.t(), [Macro.t()] -> boolean()), expected: Type.t() | nil } end diff --git a/lib/rustq/meta/typing.ex b/lib/rustq/meta/typing.ex index e30bad4..4780335 100644 --- a/lib/rustq/meta/typing.ex +++ b/lib/rustq/meta/typing.ex @@ -195,11 +195,29 @@ defmodule RustQ.Meta.Typing do def check(ast, %Type{} = expected, opts) when is_list(opts), do: check(ast, expected, env(opts)) def check(ast, %Type{} = expected, %Env{} = env) do - actual = synth(ast, env) + actual = synth(ast, env) || constructed_type(ast, expected) %Check{type: actual, coercion: coercion(actual, expected)} end + # `HashSet.new()` makes a new value of the type its call names, which a reference to that + # type borrows, though its type parameters come from where it goes. + defp constructed_type( + {{:., _, [{:__aliases__, _, [_ | _] = parts}, constructor]}, _, args}, + %Type{kind: :ref} = expected + ) + when constructor in [:new, :with_capacity, :default] and is_list(args) do + with %Type{ast: %AST.TypePath{parts: [_ | _] = type_parts}} = inner <- + Type.ref_inner(expected), + true <- List.last(type_parts) == List.last(parts) do + inner + else + _other -> nil + end + end + + defp constructed_type(_ast, _expected), do: nil + @spec expected_for_let(Macro.t(), Macro.t(), env_source()) :: Type.t() | nil def expected_for_let(pattern, expression, opts) when is_list(opts) do expected_for_let(pattern, expression, env(opts)) diff --git a/test/rustq/meta/lower/enum_test.exs b/test/rustq/meta/lower/enum_test.exs new file mode 100644 index 0000000..a345fea --- /dev/null +++ b/test/rustq/meta/lower/enum_test.exs @@ -0,0 +1,78 @@ +defmodule RustQ.Meta.Lower.EnumTest do + use RustQ.Test, async: true + + defmodule Lists do + @moduledoc "Lists used after an `Enum` call, or by values built from them." + + use RustQ.Meta + + alias RustQ.Type, as: R + + @spec any_long(R.vec(String.t())) :: R.vec(String.t()) + defrust any_long(names) do + if Enum.any?(names, fn name -> name.len() > 3 end), do: names, else: Vec.new() + end + + @spec lengths(R.vec(String.t())) :: R.vec(R.usize()) + defrust(lengths(names), do: Enum.map(names, fn name -> name.len() end)) + + @spec padded(R.vec(String.t())) :: R.vec(R.usize()) + defrust padded(names) do + first = names.first() + + Enum.map(names, fn name -> + name.len() + first.map(fn first -> first.len() end).unwrap_or(0) + end) + end + + @spec kept(R.vec(String.t())) :: {R.vec(String.t()), R.vec(String.t())} + defrust kept(names) do + long = Enum.filter(names, fn name -> name.len() > 3 end) + {long, names} + end + + @spec by_length(R.slice(String.t())) :: HashMap.t(R.usize(), R.ref(String.t())) + defrust by_length(names) do + for name <- names, + {:some, length} <- [nonzero(name)], + into: HashMap.new(), + do: {length, name} + end + + @spec checked(R.vec(String.t())) :: R.vec(R.bool()) + defrust(checked(names), do: Enum.map(names, fn name -> unseen(name, HashSet.new()) end)) + + @spec unseen(R.ref(String.t()), R.ref(HashSet.t(String.t()))) :: R.bool() + defrust(unseen(name, seen), do: not seen.contains(name)) + + @spec nonzero(R.ref(String.t())) :: R.option(R.usize()) + defrust(nonzero(name), do: if(name.is_empty(), do: nil, else: some(name.len()))) + end + + test "a question borrows the list it asks about" do + assert rust_source!(Lists, :any_long) =~ "names.iter().any(|name| name.len() > 3)" + end + + test "the list's last use takes it" do + assert rust_source!(Lists, :lengths) =~ "names.into_iter().map(|name| name.len())" + end + + test "a list a later value was built from is borrowed" do + assert rust_source!(Lists, :padded) =~ ~r/names\s*\.iter\(\)\s*\.map\(/ + end + + test "items kept from a list still in use are copies" do + assert rust_source!(Lists, :kept) =~ ~r/names\s*\.iter\(\)\s*\.cloned\(\)\s*\.filter_map\(/ + end + + test "a closure borrows a new value for an argument taken by reference" do + assert rust_source!(Lists, :checked) =~ "unseen(&name, &HashSet::new())" + end + + test "a generator over one value matches it beside the item" do + source = rust_source!(Lists, :by_length) + + assert source =~ ".map(|name| (name, nonzero(name)))" + refute source =~ "vec![" + end +end diff --git a/test/rustq/meta/provides_test.exs b/test/rustq/meta/provides_test.exs index fc87681..d37d9d3 100644 --- a/test/rustq/meta/provides_test.exs +++ b/test/rustq/meta/provides_test.exs @@ -62,6 +62,26 @@ defmodule RustQ.Meta.ProvidesTest do assert source =~ "fn second(entry: &Entry) -> Option" end + test "a module's Rust modules are reached through its alias" do + defmodule RustModuleConsumerCase do + use RustQ.Meta + + alias RustQ.Meta.ProvidedCase, as: Runtime + alias RustQ.Type, as: R + + @spec field() :: R.u32() + defrust(field(), do: Runtime.Entry.FIRST) + + @spec limit() :: R.u32() + defrust(limit(), do: Runtime.Limits.MAX) + end + + source = RustModuleConsumerCase.__rustq_source__() + + assert source =~ "provided::Entry::FIRST" + assert source =~ "provided_limits::MAX" + end + test "an alias used only in a body is used" do warnings = capture_io(:stderr, fn -> diff --git a/test/support/provided_case.ex b/test/support/provided_case.ex index 886a1f7..e34fca1 100644 --- a/test/support/provided_case.ex +++ b/test/support/provided_case.ex @@ -1,8 +1,11 @@ defmodule RustQ.Meta.ProvidedCase do @moduledoc """ Test fixture module that provides `templates/provided_case.rs`, as a library ships a Rust - runtime its generated code includes. + runtime its generated code includes, and maps the Rust modules its generated code defines. """ use RustQ.Meta + + defrustmod(__MODULE__, as: [:provided]) + defrustmod(Limits, as: [:provided_limits]) end From 69469cc40e694dfb9af0b13f078921f9b9bc6c05 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 25 Oct 2026 16:51:30 +0400 Subject: [PATCH 2/4] Never take a list behind a reference, and lower Enum filters as clippy prefers --- CHANGELOG.md | 7 +++ guides/using-rustq-well.md | 7 ++- lib/rustq/codegen/decoders/pat.ex | 5 ++ lib/rustq/codegen/dispatch.ex | 3 +- lib/rustq/meta/lower.ex | 23 ++++++-- lib/rustq/meta/lower/enum.ex | 80 ++++++++++++++------------ lib/rustq/rust/ast.ex | 8 ++- lib/rustq/rust/ast/render.ex | 2 + native/rustq_nif/src/decode.rs | 4 ++ native/rustq_nif/src/generated_ast.rs | 5 ++ test/rustq/meta/lower/defrust_test.exs | 8 +-- test/rustq/meta/lower/enum_test.exs | 16 +++++- test/support/rustq_ast_samples.ex | 4 ++ 13 files changed, 122 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 573e329..9a636b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ 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 diff --git a/guides/using-rustq-well.md b/guides/using-rustq-well.md index 642390d..c2593a0 100644 --- a/guides/using-rustq-well.md +++ b/guides/using-rustq-well.md @@ -323,8 +323,11 @@ 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 new value passed where a -reference is expected, as `HashSet.new()`, is borrowed. +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. A generator over one value matches it beside the item, so `for record <- records, {:some, id} <- [record_id(record)], do: {id, record}` diff --git a/lib/rustq/codegen/decoders/pat.ex b/lib/rustq/codegen/decoders/pat.ex index 1b1a724..eaf499d 100644 --- a/lib/rustq/codegen/decoders/pat.ex +++ b/lib/rustq/codegen/decoders/pat.ex @@ -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")) diff --git a/lib/rustq/codegen/dispatch.ex b/lib/rustq/codegen/dispatch.ex index a03005f..08886c5 100644 --- a/lib/rustq/codegen/dispatch.ex +++ b/lib/rustq/codegen/dispatch.ex @@ -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)] diff --git a/lib/rustq/meta/lower.ex b/lib/rustq/meta/lower.ex index 04cb1e0..7bdafa4 100644 --- a/lib/rustq/meta/lower.ex +++ b/lib/rustq/meta/lower.ex @@ -2021,7 +2021,13 @@ defmodule RustQ.Meta.Lower do {filters, rest} = Enum.split_while(rest, &(not generator?(&1))) item_type = comprehension_item_type(collection, context) root = root_variable(collection) - reused? = nested? and root != nil and root not in generated + + reused? = + root != nil and + ((nested? and root not in generated) or + (item_type != nil and root not in generated and + in_use?(collection, rest ++ [body], context))) + {stage, binder} = generator_stage(pattern, collection, filters, item_type, reused?) bound = context_with_match_pattern(pattern, item_type, context) @@ -2165,7 +2171,8 @@ defmodule RustQ.Meta.Lower do # A generator that cannot fail and has no filters iterates as it is; otherwise its items are # matched, filtered, and reduced to the variables the pattern binds. # A later generator over a collection from outside the comprehension iterates it once per - # item of the earlier ones, so it copies its items rather than consuming it. + # item of the earlier ones, so it copies its items rather than consuming it, as does a + # generator over a collection still in use or reached through a reference. defp generator_stage(pattern, collection, filters, item_type, reused?) do items = cond do @@ -2182,6 +2189,12 @@ defmodule RustQ.Meta.Lower do end end + defp behind_reference?({name, _, ast_context}, name, _context) when is_atom(ast_context), + do: false + + defp behind_reference?(_field, name, %Context{vars: vars}), + do: match?(%Type{kind: kind} when kind in [:ref, :mut_ref], Map.get(vars, name)) + defp root_variable({name, _, ast_context}) when is_atom(name) and is_atom(ast_context), do: name # The variable a collection is, or is a field of; a call or literal makes a fresh one. defp root_variable({{:., _, [receiver, _field]}, meta, []}), @@ -3432,14 +3445,16 @@ defmodule RustQ.Meta.Lower do end # A collection is in use when the expressions after this one use it, a value they use was - # built from it, or the call's other arguments use it. + # built from it, or the call's other arguments use it. A field reached through a reference + # is never taken, since the reference does not own it. defp in_use?(collection, other_args, %Context{} = context) do case root_variable(collection) do nil -> false name -> - MapSet.member?(context.in_use, name) or + behind_reference?(collection, name, context) or + MapSet.member?(context.in_use, name) or MapSet.member?(sources(used_variables(other_args), context.built_from), name) or MapSet.member?(used_variables(other_args), name) end diff --git a/lib/rustq/meta/lower/enum.ex b/lib/rustq/meta/lower/enum.ex index 6691626..106c03a 100644 --- a/lib/rustq/meta/lower/enum.ex +++ b/lib/rustq/meta/lower/enum.ex @@ -41,7 +41,7 @@ defmodule RustQ.Meta.Lower.Enum do defp lower_call(%Call{function: :count, args: [collection, predicate]}, %Context{} = context) do iterator = iterate(collection, :question, [predicate], context) - {:ok, iterator |> lower_filter_map(predicate, false, context, :count) |> cast_i64()} + {:ok, iterator |> lower_filter(predicate, false, collection, context, :count) |> cast_i64()} end defp lower_call(%Call{function: :empty?, args: [collection]}, %Context{} = context), @@ -63,7 +63,7 @@ defmodule RustQ.Meta.Lower.Enum do defp lower_call(%Call{function: :find, args: [collection, predicate]}, %Context{} = context) do iterator = iterate(collection, :keeps_items, [predicate], context) - {:ok, lower_filter_map(iterator, predicate, false, context, :find_map)} + {:ok, lower_filter(iterator, predicate, false, collection, context, :find)} end defp lower_call( @@ -74,7 +74,7 @@ defmodule RustQ.Meta.Lower.Enum do {:ok, iterator - |> lower_filter_map(predicate, false, context, :find_map) + |> lower_filter(predicate, false, collection, context, :find) |> method_chain(:unwrap_or, [context.lower.(default)])} end @@ -350,26 +350,26 @@ defmodule RustQ.Meta.Lower.Enum do do: typed_mapper(mapper, collection, iteration, context), else: mapper - collection = iterate(collection, iteration, context) + iterator = iterate(collection, iteration, context) case operation do :filter -> - lower_filter_map(collection, mapper, false, context) + lower_filter(iterator, mapper, false, collection, context, :collect) :reject -> - lower_filter_map(collection, mapper, true, context) + lower_filter(iterator, mapper, true, collection, context, :collect) :flat_map -> - collection |> method_chain(:flat_map, [mapper(mapper, context)]) |> collect(context) + iterator |> method_chain(:flat_map, [mapper(mapper, context)]) |> collect(context) :each -> - method_chain(collection, :for_each, [mapper(mapper, context)]) + method_chain(iterator, :for_each, [mapper(mapper, context)]) :any? -> - method_chain(collection, :any, [mapper(mapper, context)]) + method_chain(iterator, :any, [mapper(mapper, context)]) :all? -> - method_chain(collection, :all, [mapper(mapper, context)]) + method_chain(iterator, :all, [mapper(mapper, context)]) end end @@ -377,13 +377,13 @@ defmodule RustQ.Meta.Lower.Enum do defp operation_use(operation) when operation in [:filter, :reject], do: :keeps_items defp operation_use(_operation), do: :maps_items - defp lower_filter_map(collection, predicate, negate?, context), - do: lower_filter_map(collection, predicate, negate?, context, :collect) - - defp lower_filter_map( - collection, + # `filter` and `find` hand their predicate a reference to each item; a number or atom is + # copied out of it, so the predicate reads the item as its own type. + defp lower_filter( + iterator, {:fn, _, [{:->, _, [[argument], body]}]}, negate?, + collection, context, terminal ) do @@ -391,27 +391,21 @@ defmodule RustQ.Meta.Lower.Enum do condition = context.lower_closure_body.(body, nil) condition = if negate?, do: %AST.UnaryOp{op: :not, expr: condition}, else: condition - closure = %AST.Closure{ - args: [name], - body: %AST.If{ - condition: condition, - then: [%AST.Return{expr: %AST.Some{expr: %AST.Var{name: name}}}], - else: [%AST.Return{expr: %AST.None{}}] - } - } - - if terminal == :find_map do - method_chain(collection, :find_map, [closure]) - else - filtered = method_chain(collection, :filter_map, [closure]) + argument = + if copy?(collection_inner(context.type_of.(collection))), + do: %AST.PatRef{pattern: %AST.PatVar{name: name}}, + else: name + + closure = %AST.Closure{args: [argument], body: condition} - if terminal == :collect, - do: collect(filtered, context), - else: method_chain(filtered, terminal) + case terminal do + :find -> method_chain(iterator, :find, [closure]) + :count -> iterator |> method_chain(:filter, [closure]) |> method_chain(:count) + :collect -> iterator |> method_chain(:filter, [closure]) |> collect(context) end end - defp lower_filter_map(_collection, other, _negate?, _context, _terminal) do + defp lower_filter(_iterator, other, _negate?, _collection, _context, _terminal) do Diagnostic.lower( :unsupported_enum_filter_predicate, other, @@ -501,10 +495,19 @@ defmodule RustQ.Meta.Lower.Enum do receiver = lower_collection(collection, context) case iteration do - :owned -> method_chain(receiver, :into_iter) - :borrowed -> method_chain(receiver, :iter) - :copied -> receiver |> method_chain(:iter) |> method_chain(:copied) - :cloned -> receiver |> method_chain(:iter) |> method_chain(:cloned) + :owned -> + if borrowed_collection?(context.type_of.(collection)), + do: method_chain(receiver, :iter), + else: method_chain(receiver, :into_iter) + + :borrowed -> + method_chain(receiver, :iter) + + :copied -> + receiver |> method_chain(:iter) |> method_chain(:copied) + + :cloned -> + receiver |> method_chain(:iter) |> method_chain(:cloned) end end @@ -529,6 +532,11 @@ defmodule RustQ.Meta.Lower.Enum do end end + # A slice or a reference to a collection already lends its items, as `iter()` says. + defp borrowed_collection?(%Type{kind: kind}) when kind in [:slice, :ref], do: true + defp borrowed_collection?(%Type{ast: %AST.TypeRef{}}), do: true + defp borrowed_collection?(_type), do: false + defp copy?(%Type{} = type), do: Type.category(type) in [:integer, :number, :boolean, :atom] defp copy?(_type), do: false diff --git a/lib/rustq/rust/ast.ex b/lib/rustq/rust/ast.ex index 908da1f..0b8583e 100644 --- a/lib/rustq/rust/ast.ex +++ b/lib/rustq/rust/ast.ex @@ -68,6 +68,7 @@ defmodule RustQ.Rust.AST do PatOk, PatPath, PatPathTuple, + PatRef, PatSlice, PatSome, PatStruct, @@ -203,6 +204,7 @@ defmodule RustQ.Rust.AST do | PatPathTuple.t() | PatStruct.t() | PatSlice.t() + | PatRef.t() @type vis :: :pub | :crate | nil @@ -738,6 +740,9 @@ defmodule RustQ.Rust.AST do defnode(PatSome, :pat, [:pattern], type: quote(do: %__MODULE__{pattern: AST.pat()})) + # `&pattern`, matching what a reference points to, as a closure over `&T` items copies one. + defnode(PatRef, :pat, [:pattern], type: quote(do: %__MODULE__{pattern: AST.pat()})) + defnode(PatAtomGuard, :pat, [:name, module: [:atoms]], type: quote(do: %__MODULE__{name: atom(), module: [atom() | String.t()]}) ) @@ -875,7 +880,8 @@ defmodule RustQ.Rust.AST do PatErr, PatPathTuple, PatStruct, - PatSlice + PatSlice, + PatRef ] end end diff --git a/lib/rustq/rust/ast/render.ex b/lib/rustq/rust/ast/render.ex index 736f2d3..cdf1ded 100644 --- a/lib/rustq/rust/ast/render.ex +++ b/lib/rustq/rust/ast/render.ex @@ -69,6 +69,7 @@ defmodule RustQ.Rust.AST.Render do PatOk, PatPath, PatPathTuple, + PatRef, PatSlice, PatSome, PatStruct, @@ -836,6 +837,7 @@ defmodule RustQ.Rust.AST.Render do def render_pattern(%PatLiteral{value: value}) when is_atom(value), do: Atom.to_string(value) def render_pattern(%PatNone{}), do: "None" def render_pattern(%PatSome{pattern: pattern}), do: ["Some(", render_pattern(pattern), ")"] + def render_pattern(%PatRef{pattern: pattern}), do: ["&", render_pattern(pattern)] def render_pattern(%PatOk{pattern: pattern}), do: ["Ok(", render_pattern(pattern), ")"] def render_pattern(%PatErr{pattern: pattern}), do: ["Err(", render_pattern(pattern), ")"] diff --git a/native/rustq_nif/src/decode.rs b/native/rustq_nif/src/decode.rs index 4ec3e2f..389dd2c 100644 --- a/native/rustq_nif/src/decode.rs +++ b/native/rustq_nif/src/decode.rs @@ -827,6 +827,10 @@ pub(crate) fn parse_some_pat(pat: Pat) -> NifResult { parse_syn::(quote!(Some(#pat))) } +pub(crate) fn parse_ref_pat(pat: Pat) -> NifResult { + parse_syn::(quote!(&#pat)) +} + pub(crate) fn parse_ok_pat(pat: Pat) -> NifResult { parse_syn::(quote!(Ok(#pat))) } diff --git a/native/rustq_nif/src/generated_ast.rs b/native/rustq_nif/src/generated_ast.rs index f7f9ca2..40ec5b3 100644 --- a/native/rustq_nif/src/generated_ast.rs +++ b/native/rustq_nif/src/generated_ast.rs @@ -99,6 +99,7 @@ pub(crate) mod ast_modules { pub(crate) const PAT_PATH_TUPLE: &str = "Elixir.RustQ.Rust.AST.PatPathTuple"; pub(crate) const PAT_STRUCT: &str = "Elixir.RustQ.Rust.AST.PatStruct"; pub(crate) const PAT_SLICE: &str = "Elixir.RustQ.Rust.AST.PatSlice"; + pub(crate) const PAT_REF: &str = "Elixir.RustQ.Rust.AST.PatRef"; } pub(crate) fn atom(env: Env, name: &str) -> NifResult { Atom::from_str(env, name) @@ -289,6 +290,7 @@ pub(crate) fn decode_ast_pat(term: Term) -> NifResult { ast_modules::PAT_PATH_TUPLE => decode_pat_path_tuple(term), ast_modules::PAT_STRUCT => decode_pat_struct(term), ast_modules::PAT_SLICE => decode_pat_slice(term), + ast_modules::PAT_REF => decode_pat_ref(term), _ => Err(rustler::Error::BadArg), } } @@ -611,6 +613,9 @@ pub(crate) fn decode_pat_literal<'a>(term: Term<'a>) -> NifResult { pub(crate) fn decode_pat_some<'a>(term: Term<'a>) -> NifResult { super::parse_some_pat(required_pat(term, "pattern")?) } +pub(crate) fn decode_pat_ref<'a>(term: Term<'a>) -> NifResult { + super::parse_ref_pat(required_pat(term, "pattern")?) +} pub(crate) fn decode_pat_ok<'a>(term: Term<'a>) -> NifResult { super::parse_ok_pat(required_pat(term, "pattern")?) } diff --git a/test/rustq/meta/lower/defrust_test.exs b/test/rustq/meta/lower/defrust_test.exs index 700f6b5..d913421 100644 --- a/test/rustq/meta/lower/defrust_test.exs +++ b/test/rustq/meta/lower/defrust_test.exs @@ -683,9 +683,7 @@ defmodule RustQ.Meta.Lower.DefrustTest do assert rust_source!(EnumPipelineCase, :product) =~ ".into_iter().fold(1, |product, value| product * value)" - positives = rust_source!(EnumPipelineCase, :positives) - assert positives =~ ".filter_map(|value| if value > 0" - assert positives =~ "Some(value)" + assert rust_source!(EnumPipelineCase, :positives) =~ ".filter(|&value| value > 0)" assert rust_source!(EnumPipelineCase, :duplicates) =~ ".flat_map(|value| vec![value, value]).collect::>()" @@ -736,9 +734,9 @@ defmodule RustQ.Meta.Lower.DefrustTest do has_square = rust_source!(RemotePipelineCase, :has_square) assert positive_square_sum =~ - ~r/filter_map.*collect::>.*\.map.*collect::>.*\.fold/s + ~r/filter\(.*collect::>.*\.map.*collect::>.*\.fold/s - assert count_positive =~ ~r/filter_map.*collect::>.*\.len\(\) as i64/s + assert count_positive =~ ~r/filter\(.*collect::>.*\.len\(\) as i64/s assert has_square =~ ~r/\.map.*collect::>.*\.any/s assert rust_source!(RemotePipelineCase, :first_or) =~ ".into_iter().next().unwrap_or(default)" assert rust_source!(RemotePipelineCase, :trimmed) =~ ".trim().to_string()" diff --git a/test/rustq/meta/lower/enum_test.exs b/test/rustq/meta/lower/enum_test.exs index a345fea..10a0b6c 100644 --- a/test/rustq/meta/lower/enum_test.exs +++ b/test/rustq/meta/lower/enum_test.exs @@ -45,6 +45,14 @@ defmodule RustQ.Meta.Lower.EnumTest do @spec unseen(R.ref(String.t()), R.ref(HashSet.t(String.t()))) :: R.bool() defrust(unseen(name, seen), do: not seen.contains(name)) + @type roster :: %{required(:names) => R.vec(String.t())} + + @spec has_long(R.ref(roster())) :: R.bool() + defrust(has_long(roster), do: Enum.any?(roster.names, fn name -> name.len() > 3 end)) + + @spec long_names(R.ref(roster())) :: R.vec(String.t()) + defrust(long_names(roster), do: for(name <- roster.names, name.len() > 3, do: name)) + @spec nonzero(R.ref(String.t())) :: R.option(R.usize()) defrust(nonzero(name), do: if(name.is_empty(), do: nil, else: some(name.len()))) end @@ -62,7 +70,13 @@ defmodule RustQ.Meta.Lower.EnumTest do end test "items kept from a list still in use are copies" do - assert rust_source!(Lists, :kept) =~ ~r/names\s*\.iter\(\)\s*\.cloned\(\)\s*\.filter_map\(/ + assert rust_source!(Lists, :kept) =~ + ~r/names\s*\.iter\(\)\s*\.cloned\(\)\s*\.filter\(\|name\| name\.len\(\) > 3\)/ + end + + test "a list reached through a reference is borrowed" do + assert rust_source!(Lists, :has_long) =~ ~r/roster\s*\.names\s*\.iter\(\)\s*\.any\(/ + assert rust_source!(Lists, :long_names) =~ ~r/roster\s*\.names\s*\.iter\(\)\s*\.cloned\(\)/ end test "a closure borrows a new value for an argument taken by reference" do diff --git a/test/support/rustq_ast_samples.ex b/test/support/rustq_ast_samples.ex index 485e40a..d03024d 100644 --- a/test/support/rustq_ast_samples.ex +++ b/test/support/rustq_ast_samples.ex @@ -135,6 +135,7 @@ defmodule RustQ.ASTSamples do defp semantic_fragment(:pat_path_tuple), do: "Event::Click(click) =>" defp semantic_fragment(:pat_struct), do: "Click { name } =>" defp semantic_fragment(:pat_slice), do: "[head, tail @ ..] =>" + defp semantic_fragment(:pat_ref), do: "&value =>" defp semantic_fragment(_), do: "" def sample_for(:use), do: %AST.Use{group: {[:std], [:fmt, :io]}} @@ -511,6 +512,9 @@ defmodule RustQ.ASTSamples do def sample_for(:pat_struct), do: match_sample(:pat_struct_sample, P.struct([:Click], name: P.var(:name))) + def sample_for(:pat_ref), + do: match_sample(:pat_ref_sample, %AST.PatRef{pattern: A.pat(:value)}, args: [value: "&i64"]) + def sample_for(:pat_slice) do match_sample( :pat_slice_sample, From 3087e49ce07f1446bc4b5f104af74c3df5c60efa Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 25 Oct 2026 17:41:57 +0400 Subject: [PATCH 3/4] Lower MapSet, Map on HashMaps, and the rest of Enum's everyday functions --- CHANGELOG.md | 13 + guides/using-rustq-well.md | 25 ++ lib/rustq/meta/lower.ex | 115 +++++- lib/rustq/meta/lower/collection.ex | 113 ++++++ lib/rustq/meta/lower/enum.ex | 534 ++++++++++++++++++++++++- lib/rustq/meta/lower/map.ex | 304 +++++++++++++- lib/rustq/meta/lower/map_set.ex | 166 ++++++++ lib/rustq/meta/lower/stdlib.ex | 12 +- lib/rustq/meta/type.ex | 11 + lib/rustq/meta/typing.ex | 7 + test/rustq/meta/lower/enum_test.exs | 111 ++++- test/rustq/meta/lower/map_set_test.exs | 47 +++ test/rustq/meta/lower/map_test.exs | 70 ++++ test/rustq/meta_test.exs | 2 +- 14 files changed, 1483 insertions(+), 47 deletions(-) create mode 100644 lib/rustq/meta/lower/collection.ex create mode 100644 lib/rustq/meta/lower/map_set.ex create mode 100644 test/rustq/meta/lower/map_set_test.exs create mode 100644 test/rustq/meta/lower/map_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a636b4..9bf907f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ - 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. - 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. diff --git a/guides/using-rustq-well.md b/guides/using-rustq-well.md index c2593a0..9796ecb 100644 --- a/guides/using-rustq-well.md +++ b/guides/using-rustq-well.md @@ -329,6 +329,31 @@ 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. + +`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 diff --git a/lib/rustq/meta/lower.ex b/lib/rustq/meta/lower.ex index 7bdafa4..dfbe8e7 100644 --- a/lib/rustq/meta/lower.ex +++ b/lib/rustq/meta/lower.ex @@ -128,7 +128,7 @@ defmodule RustQ.Meta.Lower do expressions |> Enum.drop(index) |> used_variables() |> MapSet.union(context.in_use) current = used_variables([expression]) - sources = sources(MapSet.union(later, current), built_from) + sources = sources(MapSet.union(later, current), built_from, context.vars) %{context | in_use: MapSet.union(later, sources), built_from: built_from} end) @@ -165,19 +165,41 @@ defmodule RustQ.Meta.Lower do |> elem(1) end - defp sources(names, built_from) do - Enum.reduce(names, MapSet.new(), fn name, found -> add_sources(name, built_from, found) end) + # Only a value that may hold a reference can borrow from what it was built from; one whose + # type is unknown may. + defp sources(names, built_from, vars) do + Enum.reduce(names, MapSet.new(), fn name, found -> + add_sources(name, built_from, vars, found) + end) end - defp add_sources(name, built_from, found) do - built_from - |> Map.get(name, MapSet.new()) - |> Enum.reject(&MapSet.member?(found, &1)) - |> Enum.reduce(found, fn source, found -> - add_sources(source, built_from, MapSet.put(found, source)) - end) + defp add_sources(name, built_from, vars, found) do + if may_borrow?(Map.get(vars, name)) do + built_from + |> Map.get(name, MapSet.new()) + |> Enum.reject(&MapSet.member?(found, &1)) + |> Enum.reduce(found, fn source, found -> + add_sources(source, built_from, vars, MapSet.put(found, source)) + end) + else + found + end end + defp may_borrow?(%Type{kind: kind}) when kind in [:ref, :mut_ref, :slice], do: true + defp may_borrow?(%Type{ast: ast}), do: borrowing_type?(ast) + defp may_borrow?(_unknown), do: true + + defp borrowing_type?(%AST.TypeRef{}), do: true + defp borrowing_type?(%AST.TypePath{lifetimes: [_ | _]}), do: true + defp borrowing_type?(%AST.TypeRaw{}), do: true + + defp borrowing_type?(%_{} = type), + do: type |> Map.from_struct() |> Enum.any?(fn {_field, value} -> borrowing_type?(value) end) + + defp borrowing_type?(types) when is_list(types), do: Enum.any?(types, &borrowing_type?/1) + defp borrowing_type?(_other), do: false + # A body returning nothing runs its last expression for its effects, as a statement. defp lower_final(final, statement_context, %Context{return_type: %Type{kind: :unit}}) when final not in [:ok, nil], @@ -204,6 +226,7 @@ defmodule RustQ.Meta.Lower do end @keyed_collections [:HashMap, :HashSet, :BTreeMap, :BTreeSet] + @stdlib_modules [:Kernel, :Enum, :List, :Map, :MapSet, :String, :Tuple, :Range] @iterator_methods StdMethods.iterator() @iterator_option_methods StdMethods.iterator_option() @mutating_methods StdMethods.mutating() @@ -280,8 +303,11 @@ defmodule RustQ.Meta.Lower do defp lower_statement({:for, _, args} = expression, %Context{} = context) when is_list(args) do case comprehension_parts(expression) do - {qualifiers, [do: body]} -> lower_for_loop(qualifiers, body, context) - _other -> %AST.ExprStmt{expr: lower_comprehension(expression, nil, context)} + {qualifiers, [do: body]} -> + lower_for_loop(indexed_generators(qualifiers, context), body, context) + + _other -> + %AST.ExprStmt{expr: lower_comprehension(expression, nil, context)} end end @@ -930,7 +956,7 @@ defmodule RustQ.Meta.Lower do %Type{} = expected_type, %Context{} = context ) - when module in [:Kernel, :Enum, :List, :Map, :String, :Tuple, :Range] do + when module in @stdlib_modules do case Stdlib.lower(call, stdlib_context(context, expected_type)) do {:ok, lowered} -> lowered :unsupported -> lower_remote_or_method_call(receiver, function, args, context) @@ -1144,7 +1170,7 @@ defmodule RustQ.Meta.Lower do {{:., _, [receiver = {:__aliases__, _, [module]}, function]}, _, args} = call, %Context{} = context ) - when module in [:Kernel, :Enum, :List, :Map, :String, :Tuple, :Range] do + when module in @stdlib_modules do case Stdlib.lower(call, stdlib_context(context)) do {:ok, lowered} -> lowered :unsupported -> lower_remote_or_method_call(receiver, function, args, context) @@ -1957,6 +1983,7 @@ defmodule RustQ.Meta.Lower do # its closures never outlive the outer item they borrow. defp lower_comprehension(expression, expected_type, %Context{} = context) do {qualifiers, opts} = comprehension_parts(expression) + qualifiers = indexed_generators(qualifiers, context) if Keyword.has_key?(opts, :reduce) do Diagnostic.lower( @@ -1988,10 +2015,31 @@ defmodule RustQ.Meta.Lower do end nil -> - pipeline |> lower_expr(context) |> collect_as(@any_vec) + pipeline |> lower_expr(context) |> collect_as(into_target(opts[:into])) end end + # Without an expected type, `into:` names the collection: `HashMap.new()`, `%{}`, + # `Map.new()`, `HashSet.new()`, or `MapSet.new()`; otherwise a list, as in Elixir. + defp into_target({:%{}, _, []}), do: inferred_collection(:HashMap, 2) + + defp into_target({{:., _, [{:__aliases__, _, [module]}, :new]}, _, []}) + when module in [:Map, :HashMap, :BTreeMap], + do: inferred_collection(if(module == :BTreeMap, do: :BTreeMap, else: :HashMap), 2) + + defp into_target({{:., _, [{:__aliases__, _, [module]}, :new]}, _, []}) + when module in [:MapSet, :HashSet, :BTreeSet], + do: inferred_collection(if(module == :BTreeSet, do: :BTreeSet, else: :HashSet), 1) + + defp into_target(_into), do: @any_vec + + defp inferred_collection(collection, arity) do + %AST.TypePath{ + parts: [collection], + generics: List.duplicate(%AST.TypeRaw{source: "_"}, arity) + } + end + # A comprehension bound in a statement states what it collects into, since Rust may only # learn it from a later borrow; without a collection, it makes a list, as in Elixir. defp collect_as(lowered, nil), do: lowered @@ -2005,6 +2053,38 @@ defmodule RustQ.Meta.Lower do defp collect_target(%Type{ast: ast} = type), do: if(slice_type?(type), do: @any_vec, else: ast) defp collect_target(nil), do: @any_vec + # `{item, index} <- Enum.with_index(list)` iterates `list.iter().enumerate()`, binding + # `{index, item}`, rather than collecting the pairs first. The list is borrowed unless the + # loop is its last use. + defp indexed_generators(qualifiers, %Context{} = context) do + Enum.map(qualifiers, fn + {:<-, meta, [pattern, {{:., _, [{:__aliases__, _, [:Enum]}, :with_index]}, _, [list]}]} = + generator -> + method = + if borrowed_list?(list, List.delete(qualifiers, generator), context), + do: :iter, + else: :into_iter + + enumerated = quote(do: unquote(list) |> unquote(method)() |> enumerate()) + {:<-, meta, indexed_generator(pattern, enumerated)} + + qualifier -> + qualifier + end) + end + + defp indexed_generator({item, index}, enumerated), do: [{index, item}, enumerated] + + defp indexed_generator(pattern, enumerated), + do: [pattern, quote(do: unquote(enumerated) |> map(fn {index, item} -> {item, index} end))] + + defp borrowed_list?(list, other_qualifiers, context) do + case Typing.synth(list, typing_env(context)) do + %Type{kind: kind} when kind in [:slice, :ref, :mut_ref] -> true + _owned -> in_use?(list, other_qualifiers, context) + end + end + defp comprehension_parts({:for, _, args}) do {opts, qualifiers} = args |> Enum.reverse() |> Enum.split_while(&Keyword.keyword?/1) {Enum.reverse(qualifiers), opts |> Enum.reverse() |> Enum.concat()} @@ -3455,7 +3535,10 @@ defmodule RustQ.Meta.Lower do name -> behind_reference?(collection, name, context) or MapSet.member?(context.in_use, name) or - MapSet.member?(sources(used_variables(other_args), context.built_from), name) or + MapSet.member?( + sources(used_variables(other_args), context.built_from, context.vars), + name + ) or MapSet.member?(used_variables(other_args), name) end end diff --git a/lib/rustq/meta/lower/collection.ex b/lib/rustq/meta/lower/collection.ex new file mode 100644 index 0000000..96f3c06 --- /dev/null +++ b/lib/rustq/meta/lower/collection.ex @@ -0,0 +1,113 @@ +defmodule RustQ.Meta.Lower.Collection do + @moduledoc """ + Keyed collections, `HashMap`, `BTreeMap`, `HashSet`, and `BTreeSet`, as `RustQ.Meta.Lower.Map` + and `RustQ.Meta.Lower.MapSet` lower them. + + Elixir never changes a collection, so a function returning an updated one takes it only + where nothing uses it afterwards, and otherwise updates a copy: + + { let mut rustq_updated = scores; rustq_updated.insert(name, 1); rustq_updated } + """ + + alias RustQ.Meta.Lower.Stdlib.Context + alias RustQ.Meta.Type + alias RustQ.Rust.AST + + @maps [:HashMap, :BTreeMap] + @sets [:HashSet, :BTreeSet] + + @type keyed :: {:map, Type.t(), Type.t()} | {:set, Type.t()} + + @doc "The keyed collection a type is, or is a reference to, with its key and value types." + @spec keyed(Type.t() | nil) :: keyed() | nil + def keyed(%Type{kind: kind} = type) when kind in [:ref, :mut_ref], + do: type |> Type.ref_inner() |> keyed() + + def keyed(%Type{kind: :alias, meta: %{target: %Type{} = target}}), do: keyed(target) + + def keyed(%Type{ast: %AST.TypePath{parts: parts, generics: [key, value]}}) do + if List.last(parts) in @maps, do: {:map, Type.ast_type(key), Type.ast_type(value)} + end + + def keyed(%Type{ast: %AST.TypePath{parts: parts, generics: [item]}}) do + if List.last(parts) in @sets, do: {:set, Type.ast_type(item)} + end + + def keyed(_type), do: nil + + @doc "The type of a `HashMap` from `key` to `value`." + @spec map(Type.t(), Type.t()) :: Type.t() + def map(%Type{} = key, %Type{} = value), do: path_type(:HashMap, [key.ast, value.ast]) + + @doc "The type of a `HashSet` of `item`." + @spec set(Type.t()) :: Type.t() + def set(%Type{} = item), do: path_type(:HashSet, [item.ast]) + + defp path_type(collection, generics) do + ast = %AST.TypePath{parts: [collection], generics: generics} + %Type{kind: :type, rust: RustQ.Rust.render_type(ast), ast: ast, meta: %{}} + end + + @doc "The keyed collection `collection` is, by its type." + @spec keyed_of(Macro.t(), Context.t()) :: keyed() | nil + def keyed_of(collection, %Context{} = context), do: keyed(context.type_of.(collection)) + + @doc """ + An update of `collection`: `statements` receives the variable holding the collection, or a + copy of it when `collection` is still in use, and the block returns that variable. + """ + @spec updated(Macro.t(), [Macro.t()], (AST.expr() -> [AST.stmt()]), Context.t()) :: AST.expr() + def updated(collection, others, statements, %Context{} = context) do + name = :rustq_updated + variable = %AST.Var{name: name} + + %AST.BlockExpr{ + body: + [ + %AST.Let{ + pattern: %AST.PatVar{name: name}, + expr: owned(collection, others, context), + mutable: true + } + ] ++ + statements.(variable) ++ [%AST.Return{expr: variable}] + } + end + + @doc "`collection` to take: itself at its last use, otherwise a copy." + @spec owned(Macro.t(), [Macro.t()], Context.t()) :: AST.expr() + def owned(collection, others, %Context{} = context) do + lowered = context.lower.(collection) + + if context.in_use?.(collection, others), + do: %AST.MethodCall{receiver: lowered, method: :clone, args: []}, + else: lowered + end + + @doc "A key or item passed by reference, borrowed unless it already is one." + @spec borrowed(Macro.t(), Type.t(), Context.t()) :: AST.expr() + def borrowed(expression, %Type{} = type, %Context{} = context), + do: context.lower_expected.(expression, Type.ref(type)) + + @doc "A value read out of a collection: copied when that costs nothing, otherwise cloned." + @spec read_out(AST.expr(), Type.t()) :: AST.expr() + def read_out(option, %Type{} = type) do + method = if copy?(type), do: :copied, else: :cloned + %AST.MethodCall{receiver: option, method: method, args: []} + end + + @doc "Whether a value is copied as cheaply as it is borrowed: a number, atom, or reference." + @spec copy?(Type.t() | nil) :: boolean() + def copy?(%Type{kind: kind}) when kind in [:ref], do: true + def copy?(%Type{} = type), do: Type.category(type) in [:integer, :number, :boolean, :atom] + def copy?(_type), do: false + + @doc "A call on `receiver`." + @spec call(AST.expr(), atom(), [AST.expr()]) :: AST.expr() + def call(receiver, method, args \\ []), + do: %AST.MethodCall{receiver: receiver, method: method, args: args} + + @doc "A statement calling `method` on `receiver`." + @spec statement(AST.expr(), atom(), [AST.expr()]) :: AST.stmt() + def statement(receiver, method, args), do: %AST.ExprStmt{expr: call(receiver, method, args)} +end diff --git a/lib/rustq/meta/lower/enum.ex b/lib/rustq/meta/lower/enum.ex index 106c03a..86e5293 100644 --- a/lib/rustq/meta/lower/enum.ex +++ b/lib/rustq/meta/lower/enum.ex @@ -7,6 +7,8 @@ defmodule RustQ.Meta.Lower.Enum do alias RustQ.Diagnostic alias RustQ.Meta.Capture alias RustQ.Meta.Core.Call + alias RustQ.Meta.Lower.Collection + alias RustQ.Meta.Lower.Map, as: MapLowering alias RustQ.Meta.Lower.Stdlib alias RustQ.Meta.Lower.Stdlib.{Context, TypeContext} alias RustQ.Meta.Type @@ -172,6 +174,286 @@ defmodule RustQ.Meta.Lower.Enum do end end + defp lower_call(%Call{function: :into, args: [enumerable, collectable | transform]}, context) do + case empty_collectable(collectable) do + nil when transform == [] -> + {:ok, + Collection.updated( + collectable, + [enumerable], + fn collection -> + [ + Collection.statement(collection, :extend, [ + Collection.owned(enumerable, [], context) + ]) + ] + end, + context + )} + + nil -> + :unsupported + + into -> + {:ok, + MapLowering.collect(MapLowering.comprehension(enumerable, transform, into), context)} + end + end + + # Groups keep their items in order, as Elixir's do. + defp lower_call(%Call{function: :group_by, args: [enumerable, key | value]}, context) do + item = Macro.var(:rustq_item, __MODULE__) + groups = Macro.var(:rustq_groups, __MODULE__) + value = if value == [], do: item, else: apply_function(hd(value), item) + + block = + quote do + unquote(groups) = HashMap.new() + + for unquote(item) <- unquote(enumerable) do + unquote(groups).entry(unquote(apply_function(key, item))).or_default().push( + unquote(value) + ) + end + + unquote(groups) + end + + {:ok, context.lower_closure_body.(block, context.expected)} + end + + # An index counts as Rust indexes do, in `usize`. + defp lower_call(%Call{function: :with_index, args: [enumerable]}, context) do + {:ok, + enumerable + |> iterate(:keeps_items, [], context) + |> method_chain(:enumerate) + |> method_chain(:map, [ + %AST.Closure{ + args: [%AST.PatTuple{patterns: [%AST.PatVar{name: :index}, %AST.PatVar{name: :item}]}], + body: %AST.Tuple{values: [%AST.Var{name: :item}, %AST.Var{name: :index}]} + } + ]) + |> collect(context)} + end + + defp lower_call(%Call{function: :find_index, args: [enumerable, predicate]}, context) do + iteration = iteration(enumerable, :question, [predicate], context) + predicate = typed_mapper(predicate, enumerable, iteration, context) + + {:ok, + enumerable + |> iterate(iteration, context) + |> method_chain(:position, [mapper(predicate, context)])} + end + + # The function gives `some(value)` for the value to find and `nil` to go on. + defp lower_call(%Call{function: :find_value, args: [enumerable, function]}, context) do + iteration = iteration(enumerable, :maps_items, [function], context) + function = typed_mapper(function, enumerable, iteration, context) + + {:ok, + enumerable + |> iterate(iteration, context) + |> method_chain(:find_map, [mapper(function, context)])} + end + + # `{:cont, acc}` goes on and `{:halt, acc}` stops, as `Ok` and `Err` do for `try_fold`. + defp lower_call( + %Call{ + function: :reduce_while, + args: [ + enumerable, + initial, + {:fn, _, [{:->, _, [[item, accumulator], body]}]} = reducer + ] + }, + context + ) do + iteration = iteration(enumerable, :maps_items, [initial, reducer], context) + [item] = typed_items([item], enumerable, iteration, context) + closure = context.lower_closure.([accumulator, item], continuation(body)) + + {:ok, + enumerable + |> iterate(iteration, context) + |> method_chain(:try_fold, [context.lower.(initial), closure]) + |> method_chain(:unwrap_or_else, [ + %AST.Closure{args: [:rustq_halted], body: %AST.Var{name: :rustq_halted}} + ])} + end + + defp lower_call(%Call{function: :sort_by, args: [enumerable, key | order]}, context) do + case order do + [] -> {:ok, sort_by(enumerable, key, :asc, context)} + [order] when order in [:asc, :desc] -> {:ok, sort_by(enumerable, key, order, context)} + _comparator -> :unsupported + end + end + + # Elixir gives the first greatest item; the least by the reversed key is that one. + defp lower_call(%Call{function: function, args: [enumerable, key, fallback]}, context) + when function in [:max_by, :min_by] do + with {:ok, fallback} <- empty_fallback(fallback, context) do + order = if function == :max_by, do: :desc, else: :asc + + enumerable + |> iterate(:keeps_items, [key, fallback], context) + |> method_chain(:min_by_key, [key_function(enumerable, key, order, context)]) + |> fallback.() + |> then(&{:ok, &1}) + end + end + + defp lower_call(%Call{function: function, args: [enumerable, fallback]}, context) + when function in [:max, :min] do + with {:ok, fallback} <- empty_fallback(fallback, context) do + enumerable + |> iterate(:keeps_items, [fallback], context) + |> method_chain(function) + |> fallback.() + |> then(&{:ok, &1}) + end + end + + defp lower_call(%Call{function: :uniq, args: [enumerable]}, context), + do: lower_uniq(enumerable, [], context) + + defp lower_call(%Call{function: :uniq_by, args: [enumerable, key]}, context), + do: lower_uniq(enumerable, [key], context) + + defp lower_call(%Call{function: :dedup, args: [enumerable]}, context) do + {:ok, + Collection.updated( + enumerable, + [], + fn list -> [Collection.statement(list, :dedup, [])] end, + context + )} + end + + defp lower_call(%Call{function: :split_with, args: [enumerable, predicate]}, context) do + case context.type_of.(enumerable) |> collection_inner() do + %Type{} = inner -> + iterator = iterate(enumerable, :keeps_items, [predicate], context) + partitioned = lower_filter(iterator, predicate, false, enumerable, context, :partition) + {:ok, %{partitioned | generics: [Type.vec(inner).ast, %AST.TypeRaw{source: "_"}]}} + + nil -> + :unsupported + end + end + + defp lower_call(%Call{function: :at, args: [enumerable, index | default]} = call, context) do + case Stdlib.nonnegative_count(index, context) do + {:ok, index} -> + found = + context.lower.(enumerable) + |> method_chain(:get, [index]) + |> Collection.read_out( + collection_inner(context.type_of.(enumerable)) || %Type{kind: :type} + ) + + case default do + [] -> {:ok, found} + [default] -> {:ok, method_chain(found, :unwrap_or, [context.lower.(default)])} + end + + :unsupported -> + unsupported_count!(call) + end + end + + defp lower_call(%Call{function: :slice, args: [enumerable, start, amount]} = call, context) do + with {:ok, start} <- Stdlib.nonnegative_count(start, context), + {:ok, amount} <- Stdlib.nonnegative_count(amount, context) do + {:ok, + enumerable + |> iterate(:keeps_items, [], context) + |> method_chain(:skip, [start]) + |> method_chain(:take, [amount]) + |> collect(context)} + else + :unsupported -> unsupported_count!(call) + end + end + + defp lower_call(%Call{function: function, args: [enumerable, predicate]}, context) + when function in [:take_while, :drop_while] do + iterator = iterate(enumerable, :keeps_items, [predicate], context) + {:ok, lower_filter(iterator, predicate, false, enumerable, context, function)} + end + + defp lower_call( + %Call{function: :zip_with, args: [left, right, {:fn, _, [{:->, _, [[x, y], body]}]}]}, + context + ) do + lefts = iterate(left, :maps_items, [right, body], context) + + # `zip` takes anything iterable, so an owned list goes in as it is. + rights = + case iteration(right, :maps_items, [body], context) do + :owned -> context.lower.(right) + iteration -> iterate(right, iteration, context) + end + + {:ok, + lefts + |> method_chain(:zip, [rights]) + |> method_chain(:map, [context.lower_closure.([{x, y}], body)]) + |> collect(context)} + end + + defp lower_call(%Call{function: :chunk_every, args: [enumerable, count]} = call, context) do + case Stdlib.nonnegative_count(count, context) do + {:ok, count} -> + {:ok, + context.lower.(enumerable) + |> method_chain(:chunks, [count]) + |> method_chain(:map, [ + %AST.Closure{args: [:chunk], body: method_chain(%AST.Var{name: :chunk}, :to_vec)} + ]) + |> collect(context)} + + :unsupported -> + unsupported_count!(call) + end + end + + defp lower_call(%Call{function: :join, args: [enumerable | separator]}, context) do + if strings?(context.type_of.(enumerable)), + do: + {:ok, + method_chain(context.lower.(enumerable), :join, [join_separator(separator, context)])}, + else: :unsupported + end + + defp lower_call(%Call{function: :map_join, args: [enumerable | rest]}, context) do + {separator, function} = + case rest do + [function] -> {[], function} + [separator, function] -> {[separator], function} + end + + iteration = iteration(enumerable, :maps_items, [function], context) + function = typed_mapper(function, enumerable, iteration, context) + + strings = + enumerable + |> iterate(iteration, context) + |> method_chain(:map, [mapper(function, context)]) + |> then( + &%AST.MethodCall{ + receiver: &1, + method: :collect, + args: [], + generics: [Type.vec(string_type()).ast] + } + ) + + {:ok, method_chain(strings, :join, [join_separator(separator, context)])} + end + defp lower_call(%Call{}, %Context{}), do: :unsupported @spec synth(Call.t(), TypeContext.t()) :: {:ok, Type.t()} | :unsupported @@ -280,6 +562,78 @@ defmodule RustQ.Meta.Lower.Enum do end end + def synth(%Call{function: function, args: [collection | _]}, %TypeContext{} = context) + when function in [:sort_by, :uniq, :uniq_by, :dedup, :slice, :take_while, :drop_while], + do: collection_vec_result(context.type_of.(collection)) + + def synth(%Call{function: :with_index, args: [collection]}, %TypeContext{} = context) do + case collection_inner(context.type_of.(collection)) do + %Type{} = inner -> {:ok, Type.vec(Type.tuple([inner, usize()]))} + nil -> :unsupported + end + end + + def synth(%Call{function: :find_index}, %TypeContext{}), do: {:ok, Type.option(usize())} + + def synth(%Call{function: :find_value, args: [collection, function]}, %TypeContext{} = context) do + # It gives an `Option` even when what it finds has no known type. + found = + with %Type{} = inner <- collection_inner(context.type_of.(collection)), + %Type{} = found <- mapper_type(function, inner, context) do + found + else + _unknown -> %Type{kind: :type, rust: "_", ast: %AST.TypeRaw{source: "_"}, meta: %{}} + end + + {:ok, if(found.kind == :option, do: found, else: Type.option(found))} + end + + def synth(%Call{function: :reduce_while, args: [_collection, initial, _reducer]}, context), + do: type_result(context.type_of.(initial)) + + def synth(%Call{function: function, args: [collection | rest]}, %TypeContext{} = context) + when function in [:max_by, :min_by, :max, :min] do + case {collection_inner(context.type_of.(collection)), List.last(rest)} do + {nil, _fallback} -> :unsupported + {inner, {:fn, _, [{:->, _, [[], nil]}]}} -> {:ok, Type.option(inner)} + {inner, _default} -> {:ok, inner} + end + end + + def synth(%Call{function: :split_with, args: [collection, _predicate]}, context) do + with {:ok, list} <- collection_vec_result(context.type_of.(collection)) do + {:ok, Type.tuple([list, list])} + end + end + + def synth(%Call{function: :at, args: [collection | rest]}, %TypeContext{} = context) do + case {collection_inner(context.type_of.(collection)), rest} do + {%Type{} = inner, [_index]} -> {:ok, Type.option(inner)} + {%Type{} = inner, [_index, _default]} -> {:ok, inner} + _unknown -> :unsupported + end + end + + def synth(%Call{function: :chunk_every, args: [collection, _count]}, context) do + with {:ok, list} <- collection_vec_result(context.type_of.(collection)) do + {:ok, Type.vec(list)} + end + end + + def synth(%Call{function: function}, %TypeContext{}) when function in [:join, :map_join], + do: {:ok, string_type()} + + def synth(%Call{function: :group_by, args: [collection, key | value]}, context) do + with %Type{} = inner <- collection_inner(context.type_of.(collection)), + %Type{} = key <- mapper_type(key, inner, context), + %Type{} = value <- + if(value == [], do: inner, else: mapper_type(hd(value), inner, context)) do + {:ok, Collection.map(key, Type.vec(value))} + else + _unknown -> :unsupported + end + end + def synth(%Call{}, %TypeContext{}), do: :unsupported defp lower_map(collection, {:fn, _, [{:->, _, [args, body]}]}, context) do @@ -377,6 +731,15 @@ defmodule RustQ.Meta.Lower.Enum do defp operation_use(operation) when operation in [:filter, :reject], do: :keeps_items defp operation_use(_operation), do: :maps_items + @filter_adapters %{ + find: {:find, nil}, + count: {:filter, :count}, + collect: {:filter, :collect}, + partition: {:partition, :partition}, + take_while: {:take_while, :collect}, + drop_while: {:skip_while, :collect} + } + # `filter` and `find` hand their predicate a reference to each item; a number or atom is # copied out of it, so the predicate reads the item as its own type. defp lower_filter( @@ -398,10 +761,14 @@ defmodule RustQ.Meta.Lower.Enum do closure = %AST.Closure{args: [argument], body: condition} - case terminal do - :find -> method_chain(iterator, :find, [closure]) - :count -> iterator |> method_chain(:filter, [closure]) |> method_chain(:count) - :collect -> iterator |> method_chain(:filter, [closure]) |> collect(context) + {method, finish} = Map.fetch!(@filter_adapters, terminal) + {iterator, kept} = kept_items(iterator, finish) + adapted = iterator |> method_chain(method, [closure]) |> kept.() + + case finish do + :collect -> collect(adapted, context) + :count -> method_chain(adapted, :count) + _kept -> adapted end end @@ -414,6 +781,14 @@ defmodule RustQ.Meta.Lower.Enum do ) end + # Items copied from a borrowed list are copied once kept, not before they are tested, and + # not at all when only counted. `partition` keeps every item, so it copies them first. + defp kept_items(%AST.MethodCall{method: :cloned, receiver: borrowed}, finish) + when finish != :partition, + do: {borrowed, if(finish == :count, do: & &1, else: &method_chain(&1, :cloned))} + + defp kept_items(iterator, _finish), do: {iterator, & &1} + defp mapper({:fn, _, [{:->, _, [args, body]}]}, context), do: context.lower_closure.(args, body) @@ -556,6 +931,157 @@ defmodule RustQ.Meta.Lower.Enum do defp cast_i64(expression), do: %AST.Cast{expr: expression, type: %AST.TypePath{parts: [:i64]}} + # Keeps each item whose key is new, in order, as Elixir does. + defp lower_uniq(enumerable, key, context) do + item = Macro.var(:rustq_item, __MODULE__) + seen = Macro.var(:rustq_seen, __MODULE__) + + seen_key = + case key do + [] -> + if copy?(collection_inner(context.type_of.(enumerable))), + do: item, + else: quote(do: unquote(item).clone()) + + [key] -> + apply_function(key, item) + end + + block = + quote do + unquote(seen) = HashSet.new() + + Enum.filter(unquote(enumerable), fn unquote(item) -> + unquote(seen).insert(unquote(seen_key)) + end) + end + + {:ok, context.lower_closure_body.(block, context.expected || context.type_of.(enumerable))} + end + + # The comprehension target an empty collectable names, or nil for one with items. + defp empty_collectable({:%{}, _, []}), do: quote(do: HashMap.new()) + defp empty_collectable([]), do: quote(do: Vec.new()) + + defp empty_collectable({{:., _, [{:__aliases__, _, [module]}, :new]}, _, []} = constructor) + when module in [:Map, :MapSet, :HashMap, :HashSet, :BTreeMap, :BTreeSet], + do: constructor + + defp empty_collectable(_collectable), do: nil + + # A one-argument function applied to `value`, inlined. + defp apply_function({:fn, _, [{:->, _, [[{name, _, ast_context}], body]}]}, value) + when is_atom(name) and is_atom(ast_context) do + Macro.prewalk(body, fn + {^name, _, ^ast_context} -> value + node -> node + end) + end + + defp apply_function({:fn, _, [{:->, _, [[pattern], body]}]}, value), + do: quote(do: case(unquote(value), do: (unquote(pattern) -> unquote(body)))) + + defp apply_function({:&, _, [{:/, _, [{{:., _, _} = remote, _, _}, 1]}]}, value), + do: {remote, [], [value]} + + defp apply_function({:&, _, [{:/, _, [{name, _, _}, 1]}]}, value) when is_atom(name), + do: {name, [], [value]} + + defp apply_function(other, _value) do + Diagnostic.lower( + :unsupported_enum_function, + other, + "unsupported Enum function argument in defrust", + suggestion: "Use a one-argument anonymous function or a named one-argument capture." + ) + end + + defp continuation(body) do + Macro.prewalk(body, fn + {:cont, value} -> quote(do: ok(unquote(value))) + {:halt, value} -> quote(do: err(unquote(value))) + node -> node + end) + end + + defp sort_by(enumerable, key, order, context) do + Collection.updated( + enumerable, + [key], + fn list -> + [ + Collection.statement(list, :sort_by_key, [key_function(enumerable, key, order, context)]) + ] + end, + context + ) + end + + # The key each item sorts by, reversed for `:desc`. A key is a value, as in Elixir, so one + # read out of an item is cloned rather than borrowed. + defp key_function(enumerable, key, order, context) do + item = Macro.var(:rustq_item, __MODULE__) + + arg = + case collection_inner(context.type_of.(enumerable)) do + %Type{} = inner -> {:__rustq_typed_arg__, [], [item, Type.ref(inner)]} + nil -> item + end + + %AST.Closure{body: body} = closure = context.lower_closure.([arg], apply_function(key, item)) + + if order == :desc, + do: %{ + closure + | body: %AST.PathCall{path: %AST.Path{parts: [:std, :cmp, :Reverse]}, args: [body]} + }, + else: closure + end + + # `fn -> nil end` gives an `Option`; another fallback gives its value for an empty list. + defp empty_fallback({:fn, _, [{:->, _, [[], nil]}]}, _context), do: {:ok, & &1} + + # A literal or variable costs nothing to compute, so it needs no closure. + defp empty_fallback({:fn, _, [{:->, _, [[], default]}]}, context) do + if Macro.quoted_literal?(default) or + match?({name, _, ctx} when is_atom(name) and is_atom(ctx), default) do + {:ok, &method_chain(&1, :unwrap_or, [context.lower.(default)])} + else + {:ok, + &method_chain(&1, :unwrap_or_else, [%AST.Closure{args: [], body: context.lower.(default)}])} + end + end + + defp empty_fallback(_fallback, _context), do: :unsupported + + defp strings?(%Type{} = type) do + case collection_inner(type) do + %Type{} = inner -> + Type.category(Type.ref_inner(inner) || inner) == :string or inner.rust in ["&str", "str"] + + nil -> + false + end + end + + defp strings?(_type), do: false + + defp string_type, do: RustQ.Spec.type(quote(do: String.t())) + defp usize, do: RustQ.Spec.type(quote(do: RustQ.Type.usize())) + + defp join_separator([], _context), do: %AST.Literal{value: ""} + defp join_separator([separator], context), do: context.lower.(separator) + + @spec unsupported_count!(Call.t()) :: no_return() + defp unsupported_count!(%Call{function: function, source: source}) do + Diagnostic.lower( + :unsupported_enum_count_semantics, + source, + "Enum.#{function} requires a non-negative literal or unsigned index type", + suggestion: "Use R.usize() for dynamic non-negative native indexes." + ) + end + defp method_chain(receiver, method, args \\ []) do %AST.MethodCall{receiver: receiver, method: method, args: args} end diff --git a/lib/rustq/meta/lower/map.ex b/lib/rustq/meta/lower/map.ex index 82d53a2..3dee738 100644 --- a/lib/rustq/meta/lower/map.ex +++ b/lib/rustq/meta/lower/map.ex @@ -1,27 +1,232 @@ defmodule RustQ.Meta.Lower.Map do @moduledoc """ - Lowers `Map.get`, `Map.fetch!/2`, and `Map.has_key?/2` for `RustQ.Meta.Lower.Stdlib`, and - types such calls. + Lowers `Map` functions for `RustQ.Meta.Lower.Stdlib`, and types such calls. + + On a `HashMap` or `BTreeMap`, `get`, `fetch!`, `has_key?`, `put`, `delete`, `merge`, + `update/4`, `keys`, `values`, `to_list`, and `new` keep Elixir's meaning: values read out + are copies, and an update takes the map only at its last use; see + `RustQ.Meta.Lower.Collection`. On a struct or a map with declared atom keys, `get`, + `fetch!`, `has_key?`, `put`, and `replace!` read and write its fields. """ alias RustQ.Diagnostic alias RustQ.Meta.Core.Call + alias RustQ.Meta.Lower.Collection alias RustQ.Meta.Lower.Stdlib alias RustQ.Meta.Lower.Stdlib.{Context, TypeContext} alias RustQ.Meta.Type alias RustQ.Rust.AST @spec lower(Call.t(), Context.t()) :: {:ok, term()} | :unsupported - def lower(%Call{function: :get, args: [map, key]} = call, %Context{} = context) - when is_atom(key), - do: lower_get(call, map, key, %AST.None{}, false, context) + def lower(%Call{function: :new, args: args}, %Context{} = context), + do: lower_new(args, context) + + def lower(%Call{args: [map | _]} = call, %Context{} = context) do + case Collection.keyed_of(map, context) do + {:map, key, value} -> lower_keyed(call, key, value, context) + _fields -> lower_fields(call, context) + end + end + + # `Map.new/0,1,2` builds what the comprehension `for item <- enumerable, into: %{}` would. + defp lower_new([], _context), + do: {:ok, %AST.PathCall{path: %AST.Path{parts: [:HashMap, :new]}, args: []}} + + defp lower_new([enumerable | transform], context), + do: {:ok, collect(comprehension(enumerable, transform, quote(do: HashMap.new())), context)} + + @doc "Lowers a comprehension collecting into the type the call is expected to give, if known." + @spec collect(Macro.t(), Context.t()) :: AST.expr() + def collect(comprehension, %Context{expected: %Type{} = expected} = context), + do: context.lower_expected.(comprehension, expected) + + def collect(comprehension, %Context{} = context), do: context.lower.(comprehension) + + @doc """ + The comprehension collecting `enumerable` into `into`, through the one-argument function + `transform` gives, as `Map.new/1,2`, `MapSet.new/1,2`, and `Enum.into/2` build. + """ + @spec comprehension(Macro.t(), [Macro.t()], Macro.t()) :: Macro.t() + def comprehension(enumerable, [], into), + do: quote(do: for(item <- unquote(enumerable), into: unquote(into), do: item)) + + def comprehension(enumerable, [{:fn, _, [{:->, _, [[pattern], body]}]}], into), + do: + quote( + do: for(unquote(pattern) <- unquote(enumerable), into: unquote(into), do: unquote(body)) + ) + + def comprehension(enumerable, [{:&, _, [{:/, _, [function, 1]}]}], into) do + quote do + for item <- unquote(enumerable), into: unquote(into), do: unquote(function)(item) + end + end + + defp lower_keyed(%Call{function: :get, args: [map, key]}, key_type, value_type, context) do + lookup = + Collection.call(context.lower.(map), :get, [Collection.borrowed(key, key_type, context)]) + + {:ok, Collection.read_out(lookup, value_type)} + end + + defp lower_keyed( + %Call{function: :get, args: [map, key, default]}, + key_type, + value_type, + context + ) do + lookup = + Collection.call(context.lower.(map), :get, [Collection.borrowed(key, key_type, context)]) + + {:ok, + lookup + |> Collection.read_out(value_type) + |> Collection.call(:unwrap_or, [context.lower_expected.(default, value_type)])} + end + + # A missing key raises in Elixir; indexing panics, which Rustler raises. + defp lower_keyed(%Call{function: :fetch!, args: [map, key]}, key_type, value_type, context) do + value = %AST.Index{ + receiver: context.lower.(map), + index: Collection.borrowed(key, key_type, context) + } + + {:ok, if(Collection.copy?(value_type), do: value, else: Collection.call(value, :clone))} + end + + defp lower_keyed(%Call{function: :has_key?, args: [map, key]}, key_type, _value_type, context) do + {:ok, + Collection.call(context.lower.(map), :contains_key, [ + Collection.borrowed(key, key_type, context) + ])} + end + + defp lower_keyed(%Call{function: :put, args: [map, key, value]}, key_type, value_type, context) do + {:ok, + Collection.updated( + map, + [key, value], + fn map -> + [ + Collection.statement(map, :insert, [ + context.lower_expected.(key, key_type), + context.lower_expected.(value, value_type) + ]) + ] + end, + context + )} + end + + defp lower_keyed(%Call{function: :delete, args: [map, key]}, key_type, _value_type, context) do + {:ok, + Collection.updated( + map, + [key], + fn map -> + [Collection.statement(map, :remove, [Collection.borrowed(key, key_type, context)])] + end, + context + )} + end + + defp lower_keyed(%Call{function: :merge, args: [map, other]}, _key_type, _value_type, context) do + {:ok, + Collection.updated( + map, + [other], + fn map -> + [Collection.statement(map, :extend, [Collection.owned(other, [], context)])] + end, + context + )} + end + + # `update/4` gives a present value to `fun` and stores `default` otherwise, unchanged. + defp lower_keyed( + %Call{function: :update, args: [map, key, default, fun]}, + key_type, + value_type, + context + ) do + key_name = %AST.Var{name: :rustq_key} + value_name = %AST.Var{name: :rustq_value} + + {:ok, + Collection.updated( + map, + [key, default, fun], + fn map -> + updated = + map + |> Collection.call(:remove, [%AST.Ref{expr: key_name}]) + |> Collection.call(:map, [update_function(fun, value_type, context)]) + |> Collection.call(:unwrap_or, [context.lower_expected.(default, value_type)]) + + [ + %AST.Let{ + pattern: %AST.PatVar{name: :rustq_key}, + expr: context.lower_expected.(key, key_type) + }, + %AST.Let{pattern: %AST.PatVar{name: :rustq_value}, expr: updated}, + Collection.statement(map, :insert, [key_name, value_name]) + ] + end, + context + )} + end + + defp lower_keyed(%Call{function: function, args: [map]}, key_type, value_type, context) + when function in [:keys, :values] do + {method, type} = if function == :keys, do: {:keys, key_type}, else: {:values, value_type} + + {:ok, + context.lower.(map) + |> Collection.call(method) + |> Collection.read_out(type) + |> Collection.call(:collect) + |> collected(Type.vec(type))} + end + + defp lower_keyed(%Call{function: :to_list, args: [map]}, key_type, value_type, context) do + {:ok, + map + |> Collection.owned([], context) + |> Collection.call(:into_iter) + |> Collection.call(:collect) + |> collected(Type.vec(Type.tuple([key_type, value_type])))} + end + + defp lower_keyed(%Call{}, _key_type, _value_type, _context), do: :unsupported - def lower(%Call{function: :get, args: [map, key, default]} = call, %Context{} = context) - when is_atom(key), - do: lower_get(call, map, key, context.lower.(default), true, context) + defp update_function({:fn, _, [{:->, _, [[argument], body]}]}, value_type, context), + do: context.lower_closure.([{:__rustq_typed_arg__, [], [argument, value_type]}], body) - def lower(%Call{function: :fetch!, args: [map, key]} = call, %Context{} = context) - when is_atom(key) do + defp update_function({:&, _, [{:/, _, [function, 1]}]}, _value_type, context), + do: context.lower_capture.(function) + + defp update_function(other, _value_type, _context) do + Diagnostic.lower( + :unsupported_map_update_function, + other, + "unsupported Map.update function in defrust", + suggestion: "Use a one-argument anonymous function or a named one-argument capture." + ) + end + + defp collected(%AST.MethodCall{method: :collect} = call, %Type{ast: type}), + do: %{call | generics: [type]} + + defp lower_fields(%Call{function: :get, args: [map, key]} = call, %Context{} = context) + when is_atom(key), + do: lower_get(call, map, key, %AST.None{}, false, context) + + defp lower_fields(%Call{function: :get, args: [map, key, default]} = call, %Context{} = context) + when is_atom(key), + do: lower_get(call, map, key, context.lower.(default), true, context) + + defp lower_fields(%Call{function: :fetch!, args: [map, key]} = call, %Context{} = context) + when is_atom(key) do with %Type{} = map_type <- map_type(context.type_of.(map)), %Type{} <- Type.field_type(map_type, key) do {:ok, %AST.Field{receiver: context.lower.(map), field: key}} @@ -30,8 +235,8 @@ defmodule RustQ.Meta.Lower.Map do end end - def lower(%Call{function: :has_key?, args: [map, key]} = call, %Context{} = context) - when is_atom(key) do + defp lower_fields(%Call{function: :has_key?, args: [map, key]} = call, %Context{} = context) + when is_atom(key) do case map_type(Stdlib.shape_type(context.type_of.(map))) do %Type{} = type -> present? = match?(%Type{}, Type.field_type(type, key)) @@ -43,8 +248,11 @@ defmodule RustQ.Meta.Lower.Map do end end - def lower(%Call{function: function, args: [map, key, value]} = call, %Context{} = context) - when function in [:put, :replace!] and is_atom(key) do + defp lower_fields( + %Call{function: function, args: [map, key, value]} = call, + %Context{} = context + ) + when function in [:put, :replace!] and is_atom(key) do with %Type{} = map_type <- map_type(context.type_of.(map)), %Type{} = field_type <- Type.field_type(map_type, key) do name = :rustq_map_value @@ -70,11 +278,60 @@ defmodule RustQ.Meta.Lower.Map do end end - def lower(%Call{}, %Context{}), do: :unsupported + defp lower_fields(%Call{}, %Context{}), do: :unsupported @spec synth(Call.t(), TypeContext.t()) :: {:ok, Type.t()} | :unsupported - def synth(%Call{function: function, args: [map, key | _rest]}, %TypeContext{} = context) - when function in [:get, :fetch!] and is_atom(key) do + def synth(%Call{function: :new, args: [enumerable]}, %TypeContext{} = context) do + with %Type{} = type <- context.type_of.(enumerable), + %Type{kind: :tuple, meta: %{elements: [key, value]}} <- + Type.vec_inner(type) || Type.slice_inner(type) do + {:ok, Collection.map(key, value)} + else + _unknown -> :unsupported + end + end + + def synth(%Call{function: :new}, %TypeContext{}), do: :unsupported + + def synth(%Call{args: [map | _]} = call, %TypeContext{} = context) do + case Collection.keyed(context.type_of.(map)) do + {:map, key, value} -> synth_keyed(call, map, key, value, context) + _fields -> synth_fields(call, context) + end + end + + def synth(%Call{}, %TypeContext{}), do: :unsupported + + defp synth_keyed(%Call{function: :get, args: [_, _]}, _map, _key_type, value, _context), + do: {:ok, Type.option(value)} + + defp synth_keyed(%Call{function: function}, _map, _key_type, value, _context) + when function in [:get, :fetch!], + do: {:ok, value} + + defp synth_keyed(%Call{function: :has_key?}, _map, _key_type, _value, _context), + do: {:ok, RustQ.Spec.type(quote(do: boolean()))} + + defp synth_keyed(%Call{function: function}, map, _key_type, _value, context) + when function in [:put, :delete, :merge, :update] do + case context.type_of.(map) do + %Type{kind: kind} = type when kind in [:ref, :mut_ref] -> type_result(Type.ref_inner(type)) + type -> type_result(type) + end + end + + defp synth_keyed(%Call{function: :keys}, _map, key, _value, _context), do: {:ok, Type.vec(key)} + + defp synth_keyed(%Call{function: :values}, _map, _key, value, _context), + do: {:ok, Type.vec(value)} + + defp synth_keyed(%Call{function: :to_list}, _map, key, value, _context), + do: {:ok, Type.vec(Type.tuple([key, value]))} + + defp synth_keyed(%Call{}, _map, _key, _value, _context), do: :unsupported + + defp synth_fields(%Call{function: function, args: [map, key | _rest]}, %TypeContext{} = context) + when function in [:get, :fetch!] and is_atom(key) do with %Type{} = map_type <- map_type(context.type_of.(map)), %Type{} = field_type <- Type.field_type(map_type, key) do {:ok, field_type} @@ -83,14 +340,17 @@ defmodule RustQ.Meta.Lower.Map do end end - def synth(%Call{function: :has_key?}, %TypeContext{}), + defp synth_fields(%Call{function: :has_key?}, %TypeContext{}), do: {:ok, RustQ.Spec.type(quote(do: boolean()))} - def synth(%Call{function: function, args: [map, _key, _value]}, %TypeContext{} = context) - when function in [:put, :replace!], - do: type_result(context.type_of.(map)) + defp synth_fields( + %Call{function: function, args: [map, _key, _value]}, + %TypeContext{} = context + ) + when function in [:put, :replace!], + do: type_result(context.type_of.(map)) - def synth(%Call{}, %TypeContext{}), do: :unsupported + defp synth_fields(%Call{}, %TypeContext{}), do: :unsupported defp lower_get(call, map, key, default, evaluate_default?, context) do case map_type(context.type_of.(map)) do diff --git a/lib/rustq/meta/lower/map_set.ex b/lib/rustq/meta/lower/map_set.ex new file mode 100644 index 0000000..a7469b6 --- /dev/null +++ b/lib/rustq/meta/lower/map_set.ex @@ -0,0 +1,166 @@ +defmodule RustQ.Meta.Lower.MapSet do + @moduledoc """ + Lowers `MapSet` functions on a `HashSet` or `BTreeSet` for `RustQ.Meta.Lower.Stdlib`, and + types such calls: `new`, `put`, `delete`, `member?`, `union`, `difference`, `intersection`, + `subset?`, `disjoint?`, `size`, and `to_list`. An update takes the set only at its last use; + see `RustQ.Meta.Lower.Collection`. + """ + + alias RustQ.Meta.Core.Call + alias RustQ.Meta.Lower.Collection + alias RustQ.Meta.Lower.Map, as: MapLowering + alias RustQ.Meta.Lower.Stdlib.{Context, TypeContext} + alias RustQ.Meta.Type + alias RustQ.Rust.AST + + @spec lower(Call.t(), Context.t()) :: {:ok, term()} | :unsupported + def lower(%Call{function: :new, args: []}, %Context{}), + do: {:ok, %AST.PathCall{path: %AST.Path{parts: [:HashSet, :new]}, args: []}} + + def lower(%Call{function: :new, args: [enumerable | transform]}, %Context{} = context) do + {:ok, + MapLowering.collect( + MapLowering.comprehension(enumerable, transform, quote(do: HashSet.new())), + context + )} + end + + def lower(%Call{args: [set | _]} = call, %Context{} = context) do + case Collection.keyed_of(set, context) do + {:set, item} -> lower_set(call, item, context) + _other -> :unsupported + end + end + + def lower(%Call{}, %Context{}), do: :unsupported + + defp lower_set(%Call{function: :put, args: [set, value]}, item, context) do + {:ok, + Collection.updated( + set, + [value], + fn set -> + [Collection.statement(set, :insert, [context.lower_expected.(value, item)])] + end, + context + )} + end + + defp lower_set(%Call{function: :delete, args: [set, value]}, item, context) do + {:ok, + Collection.updated( + set, + [value], + fn set -> + [Collection.statement(set, :remove, [Collection.borrowed(value, item, context)])] + end, + context + )} + end + + defp lower_set(%Call{function: :member?, args: [set, value]}, item, context) do + {:ok, + Collection.call(context.lower.(set), :contains, [Collection.borrowed(value, item, context)])} + end + + defp lower_set(%Call{function: :union, args: [set, other]}, _item, context) do + {:ok, + Collection.updated( + set, + [other], + fn set -> + [Collection.statement(set, :extend, [Collection.owned(other, [], context)])] + end, + context + )} + end + + defp lower_set(%Call{function: function, args: [set, other]}, _item, context) + when function in [:difference, :intersection] do + kept = %AST.MethodCall{ + receiver: context.lower.(other), + method: :contains, + args: [%AST.Var{name: :rustq_item}] + } + + kept = if function == :difference, do: %AST.UnaryOp{op: :not, expr: kept}, else: kept + + {:ok, + Collection.updated( + set, + [other], + fn set -> + [Collection.statement(set, :retain, [%AST.Closure{args: [:rustq_item], body: kept}])] + end, + context + )} + end + + defp lower_set(%Call{function: function, args: [set, other]}, _item, context) + when function in [:subset?, :disjoint?] do + method = if function == :subset?, do: :is_subset, else: :is_disjoint + + {:ok, Collection.call(context.lower.(set), method, [%AST.Ref{expr: context.lower.(other)}])} + end + + defp lower_set(%Call{function: :size, args: [set]}, _item, context) do + {:ok, + %AST.Cast{ + expr: Collection.call(context.lower.(set), :len), + type: %AST.TypePath{parts: [:i64]} + }} + end + + defp lower_set(%Call{function: :to_list, args: [set]}, item, context) do + collected = + set + |> Collection.owned([], context) + |> Collection.call(:into_iter) + |> Collection.call(:collect) + + {:ok, %{collected | generics: [Type.vec(item).ast]}} + end + + defp lower_set(%Call{}, _item, _context), do: :unsupported + + @spec synth(Call.t(), TypeContext.t()) :: {:ok, Type.t()} | :unsupported + def synth(%Call{function: :new, args: [enumerable]}, %TypeContext{} = context) do + with %Type{} = type <- context.type_of.(enumerable), + %Type{} = item <- Type.vec_inner(type) || Type.slice_inner(type) do + {:ok, Collection.set(item)} + else + _unknown -> :unsupported + end + end + + def synth(%Call{function: :new}, %TypeContext{}), do: :unsupported + + def synth(%Call{function: function, args: [set | _]}, %TypeContext{} = context) do + case {function, Collection.keyed(context.type_of.(set))} do + {_function, nil} -> + :unsupported + + {function, _set} when function in [:member?, :subset?, :disjoint?] -> + {:ok, RustQ.Spec.type(quote(do: boolean()))} + + {:size, _set} -> + {:ok, RustQ.Spec.type(quote(do: integer()))} + + {:to_list, {:set, item}} -> + {:ok, Type.vec(item)} + + {function, _set} when function in [:put, :delete, :union, :difference, :intersection] -> + owned(context.type_of.(set)) + + _other -> + :unsupported + end + end + + def synth(%Call{}, %TypeContext{}), do: :unsupported + + defp owned(%Type{kind: kind} = type) when kind in [:ref, :mut_ref], + do: {:ok, Type.ref_inner(type)} + + defp owned(%Type{} = type), do: {:ok, type} +end diff --git a/lib/rustq/meta/lower/stdlib.ex b/lib/rustq/meta/lower/stdlib.ex index 07356ab..2b02441 100644 --- a/lib/rustq/meta/lower/stdlib.ex +++ b/lib/rustq/meta/lower/stdlib.ex @@ -1,7 +1,7 @@ defmodule RustQ.Meta.Lower.Stdlib do @moduledoc """ - Lowers calls to Elixir's standard library (`Kernel`, `Enum`, `List`, `Map`, `String`, - `Tuple`, and `Range`) to Rust that keeps their semantics, dispatching to one lowering module + Lowers calls to Elixir's standard library (`Kernel`, `Enum`, `List`, `Map`, `MapSet`, + `String`, `Tuple`, and `Range`) to Rust that keeps their semantics, dispatching to one lowering module per Elixir module. Calls whose semantics Rust cannot preserve are rejected. """ @@ -90,7 +90,7 @@ defmodule RustQ.Meta.Lower.Stdlib do ast, "unsupported Elixir standard-library call in defrust", suggestion: - "Use a documented Kernel/Enum/List/Map/String/Tuple/Range form or add an explicit Rust adapter." + "Use a documented Kernel/Enum/List/Map/MapSet/String/Tuple/Range form or add an explicit Rust adapter." ) end end @@ -143,6 +143,9 @@ defmodule RustQ.Meta.Lower.Stdlib do defp lower_call(%Call{module: Map} = call, context), do: RustQ.Meta.Lower.Map.lower(call, context) + defp lower_call(%Call{module: MapSet} = call, context), + do: RustQ.Meta.Lower.MapSet.lower(call, context) + defp lower_call(%Call{module: String} = call, context), do: RustQ.Meta.Lower.String.lower(call, context) @@ -170,6 +173,9 @@ defmodule RustQ.Meta.Lower.Stdlib do defp synth_call(%Call{module: Map} = call, context), do: RustQ.Meta.Lower.Map.synth(call, context) + defp synth_call(%Call{module: MapSet} = call, context), + do: RustQ.Meta.Lower.MapSet.synth(call, context) + defp synth_call(%Call{module: String} = call, context), do: RustQ.Meta.Lower.String.synth(call, context) diff --git a/lib/rustq/meta/type.ex b/lib/rustq/meta/type.ex index 37a95bc..6255172 100644 --- a/lib/rustq/meta/type.ex +++ b/lib/rustq/meta/type.ex @@ -900,6 +900,17 @@ defmodule RustQ.Meta.Type do defp parse_alias_type(ast, _raw, aliases), do: {parse(ast, aliases), aliases} @spec parse(Macro.t(), map()) :: t() + # `MapSet.t(item)` is a set of that item, as `HashSet.t(item)`. + def parse( + {{:., meta, [{:__aliases__, alias_meta, [:MapSet]}, :t]}, call_meta, [item]}, + aliases + ), + do: + parse( + {{:., meta, [{:__aliases__, alias_meta, [:HashSet]}, :t]}, call_meta, [item]}, + aliases + ) + def parse({{:., _, [module, function]}, _, args}, aliases), do: parse_remote(module, function, args, aliases) diff --git a/lib/rustq/meta/typing.ex b/lib/rustq/meta/typing.ex index 4780335..ce86c84 100644 --- a/lib/rustq/meta/typing.ex +++ b/lib/rustq/meta/typing.ex @@ -649,12 +649,19 @@ defmodule RustQ.Meta.Typing do defp direct_coercion(actual, expected) do cond do Type.compatible?(actual, expected) -> :none + inferred_option?(actual) and expected.kind == :option -> :none option_ref_adapter_compatible?(actual, expected) -> :as_ref option_adapter_compatible?(actual, expected) -> :none true -> nil end end + # An `Option` whose item Rust infers, as `Enum.find_value/2` gives without a typed item. + defp inferred_option?(%Type{kind: :option} = type), + do: match?(%Type{ast: %AST.TypeRaw{source: "_"}}, Type.inner(type)) + + defp inferred_option?(_type), do: false + defp propagation_coercion(actual, expected) do if Type.propagates?(actual) do propagated_coercion(Type.inner(actual), expected) diff --git a/test/rustq/meta/lower/enum_test.exs b/test/rustq/meta/lower/enum_test.exs index 10a0b6c..65f3444 100644 --- a/test/rustq/meta/lower/enum_test.exs +++ b/test/rustq/meta/lower/enum_test.exs @@ -57,6 +57,76 @@ defmodule RustQ.Meta.Lower.EnumTest do defrust(nonzero(name), do: if(name.is_empty(), do: nil, else: some(name.len()))) end + defmodule Queries do + @moduledoc "`Enum` functions beyond mapping and filtering." + + use RustQ.Meta + + alias RustQ.Type, as: R + + @spec by_length([String.t()]) :: %{R.usize() => [String.t()]} + defrust(by_length(names), do: Enum.group_by(names, fn name -> name.len() end)) + + @spec indexed([String.t()]) :: [{String.t(), R.usize()}] + defrust(indexed(names), do: Enum.with_index(names)) + + @spec positions(R.slice(String.t())) :: %{String.t() => R.usize()} + defrust positions(names) do + for {name, index} <- Enum.with_index(names), into: HashMap.new(), do: {name.clone(), index} + end + + @spec first_long(R.slice(String.t())) :: R.option(R.usize()) + defrust(first_long(names), do: Enum.find_index(names, fn name -> name.len() > 3 end)) + + @spec first_length(R.slice(String.t())) :: R.option(R.usize()) + defrust first_length(names) do + Enum.find_value(names, fn name -> if(name.is_empty(), do: nil, else: some(name.len())) end) + end + + @spec capped([integer()]) :: integer() + defrust capped(values) do + Enum.reduce_while(values, 0, fn value, sum -> + if sum + value > 10, do: {:halt, sum}, else: {:cont, sum + value} + end) + end + + @spec sorted([String.t()]) :: [String.t()] + defrust(sorted(names), do: Enum.sort_by(names, fn name -> name.len() end, :desc)) + + @spec longest([String.t()]) :: R.option(String.t()) + defrust(longest(names), do: Enum.max_by(names, fn name -> name.len() end, fn -> nil end)) + + @spec smallest([integer()]) :: integer() + defrust(smallest(values), do: Enum.min(values, fn -> 0 end)) + + @spec distinct([String.t()]) :: [String.t()] + defrust(distinct(names), do: Enum.uniq(names)) + + @spec split([integer()]) :: {[integer()], [integer()]} + defrust(split(values), do: Enum.split_with(values, fn value -> value > 0 end)) + + @spec second([String.t()]) :: R.option(String.t()) + defrust(second(names), do: Enum.at(names, 1)) + + @spec leading([integer()]) :: [integer()] + defrust(leading(values), do: Enum.take_while(values, fn value -> value > 0 end)) + + @spec sums([integer()], [integer()]) :: [integer()] + defrust(sums(left, right), do: Enum.zip_with(left, right, fn x, y -> x + y end)) + + @spec pairs([integer()]) :: [[integer()]] + defrust(pairs(values), do: Enum.chunk_every(values, 2)) + + @spec joined([String.t()]) :: String.t() + defrust(joined(names), do: Enum.join(names, ", ")) + + @spec described([integer()]) :: String.t() + defrust(described(values), do: Enum.map_join(values, "-", fn value -> value.to_string() end)) + + @spec named([String.t()]) :: MapSet.t(String.t()) + defrust(named(names), do: Enum.into(names, MapSet.new())) + end + test "a question borrows the list it asks about" do assert rust_source!(Lists, :any_long) =~ "names.iter().any(|name| name.len() > 3)" end @@ -71,7 +141,7 @@ defmodule RustQ.Meta.Lower.EnumTest do test "items kept from a list still in use are copies" do assert rust_source!(Lists, :kept) =~ - ~r/names\s*\.iter\(\)\s*\.cloned\(\)\s*\.filter\(\|name\| name\.len\(\) > 3\)/ + ~r/names\s*\.iter\(\)\s*\.filter\(\|name\| name\.len\(\) > 3\)\s*\.cloned\(\)/ end test "a list reached through a reference is borrowed" do @@ -89,4 +159,43 @@ defmodule RustQ.Meta.Lower.EnumTest do assert source =~ ".map(|name| (name, nonzero(name)))" refute source =~ "vec![" end + + test "group_by keeps each group's items in order" do + assert rust_source!(Queries, :by_length) =~ + "rustq_groups.entry(rustq_item.len()).or_default().push(rustq_item);" + end + + test "with_index pairs items with usize indexes, and a generator enumerates without collecting" do + assert rust_source!(Queries, :indexed) =~ ".enumerate()" + assert rust_source!(Queries, :positions) =~ "names.iter().enumerate().map(|(index, name)|" + end + + test "find_index, find_value, and reduce_while stop at the first answer" do + assert rust_source!(Queries, :first_long) =~ "names.iter().position(|name| name.len() > 3)" + assert rust_source!(Queries, :first_length) =~ "names.iter().find_map(" + assert rust_source!(Queries, :capped) =~ ".try_fold(" + assert rust_source!(Queries, :capped) =~ ".unwrap_or_else(|rustq_halted| rustq_halted)" + end + + test "sort_by and max_by sort by key, first greatest first, and min is min" do + assert rust_source!(Queries, :sorted) =~ + "sort_by_key(|rustq_item| std::cmp::Reverse(rustq_item.len()))" + + assert rust_source!(Queries, :longest) =~ + ".min_by_key(|rustq_item| std::cmp::Reverse(rustq_item.len()))" + + assert rust_source!(Queries, :smallest) =~ "values.into_iter().min().unwrap_or(0)" + end + + test "uniq, split_with, at, take_while, zip_with, chunk_every, join, and into" do + assert rust_source!(Queries, :distinct) =~ "rustq_seen.insert(rustq_item.clone())" + assert rust_source!(Queries, :split) =~ ".partition::, _>(|&value| value > 0)" + assert rust_source!(Queries, :second) =~ "names.get(1).cloned()" + assert rust_source!(Queries, :leading) =~ ".take_while(|&value| value > 0)" + assert rust_source!(Queries, :sums) =~ "left.into_iter().zip(right)" + assert rust_source!(Queries, :pairs) =~ "values.chunks(2).map(|chunk| chunk.to_vec())" + assert rust_source!(Queries, :joined) =~ ~s|names.join(", ")| + assert rust_source!(Queries, :described) =~ ~s|.collect::>().join("-")| + assert rust_source!(Queries, :named) =~ "names.into_iter().collect()" + end end diff --git a/test/rustq/meta/lower/map_set_test.exs b/test/rustq/meta/lower/map_set_test.exs new file mode 100644 index 0000000..5b8ab1c --- /dev/null +++ b/test/rustq/meta/lower/map_set_test.exs @@ -0,0 +1,47 @@ +defmodule RustQ.Meta.Lower.MapSetTest do + use RustQ.Test, async: true + + defmodule Seen do + @moduledoc "`MapSet` functions on a `HashSet`." + + use RustQ.Meta + + alias RustQ.Type, as: R + + @spec seen(MapSet.t(String.t()), String.t()) :: MapSet.t(String.t()) + defrust(seen(set, name), do: MapSet.put(set, name)) + + @spec known?(R.ref(MapSet.t(String.t())), R.ref(String.t())) :: R.bool() + defrust(known?(set, name), do: MapSet.member?(set, name)) + + @spec unseen(MapSet.t(String.t()), R.ref(MapSet.t(String.t()))) :: MapSet.t(String.t()) + defrust(unseen(set, other), do: MapSet.difference(set, other)) + + @spec count([String.t()]) :: integer() + defrust count(names) do + set = MapSet.new(names) + MapSet.size(set) + end + end + + test "MapSet.t is a HashSet" do + assert rust_source!(Seen, :seen) =~ + "fn seen(set: HashSet, name: String) -> HashSet" + + assert rust_source!(Seen, :seen) =~ "rustq_updated.insert(name);" + end + + test "member? and difference read the other set" do + assert rust_source!(Seen, :known?) =~ "set.contains(name)" + + assert rust_source!(Seen, :unseen) =~ + "rustq_updated.retain(|rustq_item| !other.contains(rustq_item));" + end + + test "new collects a list it takes at its last use" do + source = rust_source!(Seen, :count) + + assert source =~ "names.into_iter().collect::>()" + assert source =~ "set.len() as i64" + end +end diff --git a/test/rustq/meta/lower/map_test.exs b/test/rustq/meta/lower/map_test.exs new file mode 100644 index 0000000..9c6019a --- /dev/null +++ b/test/rustq/meta/lower/map_test.exs @@ -0,0 +1,70 @@ +defmodule RustQ.Meta.Lower.MapTest do + use RustQ.Test, async: true + + defmodule Scores do + @moduledoc "`Map` functions on a `HashMap`." + + use RustQ.Meta + + alias RustQ.Type, as: R + + @spec score(R.ref(%{String.t() => integer()}), String.t()) :: integer() + defrust(score(scores, name), do: Map.get(scores, name, 0)) + + @spec found(R.ref(%{String.t() => integer()}), R.ref(String.t())) :: R.option(integer()) + defrust(found(scores, name), do: Map.get(scores, name)) + + @spec known?(R.ref(%{String.t() => integer()}), R.ref(String.t())) :: R.bool() + defrust(known?(scores, name), do: Map.has_key?(scores, name)) + + @spec bump(%{String.t() => integer()}, String.t()) :: %{String.t() => integer()} + defrust(bump(scores, name), do: Map.update(scores, name, 1, fn count -> count + 1 end)) + + @spec added(%{String.t() => integer()}, String.t()) :: %{String.t() => integer()} + defrust(added(scores, name), do: Map.put(scores, name, 0)) + + @spec kept(%{String.t() => integer()}, String.t()) :: + {%{String.t() => integer()}, %{String.t() => integer()}} + defrust kept(scores, name) do + fewer = Map.delete(scores, ref(name)) + {fewer, scores} + end + + @spec combined(%{String.t() => integer()}, %{String.t() => integer()}) :: + %{String.t() => integer()} + defrust(combined(scores, more), do: Map.merge(scores, more)) + + @spec names(R.ref(%{String.t() => integer()})) :: [String.t()] + defrust(names(scores), do: Map.keys(scores)) + + @spec lengths([String.t()]) :: %{String.t() => R.usize()} + defrust(lengths(names), do: Map.new(names, fn name -> {name.clone(), name.len()} end)) + end + + test "reads copy values out" do + assert rust_source!(Scores, :score) =~ "scores.get(&name).copied().unwrap_or(0)" + assert rust_source!(Scores, :found) =~ "scores.get(name).copied()" + assert rust_source!(Scores, :known?) =~ "scores.contains_key(name)" + end + + test "update/4 gives a present value to the function and stores the default otherwise" do + source = rust_source!(Scores, :bump) + + assert source =~ ".remove(&rustq_key)" + assert source =~ ".map(|count| count + 1)" + assert source =~ ".unwrap_or(1)" + end + + test "an update takes the map at its last use and copies it otherwise" do + assert rust_source!(Scores, :added) =~ "let mut rustq_updated = scores;" + assert rust_source!(Scores, :kept) =~ "let mut rustq_updated = scores.clone();" + end + + test "merge, keys, and new collect as Elixir's do" do + assert rust_source!(Scores, :combined) =~ "rustq_updated.extend(more);" + assert rust_source!(Scores, :names) =~ "scores.keys().cloned().collect::>()" + + assert rust_source!(Scores, :lengths) =~ + "names.into_iter().map(|name| (name.clone(), name.len())).collect()" + end +end diff --git a/test/rustq/meta_test.exs b/test/rustq/meta_test.exs index 7f2a6c6..91c6497 100644 --- a/test/rustq/meta_test.exs +++ b/test/rustq/meta_test.exs @@ -439,7 +439,7 @@ defmodule RustQ.Meta.GeneratedRustTest do File.write!(Path.join(src, "lib.rs"), [ "#![allow(warnings)]\n", "use rustler::{Atom, Binary, Encoder, Env, NifResult, ResourceArc, Term};\n", - "use std::collections::HashMap;\n", + "use std::collections::{HashMap, HashSet};\n", Enum.map(files, fn {"src/" <> file, _module} -> "mod #{Path.rootname(file)};\n" end) ]) From aae7ee211d1ae6fd2d2bd4089ac092cabe8b57cb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 25 Oct 2026 18:07:23 +0400 Subject: [PATCH 4/4] Update rebound collections in place, borrow by rebinding scope, and copy kept slice items --- CHANGELOG.md | 3 ++ guides/using-rustq-well.md | 4 +- lib/rustq/meta/ast.ex | 26 ++++++++++++ lib/rustq/meta/lower.ex | 63 +++++++++++++++++++++++++++++- lib/rustq/meta/lower/collection.ex | 15 ++++++- lib/rustq/meta/lower/enum.ex | 48 ++++++++++++++--------- lib/rustq/meta/lower/stdlib.ex | 5 ++- test/rustq/meta/lower/map_test.exs | 13 ++++++ 8 files changed, 153 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf907f..601803f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ `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. diff --git a/guides/using-rustq-well.md b/guides/using-rustq-well.md index 9796ecb..5076968 100644 --- a/guides/using-rustq-well.md +++ b/guides/using-rustq-well.md @@ -335,7 +335,9 @@ a spec) or `HashSet` (`MapSet.t(item)`), `Map.get`, `fetch!`, `has_key?`, 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. +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`, diff --git a/lib/rustq/meta/ast.ex b/lib/rustq/meta/ast.ex index d7758b3..a3890b9 100644 --- a/lib/rustq/meta/ast.ex +++ b/lib/rustq/meta/ast.ex @@ -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) @@ -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)} diff --git a/lib/rustq/meta/lower.ex b/lib/rustq/meta/lower.ex index dfbe8e7..f29dd41 100644 --- a/lib/rustq/meta/lower.ex +++ b/lib/rustq/meta/lower.ex @@ -125,7 +125,10 @@ defmodule RustQ.Meta.Lower do |> Enum.with_index(1) |> Enum.map(fn {expression, index} -> later = - expressions |> Enum.drop(index) |> used_variables() |> MapSet.union(context.in_use) + expressions + |> Enum.drop(index) + |> used_after(bound_by(expression)) + |> MapSet.union(context.in_use) current = used_variables([expression]) sources = sources(MapSet.union(later, current), built_from, context.vars) @@ -137,6 +140,20 @@ defmodule RustQ.Meta.Lower do {statement_contexts, final_context} end + # A name rebound in a block means the new value from there on, as Elixir scopes it, so a use + # after the rebinding is not a use of the value before it. + defp used_after(expressions, shadowed) do + expressions + |> Enum.reduce({MapSet.new(), shadowed}, fn expression, {used, shadowed} -> + used = MapSet.union(used, MapSet.difference(used_variables([expression]), shadowed)) + {used, MapSet.union(shadowed, bound_by(expression))} + end) + |> elem(0) + end + + defp bound_by({:=, _, [pattern, _expression]}), do: variables_in(pattern) + defp bound_by(_expression), do: MapSet.new() + defp record_built_from({:=, _, [pattern, expression]}, built_from) do from = used_variables([expression]) pattern |> variables_in() |> Enum.reduce(built_from, &Map.put(&2, &1, from)) @@ -269,6 +286,7 @@ defmodule RustQ.Meta.Lower do |> collect_as(collect_target(expected_type)), type: empty_collection_type(expression, expected_type) } + |> updated_in_place() end defp lower_statement({:case, _, [expression, [do: clauses]]}, %Context{} = context) do @@ -322,6 +340,49 @@ defmodule RustQ.Meta.Lower do %AST.ExprStmt{expr: lower_statement_expr(expression, context)} end + # `map = Map.put(map, key, value)` updates the map it rebinds, which nothing uses again, so it + # changes that map in place: `map.insert(key, value);`. + defp updated_in_place( + %AST.Let{ + pattern: %AST.PatVar{name: name}, + expr: %AST.BlockExpr{ + body: [ + %AST.Let{pattern: %AST.PatVar{name: :rustq_updated}, expr: %AST.Var{name: name}} + | rest + ] + } + } = let + ) do + case Enum.split(rest, -1) do + {[_ | _] = updates, [%AST.Return{expr: %AST.Var{name: :rustq_updated}}]} -> + case rename_var(updates, :rustq_updated, name) do + [update] -> update + updates -> %AST.ExprStmt{expr: %AST.BlockExpr{body: updates}} + end + + _other -> + let + end + end + + defp updated_in_place(statement), do: statement + + defp rename_var(%AST.Var{name: from}, from, to), do: %AST.Var{name: to} + + defp rename_var(%module{} = node, from, to) do + fields = + node + |> Map.from_struct() + |> Map.new(fn {key, value} -> {key, rename_var(value, from, to)} end) + + struct(module, fields) + end + + defp rename_var(values, from, to) when is_list(values), + do: Enum.map(values, &rename_var(&1, from, to)) + + defp rename_var(value, _from, _to), do: value + defp cond_statement([{:->, _, [[true], body]} | _rest]), do: body defp cond_statement([{:->, meta, [[condition], body]} | rest]) do diff --git a/lib/rustq/meta/lower/collection.ex b/lib/rustq/meta/lower/collection.ex index 96f3c06..93a5acf 100644 --- a/lib/rustq/meta/lower/collection.ex +++ b/lib/rustq/meta/lower/collection.ex @@ -96,10 +96,21 @@ defmodule RustQ.Meta.Lower.Collection do %AST.MethodCall{receiver: option, method: method, args: []} end - @doc "Whether a value is copied as cheaply as it is borrowed: a number, atom, or reference." + @doc """ + Whether a value is copied as cheaply as it is borrowed: a number, atom, reference, or tuple + of them. + """ @spec copy?(Type.t() | nil) :: boolean() def copy?(%Type{kind: kind}) when kind in [:ref], do: true - def copy?(%Type{} = type), do: Type.category(type) in [:integer, :number, :boolean, :atom] + + def copy?(%Type{} = type) do + case Type.category(type) do + category when category in [:integer, :number, :boolean, :atom] -> true + {:tuple, elements} -> Enum.all?(elements, ©?/1) + _other -> false + end + end + def copy?(_type), do: false @doc "A call on `receiver`." diff --git a/lib/rustq/meta/lower/enum.ex b/lib/rustq/meta/lower/enum.ex index 86e5293..0062ad1 100644 --- a/lib/rustq/meta/lower/enum.ex +++ b/lib/rustq/meta/lower/enum.ex @@ -755,7 +755,7 @@ defmodule RustQ.Meta.Lower.Enum do condition = if negate?, do: %AST.UnaryOp{op: :not, expr: condition}, else: condition argument = - if copy?(collection_inner(context.type_of.(collection))), + if Collection.copy?(collection_inner(context.type_of.(collection))), do: %AST.PatRef{pattern: %AST.PatVar{name: name}}, else: name @@ -889,21 +889,34 @@ defmodule RustQ.Meta.Lower.Enum do # A question needs no item it could take, so it borrows other items even from a list nothing # uses afterwards. Copying out a number or atom costs what borrowing it does, and keeps the # item's own type. - defp iteration(collection, use, others, context) do - case context.type_of.(collection) do - %Type{kind: :vec} = type -> - copy? = copy?(Type.vec_inner(type)) - in_use? = (use == :question and not copy?) or context.in_use?.(collection, others) - - cond do - not in_use? -> :owned - copy? -> :copied - use == :keeps_items -> :cloned - true -> :borrowed - end + defp iteration(collection, use, others, context), + do: iteration(context.type_of.(collection), collection, use, others, context) + + defp iteration(%Type{kind: :vec} = type, collection, use, others, context), + do: list_iteration(collection, type, use, others, context) + + defp iteration(%Type{} = type, _collection, :keeps_items, _others, _context), + do: lent_iteration(type) + + defp iteration(_type, _collection, _use, _others, _context), do: :owned - _not_owned -> - :owned + defp list_iteration(collection, type, use, others, context) do + copy? = Collection.copy?(Type.vec_inner(type)) + in_use? = (use == :question and not copy?) or context.in_use?.(collection, others) + + cond do + not in_use? -> :owned + copy? -> :copied + use == :keeps_items -> :cloned + true -> :borrowed + end + end + + # A slice lends its items, so items kept from it are copies. + defp lent_iteration(type) do + case Type.slice_inner(type) || collection_inner(type) do + %Type{} = inner -> if Collection.copy?(inner), do: :copied, else: :cloned + nil -> :owned end end @@ -912,9 +925,6 @@ defmodule RustQ.Meta.Lower.Enum do defp borrowed_collection?(%Type{ast: %AST.TypeRef{}}), do: true defp borrowed_collection?(_type), do: false - defp copy?(%Type{} = type), do: Type.category(type) in [:integer, :number, :boolean, :atom] - defp copy?(_type), do: false - defp lower_collection(collection, context) do case context.type_of.(collection) do %Type{kind: :vec} = type -> context.lower_expected.(collection, type) @@ -939,7 +949,7 @@ defmodule RustQ.Meta.Lower.Enum do seen_key = case key do [] -> - if copy?(collection_inner(context.type_of.(enumerable))), + if Collection.copy?(collection_inner(context.type_of.(enumerable))), do: item, else: quote(do: unquote(item).clone()) diff --git a/lib/rustq/meta/lower/stdlib.ex b/lib/rustq/meta/lower/stdlib.ex index 2b02441..bfe51a3 100644 --- a/lib/rustq/meta/lower/stdlib.ex +++ b/lib/rustq/meta/lower/stdlib.ex @@ -119,7 +119,10 @@ defmodule RustQ.Meta.Lower.Stdlib do def nonnegative_count(count, %Context{} = context) do case context.type_of.(count) do - %Type{kind: kind} when kind in [:u8, :u16, :u32, :u64, :usize] -> + %Type{kind: :usize} -> + {:ok, context.lower.(count)} + + %Type{kind: kind} when kind in [:u8, :u16, :u32, :u64] -> {:ok, %AST.Cast{expr: context.lower.(count), type: %AST.TypePath{parts: [:usize]}}} _type -> diff --git a/test/rustq/meta/lower/map_test.exs b/test/rustq/meta/lower/map_test.exs index 9c6019a..174463a 100644 --- a/test/rustq/meta/lower/map_test.exs +++ b/test/rustq/meta/lower/map_test.exs @@ -37,6 +37,12 @@ defmodule RustQ.Meta.Lower.MapTest do @spec names(R.ref(%{String.t() => integer()})) :: [String.t()] defrust(names(scores), do: Map.keys(scores)) + @spec tallied(%{String.t() => integer()}, String.t()) :: %{String.t() => integer()} + defrust tallied(scores, name) do + scores = Map.put(scores, name, 1) + scores + end + @spec lengths([String.t()]) :: %{String.t() => R.usize()} defrust(lengths(names), do: Map.new(names, fn name -> {name.clone(), name.len()} end)) end @@ -60,6 +66,13 @@ defmodule RustQ.Meta.Lower.MapTest do assert rust_source!(Scores, :kept) =~ "let mut rustq_updated = scores.clone();" end + test "rebinding a map to its update changes it in place" do + source = rust_source!(Scores, :tallied) + + assert source =~ "scores.insert(name, 1);" + refute source =~ "rustq_updated" + end + test "merge, keys, and new collect as Elixir's do" do assert rust_source!(Scores, :combined) =~ "rustq_updated.extend(more);" assert rust_source!(Scores, :names) =~ "scores.keys().cloned().collect::>()"