Login
Artifact [c3f3a064e6]
Login

Artifact c3f3a064e62ec5b05c24114af8038c4e52462c64d90effe5bfe9a0bbfb1f26a4:


#### 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
  # The `Writer` class is used to generate properly-formatted RSConf data from
  # `RSValue`s.  There is also the `RemiLib::RSConf::Builder` class, which is
  # much more flexible.
  #
  # ```crystal
  # require "libremiliacr"
  #
  # root = RemiLib::RSConf::RSObject.new
  # root["test"] = 69
  # root["test2"] = "Hello, world!"
  #
  # io = IO::Memory.new
  # writer = RemiLib::RSConf::Writer.new(io)
  # writer.write(root)
  # puts io.to_s
  # ```
  class Writer
    # Returns the underlying `IO`.
    getter stream : IO
    @indent : Int64 = 0
    @toplevelWritten : Bool = false

    # The size of the indent (ASCII spaces).
    property indentSize : UInt32 = 2

    # When true, then key names are always quoted, even when they don't need to
    # be.  Otherwise they are only quoted when necessary.
    property? alwaysQuoteKeys : Bool = false

    # When true, then float values are always written using scientific notation.
    property? alwaysUseScientificNotation : Bool = false

    # When true, and the toplevel value is an `RSObject`, then braces will be
    # inserted around it (similar to JSON).  Otherwise these are omitted, as
    # permitted by the specs.
    property? explicitRootObject : Bool = false

    # When true, then a comma is always inserted after values, otherwise commas
    # are omitted.
    property? commaAfterValues : Bool = false

    # When true, then an extra newline is added after every topevel key/value
    # pair (i.e., an extra blank line between each pair).  Otherwise only a
    # single newline is emitted after each pair.
    property? extraNewlineBetweenToplevelKeys : Bool = false

    # Creates a new `Writer` instance that will write data to *stream*.
    def initialize(@stream : IO)
      unless @stream.encoding == "UTF-8"
        raise RSConfEncodingError.new("Unsupported stream encoding: #{@stream.encoding}")
      end
    end

    # Writes RSConf data to the given `IO`.
    def self.write(dest : IO, data : RSTopLevel) : Nil
      writer : Writer = Writer.new(dest)
      writer.write(data)
    end

    # Writes RSConf data to a new string and returns it.
    def self.write(data : RSTopLevel) : String
      ret = IO::Memory.new
      write(ret, data)
      ret.to_s
    end

    @[AlwaysInline]
    protected def writeIndent(size : Int) : Nil
      @stream << (" " * size)
    end

    @[AlwaysInline]
    protected def writeQuotedString(str : String) : Nil
      @stream << '"' << str.gsub("\"", "\\\"") << '"'
    end

    @[AlwaysInline]
    protected def writeUnuotedString(str : String) : Nil
      @stream << str
    end

    protected def writeValue(val : RSValue) : Nil
      case val
      in RSObject
        writeObject(val)
      in RSScalar
        case
        when val.isString?
          writeQuotedString(val.string)
          @stream << ',' if @commaAfterValues

        when val.isFloat?
          if @alwaysUseScientificNotation
            @stream << sprintf("%e", val.float).gsub('e', 'd')
          else
            @stream << sprintf("%f", val.float)
          end
          @stream << ',' if @commaAfterValues

        when val.isInt?
          @stream << val.int
          @stream << ',' if @commaAfterValues

        when val.isBool?
          @stream << (val.bool ? "true" : "false")
          @stream << ',' if @commaAfterValues

        when val.isNil?
          @stream << "nil"
          @stream << ',' if @commaAfterValues
        end
      in RSArray
        writeArray(val)
      end
      @stream << '\n'
    end

    protected def writeArray(val : RSArray) : Nil
      @stream << "[\n"
      @indent += @indentSize
      val.each do |elt|
        writeIndent(@indent)
        writeValue(elt)
      end
      @indent = Math.max(0i64, @indent - @indentSize)
      writeIndent(@indent)
      if @commaAfterValues
        @stream << "],\n"
      else
        @stream << "],"
      end
    end

    @[AlwaysInline]
    protected def keyNeedsQuotes(key : String) : Bool
      key.includes?(':') ||
        key.includes?('[') ||
        key.includes?(']') ||
        key.includes?('{') ||
        key.includes?('}') ||
        key.includes?('"')
    end

    protected def writeObjectPairs(data : RSObject, extraNewline : Bool = false) : Nil
      data.each do |key, val|
        writeIndent(@indent)

        # Do we need quotes?
        if @alwaysQuoteKeys || keyNeedsQuotes(key)
          writeQuotedString(key)
        else
          writeUnuotedString(key)
        end

        @stream << ": "
        writeValue(val)
        @stream << '\n' if extraNewline
      end
    end

    protected def writeObject(data : RSObject) : Nil
      @stream << "{\n"
      @indent += @indentSize
      writeObjectPairs(data)
      @indent = Math.max(0i64, @indent - @indentSize)
      writeIndent(@indent)
      if @commaAfterValues
        @stream << "},\n"
      else
        @stream << "},"
      end
    end

    # Writes the toplevel `RSObject` value.  After this, no more writing can
    # happen until `#stream=` is called.
    def write(data : RSObject) : Nil
      raise RSConfError.new("Cannot write an additional toplevel") if @toplevelWritten
      if @explicitRootObject
        @stream << "{\n"
        @indent += @indentSize
      end
      writeObjectPairs(data, @extraNewlineBetweenToplevelKeys)
      if @explicitRootObject
        @stream << "}"
        @indent = Math.max(0i64, @indent - @indentSize)
      end
      @stream << '\n'
      @toplevelWritten = true
    end

    # Writes the toplevel `RSArray` value.  After this, no more writing can
    # happen until `#stream=` is called.
    def write(data : RSArray) : Nil
      raise RSConfError.new("Cannot write an additional toplevel") if @toplevelWritten
      writeArray(data)
      @stream << '\n'
      @toplevelWritten = true
    end

    # Changes the destination stream.  Calling this effectively "resets" the
    # `Writer` instance, allowing you to call `#write` again to generate new
    # RSConf data.  The old stream is NOT closed.
    def stream=(value : IO) : Nil
      @stream = value
      @toplevelWritten = false
    end
  end
end