Code View

mstring / mstring-1.3.1.2 / src / MStringParser.cpp
// SPDX-License-Identifier: MIT
#include "MStringParser.hh"

#include <algorithm>
#include <cctype>
#include <filesystem>
#include <iostream>
#include <memory>
#include <optional>
#include <sstream>
#include <system_error>

#include "MUtil.hh"

using fedem::parser::ParseError;
namespace fs = std::filesystem;

namespace
{
  /// cparse's safeGet() / safePeek() end-of-input sentinel (ASCII SUB).
  constexpr char EOF_CHAR = '\032';

  bool IsBlank( char c )
  {
    return c == ' ' || c == '\t' || c == '\r';
  }

  bool IsIdentifierStart( char c )
  {
    return std::isalpha( static_cast<unsigned char>( c ) ) || c == '_';
  }

  bool IsIdentifierChar( char c )
  {
    return std::isalnum( static_cast<unsigned char>( c ) ) || c == '_';
  }

  /// `tr`, `tr_TR`, `tr_TR.UTF-8`, `sr_RS@latin`, `C`, `C.utf8`
  bool IsLocaleChar( char c )
  {
    return IsIdentifierChar( c ) || c == '.' || c == '-' || c == '@';
  }

  /// `UTF-8`, `ISO-8859-9`, `KOI8-R`
  bool IsCharsetChar( char c )
  {
    return IsIdentifierChar( c ) || c == '.' || c == '-' || c == ':';
  }

  /// `messages`, `error-texts`
  bool IsModuleChar( char c )
  {
    return IsIdentifierChar( c ) || c == '-';
  }

  /// `hh`, `hpp`, `cc`, `c++`
  bool IsExtensionChar( char c )
  {
    return IsIdentifierChar( c ) || c == '+';
  }

  std::string Upper( std::string word )
  {
    std::transform( word.begin( ), word.end( ), word.begin( ),
                    []( unsigned char c ) { return static_cast<char>( std::toupper( c ) ); } );
    return word;
  }

  std::string Describe( char c )
  {
    if ( c == EOF_CHAR )
    {
      return "end of file";
    }

    if ( c == '\n' )
    {
      return "end of line";
    }

    if ( std::isprint( static_cast<unsigned char>( c ) ) )
    {
      return std::string( "'" ) + c + "'";
    }

    std::ostringstream stream;
    stream << "character \\x" << std::hex << static_cast<unsigned>( static_cast<unsigned char>( c ) );
    return stream.str( );
  }

  struct NamedValue
  {
    const char *name;
    const char *value;
  };

  constexpr NamedValue Facilities[] =
  {
    { "AUTH", "LOG_AUTH" }, { "AUTHPRIV", "LOG_AUTHPRIV" }, { "CRON", "LOG_CRON" },
    { "DAEMON", "LOG_DAEMON" }, { "FTP", "LOG_FTP" }, { "KERN", "LOG_KERN" },
    { "LOCAL0", "LOG_LOCAL0" }, { "LOCAL1", "LOG_LOCAL1" }, { "LOCAL2", "LOG_LOCAL2" },
    { "LOCAL3", "LOG_LOCAL3" }, { "LOCAL4", "LOG_LOCAL4" }, { "LOCAL5", "LOG_LOCAL5" },
    { "LOCAL6", "LOG_LOCAL6" }, { "LOCAL7", "LOG_LOCAL7" }, { "LPR", "LOG_LPR" },
    { "MAIL", "LOG_MAIL" }, { "NEWS", "LOG_NEWS" }, { "USER", "LOG_USER" },
    { "UUCP", "LOG_UUCP" },
  };

  constexpr NamedValue Levels[] =
  {
    { "EMERG", "LOG_EMERG" }, { "EMERGENCY", "LOG_EMERG" }, { "ALERT", "LOG_ALERT" },
    { "CRIT", "LOG_CRIT" }, { "CRITICAL", "LOG_CRIT" }, { "ERR", "LOG_ERR" },
    { "ERROR", "LOG_ERR" }, { "WARNING", "LOG_WARNING" }, { "NOTICE", "LOG_NOTICE" },
    { "INFO", "LOG_INFO" }, { "DEBUG", "LOG_DEBUG" },
  };

  template <std::size_t N>
  const char *Lookup( const NamedValue ( &table )[N], const std::string &name )
  {
    for ( const NamedValue &entry : table )
    {
      if ( name == entry.name )
      {
        return entry.value;
      }
    }

    return nullptr;
  }
}

//
// ParseContext
//
ParseContext::ParseContext( Module &m )
  : module( m )
{
  // blank
}

bool ParseContext::failed( ) const
{
  return std::any_of( diagnostics.begin( ), diagnostics.end( ),
                      []( const ParseError &e ) { return e.kind != ParseError::Kind::Warning; } );
}

//
// MStringParser — entry points
//
MStringParser::MStringParser( ParseContext &context )
  : _Context( context )
{
  // blank
}

std::string MStringParser::getGrammarName( ) const noexcept
{
  return "mstring";
}

