diff --git a/AGENTS.md b/AGENTS.md index 3ea03d1..c5fabe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ The same package bundles Roslyn tooling: an incremental source generator that em * Never break the public surface. Deprecate a renamed member with `[Obsolete]` with a JetBrains `[CodeTemplate]` auto-conversion; the obsolete member forwards to the replacement and never throws or changes behavior. * Mark a member Revit invokes but consumers must not call `[EditorBrowsable(EditorBrowsableState.Never)]`. * The Roslyn tooling is public surface. It targets `netstandard2.0`, dual-targets Roslyn (the default with the `.Roslyn414` twins), uses stable `RVTTK####` diagnostic ids, and needs an `AnalyzerReleases.Unshipped.md` entry per new or changed rule. Never rename a generated member or renumber a shipped id. +* The `AddInAssemblyGenerator` runs in every project with project references. Its output compiles with C# 7.3 and declares its own attribute type. The runtime matches `Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute` by full name: never rename it. +* The MSBuild targets in `source/Nice3point.Revit.Toolkit/build` are public surface. Their targets, items, and properties take the `_RevitToolkit` prefix. * Every type compiles under every supported configuration. * A change ships with a test covering the toolkit's custom logic: Revit behavior runs on the Revit thread, and the analyzers, fixers, and generator run against the Roslyn testing harness. * Confirm an unfamiliar Revit or .NET API before use through official docs or `gh` (`gh api`, `gh search code`). @@ -20,7 +22,7 @@ The same package bundles Roslyn tooling: an incremental source generator that em ## Repository map -* `source/Nice3point.Revit.Toolkit` it packs the runtime library and the tooling into one NuGet package for users. +* `source/Nice3point.Revit.Toolkit` it packs the runtime library and the tooling into one NuGet package for users. Its `build/` folder contains the MSBuild targets NuGet imports into a consumer project. * `source/Nice3point.Revit.Toolkit.Analyzers`, `Nice3point.Revit.Toolkit.Analyzers.CodeFixers`, and `Nice3point.Revit.Toolkit.SourceGenerators` hold the analyzers, code fixers, and source generator, each with a `.Roslyn414` twin for the older Roslyn. * `tests/` — `Nice3point.Revit.Toolkit.Tests` runs inside a Revit process; the analyzer, fixer, and generator test projects run against the compiler. * `build/` — the ModularPipelines build. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0239b86..27ed88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 2027.0.4 + +### External Events + +- External events always run as your add-in, even when created on a background thread or in a modeless window. Inside the handler, `ActiveAddInId` returns your add-in, and Extensible Storage schemas with vendor access accept the changes. Previously, Revit ran such a handler without an add-in. +- `ExternalApplication` and `ExternalCommand` set this up automatically, for the add-in project and every project it references. +- New `RegisterAddInAssembly()` method for the remaining cases: an `IExternalApplication` without the Toolkit base class, an assembly loaded at run time, or a library from a NuGet package. Call it in `OnStartup`: + +```c# +public Result OnStartup(UIControlledApplication application) +{ + application.RegisterAddInAssembly(typeof(Application).Assembly); + return Result.Succeeded; +} +``` + # 2027.0.3 ### External Events diff --git a/Directory.Packages.props b/Directory.Packages.props index 5d641ef..71811c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,13 +23,13 @@ - - - - - - - + + + + + + + @@ -44,7 +44,7 @@ - + diff --git a/README.md b/README.md index 79241ce..a0c1f3c 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,15 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs. `Raise(argument)` returns that result to the caller. The `RaiseAsync()` task fails with an `InvalidOperationException`. +Inside the event handler, `UIApplication.ActiveAddInId` returns the ID of your add-in, even when the event is created on a background thread or in a modeless window. +Extensible Storage schemas with vendor write access accept the changes of the handler. +`ExternalApplication` and `ExternalCommand` set this up automatically. +Without them, or for a library from a NuGet package or loaded at run time, register the assembly in `OnStartup`: + +```c# +application.RegisterAddInAssembly(typeof(Application).Assembly); +``` + #### ExternalEvent A synchronous external event that queues the handler via the Revit external event mechanism. diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs new file mode 100644 index 0000000..ea20b6e --- /dev/null +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs @@ -0,0 +1,58 @@ +using System.Text; +using Microsoft.CodeAnalysis.CSharp; +using Nice3point.Revit.Toolkit.SourceGenerators.Models; + +namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; + +/// +/// Writes the assembly attributes and the internal attribute type for the listed project references. +/// +/// +/// Each assembly declares its own attribute type. +/// The attributes resolve whichever toolkit version Revit loads into the shared context. +/// The source compiles with C# 7.3, the default language version of a .NET Framework project. +/// +internal static class AddInAssemblyWriter +{ + public const string HintName = "AddInAssemblies.g.cs"; + + private const string GeneratorName = "Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblyGenerator"; + private const string AttributeNamespace = "Nice3point.Revit.Toolkit.Generated"; + private const string AttributeName = "AddInAssemblyAttribute"; + + private static readonly string AssemblyVersion = typeof(AddInAssemblyGenerator).Assembly.GetName().Version?.ToString() ?? "1.0.0"; + + public static string GenerateSource(EquatableArray assemblyNames) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#pragma warning disable"); + builder.AppendLine(); + + foreach (var assemblyName in assemblyNames) + { + builder.AppendLine($"[assembly: global::{AttributeNamespace}.{AttributeName}({SymbolDisplay.FormatLiteral(assemblyName, quote: true)})]"); + } + + builder.AppendLine($$""" + + namespace {{AttributeNamespace}} + { + [global::System.CodeDom.Compiler.GeneratedCode("{{GeneratorName}}", "{{AssemblyVersion}}")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, AllowMultiple = true)] + internal sealed class {{AttributeName}} : global::System.Attribute + { + public {{AttributeName}}(string assemblyName) + { + AssemblyName = assemblyName; + } + + public string AssemblyName { get; } + } + } + """); + + return builder.ToString(); + } +} diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs new file mode 100644 index 0000000..9ae3e7f --- /dev/null +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs @@ -0,0 +1,33 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Diagnostics; +using Nice3point.Revit.Toolkit.SourceGenerators.Models; + +namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; + +/// +/// Reads the project references listed by the package targets. +/// +internal static class AddInProjectReferences +{ + private const string PropertyName = "build_property._RevitToolkitAddInProjectReferences"; + + /// + /// Reads the assembly names of the project references from the global analyzer config options. + /// + /// The global analyzer config options of the compilation. + /// The distinct assembly names in ordinal order, or an empty array for a project without project references. + public static EquatableArray Read(AnalyzerConfigOptions globalOptions) + { + if (!globalOptions.TryGetValue(PropertyName, out var value)) + { + return ImmutableArray.Empty; + } + + return value.Split(',') + .Select(static assemblyName => assemblyName.Trim()) + .Where(static assemblyName => assemblyName.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static assemblyName => assemblyName, StringComparer.Ordinal) + .ToImmutableArray(); + } +} diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs new file mode 100644 index 0000000..9ee9bc1 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs @@ -0,0 +1,35 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; + +namespace Nice3point.Revit.Toolkit.SourceGenerators; + +/// +/// Incremental source generator that declares an [assembly: AddInAssembly] attribute for each project reference. +/// +/// +/// The package targets list the project references in a build property, transitive ones included. +/// A project without project references generates no source. +/// +[Generator(LanguageNames.CSharp)] +public sealed class AddInAssemblyGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var assemblyNames = context.AnalyzerConfigOptionsProvider + .Select(static (optionsProvider, _) => AddInProjectReferences.Read(optionsProvider.GlobalOptions)); + + context.RegisterSourceOutput(assemblyNames, static (sourceProductionContext, projectReferences) => + { + if (projectReferences.IsEmpty) + { + return; + } + + var generatedSource = AddInAssemblyWriter.GenerateSource(projectReferences); + sourceProductionContext.AddSource(AddInAssemblyWriter.HintName, SourceText.From(generatedSource, Encoding.UTF8)); + }); + } +} diff --git a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs index 4226c1c..0bb7ce9 100644 --- a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs +++ b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using Autodesk.Revit.UI; using Nice3point.Revit.Toolkit.Helpers; +using Nice3point.Revit.Toolkit.Internal; #if NET using System.Runtime.Loader; #endif @@ -46,7 +47,9 @@ public Result OnStartup(UIControlledApplication application) Application = application; var currentType = GetType(); + AddInContext.Capture(currentType.Assembly); #if NET + if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -59,6 +62,7 @@ public Result OnStartup(UIControlledApplication application) OnStartup(); } #else + using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { OnStartup(); diff --git a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs index 7700b29..007f2ce 100644 --- a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs +++ b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using Autodesk.Revit.UI; using Nice3point.Revit.Toolkit.Helpers; +using Nice3point.Revit.Toolkit.Internal; #if NET using System.Runtime.Loader; #endif @@ -129,7 +130,9 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme JournalData = commandData.JournalData; var currentType = GetType(); + AddInContext.Capture(currentType.Assembly); #if NET + if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -142,6 +145,7 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme Execute(); } #else + using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { Execute(); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs new file mode 100644 index 0000000..b8c909b --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using Autodesk.Revit.UI; +using Nice3point.Revit.Toolkit.Internal; + +// ReSharper disable once CheckNamespace +namespace Nice3point.Revit.Toolkit.External; + +/// +/// Provides extension methods for and to register add-in assemblies. +/// +[PublicAPI] +public static class AddInAssemblyExtensions +{ + /// + /// Defines extensions for . + /// + /// The application passed to the add-in at startup. + extension(UIControlledApplication application) + { + /// + /// Registers the assembly as part of the active add-in. + /// + /// The assembly with external event handlers of the add-in. + /// is . + /// The method is called outside a Revit callback. + /// + /// External events of the assembly run as the active add-in on any thread. + /// Project references of the assembly are registered too. + /// and register their assembly automatically. + /// An external event created before the registration runs as the add-in Revit bound it to at creation. + /// On a Revit version without the expected native API, the method does nothing. + /// + public void RegisterAddInAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + AddInContext.Register(assembly); + } + } + + /// + /// Defines extensions for . + /// + /// The application passed to a command or an event handler. + extension(UIApplication application) + { + /// + public void RegisterAddInAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + AddInContext.Register(assembly); + } + } +} diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs index 5ea18a3..d5a52db 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs @@ -28,7 +28,7 @@ public AsyncExternalEvent(Action handler) : this(handler, ExternalEventOptions.N /// The execution logic. /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -56,7 +56,7 @@ public AsyncExternalEvent(Action handler) : this(handler, Externa /// The execution logic that receives the current . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -84,7 +84,7 @@ public AsyncExternalEvent(Action handler) : th /// The execution logic that receives the current and the token passed to . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs index 8aef493..507201b 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs @@ -31,7 +31,7 @@ public AsyncExternalEvent(Action handler) : this(handler, ExternalEventOption /// The execution logic that receives an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -59,7 +59,7 @@ public AsyncExternalEvent(Action handler) : this(handler, Exte /// The execution logic that receives the current and an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -87,7 +87,7 @@ public AsyncExternalEvent(Action handler) : /// The execution logic that receives the current , an argument of type , and the token passed to . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs index e6c2484..49ebea3 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs @@ -32,7 +32,7 @@ public AsyncRequestExternalEvent(Func handler) : this(handler, Exter /// The execution logic that receives an argument of type and returns a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -55,7 +55,7 @@ public AsyncRequestExternalEvent(Func handler) : this /// The execution logic that receives the current and an argument of type , returning a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -78,7 +78,7 @@ public AsyncRequestExternalEvent(FuncThe execution logic that receives the current , an argument of type , and the token passed to , returning a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs index 356d89c..9c1a7bf 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs @@ -29,7 +29,7 @@ public AsyncRequestExternalEvent(Func handler) : this(handler, External /// The execution logic that returns a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -52,7 +52,7 @@ public AsyncRequestExternalEvent(Func handler) : this(ha /// The execution logic that receives the current and returns a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -75,7 +75,7 @@ public AsyncRequestExternalEvent(Func /// The execution logic that receives the current and the token passed to , returning a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs index a8f82d6..875cc9b 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs @@ -21,7 +21,7 @@ public class ExternalEvent : ExternalEventHandler, IExternalEvent /// /// The execution logic. /// Thrown if is . - public ExternalEvent(Action handler) + public ExternalEvent(Action handler) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -34,7 +34,7 @@ public ExternalEvent(Action handler) /// The execution logic. /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -47,7 +47,7 @@ public ExternalEvent(Action handler, ExternalEventOptions options) /// /// The execution logic that receives the current . /// Thrown if is . - public ExternalEvent(Action handler) + public ExternalEvent(Action handler) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -60,7 +60,7 @@ public ExternalEvent(Action handler) /// The execution logic that receives the current . /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs index a958244..3a00185 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs @@ -1,12 +1,18 @@ using Autodesk.Revit.UI; +using Nice3point.Revit.Toolkit.Internal; // ReSharper disable once CheckNamespace namespace Nice3point.Revit.Toolkit.External; /// -/// A base class wrapping the Revit and -/// boilerplate to simplify external event handler implementation. +/// A base class wrapping the Revit and boilerplate to simplify external event handler implementation. /// +/// +/// The handler runs as the add-in that declares it, on any thread the event is created on. +/// and register their assembly with its project references automatically. +/// An assembly that creates an external event inside a Revit callback registers itself. +/// Other assemblies, for example a library from a NuGet package, are registered with . +/// [PublicAPI] public abstract class ExternalEventHandler : IExternalEventHandler { @@ -14,14 +20,27 @@ public abstract class ExternalEventHandler : IExternalEventHandler private string? _identifier; /// - /// Initializes a new instance of the class - /// and creates the underlying Revit external event. + /// Initializes a new instance of the class. /// - protected ExternalEventHandler() + /// + /// The constructor creates the underlying Revit external event, bound to the add-in of the assembly of the derived type. + /// + protected ExternalEventHandler() : this(handler: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The delegate that implements the execution logic, or when the derived type implements it. + /// + /// The constructor creates the underlying Revit external event, bound to the add-in of the handler assembly. + /// + private protected ExternalEventHandler(Delegate? handler) { using (RevitContext.BeginApiContextScope()) { - _externalEvent = Autodesk.Revit.UI.ExternalEvent.Create(this); + _externalEvent = AddInContext.CreateExternalEvent(this, handler); } } diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs index 68b48c5..823719f 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs @@ -31,7 +31,7 @@ public ExternalEvent(Action handler) : this(handler, ExternalEventOptions.Non /// The execution logic that receives an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -54,7 +54,7 @@ public ExternalEvent(Action handler) : this(handler, ExternalE /// The execution logic that receives the current and an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs new file mode 100644 index 0000000..0b58e21 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -0,0 +1,380 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Autodesk.Revit.UI; +#if NET +using System.Runtime.Loader; +#endif + +namespace Nice3point.Revit.Toolkit.Internal; + +/// +/// Provides the captured add-in of the handler assembly to an external event created outside a Revit callback. +/// +/// +/// Revit binds an external event to the add-in on top of the add-in data stack of the creating thread. +/// The stack is thread-local and empty outside a Revit callback, where Revit binds the event to no add-in. +/// A native AddInDataScope pushes a copy of the captured add-in data onto the stack of the current thread for the creation of the event. +/// A shared toolkit binds the assemblies listed by the generated AddInAssemblyAttribute to the add-in of the declaring assembly. +/// A listed assembly is matched by name at its first external event. +/// The context never frees a published copy of the add-in data. +/// A Revit version without a member of the expected signature creates every event without a scope. +/// +internal static unsafe class AddInContext +{ + private static readonly bool IsSupported; + private static readonly bool IsShared; + private static readonly int AddInDataSize; + private static readonly int AddInDataScopeSize; + private static readonly delegate* GetAddInDataStack; + private static readonly delegate* IsAddInDataStackEmpty; + private static readonly delegate* GetActiveAddInData; + private static readonly delegate* EnterAddInDataScope; + private static readonly delegate* ExitAddInDataScope; + + private const string AddInAssemblyAttributeName = "Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute"; + + private static readonly Assembly ToolkitAssembly = typeof(AddInContext).Assembly; + private static readonly ConditionalWeakTable> CapturedAddIns = new(); + private static readonly ConditionalWeakTable>.CreateValueCallback CreateEmptyCapture = static _ => new StrongBox(); + private static readonly Lock ListedAddInAssembliesLock = new(); + private static readonly ListedAddInAssemblies ListedAddInAssemblies = new(); + + static AddInContext() + { +#if NET + // Add-ins without dependency isolation share the toolkit loaded into the default AssemblyLoadContext. + IsShared = AssemblyLoadContext.GetLoadContext(ToolkitAssembly) == AssemblyLoadContext.Default; +#else + IsShared = true; +#endif + + try + { + var uiAssembly = typeof(UIApplication).Assembly; + var foundationReference = uiAssembly.GetReferencedAssemblies().First(static reference => reference.Name == "RevitAPIFoundation"); + var uiModule = uiAssembly.ManifestModule; + var foundationModule = Assembly.Load(foundationReference).ManifestModule; + + var addInDataType = uiModule.GetType("AddInData", throwOnError: true, ignoreCase: false)!; + var addInDataScopeType = uiModule.GetType("AddInDataScope", throwOnError: true, ignoreCase: false)!; + var addInDataStackType = foundationModule.GetType("ArrStack", throwOnError: true, ignoreCase: false)!; + var addInDataPointerType = addInDataType.MakePointerType(); + var addInDataScopePointerType = addInDataScopeType.MakePointerType(); + + GetAddInDataStack = (delegate*)GetGlobalFunction(foundationModule, "getAddInDataStack"); + IsAddInDataStackEmpty = (delegate*)GetGlobalFunction(foundationModule, "ArrStack.empty", addInDataStackType.MakePointerType()); + GetActiveAddInData = (delegate*)GetGlobalFunction(uiModule, "getActiveAddInData", addInDataPointerType); + EnterAddInDataScope = (delegate*)GetGlobalFunction(uiModule, "AddInDataScope.{ctor}", addInDataScopePointerType, addInDataPointerType); + ExitAddInDataScope = (delegate*)GetGlobalFunction(uiModule, "AddInDataScope.{dtor}", addInDataScopePointerType); + + // The native sizes differ between Revit versions. + AddInDataSize = addInDataType.StructLayoutAttribute?.Size ?? 0; + AddInDataScopeSize = addInDataScopeType.StructLayoutAttribute?.Size ?? 0; + IsSupported = AddInDataSize > 0 && AddInDataScopeSize > 0; + } + catch (Exception) + { + IsSupported = false; + } + } + + /// + /// Captures the add-in of the current Revit callback as the add-in of the assembly. + /// + /// The assembly that declares the callback. + /// + /// A call for an assembly with a captured add-in returns immediately. + /// Outside a Revit callback, the call captures nothing. + /// + internal static void Capture(Assembly assembly) + { + if (!IsSupported) + { + return; + } + + CaptureAddIn(IsShared ? assembly : ToolkitAssembly); + } + + /// + /// Captures the add-in of the current Revit callback for the assembly, replacing an earlier capture. + /// + /// The assembly to capture the add-in for. + /// The current thread runs outside a Revit callback. + /// + /// The assemblies listed by the generated AddInAssemblyAttribute receive the same add-in. + /// + internal static void Register(Assembly assembly) + { + if (!IsSupported) + { + return; + } + + if (!IsAddInActive()) + { + throw new InvalidOperationException("No add-in is active on the current thread. Call the method in OnStartup or in the Execute method of a command."); + } + + var owner = IsShared ? assembly : ToolkitAssembly; + var addInData = CopyActiveAddInData(); + + // A concurrent event can read the replaced copy, and the context never frees it. + var capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture); + Volatile.Write(ref capturedAddIn.Value, addInData); + + ListAddInAssemblies(owner, addInData, replaceListedNames: true); + } + + /// + /// Creates the Revit external event of the event handler, bound to the add-in of the handler assembly. + /// + /// The external event handler. + /// The delegate that implements the execution logic, or when the type of implements it. + /// The Revit external event. + /// + /// The handler assembly is the assembly of , or of the type of . + /// Inside a Revit callback, Revit binds the event to the active add-in. + /// The call captures the active add-in for the handler assembly. + /// Outside a Revit callback, the call binds the event to the captured add-in of the handler assembly. + /// + internal static ExternalEvent CreateExternalEvent(IExternalEventHandler eventHandler, Delegate? handler) + { + if (!IsSupported) + { + return ExternalEvent.Create(eventHandler); + } + + var owner = FindOwner(eventHandler, handler); + if (IsAddInActive()) + { + CaptureAddIn(owner); + return ExternalEvent.Create(eventHandler); + } + + if (!TryGetCapturedAddIn(owner, out var addInData)) + { + return ExternalEvent.Create(eventHandler); + } + + // The native scope stores its state in the buffer, and the destructor reads it back. + var scope = stackalloc byte[AddInDataScopeSize]; + EnterAddInDataScope(scope, (void*)addInData); + try + { + return ExternalEvent.Create(eventHandler); + } + finally + { + ExitAddInDataScope(scope); + } + } + + /// + /// Captures the active add-in for the assembly without a captured add-in. + /// + /// The assembly to store the add-in under. + /// + /// The first copy published by concurrent captures wins. + /// The method frees a copy that loses. + /// + private static void CaptureAddIn(Assembly owner) + { + if (CapturedAddIns.TryGetValue(owner, out var capturedAddIn) && Volatile.Read(ref capturedAddIn.Value) != IntPtr.Zero) + { + return; + } + + if (!IsAddInActive()) + { + return; + } + + capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture); + var addInData = CopyActiveAddInData(); + if (Interlocked.CompareExchange(ref capturedAddIn.Value, addInData, IntPtr.Zero) != IntPtr.Zero) + { + Marshal.FreeHGlobal(addInData); + return; + } + + ListAddInAssemblies(owner, addInData, replaceListedNames: false); + } + + /// + /// Copies the active add-in data into a new native buffer. + /// + /// The pointer to the copy. + private static nint CopyActiveAddInData() + { + var addInData = Marshal.AllocHGlobal(AddInDataSize); + GetActiveAddInData((void*)addInData); + return addInData; + } + + /// + /// Lists the assemblies named by the generated AddInAssemblyAttribute of the assembly under its add-in. + /// + /// The assembly that declares the attributes. + /// The pointer to the add-in data of the assembly. + /// to overwrite a name listed by another assembly; otherwise, . + /// + /// An isolated toolkit lists no names. + /// + private static void ListAddInAssemblies(Assembly owner, nint addInData, bool replaceListedNames) + { + if (!IsShared || !IsInDefaultContext(owner)) + { + return; + } + + var assemblyNames = ReadListedAssemblyNames(owner); + if (assemblyNames.Length == 0) + { + return; + } + + lock (ListedAddInAssembliesLock) + { + ListedAddInAssemblies.Add(assemblyNames, addInData, replaceListedNames); + } + } + + /// + /// Reads the assembly names listed by the generated AddInAssemblyAttribute of the assembly. + /// + /// The assembly to read the attributes of. + /// The listed assembly names, or an empty array when the attributes can't be read. + /// + /// The source generator declares the attribute type in each assembly, and the method matches it by full name. + /// An attribute type from a missing assembly fails the read. + /// A failed read lists no names: the capture never fails the startup of an add-in. + /// + private static string[] ReadListedAssemblyNames(Assembly assembly) + { + try + { + return assembly.GetCustomAttributesData() + .Where(static attribute => attribute.AttributeType.FullName == AddInAssemblyAttributeName) + .Select(static attribute => attribute.ConstructorArguments[0].Value) + .OfType() + .ToArray(); + } + catch (Exception) + { + return []; + } + } + + /// + /// Determines whether the assembly is loaded into the default AssemblyLoadContext. + /// + /// The assembly to check. + /// if the assembly is loaded into the default context; otherwise, . + /// + /// A shared toolkit matches names for the default context only. + /// An isolated add-in can load its own assembly with a listed name. + /// + private static bool IsInDefaultContext(Assembly assembly) + { +#if NET + return AssemblyLoadContext.GetLoadContext(assembly) == AssemblyLoadContext.Default; +#else + return true; +#endif + } + + /// + /// Gets the captured add-in data of the assembly. + /// + /// The assembly the captured add-in is stored under. + /// When this method returns, contains the pointer to the captured add-in data, if found. + /// if the assembly has a captured add-in; otherwise, . + /// + /// On a miss, the method looks up the assembly name among the listed names. + /// A match becomes the capture of the assembly, unless a concurrent capture publishes first. + /// + private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) + { + if (CapturedAddIns.TryGetValue(owner, out var capturedAddIn)) + { + addInData = Volatile.Read(ref capturedAddIn.Value); + if (addInData != 0) + { + return true; + } + } + + addInData = 0; + if (!IsShared || !IsInDefaultContext(owner)) + { + return false; + } + + nint listedAddInData; + lock (ListedAddInAssembliesLock) + { + if (!ListedAddInAssemblies.TryMatch(owner, out listedAddInData)) + { + return false; + } + } + + capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture); + Interlocked.CompareExchange(ref capturedAddIn.Value, listedAddInData, IntPtr.Zero); + addInData = Volatile.Read(ref capturedAddIn.Value); + return true; + } + + /// + /// Determines whether the add-in data stack of the current thread contains an add-in. + /// + /// if the current thread runs inside a Revit callback of an add-in; otherwise, . + /// + /// Revit registers an external event for an add-in only when the add-in data stack contains one. + /// The active add-in data does not indicate an empty stack: it contains the null GUID before Revit 2027 and a fixed non-null GUID since. + /// + private static bool IsAddInActive() + { + return !IsAddInDataStackEmpty(GetAddInDataStack()); + } + + /// + /// Finds the assembly the captured add-in of the event handler is stored under. + /// + /// The external event handler. + /// The delegate that implements the execution logic, or when the type of implements it. + /// The handler assembly for a shared toolkit, or the toolkit assembly for an isolated toolkit. + /// + /// An isolated toolkit stores one add-in for every assembly under its own assembly. + /// The delegate target is the closure, the lambda cache, or the instance of the consumer code. + /// The target comes first: runs a reflection lookup and allocates on every new delegate instance. + /// + private static Assembly FindOwner(IExternalEventHandler eventHandler, Delegate? handler) + { + if (!IsShared) + { + return ToolkitAssembly; + } + + if (handler is null) + { + return eventHandler.GetType().Assembly; + } + + return handler.Target?.GetType().Assembly ?? handler.Method.Module.Assembly; + } + + private static nint GetGlobalFunction(Module module, string name, params Type[] parameterTypes) + { + var method = module.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static, binder: null, CallingConventions.Any, parameterTypes, modifiers: null); + if (method is null) + { + throw new MissingMethodException(module.Name, name); + } + + // The function pointer of a global method is the managed entry of its native call stub. + return method.MethodHandle.GetFunctionPointer(); + } +} diff --git a/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs new file mode 100644 index 0000000..eebed1d --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Nice3point.Revit.Toolkit.Internal; + +/// +/// Maps the assembly names listed by the generated AddInAssemblyAttribute to the add-in of the listing assembly. +/// +/// +/// Assembly names are compared case-insensitively, as the runtime compares simple names. +/// The type is not thread-safe. +/// +internal sealed class ListedAddInAssemblies +{ + private readonly Dictionary _addIns = new(StringComparer.OrdinalIgnoreCase); + private readonly ConditionalWeakTable> _unmatchedAssemblies = new(); + + /// + /// Lists the assembly names under the add-in. + /// + /// The simple names of the assemblies. + /// The pointer to the add-in data. + /// to overwrite a name listed earlier; otherwise, . + public void Add(IEnumerable assemblyNames, nint addInData, bool replaceListedNames) + { + foreach (var assemblyName in assemblyNames) + { + if (replaceListedNames || !_addIns.ContainsKey(assemblyName)) + { + _addIns[assemblyName] = addInData; + } + } + } + + /// + /// Finds the add-in listed for the name of the assembly. + /// + /// The assembly to match. + /// When this method returns, contains the pointer to the add-in data, if found. + /// if the name of the assembly is listed; otherwise, . + /// + /// A miss is cached until a call to lists a new name. + /// + public bool TryMatch(Assembly assembly, out nint addInData) + { + addInData = 0; + if (_unmatchedAssemblies.TryGetValue(assembly, out var listedNameCount) && listedNameCount.Value == _addIns.Count) + { + return false; + } + + var assemblyName = assembly.GetName().Name; + if (assemblyName is not null && _addIns.TryGetValue(assemblyName, out addInData)) + { + return true; + } + + _unmatchedAssemblies.Remove(assembly); + _unmatchedAssemblies.Add(assembly, new StrongBox(_addIns.Count)); + return false; + } +} diff --git a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj index 901a560..f0eb7b5 100644 --- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj +++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj @@ -56,6 +56,7 @@ + @@ -64,4 +65,4 @@ - \ No newline at end of file + diff --git a/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets new file mode 100644 index 0000000..c4fdc99 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets @@ -0,0 +1,17 @@ + + + + + + + + + + + <_RevitToolkitAddInProjectReferences>@(ReferencePath->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->'%(Filename)', ',') + + + + diff --git a/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs new file mode 100644 index 0000000..95478e2 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs @@ -0,0 +1,146 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Nice3point.Revit.Toolkit.SourceGenerators.Tests.Helpers; +using TUnit.Assertions.Enums; + +namespace Nice3point.Revit.Toolkit.SourceGenerators.Tests; + +public sealed class AddInAssemblyGeneratorTests +{ + private const string Source = "public class Model { }"; + private const string AttributeName = "Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute"; + + [Test] + public async Task ProjectReferences_DeclareAnAttributePerAssemblyAsync() + { + var (driver, output) = await RunAsync(Source, projectReferences: "MyAddIn.UI,MyAddIn.Core"); + + await Assert.That(driver.GetRunResult().GeneratedTrees).Count().IsEqualTo(1); + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core", "MyAddIn.UI"], CollectionOrdering.Matching); + } + + [Test] + [Arguments(" MyAddIn.Core , MyAddIn.Core,,")] + [Arguments("MyAddIn.Core,myaddin.core")] + [Arguments("MyAddIn.Core")] + public async Task RepeatedPaddedOrCaseVariantNames_DeclareEachAssemblyOnceAsync(string projectReferences) + { + var (_, output) = await RunAsync(Source, projectReferences); + + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core"]); + } + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" , ")] + public async Task NoProjectReferences_GeneratesNoSourceAsync(string? projectReferences) + { + var (driver, _) = await RunAsync(Source, projectReferences); + + await Assert.That(driver.GetRunResult().GeneratedTrees).IsEmpty(); + } + + [Test] + [Arguments(LanguageVersion.CSharp7_3)] + [Arguments(LanguageVersion.CSharp14)] + public async Task LanguageVersion_GeneratesCompilableSourceAsync(LanguageVersion languageVersion) + { + var (_, output) = await RunAsync(Source, projectReferences: "MyAddIn.Core", languageVersion: languageVersion); + + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core"]).Because("a .NET Framework project compiles with C# 7.3 by default"); + } + + [Test] + public async Task ReferenceWithVisibleInternals_DeclaresTheAttributeWithoutConflictsAsync() + { + const string librarySource = """[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Consumer")]"""; + var (_, library) = await RunAsync(librarySource, projectReferences: "MyAddIn.Core", assemblyName: "MyAddIn.Services"); + + var (_, output) = await RunAsync(Source, projectReferences: "MyAddIn.Services", additionalReference: library.ToMetadataReference()); + + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Services"]); + } + + [Test] + public async Task UnrelatedSourceChange_ReusesGeneratedSourceAsync() + { + var (driver, output) = await RunAsync(Source, projectReferences: "MyAddIn.Core"); + var previousSource = driver.GetRunResult().GeneratedTrees.Single().GetText().ToString(); + var unrelatedTree = CSharpSyntaxTree.ParseText("public class Unrelated { }", (CSharpParseOptions)output.SyntaxTrees.First().Options); + + var updated = driver.RunGenerators(output.RemoveSyntaxTrees(driver.GetRunResult().GeneratedTrees).AddSyntaxTrees(unrelatedTree), TestContext.Current!.Execution.CancellationToken); + var outputStep = updated.GetRunResult().Results.Single().TrackedOutputSteps.Single().Value.Single(); + + await Assert.That(updated.GetRunResult().GeneratedTrees.Single().GetText().ToString()).IsEqualTo(previousSource); + await Assert.That(outputStep.Outputs.Single().Reason).IsEqualTo(IncrementalStepRunReason.Cached); + } + + private static async Task<(GeneratorDriver Driver, Compilation Output)> RunAsync( + [StringSyntax("C#")] string source, + string? projectReferences, + string assemblyName = "Consumer", + LanguageVersion languageVersion = LanguageVersion.CSharp14, + MetadataReference? additionalReference = null) + { + var cancellationToken = TestContext.Current!.Execution.CancellationToken; + var parseOptions = new CSharpParseOptions(languageVersion); + var references = await ReferenceAssemblies.Net.Net80.ResolveAsync(LanguageNames.CSharp, cancellationToken); + var input = CSharpCompilation.Create(assemblyName, + [CSharpSyntaxTree.ParseText(source, parseOptions, cancellationToken: cancellationToken)], + additionalReference is null ? references : references.Add(additionalReference), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var globalOptions = projectReferences is null + ? ImmutableDictionary.Empty + : ImmutableDictionary.Empty.Add("build_property._RevitToolkitAddInProjectReferences", projectReferences); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AddInAssemblyGenerator().AsSourceGenerator()], + parseOptions: parseOptions, + optionsProvider: new GlobalOptionsProvider(globalOptions), + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + + driver = driver.RunGeneratorsAndUpdateCompilation(input, out var output, out var diagnostics, cancellationToken); + + await Assert.That(diagnostics).IsEmpty(); + await GeneratorTest.AssertCompilesAsync(output); + return (driver, output); + } + + private static IEnumerable ReadListedAssemblyNames(Compilation compilation) + { + return compilation.Assembly.GetAttributes() + .Where(static attribute => attribute.AttributeClass?.ToDisplayString() == AttributeName) + .Select(static attribute => (string)attribute.ConstructorArguments[0].Value!); + } + + private sealed class GlobalOptionsProvider(ImmutableDictionary globalOptions) : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new DictionaryOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return DictionaryOptions.Empty; + } + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return DictionaryOptions.Empty; + } + } + + private sealed class DictionaryOptions(ImmutableDictionary options) : AnalyzerConfigOptions + { + public static readonly DictionaryOptions Empty = new(ImmutableDictionary.Empty); + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + return options.TryGetValue(key, out value); + } + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs new file mode 100644 index 0000000..1d64524 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -0,0 +1,202 @@ +using System.Diagnostics.CodeAnalysis; +using System.Windows.Threading; +using Autodesk.Revit.DB.ExtensibleStorage; +using Autodesk.Revit.UI; +using Nice3point.Revit.Toolkit.External; +using Nice3point.TUnit.Revit; +using InvalidOperationException = Autodesk.Revit.Exceptions.InvalidOperationException; + +namespace Nice3point.Revit.Toolkit.Tests; + +[SuppressMessage("ReSharper", "MethodSupportsCancellation", Justification = "The tests raise the event through the overload without a token and bound the wait with WaitAsync.")] +public sealed class ExternalEventHandlerTests : RevitApiUiTest +{ + private const string WrittenResult = "Written"; + private static readonly Guid AddInId = new(TestsConfiguration.AddInId); + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedInsideAddInCallback_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + var externalEvent = new AsyncRequestExternalEvent(ReadActiveAddInId); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedInsideAddInCallbackAndRaisedFromThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + var externalEvent = new AsyncRequestExternalEvent(ReadActiveAddInId); + + // Act + var handlerAddInId = await Task.Run(externalEvent.RaiseAsync, cancellationToken).WaitAsync(cancellationToken); + + // Assert + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedOnThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var (creationAddInId, externalEvent) = await Task.Run(CreateActiveAddInIdEvent, cancellationToken); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(creationAddInId).IsNotEqualTo(AddInId).Because("the add-in is not active on a thread-pool thread"); + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + } + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedInDispatcherCallback_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var dispatcher = Dispatcher.CurrentDispatcher; + var creation = dispatcher.InvokeAsync(CreateActiveAddInIdEvent); + var (creationAddInId, externalEvent) = await creation.Task.WaitAsync(cancellationToken); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(creationAddInId).IsNotEqualTo(AddInId).Because("the add-in is not active in a dispatcher callback outside a Revit callback"); + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + } + + [Test] + [Timeout(10_000)] + public async Task Raise_HandlerTypeCreatedOnThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var handler = await Task.Run(static () => new ActiveAddInHandler(), cancellationToken); + + // Act + handler.Raise(); + var handlerAddInId = await handler.Execution.Task.WaitAsync(cancellationToken); + + // Assert + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + + [Test] + [Timeout(30_000)] + public async Task RaiseAsync_CreatedOnThreadPool_WritesAnEntityOfTheVendorSchema(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var externalEvent = await Task.Run(static () => new AsyncRequestExternalEvent(WriteVendorEntity), cancellationToken); + + // Act + var result = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(WrittenResult); + } + + [Test] + [Timeout(10_000)] + public async Task RegisterAddInAssembly_OnThreadPool_ThrowsInvalidOperation(CancellationToken cancellationToken) + { + // Arrange + var assembly = typeof(ExternalEventHandlerTests).Assembly; + + // Act + var registration = Task.Run(() => UiApplication.RegisterAddInAssembly(assembly), cancellationToken); + + // Assert + await Assert.That(() => registration).Throws(); + } + + [Test] + public async Task RegisterAddInAssembly_NullAssembly_ThrowsArgumentNull() + { + await Assert.That(() => UiApplication.RegisterAddInAssembly(null!)).Throws(); + } + + /// + /// Captures the add-in of the test assembly through an external event created inside a Revit callback. + /// + private static void CreateExternalEventInsideAddInCallback() + { + _ = new External.ExternalEvent(static () => { }); + } + + private static (Guid? CreationAddInId, AsyncRequestExternalEvent ExternalEvent) CreateActiveAddInIdEvent() + { + return (ReadActiveAddInId(), new AsyncRequestExternalEvent(ReadActiveAddInId)); + } + + private static Guid? ReadActiveAddInId() + { + return RevitApiContext.Application.ActiveAddInId?.GetGUID(); + } + + /// + /// Writes an entity of a schema with the vendor write access level and the vendor ID of the test add-in. + /// + /// The current . + /// The written result, or the message Revit refuses the write with. + private static string WriteVendorEntity(UIApplication uiApplication) + { + var schemaId = Guid.NewGuid(); + var schemaBuilder = new SchemaBuilder(schemaId); + schemaBuilder.SetSchemaName($"ToolkitTests{schemaId:N}"); + schemaBuilder.SetVendorId(TestsConfiguration.VendorId); + schemaBuilder.SetReadAccessLevel(AccessLevel.Public); + schemaBuilder.SetWriteAccessLevel(AccessLevel.Vendor); + schemaBuilder.AddSimpleField("Value", typeof(string)); + var schema = schemaBuilder.Finish(); + + var document = uiApplication.Application.NewProjectDocument(UnitSystem.Metric); + try + { + using var transaction = new Transaction(document, "Write entity"); + transaction.Start(); + + var entity = new Entity(schema); + entity.Set("Value", WrittenResult); + document.ProjectInformation.SetEntity(entity); + + transaction.Commit(); + return WrittenResult; + } + catch (InvalidOperationException exception) + { + return exception.Message; + } + finally + { + document.Close(false); + } + } + + private sealed class ActiveAddInHandler : ExternalEventHandler + { + public TaskCompletionSource Execution { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override void Execute(UIApplication uiApplication) + { + Execution.TrySetResult(uiApplication.ActiveAddInId?.GetGUID()); + } + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs new file mode 100644 index 0000000..f37aa42 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using Nice3point.Revit.Toolkit.Internal; +using Nice3point.TUnit.Revit; + +namespace Nice3point.Revit.Toolkit.Tests; + +public sealed class ListedAddInAssembliesTests : RevitApiUiTest +{ + private const nint FirstAddIn = 1; + private const nint SecondAddIn = 2; + private static readonly Assembly ListedAssembly = typeof(ListedAddInAssembliesTests).Assembly; + private static readonly string ListedName = ListedAssembly.GetName().Name!; + + [Test] + public async Task TryMatch_ListedName_ReturnsTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName], FirstAddIn, replaceListedNames: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatched).IsTrue(); + await Assert.That(addInData).IsEqualTo(FirstAddIn); + } + } + + [Test] + public async Task TryMatch_ListedNameInAnotherCase_ReturnsTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName.ToUpperInvariant()], FirstAddIn, replaceListedNames: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatched).IsTrue().Because("the runtime compares simple assembly names case-insensitively"); + await Assert.That(addInData).IsEqualTo(FirstAddIn); + } + } + + [Test] + public async Task TryMatch_UnlistedName_ReturnsFalse() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replaceListedNames: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatched).IsFalse(); + await Assert.That(addInData).IsEqualTo(0); + } + } + + [Test] + public async Task TryMatch_NameListedAfterAMiss_ReturnsTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replaceListedNames: false); + var isMatchedBeforeListing = listedAssemblies.TryMatch(ListedAssembly, out _); + listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatchedBeforeListing).IsFalse(); + await Assert.That(isMatched).IsTrue().Because("a new listed name invalidates the cached miss"); + await Assert.That(addInData).IsEqualTo(SecondAddIn); + } + } + + [Test] + public async Task Add_NameListedEarlierWithoutReplace_KeepsTheFirstAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName], FirstAddIn, replaceListedNames: false); + + // Act + listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: false); + + // Assert + listedAssemblies.TryMatch(ListedAssembly, out var addInData); + await Assert.That(addInData).IsEqualTo(FirstAddIn); + } + + [Test] + public async Task Add_NameListedEarlierWithReplace_ReplacesTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName], FirstAddIn, replaceListedNames: false); + + // Act + listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: true); + + // Assert + listedAssemblies.TryMatch(ListedAssembly, out var addInData); + await Assert.That(addInData).IsEqualTo(SecondAddIn); + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs b/tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs new file mode 100644 index 0000000..1380390 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs @@ -0,0 +1,13 @@ +using Nice3point.Revit.Injector.Attributes; +using Nice3point.Revit.Toolkit.Tests; + +[assembly: RevitClient(Id = TestsConfiguration.AddInId, Name = TestsConfiguration.AddInName, VendorId = TestsConfiguration.VendorId)] + +namespace Nice3point.Revit.Toolkit.Tests; + +internal static class TestsConfiguration +{ + public const string AddInId = "4C2E8A61-7F3B-4D95-B0A8-9E1D6C5F2B47"; + public const string AddInName = "Toolkit Tests"; + public const string VendorId = "Nice3point"; +}