Developing a custom Rider IDE plug-in for our symbol reference finder
- Software Engineering
Magdalena Augustynska recently completed a software engineer internship at G-Research. For her intern project she worked on setting up reference finding through a Rider plug in. Find out how she went through the whole process below.
Context
As a company grows, code bases become bigger over time as more and more developers work on them. It gets harder to navigate all the projects and when you want to remove or modify some of the company’s shared library code, you can’t be sure that you won’t break anything.
That’s why our Shared Code team built “Reference Finding” – a tool that lets you find references to any symbol in your code from any other code in the company. There are already some great existing tools, but they work with a separate server storing the code which was not an option for some of our secure code bases.
For my internship at G-Research, I joined the Shared Code team and spent my first three months helping them improve the developer experience for this Reference Finding tool.
At first, Reference Finding was available as a web app where you can search for a symbol by typing its name and choosing a desired one from the suggestions. Once chosen, the site displays a list of all the references to the symbol together with links to its declaration and usages in GitHub.
Alongside that, Shared Code had already created a plugin for Visual Studio that lets you right-click on a symbol (e.g. a method or property) and find references to it, jumping you into the web app. This is very useful for many of our engineers but a great number of them use Rider, so there was a desire to implement a plugin for this IDE too.
That’s the project I was assigned during my software engineering internship.
I found working on this project extremely interesting and that’s why I would like to walk you through my experiences in that with the hope that you may find it useful.
You may find these tips particularly helpful if you are writing a Rider plugin that works directly with C# symbols.
Introduction
To start, I recommend getting familiar with the official documentation covering the basics of some of the areas of the ReSharper platform. Some other resources I found useful:
Packing the plugin
We will build the plugin on the basis of the template linked above – it’s a good place to start.
After following the guidelines from the JetBrains blog about creating the project with the template, you end up with two directories:
dotnet
: back-end implementationrider
: front-end implementation
Additionally, you can follow these steps to add a front-end action to your project.
Rider consists of the IntelliJ front-end running on the JVM and the ReSharper back-end running on .NET.
Depending on what you want your plugin to do, you might need to take care to implement only the front-end part (in which case you don’t need much knowledge of the code structure or back-end part), or perhaps you want to have extensive insight into the code, or modify the code itself, or both.
Knowing that the two parts are supposed to communicate with one another through a custom protocol, the first thing we want to do is to try to pack together both (at this point independent) components, so the final ZIP’s structure corresponds to the one presented here.
Although we ended up not needing this, maybe you’ll find it handy:
- Build front-end part:
$ cd ./rider
$ gradle :buildPlugin
- Build back-end part:
$ cd ./dotnet
$ gradle :buildPlugin
- Now there is a ZIP file in
dotnet/build/distributions
called$projectName-$version.zip
- Unpack it
- Copy
dotnet/src/rider/main/resources/META-INF
directory into the unpacked dir - Copy
rider/build/distributions/rider-$version.zip/rider/lib/rider-$version.jar
into lib directory in previously unpacked dir - Pack it again to ZIP
- The plugin can be installed from your created ZIP from Rider -> Ctrl+Alt+S -> Plugins.
(theprojectName
isrootProject.name
defined indotnet/settings.gradle
file)
Working with the Abstract Syntax Tree
We want to achieve the following when clicking on a symbol in the editor: To get a ReSharper object, find its original declaration, and then do a plugin-specific thing with it (which in our case is converting the declaration to the internal fully-qualified name format).
We’d like the feature to be available as a menu item and that can be done using IExecutableAction
like in this example Create Menu Items Using Actions.
For this to work as a right-click menu item, we need to implement the front-end part that communicates with this action. After going through most of the available articles and documentation, I still had no clue how to do this but found another ReSharper class, IContextAction
, which works with only the C# part implemented. Excited by this discovery, I decided to go with this class for the time being, just to have something to work with, and replace the class later.
The only concerning thing (apart from the fact that it implements context action) was that IContextAction
provides access to other data structures than IExecutableAction
.
IContextAction
gives access to IContextActionDataProvider
, whereas IExecutableAction
gives access to IDataContext
– but most of the objects representing syntactic and semantic views of a codebase can be retrieved from both classes.
Having the IContextActionDataProvider
, we can do for example:
var file = dataProvider.PsiFile;
var treeTextRange = dataProvider.SelectedTreeRange;
PsiFileView psiFileView = new PsiFileView(file, treeTextRange);
The above class (and more general IPsiView
) represents the view of the PSI (Program Structure Interface).
From IPsiView
we are able to get elements like ITreeNode
, IDeclaration
, IDeclaredElement
– structures containing details about particular symbol usage in the code.
(For all the type related ReSharper’s data structures I recommend reading the Type System docs.)
To actually find all details about a symbol – the assembly, namespace and full name – we need to get to the original declaration of the element. The structure that contains all the information we need is IDeclaredElement
.
Having IPsiView
, we can do psiView.GetSelectedTreeNode<ICSharpDeclaration>()
,
get the declaration and then the Declared Element for it. But it only resolves correctly if the node we click on is indeed the declaration of some element – for example it doesn’t resolve keywords associated to specific symbols, attribute constructors, tokens or just references to an element.
We obviously didn’t want this kind of limited functionality.
Some digging led me to an example of finding the reference to an actual symbol’s definition. It uses objects called navigators.
Here is an example of using one:
Let cSharpTreeNode
be an ITreeNode
we got using any of the mentioned structures (IPsiView
or IDataContext
)
var cSharpIdentifier = cSharpTreeNode as ICSharpIdentifier;
(ICSharpIdentifier
(IIdentifier
) is just a representation of ITreeNode
with additional info about the node’s name excluding language-specific details.)
var declarationUnderCaret = FieldDeclarationNavigator.GetByNameIdentifier(cSharpIdentifier);
var declaredElement = declarationUnderCaret?.DeclaredElement;
Another way to resolve a symbol is by using References.
Let’s say we want to find the original declaration of an attribute’s constructor.
var referenceName = ReferenceNameNavigator.GetByNameIdentifier(cSharpIdentifier);
var attribute = AttributeNavigator.GetByName(referenceName);
var declaredElement = attribute?.ConstructorReference.Resolve().DeclaredElement;
We covered most of the cases with these two approaches.
Front-end part
We wanted the functionality of opening “Reference Finding” to be available as an option in the right-click menu. This part of the Rider IDE belongs to the IntelliJ front-end. In that case, you need to have a Java or Kotlin part that shares a model with the back-end and calls the action implemented in C#.
More precisely, you need to add a Kotlin class, along with updating the plugin.xml
file – adding an action tag with details about your action and link to the front-end class.
For our purposes, there was no need to write a custom class compatible with the shared Kotlin protocol; it’s enough to use some of the ones used in Rider.
After spending too much time going through the IntelliJ OpenAPI (which has most of the implementations hidden) and looking for an answer on blogs, I reached out for help to the developers at JetBrains.
They provided me with an example of what the Kotlin class should look like together with the xml file containing the plugin specification – GlobalNukeTargetExecutionAction
inside nuke-build. They updated the template as well.
Once I could use IExecutableAction
, I slightly changed our implementation and pulled IFile
and TreeTextRange
from IDataContext
to create IPsiView
that our implementation was already handling well.
Soon it came to my attention that IDataContext
contains very useful functionality – it stores cached values of data constants, for instance the ones defined in PsiDataConstants
class.
With that, we can make an attempt to obtain an already-evaluated IReference
for the chosen tree node:
dataContext.GetData(PsiDataConstants.REFERENCE)?.Resolve().DeclaredElement;
This will work if the symbol in question is indeed just a reference to the original declaration.
If we investigate a declaration, we can do:
dataContext.GetSelectedTreeNode<IDeclaration>()?.DeclaredElement;
This makes the problem a lot simpler. However, these two scenarios don’t cover everything we wanted (e.g. new
keywords, attribute constructors) and I decided to stick with the previous approach which lets us handle each case the exact way we want to.
Testing
There are various ways you may choose to test your plugin. We want to be able to generate ReSharper C# files (IFiles
) compiled into a proper solution from sample source code that we have included as a project, so we can then access individual symbols at specific positions in the form they are represented internally. With these objects, we can test if the implementation resolves the symbols to correct fully qualified names.
It was quite a challenge to find the right way of generating ReSharper objects from strings. I found most of the hints on the internet unclear or not up to date.
We located a working solution within some test classes in the ReSharper source code.
public class TestClass : BaseTestWithSingleProject
{
protected override string RelativeTestDataPath => …
private IEnumerable<string> fileNames = …
[Test]
public void Test()
{
try
{
WithSingleProject(fileNames, (lifetime, solution, project) =>
{
RunGuarded(() =>
{
var psiFiles = Solution.GetPsiServices().Files;
foreach (var testFileName in fileNames)
{
var testFilePath = GetTestDataFilePath2(testFileName);
var sourceFile = project.FindProjectItemsByLocation(testFilePath).OfType<IProjectFile>()
.Single().ToSourceFile();
if (sourceFile == null)
throw …
var cSharpFile = psiFiles.GetPsiFiles<CSharpLanguage>(sourceFile).OfType<ICSharpFile>().Single();
// Do something with the cSharpFile
}
});
});
}
catch
{
// ...
}
}
}
The BaseTestWithSingleProject
class is defined in the JetBrains.ReSharper.TestFramework
package. It lets you create a single project made of specified files.
RelativeTestDataPath
property can be used to specify the location of your solution files. This needs to be relative to the BaseTestDataPath
defined in BestTestNoShell
(which is a superclass of BaseTestWithSingleProject
).
The collection fileNames
should consist of the names of files that you want your one-project solution to consist of and which should be under the RelativeTestDataPath
directory.
There were a couple more issues I had to solve to get the tests working:
The method GetTestDataPackages()
was returning JetBrains.Tests.Platform.NETFrameWork
which we didn’t need and that was breaking the tests.
It is enough to override this method:
protected override IEnumerable<PackageDependency> GetTestDataPackages()
{
return Enumerable.Empty<PackageDependency>();
}
I also came across the problem where the mscorlib
path was empty in some internal ReSharper object PlatformInfo
. To solve that, you need to override the method
protected override IEnumerable<string> GetReferencedAssemblies(TargetFrameworkId targetFrameworkId)
and add to the result your own mscorlib
path.
Packing it again
Once the front-end and back-end parts are connected, it is sufficient to run Gradle’s buildPlugin
task once to get ready to install the plugin.
The outcome of the project was a success – we’ve received much positive feedback from C# developers on the plugin and how it makes their daily work simpler.
The internship experience
This project, as well as every other assignment I worked on over the course of six months, was really engaging and challenging. During that time, although I was an intern, I knew that my work impacted the business and delivered real value for the people working around me.
At every step of development of the solution, I could always count on help from my mentor, manager and the rest of my team. I’ve learned a lot, both in terms of technical skills and soft ones, working as a member of a team.
If you’re looking for a place where you can develop your software engineering skills, apply them to solve real-life problems and learn something new every day while working on exciting projects with some of the best people in their fields, without a doubt, I can strongly recommend applying to G-Research.