Visual Studio extension to change the C# language version of a project

Akos Nagy
May 16, 2019

One of the courses that I teach is a workshop on the new features of the new C# language versions. During the course we look at the different language features of C# 6 and the newer versions. To illustrate the different features, I often change the language version of the project back and forth.

If you know Visual Studio, you also know that this is a pain: you have to select the project properties, then the Build tab, then the Advanced option, and then select the version from a drop-down. Kinda breaks the flow of the course.

Creating a Visual Studio extension

This gave me an idea: I should create a Visual Studio extension to make it easier to change the language version. Just a couple clicks ideally. And so I did :) If you are only interested in the extension, you can download it from the marketplace. But if you are interested in how I created the extension, read on.

Create a component to handle project versions

First, I created a component to query the Visual Studio version, determine the available language versions based on that, get the language version of the current project and set the language version of the project.

public interface IProjectVersionService
{
  HashSet<string> GetAvailableLanguageVersions();
  string GetLanguageVersion();
  void SetLanguageVersion(string version);
}

Setting up the component

First thing's first: we have to get a reference to a DTE object — that is, the connection to the Visual Studio host.

public class ProjectVersionService : IProjectVersionService
{  
  private readonly DTE2 dte;
  public ProjectVersionService(DTE2 dte)
  {
    this.dte = dte;
  }

Getting the availabe language versions

To get the available language versions, first we have to determine the Visual Studio version. That's actually quite easy: just query the version of devenev.exe. Then, based on the documentation here, you can determine the available language versions based on that version number.

public class ProjectVersionService : IProjectVersionService
{
  private const string devenvExe = "devenv.exe";  
  private readonly DTE2 dte;
  public ProjectVersionService(DTE2 dte)
  {
    this.dte = dte;
  }
   
  
  private Version GetVisualStudioVersion()
  {
    string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, devenvExe);
    var fvi = FileVersionInfo.GetVersionInfo(path);

    string verName = fvi.ProductVersion;
    for (int i = 0; i < verName.Length; i++)
    {
      if (!char.IsDigit(verName, i) && verName[i] != '.')
      {
        verName = verName.Substring(0, i);
        break;
      }
    }
    return new Version(verName);
  }
       
  public HashSet<string> GetAvailableLanguageVersions()
  {
    var version = GetVisualStudioVersion();
    var availableVersions = new HashSet<string> { LanguageVersions.Default,
                                                  LanguageVersions.Latest, 
                                                  LanguageVersions.CSharp5, 
                                                  LanguageVersions.CSharp6 };
    if (version.Major>=16)
    {
      availableVersions.Add(LanguageVersions.LatestMajor);
      availableVersions.Add(LanguageVersions.CSharp8);
      availableVersions.Add(LanguageVersions.Preview);
    }
    else if (version.Major >= 15)
    {
      availableVersions.Add(LanguageVersions.CSharp7);
      if (version.Minor >= 3)
      {
        availableVersions.Add(LanguageVersions.CSharp71);
      }
      if (version.Minor >= 5)
      {
        availableVersions.Add(LanguageVersions.CSharp72);
      }
      if (version.Minor >= 7)
      {
        availableVersions.Add(LanguageVersions.CSharp73);
      }
    }
    return availableVersions;
  }  

To be fair, from VS219 the tooling is a bit different and now the language versions are determined by the target framework of the project itself, and not the Visual Studio version (see this for more info). But for now I think I will stick with this solution till I can find out how the language version chooser combobox in the project settings is actually populated.

Getting the current language version

To get the current language version setting of the current project, you first have to actually get the current project from the DTE object. Then you have to query the "LanguageVersion" property of the project object.

public class ProjectVersionService : IProjectVersionService
{
  private const string devenvExe = "devenv.exe";
  private const string langVersionIndexer = "LanguageVersion";
  private readonly DTE2 dte;
  public ProjectVersionService(DTE2 dte)
  {
    this.dte = dte;
  }

  private Project GetActiveProject()
  {
    if (dte.ActiveSolutionProjects is Array activeSolutionProjects && activeSolutionProjects.Length > 0)
    {
      return activeSolutionProjects.GetValue(0) as Project;
    }
    
    Document doc = dte.ActiveDocument;
    if (doc != null && !string.IsNullOrEmpty(doc.FullName))
    {
      return dte.Solution?.FindProjectItem(doc.FullName)?.ContainingProject ?? throw new Exception("Cannot get current project");
    }

    throw new Exception("Cannot get current project");
  }
        
  public string GetLanguageVersion() => 
            (string)GetActiveProject().ConfigurationManager
                                      .ActiveConfiguration
                                      .Properties
                                      .Item(langVersionIndexer).Value; 

Setting the language version

Setting the language version is easy if you can get the current project. The only tricky thing is to also save the project, so the user is not prompted in Visual Studio to do a manual save after using the menu of the extension.

public void SetLanguageVersion(string version)
{
  var project = GetActiveProject();
  project.ConfigurationManager
         .ActiveConfiguration
         .Properties.Item(langVersionIndexer).Value = version;
  project.Save();
}

Create the commands and add them to Visual Studio

Next, I created a VSIX project and added a custom command to it. This command class represents an available language version and will be instantiated for every available language version.

internal sealed class ChangeLanguageVersionCommand : OleMenuCommand
{
  private readonly IProjectVersionService projectVersionService;
  private readonly AsyncPackage package;
  public string LanguageVersion { get; }
  public event EventHandler OnChecked;

