Login
Artifact [8837a94e8d]
Login

Artifact 8837a94e8d183db6216f1752f8f4f9d654d26f0ea9404923e1ac5d218a27f004:


#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU General Public License as published the Free
#### Software Foundation, either version 3 of the License, or (at your option)
#### any later version.
####
#### This program is distributed in the hope that it will be useful, but WITHOUT
#### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#### FITNESS FOR A PARTICULAR PURPOSE.See the GNU General Public License for
#### more details.
####
#### You should have received a copy of the GNU General Public License along
#### with this program.If not, see<http:####www.gnu.org/licenses/.>
require "./common"

module RemiLib::RSConf
  # Annotates a field in a class or struct to provide additional options when
  # (de)serializing RSConf data.
  #
  # Possible options are:
  #
  # * **ignore**: if `true` skip this field in serialization and deserialization
  #   (by default false)
  # * **neverSerialize**: If truthy, skip this field in serialization (default:
  #   `false`). The value can be any Crystal expression and is evaluated at
  #   runtime.
  # * **neverDeserialize**: if `true` skip this field in deserialization (by
  #   default false)
  # * **key**: the value of the key in the RSConf object (by default the name of
  #   the instance variable)
  # * **root**: assume the value is inside an RSConf object with a given key
  #   (see `Object.fromRSConf(stringOrIO, root)`)
  # * **converter**: specify an alternate type for parsing and generation. The
  #   converter must define `fromRsconf(RemiLib::RSConf::RSValue)` and
  #   `toRsconf(value, RemiLib::RSConf::RSValue)`. Examples of converters are a
  #   `Time::Format` instance and `Time::EpochConverter` for `Time`.
  # * **presence**: if `true`, a `@{{key}}Present` instance variable and a
  #   `#getter?` with the same name will be generated when the key was present
  #   (even if it has a `null` value), `false` by default
  # * **emitNull**: if `true`, emits a `null` value for nilable property (by
  #   default nulls are not emitted)
  # * **blanksBefore**: the number of blank lines to print before this field is
  #   written.  This occurs after any `Serializable#beforeToRsconf` method is
  #   called, and before any comment is written. Default is 0.
  # * **blanksAfter**: the number of blank lines to print after this field is
  #   written.  This occurs before any `Serializable#afterToRsconf` method is
  #   called, and (if using `RemiLib::RSConf::Serializable::Unmapped`), before
  #   any unmapped fields are written. Default is 0.
  # * **commentBefore**: A comment string to write before the field is written.
  #   This occurs after any `Serializable#beforeToRsconf` method is called.
  #   This cannot be used together with the **commentFnBefore** property.
  # * **commentFnBefore**: A function that must return a string.  This will be
  #   called before the field is written, and its return value will be written
  #   as a comment.  This occurs after any `Serializable#beforeToRsconf` method
  #   is called.  This cannot be used together with the **commentBefore**
  #   property.
  # * **commentIsBlock**: When `true`, then the **commentBefore** or
  #   **commentFnBefore** field is written using
  #   `RemiLib::RSConf::Builder#writeBlockComment`.  Default is `false`.
  annotation Field
  end

  # The `RemiLib::RSConf::Serializable` module automatically generates methods
  # for RSConf serialization when included.
  #
  # ### Example
  #
  # ```
  # require "libremiliacr"
  #
  # alias RSConf = RemiLib::RSConf
  #
  # class Location
  #   include RSConf::Serializable
  #
  #   @[RSConf::Field(key: "lat")]
  #   property latitude : Float64
  #
  #   @[RSConf::Field(key: "long")]
  #   property longitude : Float64
  # end
  #
  # class House
  #   include RSConf::Serializable
  #   property address : String
  #   property location : Location?
  # end
  #
  # rsStr = %|
  # address = "Crystal Road 1234"
  # location = {
  #   lat: 12.3
  #   long: 34.5
  # }
  # |
  #
  # parsed = RSConf::Parser.parse(rsStr)
  # house = House.fromRsconf(parsed)
  # house.address  # => "Crystal Road 1234"
  # house.location # => #<Location:0x10cd93d80 @latitude=12.3, @longitude=34.5>
  # ```
  #
  # ### Usage
  #
  # Including `RemiLib::RSConf::Serializable` will create `#toRsconf` and
  # `self.fromRsconf` methods on the current class, and a constructor which
  # takes a `RemiLib::RSConf::RSValue`. By default, these methods serialize into
  # an RSConf object containing the value of every instance variable, the keys
  # being the instance variable name.  Most primitives and collections supported
  # as instance variable values (string, integer, array, hash, etc.), along with
  # objects which define toRsconf and a constructor taking a
  # `oRemiLib::RSConf::RSValue`.  Union types are also supported, including
  # unions with `nil`. If multiple types in a union parse correctly, it is
  # undefined which one will be chosen.
  #
  # To change how individual instance variables are parsed and serialized, the
  # annotation `RemiLib::RSConf::Field` can be placed on the instance
  # variable. Annotating property, getter and setter macros is also allowed.
  #
  # ```
  # require "libremiliacr"
  # alias RSConf = RemiLib::RSConf
  #
  # class A
  #   include RSConf::Serializable
  #
  #   @[RSConf::Field(key: "my_key", emitNull: true)]
  #   getter a : Int32?
  # end
  # ```
  #
  # `RemiLib::RSConf::Field` properties:
  #
  # * **ignore**: if `true` skip this field in serialization and deserialization
  #   (by default false)
  # * **neverSerialize**: If truthy, skip this field in serialization (default:
  #   `false`). The value can be any Crystal expression and is evaluated at
  #   runtime.
  # * **neverDeserialize**: if `true` skip this field in deserialization (by
  #   default false)
  # * **key**: the value of the key in the RSConf object (by default the name of
  #   the instance variable)
  # * **root**: assume the value is inside an RSConf object with a given key
  #   (see `Object.fromRSConf(stringOrIO, root)`)
  # * **converter**: specify an alternate type for parsing and generation. The
  #   converter must define `fromRsconf(RemiLib::RSConf::RSValue)` and
  #   `toRsconf(value, RemiLib::RSConf::RSValue)`. Examples of converters are a
  #   `Time::Format` instance and `Time::EpochConverter` for `Time`.
  # * **presence**: if `true`, a `@{{key}}Present` instance variable and a
  #   `#getter?` with the same name will be generated when the key was present
  #   (even if it has a `null` value), `false` by default
  # * **emitNull**: if `true`, emits a `null` value for nilable property (by
  #   default nulls are not emitted)
  # * **blanksBefore**: the number of blank lines to print before this field is
  #   written.  This occurs after any `#beforeToRsconf` method is
  #   called, and before any comment is written. Default is 0.
  # * **blanksAfter**: the number of blank lines to print after this field is
  #   written.  This occurs before any `#afterToRsconf` method is called, and
  #   (if using `RemiLib::RSConf::Serializable::Unmapped`), before any unmapped
  #   fields are written. Default is 0.
  # * **commentBefore**: A comment string to write before the field is written.
  #   This occurs after any `#beforeToRsconf` method is called.  This cannot be
  #   used together with the **commentFnBefore** property.
  # * **commentFnBefore**: A function that must return a string.  This will be
  #   called before the field is written, and its return value will be written
  #   as a comment.  This occurs after any `#beforeToRsconf` method is called.
  #   This cannot be used together with the **commentBefore** property.
  # * **commentIsBlock**: When `true`, then the **commentBefore** or
  #   **commentFnBefore** field is written using
  #   `RemiLib::RSConf::Builder#writeBlockComment`.  Default is `false`.
  #
  # Deserialization also respects default values of variables:
  # ```
  # require "libremiliacr"
  # alias RSConf = RemiLib::RSConf
  #
  # struct A
  #   include RSConf::Serializable
  #   @a : Int32
  #   @b : Float64 = 1.0
  # end
  #
  # data = RSConf::Parser.parse(%|a = 1|)
  # A.fromRsconf(data) # => A(@a=1, @b=1.0)
  # ```
  #
  # ### Extensions: `RemiLib::RSConf::Serializable::Strict` and
  # ### `RemiLib::RSConf::Serializable::Unmapped`.
  #
  # If the `RemiLib::RSConf::Serializable::Strict` module is included, unknown
  # properties in the RSConf document will raise a parse exception. By default
  # the unknown properties are silently ignored.  If the
  # `RemiLib::RSConf::Serializable::Unmapped` module is included, unknown
  # properties in the RSConf document will be stored in a `Hash(String,
  # RemiLib::RSConf::RSValue)`. On serialization, any keys inside
  # `rsconfUnmapped` will be serialized and appended to the current RSConf
  # object.
  #
  # ```
  # require "libremiliacr"
  # alias RSConf = RemiLib::RSConf
  #
  # struct A
  #   include RSConf::Serializable
  #   include RSConf::Serializable::Unmapped
  #   @a : Int32
  # end
  #
  # data = RSConf::Parser.parse($|a = 1, b = 2|)
  # a = A.fromRsconf(data) # => A(@rsconfUnmapped={"b" => 2}, @a=1)
  # a.rsconfUnmapped["b"].raw.class   # => Int64
  # ```
  #
  #
  # ### Class annotation `RemiLib::RSConf::Serializable::Options`
  #
  # Supported properties:
  #
  # * **emitNulls**: if `true`, emits a `null` value for all nilable properties
  #   (by default nulls are not emitted)
  #
  #
  # ```
  # require "libremiliacr"
  # alias RSConf = RemiLib::RSConf
  #
  # @[RSConf::Serializable::Options(emitNulls: true)]
  # class A
  #   include RSConf::Serializable
  #   @a : Int32?
  # end
  # ```
  #
  # ### `after_initialize` method
  #
  # `#after_initialize` is a method that runs after an instance is deserialized
  # from RSConf. It can be used as a hook to post-process the initialized
  # object.
  #
  # Example:
  # ```
  # require "libremiliacr"
  # alias RSConf = RemiLib::RSConf
  #
  # class Person
  #   include RSConf::Serializable
  #   getter name : String
  #
  #   def after_initialize
  #     @name = @name.upcase
  #   end
  # end
  #
  # data = RSConf::Parser.parse("name: jane")
  # person = Person.fromRsconf(data)
  # person.name # => "JANE"
  # ```
  module Serializable
    # This annotation can be attached to classes and structs to provide RSConf
    # (de)serialization options.
    #
    # Possible options are:
    # * **emitNulls**: if `true`, emits a `null` value for all nilable
    #   properties (by default nulls are not emitted).
    annotation Options
    end

    macro included
      # Creates a new instance by deserializing data from a
      # `RemiLib::RSConf::RSValue`.
      def self.new(toplevel : ::RemiLib::RSConf::RSValue)
        newFromRSConfTopLevel(toplevel)
      end

      private def self.newFromRSConfTopLevel(toplevel : ::RemiLib::RSConf::RSValue)
        instance = allocate
        instance.initialize(__fromRSConfTopLevel: toplevel)
        ::GC.add_finalizer(instance) if instance.responds_to?(:finalize)
        instance
      end

      macro inherited
        def self.new(toplevel : ::RemiLib::RSConf::RSValue)
          newFromRSConfTopLevel(toplevel)
        end
      end
    end

    # Called internally to perform the actual deserialization from an
    # `RemiLib::RSConf::RSValue`.
    def initialize(*, __fromRSConfTopLevel toplevel : ::RemiLib::RSConf::RSValue)
      {% begin %}
        unless toplevel.is_a?(::RemiLib::RSConf::RSObject)
          raise ::RemiLib::RSConf::RSConfSerializationError.new("Toplevel is not an object.")
        end

        {% properties = {} of Nil => Nil %}
        {% for ivar in @type.instance_vars %}
          {% ann = ivar.annotation(::RemiLib::RSConf::Field) %}
          {% unless ann && (ann[:ignore] || ann[:neverDeserialize]) %}
            {%
             properties[ivar.id] = {
               key:         ((ann && ann[:key]) || ivar).id.stringify, # The name of the field
               hasDefault:  ivar.has_default_value?, # Whether or not this field has a default value
               default:     ivar.default_value, # The default value for this field, if any
               nilable:     ivar.type.nilable?, # Whether or not this field is nillable
               type:        ivar.type, # The type of this field

               root:        ann && ann[:root], # This field's value is in a key directly underneath with this name
               converter:   ann && ann[:converter], # A conversion type
               presence:    ann && ann[:presence], # Whether or not a "presence" field is also generated
             }
            %}
          {% end %}
        {% end %}

        # `%var`'s type must be exact to avoid type inference issues with
        # recursively defined serializable types
        {% for name, value in properties %}
          %var{name} = uninitialized ::Union({{value[:type]}})
          %found{name} = false
        {% end %}

        # Loop through all of toplevel's pairs, checking to see if we can
        # deserialize the pair into a field.
        toplevel.each do |%key, %val|
          # Match this object's key to one of the field names.
          case %key
          {% for name, value in properties %}
          when {{value[:key]}}
            {% if value[:hasDefault] || value[:nilable] %}
              # Take care of null values right away
              if %val.is_a?(::RemiLib::RSConf::RSScalar) && %val.nil?
                {% if value[:nilable] %}
                  %var{name} = nil
                  %found{name} = true
                {% end %}
                next
              end
            {% end %}

            {% if value[:root] %}
              # This key expects its value in a subnode.
              if %val.is_a?(::RemiLib::RSConf::RSObject)
                if %val.has_key?({{value[:root]}})
                  begin
                    {% if value[:converter] %}
                      %subval = {{value[:converter]}}.fromRsconf(%val[{{value[:root]}}])
                    {% else %}
                      %subval = {{value[:type]}}.fromRsconf(%val[{{value[:root]}}])
                    {% end %}
                  rescue err : ::RemiLib::RSConf::RSConfError
                    raise ::RemiLib::RSConf::RSConfError.new("Bad value for key '#{%key}': #{err}", err)
                  end
                {% if value[:hasDefault] %}
                else
                  %subval = {{value[:default]}}
                {% else %}
                else
                  raise RSConfSerializationError.new("Missing root node for key '#{%key}'")
                {%end%}
                end

              {% if value[:hasDefault] %}
              elsif %val.is_a?(RSScalar) && %val.nil?
                %subval = {{value[:default]}}
              {% else %}
              else
                raise RSConfSerializationError.new("Root node for key '#{%key}' is not an object")
              {%end%}
              end

              %var{name} = %subval
              next
            {% else %}
              # This key expects is value directly (i.e. this doesn't use the
              # 'root' field option)
              begin
                {% if value[:converter] %}
                  %var{name} = {{value[:converter]}}.fromRsconf(%val)
                {% else %}
                  %var{name} = {{value[:type]}}.fromRsconf(%val)
                {% end %}
              rescue err : ::RemiLib::RSConf::RSConfError
                raise ::RemiLib::RSConf::RSConfError.new("Bad value for key '#{%key}': #{err}", err)
              end
            {% end %}
            %found{name} = true
          {% end %}
          else
            onUnknownRSConfAttribute(%key, %val)
          end
        end

        {% for name, value in properties %}
          if %found{name}
            @{{name}} = %var{name}
          else
            {% unless value[:hasDefault] || value[:nilable] %}
              raise ::RemiLib::RSConf::RSConfSerializationError.new("Missing RSConf attribute: {{value[:key].id}}")
            {% end %}
          end

          {% if value[:presence] %}
            @{{name}}Present = %found{name}
          {% end %}
        {% end %}
      {% end %}
      after_initialize
    end

    # Called after deserialization is completed.
    def after_initialize
    end

    protected def onUnknownRSConfAttribute(key, value)
    end

    # Called immediately before this object is serialized to RSConf.
    def beforeToRsconf(builder : ::RemiLib::RSConf::Builder) : Nil
    end

    # Called immediately after this object is serialized to RSConf.
    def afterToRsconf(builder : ::RemiLib::RSConf::Builder) : Nil
    end

    # Called after the key/value pairs for this object are serialized to RSConf,
    # but before the object is closed out.
    protected def toRsconfBeforeObjectFinished(builder : ::RemiLib::RSConf::Builder) : Nil
    end

    # Serializes this instance to RSConf data.
    def toRsconf(builder : ::RemiLib::RSConf::Builder)
      {% begin %}
        {% options = @type.annotation(::RemiLib::RSConf::Serializable::Options) %}
        {% emitNulls = options && options[:emitNulls] %}

        {% properties = {} of Nil => Nil %}
        {% for ivar in @type.instance_vars %}
          {% ann = ivar.annotation(::RemiLib::RSConf::Field) %}
          {% unless ann && (ann[:ignore] || ann[:neverSerialize] == true) %}
            {%
              properties[ivar.id] = {
                key:              ((ann && ann[:key]) || ivar).id.stringify,
                root:             ann && ann[:root],
                converter:        ann && ann[:converter],
                emitNull:         (ann && (ann[:emitNull] != nil) ? ann[:emitNull] : emitNulls),
                neverSerialize:   ann && ann[:ignoreSerialize],
                blanksBefore:     ann && (ann[:blanksBefore] || 0),
                blanksAfter:      ann && (ann[:blanksAfter] || 0),
                commentBefore:    ann && ann[:commentBefore],
                commentFnBefore:  ann && ann[:commentFnBefore],
                commentIsBlock:   ann && ann[:commentIsBlock],
              }
            %}
            {% if properties[ivar.id][:commentBefore] && properties[ivar.id][:commentFnBefore] %}
              {% raise "Cannot use both commentBefore and commentFnBefore in a RemiLib::RSConf::Field" %}
            {% end %}
          {% end %}
        {% end %}

        beforeToRsconf(builder)
        builder.writeObject do
          {% for name, value in properties %}
            _{{name}} = @{{name}}

            {% if value[:neverSerialize] %}
              unless {{ value[:neverSerialize] }}
            {% end %}

              {% unless value[:emitNull] %}
                unless _{{name}}.nil?
              {% end %}

              {% if value[:blanksBefore] %}
                {% for i in 1..value[:blanksBefore] %}
                  builder.writeBlankLine
                {% end %}
              {% end %}

              {% if value[:commentBefore] %}
                {% if value[:commentIsBlock] %}
                  builder.writeBlockComment({{value[:commentBefore]}})
                {% else %}
                  builder.writeComment({{value[:commentBefore]}})
                {% end %}
              {% end %}

              {% if value[:commentFnBefore] %}
                {% if value[:commentIsBlock] %}
                  builder.writeBlockComment({{value[:commentFnBefore]}})
                {% else %}
                  builder.writeComment({{value[:commentFnBefore]}})
                {% end %}
              {% end %}

                builder.writePair({{value[:key]}}) do
                  {% if value[:root] %}
                    {% if value[:emitNull] %}
                      if _{{name}}.nil?
                        builder.writeNil
                      else
                    {% end %}

                    builder.writeObject do
                      builder.writePair({{value[:root]}}) do
                  {% end %}

                  {% if value[:converter] %}
                    if _{{name}}
                      {{ value[:converter] }}.toRsconf(_{{name}}, builder)
                    else
                      builder.writeNil
                    end
                  {% else %}
                    _{{name}}.toRsconf(builder)
                  {% end %}

                  {% if value[:root] %}
                    {% if value[:emitNull] %}
                      end
                    {% end %}
                      end
                    end
                  {% end %}
                end

              {% unless value[:emitNull] %}
                end
              {% end %}
            {% if value[:neverSerialize] %}
              end
            {% end %}

            {% if value[:blanksAfter] %}
              {% for i in 1..value[:blanksAfter] %}
                builder.writeBlankLine
              {% end %}
            {% end %}
          {% end %}
          toRsconfBeforeObjectFinished(builder)
        end
        afterToRsconf(builder)
      {% end %}
    end

    # If the `RemiLib::RSConf::Serializable::Strict` module is included, unknown
    # properties in the RSConf document encountered during deserialization will
    # raise a parse exception. Without this, unknown properties are silently
    # ignored during deserialization.
    module Strict
      protected def onUnknownRSConfAttribute(key, value)
        raise ::RemiLib::RSConf::RSConfSerializationError.new("Unknown RSConf attribute: #{key}")
      end
    end

    # If the `RemiLib::RSConf::Serializable::Unmapped` module is included,
    # unknown properties in the RSConf document encountered during
    # deserialization will be stored in a `Hash(String, RemiLib::RSConf::RSValue)`.
    # On serialization, any keys inside `#rsconfUnmapped` will be serialized and
    # appended to the current RSConf object.
    module Unmapped
      @[RemiLib::RSConf::Field(ignore: true)]
      property rsconfUnmapped : Hash(String, ::RemiLib::RSConf::RSValue) =
               Hash(String, ::RemiLib::RSConf::RSValue).new

      protected def onUnknownRSConfAttribute(key, value)
        @rsconfUnmapped[key] = value
      end

      protected def toRsconfBeforeObjectFinished(builder : ::RemiLib::RSConf::Builder) : Nil
        @rsconfUnmapped.each do |key, val|
          builder.writePair(key, val)
        end
      end
    end
  end
end