bool MStringParser::parseFile( const std::string &path )
{
  _DisplayName = path;

  std::error_code ec;
  fs::path const canonical = fs::canonical( path, ec );

  if ( ec || ! fs::is_regular_file( canonical, ec ) )
  {
    ParseError e;
    e.kind = ParseError::Kind::Syntax;
    e.message = "cannot open '" + path + "' for reading";
    e.filename = path;
    _Context.diagnostics.push_back( e );
    return false;
  }

  std::string const key = canonical.string( );

  if ( std::find( _Context.importStack.begin( ), _Context.importStack.end( ), key )
       != _Context.importStack.end( ) )
  {
    ParseError e;
    e.kind = ParseError::Kind::Syntax;
    e.message = "recursive $IMPORT of '" + path + "'";
    e.filename = path;
    _Context.diagnostics.push_back( e );
    return false;
  }

  _Context.importStack.push_back( key );
  bool const parsed = parse( key );
  _Context.importStack.pop_back( );

  if ( ! parsed && ! _Context.failed( ) )
  {
    // cparse could not run the grammar at all (e.g. the file vanished).
    ParseError e;
    e.kind = ParseError::Kind::Internal;
    e.message = errors( ).empty( ) ? "cannot parse '" + path + "'" : errors( ).front( ).message;
    e.filename = path;
    _Context.diagnostics.push_back( e );
  }

  return parsed && ! _Context.failed( );
}

bool MStringParser::parseStream( const std::string &name, std::istream &input )
{
  _DisplayName = name;

  // The cursor seeks back when it backtracks, which a pipe cannot do: read
  // the whole input first.
  auto buffer = std::make_shared<std::stringstream>( );
  *buffer << input.rdbuf( );
  buffer->clear( );   // an empty input sets failbit, which the cursor would throw on
  buffer->seekg( 0 );

  pushCursor( name, buffer );
  return start( ) && ! _Context.failed( );
}

bool MStringParser::start( )
{
  try
  {
    while ( true )
    {
      skipEmptyLines( );

      if ( atEnd( ) )
      {
        closeMessage( );
        break;
      }

      if ( peek( ) == '$' )
      {
        directive( );
      }
      else
      {
        messageLine( );
      }
    }

    return true;
  }
  catch ( Abort const & )
  {
    return false;
  }
}

//
// lexical helpers
//
char MStringParser::peek( )
{
  return getCursor( ).safePeek( );
}

char MStringParser::next( )
{
  char const c = getCursor( ).safeGet( );
  _Last = c;
  return c;
}

bool MStringParser::atEnd( )
{
  return peek( ) == EOF_CHAR;
}

bool MStringParser::atEndOfLine( )
{
  char const c = peek( );
  return c == '\n' || c == '#' || c == EOF_CHAR;
}

MStringParser::Location MStringParser::here( ) const
{
  auto const &cursor = getCursor( );

  if ( cursor.getLineNumber( ) == 0 || _Last == '\n' )
  {
    return { cursor.getLineNumber( ) + 1, 1 };
  }

  return { cursor.getLineNumber( ), cursor.getColumnNumber( ) + 1 };
}

void MStringParser::skipBlanks( )
{
  while ( IsBlank( peek( ) ) )
  {
    next( );
  }
}

bool MStringParser::skipComments( )
{
  if ( peek( ) != '#' )
  {
    return false;
  }

  while ( ! atEnd( ) && peek( ) != '\n' )
  {
    next( );
  }

  return true;
}

void MStringParser::skipEmptyLines( )
{
  while ( true )
  {
    skipBlanks( );
    skipComments( );

    if ( peek( ) != '\n' )
    {
      return;
    }

    next( );
  }
}

void MStringParser::expectEndOfLine( )
{
  skipBlanks( );
  skipComments( );

  if ( peek( ) == '\n' )
  {
    next( );
  }
  else if ( ! atEnd( ) )
  {
    error( "unexpected " + Describe( peek( ) ) + ", expected end of line" );
  }
}

std::string MStringParser::readWhile( bool ( *accept )( char ) )
{
  std::string word;

  while ( accept( peek( ) ) )
  {
    word += next( );
  }

  return word;
}

std::string MStringParser::readIdentifier( )
{
  if ( ! IsIdentifierStart( peek( ) ) )
  {
    return "";
  }

  return readWhile( IsIdentifierChar );
}

std::string MStringParser::requireIdentifier( const std::string &what )
{
  skipBlanks( );
  Location const location = here( );
  std::string const name = readIdentifier( );

  if ( name.empty( ) )
  {
    error( "expected " + what + ", found " + Describe( peek( ) ), location );
  }

  return name;
}

std::string MStringParser::requireWord( bool ( *accept )( char ), const std::string &what )
{
  skipBlanks( );
  Location const location = here( );
  std::string const word = readWhile( accept );

  if ( word.empty( ) )
  {
    error( "expected " + what + ", found " + Describe( peek( ) ), location );
  }

  return word;
}

std::string MStringParser::readQualifiedName( const std::string &what )
{
  std::string name = requireIdentifier( what );

  while ( true )
  {
    skipBlanks( );

    if ( peek( ) != ':' )
    {
      return name;
    }

    Location const location = here( );
    next( );

    if ( peek( ) != ':' )
    {
      error( "expected '::'", location );
    }

    next( );
    name += "::" + requireIdentifier( "a name after '::'" );
  }
}

