Editor/Serialization/DocumentIO.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Projection;
using Grains.RazorDesigner.Serialization.IR;
using Grains.RazorDesigner.Validation;
using Sandbox;
namespace Grains.RazorDesigner.Serialization;
public readonly record struct SaveResult(
string JsonPath,
string RazorPath,
string ScssPath,
bool RazorExported,
IReadOnlyList<ValidationDiagnostic> Diagnostics,
string CSharpPath = null,
bool CSharpExported = false );
public readonly record struct LoadResult(
DesignerDocument Document,
string ResolvedPath,
IReadOnlyList<string> Warnings,
bool Success,
bool DriftDetected = false,
bool StampMismatch = false,
bool MigratedFromLegacy = false,
string LegacyRazorPath = null );
public static class DocumentIO
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public static SaveResult Save( DesignerDocument document, string razorPath, PreviewTheme theme )
{
// Perf probe (grd-pewf): covers validate + IR write + .razor/.scss regen + disk I/O.
var probeSw = System.Diagnostics.Stopwatch.StartNew();
var className = System.IO.Path.GetFileNameWithoutExtension( razorPath );
RewriteOutOfAssetsImages( document );
IReadOnlyList<ValidationDiagnostic> diags;
bool hasError;
try
{
diags = new Validator().Validate( new RecordNode( document.RootRecord ) );
hasError = diags.Any( d => d.Severity == DiagnosticSeverity.Error );
Log.Info( $"{LogPrefix} Save: validator returned {diags.Count} diagnostic(s), hasError={hasError}" );
foreach ( var d in diags )
Log.Warning( $"{LogPrefix} Save: {d.Severity} [{d.Code}] node={d.NodeId?.ToString() ?? "<document>"} — {d.Message}" );
}
catch ( Exception ex )
{
// Validator must not throw (spec: "validator is total"), but be defensive.
Log.Warning( $"{LogPrefix} Save: validator threw unexpectedly: {ex.Message}; proceeding as if no errors" );
diags = Array.Empty<ValidationDiagnostic>();
hasError = false;
}
var probeDiskMs = 0.0;
var probeLap = System.Diagnostics.Stopwatch.StartNew();
var json = IRWriter.WriteDocument( document );
var probeJsonMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
var hash = IRWriter.CanonicalHash( json );
var probeHashMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
var jsonPath = razorPath + ".json";
var tmp = jsonPath + ".tmp";
System.IO.File.WriteAllText( tmp, json );
System.IO.File.Move( tmp, jsonPath, overwrite: true );
probeDiskMs += probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
Log.Info( $"{LogPrefix} Saved -> {jsonPath}" );
// Step 4: Conditional .razor + .razor.scss regeneration.
var scssPath = razorPath + ".scss";
var csPath = razorPath + ".cs";
bool razorExported = false;
bool csharpExported = false;
var probeRazorMs = 0.0;
var probeScssMs = 0.0;
var probeCsharpMs = 0.0;
if ( !hasError )
{
try
{
probeLap.Restart();
var razor = DocumentSerializer.GenerateRazorMarkup( document, hash );
probeRazorMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
var scss = DocumentSerializer.GenerateSavedScss( document, className, theme );
probeScssMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
System.IO.File.WriteAllText( razorPath, razor );
System.IO.File.WriteAllText( scssPath, scss );
probeDiskMs += probeLap.Elapsed.TotalMilliseconds;
razorExported = true;
Log.Info( $"{LogPrefix} Saved -> {razorPath}" );
Log.Info( $"{LogPrefix} Saved -> {scssPath}" );
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} Razor export failed (JSON saved OK): {ex.Message}" );
}
try
{
probeLap.Restart();
var nsFallback = Project.Current?.Package?.Ident ?? "Grains";
var cs = DocumentSerializer.GenerateCSharp( document, className, nsFallback );
probeCsharpMs = probeLap.Elapsed.TotalMilliseconds; probeLap.Restart();
if ( cs is not null )
{
System.IO.File.WriteAllText( csPath, cs );
probeDiskMs += probeLap.Elapsed.TotalMilliseconds;
csharpExported = true;
Log.Info( $"{LogPrefix} Saved -> {csPath}" );
}
else
{
Log.Info( $"{LogPrefix} CSharp emit elided (empty wiring); existing {csPath} untouched if present." );
}
}
catch ( Exception ex )
{
Log.Error( $"{LogPrefix} CSharp export failed (JSON saved OK): {ex.Message}" );
}
}
else
{
var errorCount = diags.Count( d => d.Severity == DiagnosticSeverity.Error );
Log.Warning(
$"{LogPrefix} Razor export skipped — {errorCount} validation error(s); " +
$".razor.json saved. Fix the errors and re-save to regenerate .razor." );
}
var probeTotal = probeSw.Elapsed.TotalMilliseconds;
Log.Info( $"{LogPrefix} probe: Save took {probeTotal:F3}ms = IRWrite {probeJsonMs:F3} + hash {probeHashMs:F3} + razorGen {probeRazorMs:F3} + scssGen {probeScssMs:F3} + csharpGen {probeCsharpMs:F3} + disk {probeDiskMs:F3} + validate/other {probeTotal - probeJsonMs - probeHashMs - probeRazorMs - probeScssMs - probeCsharpMs - probeDiskMs:F3} (razorExported={razorExported}, csharpExported={csharpExported})" );
return new SaveResult( jsonPath, razorPath, scssPath, razorExported, diags, csPath, csharpExported );
}
private static readonly Regex IrHashStampRegex = new(
@"@\*\s*generated-from-ir-hash:\s*(?<h>[0-9a-f]+)\s*\*@",
RegexOptions.Compiled );
private static string NormalizeNewlines( string s ) =>
s is null ? null : s.Replace( "\r\n", "\n" ).Replace( "\r", "\n" );
public static LoadResult Load( string path )
{
if ( string.IsNullOrEmpty( path ) )
{
Log.Warning( $"{LogPrefix} Load: path is null or empty" );
return new LoadResult( null, path, new[] { "path is null or empty" }, Success: false );
}
var stem = path.EndsWith( ".razor.json", StringComparison.OrdinalIgnoreCase )
? path[..^5] // strip ".json" → yields "…/Foo.razor"
: path;
var jsonPath = stem + ".json"; // "…/Foo.razor.json"
Log.Info( $"{LogPrefix} Load: stem={stem} jsonPath={jsonPath}" );
if ( File.Exists( jsonPath ) )
{
DesignerDocument doc;
try
{
var jsonText = File.ReadAllText( jsonPath );
doc = IRReader.ReadDocument( jsonText );
}
catch ( Exception ex )
{
var msg = $"Failed to read .razor.json: {ex.Message}";
Log.Error( $"{LogPrefix} Load: {msg} ({jsonPath})" );
return new LoadResult( null, jsonPath, new[] { msg }, Success: false,
LegacyRazorPath: stem );
}
// Check for drift / stamp-mismatch if .razor also exists.
if ( File.Exists( stem ) )
{
var razorText = NormalizeNewlines( File.ReadAllText( stem ) );
var jsonForHash = File.ReadAllText( jsonPath );
var expectedHash = IRWriter.CanonicalHash( jsonForHash );
var regeneratedRazor = NormalizeNewlines( DocumentSerializer.GenerateRazorMarkup( doc, expectedHash ) );
if ( string.Equals( razorText, regeneratedRazor, StringComparison.Ordinal ) )
{
// .razor matches the IR byte-for-byte (modulo line endings) — silent canonical load.
Log.Info( $"{LogPrefix} Load: loaded from .razor.json (canonical, .razor matches IR)" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
LegacyRazorPath: stem );
}
var stampMatch = IrHashStampRegex.Match( razorText );
var stampHash = stampMatch.Success ? stampMatch.Groups["h"].Value : null;
if ( stampHash == expectedHash )
{
Log.Info( $"{LogPrefix} Load: drift detected — .razor body hand-edited (stamp intact: {stampHash})" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
DriftDetected: true, LegacyRazorPath: stem );
}
Log.Info( $"{LogPrefix} Load: stamp mismatch — .razor generated from a different IR " +
$"(stamp={stampHash ?? "<absent>"} expected={expectedHash})" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
StampMismatch: true, LegacyRazorPath: stem );
}
// .razor.json present, .razor absent — clean canonical load (no drift check possible).
Log.Info( $"{LogPrefix} Load: loaded from .razor.json (canonical, no .razor sibling)" );
return new LoadResult( doc, jsonPath, Array.Empty<string>(), Success: true,
LegacyRazorPath: stem );
}
if ( File.Exists( stem ) )
{
Log.Info( $"{LogPrefix} Load: no .razor.json found — cold-migrating from legacy .razor: {stem}" );
var (doc, diags) = LegacyRazorImporter.Import( stem );
var warnings = diags.Select( d => $"{d.Severity}: {d.Message}" ).ToList();
if ( doc == null )
{
var reason = warnings.Count > 0 ? warnings[warnings.Count - 1] : "unknown";
Log.Warning( $"{LogPrefix} Load: cold migration aborted — {reason}" );
return new LoadResult( null, stem, warnings, Success: false,
LegacyRazorPath: stem );
}
// Write the .razor.json sibling so subsequent opens go through the canonical path.
WriteJson( doc, jsonPath );
Log.Info( $"{LogPrefix} Load: cold-migrated from legacy .razor → wrote {jsonPath}" );
var migDiag = new ValidationDiagnostic(
NodeId: null,
Severity: DiagnosticSeverity.Warn,
Code: "legacy-migrated",
Message: $"Migrated from legacy .razor — created {jsonPath}" );
warnings.Add( $"{migDiag.Severity}: {migDiag.Message}" );
return new LoadResult( doc, stem, warnings, Success: true,
MigratedFromLegacy: true, LegacyRazorPath: stem );
}
Log.Warning( $"{LogPrefix} Load: file not found: {path}" );
return new LoadResult( null, path, new[] { $"file not found: {path}" }, Success: false );
}
internal static void WriteJson( DesignerDocument doc, string jsonPath )
{
var json = IRWriter.WriteDocument( doc );
var tmp = jsonPath + ".tmp";
File.WriteAllText( tmp, json );
File.Move( tmp, jsonPath, overwrite: true );
Log.Info( $"{LogPrefix} WriteJson: wrote {jsonPath}" );
}
public static void RewriteOutOfAssetsImages( DesignerDocument document )
{
var assetsRoot = Project.Current?.GetAssetsPath();
if ( string.IsNullOrEmpty( assetsRoot ) )
{
Log.Warning( $"{LogPrefix} Auto-import skipped: Project.Current has no assets path" );
return;
}
var importsDir = System.IO.Path.Combine( assetsRoot, "ImageImports" );
foreach ( var r in document.WalkAll() )
{
if ( r.Type != ControlType.Image ) continue;
if ( string.IsNullOrEmpty( r.Source ) ) continue;
// Inside-Assets paths don't escape; nothing to do.
var rel = r.Source.Replace( '\\', '/' );
if ( !rel.StartsWith( "../" ) ) continue;
var sourceAbs = System.IO.Path.GetFullPath( r.Source, assetsRoot );
if ( !System.IO.File.Exists( sourceAbs ) )
{
Log.Warning( $"{LogPrefix} Auto-import skipped (file missing): {sourceAbs}" );
continue;
}
System.IO.Directory.CreateDirectory( importsDir );
var fileName = System.IO.Path.GetFileName( sourceAbs );
var stem = System.IO.Path.GetFileNameWithoutExtension( fileName );
var ext = System.IO.Path.GetExtension( fileName );
var dest = System.IO.Path.Combine( importsDir, fileName );
var n = 1;
while ( System.IO.File.Exists( dest ) && !FilesEqual( sourceAbs, dest ) )
{
dest = System.IO.Path.Combine( importsDir, $"{stem}_{n}{ext}" );
n++;
}
if ( !System.IO.File.Exists( dest ) )
{
System.IO.File.Copy( sourceAbs, dest );
Log.Info( $"{LogPrefix} Auto-imported image: {sourceAbs} -> {dest}" );
}
else
{
Log.Info( $"{LogPrefix} Auto-import: existing identical file reused at {dest}" );
}
r.Source = $"ImageImports/{System.IO.Path.GetFileName( dest )}";
}
}
private static bool FilesEqual( string a, string b )
{
try
{
var ia = new System.IO.FileInfo( a );
var ib = new System.IO.FileInfo( b );
if ( ia.Length != ib.Length ) return false;
using var sa = ia.OpenRead();
using var sb = ib.OpenRead();
var bufA = new byte[8192];
var bufB = new byte[8192];
int read;
while ( ( read = sa.Read( bufA, 0, bufA.Length ) ) > 0 )
{
var got = sb.Read( bufB, 0, read );
if ( got != read ) return false;
for ( int i = 0; i < read; i++ )
if ( bufA[i] != bufB[i] ) return false;
}
return true;
}
catch ( System.Exception e )
{
Log.Warning( $"{LogPrefix} FilesEqual({a}, {b}) failed: {e.Message}" );
return false;
}
}
}