Liberate yourself from VS project files

Published on January 12, 2015

I have been using the .NET framework professionally since the very first release. And there are some things I like about the framework, others not so much.

But there is one thing that really annoys me; one thing that I think is fundamentally wrong with .NET ecosystem. There is a tight coupling between your project files in your IDE of choice and your build scripts.

I have yet to experience a .NET project, where the build script would not call msbuild myproject.sln or msbuild myproject.csproj.

Your IDE project file has become your build script. If the Visual Studio developers wanted to implement the Separation of Concerns principles, this is one place where they failed.

And though there are tools that try to help you write .NET code using pure text editors (Emacs, Vim, Sublime, etc), most notably OmniSharp. But most, if not all, of what I have seen still assumes that you have .csproj or .fsproj project file. If not, you are out of luck; in particular, if you depend on functionality from NuGet packages.

For fun, I wanted to create a small web based game using an F# backend, but I wanted to start in Vim, and I wanted to stay in Vim.

So I started to create some build tools to allow me to much more easily create a build script, including support for referencing NuGet packages.

Without too much effort, I had created a nice POC using Rake and Paket. (Writing this blog post has taken considerably more time than creating the POC)

desc "Build main executable"
fsc :build do |t|
  t.output_file = "nde.exe"
  t.output_folder = "output"
  t.source_files << "host.fs"
  t.source_files << "main.fs"
  t.packages << "packages/Nancy"
  t.packages << "packages/Nancy.Hosting.Self"
end

task :default => [:clean, :build]

If I had coded in C#, I could just have selected “*.cs” as source files instead of explicitly specifying every source file, but F# requires that the source files are supplied in a specific order, one thing I hope to automate using this tool.

At the time of writing, it’s in an exploratory state – and has not been distributed as a separate gem.

The code can be found at https://github.com/stroimsn/nde

Note: All my experiments are conducted on a Mac using Mono. I have not bothered to make it cross platform yet, so if you are running this on Windows, you need to adapt, e.g. the F# compiler executable is called fsc in .NET, and fsharpc in Mono.

Paket

I have used Paket to handle NuGet packages.

Paket is an alternate to NuGet as a package manager. It supports NuGet as the format for packages, and supports getting then from a standard NuGet feed. But it also allows you install packages from the command line.

This makes it a lot more friendly than bare NuGet if you want to get rid of the IDE.

Rake

Rake is a Ruby based make tool. It does everything a proper build tool should, allowing you to specify tasks, task dependencies, and build optimization, i.e. skip building the product of none of the source has been modified since the last build. And it has a great intuitive DSL.

This makes Rake the perfect tool for the task. One particular advantage of this over xml-based configuration tool is that the build script is plain ruby code. So if you need to add functionality, you just create a function. (Ever tried to create custom msbuild tasks?)

The downside for depending on Rake is that you have to install Ruby in order to use it. This may unfortunately scare some (many) .NET developers. But they are probably not reading this to begin with. Step 1 – Hello World

First, I wanted to compile this a simple Hello World application

module Main

[<EntryPoint>]
let main args =
  printfn "Hello world"
  0

This is the build script

desc "Main executable"
fsc :build do |t|
  t.output_file = "nde.exe"
  t.output_folder = "output"
  t.source_files << "main.fs"
end

The main code is implemented in the class FscBuilder

# Creates the concrete task for building F# code
class FscBuilder
  attr_accessor :output_folder
  attr_accessor :output_file

  def initialize *args
    @args = args
    @output_folder = "."
    yield self if block_given?
  end

  def source_files
    @source_files ||= []
  end
  ...
  def create_compilation_task
    dest = File.join output_folder, output_file
    Rake::FileTask::define_task dest do |t|
      output = "--out:#{dest}"
      target = "--target:exe"
      sources = source_files.join(" ")
      system "fsharpc #{output} #{target} #{sources}"
    end
  end
end

The heart of this code is the Rake::FileTask::define_task that creates the actual Rake task. The name of the task is the name of the file being generated. The block contains the code for actually generating the file.

This example, this will create a task named “output/nde.exe”. This is not really helpful when we want to run it from command line, or use it as dependency to other tasks, e.g. a task that compiles and runs unit tests. A change to the output folder would change the name of this task. So we create a surrogate task to trigger the execution of this task, and a helper function:

class FscBuilder
  def initialize *args
    @args = args
    @output_folder = "."
    yield self if block_given?
  end
  ...
  def create_task
    task = Rake::Task::define_task *@args
    file_task = create_compilation_task
    task.enhance [file_task]
  end
end

def fsc *args, &block
  builder = FscBuilder.new *args, &block
  builder.create_task
end

Now, the task name we gave as argument to the fsc call becomes the name of the created task. And any task dependencies that we specify when alling the fsc function will automatically become dependencies of the surrogate task, executing before the actual compilation.

Run this from the command line

$ rake
> F# Compiler for F# 3.1 (Open Source Edition)
> Freely distributed under the Apache 2.0 Open Source License
$ mono output/nde.exe
> Hello world
$

It’s working. Because we are using a FileTask with a name identical to the output file, Rake will automatically detect if this needs to be rebuild. Running Rake again:

$ rake
$

Nothing happens. The file already exists, and Rake avoids generating the file again. But if the source files are modified, the file is not generated again. This is very simply remedied:

def create_compilation_task
  dest = File.join output_folder, output_file
  Rake::FileTask::define_task dest => source_files do |t|
  ...
end

We have added the source files as dependencies to the output file. Now the product will rebuild whenever any source file has been modified.

