// SPDX-License-Identifier: MIT
#include "Fson.hh"
#include "FsonParser.hh"
#include "FsonWriter.hh"
#include "IncludeResolver.hh"
#include <filesystem>
#include <fstream>
#include <chrono>
#include <cstdint>
using namespace std;
namespace fedem
{
namespace fson
{
namespace
{
// FNV-1a 64-bit over a file's bytes. Not cryptographic — used only to
// distinguish "same bytes" from "different bytes" for staleness.
// Returns 0 when the file cannot be read.
uint64_t hashFile( string const& path )
{
ifstream stream( path, ios::binary );
if( !stream )
return 0ULL;
uint64_t hash = 14695981039346656037ULL; // FNV offset basis
char buffer[ 64 * 1024 ];
while( stream.read( buffer, sizeof( buffer ) ) || stream.gcount( ) > 0 )
{
streamsize const got = stream.gcount( );
for( streamsize i = 0; i < got; ++i )
{
hash ^= static_cast< unsigned char >( buffer[ i ] );
hash *= 1099511628211ULL; // FNV prime
}
}
return hash;
}
// Captures mtime (seconds since epoch) and size for one file; zeros
// both on failure.
void statFile( string const& path, int64_t& mtime, uintmax_t& size )
{
error_code ec;
auto const writeTime = filesystem::last_write_time( path, ec );
mtime = ec ? 0
: static_cast< int64_t >(
chrono::duration_cast< chrono::seconds >(
writeTime.time_since_epoch( ) ).count( ) );
error_code sizeEc;
auto const bytes = filesystem::file_size( path, sizeEc );
size = sizeEc ? 0U : bytes;
}
} // end anonymous namespace
Fson::LoadResult Fson::load( string const& filename )
{
LoadResult result;
// fson 0.9.1.0: skip parsing entirely when the file does not exist.
// The cparse layer (Parser::parse) writes an unconditional
// "[Parser::parse] filesystem error: cannot make canonical path"
// line to std::clog for a missing file. Optional-config callers
// (which check .ok()/.document and fall back to defaults) then
// produced that noise on every load of an absent file. Returning
// the same not-found result (document == nullptr, empty errors)
// WITHOUT invoking the parser preserves the existing contract
// (.ok() stays false) while staying silent. A file that exists but
// is unreadable/unparseable still goes through the parser and
// surfaces its real diagnostics.
error_code ec;
if( !filesystem::exists( filename, ec ) || ec )
return result; // document == nullptr → ok() == false
FsonParser parser;
result.document = parser.parseFile( filename );
result.errors = parser.errors( );
// Resolve %include directives into the query-visible merge view.
// Errors from resolution (missing target, cycle, non-object merge
// root) are appended; dependency paths are exposed for reload use.
if( result.document )
{
IncludeResolver resolver;
resolver.resolve( *result.document, filename );
for( auto const& error : resolver.errors( ) )
result.errors.push_back( error );
result.dependencies = resolver.dependencies( );
result.dependencySet = resolver.dependencySet( );
}
return result;
}
std::set< string > Fson::dependenciesOf( string const& filename )
{
// Mirror load()'s silent not-found contract: an absent host file has
// no dependencies (and would otherwise make the parser log to clog).
error_code ec;
if( !filesystem::exists( filename, ec ) || ec )
return { };
FsonParser parser;
unique_ptr< Document > document = parser.parseFile( filename );
if( !document )
return { };
IncludeResolver resolver;
resolver.resolve( *document, filename );
return resolver.dependencySet( );
}
set< string > Fson::Snapshot::files( ) const
{
set< string > result;
for( auto const& entry : entries )
result.insert( entry.first );
return result;
}
Fson::Snapshot Fson::snapshot( string const& filename )
{
Snapshot snap;
// The files to record are exactly those needed to resolve the host
// (host + includes). An absent host yields an empty snapshot.
set< string > const files = dependenciesOf( filename );
for( auto const& path : files )
{
Snapshot::Entry entry{ 0, 0U, 0ULL };
statFile( path, entry.mtime, entry.size );
entry.hash = hashFile( path );
snap.entries.emplace( path, entry );
}
return snap;
}
bool Fson::isStale( string const& filename,
Snapshot const& snap,
StaleMode mode )
{
// Re-resolve the current dependency set. If the include graph changed
// (a file added or removed since the snapshot), that alone is stale —
// the resolved document can differ even if no recorded file's bytes
// did. This also covers the empty-snapshot case: a host that was
// missing (empty snapshot) but now resolves to a non-empty set.
set< string > const current = dependenciesOf( filename );
set< string > recorded;
for( auto const& entry : snap.entries )
recorded.insert( entry.first );
if( current != recorded )
return true;
// Same file set: compare each file under the chosen mode.
for( auto const& path : current )
{
auto const found = snap.entries.find( path );
if( found == snap.entries.end( ) )
return true; // defensive; sets were equal, so unreachable
Snapshot::Entry const& was = found->second;
if( mode == StaleMode::ContentHash )
{
if( hashFile( path ) != was.hash )
return true;
}
else // MtimeSize
{
int64_t mtime = 0;
uintmax_t size = 0U;
statFile( path, mtime, size );
if( mtime != was.mtime || size != was.size )
return true;
}
}
return false;
}
bool Fson::save( Document const& document, string const& filename )
{
FsonWriter writer;
return writer.writeFile( document, filename );
}
} // end namespace fson
} // end namespace fedem