std::string MStringParser::readRestOfLine( )
{
  std::string text;

  while ( ! atEndOfLine( ) )
  {
    text += next( );
  }

  return Trim( text );
}

std::string MStringParser::readKeyword( )
{
  skipBlanks( );
  return Upper( readIdentifier( ) );
}

void MStringParser::error( const std::string &message, Location location )
{
  ParseError e;
  e.kind = ParseError::Kind::Syntax;
  e.message = message;
  e.filename = _DisplayName;
  e.line = location.line;
  e.col = location.col;
  _Context.diagnostics.push_back( e );
  throw Abort( );
}

void MStringParser::error( const std::string &message )
{
  error( message, here( ) );
}

void MStringParser::warning( const std::string &message, Location location )
{
  ParseError e;
  e.kind = ParseError::Kind::Warning;
  e.message = message;
  e.filename = _DisplayName;
  e.line = location.line;
  e.col = location.col;
  _Context.diagnostics.push_back( e );
}

//
// directives
//
void MStringParser::directive( )
{
  Location const location = here( );
  next( );  // '$'

  // Any directive closes the message being defined.
  closeMessage( );

  std::string const name = readKeyword( );

  if ( name.empty( ) )
  {
    error( "expected a directive name after '$'", location );
  }

  if ( name == "LANGUAGE" )
    languageDirective( );
  else if ( name == "CHARSET" )
    charsetDirective( );
  else if ( name == "STREAMABLE" )
    classDirective( _Context.settings._Streamable, "$STREAMABLE", false );
  else if ( name == "EXCEPTION" )
    classDirective( _Context.settings._Exception, "$EXCEPTION", true );
  else if ( name == "STRING" )
    functionDirective( _Context.settings._String, "$STRING" );
  else if ( name == "THROW" )
    functionDirective( _Context.settings._Throw, "$THROW" );
  else if ( name == "ERROR" )
    errorDirective( );
  else if ( name == "SYSLOG" )
    syslogDirective( );
  else if ( name == "INCLUDE" )
    includeDirective( );
  else if ( name == "IMPORT" )
    importDirective( );
  else if ( name == "USING" )
    usingDirective( );
  else if ( name == "NAMESPACE" )
    namespaceDirective( );
  else if ( name == "MODULE" )
    moduleDirective( );
  else if ( name == "MESSAGE" )
    messageDirective( );
  else if ( name == "EXPORT" )
  {
    // `$EXPORT MACRO` — or `$EXPORT` alone: no macro.
    skipBlanks( );
    _Context.module.Export = atEndOfLine( ) ? "" : requireIdentifier( "a macro name" );
    expectEndOfLine( );
  }
  else
    error( "unknown directive '$" + name + "'", location );
}

void MStringParser::languageDirective( )
{
  _Context.language = requireWord( IsLocaleChar, "a language name" );
  expectEndOfLine( );
}

void MStringParser::charsetDirective( )
{
  skipBlanks( );
  Location const location = here( );
  _Context.charset = requireWord( IsCharsetChar, "a charset name" );
  expectEndOfLine( );
  warning( "$CHARSET is deprecated: it only labels the texts with a comment "
           "(the text bytes are written as they are)", location );
}

bool MStringParser::commonOption( Concept &concept, const std::string &option )
{
  if ( option == "NOPREFIX" )
  {
    concept._Prefix.clear( );
    concept._Customized = true;
  }
  else if ( option == "NOPOSTFIX" )
  {
    concept._Postfix.clear( );
    concept._Customized = true;
  }
  else if ( option == "PREFIX" || option == "POSTFIX" )
  {
    std::string const word = requireIdentifier( "a " + Upper( option ) + " word" );

    // The first customisation replaces both defaults: `PREFIX get` turns
    // `HelloStr` into `getHello`, not `getHelloStr`.
    if ( ! concept._Customized )
    {
      concept._Prefix.clear( );
      concept._Postfix.clear( );
    }

    ( option == "PREFIX" ? concept._Prefix : concept._Postfix ) = word;
    concept._Customized = true;
  }
  else if ( option == "ENABLE" )
  {
    concept._Active = true;
  }
  else if ( option == "DISABLE" )
  {
    concept._Active = false;
  }
  else if ( option == "LOCALE" )
  {
    Location const location = here( );

    if ( readKeyword( ) != "EXTRA" )
    {
      error( "expected EXTRA after LOCALE", location );
    }

    Location const valueLocation = here( );
    std::string const value = readKeyword( );

    if ( value == "ENABLE" )
      concept._LocaleExtra = true;
    else if ( value == "DISABLE" )
      concept._LocaleExtra = false;
    else
      error( "expected ENABLE or DISABLE after LOCALE EXTRA", valueLocation );
  }
  else
  {
    return false;
  }

  return true;
}

