diff --git a/README.md b/README.md index 30cb05a6..1541039c 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Mark computed properties and methods with `[Expressive]` to generate companion e | Tuples, index/range, `with`, collection expressions | And more modern C# syntax | | Expression transformers | Built-in + custom `IExpressionTreeTransformer` pipeline | | SQL window functions | ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST, SUM/AVG/COUNT/MIN/MAX OVER, LAG/LEAD, FIRST_VALUE/LAST_VALUE/NTH_VALUE with ROWS/RANGE frames (experimental) | +| Hot reload | Compatible with `dotnet watch` — edits to `[Expressive]` bodies propagate to generated expression trees | See the [full documentation](https://efnext.github.io/ExpressiveSharp/guide/introduction) for detailed usage, [reference](https://efnext.github.io/ExpressiveSharp/reference/expressive-attribute), and [recipes](https://efnext.github.io/ExpressiveSharp/recipes/computed-properties). diff --git a/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs b/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs index 2cce32b9..30c0a5bc 100644 --- a/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs +++ b/src/ExpressiveSharp.Generator/Registry/ExpressionRegistryEmitter.cs @@ -145,12 +145,16 @@ private static void WriteRegistryEntryStatement(IndentedTextWriter writer, Expre } /// - /// Emits the _map field that lazily builds the registry once at class-load time: - /// private static readonly Dictionary<nint, LambdaExpression> _map = Build(); + /// Emits the _map field plus a ResetMap entry point used by the hot-reload + /// handler to rebuild the map after a metadata update has patched the factory-method IL. + /// volatile ensures the new + /// is safely published to concurrent readers on weak-memory architectures. /// private static void EmitMapField(IndentedTextWriter writer) { - writer.WriteLine("private static readonly Dictionary _map = Build();"); + writer.WriteLine("private static volatile Dictionary _map = Build();"); + writer.WriteLine(); + writer.WriteLine("internal static void ResetMap() => _map = Build();"); } /// diff --git a/src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs b/src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs new file mode 100644 index 00000000..cb491659 --- /dev/null +++ b/src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Metadata; + +[assembly: MetadataUpdateHandler(typeof(ExpressiveSharp.Services.ExpressiveHotReloadHandler))] + +namespace ExpressiveSharp.Services; + +internal static class ExpressiveHotReloadHandler +{ + public static void ClearCache(Type[]? updatedTypes) + { + ResetGeneratedRegistries(SelectAffectedAssemblies(updatedTypes)); + ExpressiveResolver.ClearCachesForMetadataUpdate(); + ExpressiveReplacer.ClearCachesForMetadataUpdate(); + } + + public static void UpdateApplication(Type[]? updatedTypes) => ClearCache(updatedTypes); + + /// + /// When the runtime tells us which types changed, use their assemblies directly. + /// Fall back to a full scan only when is null or empty, + /// which the runtime may do for large/unknown change sets. + /// + private static IEnumerable SelectAffectedAssemblies(Type[]? updatedTypes) + { + if (updatedTypes is { Length: > 0 }) + { + var set = new HashSet(); + foreach (var t in updatedTypes) + { + if (t is not null) set.Add(t.Assembly); + } + return set; + } + + return AppDomain.CurrentDomain.GetAssemblies(); + } + + /// + /// Invokes ResetMap() on each assembly's generated ExpressionRegistry class + /// (when present) so the next TryGet rebuilds LambdaExpression instances + /// from the hot-reloaded factory IL. + /// + private static void ResetGeneratedRegistries(IEnumerable assemblies) + { + foreach (var assembly in assemblies) + { + if (assembly.IsDynamic) continue; + + Type? registryType; + try + { + registryType = assembly.GetType("ExpressiveSharp.Generated.ExpressionRegistry", throwOnError: false); + } + catch + { + continue; + } + + var reset = registryType?.GetMethod("ResetMap", BindingFlags.Static | BindingFlags.NonPublic); + if (reset is null) continue; + + try { reset.Invoke(null, null); } + catch { /* best-effort; stale registry stays stale */ } + } + } +} diff --git a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs index 2674981e..58b6a278 100644 --- a/src/ExpressiveSharp/Services/ExpressiveReplacer.cs +++ b/src/ExpressiveSharp/Services/ExpressiveReplacer.cs @@ -21,6 +21,8 @@ public class ExpressiveReplacer : ExpressionVisitor private static readonly ConditionalWeakTable> _compilerGeneratedClosureCache = new(); + internal static void ClearCachesForMetadataUpdate() => _compilerGeneratedClosureCache.Clear(); + public ExpressiveReplacer(IExpressiveResolver resolver) { _resolver = resolver; diff --git a/src/ExpressiveSharp/Services/ExpressiveResolver.cs b/src/ExpressiveSharp/Services/ExpressiveResolver.cs index ce5f4d7f..27e2c345 100644 --- a/src/ExpressiveSharp/Services/ExpressiveResolver.cs +++ b/src/ExpressiveSharp/Services/ExpressiveResolver.cs @@ -35,6 +35,24 @@ internal static void ResetAllCaches() _assemblyScanFilter = null; } + /// + /// Invalidates cached expression trees so the next lookup rebuilds from the (possibly + /// hot-reloaded) generated factory method. Called from . + /// Preserves _assemblyScanFilter and _typeNameCache — neither goes stale on + /// non-rude edits, and wiping the filter would silently disable a user-configured restriction. + /// + internal static void ClearCachesForMetadataUpdate() + { + _expressionCache.Clear(); + _reflectionCache.Clear(); + _assemblyRegistries.Clear(); + Volatile.Write(ref _lastScannedAssemblyCount, 0); + } + + internal static bool IsExpressionCached(MemberInfo mi) => _expressionCache.ContainsKey(mi); + + internal static Func? GetAssemblyScanFilter() => _assemblyScanFilter; + private static Func? _assemblyScanFilter; /// diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt index 93453bac..8fe54561 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MethodOverloads_BothRegistered.verified.txt @@ -22,7 +22,9 @@ namespace ExpressiveSharp.Generated return map; } - private static readonly Dictionary _map = Build(); + private static volatile Dictionary _map = Build(); + + internal static void ResetMap() => _map = Build(); public static LambdaExpression TryGet(MemberInfo member) { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt index 2a77d60a..64aab713 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.MultipleExpressives_AllRegistered.verified.txt @@ -22,7 +22,9 @@ namespace ExpressiveSharp.Generated return map; } - private static readonly Dictionary _map = Build(); + private static volatile Dictionary _map = Build(); + + internal static void ResetMap() => _map = Build(); public static LambdaExpression TryGet(MemberInfo member) { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt index 804c39ad..3ff6151d 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleMethod_RegistryContainsEntry.verified.txt @@ -21,7 +21,9 @@ namespace ExpressiveSharp.Generated return map; } - private static readonly Dictionary _map = Build(); + private static volatile Dictionary _map = Build(); + + internal static void ResetMap() => _map = Build(); public static LambdaExpression TryGet(MemberInfo member) { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt index 5126e389..56bdc827 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/RegistryTests.SingleProperty_RegistryContainsEntry.verified.txt @@ -21,7 +21,9 @@ namespace ExpressiveSharp.Generated return map; } - private static readonly Dictionary _map = Build(); + private static volatile Dictionary _map = Build(); + + internal static void ResetMap() => _map = Build(); public static LambdaExpression TryGet(MemberInfo member) { diff --git a/tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs b/tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs new file mode 100644 index 00000000..d1149146 --- /dev/null +++ b/tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs @@ -0,0 +1,95 @@ +using System.Reflection; +using System.Reflection.Metadata; +using ExpressiveSharp.Services; +using ExpressiveSharp.Tests.TestFixtures; + +namespace ExpressiveSharp.Tests.Services; + +[TestClass] +public class ExpressiveHotReloadHandlerTests +{ + [TestMethod] + public void ClearCache_AfterResolve_RemovesMemberFromCache() + { + var mi = typeof(Product).GetProperty(nameof(Product.Total))!; + var resolver = new ExpressiveResolver(); + + _ = resolver.FindGeneratedExpression(mi); + Assert.IsTrue(ExpressiveResolver.IsExpressionCached(mi)); + + ExpressiveHotReloadHandler.ClearCache(null); + + Assert.IsFalse(ExpressiveResolver.IsExpressionCached(mi)); + } + + [TestMethod] + public void ClearCache_PreservesAssemblyScanFilter() + { + var sentinel = new Func(_ => true); + ExpressiveResolver.SetAssemblyScanFilter(sentinel); + try + { + ExpressiveHotReloadHandler.ClearCache(null); + + Assert.AreSame(sentinel, ExpressiveResolver.GetAssemblyScanFilter()); + } + finally + { + ExpressiveResolver.SetAssemblyScanFilter(null); + } + } + + [TestMethod] + public void ClearCache_RebuildReturnsEquivalentExpression() + { + var mi = typeof(Product).GetProperty(nameof(Product.Total))!; + var resolver = new ExpressiveResolver(); + + var before = resolver.FindGeneratedExpression(mi).ToString(); + + ExpressiveHotReloadHandler.ClearCache(null); + + var after = resolver.FindGeneratedExpression(mi).ToString(); + + Assert.AreEqual(before, after); + } + + [TestMethod] + public void ClearCache_WithNullAndEmptyAndPopulatedArrays_DoesNotThrow() + { + ExpressiveHotReloadHandler.ClearCache(null); + ExpressiveHotReloadHandler.ClearCache([]); + ExpressiveHotReloadHandler.ClearCache([typeof(Product)]); + } + + [TestMethod] + public void ClearCache_WithUpdatedTypes_ClearsResolverCache() + { + var mi = typeof(Product).GetProperty(nameof(Product.Total))!; + var resolver = new ExpressiveResolver(); + + _ = resolver.FindGeneratedExpression(mi); + Assert.IsTrue(ExpressiveResolver.IsExpressionCached(mi)); + + ExpressiveHotReloadHandler.ClearCache([typeof(Product)]); + + Assert.IsFalse(ExpressiveResolver.IsExpressionCached(mi)); + } + + [TestMethod] + public void UpdateApplication_WithNull_DoesNotThrow() + { + ExpressiveHotReloadHandler.UpdateApplication(null); + } + + [TestMethod] + public void Assembly_RegistersExpressiveHotReloadHandler() + { + var attributes = typeof(ExpressiveResolver).Assembly + .GetCustomAttributes() + .ToList(); + + Assert.IsTrue(attributes.Any(a => a.HandlerType == typeof(ExpressiveHotReloadHandler)), + "MetadataUpdateHandlerAttribute for ExpressiveHotReloadHandler not found on ExpressiveSharp assembly."); + } +}