Code View

fson / source / fson-1.1.0.0 / libs / internal / sdk / fson / FsonParser.cpp
// SPDX-License-Identifier: MIT
#include "FsonParser.hh"

#include <cctype>
#include <charconv>
#include <limits>
#include <system_error>

#include "Array.hh"
#include "Boolean.hh"
#include "Null.hh"
#include "Number.hh"
#include "Object.hh"
#include "String.hh"
#include "Value.hh"

using namespace std;

namespace fedem
{
  namespace fson
  {
    namespace
    {
      // Sentinel returned by Cursor::safePeek()/safeGet() at end of file.
      constexpr char const END_OF_FILE = '\032';

      bool isLetter( char const character ) noexcept
      {
        return isalpha( static_cast< unsigned char >( character ) ) != 0;
      }

      bool isLetterOrDigit( char const character ) noexcept
      {
        return isalnum( static_cast< unsigned char >( character ) ) != 0;
      }

      bool isDigit( char const character ) noexcept
      {
        return isdigit( static_cast< unsigned char >( character ) ) != 0;
      }
    }  // end anonymous namespace

    FsonParser::FsonParser( ) = default;
    FsonParser::~FsonParser( ) = default;

    string FsonParser::getGrammarName( ) const noexcept
    {
      return "fson";
    }

    unique_ptr< Document > FsonParser::parseFile( string const& filename )
    {
      document.reset( );
      pending.clear( );

      parse( filename );

      // document stays null only when the file could not be opened —
      // start() was never entered.
      return std::move( document );
    }

    // ── start ──────────────────────────────────────────────────────────

    bool FsonParser::start( )
    {
      document = make_unique< Document >( );
      pending.clear( );

      skipCommentsBlock( );
      document->getHeaderComments( ) = takeAllPending( );

      unique_ptr< Value > rootValue;
      parseValue( rootValue );  // records "expected a value" on its own failure
      if( rootValue )
        document->setRoot( std::move( rootValue ) );

      skipCommentsBlock( );
      document->getFooterComments( ) = takeAllPending( );

      if( getCursor( ).safePeek( ) != END_OF_FILE )
        recordError( parser::ParseError::Kind::Syntax,
                     "unexpected content after root value" );

      return true;
    }

    // ── comments (skip_comments_and_blanks, 10000-10002) ───────────────

    bool FsonParser::skipComments( )
    {
      if( extractToken( "//" ) )
      {
        unsigned long int const line = getCursor( ).getLineNumber( );
        string text;
        while( true )
        {
          char const character = getCursor( ).safePeek( );
          if( character == '\n' || character == END_OF_FILE )
            break;
          text += getCursor( ).safeGet( );
        }
        pending.push_back( { Comment( Comment::Kind::SingleLine, std::move( text ) ), line } );
        return true;
      }

      if( extractToken( "/*" ) )
      {
        unsigned long int const line = getCursor( ).getLineNumber( );
        string text;
        unsigned int depth = 1U;
        while( depth > 0U )
        {
          if( getCursor( ).safePeek( ) == END_OF_FILE )
          {
            recordError( parser::ParseError::Kind::Syntax,
                         "unterminated multi-line comment", line );
            break;
          }
          if( extractToken( "*/" ) )
          {
            if( --depth > 0U )
              text += "*/";
            continue;
          }
          if( extractToken( "/*" ) )
          {
            ++depth;
            text += "/*";
            continue;
          }
          text += getCursor( ).safeGet( );
        }
        pending.push_back( { Comment( Comment::Kind::MultiLine, std::move( text ) ), line } );
        return true;
      }

      return false;
    }

    // ── whitespace (10100-white_space_character.rrd) ───────────────────
    //
    // The base class only recognises ASCII isspace() characters. JSON5
    // additionally treats NBSP, the BOM/ZWNBSP, U+2028/2029, and the rest
    // of Unicode category Zs as whitespace; this override extends the
    // base behaviour with that set, matched as literal UTF-8 byte
    // sequences (cheaper than decoding code points, and exact for this
    // fixed, small set).

    bool FsonParser::skipWhiteSpaces( )
    {
      static char const* const EXTRA_WHITESPACE[] =
      {
        "\xEF\xBB\xBF",  // U+FEFF  ZERO WIDTH NO-BREAK SPACE / BOM
        "\xC2\xA0",      // U+00A0  NO-BREAK SPACE
        "\xE2\x80\xA8",  // U+2028  LINE SEPARATOR
        "\xE2\x80\xA9",  // U+2029  PARAGRAPH SEPARATOR
        "\xE1\x9A\x80",  // U+1680  OGHAM SPACE MARK
        "\xE2\x80\x80",  // U+2000  EN QUAD
        "\xE2\x80\x81",  // U+2001  EM QUAD
        "\xE2\x80\x82",  // U+2002  EN SPACE
        "\xE2\x80\x83",  // U+2003  EM SPACE
        "\xE2\x80\x84",  // U+2004  THREE-PER-EM SPACE
        "\xE2\x80\x85",  // U+2005  FOUR-PER-EM SPACE
        "\xE2\x80\x86",  // U+2006  SIX-PER-EM SPACE
        "\xE2\x80\x87",  // U+2007  FIGURE SPACE
        "\xE2\x80\x88",  // U+2008  PUNCTUATION SPACE
        "\xE2\x80\x89",  // U+2009  THIN SPACE
        "\xE2\x80\x8A",  // U+200A  HAIR SPACE
        "\xE2\x80\xAF",  // U+202F  NARROW NO-BREAK SPACE
        "\xE2\x81\x9F",  // U+205F  MEDIUM MATHEMATICAL SPACE
        "\xE3\x80\x80",  // U+3000  IDEOGRAPHIC SPACE
      };

      bool any = true;
      bool progress = false;

      while( any )
      {
        any = parser::Parser::skipWhiteSpaces( );

        for( char const* const token : EXTRA_WHITESPACE )
        {
          while( extractToken( token ) )
          {
            any = true;
            progress = true;
          }
        }

        progress = progress || any;
      }

      return progress;
    }

