Assemblies
Assemblies are objects implementing IAssembly that can track reflection data from binary .NET assemblies or source code files. This reflection data is then used by other add-on features such as the resolver and automated IntelliPrompt. It is critical to configure assemblies correctly to use the advanced features offered by this add-on.
An ambient assembly repository can be installed to manage reuse of binary assemblies in scenarios where multiple project assemblies are active, and can also cache assembly data to improve future load performance.
What is an Assembly?
There are two main types of assemblies: project assemblies and binary assemblies.
Project assemblies are similar in concept to a Visual Studio project in that they can track reflection data for source code files and can maintain a list of other IAssembly references related to the project. These references can be to other project assemblies (similar to Visual Studio when a solution hosts multiple projects that are dependent upon each other) or can be to binary assemblies.
Binary assemblies store reflection data for pre-compiled binary .dll files such as mscorlib.dll
.
Since IAssembly and the rest of the types used for reflection in this add-on are all implemented as interfaces, it is possible to create other custom assembly types when there are special need scenarios.
All assemblies have a name, provide a flat list of namespaces defined within type, and provide access to a namespace hierarchy that is rooted at the global namespace. Each namespace can contain other nested namespaces or types, types can contain members, and so on. This design is described in more detail in the Reflection topic.
Project Assemblies
Project assemblies, classes that implement IProjectAssembly, can track source code files and references to other assemblies. They also can return an IResolver class that is used to resolve expressions to a namespace, type, member, etc.
Different project assembly types are available for each language, since there are some language-specific rules that need to be followed by the resolver. For instance, a C# resolver should ignore standard modules, while a VB resolver should handle standard modules and use case insensitive name matching.
The built-in project assembly types are:
- CSharpProjectAssembly - for C# languages
- VBProjectAssembly - for Visual Basic languages
This diagram shows the general structure of a project assembly:
In the diagram, there are three source files loaded: Foo1.cs
, Foo2.cs
, and Bar.cs
. The first two files contain a partial class named Foo
, so the two class declarations are merged together in the reflection data tracked by the project assembly. The last file contains a Bar
class along with a nested type named Nested
. The diagram also shows that there are references made to other assemblies such as mscorlib.dll
, System.dll
, etc. All of this data is tracked live and can be updated on-the-fly as code changes or references need to be added/removed.
When the resolver executes, it uses all of the above information along with language-specific rules to try and find the best match for a type, member, etc., even if that match is in a referenced assembly.
Registering with a Syntax Language
Any object that implements IProjectAssembly, such as the built-in CSharpProjectAssembly and VBProjectAssembly classes, can be associated with a syntax language by registering it as an IProjectAssembly service on the language.
Note
Due to language-specific rules that project assemblies use, only register CSharpProjectAssembly instances on a CSharpSyntaxLanguage, and VBProjectAssembly instances on a VBSyntaxLanguage.
Appropriate default IProjectAssembly instances are pre-registered on the built-in C#/VB syntax languages but need to be configured with source files and/or assembly references to be useful. Alternatively, you can create and configure a new project assembly instance and then register it on the language.
This code creates a new C# project assembly, uses a BackgroundWorker
to adds some references asynchronously (see following sections for more information on why), and registers it with the CSharpSyntaxLanguage that is already declared in the language
variable:
CSharpProjectAssembly project = new CSharpProjectAssembly("Sample");
BackgroundWorker assemblyLoader = new BackgroundWorker();
assemblyLoader.DoWork += (sender, e) => {
project.AssemblyReferences.AddMsCorLib();
project.AssemblyReferences.Add("System");
};
assemblyLoader.RunWorkerAsync();
language.RegisterProjectAssembly(project);
Note
The DotNetSyntaxLanguageExtensions.RegisterProjectAssembly method in the code snippet above is a helper extension method that gets added to ISyntaxLanguage objects when the ActiproSoftware.Text.Languages.DotNet
namespace is imported. See the Service Locator Architecture topic for details on registering and retrieving various service object instances, both via extension methods and generically, as there are some additional requirements for using the extension methods.
Managing Assembly References
For the resolver and automated IntelliPrompt features to work properly, the appropriate assembly references must be added to the project assembly. At a minimum, a reference to mscorlib.dll
should always be added since that is where native types like Int32
, String
, etc. are defined. The resolver only knows about the assemblies that are referenced by the project, so if a reference is made to a type Foo
but there is no source file or referenced assembly that defines Foo
, the resolution of that reference will fail.
Always think about project assemblies in terms of being equivalient to Visual Studio projects. If an assembly reference would be needed to support a type or member in code, then a reference to the assembly should be made in the project assembly.
There are numerous methods and overloads for adding a reference to a project assembly. These methods are located on the IProjectAssembly.AssemblyReferences collection, which also allows enumeration and removal of references.
These methods can add references to a project assembly:
Member | Description |
---|---|
Adds a new reference for the specified IAssembly, which can be an already-loaded project or binary assembly. |
|
Add(Assembly) Method |
Adds a new reference to an IBinaryAssembly for the specified reflection |
Add(Assembly, IAssemblyName) Method |
Adds a new reference to an IBinaryAssembly for the specified reflection |
Add(string) Method |
Adds a new reference to an IBinaryAssembly for the specified assembly name. While using the simple assembly name may work in some simple cases (such as |
AddFrom(string) Method |
Adds a new reference to an IBinaryAssembly for the assembly at the specified path. The application must have access privileges to the path. |
AddAllInAppDomain() Method |
Adds new references to IBinaryAssembly objects for all assemblies currently loaded in the This method is only recommended for testing purposes since a properly-designed application should pick and choose which exact assembly references to have on a project assembly. |
AddMsCorLib() Method |
Adds a new reference to an IBinaryAssembly for |
This code shows how to add assembly references to an IProjectAssembly in the project
variable using a variety of methods:
// Add mscorlib (should always add at a minimum)
project.AssemblyReferences.AddMsCorLib();
// Add 'System.dll' via a direct Assembly
project.AssemblyReferences.Add(typeof(System.Uri).Assembly);
// Add 'System.Core.dll' via its full name
project.AssemblyReferences.Add("System.Core, Version=3.5.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
// Add another IProjectAssembly that was previously loaded elsewhere
project.AssemblyReferences.Add(anotherProject);
This code shows a couple other ways to add assembly references to an IProjectAssembly in the project
variable:
// Add a custom assembly from a file path (app must have access to the path)
project.AssemblyReferences.AddFrom(@"C:\MyAssembly.dll");
// Only for testing - add all assemblies in the current AppDomain
// project.AssemblyReferences.AddAllInAppDomain();
Assembly Reference Cleanup
When an IProjectAssembly is no longer needed in your application, it is a good idea to clear its AssemblyReferences collection. When an assembly repository is used (described later in this topic), it will often track the reference count of an assembly. While that reference count remains above zero, it will track data for the assembly. When the reference count returns to zero, it will close up any open cache files or other resources that it may have open for the assembly.
Thus it is very important to clear the assembly references collection on a project assembly prior to the project assembly going out of scope, so that memory and resources for its referenced assemblies can be reclaimed when appropriate.
Performance Impact of Adding References
When a new IBinaryAssembly needs to be created using reflection on a System.Reflection.Assembly
(regardless of if the Assembly
is passed directly or via a full name or file path), there is processing that must take place to gather the data needed to construct the resulting IBinaryAssembly. This processing uses .NET reflection and thus can take some time to complete. For instance, if 20 new assembly references are being added to a project assembly, it might take several seconds to load.
This is the reason why in the sample code earlier in this topic, we used a System.ComponentModel.BackgroundWorker
to execute the loading code. That allows the code to execute asynchronously, thus preventing the UI from slowing down or being blocked. .NET 4.0 and later includes other features such as tasks that can be used in a similar fashion instead.
Note that there are features built in to cache the IBinaryAssembly data constructed from an Assembly
. These features are part of the "Assembly Repositories" object model described later in this topic, and can help speed up assembly loading by ten times or more when properly used.
Source Files
Source files, represented by the ISourceFile interface, have a string-based key and a collection of ITypeDefinition objects that are defined in the file.
Source file objects can be added to and removed from the IProjectAssembly.SourceFiles collection. This is similar in concept to the adding/removing of code files to a Visual Studio project. When a source file is included in a project, its types, members, etc. can be used by the resolver.
Automatic Source File Creation by Documents
As long as the following conditions are met, documents will automatically update a project assembly whenever the document is altered (such as when typing is performed in a SyntaxEditor):
The document must have a ITextDocument.FileName property value set. The value of this property is used as the ISourceFile.Key. No source file can be created without it as a key.
The document is using a .NET syntax language (CSharpSyntaxLanguage or VBSyntaxLanguage).
The .NET syntax language in use must have an IProjectAssembly service registered. The ISourceFile created for the document will be added to this project assembly.
Behind the scenes, the .NET syntax languages watch any attached document for ICodeDocument.ParseData changes. When they detect a change, they translate the document's AST to ITypeDefinition instances, and an ISourceFile instance is created to contain the type definitions with the document's FileName as its key. The ISourceFile is added to the project assembly and replaces any prior source files with the same key.
Programmatically Adding Source Files
There are several ways to programmatically append source files to a project assembly, all available from the IProjectAssembly.SourceFiles collection. One way is to add an ISourceFile instance directly with the Add
method. Here are the other ways:
Member | Description |
---|---|
Add Method |
Adds an ISourceFile for the specified AST CompilationUnit to the collection. This method allows the source file key to be specified, and it translates the compilation unit's contents to a set of ITypeDefinition objects. |
QueueCode Method |
Queues parsing for the specified source code using an indicated language, and adds an ISourceFile for the result. The parsing operation will occur in a worker thread and will not block the calling thread as long as an ambient parse request dispatcher is set up. |
QueueFile Method |
Queues parsing for the specified file using an indicated language, and adds an ISourceFile for the result. The parsing operation will occur in a worker thread and will not block the calling thread as long as an ambient parse request dispatcher is set up. |
This code shows how to queue up parsing to create source files for a project assembly defined in project
:
project.SourceFiles.QueueCode(cSharpSyntaxLanguage, "MyFile1.cs", "class Foo { public void Bar() {} }");
project.SourceFiles.QueueFile(cSharpSyntaxLanguage, @"C:\MyFile2.cs");
Again, make sure an ambient parse request dispatcher is set up so parsing occurs in a worker thread.
Programmatic Source File Creation
The SourceFile class implements ISourceFile and can be used to programmatically add a set of ITypeDefinition objects to a project assembly.
Binary Assemblies
Binary assemblies, represented by the IBinaryAssembly interface, store reflection data for pre-compiled binary .dll files such as mscorlib.dll
. They can be created for any System.Reflection.Assembly
instance, one in memory or one on disk.
Similar to project assemblies, binary assemblies also have their own references to other assemblies. However in binary assemblies, the references are more informational.
IBinaryAssembly instances can be created for an Assembly
using any of the methods described in the 'Managing Assembly References' section earlier in this topic. Similar loading methods are available on IAssemblyRepository that can be used to load an IBinaryAssembly without adding it as a reference to a project. The assembly reference add methods internally call these various IAssemblyRepository load methods.
Limitations of .NET Reflection
While using .NET reflection to load binary assembly data is fast, there are some circumstances where problems can occur. .NET reflection requires that referenced assemblies are able to be resolved and loaded. There might be other cases where .NET reflection data for an assembly was already loaded into a binary assembly from a location, the file assembly was rebuilt, and the binary assembly data tried to reload but couldn't. .NET reflection locks the file and only allows it to be loaded once.
In scenarios like this, it might be useful to take advantage of the optional Roslyn extensions we provide that ship in the ActiproSoftware.Text.Addons.DotNet.Roslyn.Wpf
assembly. Roslyn is a set of assemblies produced by Microsoft that can examine .NET assemblies for reflection data without many of the restrictions of using normal .NET reflection. For instance, references don't need to be loaded and assembly files aren't locked.
Loading Binary Assemblies with Roslyn Extensions
Our optional Roslyn extensions are fully capable of loading IBinaryAssembly data for a particular file system assembly file. This binary assembly can then be referenced by a project assembly as described in the 'Managing Assembly References' section earlier in this topic. The only requirements are that your app uses .NET 4.6.1 or later, references our ActiproSoftware.Text.Addons.DotNet.Roslyn.Wpf
assembly, and has a NuGet package reference to 'Microsoft.CodeAnalysis'.
This code shows how to load a binary assembly with our Roslyn extensions and then add it as a reference to a project assembly:
var loader = new ActiproSoftware.Text.Languages.DotNet.Reflection.Implementation.RoslynBinaryAssemblyLoader();
var binaryAssembly = loader.LoadFrom(pathToAssemblyFile);
project.AssemblyReferences.Add(binaryAssembly);
Assembly Repositories
Assembly repositories provide a way to store loaded binary assemblies and possibly cache their data so that it can be retrieved much faster in the future. They are represented by the IAssemblyRepository interface and the FileBasedAssemblyRepository class provides the implementation used by most applications.
Assembly repositories have several Load methods for loading an IBinaryAssembly from a reflection Assembly
. If the assembly repository implementation supports caching, the loaded assembly data is cached from these load method calls. The various methods for adding references to project assemblies mentioned in previous sections call these load methods.
Pruning the Cache
The assembly repository's cache may write to files or some other mechanism depending on the implementation. Regardless it is important to call the IAssemblyRepository.PruneCache method to prune invalid data from the cache upon application shutdown.
Note
Since assemblies can change over time (when a .dll is recompiled or deleted), it is important to make sure the cache removes data that is no longer valid via a call to IAssemblyRepository.PruneCache when the application is shut down.
Reference Tracking
The default implementation of IProjectAssembly will call the IAssemblyRepository.Add method when a binary assembly is added as a reference. The assembly repository keeps a pointer to the binary assembly and tracks a reference count for it. Likewise, the IAssemblyRepository.Remove method is called when a binary assembly is removed as a project assembly reference. If the reference count for the binary assembly reaches zero, the assembly repository will discard its pointer to the assembly.
The FileBasedAssemblyRepository Class
The FileBasedAssemblyRepository is the main IAssemblyRepository implementation provided with the add-on. It supports caching of assembly data out to files in a folder, as long as the application's security allows for write access to the folder specified in its cache path. This often requires full/elevated trust by the application. These cache files can be reloaded in the next application session, dramatically reducing binary assembly load times.
Use of this repository type as the ambient assembly repository is recommended since its caching mechanism can load its cached assembly data for an assembly about ten times faster than creating a new IBinaryAssembly via the use of .NET reflection.
If no cache path is set on the repository then it will not attempt to save or load any assembly data cache files. However it still will perform reference tracking, which significantly helps performance when multiple project assemblies are used.
This table illustrates the preferred catch path settings for FileBasedAssemblyRepository based on the type of application and its trust level.
Platform | Trust Level | Notes |
---|---|---|
WPF (application) | Full | Use a cache path that is accessible for read/write by the application, such as in a subfolder within the Environment.SpecialFolder.LocalApplicationData special folder. |
WPF (XBAP) | Sandboxed | Sandboxed XBAPs don't support file I/O features that are needed for caching so just pass null as the cache path. |
WPF (XBAP) | Full | Use a cache path that is accessible for read/write by the application, such as in a subfolder within the Environment.SpecialFolder.LocalApplicationData special folder. |
Ambient Assembly Repository
There are often scenarios where multiple project assemblies are used at the same time. This is similar to scenarios in Visual Studio when a solution has multiple projects open. Each one of these projects probably references some of the same binary assemblies, like mscorlib
.
By using an ambient assembly repository, the loaded binary assembly instances are reused among the various project assemblies, thus saving on memory. As mentioned in the previous section, assembly repositories also are capable of caching data, thus sometimes improving loading speeds by ten times or more.
Important
By default, no ambient IAssemblyRepository is installed meaning that more than one project assembly will use unncessary extra memory when referencing various binary assemblies, since those instances will not be shared among the project assemblies. Also, no caching will occur for binary assemblies if there is no ambient assembly repository, thus affecting application performance when adding references to project assemblies.
This code is executed at application startup and creates a FileBasedAssemblyRepository for use as the ambient assembly repository. Note that it uses an application-specific data folder path that you should update as appropriate for your application.
string appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@"YourCompanyName\YourApplicationName\Assembly Repository");
AmbientAssemblyRepositoryProvider.Repository = new FileBasedAssemblyRepository(appDataPath);
At application shutdown, this code can be used to prune cache data that is no longer valid.
var repository = AmbientAssemblyRepositoryProvider.Repository;
if (repository != null)
repository.PruneCache();
Loading Pre-Defined Binary Assembly Cache Data
Many of the add reference methods described above in the "Managing Assembly References" section will load the assemblies for the current platform version. Say that your application is a Silverlight application and you call the IProjectAssemblyReferenceCollection.AddMsCorLib method. In this case, the Silverlight MsCorLib
assembly will be added.
But what if you are developing a Silverlight application that is for a .NET 2.0 WinForms development IDE? First, Silverlight's MsCorLib
has slightly different API than .NET 2.0's MsCorLib
does. Second, you can't load .NET 2.0 assemblies into Silverlight applications for reflection. Luckily the .NET Languages Add-on has a mechanism to support this scenario.
When using the FileBasedAssemblyRepository as the ambient assembly repository, as described in previous sections, you can set a cache path. After loading the appropriate assembly once, you can go to this cache folder and find the related cache file for the assembly.
The following code is a snippet you can paste in a new Visual Studio WPF application project (we'll assume it's called 'WpfApplication1'). In the application project, add references to the .NET Languages Add-on and SyntaxEditor assemblies. The WPF version of these Actipro assemblies can be found in the Language Designer tool's folder, since it uses them. Then paste this code in your application startup section:
var appPath = Directory.GetCurrentDirectory();
var cachePath = Path.Combine(appPath, "Cache");
AmbientAssemblyRepositoryProvider.Repository = new FileBasedAssemblyRepository(cachePath);
var project = new CSharpProjectAssembly("Loader");
// Customize these based on which .NET assemblies you wish to load
project.AssemblyReferences.AddMsCorLib();
project.AssemblyReferences.AddFrom(Path.Combine(appPath, "WpfApplication1.exe"));
After running the application, in the 'bin/Debug' folder there should now be a 'Cache' folder. That folder will contain files named something like 'mscorlib-v2.0-d7ffa534.Reflection.dat'. Each '...Reflection.dat' file is the reflection data file for an assembly.
If you copy a '...Reflection.dat' file to your application and make it accessible as a Stream
(perhaps by making it an Embedded Resource), it can be loaded to create an IBinaryAssembly instance, even if it was created on a completely different platform (WPF vs. Silverlight, etc.).
This code creates a BinaryAssemblySerializer and loads an IBinaryAssembly from a Stream
located in the resourceStreamName
resource path.
var serializer = new BinaryAssemblySerializer();
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceStreamName)) {
var assembly = serializer.LoadFromStream(null, stream);
if (assembly != null)
project.AssemblyReferences.Add(assembly);
}
Note
Assembly cache files all contain version numbers. While these version numbers generally remain constant, they are incremented when there are additions/changes made to the serialized data so that it can be properly loaded. Trying to load a cache file with an older version into a newer BinaryAssemblySerializer will result in no IBinaryAssembly being created. The Language Designer's .NET Languages Add-on assemblies should always be in sync with this version in regards to codebases. So for Silverlight and UWP controls, it is best to use the Language Designer's WPF assemblies to generate the reflection data files.