Files
sbox-public/engine/Sandbox.Generator/Processors/ILHotload.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

504 lines
14 KiB
C#

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
namespace Sandbox.Generator;
public static class ILHotloadProcessor
{
internal static void Process( Processor processor )
{
processor.ILHotloadSupported = false;
if ( processor.LastSuccessfulCompilation == null || processor.BeforeILHotloadProcessingTrees.IsDefault )
{
return;
}
var oldTrees = GetSyntaxTreeDict( processor.BeforeILHotloadProcessingTrees );
var newTrees = GetSyntaxTreeDict( processor.Compilation.SyntaxTrees );
//
// Early out: check if files added / removed
//
if ( oldTrees.Count != newTrees.Count )
{
return;
}
if ( oldTrees.Keys.Any( x => !newTrees.ContainsKey( x ) ) )
{
return;
}
if ( newTrees.Keys.Any( x => !oldTrees.ContainsKey( x ) ) )
{
return;
}
//
// Compare each syntax tree.
// If only statement blocks have changed, inject attributes to mark the containing definitions.
//
var onlyBlocksChanged = true;
var updatedNewTrees = new ConcurrentBag<SyntaxTree>();
var exceptions = new ConcurrentBag<Exception>();
Parallel.ForEach( oldTrees.Values, oldTree =>
{
try
{
var newTree = newTrees[oldTree.FilePath];
if ( !HaveOnlyBlocksChanged( oldTree, newTree, out var updatedNewTree ) )
{
Volatile.Write( ref onlyBlocksChanged, false );
return;
}
updatedNewTrees.Add( updatedNewTree );
}
catch ( Exception e )
{
Volatile.Write( ref onlyBlocksChanged, false );
exceptions.Add( e );
}
} );
if ( exceptions.Count == 1 )
{
throw exceptions.First();
}
if ( exceptions.Count > 1 )
{
throw new AggregateException( exceptions );
}
//
// Abort if any tree had a more substantial change than just a statement block.
//
if ( !onlyBlocksChanged )
{
return;
}
processor.ILHotloadSupported = true;
//
// Add SupportsILHotloadAttribute
//
var oldAssemblyVersion = processor.LastSuccessfulCompilation.Assembly.Identity.Version;
var compilerExtraTree = processor.Compilation.SyntaxTrees
.Single( x => x.FilePath == Processor.CompilerExtraPath );
var compilerExtraText = compilerExtraTree.GetText();
var updatedCompilerExtraTree = compilerExtraTree.WithChangedText( compilerExtraText.WithChanges( new TextChange(
new TextSpan( compilerExtraText.Length, 0 ),
$"{Environment.NewLine}[assembly: global::Sandbox.SupportsILHotloadAttribute(\"{oldAssemblyVersion}\")]" ) ) );
processor.Compilation = processor.Compilation.ReplaceSyntaxTree( compilerExtraTree, updatedCompilerExtraTree );
//
// Apply changed syntax trees
//
foreach ( var updatedNewTree in updatedNewTrees )
{
var newTree = newTrees[updatedNewTree.FilePath];
processor.Compilation = processor.Compilation.ReplaceSyntaxTree( newTree, updatedNewTree );
}
}
private static Dictionary<string, SyntaxTree> GetSyntaxTreeDict( ImmutableArray<SyntaxTree> trees )
{
return trees
.Where( x => !x.FilePath.Equals( Processor.CompilerExtraPath, StringComparison.OrdinalIgnoreCase ) )
.ToDictionary( x => x.FilePath, x => x );
}
/// <summary>
/// For debugging, get a string representing the path to a node in an AST. Type and method names
/// are included.
/// </summary>
/// <param name="node">Node to find the path of.</param>
/// <returns>A string representing the path</returns>
private static string FormatNodePath( SyntaxNode node )
{
var thisNodeFormat = node.Kind().ToString();
switch ( node )
{
case MethodDeclarationSyntax methodDef:
thisNodeFormat = $"{thisNodeFormat}( {methodDef.Identifier.ValueText} )";
break;
case TypeDeclarationSyntax classDef:
thisNodeFormat = $"{thisNodeFormat}( {classDef.Identifier.ValueText} )";
break;
}
if ( node.Parent == null )
{
return thisNodeFormat;
}
return $"{FormatNodePath( node.Parent )} -> {thisNodeFormat}";
}
private static bool NodeIsDeclaration( SyntaxNode node )
{
// TODO: This might not be exhaustive!
switch ( node.Kind() )
{
case SyntaxKind.UsingDirective:
case SyntaxKind.ClassDeclaration:
case SyntaxKind.DelegateDeclaration:
case SyntaxKind.EnumDeclaration:
case SyntaxKind.StructDeclaration:
case SyntaxKind.RecordDeclaration:
case SyntaxKind.RecordStructDeclaration:
case SyntaxKind.MethodDeclaration:
case SyntaxKind.PropertyDeclaration:
case SyntaxKind.FieldDeclaration:
case SyntaxKind.EventDeclaration:
case SyntaxKind.ConstructorDeclaration:
case SyntaxKind.DestructorDeclaration:
case SyntaxKind.ConversionOperatorDeclaration:
case SyntaxKind.AddAccessorDeclaration:
case SyntaxKind.RemoveAccessorDeclaration:
case SyntaxKind.GetAccessorDeclaration:
case SyntaxKind.SetAccessorDeclaration:
case SyntaxKind.IndexerDeclaration:
case SyntaxKind.OperatorDeclaration:
return true;
default:
return false;
}
}
/// <summary>
/// Test to see if the given node contains any declarations that would exclude it
/// from using <see cref="ILHotload"/>'s fast path.
/// </summary>
private static bool NodeContainsDeclarations( SyntaxNode node )
{
foreach ( var descendant in node.DescendantNodesAndSelf() )
{
if ( NodeIsDeclaration( descendant ) )
{
return true;
}
}
return false;
}
private static bool HaveOnlyBlocksChanged( SyntaxTree oldTree, SyntaxTree newTree, out SyntaxTree updatedNewTree )
{
if ( oldTree == null && newTree == null )
{
throw new ArgumentNullException( nameof( newTree ),
$"Expected at least one of ({nameof( oldTree )}, {nameof( newTree )}) to not be null" );
}
updatedNewTree = oldTree;
if ( oldTree == null || newTree == null )
{
return false;
}
var oldDefines = new HashSet<string>( oldTree.Options.PreprocessorSymbolNames );
if ( !oldDefines.SetEquals( newTree.Options.PreprocessorSymbolNames ) )
{
return false;
}
if ( newTree.IsEquivalentTo( oldTree ) )
{
return true;
}
var changes = newTree.GetChanges( oldTree );
if ( changes.Count == 0 )
{
return true;
}
if ( !oldTree.TryGetRoot( out var oldRoot ) || !newTree.TryGetRoot( out var newRoot ) )
{
// Maybe throw?
return false;
}
var newSpanStartOffset = 0;
var replacements = new Dictionary<SyntaxNode, SyntaxNode>();
foreach ( var change in changes )
{
// For each change:
// 1. Find the deepest node containing that change in both the old and new ASTs
// 2. If either node isn't within a method declaration, fail the test
// 3. If either node contains a type or member declaration, fail the test
// 4. We found a valid change, add it to the set if given
var newSpan = new TextSpan( change.Span.Start + newSpanStartOffset, change.NewText?.Length ?? 0 );
newSpanStartOffset += (change.NewText?.Length ?? 0) - change.Span.Length;
var oldNode = oldRoot.FindNode( change.Span, getInnermostNodeForTie: true );
var newNode = newRoot.FindNode( newSpan, getInnermostNodeForTie: true );
var oldIsTrivia = IsTrivialChange( oldNode, change.Span );
var newIsTrivia = IsTrivialChange( newNode, newSpan );
if ( newIsTrivia && oldIsTrivia && string.Equals( newNode.ToString(), oldNode.ToString(), StringComparison.Ordinal ) )
{
// Only trivia changed
continue;
}
var oldMethodBlock = oldNode.FirstAncestorOrSelf<SyntaxNode>( IsDeclaringBlock );
var newMethodBlock = newNode.FirstAncestorOrSelf<SyntaxNode>( IsDeclaringBlock );
if ( oldMethodBlock == null || newMethodBlock == null )
{
// Ignore SourceLocation attribute parameter changes
var sourceLocation = SyntaxFactory.ParseName( "Sandbox.Internal.SourceLocation" );
var oldAttrib = oldNode.FirstAncestorOrSelf<AttributeListSyntax>( x => IsSingleAttribute( x, sourceLocation ) );
var newAttrib = newNode.FirstAncestorOrSelf<AttributeListSyntax>( x => IsSingleAttribute( x, sourceLocation ) );
if ( oldAttrib == null || newAttrib == null )
{
return false;
}
continue;
}
if ( NodeContainsDeclarations( oldNode ) || NodeContainsDeclarations( newNode ) )
{
return false;
}
if ( TryHandleMethodDecl( oldMethodBlock, newMethodBlock, replacements ) )
{
continue;
}
if ( TryHandlePropertyDecl( oldMethodBlock, newMethodBlock, replacements ) )
{
continue;
}
return false;
}
if ( replacements.Count > 0 )
{
var root = newTree.GetRoot();
updatedNewTree = newTree.WithRootAndOptions(
root.ReplaceNodes( replacements.Keys, ( a, b ) => replacements[a] ),
newTree.Options );
}
return true;
}
/// <summary>
/// Can we ignore the given change because it only involves whitespace / comments?
/// </summary>
private static bool IsTrivialChange( SyntaxNode changedNode, TextSpan changedSpan )
{
return !changedNode.Span.OverlapsWith( changedSpan );
}
private static bool IsSingleAttribute( AttributeListSyntax node, NameSyntax name )
{
return node.Attributes.Count == 1 && node.Attributes[0].Name.IsEquivalentTo( name );
}
private static bool IsDeclaringBlock( SyntaxNode node )
{
if ( node.IsKind( SyntaxKind.ArrowExpressionClause ) )
{
return true;
}
if ( !node.IsKind( SyntaxKind.Block ) && !node.IsKind( SyntaxKind.ArrowExpressionClause ) )
{
return false;
}
return node.Parent.IsKind( SyntaxKind.MethodDeclaration )
|| node.Parent.IsKind( SyntaxKind.GetAccessorDeclaration )
|| node.Parent.IsKind( SyntaxKind.SetAccessorDeclaration );
}
private static bool TryHandleMethodDecl( SyntaxNode oldBlock, SyntaxNode newBlock, Dictionary<SyntaxNode, SyntaxNode> replacements )
{
if ( oldBlock.Parent is not MethodDeclarationSyntax || newBlock.Parent is not MethodDeclarationSyntax newMethodDecl )
{
return false;
}
var replacingMethodDecl = newMethodDecl.WithAttributeLists(
newMethodDecl.AttributeLists.Add(
SyntaxFactory.AttributeList( SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.Attribute( SyntaxFactory.QualifiedName(
SyntaxFactory.AliasQualifiedName(
SyntaxFactory.IdentifierName( SyntaxFactory.Token( SyntaxKind.GlobalKeyword ) ),
SyntaxFactory.IdentifierName( "Sandbox" ) ),
SyntaxFactory.IdentifierName( "MethodBodyChangeAttribute" )
) )
) )
)
);
replacements[newMethodDecl] = replacingMethodDecl;
return true;
}
private static bool TryHandlePropertyDecl( SyntaxNode oldBlock, SyntaxNode newBlock, Dictionary<SyntaxNode, SyntaxNode> replacements )
{
if ( !TryGetPropertyDeclarationFromBlock( oldBlock, out _, out _ ) )
{
return false;
}
if ( !TryGetPropertyDeclarationFromBlock( newBlock, out var newPropertyDecl, out var accessor ) )
{
return false;
}
var replacingPropertyDecl = newPropertyDecl.WithAttributeLists(
newPropertyDecl.AttributeLists.Add(
SyntaxFactory.AttributeList( SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.Attribute(
SyntaxFactory.QualifiedName( SyntaxFactory.AliasQualifiedName(
SyntaxFactory.IdentifierName( SyntaxFactory.Token( SyntaxKind.GlobalKeyword ) ),
SyntaxFactory.IdentifierName( "Sandbox" ) ),
SyntaxFactory.IdentifierName( "PropertyAccessorBodyChangeAttribute" )
),
SyntaxFactory.AttributeArgumentList( SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.AttributeArgument( SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.AliasQualifiedName(
SyntaxFactory.IdentifierName( SyntaxFactory.Token( SyntaxKind.GlobalKeyword ) ),
SyntaxFactory.IdentifierName( "Sandbox" ) ),
SyntaxFactory.IdentifierName( "PropertyAccessor" ) ),
SyntaxFactory.IdentifierName( accessor.ToString() ) ) ) ) )
)
) )
)
);
replacements[newPropertyDecl] = replacingPropertyDecl;
return true;
}
private enum PropertyAccessor
{
Get,
Set
}
private static bool TryGetPropertyDeclarationFromBlock( SyntaxNode block, out PropertyDeclarationSyntax outPropDecl, out PropertyAccessor accessor )
{
if ( block.Parent is PropertyDeclarationSyntax propDecl )
{
outPropDecl = propDecl;
accessor = PropertyAccessor.Get;
return true;
}
if ( block.Parent is AccessorDeclarationSyntax accDecl && accDecl.Parent is AccessorListSyntax accList )
{
outPropDecl = accList.Parent as PropertyDeclarationSyntax;
accessor = accDecl.Keyword.Text == "get" ? PropertyAccessor.Get : PropertyAccessor.Set;
return outPropDecl != null;
}
outPropDecl = null;
accessor = default;
return false;
}
/// <summary>
/// Compare lists of syntax trees, and log any changes. Includes the syntax node path to each change.
/// </summary>
/// <param name="oldTrees">Syntax trees from a previous compilation</param>
/// <param name="newTrees">New syntax trees to compare</param>
public static string GetChanges( SyntaxTree oldTree, SyntaxTree newTree )
{
var changes = newTree.GetChanges( oldTree );
if ( changes.Count == 0 )
{
return null;
}
if ( !oldTree.TryGetRoot( out var oldRoot ) )
{
return null;
}
var writer = new StringWriter();
foreach ( var change in changes )
{
var node = oldRoot.FindNode( change.Span, getInnermostNodeForTie: true );
var isTrivia = !node.Span.OverlapsWith( change.Span );
var startPosition = oldTree.GetLocation( change.Span ).GetLineSpan().StartLinePosition;
writer.WriteLine( $"{FormatNodePath( node )}{(isTrivia ? " (trivia)" : "")}" );
var oldLines = oldRoot.GetText().ToString( change.Span ).Split( '\n' );
var newLines = change.NewText?.Split( '\n' ) ?? Array.Empty<string>();
if ( oldLines.Length + newLines.Length > 5 )
{
writer.WriteLine( $"REMOVED {oldLines.Length}, ADDED {newLines.Length} LINES" );
continue;
}
var offset = 0;
foreach ( var line in oldLines )
{
writer.WriteLine( $"LINE {(startPosition.Line + ++offset).ToString().PadRight( 4 )} --- {line}" );
}
offset = 0;
foreach ( var line in newLines )
{
writer.WriteLine( $"LINE {(startPosition.Line + ++offset).ToString().PadRight( 4 )} +++ {line}" );
}
}
return writer.ToString();
}
}