    // ── trivia management ───────────────────────────────────────────────

    CommentList FsonParser::takeAllPending( )
    {
      CommentList result;
      result.reserve( pending.size( ) );
      for( auto& entry : pending )
        result.push_back( std::move( entry.comment ) );
      pending.clear( );
      return result;
    }

    optional< Comment > FsonParser::takePendingOnLine( unsigned long int line )
    {
      for( auto position = pending.begin( ); position != pending.end( ); ++position )
      {
        if( position->line == line )
        {
          Comment comment = std::move( position->comment );
          pending.erase( position );
          return comment;
        }
      }
      return nullopt;
    }

    void FsonParser::restorePending( CommentList comments )
    {
      // Re-queued comments get line 0, which never matches a real line, so
      // they can no longer be claimed as trailing comments — only as
      // leading/dangling trivia of whatever comes next.
      for( size_t index = comments.size( ); index > 0U; --index )
        pending.insert( pending.begin( ), { std::move( comments[ index - 1U ] ), 0UL } );
    }

    // ── object (00010-object.rrd) ──────────────────────────────────────

    bool FsonParser::parseObjectBody( Object& object )
    {
      skipCommentsBlock( );

      if( extractCharacter( '}' ) )
      {
        object.getDanglingComments( ) = takeAllPending( );
        return true;
      }

      while( true )
      {
        unsigned long int entryLine = 0UL;

        // A "%include" directive may stand where a member stands, optionally
        // behind a "--" disabled prefix. Decide with a stored-cursor peek so
        // the member path is untouched for ordinary members.
        bool const directive = looksLikeInclude( );

        // Both Member and Include expose getTrailingComment(); capture a
        // uniform pointer so the delimiter logic below is shared.
        std::optional< Comment >* trailing = nullptr;
        bool valueWasMultiline = false;
        if( directive )
        {
          Include* const include = parseInclude( object, entryLine );
          if( include )
            trailing = &include->getTrailingComment( );
        }
        else
        {
          Member* const member = parseMember( object, entryLine );
          if( member )
          {
            trailing = &member->getTrailingComment( );
            // A `|`-block runs to end-of-line, so a comma cannot sit on the
            // block's last line (it would be content). After a multiline
            // value the separating comma is therefore optional: the block's
            // end (a line not starting with '|') already delimits it.
            String const* const asString = member->getValue( ).asString( );
            valueWasMultiline =
              asString && asString->getForm( ) == String::Form::Multiline;
          }
        }

        while( true )  // delimiter handling, repeats after recovery
        {
          skipCommentsBlock( );

          if( extractCharacter( ',' ) )
          {
            skipCommentsBlock( );

            if( trailing && !*trailing )
              *trailing = takePendingOnLine( entryLine );

            if( extractCharacter( '}' ) )
            {
              // trailing ',' before '}' is valid JSON5/FSON syntax since v0.9.0.0
              object.getDanglingComments( ) = takeAllPending( );
              return true;
            }
            break;  // next member
          }

          if( trailing && !*trailing )
            *trailing = takePendingOnLine( entryLine );

          if( extractCharacter( '}' ) )
          {
            object.getDanglingComments( ) = takeAllPending( );
            return true;
          }

          if( getCursor( ).safePeek( ) == END_OF_FILE )
          {
            recordError( parser::ParseError::Kind::Syntax,
                         "unterminated object: expected '}'" );
            object.getDanglingComments( ) = takeAllPending( );
            return false;
          }

          // After a multiline `|`-block the comma is optional: the block
          // already ended at a non-'|' line, and that next token starts the
          // following member. Treat the missing comma as a valid separator.
          if( valueWasMultiline )
            break;  // proceed to the next member without a comma

          recordError( parser::ParseError::Kind::Syntax,
                       "expected ',' or '}' in object" );
          recover( );
          extractCharacter( ']' );  // swallow a stray closer, then retry
        }
      }
    }

