#### 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