bool MStringParser::classOption( ClassConcept &concept, const std::string &option )
{
  if ( option != "INHERITED" && option != "PARENT" )
  {
    return false;
  }

  skipBlanks( );
  Location const location = here( );
  std::string word = readIdentifier( );
  std::string const access = Upper( word );
  inheritance type = PUBLIC_INHERITANCE;   // `catch ( std::exception & )` must see it
  bool const isAccess = access == "PUBLIC" || access == "PROTECTED" || access == "PRIVATE";

  skipBlanks( );

  if ( isAccess && ! IsIdentifierStart( peek( ) ) && peek( ) != ':' )
  {
    error( "expected a base class name after " + access );
  }

  if ( isAccess && IsIdentifierStart( peek( ) ) )
  {
    type = access == "PUBLIC" ? PUBLIC_INHERITANCE
         : access == "PROTECTED" ? PROTECTED_INHERITANCE
         : PRIVATE_INHERITANCE;
    word.clear( );
  }

  std::string parent;

  if ( word.empty( ) )
  {
    parent = readQualifiedName( "a base class name" );
  }
  else
  {
    // `word` already is the first component of the base class name.
    parent = word;

    while ( peek( ) == ':' )
    {
      Location const colons = here( );
      next( );

      if ( peek( ) != ':' )
      {
        error( "expected '::'", colons );
      }

      next( );
      parent += "::" + requireIdentifier( "a name after '::'" );
      skipBlanks( );
    }
  }

  if ( parent.empty( ) )
  {
    error( "expected a base class name", location );
  }

  concept._InheritanceType = type;
  concept._Parent = parent;
  return true;
}

bool MStringParser::functionOption( FunctionConcept &concept, const std::string &option, Location location )
{
  if ( option == "NONMEMBER" )
  {
    concept._Type = NONMEMBER;
    concept._Naming.clear( );
    return true;
  }

  bool constness = true;
  std::string member = option;

  if ( option == "CONST" || option == "NONCONST" )
  {
    constness = option == "CONST";
    location = here( );
    member = readKeyword( );

    if ( member != "MEMBER" )
    {
      error( "expected MEMBER after " + option, location );
    }
  }
  else if ( option != "MEMBER" )
  {
    return false;
  }

  Location const kindLocation = here( );
  std::string const kind = readKeyword( );

  if ( kind == "OF" )
  {
    concept._Type = MEMBER_OF;
    concept._Naming = readQualifiedName( "a class name after MEMBER OF" );
  }
  else if ( kind == "AS" )
  {
    concept._Type = MEMBER_AS;
    concept._Naming = requireIdentifier( "a function name after MEMBER AS" );
  }
  else
  {
    error( "expected OF or AS after MEMBER", kindLocation );
  }

  concept._Constness = constness;
  return true;
}

void MStringParser::unknownOption( const std::string &option, const std::string &directive, Location location )
{
  if ( option.empty( ) )
  {
    error( "expected an option for " + directive + ", found " + Describe( peek( ) ), location );
  }

  error( "unknown " + directive + " option '" + option + "'", location );
}

void MStringParser::classDirective( ClassConcept &concept, const std::string &directive, bool exception )
{
  do
  {
    skipBlanks( );
    Location const location = here( );
    std::string const option = readKeyword( );

    if ( exception && option == "OVERRIDE" )
    {
      // `OVERRIDE name` — or `OVERRIDE NONE`: back to What() / what().
      std::string const method = requireIdentifier( "the name of the base's text method" );
      concept._Override = Upper( method ) == "NONE" ? "" : method;
    }
    else if ( option.empty( ) || ( ! commonOption( concept, option ) && ! classOption( concept, option ) ) )
    {
      unknownOption( option, directive, location );
    }

    skipBlanks( );
  }
  while ( ! atEndOfLine( ) );

  expectEndOfLine( );
}

void MStringParser::functionDirective( FunctionConcept &concept, const std::string &directive )
{
  do
  {
    skipBlanks( );
    Location const location = here( );
    std::string const option = readKeyword( );

    if ( option.empty( ) ||
         ( ! commonOption( concept, option ) && ! functionOption( concept, option, location ) ) )
    {
      unknownOption( option, directive, location );
    }

    skipBlanks( );
  }
  while ( ! atEndOfLine( ) );

  expectEndOfLine( );
}

void MStringParser::errorDirective( )
{
  ErrorConcept &concept = _Context.settings._Error;

  do
  {
    skipBlanks( );
    Location const location = here( );
    std::string const option = readKeyword( );

    if ( option == "EXIT" )
    {
      // EXIT takes the rest of the line: it is the last option.
      skipBlanks( );
      Location const expression = here( );
      std::string call = readRestOfLine( );

      while ( ! call.empty( ) && call.back( ) == ';' )
      {
        call = Trim( call.substr( 0, call.size( ) - 1 ) );
      }

      if ( call.empty( ) )
      {
        error( "expected a statement after EXIT", expression );
      }

      concept._ExitCall = call;
      break;
    }

    if ( option.empty( ) ||
         ( ! commonOption( concept, option ) && ! functionOption( concept, option, location ) ) )
    {
      unknownOption( option, "$ERROR", location );
    }

    skipBlanks( );
  }
  while ( ! atEndOfLine( ) );

  expectEndOfLine( );
}

