Artifact 8837a94e8d183db6216f1752f8f4f9d654d26f0ea9404923e1ac5d218a27f004:
- File
src/remilib/rsconf/serializable.cr
— part of check-in
[f3087154cb]
at
2025-12-31 11:10:21
on branch trunk
— Slightly better errors when deserializing RSConf data.
This is a bandage until a proper pull parser is implemented. (user: alexa size: 22609)
#### 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