Writing C# build scripts with FAKE, OmniSharp and VS Code

Β· 2303 words Β· 11 minutes to read

In this blog post I’d like to show an extremely - in my opinion - productive way of writing build scripts using C#. As a basis, we’ll use the excellent core FAKE library called FakeLib, which is written F# and consume it in C# scripts.

Sure, there are other projects/task runners like Cake or Bau that allow you to write C# build scripts (few more actually out there) but the approach I’d like to show you today, is I think the most productive of all, so bear with me.

More after the jump.

What do I need from a C# build system / task runner? πŸ”—

So first of all, let’s consider what do I want from a build system that lets me use C#.

  1. I want cross platform support
  2. I want intellisense (how the hell can you use .NET without intellisense?!))
  3. I want to have a targets API that works reasonably well and prints out things elegantly at the end
  4. I want to have a rich ecosystem of integrations - I don’t want to have to manually call into nuget.exe, dotnet CLI or Azure APIs
  5. I don’t want to use some custom C# DSL / C# dialect - but rather would like to stick to “standard C# scripting”

The last point is important - and I’m guilty as charged here. From my experience with the scriptcs project, I can say it’s really much better to write standardized C# scripts, that can run on any runner such as csi.exe, rather than trying to fragment the landscape with scripting dialects. The latter, after all, ships with MSBuild these days too, making C# scripting possible to be used without any extra installation steps.

Main advantage of that is that if we stick to standardized scripting, we can easily provide language services, intellisense, refactoring, debugging and such.

So how about FAKE? πŸ”—

So despite some great C# build systems being out there, none of them ticks all the boxes at the moment.

We’ve had various Twitter discussions about the nature of scripting on the .NET platform in the past, and this time around, Steffen suggested to use FakeLib. I must admit - in all my ignorance - I didn’t know FAKE is structured in a way that the core lib can be reused so easily.

I have actually been a big fan of FAKE myself, and used it in various projects wherever I could - F# is an excellent language for scripting - but due to my long term involvement in the C# scripting ecosystem, I was always gravitating towards doing things in C# when it was possible.

Turns out all the helpers and integrations in FAKE are completely reusable. The same applies to its targets API. This will immediately tick boxes 3 and 4 for me.

C# scripting intellisense and language services πŸ”—

With the OmniSharp project, it’s possible to have intellisense for scripts. In fact, not just intellisense, but fully fledged language services - refactoring capabilities, code navigation, you name it.

This is a tremendous boost for productivity, especially when dealing with a verbose language like C# and a verbose platform like .NET.

Note that at the moment, the best (or rather, “revamped”) support for CSX language services is only on the dev branch of OmniSharp (I only added this refreshed scripting support in there recently). OmniSharp now has CSX support for both desktop CLR and .NET Core - provided your scripts follow standard C# scripting model.

This leads me back to my original points - with OmniSharp I can tick box 2. Now, to be able to use it, and take advantage of its unbelievable productivity boost though, I have to make sure to author my CSX in a way not violate point number 5 - stick to standard C# scripting only.

What does it mean in practice?

  • no custom preprocessor directives
  • no custom host objects (magic global properties or methods injected into the script)
  • no custom mechanism for assembly loading
  • no implicit assembly references
  • no implicit namespace imports

This sounds constraining, but in reality I don’t find it limiting at all, and hopefully by looking at our end result, you will agree.

VS Code πŸ”—

You will need at least version 1.8.0 of the C# extension for VS Code to get CSX support.

Putting it all together πŸ”—

So now it’s a time to put it all together - and if you are impatient here is the final script.

You could really use any project to code along, I am using a little scripting demos project as the project to create my build script for.

Let’s start by adding build.csx file and a bin folder (note - the folder name has no meaning, it might be anything). Also, don’t forget the empty project.json.

In the bin folder, we’ll need to place 4 DLLs:

  • FakeLib.dll - FAKE core library
  • FSharp.Core.dll - F# core library
  • FSharpx.Extras.dll - F# to C# interop helpers
  • System.Runtime.dll - needed just in case you want to run your build script on Mono. Version 4.0.20.0

All these DLLs can be grabbed from Nuget - which is what I did, manually. Note that at the moment it’s not a function of our build system to resolve nuget packages for itself, though it would be very easy to write a little bootstrapper tool that just downloads these dependencies and places them in the bin folder. This will likely not change often too, so it’s up to you to decide how much time you want to invest i nthe bootstrapping.

Another interesting (and cool) thing, is that FakeLib.dll is a single DLL that contains integration into dozens of tools and services like NuGet, unit test runners, Dotnet CLI, AppVeyor, Git, MSBuild, Xamarin and many more.