void MStringParser::syslogDirective( )
{
  LogConcept &concept = _Context.settings._Log;

  do
  {
    skipBlanks( );
    Location const location = here( );
    std::string const option = readKeyword( );

    if ( option == "FACILITY" || option == "LEVEL" )
    {
      skipBlanks( );
      Location const valueLocation = here( );
      std::string const value = Upper( readWhile( IsIdentifierChar ) );
      bool const facility = option == "FACILITY";
      const char *constant = facility ? Lookup( Facilities, value ) : Lookup( Levels, value );

      if ( constant == nullptr )
      {
        error( "unknown syslog " + std::string( facility ? "facility" : "level" ) + " '" + value + "'",
               valueLocation );
      }

      ( facility ? concept._Facility : concept._Level ) = constant;
    }
    else if ( option.empty( ) ||
              ( ! commonOption( concept, option ) && ! functionOption( concept, option, location ) ) )
    {
      unknownOption( option, "$SYSLOG", location );
    }

    skipBlanks( );
  }
  while ( ! atEndOfLine( ) );

  expectEndOfLine( );
}

void MStringParser::includeDirective( )
{
  skipBlanks( );
  Location const location = here( );
  std::string include;
  char const open = peek( );

  if ( open == '"' || open == '<' )
  {
    char const close = open == '"' ? '"' : '>';
    next( );
    std::string name;

    while ( peek( ) != close )
    {
      if ( peek( ) == '\n' || atEnd( ) )
      {
        error( std::string( "missing closing " ) + close + " in $INCLUDE", location );
      }

      name += next( );
    }

    next( );

    if ( Trim( name ).empty( ) )
    {
      error( "empty file name in $INCLUDE", location );
    }

    include = open + name + close;
  }
  else
  {
    include = readIdentifier( );

    if ( include.empty( ) )
    {
      error( "expected \"file\", <file> or a macro name after $INCLUDE", location );
    }
  }

  expectEndOfLine( );

  auto &includes = _Context.module.Includes;

  if ( std::find( includes.begin( ), includes.end( ), include ) == includes.end( ) )
  {
    includes.push_back( include );
  }
}

std::string MStringParser::resolveImport( const std::string &filename ) const
{
  fs::path const name( filename );
  std::error_code ec;

  if ( name.is_absolute( ) )
  {
    return fs::is_regular_file( name, ec ) ? filename : "";
  }

  // Like #include "...": next to the importing file first, then the working
  // folder, then the -I folders in the order given.
  std::vector<fs::path> folders;
  fs::path const current( _DisplayName );

  if ( current.has_parent_path( ) && fs::is_regular_file( current, ec ) )
  {
    folders.push_back( current.parent_path( ) );
  }

  folders.emplace_back( "." );

  for ( const std::string &folder : _Context.importFolders )
  {
    folders.emplace_back( folder );
  }

  for ( const fs::path &folder : folders )
  {
    fs::path const candidate = folder / name;

    if ( fs::is_regular_file( candidate, ec ) )
    {
      return candidate.lexically_normal( ).string( );
    }
  }

  return "";
}

void MStringParser::importDirective( )
{
  skipBlanks( );
  Location const location = here( );
  std::string filename;

  if ( peek( ) == '"' )
  {
    next( );

    while ( peek( ) != '"' )
    {
      if ( peek( ) == '\n' || atEnd( ) )
      {
        error( "missing closing \" in $IMPORT", location );
      }

      filename += next( );
    }

    next( );
  }
  else
  {
    filename = readRestOfLine( );
  }

  if ( filename.empty( ) )
  {
    error( "expected a file name after $IMPORT", location );
  }

  expectEndOfLine( );

  std::string const path = resolveImport( filename );

  if ( path.empty( ) )
  {
    error( "import file '" + filename + "' does not exist", location );
  }

  // A separate parser with its own cursor, sharing the context: the
  // imported file behaves as if its lines were written here.
  MStringParser imported( _Context );

  if ( ! imported.parseFile( path ) )
  {
    error( "error in imported file '" + filename + "'", location );
  }
}

void MStringParser::usingDirective( )
{
  skipBlanks( );
  Location const location = here( );
  std::string entity = readRestOfLine( );

  while ( ! entity.empty( ) && entity.back( ) == ';' )
  {
    entity = Trim( entity.substr( 0, entity.size( ) - 1 ) );
  }

  if ( entity.empty( ) )
  {
    error( "expected a name after $USING", location );
  }

  expectEndOfLine( );
  _Context.module.Usings.push_back( entity );
}

void MStringParser::namespaceDirective( )
{
  skipBlanks( );
  std::vector<std::string> names;

  if ( ! atEndOfLine( ) )
  {
    std::string const qualified = readQualifiedName( "a namespace name" );
    std::string::size_type begin = 0;

    while ( true )
    {
      std::string::size_type const end = qualified.find( "::", begin );
      names.push_back( qualified.substr( begin, end - begin ) );

      if ( end == std::string::npos )
      {
        break;
      }

      begin = end + 2;
    }
  }

  expectEndOfLine( );
  _Context.module.Namespaces = names;
}

