# SPDX-FileCopyrightText: 2025 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.Dsl.UniquenessTransformer do @moduledoc """ Validates that all entity names are globally unique across the robot. This includes links, joints, sensors, actuators, controllers, and bridges - all entities that get registered in the process registry. Commands are not included since they're not registered processes. """ use Spark.Dsl.Transformer alias Spark.Dsl.Transformer alias Spark.Error.DslError @doc false @impl true def after?(BB.Dsl.DefaultNameTransformer), do: true def after?(_), do: false @doc false @impl true def before?(BB.Dsl.RobotTransformer), do: true def before?(_), do: false @doc false @impl true def transform(dsl) do names = [[:sensors], [:controllers], [:topology], [:parameters]] |> Enum.reduce(%{}, fn path, names -> dsl |> Transformer.get_entities(path) |> retrieve_names(path, names) end) dupes = names |> Enum.reject(fn {_name, []} -> true {_name, [_]} -> true {_name, _paths} -> false end) if Enum.empty?(dupes) do {:ok, dsl} else dupes = dupes |> Enum.map(fn {name, paths} -> paths = paths |> Enum.reverse() |> Enum.map_join("\n", &" - `#{inspect(&1)}`") " - `#{inspect(name)}`:\n#{paths}\n" end) {:error, DslError.exception( module: Transformer.get_persisted(dsl, :module), message: """ Entities with duplicate names found at the following paths: #{dupes} """ )} end end # `Localize.Unit` and `Decimal` structs carry a `:name` field that is not a # DSL entity identifier — skip them so they aren't treated as named entities. defp retrieve_names(%Localize.Unit{}, _path, names), do: names defp retrieve_names(%Decimal{}, _path, names), do: names # Estimator input/output entities use `:name` as a callback-side key # (matched in the user's `handle_input/2` payload map and `{:reply, [...]}` # output list). They are not process-registered and frequently share names # with sensors they reference, so they are excluded from the uniqueness # check. defp retrieve_names(%BB.Dsl.Estimator.Input{}, _path, names), do: names defp retrieve_names(%BB.Dsl.Estimator.Output{}, _path, names), do: names defp retrieve_names(entity, path, names) when is_map_key(entity, :name) do names = names |> Map.update(entity.name, [path], &[path | &1]) path = [entity.name | path] entity |> Map.drop([:name, :__struct__]) |> Enum.reduce(names, fn {key, value}, names -> retrieve_names(value, [key | path], names) end) end defp retrieve_names(entity, path, names) when is_map(entity) do entity |> Map.delete(:__struct__) |> Enum.reduce(names, fn {key, value}, names -> retrieve_names(value, [key | path], names) end) end defp retrieve_names(entities, path, names) when is_list(entities) do entities |> Enum.reduce(names, fn value, names -> retrieve_names(value, path, names) end) end defp retrieve_names(_value, _path, names), do: names end