  public ChangeLanguageVersionCommand(AsyncPackage package, IProjectVersionService projectVersionService, string languageVersion, CommandID id) : base(Execute, id)
  {
    this.package = package;
    this.projectVersionService = projectVersionService;
    this.LanguageVersion = languageVersion;          
  }


  private static void Execute(object sender, EventArgs e) =>
                     ((ChangeLanguageVersionCommand)sender).ExecuteInternal();
        
  private void ExecuteInternal()
  {
    projectVersionService.SetLanguageVersion(LanguageVersion);
    this.Checked = true;
  }

  public override bool Checked
  {
    get => base.Checked;
    set
    {
      base.Checked = value;
      if (value)
      {
        OnChecked?.Invoke(this, EventArgs.Empty);
      }
    }
  }
}  

The command holds a reference to the package that it is part of (the actual extension) and also to the language version it represents. The Execute() static method is called when the command is clicked from the UI, which calls the ExecuteInternal() method (this event handler must be specified in the constructor, but in the construcor only statis methods can be passed in as delegates, that's why there is another layer of abstraction).

I also added an OnChecked event that is fired when the Checked property is set to true (that's part of the base class). I will use this event to uncheck every other command version.

The next step is to specify the commands in the vsct file of the extension. It has a very convoluted syntax and the documentation there is not much documentation about it, so I had to do a lot of trial and error to get the desired output (I even asked a question on Stackoverflow, which I ended up answering myself). If you are interested about how to create a menuitem with subitems, you can check out the source code in the Github repo. The basic idea is this:

  1. Create a group and set the parent of this group to the Visual Studio projects context menu.
  2. Create a menu, whose parent is the group created in step 1.
  3. Create a second group, whose parent is the menu created in step 2.
  4. Create buttons for only the subitems.
  5. Create commandplacements for the subitems created in step 4 where you place each button in the group created in step 3.

This gives you a nice little menu item in your project context menu:

On a side note: I have to say, the VSIX framework and API are not that fun to work with. You have to create this vsct file, assign GUIDs to the commands and then refer to these GUIDs from the source code. It's very easy to mistype of mismatch the GUIDs and then the project fails to build. If you do not want to mess around with string GUIDs, you should check out the Extensibility tools by Mads Kristensen. It adds a nice little command that parses through the vsct file and generates a code file with the GUIDs that you can refer to from your source code like any other member of any other class. Awesome :)

Hooking everything up

And finally, I just had to hook everything up in the package class. Get the available language versions for the Visual Studio version, apply these as a filter, create command objects and also subscribe to the Checked event to uncheck the one checked previously (and some attributes that I omit here for clarity; check out the source on Github for the full code).

public sealed class ChangeLanguageVersionCommandPackage : AsyncPackage
{

  private IProjectVersionService projectVersionService;
  private List<ChangeLanguageVersionCommand> commands;
  private readonly List<(string version, int commandId)> versionCommands = 
             new List<(string version, int commandId)> { 
                        (LanguageVersions.Default,PackageIds.cmdSetToDefault),  
                        (LanguageVersions.Latest,PackageIds.cmdSetToLatest),   
                        (LanguageVersions.LatestMajor,PackageIds.cmdSetToLatestMajor),   
                        (LanguageVersions.CSharp5, PackageIds.cmdSetToCSharp5),
                        (LanguageVersions.CSharp6, PackageIds.cmdSetToCSharp6)
                        (LanguageVersions.CSharp7, PackageIds.cmdSetToCSharp7),
                        (LanguageVersions.CSharp71, PackageIds.cmdSetToCSharp71),
                        (LanguageVersions.CSharp72, PackageIds.cmdSetToCSharp72),
                        (LanguageVersions.CSharp73, PackageIds.cmdSetToCSharp73),
                        (LanguageVersions.CSharp8, PackageIds.cmdSetToCSharp8),
                        (LanguageVersions.Preview, PackageIds.cmdSetToPreview),
                  };        

  private void HandleChecked(object sender, EventArgs e)
  {
    foreach (var command in commands)
    {
      if (command != (ChangeLanguageVersionCommand)sender)
      {
        command.Checked = false;
      }
    }
  }
  
  protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
  {
    await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
    projectVersionService = new ProjectVersionService((DTE2)GetGlobalService(typeof(SDTE)));
    var commandService = (OleMenuCommandService)(await GetServiceAsync((typeof(IMenuCommandService))));
    commands = versionCommands.Select(c => new ChangeLanguageVersionCommand(this, projectVersionService, c.version, new CommandID(PackageGuids.guidChangeLanguageVersionCommandPackageCmdSet, c.commandId))).ToList();
    var currentLanguageVersion = projectVersionService.GetLanguageVersion();
    var availableVersions = projectVersionService.GetAvailableLanguageVersions();
    foreach (var command in commands)
    {
      if (command.LanguageVersion == currentLanguageVersion)
      {
        command.Checked = true;
      }
      command.OnChecked += HandleChecked;
      command.Visible = availableVersions.Contains(command.LanguageVersion);
      commandService.AddCommand(command);
    }    
  }
}

And that's it! I understand how this might never be one of the greatest hits on the marketplace, but if you teach course like I do, you might find it useful. If you don't teach but you are interested in the new features of C#, don't forget that you can hire me to teach a course for you or your collegues.

If you are interested in the source code as well, you can check it out on Github. Ideas and PRs are welcome.

Akos Nagy