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