1st step complete. We can compile a collection of source files with a simple build script, and the build script supports optimizing the build, only building what needs to be build.

Step 2 – Adding NuGet dependencies

Let’s add some NuGet packages to the project. For this small test, I will add Nancy and Nancy.SelfHost

The modified main.fs looks like this:

module Main
open System
open Nancy
open Nancy.Hosting.Self

[<EntryPoint>]
let main args =
  let bootstrapper = new DefaultNancyBootstrapper ()
  let nancyHost =
    new NancyHost(
      bootstrapper,
      new Uri("http://localhost:1239/"),
      new Uri("http://127.0.0.1:1239/"))
  nancyHost.Start()
  nancyHost.Stop()
  printfn "Hello world"
  0

First, download the Nancy nuget packages using paket:

$ mono paket.exe add nuget Nancy
$ mono paket.exe add nuget Nancy.SelfHost

Add dependencies to the Rakefile

fsc :build do |t|
  t.output_file = "nde.exe"
  t.source_files << "main.fs"
  t.packages << "packages/Nancy"
  t.packages << "packages/Nancy.Hosting.Self"
end

And support for NuGet packages when compiling

class FscBuilder
  ...
  def packages
    @packages ||= []
  end

  def get_nuget_dlls package
    Dir[File.join package, "lib/net40/*.dll"]
  end

  def assembly_refs
    packages.flat_map { |m| get_nuget_dlls m }
  end

  def create_compilation_task
    task_dependencies = source_files | assembly_refs
    dest = File.join output_folder, output_file
    Rake::FileTask::define_task dest => task_dependencies do |t|
      refs = assembly_refs.map { |r| "-r:#{r}" }.join(" ")
      output = "--out:#{dest}"
      target = "--target:exe"
      sources = source_files.join(" ")
      system "fsharpc #{output} #{refs} #{target} #{sources}"
    end
  end
end

This is not a production-ready solution. It simply iterates through all .dll files inside the lib/net40 folder of each NuGet package. Of course it should respect what has been specified in the .nuspec file – and it should select the appropriate framework, depending on the target framework of your product. But it will do for now.

Notice that the referenced assemblies have been added as dependency to the compilation task, making sure that the product is rebuild if a new version of a package is downloaded.

Now, the code compiles once more.

Step 3 – Copy dependencies to output folder

With the latest changes, the application builds, but if we try to run it:

$ mono output/nde.exe
Could not load signature of Host:get_nancyHost due to: Could not load file or assembly or one of its dependencies.

Unhandled Exception:
System.TypeLoadException: A type load exception has occurred.
[ERROR] FATAL UNHANDLED EXCEPTION: System.TypeLoadException: A type load exception has occurred.

Although the dependent assemblies can be found at compile time, they cannot be found at run time. We need to copy those to the output folder.

Lets add code to copy dependencies to the output folder:

def create_copy_task source
  dest = File.join output_folder, File.basename(source)
  Rake::FileTask::define_task dest => source do
    FileUtils.cp source, dest
  end
end

def create_task
  task = Rake::Task::define_task *@args
  copy_dependency_tasks = assembly_refs.map { |m| create_copy_task m }
  ...
  task.enhance copy_dependency_tasks
end

For this, we created a new FileTask for each file to copy, and adding these as dependencies to the build task. This again tells Rake to avoid copying a dependency if there are no changes since the last time we compiled.

And now, if we try to run, it works

$ mono output/nde.exe
> Hello World
$

Halfway there

… is an exageration. There’s quite some work before this can be a mature production ready tool. But it does show that there is a way forward to create tools, where a small project could start with just a few files in a folder and a build script.

To get there least the following features needs to be implemented:

  • Package it as a gem. My first attempt was a proof of concept, showing that this is doable. For this to be usable, this needs to be packaged as a ruby gem.
  • C# support.
  • Handle multiple target frameworks. Right now, I have hardcoded using net40 assemblies from the referenced NuGet package. The assemblies corresponding the the desired target framework should be used.
  • Dependencies’ dependencies. When I reference a NuGet package that in itself reference other nuget packages my product may build just fine even though I don’t reference the dependencies of my dependencies. But if these are not copied to the output folder, the software will not work.
  • Support for referencing normal .dll assembly files – not everything is distributed as a NuGet package.
  • Support for multiple projects referencing each other. Any good project should have at least two build outputs, a system assembly, and a test assembly, referencing the system assembly. We need to have support for dependencies between these two projects.
  • Other compiler directives, such as optimization on/off, pdb output, xmldoc, etc.

And probably lots more I have not yet thought of.

Then you could start thinking about the following:

  • F# requires that the input files are passed in a specific order (code in one file cannot call functions defined in a file later in the list). Therefore you cannot just add *.fs. A simple analysis tool for determining the correct order of the source files could make the task of adding new source files simpler, as you don’t have to update the build script. This is not a problem for C#.
  • Avoid having to both install a new NuGet package from the command line AND having to update the build script. Perhaps dependencies could be detected automatically based on the paket.dependencies, or perhaps both package installation and the build script could share a common configuration file, much like Leiningen

But the first task is probably the most difficult. To come up with a great name for the gem.

I hope I am not the only .NET developer who wants to be free, once and for all, of Visual Studio, and others will join me.

Don’t get me wrong. I like Visual Studio, I think it’s a great product – but it should just be tool for writing code, and not dictate how your build script looks, and how every other tool should behave.

Previous: Interface Segregation Principle in typescriptNext: Using FSpec with NCrunch