OK, so in build.csx, add the following directives to import the assemblies. We might also already import all the necessary namespaces.

#r "bin/FakeLib.dll"
#r "bin/FSharp.Core.dll"
#r "bin/FSharpx.Extras.dll"
#r "bin/System.Runtime.dll"

using Fake;
using FSharpx;
using System.Linq;
using System.IO;
using static FSharpx.FSharpFunc;
using static Fake.TargetHelper;
using static Fake.MSBuildHelper;
using static Fake.NuGetHelper;
using static Fake.FileHelper;

You could offload that to a separate CSX file i.e. bootstrap.csx and then #load that CSX from build.csx, but I don’t think it’s super necessary.

At the moment OmniSharp will not parse these reference changes in realtime, so when you add new assembly references, you need to restart OmniSharp. This is done by going to command palette in VS Code (ctrl+shift+p or CMD+shift+p) and selecting “Restart OmniSharp”. After the restart, the references to the new assemblies will be recognized.

I’d like to spend a moment explaining something here. I’m sure you noticed the “using static” - that’s because we are relying on one little trick. Because of the way how F# and C# interops, loose F# functions, on which FAKE largely relies, are surfaced to C# as static methods of static classes.

For example, the following FAKE function:

[<AutoOpen>]
/// Contains helpers which allow to interact with the file system.
module Fake.FileHelper

open System.IO
open System.Text
open System.Diagnostics

/// Deletes multiple directories
let DeleteDirs dirs = Seq.iter DeleteDir dirs

would be visible in C# as if it was:

namespace Fake 
{
    public static class FileHelper
    {
        public static void DeleteDirs(IEnumerable<string> dirs);
    }
}

As a consequence, by relying on the using static functionality of C# 6, we can mimic the F# experience. In the above example, we could just say:

using static Fake.FileHelper;

DeleteDirs(myDirs);

Which makes the scripting experience much better.

So with that in mind, we can proceed to define our targets. In this demo I will show you 4 sample targets:

  • default - does nothing just prints a message
  • build - builds the project with MSBuild
  • clean - cleans up some directories
  • pack - creates a nuget package

So our shell structure would look like this (from now on I am skipping the “header” where we defined references and using statements to conserve space):

// my build script is in a folder "build", so I need to go up
var projectFolder = "../ScriptingDemos";

Target("Default", FromAction(() => {
  // do stuff
}));

Target("Clean", FromAction(() => {
  // do stuff
}));

Target("Build", FromAction(() => {
  // do stuff
}));

Target("Pack", FromAction(() => {
  // do stuff
}));

dependency("Build", "Clean");
dependency("Pack", "Build");

var targetName = Args.FirstOrDefault() ?? "Default";
run(targetName);

I think this is rather self explanatory at the moment, but let me quickly walk you through. Target is naturally, the FAKE target here. We have access to the method, because we statically imported TargetHelper before.

Same applies to dependency function, used to link our targets into dependency hierarchies, and the run, which we can use to invoke a specific target. Args is a standard C# scripting way of dealing with script arguments, so we can get a target name from there, or default to, well “Default” - those will be passed in when we invoke the script from the command line.

So in our case, “Build” depends on “Clean”, and “Pack” depends on “Build”.

One more thing worth noting, is that we need to use a little helper called FromAction. It comes from FSharpx.Extras.dll and converts a C# action (our simple lambda) into FSharpFunc<Unit, Unit>, which is what the FAKE API would required. We could simplify it further by creating a custom Target method which would do this conversion internally and delagate to real FAKE Target but I don’t think it’s necessary.

So let’s start filling up our targets with real logic. First the simple ones, “Default” and “Clean”:

Target("Default", FromAction(() => {
  Console.WriteLine("Woohoo, nothing to do!");
}));

Target("Clean", FromAction(() => {
  DeleteDirs(new[] { "../artifacts", $"{projectFolder}/bin", $"{projectFolder}/obj" });
}));

Pretty obvious so far, right? Next, let’s add the “Build” task.

Target("Build", FromAction(() => {
  MSBuildLoggers = new string[0].ToFSharpList();

  build(Fun<MSBuildParams>(msBuild => {
    msBuild.Verbosity = MSBuildVerbosity.Quiet.ToFSharpOption();
    msBuild.NoLogo = true;
    return msBuild;
  }), $"{projectFolder}/ScriptingDemos.csproj");
}));


static Microsoft.FSharp.Core.FSharpFunc<TParams, TParams> Fun<TParams>(Func<TParams, TParams> func) => FSharpx.FSharpFunc.FromFunc(func);

