Code View

fson / source / fson-1.1.0.0 / libs / internal / sdk / fson / FsonWriter.cpp
// 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