Packages

BACstack is a low-level Elixir implementation for the ASHRAE standard 135, BACnet - Building Automation and Controller network. In its current published state, v0.0.x, it can (only) act as a BACnet client for other BACnet devices.

Current section

Files

Jump to
bacstack lib bacnet beam_types.ex
Raw

lib/bacnet/beam_types.ex

defmodule BACnet.BeamTypes do
  @moduledoc """
  Contains functions to resolve typespecs and types from BEAM bytecode
  into a type declaration and functions to validate those types against values.

  During compilation, it uses a compilation tracer (as configured in the bacstack Mix Project)
  to track fresh compiled modules and stores them in persistent term for lookup later.

  The types are mostly used for BACnet object type declarations, where those types are
  at compile-time resolved, so compilation tracing is a huge need for development.
  Modules that have not changed and are not compiled, are read from the BEAM file.

  The module `BACnet.Protocol.ObjectsMacro` and `BACnet.Protocol.ObjectsUtility` use this
  module to resolve and validate types accurately to the need of ASHRAE 135 BACnet specification.
  """

  alias BACnet.Protocol.ApplicationTags
  alias BACnet.Protocol.BACnetArray
  alias BACnet.Protocol.Constants

  @typedoc """
  Valid type checker types. This is mostly used for BACnet object properties.

  Following types are supported:
  - `nil` - `nil`
  - `:any` - `any()`/`term()`
  - `:boolean` - `boolean()`
  - `:string` - `String.t()`
  - `:octet_string` - `binary()`
  - `:signed_integer` - `integer()`
  - `:unsigned_integer` - `non_neg_integer()`
  - `:real` - `float()`, also allowing `:NaN`, `:inf`, `:infn`
  - `:double` - value check same as `:real` (for `bac_object/2`, this type must be explicitely specified)
  - `:bitstring` - tuple of booleans
  - `{:array, subtype}` (validates a `BACnetArray` and every value of it being of `subtype`)
  - `{:array, subtype, fixed_size}` (validates a `BACnetArray` with fixed size of `fixed_size` and every value of it being of `subtype`)
  - `{:constant, type}` - `Constants.type()`
  - `{:in_list, values}`
  - `{:in_range, low, high}` - `x..y`
  - `{:list, type}` - `[type()]`
  - `{:literal, value_check}` (checks if `value` equals to `value_check` using the match `===/2` operator)
  - `{:struct, module}` (calls the module's `valid?/1` function, if exported)
  - `{:tuple, [type]}` (checks in sequence if the tuple element matches to the type in the same index)
  - `{:type_list, [type]}` - `type_a()|type_b()` (checks if the value passes one of the type checks in `types` list)
  - `{:with_validator, type, (term() -> boolean())}` - First checks the type and then calls the validator function.
  - `{:with_validator, type, validator_function_ast}` - First checks the type and then calls the validator function,
     which is AST that gets first evaluated and then called with the value. The AST must evaluate to a single arity function.
  """
  @type typechecker_types ::
          nil
          | :any
          | :boolean
          | :string
          | :octet_string
          | :signed_integer
          | :unsigned_integer
          | :real
          | :double
          | :bitstring
          | {:array, typechecker_types()}
          | {:array, typechecker_types(), pos_integer()}
          | {:constant, atom()}
          | {:in_list, term()}
          | {:in_range, integer(), integer()}
          | {:list, typechecker_types()}
          | {:literal, term()}
          | {:struct, module()}
          | {:tuple, [typechecker_types()]}
          | {:type_list, [typechecker_types()]}
          | {:with_validator, typechecker_types(), (term() -> boolean())}
          | {:with_validator, typechecker_types(), Macro.t()}

  @doc """
  Checks the type of value and verifies more complex type it is of the same type as the given type.
  """
  @spec check_type(typechecker_types(), term()) :: boolean()
  def check_type(type, value)

  def check_type(nil, nil), do: true
  def check_type(nil, _value), do: false

  def check_type(:any, _value), do: true

  def check_type(:boolean, value), do: is_boolean(value)

  def check_type(:string, value), do: is_binary(value) and String.valid?(value)
  def check_type(:octet_string, value), do: is_binary(value)

  def check_type(:signed_integer, value), do: is_integer(value)
  def check_type(:unsigned_integer, value), do: is_integer(value) and value >= 0

  def check_type(:real, value), do: is_float(value) or value in [:NaN, :inf, :infn]

  def check_type(:double, value), do: is_float(value) or value in [:NaN, :inf, :infn]

  def check_type(:bitstring, value) when is_tuple(value) do
    value
    |> Tuple.to_list()
    |> Enum.all?(&is_boolean/1)
  end

  def check_type(:bitstring, _value), do: false

  def check_type({:array, subtype}, value) do
    if is_struct(value, BACnetArray) do
      subtype == :any or
        BACnetArray.reduce_while(value, true, fn item, _acc ->
          if check_type(subtype, item) do
            {:cont, true}
          else
            {:halt, false}
          end
        end)
    else
      false
    end
  end

  def check_type(
        {:array, subtype, fixed_size},
        %BACnetArray{fixed_size: fixed_size2} = value
      )
      when is_integer(fixed_size) and fixed_size >= 1 and fixed_size == fixed_size2 do
    check_type({:array, subtype}, value)
  end

  def check_type({:array, _subtype, fixed_size}, _value)
      when is_integer(fixed_size) and fixed_size >= 1,
      do: false

  def check_type({:constant, type}, value) when is_atom(type),
    do: is_atom(value) and Constants.has_by_name(type, value)

  def check_type({:in_list, values}, value) when is_list(values) do
    value in values
  end

  def check_type({:in_range, low, high}, value) when is_integer(low) and is_integer(high) do
    is_integer(value) and value >= low and value <= high
  end

  def check_type({:list, _type}, []), do: true

  def check_type({:list, type}, value) when is_list(value) do
    Enum.all?(value, &check_type(type, &1))
  end

  def check_type({:list, _type}, _value), do: false

  def check_type({:literal, eq}, value), do: value === eq

  def check_type({:tuple, subtypes}, value) when is_list(subtypes) and is_tuple(value) do
    if length(subtypes) == tuple_size(value) do
      subtypes
      |> Stream.with_index()
      |> Enum.all?(fn {spec, index} ->
        check_type(spec, elem(value, index))
      end)
    else
      false
    end
  end

  def check_type({:tuple, subtypes}, _value) when is_list(subtypes), do: false

  def check_type({:struct, type}, value) when is_struct(value, type) do
    if function_exported?(type, :valid?, 1) do
      type.valid?(value)
    else
      true
    end
  end

  def check_type({:struct, _type}, _value), do: false

  def check_type({:type_list, types}, value) when is_list(types) do
    Enum.any?(types, &check_type(&1, value))
  end

  def check_type({:with_validator, type, validator}, value) when is_function(validator, 1) do
    check_type_validator(type, validator, value)
  end

  def check_type({:with_validator, type, validator_ast}, value) do
    {validator, _bind} = Code.eval_quoted(validator_ast, [], __ENV__)
    check_type_validator(type, validator, value)
  end

  def check_type(type, _value) do
    raise ArgumentError, "Unknown type: #{inspect(type)}"
  end

  defp check_type_validator(type, validator, value) do
    if is_function(validator, 1) do
      check_type(type, value) and validator.(value)
    else
      raise ArgumentError, "Only one arity functions are allowed for :with_validator"
    end
  end

  @doc """
  Generates `valid?/1` clause body based on the given module's `:t` typespec,
  it must reference a struct.

  The variable used for the validation is called `t`.
  Keys that start with an underline are ignored.
  """
  @spec generate_valid_clause(module(), Macro.Env.t()) :: Macro.t()
  def generate_valid_clause(module, env) when is_atom(module) do
    validation = resolve_struct_type(module, :t, env, ignore_underlined_keys: true)

    if map_size(validation) == 0 do
      quote do
        true
      end
    else
      var = Macro.var(:t, env.module)

      validation
      |> Enum.map(fn {field, type} ->
        quote do
          unquote(__MODULE__).check_type(unquote(Macro.escape(type)), unquote(var).unquote(field))
        end
      end)
      |> Enum.reduce(fn expr1, acc ->
        quote do
          unquote(acc) and unquote(expr1)
        end
      end)
    end
  end

  @doc """
  Resolves an AST type to something `check_type/2` works with.
  """
  @spec resolve_type(Macro.t(), Macro.Env.t()) :: term()
  def resolve_type(type, env) do
    field_typespec_to_bactype(type, env, %{})
  end

  @doc """
  Resolves a type (struct) to a map of fields to typespecs (`resolve_type/2`-like).

  This function only works with BACnet data structures and not with types, such as
  exposed by the module `ApplicationTags`. Since `tuples` are used in that module to
  structure data together, `tuples` are used in BACnet data structures as bitstrings
  (as seen in `ApplicationTags` bitstrings).
  As such wrong typespecs will be generated and cannot be used for validation.

  For structs that do not define any fields, an empty map will be returned.

  Tuple types such as `{binary(), integer()}` will be resolved to `{:tuple, [:octet_string, :integer]}`.

  ```elixir
  iex> resolve_struct_type(BACnet.Protocol.EventParameters.ChangeOfBitstring, :t, __ENV__)
  %{
    alarm_values: {:list, :bitstring},
    bitmask: :bitstring,
    time_delay: :unsigned_integer,
    time_delay_normal: {:type_list, [:unsigned_integer, {:literal, nil}]}
  }
  ```

  Available options:
  - `ignore_underlined_keys: boolean()` - Ignores/skips keys starting with `_`, as such the
    resolved types will exclude types for underline-prefixed keys.
  """
  @spec resolve_struct_type(module(), atom(), Macro.Env.t(), Keyword.t()) :: map()
  def resolve_struct_type(module, type, env, opts \\ []) do
    Code.ensure_compiled!(module)
    beam = get_beam_entity_for_module(module, env)

    # Add spec_module, so we may be able to get it in some non-BEAM specific function
    opts_map =
      opts
      |> Map.new()
      |> Map.put(:spec_module, module)

    ignore_underlined_keys = opts_map[:ignore_underlined_keys] == true

    result =
      case parse_beam_module_for_typespec(module, type, beam, env) do
        {:type, _line, :map, fields} ->
          fields
          |> Map.new(fn
            {:type, _line, :map_field_exact,
             [{:atom, _num, :__struct__}, {:atom, _other, ^module}]} ->
              {:__drop__, nil}

            {:type, _line, :map_field_exact, [{:atom, _num, key}, spec]} ->
              if ignore_underlined_keys and String.starts_with?(Atom.to_string(key), "_") do
                {:__drop__, nil}
              else
                {key, map_beam_typespec_data(spec, env, module, opts_map)}
              end

            _else ->
              {:__drop__, nil}
          end)
          |> Map.delete(:__drop__)

        _term ->
          str_mod = String.replace("#{module}", "Elixir.", "")

          raise CompileError,
            description: "Type #{str_mod}.#{type} does not export the type as struct",
            file: env.file,
            line: env.line
      end

    result
  end

  # List of something (i.e. primitive type, struct)
  defp field_typespec_to_bactype(
         [ast],
         env,
         opts
       ) do
    {:list, field_typespec_to_bactype(ast, env, opts)}
  end

  # No type checking - this is an internal struct field
  defp field_typespec_to_bactype({:internal_metadata, _list, []}, _env, _opts) do
    nil
  end

  # Map true and false to boolean()
  defp field_typespec_to_bactype(type, _env, _opts) when type in [true, false] do
    :boolean
  end

  defp field_typespec_to_bactype(type, _env, _opts) when is_atom(type) do
    type
  end

  # Catch unsupported generic types (those we know so far)
  defp field_typespec_to_bactype({type, _any, []}, env, _opts) when type in [:map] do
    raise CompileError,
      description: "Type #{type} is not supported",
      file: env.file,
      line: env.line
  end

  # Special handling for some types
  defp field_typespec_to_bactype({:integer, _any, []}, _env, _opts) do
    :signed_integer
  end

  defp field_typespec_to_bactype({:pos_integer, _any, []}, _env, _opts) do
    # pos_integer means non_neg_integer but starting from 1
    # so we need a validator (in AST form)
    validator =
      quote do
        &(is_integer(&1) and &1 >= 1)
      end

    {:with_validator, :unsigned_integer, validator}
  end

  defp field_typespec_to_bactype({:non_neg_integer, _any, []}, _env, _opts) do
    :unsigned_integer
  end

  defp field_typespec_to_bactype({:float, _any, []}, _env, _opts) do
    :real
  end

  # defp field_typespec_to_bactype({:boolean, _any, []}, _env, _opts) do
  #   # bac_type for boolean in a context-specified tag
  #   # is enumerated, not boolean
  #   :enumerated
  # end

  # Special handling for :binary (which is :octet_string)
  # String.t() gets mapped to :string (= character_string)
  defp field_typespec_to_bactype({:binary, _any, []}, _env, _opts) do
    :octet_string
  end

  # Map term() to any()
  defp field_typespec_to_bactype({:term, _any, []}, _env, _opts) do
    :any
  end

  # Map tuple() to bitstring()
  defp field_typespec_to_bactype({:tuple, _any, []}, _env, _opts) do
    :bitstring
  end

  defp field_typespec_to_bactype({type, _any, []}, _env, _opts) when is_atom(type) do
    type
  end

  # Range expression x..y
  defp field_typespec_to_bactype({:.., _list, [value1, value2]}, _env, _opts) do
    {:in_range, value1, value2}
  end

  # Range expression x..y//z
  defp field_typespec_to_bactype({range, _list, [value1, value2, step]}, _env, _opts)
       when range in [:.., :..//] do
    {:in_list, Enum.to_list(value1..value2//step)}
  end

  # OR expression (i.e. "String.t() | nil")
  defp field_typespec_to_bactype({:|, _list, values}, env, opts) when is_list(values) do
    {:type_list, Enum.map(values, &field_typespec_to_bactype(&1, env, opts))}
  end

  # Special handling for String.t() (which is :string)
  defp field_typespec_to_bactype(
         {{:., _any3, [{:__aliases__, _alias, [:String]}, :t]}, _any, _any2},
         _env,
         _opts
       ) do
    :string
  end

  # credo:disable-for-lines:50 Credo.Check.Refactor.CyclomaticComplexity
  # Remote type (such as Date.t), resolve it based on type
  defp field_typespec_to_bactype(
         {{:., _any3, [ast, type]}, _any, params},
         env,
         opts
       )
       when is_atom(type) do
    module = Macro.expand(ast, env)
    Code.ensure_compiled!(module)

    # Decide the code path
    cond do
      # Application Tags encoding - ApplicationTags.encoding()
      module == ApplicationTags and type == :encoding ->
        :any

      # IEEE754 floats - ApplicationTags.ieee_float()
      module == ApplicationTags and type == :ieee_float ->
        :real

      # Unsigned 8bit - ApplicationTags.unsigned8()
      module == ApplicationTags and type == :unsigned8 ->
        validator =
          quote do
            &(is_integer(&1) and &1 >= 0 and &1 <= 255)
          end

        {:with_validator, :unsigned_integer, validator}

      # Unsigned 16bit - ApplicationTags.unsigned16()
      module == ApplicationTags and type == :unsigned16 ->
        validator =
          quote do
            &(is_integer(&1) and &1 >= 0 and &1 <= 65_535)
          end

        {:with_validator, :unsigned_integer, validator}

      # Unsigned 32bit - ApplicationTags.unsigned32()
      module == ApplicationTags and type == :unsigned32 ->
        validator =
          quote do
            &(is_integer(&1) and &1 >= 0 and &1 <= 4_294_967_295)
          end

        {:with_validator, :unsigned_integer, validator}

      # BACnet Array
      module == BACnetArray and type == :t ->
        case params do
          # BACnetArray.t() - Error as no subtype (we require a subtype)
          [] ->
            raise CompileError,
              description: "BACnetArray must have a subtype",
              file: env.file,
              line: env.line

          # BACnet.Array.t(subtype)
          [subtype] ->
            {:array, field_typespec_to_bactype(subtype, env, opts)}

          # BACnet.Array.t(subtype, fixed_size)
          [subtype, fixed_size] when is_integer(fixed_size) ->
            {:array, field_typespec_to_bactype(subtype, env, opts), fixed_size}

          # BACnet.Array.t(subtype, fixed_size) - ignore size if not integer (may be a subtype)
          [subtype, _size] ->
            {:array, field_typespec_to_bactype(subtype, env, opts)}

          # BACnet.Array.t(...) - Error as more than a single parameter (subtype)
          _term ->
            raise CompileError,
              description: "BACnetArray must have one or two parameters",
              file: env.file,
              line: env.line
        end

      # Constants.type() (BACnet constants)
      module == Constants ->
        {:constant, type}

      # Remote type (such as Date.t), fetch the type and transform it
      true ->
        beam = get_beam_entity_for_module(module, env)
        parse_beam_module_for_types(module, type, beam, env, opts)
    end
  end

  # Catch BEAM typespecs as they may fall through from map_beam_typespec_data/4

  defp field_typespec_to_bactype({:remote_type, _line, _spec} = spec, env, opts) do
    map_beam_typespec_data(spec, env, opts[:spec_module], opts)
  end

  defp field_typespec_to_bactype({:type, _line, _type, _args} = spec, env, opts) do
    map_beam_typespec_data(spec, env, opts[:spec_module], opts)
  end

  defp field_typespec_to_bactype({:user_type, _line, _name, _spec} = spec, env, opts) do
    map_beam_typespec_data(spec, env, opts[:spec_module], opts)
  end

  defp parse_beam_module_for_types(module, type, beam, env, opts) do
    module
    |> parse_beam_module_for_typespec(type, beam, env)
    |> map_beam_typespec_data(env, module, opts)
  end

  defp parse_beam_module_for_typespec(module, type, beam, env) do
    with {:ok, {_module, [{:abstract_code, {:raw_abstract_v1, attributes}}]}} <-
           :beam_lib.chunks(beam, [:abstract_code]),
         raw_types =
           attributes
           |> Enum.filter(fn
             {:attribute, _any, :type, _types} -> true
             _attribute -> false
           end)
           |> Enum.map(fn {:attribute, _any, :type, types} -> types end),
         typespec when is_tuple(typespec) <-
           Enum.find_value(raw_types, fn
             {^type, value, _any} -> value
             _term -> false
           end) do
      typespec
    else
      _term ->
        raise CompileError,
          description: "Unable to resolve type \"#{inspect(module)}.#{type}\"",
          file: env.file,
          line: env.line
    end
  end

  # Annotated remote type (such as rest :: String.t())
  defp map_beam_typespec_data(
         {:ann_type, _line, [{:var, _line2, _name}, {:remote_type, _line3, _type} = spec]},
         env,
         spec_module,
         opts
       ) do
    map_beam_typespec_data(
      spec,
      env,
      spec_module,
      opts
    )
  end

  # Annotated generic type (such as rest :: list())
  defp map_beam_typespec_data(
         {:ann_type, _line, [{:var, _line2, _name}, {:type, _line3, _type, _args} = spec]},
         env,
         spec_module,
         opts
       ) do
    map_beam_typespec_data(
      spec,
      env,
      spec_module,
      opts
    )
  end

  # Remote type (such as String.t)
  defp map_beam_typespec_data(
         {:remote_type, _line, [{:atom, _num, module}, {:atom, _other, type}, args]},
         env,
         _spec_module,
         opts
       ) do
    field_typespec_to_bactype(
      {{:., [], [module, type]}, [], args},
      env,
      opts
    )
  end

  # OR expression
  defp map_beam_typespec_data({:type, _line, :union, union}, env, spec_module, opts) do
    types =
      Enum.map(union, fn
        {:ann_type, _line, [{:var, _line2, _name}, {:remote_type, _line3, _type} = spec]} ->
          map_beam_typespec_data(spec, env, spec_module, opts)

        {:ann_type, _line, [{:var, _line2, _name}, {:type, _line3, _type, _args} = spec]} ->
          map_beam_typespec_data(spec, env, spec_module, opts)

        {:atom, _num, atom_name} ->
          {:literal, atom_name}

        {:remote_type, _line, [{:atom, _num, module}, {:atom, _other, type}, args]} ->
          field_typespec_to_bactype(
            {{:., [], [module, type]}, [], args},
            env,
            opts
          )

        {:type, _line, _type, _spec} = type ->
          map_beam_typespec_data(type, env, spec_module, opts)

        {:user_type, _line, type, args} ->
          field_typespec_to_bactype(
            {{:., [], [spec_module, type]}, [], args},
            env,
            opts
          )

        # Variables, may be anything
        {:var, _line, _name} ->
          :any

        term ->
          raise "Unknown how to handle BEAM typespec (from union: #{inspect(union)}, from module: #{inspect(spec_module)}): #{inspect(term)}"
      end)

    {:type_list, types}
  end

  # Map typespec (structs count as this)
  defp map_beam_typespec_data({:type, _line, :map, definition}, env, _spec_module, _opts)
       when not is_atom(definition) do
    # Check if it's a struct, if so, return the module name
    module =
      Enum.find_value(definition, fn
        {:type, _line, :map_field_exact, [{:atom, _num, :__struct__}, {:atom, _other, module}]} ->
          module

        _term ->
          false
      end)

    if module do
      {:struct, module}
    else
      raise CompileError,
        description: "Only structs are allowed as typespec, plain maps are not supported",
        file: env.file,
        line: env.line
    end
  end

  # List type (i.e. [tuple()])
  defp map_beam_typespec_data({:type, _line, :list, [type]}, env, spec_module, opts) do
    {:list, map_beam_typespec_data(type, env, spec_module, opts)}
  end

  # Generic type (such as boolean, integer, float, etc.)
  defp map_beam_typespec_data({:type, _line, subtype, subtypes}, env, _spec_module, opts)
       when subtypes == [] or subtypes == :any do
    field_typespec_to_bactype({subtype, [], []}, env, opts)
  end

  # Ranges x..y (where did these spawn from in BEAM?)
  defp map_beam_typespec_data(
         {:type, _line, :range, [{:integer, _any, min}, {:integer, _any2, max}]},
         _env,
         _spec_module,
         _opts
       ) do
    {:in_range, min, max}
  end

  defp map_beam_typespec_data({:type, _line, subtype, subtypes}, env, spec_module, opts)
       when is_list(subtypes) do
    subtypes =
      Enum.map(subtypes, fn
        {:ann_type, _num, [_var, spec]} -> map_beam_typespec_data(spec, env, spec_module, opts)
        {:atom, _num, name} -> {:literal, name}
        {:integer, _num, number} -> {:literal, number}
        spec -> map_beam_typespec_data(spec, env, spec_module, opts)
      end)

    {subtype, subtypes}
  end

  # Literal numbers or atoms
  defp map_beam_typespec_data({type, _num, val}, _env, _spec_module, _opts)
       when type in [:atom, :integer] do
    {:literal, val}
  end

  # Custom local types
  defp map_beam_typespec_data(
         {:user_type, _line, type, args},
         env,
         module,
         opts
       ) do
    field_typespec_to_bactype(
      {{:., [], [module, type]}, [], args},
      env,
      opts
    )
  end

  defp get_beam_entity_for_module(module, env, retried \\ false) do
    # Find the bytecode in our compilation tracer, if not available,
    # check if the BEAM file exists and use it instead

    case :persistent_term.get(module, nil) do
      nil ->
        # If we don't have it "cached", try to get it through
        # :code.get_object_code/1, if it fails,
        # fallback to finding the BEAM file (which fails for cover compiled)
        case :code.get_object_code(module) do
          {_module, binary, _filename} ->
            binary

          :error ->
            beam_path =
              case :code.which(module) do
                :cover_compiled ->
                  nil

                :non_existing ->
                  nil

                beam_path ->
                  if File.exists?(beam_path) do
                    beam_path
                  end
              end

            if beam_path do
              beam_path
            else
              # We retry when all methods fail, because there seems to be
              # a race condition, where Code.ensure_compiled!/1 releases
              # the lock, but the compilation tracer wasn't called and
              # the BEAM file wasn't written yet - by retrying 100ms later,
              # it seems that we can successfully resolve the race condition
              # and not fail the complete compilation (tested with 200 full runs)
              if retried do
                raise CompileError,
                  description:
                    "Missing bytecode for module #{inspect(module)}, unable to lookup types",
                  file: env.file,
                  line: env.line
              else
                Process.sleep(100)
                get_beam_entity_for_module(module, env, true)
              end
            end
        end

      bytecode ->
        bytecode
    end
  end
end