This is also pretty easy to comprehend. FAKE’s MSBuildHelper exposes a build function, which takes a delegate that can modify the default MSBuildParams. Here we could set custom properties and so on.

Similarly as it was a case with Actions, we just need to convert a C# Func into an FSharpFunc. This is something we do via FSharpx.Extras too. Note that I wrote a single line helper to reduce the amount of verbosity even further. It makes sense, since we will reuse this little helper in the other target too.

Of course everything here is fully discoverable and inspectable with OmniSharp intellisense, so even if it may not seem obvious at first glance, it’s actually super easy to work with!

Finally, let’s add the last task - packaging with NuGet.

Target("Pack", FromAction(() => {
  var outputPath = "../artifacts";
  if (!Directory.Exists(outputPath)) {
    Directory.CreateDirectory(outputPath);
  }
  NuGetPack(Fun<NuGetParams>(nuget => {
    nuget.Version = "0.1.0-rc";
    nuget.ToolPath = "../tools/nuget.exe";
    nuget.IncludeReferencedProjects = true;
    nuget.WorkingDir = outputPath;
    nuget.OutputPath = outputPath;
    return nuget;
  }), $"{projectFolder}/ScriptingDemos.csproj");
}));

This task is a bit more complicated but also rather self-explanatory. We ensure the artifacts folder exists, and then hand off to FAKE’s NuGetHelper. By default FAKE would look for NuGet in the chocolatey install folder, but since I committed nuget.exe with my project, I am repointing FAKE to that particular executable.

Running the whole shebang πŸ”—

We can now run this - but you might ask, how? Well, as I mentioned, because we used standard C# scripting here, we can just use csi.exe, which was built by the Roslyn team as part of Roslyn and is the official C# script runner.

It also ships with MSBuild, so that means if you have MSBuild, you do not have to install anything to run this build script, you just need to point it to csi.exe. CSI is available in the PATH on Windows boxes if you run VS Developer Command Prompt. Otherwise you can find it in C:\Program Files (x86)\MSBuild\14.0\Bin.

Also, CSI is portable, so you could just copy it over if needed. Adam Ralph actually ILMerged all CSI dependencies into 1 file, so you can grab his ILMerged version too.

I will not go into details now, but it’s very easy to write a cmd or sh file which will pick up CSI from a proper place, maybe even wget it, and bootstrap all the running - this is beyond scope for this article.

Anyway, let’s walk up to CSI now and run:

csi build.csx Build

The output should be:

Z:\Documents\dev\csharp-scripting-demos\build>csi build.csx Build
Building project with version: LocalBuild
Shortened DependencyGraph for Target Build:
<== Build
   <== Clean

The resulting target order is:
 - Clean
 - Build
Starting Target: Clean
Z:\Documents\dev\csharp-scripting-demos\artifacts does not exist.
Deleting Z:\Documents\dev\csharp-scripting-demos\ScriptingDemos\bin
Deleting Z:\Documents\dev\csharp-scripting-demos\ScriptingDemos\obj
Finished Target: Clean
Starting Target: Build (==> Clean)
Building project: ../ScriptingDemos/ScriptingDemos.csproj
  C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe  ../ScriptingDemos/ScriptingDemos.csproj  /m /nologo   /v:q  /p:RestorePackages="False"
Finished Target: Build

---------------------------------------------------------------------
Build Time Report
---------------------------------------------------------------------
Target     Duration
------     --------
Clean      00:00:00.1663146
Build      00:00:00.8178118
Total:     00:00:01.1254374
Status:    Ok
---------------------------------------------------------------------

CSI will work on Mono too, I believe you need Mono 4.6. We also need the System.Runtime reference for Mono specifically. You can see it below (my sample project is not x-plat, so running a Clean task only).

View post on imgur.com

Authoring experience πŸ”—

And this is how it looks in terms of authoring experience. Pretty cool for C# scripting, isn’t it? Full intellisense, refactoring, reference counts, code navigation, you name it.

View post on imgur.com

Bonus - interactive mode πŸ”—

Because we use standard C# scripting syntax, we can actually leverage the interactive mode (REPL) of CSI too. What I mean by that, is that we can start CSI REPL, #load our build script, and interact with it in the REPL context - run tasks manually, inspect variables, inject new tasks and so on.

The experience is not perfect, as it doesn’t at the moment because CSI doesn’t respect the using statements of #load-ed scripts, but nevertheless it’s quite cool.

This is shown in the GIF below:

View post on imgur.com

All the code from this article is available here.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP