// SPDX-License-Identifier: MIT
#include "FsonWriter.hh"
#include <string_view>
#include <charconv>
#include <cstdio>
#include <fstream>
#include <ostream>
#include <sstream>
#include <stdexcept>
#include <system_error>
#include <vector>
#include "Array.hh"
#include "Boolean.hh"
#include "Number.hh"
#include "Object.hh"
#include "String.hh"
#include "Value.hh"
using namespace std;
namespace fedem
{
namespace fson
{
namespace
{
// Splits arbitrary text on '\n' into lines (no trailing-newline
// special case — "a\nb" -> {"a","b"}; "a\n" -> {"a",""}).
vector< string > splitLines( string const& text )
{
vector< string > lines;
size_t start = 0U;
while( true )
{
size_t const pos = text.find( '\n', start );
if( pos == string::npos )
{
lines.push_back( text.substr( start ) );
break;
}
lines.push_back( text.substr( start, pos - start ) );
start = pos + 1U;
}
return lines;
}
} // end anonymous namespace
FsonWriter::FsonWriter( )
: dialect( Dialect::Fson )
, disabledPolicy( DisabledMemberPolicy::ConvertToComment )
, preferBare( false )
{
}
void FsonWriter::setDialect( FsonWriter::Dialect dialect ) noexcept
{
this->dialect = dialect;
}
FsonWriter::Dialect FsonWriter::getDialect( ) const noexcept
{
return dialect;
}
void FsonWriter::setDisabledMemberPolicy( DisabledMemberPolicy policy ) noexcept
{
disabledPolicy = policy;
}
FsonWriter::DisabledMemberPolicy FsonWriter::getDisabledMemberPolicy( ) const noexcept
{
return disabledPolicy;
}
void FsonWriter::setKeepDisabledMembers( bool keep ) noexcept
{
disabledPolicy = keep ? DisabledMemberPolicy::Keep
: DisabledMemberPolicy::ConvertToComment;
}
bool FsonWriter::getKeepDisabledMembers( ) const noexcept
{
return disabledPolicy == DisabledMemberPolicy::Keep;
}
void FsonWriter::setPreferBareKeys( bool prefer ) noexcept
{
preferBare = prefer;
}
bool FsonWriter::getPreferBareKeys( ) const noexcept
{
return preferBare;
}
bool FsonWriter::isOrdinaryMember( Member const& member ) const noexcept
{
if( dialect == Dialect::Fson )
return true;
if( !member.isDisabled( ) )
return true;
return disabledPolicy == DisabledMemberPolicy::Keep;
}
bool FsonWriter::isConvertedToComment( Member const& member ) const noexcept
{
if( dialect != Dialect::Json5 )
return false; // Fson shows disabled members literally; Json's
// ConvertToComment behaves like Drop (no comments)
return member.isDisabled( ) &&
disabledPolicy == DisabledMemberPolicy::ConvertToComment;
}
// ── entry points ────────────────────────────────────────────────────
void FsonWriter::write( Document const& document, ostream& stream )
{
indentor.reset( );
writeCommentLines( document.getHeaderComments( ), stream );
writeValue( document.getRootValue( ), stream );
stream << '\n';
writeCommentLines( document.getFooterComments( ), stream );
}
string FsonWriter::write( Document const& document )
{
ostringstream stream;
write( document, stream );
return stream.str( );
}
bool FsonWriter::writeFile( Document const& document, string const& filename )
{
ofstream stream( filename, ios::out | ios::trunc );
if( !stream )
return false;
write( document, stream );
stream.close( );
return stream.good( );
}
// ── values ──────────────────────────────────────────────────────────
void FsonWriter::writeValue( Value const& value, ostream& stream )
{
switch( value.getType( ) )
{
case Value::Type::Null:
stream << "null";
break;
case Value::Type::Boolean:
stream << ( value.asBoolean( )->getValue( ) ? "true" : "false" );
break;
case Value::Type::Number:
writeNumber( *value.asNumber( ), stream );
break;
case Value::Type::String:
{
String const& string = *value.asString( );
if( canWriteMultiline( string ) )
writeMultilineString( string, stream );
else
stream << '"' << encodeString( string.getValue( ) ) << '"';
break;
}
case Value::Type::Array:
writeArray( *value.asArray( ), stream );
break;
case Value::Type::Object:
writeObject( *value.asObject( ), stream );
break;
}
}
void FsonWriter::writeNumber( Number const& number, ostream& stream )
{
if( dialect != Dialect::Json )
{
// Fson and Json5 both write the lexeme verbatim: hex stays hex,
// Infinity/-Infinity/NaN stay as their literal tokens.
stream << number.getLexeme( );
return;
}
// Json: strict — no hex literals, no non-finite values.
switch( number.getKind( ) )
{
case Number::Kind::Infinity:
case Number::Kind::NegativeInfinity:
case Number::Kind::NaN:
throw domain_error(
"fson::FsonWriter: Json dialect cannot represent Infinity/-Infinity/NaN" );
case Number::Kind::Hex:
{
char buffer[ 64 ];
auto const result =
to_chars( buffer, buffer + sizeof( buffer ), number.asDouble( ) );
if( result.ec == errc( ) )
stream.write( buffer, result.ptr - buffer );
else
stream << "0"; // defensive fallback; not expected to trigger
break;
}
case Number::Kind::Decimal:
stream << number.getLexeme( );
break;
}
}
// An %include directive is emitted verbatim only in the Fson dialect.
// JSON has no include concept and JSON5 (as a strict JSON extension)
// does not either, so under Json/Json5 a directive is skipped on the
// assumption the caller resolved includes at load time when a flattened
// document was wanted. (Resolution/flattening lives in the loader, not
// the writer — see Fson::load.)
bool FsonWriter::isEmittedInclude( Include const& include ) const noexcept
{
if( dialect != Dialect::Fson )
return false;
// A disabled directive follows the disabled-member policy: kept only
// under Keep (written with its "--"); otherwise dropped, since there
// is no meaningful "convert %include to a comment" rendering.
if( include.isDisabled( ) )
return disabledPolicy == DisabledMemberPolicy::Keep;
return true;
}
void FsonWriter::writeObject( Object const& object, ostream& stream )
{
size_t const entryCount = object.entryCount( );
// First pass: locate the LAST entry that emits a comma-bearing line
// (an ordinary member or an emitted %include) and whether anything is
// visible at all (that, or a member converted into a comment).
size_t lastCommaPos = entryCount; // sentinel: none
bool anyOutput = false;
for( size_t position = 0U; position < entryCount; ++position )
{
Object::Entry const& entry = object.getEntry( position );
if( entry.kind == Object::EntryKind::Member )
{
Member const& member = object.getMember( entry.index );
if( member.isMerged( ) )
continue; // merged-in from an %include: query-view only
if( isOrdinaryMember( member ) )
{
lastCommaPos = position;
anyOutput = true;
}
else if( isConvertedToComment( member ) )
{
anyOutput = true;
}
}
else // Include
{
if( isEmittedInclude( object.getInclude( entry.index ) ) )
{
lastCommaPos = position;
anyOutput = true;
}
}
}
bool const hasDangling =
dialect != Dialect::Json && !object.getDanglingComments( ).empty( );
if( !anyOutput && !hasDangling )
{
stream << "{}";
return;
}
stream << "{\n";
indentor.right( );
for( size_t position = 0U; position < entryCount; ++position )
{
Object::Entry const& entry = object.getEntry( position );
if( entry.kind == Object::EntryKind::Include )
{
Include const& include = object.getInclude( entry.index );
if( !isEmittedInclude( include ) )
continue;
writeCommentLines( include.getLeadingComments( ), stream );
stream << indentor;
if( include.isDisabled( ) )
stream << "--";
stream << "%include ";
stream << '"' << encodeString( include.getPath( ) ) << '"';
if( include.hasAlias( ) )
stream << " as " << *include.getAlias( );
if( position != lastCommaPos )
stream << ',';
Comment const* const trailing = include.getTrailingComment( )
? &*include.getTrailingComment( )
: nullptr;
writeTrailingComment( trailing, stream );
continue;
}
Member const& member = object.getMember( entry.index );
if( member.isMerged( ) )
continue; // merged-in from an %include: query-view only
if( isOrdinaryMember( member ) )
{
writeCommentLines( member.getLeadingComments( ), stream );
// A keyless disabled member is a "--|…" bare multiline block: emit
// the "--" prefix and the value with no "key:" part. This form
// only exists in the Fson dialect (it is produced only by the
// parser for "--|" input); other dialects never reach here with an
// empty key because such a member is always disabled and they drop
// or comment disabled members via isOrdinaryMember/isConverted.
bool const keyless =
member.isDisabled( ) && member.getKey( ).getName( ).empty( );
stream << indentor;
if( member.isDisabled( ) && dialect == Dialect::Fson )
stream << "--";
if( !keyless )
{
writeKey( member.getKey( ), stream );
stream << ": ";
}
writeValue( member.getValue( ), stream );
// A multiline `|`-block runs to end-of-line, so a comma here would
// be re-read as content. The comma is optional after a block, so
// suppress it — the newline before the next member separates them.
String const* const asString = member.getValue( ).asString( );
bool const wroteBlock = asString && canWriteMultiline( *asString );
if( position != lastCommaPos && !wroteBlock )
stream << ',';
Comment const* const trailing = member.getTrailingComment( )
? &*member.getTrailingComment( )
: nullptr;
writeTrailingComment( trailing, stream );
}
else if( isConvertedToComment( member ) )
{
writeCommentLines( member.getLeadingComments( ), stream );
writeConvertedMember( member, stream );
// the member's own trailing comment is not reproduced here —
// an accepted, documented minor loss for converted members
}
// else: dropped entirely (DisabledMemberPolicy::Drop, or Json's
// ConvertToComment, which behaves the same as Drop) — no output
}
writeCommentLines( object.getDanglingComments( ), stream );
indentor.left( );
stream << indentor << '}';
}
void FsonWriter::writeArray( Array const& array, ostream& stream )
{
size_t const count = array.size( );
bool const hasDangling =
dialect != Dialect::Json && !array.getDanglingComments( ).empty( );
if( count == 0U && !hasDangling )
{
stream << "[]";
return;
}
stream << "[\n";
indentor.right( );
for( size_t index = 0U; index < count; ++index )
{
Element const& element = array.getElement( index );
writeCommentLines( element.getLeadingComments( ), stream );
stream << indentor;
writeValue( element.getValue( ), stream );
// Suppress the separator comma after a multiline block (it would be
// read as content); the newline separates elements, and comma is
// optional after a block.
String const* const asString = element.getValue( ).asString( );
bool const wroteBlock = asString && canWriteMultiline( *asString );
if( index + 1U < count && !wroteBlock )
stream << ',';
Comment const* const trailing = element.getTrailingComment( )
? &*element.getTrailingComment( )
: nullptr;
writeTrailingComment( trailing, stream );
}
writeCommentLines( array.getDanglingComments( ), stream );
indentor.left( );
stream << indentor << ']';
}
void FsonWriter::writeKey( Key const& key, ostream& stream )
{
bool bare = key.getForm( ) == Key::Form::Bare;
if( dialect == Dialect::Json )
bare = false; // JSON: always quoted
else if( preferBare && !bare )
bare = Key::isValidName( key.getName( ) ); // normalize when possible
if( bare )
stream << key.getName( );
else
stream << '"' << encodeString( key.getName( ) ) << '"';
}
void FsonWriter::writeConvertedMember( Member const& member, ostream& stream )
{
// Render "key: value" sharing the SAME indentor instance, so a
// container value's nested lines (and closing brace/bracket) carry
// the correct embedded indentation for their depth.
ostringstream rendered;
writeKey( member.getKey( ), rendered );
rendered << ": ";
writeValue( member.getValue( ), rendered );
vector< string > const lines = splitLines( rendered.str( ) );
for( size_t index = 0U; index < lines.size( ); ++index )
{
if( index == 0U )
{
// the first line has no embedded indentation of its own (the
// render started cold); add it here, same as an ordinary member
stream << indentor << "// " << lines[ index ] << '\n';
continue;
}
// subsequent lines already carry their own embedded indentation,
// captured live from the shared indentor while rendering the
// nested container above; insert "// " right after it.
string const& line = lines[ index ];
size_t const indent = line.find_first_not_of( ' ' );
if( indent == string::npos )
stream << "// " << line << '\n';
else
stream << line.substr( 0U, indent ) << "// " << line.substr( indent ) << '\n';
}
}
// ── comments ────────────────────────────────────────────────────────
void FsonWriter::writeComment( Comment const& comment, ostream& stream )
{
if( comment.getKind( ) == Comment::Kind::SingleLine )
stream << "//" << comment.getText( );
else
stream << "/*" << comment.getText( ) << "*/";
}
void FsonWriter::writeCommentLines( CommentList const& comments, ostream& stream )
{
if( dialect == Dialect::Json )
return;
for( auto const& comment : comments )
{
if( dialect == Dialect::Json5 && comment.getKind( ) == Comment::Kind::MultiLine )
{
// JSON5 forbids nested block comments — flatten into one "//"
// line per source line (a no-op for genuinely single-line text).
for( auto const& line : splitLines( comment.getText( ) ) )
stream << indentor << "//" << line << '\n';
continue;
}
stream << indentor;
writeComment( comment, stream );
stream << '\n';
}
}
void FsonWriter::writeTrailingComment( Comment const* comment, ostream& stream )
{
if( !comment || dialect == Dialect::Json )
{
stream << '\n';
return;
}
if( dialect == Dialect::Fson || comment->getKind( ) == Comment::Kind::SingleLine )
{
stream << ' ';
writeComment( *comment, stream );
stream << '\n';
return;
}
// Json5 + MultiLine trailing comment: cannot fit inline — end this
// line, then emit the flattened comment as standalone indented
// lines immediately after (approximating "trailing" as "right
// after", since a multi-line comment cannot live in an inline slot).
stream << '\n';
for( auto const& line : splitLines( comment->getText( ) ) )
stream << indentor << "//" << line << '\n';
}
// ── string escaping (00040-string.rrd, mandatory set only) ─────────
// A String is written as a `|`-block only in the Fson dialect, only
// when its form was Multiline, and only when its content ends in '\n'
// (every `|`-line contributes a trailing newline, so a block value must
// end in one — otherwise it is not representable as a block and falls
// back to quoted). JSON/JSON5 have no `|` syntax, so they always quote.
bool FsonWriter::canWriteMultiline( String const& string ) const noexcept
{
if( dialect != Dialect::Fson )
return false;
if( string.getForm( ) != String::Form::Multiline )
return false;
string_view const value = string.getValue( );
return !value.empty( ) && value.back( ) == '\n';
}
// Emits the value as a canonical `|`-block at the current indentation:
// one `|`-line per '\n'-separated segment (the final '\n' closes the
// last line and produces no extra empty line). Presentation is
// normalised; content is preserved. The first line is emitted inline
// (the caller has already written "key: "); subsequent lines start on a
// new line at the current indent.
void FsonWriter::writeMultilineString( String const& string, ostream& stream )
{
std::string const& value = string.getValue( );
// Split into lines on '\n'. The value ends in '\n' (guaranteed by
// canWriteMultiline), so the trailing segment after the last '\n' is
// empty and is NOT emitted as a line.
std::size_t start = 0U;
bool first = true;
while( start < value.size( ) )
{
std::size_t const newline = value.find( '\n', start );
std::size_t const end = ( newline == std::string::npos )
? value.size( ) : newline;
std::string_view const line( value.data( ) + start, end - start );
if( !first )
stream << '\n' << indentor;
first = false;
stream << '|' << encodeMultilineLine( line );
if( newline == std::string::npos )
break;
start = newline + 1U;
}
}
// Encodes one line of a `|`-block. '\n' never appears (we split on it).
// Backslash and control characters are escaped so the line round-trips
// and never ends in a bare '\' (which would be read as a continuation).
// Quotes are literal in `|` form and pass through unescaped.
string FsonWriter::encodeMultilineLine( std::string_view line )
{
string result;
result.reserve( line.size( ) );
for( char const character : line )
{
switch( character )
{
case '\\': result += "\\\\"; break;
case '\b': result += "\\b"; break;
case '\f': result += "\\f"; break;
case '\r': result += "\\r"; break;
case '\t': result += "\\t"; break;
default:
if( static_cast< unsigned char >( character ) < 0x20U )
{
char buffer[ 8 ];
snprintf( buffer, sizeof( buffer ), "\\u%04x",
static_cast< unsigned int >(
static_cast< unsigned char >( character ) ) );
result += buffer;
}
else
{
result += character;
}
break;
}
}
return result;
}
string FsonWriter::encodeString( string const& text )
{
string result;
result.reserve( text.size( ) );
for( char const character : text )
{
switch( character )
{
case '"': result += "\\\""; break;
case '\\': result += "\\\\"; break;
case '\b': result += "\\b"; break;
case '\f': result += "\\f"; break;
case '\n': result += "\\n"; break;
case '\r': result += "\\r"; break;
case '\t': result += "\\t"; break;
default:
if( static_cast< unsigned char >( character ) < 0x20U )
{
char buffer[ 8 ];
snprintf( buffer, sizeof( buffer ), "\\u%04x",
static_cast< unsigned int >(
static_cast< unsigned char >( character ) ) );
result += buffer;
}
else
{
result += character; // raw UTF-8 passthrough
}
break;
}
}
return result;
}
} // end namespace fson
} // end namespace fedem