    Member* FsonParser::parseMember( Object& object, unsigned long int& memberLine )
    {
      CommentList leading = takeAllPending( );

      // "--" disabled-member prefix; trivia after it joins the leading set.
      bool const disabled = extractToken( "--" );
      if( disabled )
      {
        skipCommentsBlock( );
        for( auto& comment : takeAllPending( ) )
          leading.push_back( std::move( comment ) );
      }

      optional< Key > key;
      char const first = getCursor( ).safePeek( );

      // A disabled directive may target a KEYLESS multiline `|`-block:
      //   --|line one
      //     |line two
      // The whole block is parsed (so all its lines are consumed and skipped
      // over, not left to be mis-read) and stored as a disabled member with
      // an empty key. It is invisible to queries and, being disabled, does
      // not collide on its empty name. Only valid after "--" — an active
      // bare value has no key and is not a member.
      if( disabled && first == '|' )
      {
        string text;
        parseMultilineString( text );
        auto value = make_unique< String >( std::move( text ),
                                            String::Form::Multiline );

        memberLine = getCursor( ).getLineNumber( );

        // An empty name is not a valid BARE key (Key::bare would throw), so
        // use a quoted empty key. The member is disabled and rendered
        // keyless, so the key text is never emitted or queried.
        Member member( Key::quoted( string( ) ), std::move( value ), true );
        member.getLeadingComments( ) = std::move( leading );
        return &object.appendMember( std::move( member ) );
      }

      if( first == '"' || first == '\'' )
      {
        string text;
        parseString( text );
        key = Key::quoted( std::move( text ) );
      }
      else if( isLetter( first ) )
      {
        string name;
        parseName( name );
        key = Key::bare( std::move( name ) );  // valid by construction
      }

      if( !key )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "expected member key (string or name)" );
        restorePending( std::move( leading ) );
        recover( );
        return nullptr;
      }

      skipCommentsBlock( );
      for( auto& comment : takeAllPending( ) )
        leading.push_back( std::move( comment ) );

      unique_ptr< Value > value;

