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

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.

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:

would be visible in C# as if it was:

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:

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):

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”:

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

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.

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:

The output should be:

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.

Share the post!

  • Jamie Phillips

    The syntax is very similar to Cake.

    • RichiCoder

      A long while back when Cake was still young and roslyn scripting was still barely 1.x, I started a project to make Cake compatible with exactly this kind of development, but never got around to it. Would love to see a Script Runner agnostic Cake.

  • Paul Wheeler

    This is exactly what I’ve been looking for (including the tooling), thanks!