void MStringParser::moduleDirective( )
{
  Module &module = _Context.module;
  module.Name = requireWord( IsModuleChar, "a module name" );
  skipBlanks( );
  std::optional<Location> noSource;

  while ( ! atEndOfLine( ) )
  {
    Location const location = here( );
    std::string const option = readKeyword( );

    if ( option == "SOURCE" )
      module.SourceExtension = requireWord( IsExtensionChar, "a file extension after SOURCE" );
    else if ( option == "HEADER" )
      module.HeaderExtension = requireWord( IsExtensionChar, "a file extension after HEADER" );
    else if ( option == "NOSOURCE" )
    {
      module.SourceExtension.reset( );
      noSource = location;
    }
    else if ( option == "INLINE" )
    {
      module.Inline = true;
      module.SourceExtension.reset( );
    }
    else if ( option == "NOHEADER" )
      module.HeaderExtension.reset( );
    else
      unknownOption( option, "$MODULE", location );

    skipBlanks( );
  }

  expectEndOfLine( );

  if ( noSource && ! module.Inline )
  {
    warning( "NOSOURCE writes declarations without definitions; INLINE puts the "
             "definitions into the header", *noSource );
  }
}

void MStringParser::messageDirective( )
{
  skipBlanks( );
  Location const location = here( );
  std::string const name = requireIdentifier( "a message name" );

  // `$MESSAGE Name : STRING THROW` — exactly these artefacts, for this
  // message only; their naming comes from the current settings.
  MessageSettings settings = _Context.settings;
  skipBlanks( );

  if ( peek( ) == ':' )
  {
    next( );
    Concept *const concepts[] =
    {
      &settings._Streamable, &settings._String, &settings._Error,
      &settings._Exception, &settings._Throw, &settings._Log
    };

    for ( Concept *concept : concepts )
    {
      concept->_Active = false;
    }

    skipBlanks( );

    if ( atEndOfLine( ) )
    {
      error( "expected STREAMABLE, STRING, ERROR, EXCEPTION, THROW or SYSLOG after ':'" );
    }

    while ( ! atEndOfLine( ) )
    {
      Location const optionLocation = here( );
      std::string const option = readKeyword( );

      if ( option == "STREAMABLE" )
        settings._Streamable._Active = true;
      else if ( option == "STRING" )
        settings._String._Active = true;
      else if ( option == "ERROR" )
        settings._Error._Active = true;
      else if ( option == "EXCEPTION" )
        settings._Exception._Active = true;
      else if ( option == "THROW" )
        settings._Throw._Active = true;
      else if ( option == "SYSLOG" )
        settings._Log._Active = true;
      else if ( option.empty( ) )
        error( "unexpected " + Describe( peek( ) ) +
               ", expected STREAMABLE, STRING, ERROR, EXCEPTION, THROW or SYSLOG", optionLocation );
      else
        error( "'" + option + "' is not an artefact: expected STREAMABLE, STRING, ERROR, "
               "EXCEPTION, THROW or SYSLOG", optionLocation );

      skipBlanks( );
    }
  }

  expectEndOfLine( );

  Module &module = _Context.module;

  if ( module.FindMessage( name ) != nullptr )
  {
    error( "message '" + name + "' is already defined", location );
  }

  if ( _Context.trace )
  {
    std::cerr << "$MESSAGE " << name << std::endl;
  }

  module.Messages.emplace_back( name, _Context.language, settings );
  module.Messages.back( ).File = _DisplayName;
  module.Messages.back( ).Line = location.line;
  module.Messages.back( ).Col = location.col;
  _InMessage = true;
  _MessageLocation = location;
  _MessageIndex = module.Messages.size( ) - 1;
  _TextStarted = false;
  _MessageLanguage = _Context.language;
  _MessageCharset = _Context.charset;
}

void MStringParser::closeMessage( )
{
  if ( _InMessage && currentMessage( ).Locales.empty( ) )
  {
    warning( "message '" + currentMessage( ).Name + "' has no text", _MessageLocation );
  }

  _InMessage = false;
}

//
// message body
//
Message &MStringParser::currentMessage( )
{
  return _Context.module.Messages[_MessageIndex];
}

LocaleMessage &MStringParser::currentLocale( )
{
  return currentMessage( ).Locale( _MessageLanguage, _MessageCharset );
}

void MStringParser::messageLine( )
{
  char const c = peek( );

  if ( ! _InMessage )
  {
    if ( c == '@' || c == '[' || c == '\'' || c == '"' || c == '|' )
    {
      error( "message text outside of a $MESSAGE block" );
    }

    error( "unexpected " + Describe( c ) + ", expected a $DIRECTIVE" );
  }

  switch ( c )
  {
    case '@':
      parameterLine( );
      break;
    case '[':
      setHeaderLine( );
      break;
    case '\'':
    case '"':
      quotedTextLine( c );
      break;
    case '|':
      barTextLine( );
      break;
    default:
      error( "unexpected " + Describe( c ) +
             ", expected a message text ('...', \"...\" or |...), a [language] header, "
             "a @parameter or a $DIRECTIVE" );
  }
}

void MStringParser::parameterLine( )
{
  Location const location = here( );

  if ( _TextStarted )
  {
    error( "parameters must be declared before the message texts", location );
  }

  next( );  // '@'
  skipBlanks( );
  Location const nameLocation = here( );
  std::string const name = requireIdentifier( "a parameter name after '@'" );
  skipBlanks( );
  std::string const type = readRestOfLine( );

  if ( type.empty( ) )
  {
    error( "expected the type of parameter '" + name + "'" );
  }

  expectEndOfLine( );

  Message &message = currentMessage( );

  // Names the generated functions use for their own parameters and locals.
  static const char *const reserved[] =
  {
    "stream", "loc", "option_parameter", "streamLocale", "originalFormat", "internal_locale_name"
  };

  for ( const char *word : reserved )
  {
    if ( name == word )
    {
      error( "parameter name '" + name + "' is reserved for the generated code", nameLocation );
    }
  }

  if ( name.compare( 0, 7, "member_" ) == 0 || name.compare( 0, 9, "internal_" ) == 0 )
  {
    error( "parameter names starting with 'member_' or 'internal_' are reserved for the generated code",
           nameLocation );
  }

  if ( message.FindParameter( name ) != nullptr )
  {
    error( "parameter '" + name + "' is already declared for message '" + message.Name + "'", nameLocation );
  }

  message.Parameters.emplace_back( name, type );
}

void MStringParser::setHeaderLine( )
{
  next( );  // '['
  std::string const language = requireWord( IsLocaleChar, "a language name after '['" );
  skipBlanks( );
  std::string charset = _Context.charset;

  if ( peek( ) != ']' )
  {
    Location const charsetLocation = here( );
    charset = requireWord( IsCharsetChar, "a charset name or ']'" );
    skipBlanks( );
    warning( "the charset of a [language charset] header is deprecated: it only "
             "labels the text with a comment", charsetLocation );
  }

  if ( peek( ) != ']' )
  {
    error( "expected ']' to close the [" + language + "] header, found " + Describe( peek( ) ) );
  }

  next( );
  expectEndOfLine( );

  _TextStarted = true;
  _MessageLanguage = language;
  _MessageCharset = charset;
}

void MStringParser::escapeSequence( std::string &text )
{
  // The backslash has been consumed.
  char const c = peek( );

  switch ( c )
  {
    case 'a':  text += '\a'; break;
    case 'b':  text += '\b'; break;
    case 'f':  text += '\f'; break;
    case 'n':  text += '\n'; break;
    case 'r':  text += '\r'; break;
    case 't':  text += '\t'; break;
    case 'v':  text += '\v'; break;
    case '\\': text += '\\'; break;
    case '\'': text += '\''; break;
    case '"':  text += '"';  break;
    case '{':  text += '{';  break;
    case 'x':
    {
      // \xH or \xHH: one byte
      next( );
      int value = 0;
      int digits = 0;

      while ( digits < 2 && std::isxdigit( static_cast<unsigned char>( peek( ) ) ) )
      {
        char const h = next( );
        value = value * 16 + ( std::isdigit( static_cast<unsigned char>( h ) )
                               ? h - '0' : std::tolower( static_cast<unsigned char>( h ) ) - 'a' + 10 );
        ++digits;
      }

      if ( digits == 0 )
      {
        text += "\\x";   // not an escape after all
      }
      else
      {
        text += static_cast<char>( value );
      }

      return;
    }
    default:
      if ( c >= '0' && c <= '7' )
      {
        // \o, \oo or \ooo: one byte (UTF-8 bytes of another charset, ...)
        int value = 0;

        for ( int digits = 0; digits < 3 && peek( ) >= '0' && peek( ) <= '7'; ++digits )
        {
          value = value * 8 + ( next( ) - '0' );
        }

        text += static_cast<char>( value & 0xff );
        return;
      }

      // Not an escape: keep the backslash, `C:\temp` stays `C:\temp`.
      text += '\\';
      return;
  }

  next( );
}

void MStringParser::quotedTextLine( char quote )
{
  Location const open = here( );
  next( );  // the opening quote

  bool const macros = quote == '"';
  LocaleMessage &locale = currentLocale( );
  std::string text;
  _TextStarted = true;

  while ( true )
  {
    char const c = peek( );

    if ( c == '\n' || c == EOF_CHAR )
    {
      error( std::string( "unterminated string, missing closing " ) + quote, open );
    }

    Location const location = here( );
    next( );

    if ( c == quote )
    {
      break;
    }

    if ( c == '\\' )
    {
      if ( peek( ) == '\n' || atEnd( ) )
      {
        error( std::string( "unterminated string, missing closing " ) + quote, open );
      }

      escapeSequence( text );
    }
    else if ( c == '{' && macros && peek( ) == '$' )
    {
      next( );
      locale.AppendText( text );
      text.clear( );
      macro( locale, location );
    }
    else
    {
      text += c;
    }
  }

  locale.AppendText( text );
  expectEndOfLine( );
}

void MStringParser::barTextLine( )
{
  next( );  // '|'

  LocaleMessage &locale = currentLocale( );
  std::string text;
  bool newline = true;
  _TextStarted = true;

  while ( true )
  {
    char const c = peek( );

    if ( c == EOF_CHAR )
    {
      break;
    }

    Location const location = here( );
    next( );

    if ( c == '\n' )
    {
      break;
    }

    if ( c == '\r' && peek( ) == '\n' )
    {
      continue;   // CRLF line end
    }

    if ( c == '\\' )
    {
      if ( peek( ) == '\r' )
      {
        next( );

        if ( peek( ) != '\n' )
        {
          text += "\\\r";
          continue;
        }
      }

      if ( peek( ) == '\n' || atEnd( ) )
      {
        // A trailing backslash joins the next line without a newline.
        if ( ! atEnd( ) )
        {
          next( );
        }

        newline = false;
        break;
      }

      escapeSequence( text );
    }
    else if ( c == '{' && peek( ) == '$' )
    {
      next( );
      locale.AppendText( text );
      text.clear( );
      macro( locale, location );
    }
    else
    {
      text += c;
    }
  }

  if ( newline )
  {
    text += '\n';
  }

  locale.AppendText( text );
}

void MStringParser::macro( LocaleMessage &locale, Location open )
{
  // `{$` has been consumed:  {$ expression [; format]... }
  TextElement element( true );
  std::string part;
  bool expression = true;
  int depth = 0;

  while ( true )
  {
    char const c = peek( );

    if ( c == '\n' || c == EOF_CHAR )
    {
      error( "unterminated macro, missing '}'", open );
    }

    next( );

    if ( c == '\\' && peek( ) != '\n' && ! atEnd( ) )
    {
      part += next( );   // \; and \} are literal
      continue;
    }

    if ( c == '{' )
    {
      ++depth;
    }
    else if ( c == '}' && depth > 0 )
    {
      --depth;
    }
    else if ( ( c == ';' && depth == 0 ) || c == '}' )
    {
      std::string const value = Trim( part );
      part.clear( );

      if ( expression )
      {
        if ( value.empty( ) )
        {
          error( "empty macro expression", open );
        }

        element.Text = value;
        expression = false;
      }
      else if ( ! value.empty( ) )
      {
        element.Formats.push_back( value );
      }

      if ( c == '}' )
      {
        break;
      }

      continue;
    }

    part += c;
  }

  locale.TextElements.push_back( element );
}

//
// whole-module checks
//
namespace
{
  /// Does the macro expression \p expression mention the identifier \p name?
  bool Mentions( const std::string &expression, const std::string &name )
  {
    for ( std::string::size_type pos = expression.find( name ); pos != std::string::npos;
          pos = expression.find( name, pos + 1 ) )
    {
      bool const before = pos == 0 || ! IsIdentifierChar( expression[pos - 1] );
      std::string::size_type const end = pos + name.size( );
      bool const after = end == expression.size( ) || ! IsIdentifierChar( expression[end] );

      if ( before && after )
      {
        return true;
      }
    }

    return false;
  }

  bool Uses( const LocaleMessage &locale, const std::string &name )
  {
    return std::any_of( locale.TextElements.begin( ), locale.TextElements.end( ),
                        [&name]( const TextElement &element )
                        { return element.Macro && Mentions( element.Text, name ); } );
  }
}

void CheckTranslations( ParseContext &context )
{
  auto warn = [&context]( const Message &message, const std::string &text )
  {
    ParseError e;
    e.kind = ParseError::Kind::Warning;
    e.message = text;
    e.filename = message.File;
    e.line = message.Line;
    e.col = message.Col;
    context.diagnostics.push_back( e );
  };

  std::vector<std::string> languages;

  for ( const Message &message : context.module.Messages )
  {
    for ( const LocaleMessage &locale : message.Locales )
    {
      if ( std::find( languages.begin( ), languages.end( ), locale.Language ) == languages.end( ) )
      {
        languages.push_back( locale.Language );
      }
    }
  }

  for ( const Message &message : context.module.Messages )
  {
    if ( message.Locales.empty( ) )
    {
      continue;   // already warned: "has no text"
    }

    if ( languages.size( ) > 1 )
    {
      for ( const std::string &language : languages )
      {
        bool const present = std::any_of( message.Locales.begin( ), message.Locales.end( ),
                                          [&language]( const LocaleMessage &locale )
                                          { return locale.Language == language; } );

        if ( ! present )
        {
          warn( message, "message '" + message.Name + "' has no [" + language +
                         "] text; its default text is used" );
        }
      }
    }

    // A parameter used by one language and not by another is almost always
    // a translation mistake. One no language uses is data the class carries.
    for ( const Parameter &parameter : message.Parameters )
    {
      const LocaleMessage *user = nullptr;

      for ( const LocaleMessage &locale : message.Locales )
      {
        if ( Uses( locale, parameter.Name ) )
        {
          user = &locale;
          break;
        }
      }

      if ( user == nullptr )
      {
        continue;
      }

      for ( const LocaleMessage &locale : message.Locales )
      {
        if ( ! Uses( locale, parameter.Name ) )
        {
          warn( message, "parameter '" + parameter.Name + "' of message '" + message.Name +
                         "' is used in the [" + user->Language + "] text but not in the [" +
                         locale.Language + "] text" );
        }
      }
    }
  }
}