      if( !extractCharacter( ':' ) )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "expected ':' after member key" );
        recover( );
      }
      else
      {
        skipCommentsBlock( );
        for( auto& comment : takeAllPending( ) )
          leading.push_back( std::move( comment ) );

        parseValue( value );
      }

      if( !value )
        value = make_unique< Null >( );  // placeholder after an error

      memberLine = getCursor( ).getLineNumber( );

      if( !disabled && object.find( key->getName( ) ) )
        recordError( parser::ParseError::Kind::Faulty,
                     "duplicate member '" + key->getName( ) + "'" );

      Member member( std::move( *key ), std::move( value ), disabled );
      member.getLeadingComments( ) = std::move( leading );
      return &object.appendMember( std::move( member ) );
    }

    // ── include (%include directive, 00011-include.rrd) ─────────────────

    bool FsonParser::looksLikeInclude( )
    {
      parser::Cursor const save = storeCursor( );

      // Skip an optional "--" disabled prefix and any interleaving trivia,
      // then test for the '%' that opens the directive. Comments here are
      // deliberately skipped by skipCommentsBlock, which pushes them onto
      // 'pending'; because we restore the cursor, whatever we consumed is
      // re-read by the real parse path, so those pending entries would be
      // duplicated. Snapshot and restore the pending list too.
      size_t const pendingMark = pending.size( );

      extractToken( "--" );
      skipCommentsBlock( );
      bool const result = getCursor( ).safePeek( ) == '%';

      pending.erase( pending.begin( ) + static_cast< ptrdiff_t >( pendingMark ), pending.end( ) );
      restoreCursor( save );
      return result;
    }

    Include* FsonParser::parseInclude( Object& object, unsigned long int& entryLine )
    {
      CommentList leading = takeAllPending( );

      bool const disabled = extractToken( "--" );
      if( disabled )
      {
        skipCommentsBlock( );
        for( auto& comment : takeAllPending( ) )
          leading.push_back( std::move( comment ) );
      }

      if( !extractToken( "%include" ) )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "expected '%include' directive" );
        restorePending( std::move( leading ) );
        recover( );
        return nullptr;
      }

      skipCommentsBlock( );
      for( auto& comment : takeAllPending( ) )
        leading.push_back( std::move( comment ) );

      // The include target is a quoted string (single or double quote), per
      // the same string grammar as a member value.
      char const first = getCursor( ).safePeek( );
      if( first != '"' && first != '\'' )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "expected a quoted path after '%include'" );
        restorePending( std::move( leading ) );
        recover( );
        return nullptr;
      }

      string path;
      parseString( path );

      // Optional "as <name>" alias clause.
      optional< string > alias;
      {
        parser::Cursor const save = storeCursor( );
        size_t const pendingMark = pending.size( );
        skipCommentsBlock( );
        if( extractToken( "as" ) )
        {
          skipCommentsBlock( );
          for( auto& comment : takeAllPending( ) )
            leading.push_back( std::move( comment ) );

          char const aliasFirst = getCursor( ).safePeek( );
          if( aliasFirst == '"' || aliasFirst == '\'' )
          {
            string quoted;
            parseString( quoted );
            alias = std::move( quoted );
          }
          else if( isLetter( aliasFirst ) )
          {
            string name;
            parseName( name );
            alias = std::move( name );
          }
          else
          {
            recordError( parser::ParseError::Kind::Syntax,
                         "expected an alias name after 'as'" );
          }
        }
        else
        {
          // No alias clause: rewind so trivia we skipped flows to the
          // delimiter/next-entry handling as usual.
          pending.erase( pending.begin( ) + static_cast< ptrdiff_t >( pendingMark ), pending.end( ) );
          restoreCursor( save );
        }
      }

      entryLine = getCursor( ).getLineNumber( );

      Include include( std::move( path ), std::move( alias ), disabled );
      include.getLeadingComments( ) = std::move( leading );
      return &object.appendInclude( std::move( include ) );
    }

    // ── array (00020-array.rrd) ────────────────────────────────────────

    bool FsonParser::parseArrayBody( Array& array )
    {
      skipCommentsBlock( );

      if( extractCharacter( ']' ) )
      {
        array.getDanglingComments( ) = takeAllPending( );
        return true;
      }

      while( true )
      {
        CommentList leading = takeAllPending( );

        unique_ptr< Value > value;
        Element* element = nullptr;
        unsigned long int elementLine = 0UL;
        bool valueWasMultiline = false;

        if( parseValue( value ) || value )
        {
          array.append( std::move( value ) );
          element = &array.getElement( array.size( ) - 1U );
          element->getLeadingComments( ) = std::move( leading );
          elementLine = getCursor( ).getLineNumber( );
          String const* const asString = element->getValue( ).asString( );
          valueWasMultiline =
            asString && asString->getForm( ) == String::Form::Multiline;
        }
        else
        {
          restorePending( std::move( leading ) );
        }

        while( true )  // delimiter handling, repeats after recovery
        {
          skipCommentsBlock( );

          if( extractCharacter( ',' ) )
          {
            skipCommentsBlock( );

            if( element && !element->getTrailingComment( ) )
              element->getTrailingComment( ) = takePendingOnLine( elementLine );

            if( extractCharacter( ']' ) )
            {
              // trailing ',' before ']' is valid JSON5/FSON syntax since v0.9.0.0
              array.getDanglingComments( ) = takeAllPending( );
              return true;
            }
            break;  // next element
          }

          if( element && !element->getTrailingComment( ) )
            element->getTrailingComment( ) = takePendingOnLine( elementLine );

          if( extractCharacter( ']' ) )
          {
            array.getDanglingComments( ) = takeAllPending( );
            return true;
          }

          if( getCursor( ).safePeek( ) == END_OF_FILE )
          {
            recordError( parser::ParseError::Kind::Syntax,
                         "unterminated array: expected ']'" );
            array.getDanglingComments( ) = takeAllPending( );
            return false;
          }

          // Comma is optional after a multiline `|`-block (see parseObjectBody).
          if( valueWasMultiline )
            break;

          recordError( parser::ParseError::Kind::Syntax,
                       "expected ',' or ']' in array" );
          recover( );
          extractCharacter( '}' );  // swallow a stray closer, then retry
        }
      }
    }

    // ── value (00030-value.rrd) ────────────────────────────────────────

    bool FsonParser::parseValue( unique_ptr< Value >& result )
    {
      char const first = getCursor( ).safePeek( );

      if( first == '"' || first == '\'' )
      {
        string text;
        bool const closed = parseString( text );
        result = make_unique< String >( std::move( text ) );
        return closed;
      }

      if( first == '|' )
      {
        string text;
        bool const closed = parseMultilineString( text );
        result = make_unique< String >( std::move( text ),
                                        String::Form::Multiline );
        return closed;
      }

      if( first == '{' )
      {
        getCursor( ).safeGet( );
        // The brace is now consumed and the object is open. If end-of-input
        // arrives before the matching '}', the cursor throws and unwinds
        // straight past parseObjectBody's own END_OF_FILE check — so that
        // check never ran for a file cut short at a member boundary, and
        // "{ a: 1, " parsed clean with no diagnostics. The depth is what
        // lets Parser::parse tell that ending apart from a real one.
        enterScope( );
        auto object = make_unique< Object >( );
        bool const closed = parseObjectBody( *object );
        if( closed )
          leaveScope( );
        result = std::move( object );
        return closed;
      }

      if( first == '[' )
      {
        getCursor( ).safeGet( );
        enterScope( );                       // see the object branch above
        auto array = make_unique< Array >( );
        bool const closed = parseArrayBody( *array );
        if( closed )
          leaveScope( );
        result = std::move( array );
        return closed;
      }

      if( first == 't' && extractToken( "true" ) )
      {
        parseLiteralTail( "true" );
        result = make_unique< Boolean >( true );
        return true;
      }

      if( first == 'f' && extractToken( "false" ) )
      {
        parseLiteralTail( "false" );
        result = make_unique< Boolean >( false );
        return true;
      }

      if( first == 'n' && extractToken( "null" ) )
      {
        parseLiteralTail( "null" );
        result = make_unique< Null >( );
        return true;
      }

      // JSON5 number extensions (00050-number.rrd): leading '+', a bare
      // leading/trailing '.', a hex literal, or Infinity/-Infinity/NaN.
      if( first == '-' || first == '+' || first == '.' || isDigit( first ) ||
          first == 'I' || first == 'N' )
        return parseNumber( result );

      recordError( parser::ParseError::Kind::Syntax, "expected a value" );
      recover( );
      return false;
    }

    bool FsonParser::parseLiteralTail( string const& literal )
    {
      char const next = getCursor( ).safePeek( );
      if( isLetterOrDigit( next ) || next == '_' )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "malformed literal starting with '" + literal + "'" );
        while( isLetterOrDigit( getCursor( ).safePeek( ) ) ||
               getCursor( ).safePeek( ) == '_' )
          getCursor( ).safeGet( );
        return false;
      }
      return true;
    }

    // ── name (00200-name.rrd) ──────────────────────────────────────────

    bool FsonParser::parseName( string& result )
    {
      if( !isLetter( getCursor( ).safePeek( ) ) )
        return false;

      result += getCursor( ).safeGet( );

      while( true )
      {
        char const character = getCursor( ).safePeek( );
        if( isLetterOrDigit( character ) )
        {
          result += getCursor( ).safeGet( );
        }
        else if( character == '_' )
        {
          // single underscore must be followed by a letter or digit
          parser::Cursor const save = storeCursor( );
          getCursor( ).safeGet( );
          if( isLetterOrDigit( getCursor( ).safePeek( ) ) )
          {
            result += '_';
            result += getCursor( ).safeGet( );
          }
          else
          {
            restoreCursor( save );
            break;
          }
        }
        else
        {
          break;
        }
      }

      return true;
    }

    // ── number (00050-number.rrd / 00046-hex_integer.rrd /
    //    00047-decimal_number.rrd) ─────────────────────────────────────

    bool FsonParser::parseNumber( unique_ptr< Value >& result )
    {
      string lexeme;
      bool negative = false;

      if( extractCharacter( '-' ) )
      {
        lexeme += '-';
        negative = true;
      }
      else if( extractCharacter( '+' ) )
      {
        lexeme += '+';
      }

      // Infinity / -Infinity / NaN — checked before the digit grammar,
      // since they share no prefix with a decimal or hex literal.
      if( getCursor( ).safePeek( ) == 'I' && extractToken( "Infinity" ) )
      {
        lexeme += "Infinity";
        parseLiteralTail( "Infinity" );
        double const value = negative ? -numeric_limits< double >::infinity( )
                                       :  numeric_limits< double >::infinity( );
        Number::Kind const kind = negative ? Number::Kind::NegativeInfinity
                                            : Number::Kind::Infinity;
        result = make_unique< Number >( value, std::move( lexeme ), kind );
        return true;
      }

      if( getCursor( ).safePeek( ) == 'N' && extractToken( "NaN" ) )
      {
        lexeme += "NaN";
        parseLiteralTail( "NaN" );
        result = make_unique< Number >( numeric_limits< double >::quiet_NaN( ),
                                        std::move( lexeme ), Number::Kind::NaN );
        return true;
      }

      // hex_integer: "0x"/"0X" followed by one or more hex digits.
      string hexPrefix;
      if( extractToken( "0x" ) )
        hexPrefix = "0x";
      else if( extractToken( "0X" ) )
        hexPrefix = "0X";

      if( !hexPrefix.empty( ) )
      {
        string hexDigits;
        while( isxdigit( static_cast< unsigned char >( getCursor( ).safePeek( ) ) ) )
          hexDigits += getCursor( ).safeGet( );

        if( hexDigits.empty( ) )
          recordError( parser::ParseError::Kind::Syntax,
                       "malformed number: hex digit expected after '" + hexPrefix + "'" );

        lexeme += hexPrefix;
        lexeme += hexDigits;

        unsigned long long hexValue = 0ULL;
        from_chars( hexDigits.data( ), hexDigits.data( ) + hexDigits.size( ), hexValue, 16 );
        double const value = negative ? -static_cast< double >( hexValue )
                                       :  static_cast< double >( hexValue );
        result = make_unique< Number >( value, std::move( lexeme ), Number::Kind::Hex );
        return true;
      }

      // decimal_number: plain ("12", "12.5"), leading-dot (".5"), and
      // trailing-dot ("5.") forms.
      if( extractCharacter( '.' ) )
      {
        lexeme += '.';
        if( !isDigit( getCursor( ).safePeek( ) ) )
          recordError( parser::ParseError::Kind::Syntax,
                       "malformed number: digit expected after '.'" );
        while( isDigit( getCursor( ).safePeek( ) ) )
          lexeme += getCursor( ).safeGet( );
      }
      else
      {
        if( !isDigit( getCursor( ).safePeek( ) ) )
        {
          recordError( parser::ParseError::Kind::Syntax,
                       "malformed number: digit expected" );
          recover( );
          return false;
        }

        if( getCursor( ).safePeek( ) == '0' )
        {
          lexeme += getCursor( ).safeGet( );
          if( isDigit( getCursor( ).safePeek( ) ) )
          {
            recordError( parser::ParseError::Kind::Syntax,
                         "malformed number: leading zero" );
            while( isDigit( getCursor( ).safePeek( ) ) )
              lexeme += getCursor( ).safeGet( );
          }
        }
        else
        {
          while( isDigit( getCursor( ).safePeek( ) ) )
            lexeme += getCursor( ).safeGet( );
        }

        if( extractCharacter( '.' ) )
        {
          lexeme += '.';
          // trailing-dot form ("5."): no fraction digit is required here,
          // since an integer part already precedes the dot.
          while( isDigit( getCursor( ).safePeek( ) ) )
            lexeme += getCursor( ).safeGet( );
        }
      }

      char const exponent = getCursor( ).safePeek( );
      if( exponent == 'e' || exponent == 'E' )
      {
        lexeme += getCursor( ).safeGet( );
        char const sign = getCursor( ).safePeek( );
        if( sign == '+' || sign == '-' )
          lexeme += getCursor( ).safeGet( );
        if( !isDigit( getCursor( ).safePeek( ) ) )
          recordError( parser::ParseError::Kind::Syntax,
                       "malformed number: digit expected in exponent" );
        while( isDigit( getCursor( ).safePeek( ) ) )
          lexeme += getCursor( ).safeGet( );
      }

      // std::from_chars for floating-point does not accept a leading '+'
      // (only '-' is permitted), so strip it for the conversion while
      // keeping it in the stored lexeme.
      string const conversionInput = ( !lexeme.empty( ) && lexeme.front( ) == '+' )
                                    ? lexeme.substr( 1 )
                                    : lexeme;

      double numeric = 0.0;
      auto const conversion =
        from_chars( conversionInput.data( ), conversionInput.data( ) + conversionInput.size( ), numeric );
      if( conversion.ec == errc::result_out_of_range )
      {
        recordError( parser::ParseError::Kind::Warning,
                     "number out of double range: " + lexeme );
        numeric = 0.0;
      }
      else if( conversion.ec != errc( ) )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "malformed number: " + lexeme );
      }

      result = make_unique< Number >( numeric, std::move( lexeme ) );
      return true;
    }

    // ── string (00040-string.rrd / 00045-escape_sequence.rrd) ──────────

    bool FsonParser::parseString( string& result )
    {
      char const quote = getCursor( ).safePeek( );
      if( quote != '"' && quote != '\'' )
        return false;
      getCursor( ).safeGet( );

      while( true )
      {
        char const character = getCursor( ).safePeek( );

        if( character == quote )
        {
          getCursor( ).safeGet( );
          return true;
        }

        if( character == END_OF_FILE )
        {
          recordError( parser::ParseError::Kind::Syntax, "unterminated string" );
          return false;
        }

        if( character == '\n' )
        {
          // almost certainly a missing closing quote — terminate here
          recordError( parser::ParseError::Kind::Syntax,
                       "unescaped line break in string" );
          return false;
        }

        if( static_cast< unsigned char >( character ) < 0x20U )
        {
          recordError( parser::ParseError::Kind::Syntax,
                       "control character in string" );
          getCursor( ).safeGet( );
          continue;
        }

        if( character == '\\' )
        {
          getCursor( ).safeGet( );

          // Line continuation (JSON5 multi-line strings): '\' followed by
          // a line terminator removes the break from the decoded string.
          if( extractToken( "\r\n" ) || extractCharacter( '\n' ) ||
              extractCharacter( '\r' ) ||
              extractToken( "\xE2\x80\xA8" ) ||  // U+2028 LINE SEPARATOR
              extractToken( "\xE2\x80\xA9" ) )   // U+2029 PARAGRAPH SEPARATOR
            continue;

          char const escape = getCursor( ).safePeek( );
          switch( escape )
          {
            case '"':  result += '"';  getCursor( ).safeGet( ); break;
            case '\'': result += '\''; getCursor( ).safeGet( ); break;
            case '\\': result += '\\'; getCursor( ).safeGet( ); break;
            case '/':  result += '/';  getCursor( ).safeGet( ); break;
            case 'b':  result += '\b'; getCursor( ).safeGet( ); break;
            case 'f':  result += '\f'; getCursor( ).safeGet( ); break;
            case 'n':  result += '\n'; getCursor( ).safeGet( ); break;
            case 'r':  result += '\r'; getCursor( ).safeGet( ); break;
            case 't':  result += '\t'; getCursor( ).safeGet( ); break;
            case 'v':  result += '\v'; getCursor( ).safeGet( ); break;
            case '0':  result += '\0'; getCursor( ).safeGet( ); break;
            case 'x':
              getCursor( ).safeGet( );
              decodeHexByteEscape( result );
              break;
            case 'u':
              getCursor( ).safeGet( );
              decodeUnicodeEscape( result );
              break;
            case END_OF_FILE:
              recordError( parser::ParseError::Kind::Syntax, "unterminated string" );
              return false;
            default:
              // JSON5-superset simplification (v0.9.0.0): any other
              // character after '\' is itself, with no error — a
              // deliberate relaxation of strict JSON5's narrower escape
              // grammar (which forbids e.g. \1-\9 digit escapes).
              result += getCursor( ).safeGet( );
              break;
          }
          continue;
        }

        result += getCursor( ).safeGet( );  // raw UTF-8 passthrough
      }
    }

    // ── multi-line string (`|`-block, 00040-string.rrd option 3) ────────
    //
    // Each source line begins with '|'; the '|' is not part of the value.
    // The line's content runs to the line terminator. Unless the content
    // ends with a '\' (which is removed, joining the next line without a
    // break), a '\n' is appended — for EVERY line, the last included. After
    // the terminator, inline whitespace and an optional /* */ block comment
    // are skipped; if the next non-blank thing on that physical line is
    // another '|', the block continues, otherwise it ends. A blank line, a
    // line comment, or any non-'|' content between '|'-lines ends the block
    // (and the parser then sees stray content, which surfaces as an error
    // at the caller — a member value cannot be followed by a bare value).
    bool FsonParser::parseMultilineString( string& result )
    {
      if( getCursor( ).safePeek( ) != '|' )
        return false;

      while( true )
      {
        getCursor( ).safeGet( );  // consume the leading '|'

        // Read this line's content until a line terminator or EOF, decoding
        // escapes exactly as a quoted string does. A trailing '\' right
        // before the terminator is a continuation marker.
        bool continuation = false;
        while( true )
        {
          char const character = getCursor( ).safePeek( );

          if( character == END_OF_FILE || character == '\n' || character == '\r' )
            break;

          if( character == '\\' )
          {
            getCursor( ).safeGet( );
            char const next = getCursor( ).safePeek( );
            // '\' immediately before the line end → continuation.
            if( next == '\n' || next == '\r' || next == END_OF_FILE )
            {
              continuation = true;
              break;
            }
            // otherwise a normal escape: decode the same set as parseString.
            switch( next )
            {
              case '"':  result += '"';  getCursor( ).safeGet( ); break;
              case '\'': result += '\''; getCursor( ).safeGet( ); break;
              case '\\': result += '\\'; getCursor( ).safeGet( ); break;
              case '/':  result += '/';  getCursor( ).safeGet( ); break;
              case 'b':  result += '\b'; getCursor( ).safeGet( ); break;
              case 'f':  result += '\f'; getCursor( ).safeGet( ); break;
              case 'n':  result += '\n'; getCursor( ).safeGet( ); break;
              case 'r':  result += '\r'; getCursor( ).safeGet( ); break;
              case 't':  result += '\t'; getCursor( ).safeGet( ); break;
              case 'v':  result += '\v'; getCursor( ).safeGet( ); break;
              case '0':  result += '\0'; getCursor( ).safeGet( ); break;
              case 'x':  getCursor( ).safeGet( ); decodeHexByteEscape( result ); break;
              case 'u':  getCursor( ).safeGet( ); decodeUnicodeEscape( result ); break;
              default:   result += getCursor( ).safeGet( ); break;
            }
            continue;
          }

          result += getCursor( ).safeGet( );  // raw UTF-8 passthrough
        }

        // Consume the line terminator (if any). Append '\n' unless this line
        // continued. EOF ends the block with the current line's newline
        // rule applied (a final unterminated '|line' still contributes its
        // '\n' — the value always ends in '\n' for a block, per the design).
        bool atEof = false;
        if( extractToken( "\r\n" ) || extractCharacter( '\n' ) ||
            extractCharacter( '\r' ) )
        {
          // consumed one terminator
        }
        else
        {
          atEof = true;  // no terminator: end of input
        }

        if( !continuation )
          result += '\n';

        if( atEof )
          return true;

        // Look ahead to the next physical line: skip inline whitespace and
        // an optional /* */ block comment (which must close on this same
        // lookahead line), then test for a continuing '|'. Anything else —
        // blank line, '//' comment, or other content — ends the block. We
        // must not consume across a newline: a blank line breaks the block.
        parser::Cursor const save = storeCursor( );
        size_t const pendingMark = pending.size( );

        skipInlineBlanksAndBlockComments( );

        if( getCursor( ).safePeek( ) == '|' )
        {
          // Continue: the pending list may hold a /* */ we skipped between
          // lines; drop it — inter-line block comments inside a `|`-block
          // are not represented (the design keeps `|`-lines contiguous).
          pending.erase( pending.begin( ) + static_cast< ptrdiff_t >( pendingMark ),
                         pending.end( ) );
          continue;
        }

        // Block ends here: rewind so the caller sees whatever follows
        // (a delimiter, or stray content it will flag).
        pending.erase( pending.begin( ) + static_cast< ptrdiff_t >( pendingMark ),
                       pending.end( ) );
        restoreCursor( save );
        return true;
      }
    }

    // Skips spaces/tabs and any number of /* */ block comments on the
    // CURRENT physical line only (never crossing a line terminator), used
    // by the `|`-block continuation lookahead. Line comments and newlines
    // are deliberately NOT skipped: encountering one ends the block.
    void FsonParser::skipInlineBlanksAndBlockComments( )
    {
      while( true )
      {
        char const character = getCursor( ).safePeek( );
        if( character == ' ' || character == '\t' )
        {
          getCursor( ).safeGet( );
          continue;
        }
        if( character == '/' )
        {
          parser::Cursor const save = storeCursor( );
          if( extractToken( "/*" ) )
          {
            // consume through the matching */ (single level is enough for
            // the lookahead; nested handled by skipComments elsewhere)
            unsigned int depth = 1U;
            while( depth > 0U && getCursor( ).safePeek( ) != END_OF_FILE )
            {
              if( extractToken( "*/" ) ) { --depth; continue; }
              if( extractToken( "/*" ) ) { ++depth; continue; }
              getCursor( ).safeGet( );
            }
            continue;
          }
          restoreCursor( save );
        }
        break;
      }
    }

    bool FsonParser::readHexQuad( unsigned long int& codeUnit )
    {
      codeUnit = 0UL;
      for( unsigned int index = 0U; index < 4U; ++index )
      {
        char const character = getCursor( ).safePeek( );
        if( !isxdigit( static_cast< unsigned char >( character ) ) )
          return false;
        getCursor( ).safeGet( );

        unsigned long int digit = 0UL;
        if( character >= '0' && character <= '9' )
          digit = static_cast< unsigned long int >( character - '0' );
        else
          digit = static_cast< unsigned long int >( ( character | 0x20 ) - 'a' ) + 10UL;

        codeUnit = ( codeUnit << 4U ) | digit;
      }
      return true;
    }

    bool FsonParser::readHexByte( unsigned long int& byteValue )
    {
      byteValue = 0UL;
      for( unsigned int index = 0U; index < 2U; ++index )
      {
        char const character = getCursor( ).safePeek( );
        if( !isxdigit( static_cast< unsigned char >( character ) ) )
          return false;
        getCursor( ).safeGet( );

        unsigned long int digit = 0UL;
        if( character >= '0' && character <= '9' )
          digit = static_cast< unsigned long int >( character - '0' );
        else
          digit = static_cast< unsigned long int >( ( character | 0x20 ) - 'a' ) + 10UL;

        byteValue = ( byteValue << 4U ) | digit;
      }
      return true;
    }

    void FsonParser::decodeHexByteEscape( string& result )
    {
      unsigned long int byteValue = 0UL;
      if( !readHexByte( byteValue ) )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "invalid \\x escape: two hex digits expected" );
        appendUtf8( result, 0xFFFDUL );
        return;
      }
      appendUtf8( result, byteValue );
    }

    void FsonParser::decodeUnicodeEscape( string& result )
    {
      constexpr unsigned long int const REPLACEMENT = 0xFFFDUL;

      unsigned long int high = 0UL;
      if( !readHexQuad( high ) )
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "invalid \\u escape: four hex digits expected" );
        appendUtf8( result, REPLACEMENT );
        return;
      }

      if( high >= 0xD800UL && high <= 0xDBFFUL )  // high surrogate
      {
        unsigned long int low = 0UL;
        if( extractToken( "\\u" ) && readHexQuad( low ) )
        {
          if( low >= 0xDC00UL && low <= 0xDFFFUL )
          {
            unsigned long int const codePoint =
              0x10000UL + ( ( high - 0xD800UL ) << 10U ) + ( low - 0xDC00UL );
            appendUtf8( result, codePoint );
            return;
          }

          recordError( parser::ParseError::Kind::Syntax,
                       "invalid \\u escape: low surrogate expected" );
          appendUtf8( result, REPLACEMENT );
          // the second code unit is valid on its own unless it is itself
          // a surrogate
          if( low < 0xD800UL || low > 0xDFFFUL )
            appendUtf8( result, low );
          else
            appendUtf8( result, REPLACEMENT );
          return;
        }

        recordError( parser::ParseError::Kind::Syntax,
                     "invalid \\u escape: lone high surrogate" );
        appendUtf8( result, REPLACEMENT );
        return;
      }

      if( high >= 0xDC00UL && high <= 0xDFFFUL )  // lone low surrogate
      {
        recordError( parser::ParseError::Kind::Syntax,
                     "invalid \\u escape: lone low surrogate" );
        appendUtf8( result, REPLACEMENT );
        return;
      }

      appendUtf8( result, high );
    }

    void FsonParser::appendUtf8( string& result, unsigned long int codePoint )
    {
      if( codePoint < 0x80UL )
      {
        result += static_cast< char >( codePoint );
      }
      else if( codePoint < 0x800UL )
      {
        result += static_cast< char >( 0xC0UL | ( codePoint >> 6U ) );
        result += static_cast< char >( 0x80UL | ( codePoint & 0x3FUL ) );
      }
      else if( codePoint < 0x10000UL )
      {
        result += static_cast< char >( 0xE0UL | ( codePoint >> 12U ) );
        result += static_cast< char >( 0x80UL | ( ( codePoint >> 6U ) & 0x3FUL ) );
        result += static_cast< char >( 0x80UL | ( codePoint & 0x3FUL ) );
      }
      else
      {
        result += static_cast< char >( 0xF0UL | ( codePoint >> 18U ) );
        result += static_cast< char >( 0x80UL | ( ( codePoint >> 12U ) & 0x3FUL ) );
        result += static_cast< char >( 0x80UL | ( ( codePoint >> 6U ) & 0x3FUL ) );
        result += static_cast< char >( 0x80UL | ( codePoint & 0x3FUL ) );
      }
    }

    // ── recovery ───────────────────────────────────────────────────────

    void FsonParser::recover( )
    {
      unsigned int depth = 0U;

      while( true )
      {
        char const character = getCursor( ).safePeek( );

        if( character == END_OF_FILE )
          return;

        if( depth == 0U &&
            ( character == ',' || character == '}' || character == ']' ) )
          return;

        if( character == '{' || character == '[' )
        {
          ++depth;
          getCursor( ).safeGet( );
          continue;
        }

        if( character == '}' || character == ']' )  // depth > 0 here
        {
          --depth;
          getCursor( ).safeGet( );
          continue;
        }

        if( character == '"' || character == '\'' )
        {
          char const quote = character;
          getCursor( ).safeGet( );
          while( true )
          {
            char const inner = getCursor( ).safeGet( );
            if( inner == '\\' )
            {
              getCursor( ).safeGet( );
              continue;
            }
            if( inner == quote || inner == '\n' || inner == END_OF_FILE )
              break;
          }
          continue;
        }

        if( character == '/' && skipComments( ) )
          continue;  // recovered comments stay pending as trivia

        getCursor( ).safeGet( );
      }
    }
  }  // end namespace fson
}  // end namespace fedem