F# Scripting on Linux in 2020
Tags: fsharp, dotnet, linux
Published: 2020-01-06 16:20:40

I recently had occasion to write some F# scripts on Linux. I ran into a few hiccups getting my environment set up and had trouble finding documented solutions, so I thought I'd share what worked for me.

My machine is running [Zorin OS 15](https://zorinos.com/), which is based on [Ubuntu 18.04 LTS](https://ubuntu.com/). As long as you're running a distribution that supports [snaps](https://snapcraft.io/), your experience will likely be similar to mine.

## tl;dr

Here's one way to get up and running with F# scripting on Linux:

1. Install the .NET Core SDK snap with `sudo snap install dotnet-sdk --classic`
2. Add `/snap/dotnet-sdk/current` to `$PATH`
3. Install the VS Code snap with `sudo snap install code --classic`
4. Install the Ionide extension with `code --install-extension ionide.ionide-fsharp`
5. Add the following to VS Code's `settings.json` file: `"FSharp.dotNetRoot": "/snap/dotnet-sdk/current"` and `"FSharp.useSdkScripts": true`
6. Use [Paket](https://fsprojects.github.io/Paket/) to manage NuGet packages
7. Change the `paket.dependencies` file's `storage` setting from `none` to `packages`

That's the quick and dirty version. Read on for the why and the how, as well as some nifty bonus tips. 💡

## Installing .NET Core

F# runs on .NET, so my first step is to install the [recently released .NET Core 3.1 LTS](https://devblogs.microsoft.com/dotnet/announcing-net-core-3-1/). The [.NET Core downloads page](https://dotnet.microsoft.com/download) links to some docs that recommend installation using a package manager. This is a fine solution, as is [Docker](https://hub.docker.com/_/microsoft-dotnet-core/), but I prefer to use snap packages whenever I can. It turns out there is an [official snap for the .NET Core SDK](https://snapcraft.io/dotnet-sdk). The following command installs it:

```bash
sudo snap install dotnet-sdk --classic
```

Cool! That seemed straightforward enough. Let's try out the dotnet CLI.

```bash
$ dotnet --version
Command 'dotnet' not found, but can be installed with:
sudo snap install dotnet-sdk
```

Hmmm. Pretty sure I did that already. Maybe I need to reboot or something?

_reboots, tries again..._

Nope, no luck. I wonder if the dotnet CLI was installed to a location that isn't in my path. Come to think of it, what _is_ in my path?

```bash
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
```

Okay, so `/snap/bin` is already in my path, but apparently, the `dotnet` command isn't there. I wonder what else is in the `/snap` directory...

```bash
$ ls -1 /snap
bin
code
core
core18
dotnet-sdk
node
README
slack
supertuxkart
```

Aha! Aside from the fact that you now know what fun snaps I've installed, this is interesting information. Let's see what's in that `dotnet-sdk` directory.

```bash
$ ls -1 /snap/dotnet-sdk/
54
57
current

$ ls -1 /snap/dotnet-sdk/current
command-dotnet.wrapper
dotnet
dotnet-runtime
dotnet-sdk-3.0.100-linux-x64.tar.gz
etc
host
lib
LICENSE.txt
meta
packs
sdk
shared
snap
templates
ThirdPartyNotices.txt
usr
var

$ /snap/dotnet-sdk/current/dotnet --version
3.1.100
```

Bingo! There's our dotnet CLI executable (or at least a symbolic link to it). Now I just need to modify my path so that my shell will be able to find it whenever I type `dotnet`.

I'm pretty sure the code in `~/.profile` runs when I log into my machine, so that seems like as good a place as any to make sure the dotnet CLI location is added to my path. Here's a one-time script to modify `~/.profile` appropriately:

```bash
echo "
# set PATH so it includes dotnet core sdk snap and tools
if [ -d /snap/dotnet-sdk/current ]
then
  PATH=\"\$PATH:/snap/dotnet-sdk/current\"
fi
if [ -d \$HOME/.dotnet/tools ]
then
  PATH=\"\$PATH:\$HOME/.dotnet/tools\"
fi" >> ~/.profile
```

Now I can finally type `dotnet` to invoke the dotnet CLI. We're in business!

A quick aside: there is a simpler way to make the `dotnet` command available without modifying the path. Snaps have the concept of an [alias](https://forum.snapcraft.io/t/commands-and-aliases/3950), which allows us to specify how we're going to invoke a snap on the command line. This is useful when the unique name of the snap (in this case, `dotnet-sdk`) is different from how we expect to invoke it (in this case, `dotnet`).

For example, the NodeJS snap adds helpful aliases by default:

```bash
$ snap aliases
Command       Alias    Notes
node.npm      npm      -
node.npx      npx      -
node.yarn     yarn     -
node.yarnpkg  yarnpkg  -
```

At the time of writing, there is an [open issue in the backlog](https://github.com/dotnet/cli/issues/11837) of the dotnet CLI repository to add a `dotnet` snap alias on install. For now, we can add one manually with the following command:

```bash
sudo snap alias dotnet-sdk.dotnet dotnet
```

This allows us to type `dotnet` to invoke the dotnet CLI, and it only took one command to get us there - no path wrangling needed. Nice!

Now for the bad news. When I tried adding a `dotnet` snap alias, everything worked great on the command line, but the F# tooling in VS Code failed. I wasn't able to make it work, so I went back to the slightly-less-elegant solution of modifying my path. If you are able to make the F# tooling work with the snap alias, please let me know!

In any case, .NET Core is now installed and operational, so let's move on.

## Configuring VS Code

I'll use [VS Code](https://code.visualstudio.com/) with the [Ionide extension](https://marketplace.visualstudio.com/items?itemName=Ionide.Ionide-fsharp). This seems to be the _de facto_ tool set for cross-platform F# development. I've used it on Windows and really enjoyed it.

Let's install the [VS Code snap](https://snapcraft.io/code), which, fortunately, works out of the box:

```bash
sudo snap install code --classic
```

Then we can install Ionide from the command line:

```bash
code --install-extension ionide.ionide-fsharp
```

Voilà! An F# development environment.

Mostly.

Ionide is not without its quirks. Occasionally, when I open VS Code, a `.ionide` directory is created in my workspace, regardless of whether the workspace contains any F#. At the time of writing, there is an [open issue](https://github.com/ionide/ionide-vscode-fsharp/issues/1041) in the Ionide repository to address this behavior. For now, I'll work around it by disabling the Ionide extension globally, then enabling it manually for each F# workspace. This can be done from the "Extensions" pane in VS Code.

Another Ionide quirk affects one of my favorite features: CodeLens type signatures. Ionide can help us understand our code by showing the type signature of each function. Consider the following. If, somewhere in the code, `add` is called with two integer parameters, we would see this (the comments are visible in the editor, but not part of the source code):

```fsharp
let add x y = x + y // int -> int -> int
```

When I first installed Ionide and opened an F# script file, the CodeLens type signatures were noticeably absent. All I saw was the code itself:

```fsharp
let add x y = x + y
```

After a bit of digging, I came upon two Ionide settings that needed to be explicitly set. The `dotNetRoot` setting fixes the CodeLens problem by telling Ionide where to find the dotnet CLI, and the `useSdkScripts` setting instructs Ionide to use the .NET Core version of FSharp Interactive (`dotnet fsi`). I'll add these to my VS Code `settings.json` file:

```json
"FSharp.dotNetRoot": "/snap/dotnet-sdk/current",
"FSharp.useSdkScripts": true
```

Now we've got a first-class F# development environment. Let's get scripting!

## Managing dependencies

From here on, we'll discuss F# scripting in general, not limited to Linux.

Although F# is a compiled language suitable for large projects, it works just as well as a scripting language. An F# script has a `.fsx` extension rather than a `.fs` extension, and stands alone - it does not make use of .NET project files (i.e. `*.fsproj`, `*.csproj`). If we want to use a library in our script, we have to download it and reference its path explicitly.

The dotnet CLI can manage NuGet packages, but it assumes that we're working with full-fledged .NET projects. What we really need is a repeatable way to download NuGet packages to the location of our choosing so that we can reference them in our script. Fortunately, the F# community has developed a dependency manager that solves this problem: [Paket](https://fsprojects.github.io/Paket/index.html).

To see how Paket works, we'll follow the ["Get started"](https://fsprojects.github.io/Paket/get-started.html) guide and work through an example. Let's say that we have a bunch of data in a JSON file and we're writing an F# script to process that data. To deserialize our JSON data, we'll use the [Newtonsoft.Json](https://www.newtonsoft.com/json) library.

Right now, all we have is a directory with our data and our script:

```text
.
|--data.json
`--script.fsx
```

Paket is available as a dotnet CLI tool. We have the option to install it globally, but for this example, we'll scope it to our codebase so that it's an explicit dependency. The following command will create a `.config` directory with a skeleton `dotnet-tools.json` manifest file:

```bash
dotnet new tool-manifest
```

Here's our directory so far:

```text
.
|--.config
|  `--dotnet-tools.json
|--data.json
`--script.fsx
```

The following command will install the latest stable version of Paket locally and record that version in the manifest:

```bash
dotnet tool install paket
```

Now we can use Paket inside our codebase with `dotnet paket`. So far so good!

Paket uses a `paket.dependencies` file to specify the NuGet packages we want. We'll create this file with the following command:

```bash
dotnet paket init
```

The default `paket.dependencies` file looks like this:

```text
source https://api.nuget.org/v3/index.json

storage: none
framework: netcore3.0, netstandard2.0, netstandard2.1
```

If we want the latest stable version of a NuGet package, we add the word `nuget` followed by the name of the package - no version necessary:

```text
source https://api.nuget.org/v3/index.json

storage: none
framework: netcore3.0, netstandard2.0, netstandard2.1

nuget Newtonsoft.Json
```

Now we're finally ready to download the Newtonsoft.Json NuGet package. ["Learn how to use Paket"](https://fsprojects.github.io/Paket/learn-how-to-use-paket.html) indicates we can do so with the following command:

```bash
dotnet paket install
```

Note that this command creates a `paket.lock` file, and it _does_ include a version number for each package, allowing us to share our code and restore exactly the same dependencies on another machine:

```text
STORAGE: NONE
RESTRICTION: || (== netcoreapp3.0) (== netstandard2.0) (== netstandard2.1)
NUGET
  remote: https://api.nuget.org/v3/index.json
    Newtonsoft.Json (12.0.3)
```

Here's what our directory looks like now:

```text
.
|--.config
|  `--dotnet-tools.json
|--paket-files
|  `--paket.restore.cached
|--data.json
|--paket.dependencies
|--paket.lock
`--script.fsx
```

Hey, where are the NuGet packages?

Let's take another look at `paket.dependencies`. The default setting `storage: none` instructs Paket to download NuGet packages to a global cache. This is efficient for full-fledged .NET projects, but it isn't helpful for scripting. We need to know exactly where to find `Newtonsoft.Json.dll` relative to our script.

According to [the Paket documentation](https://fsprojects.github.io/Paket/dependencies-file.html#Disable-packages-folder), we can instruct Paket to use a local `packages` directory instead of a global cache by changing the `storage` setting from `none` to `packages`. Here's our `paket.dependencies` file after making the change:

```text
source https://api.nuget.org/v3/index.json

storage: packages
framework: netcore3.0, netstandard2.0, netstandard2.1

nuget Newtonsoft.Json
```

Let's try one more time:

```bash
dotnet paket install
```

Success! We have a `packages` directory containing the Newtonsoft.Json NuGet package:

```text
.
|--.config
|  `--dotnet-tools.json
|--packages
|  `--Newtonsoft.Json
|     `--[lots of stuff in here]
|--paket-files
|  `--paket.restore.cached
|--data.json
|--paket.dependencies
|--paket.lock
`--script.fsx
```

To reference it, we can add some code like this at the top of our script:

```fsharp
#r "packages/Newtonsoft.Json/lib/netstandard2.0/Newtonsoft.Json.dll"
```

And away we go! With all of that setup behind us, we can focus on our code, bringing the power and elegance of F# to our scripting tasks.

But before we close, there are a few things that can make our lives a bit easier.

## Bonus tip: restoring dependencies

The example above took the perspective of setting up Paket for the first time, but what should we do when we've committed our code to source control and cloned it on another machine?

Fortunately, all of our dependencies - including Paket itself - are explicitly specified in the configuration files we created: `dotnet-tools.json`, `paket.dependencies`, and `paket.lock`. We can use the dotnet CLI to restore Paket locally with the following command:

```bash
dotnet tool restore
```

Then we can use Paket to restore NuGet packages:

```bash
dotnet paket restore
```

These steps can be combined into a single `restore.sh` shell script that we execute any time we pull down a new version of our F# script:

```bash
dotnet tool restore
dotnet paket restore
```

## Bonus tip: ignoring generated files

As we worked through the example above, we created quite a few files dedicated to dependency management. Some of these should be committed to source control along with our code, such as `dotnet-tools.json`, `paket.dependencies`, and `paket.lock`. Some will be generated when we install or restore dependencies, and can thus be ignored by source control.

If we add the following lines to our `.gitignore` file, we can safely commit everything else:

```text
.ionide/
.paket/
paket-files/
packages/
```

## Bonus tip: setting the current directory

Here's a little tip I picked up from the excellent [_F# for Fun and Profit_](https://fsharpforfunandprofit.com/) by Scott Wlaschin. His series on low-risk ways to use F# at work has some [helpful example scripts](https://fsharpforfunandprofit.com/posts/low-risk-ways-to-use-fsharp-at-work/#2-use-f-to-test-your-own-code-interactively) that use this technique.

When I'm writing a script, I often find that I need to read the contents of a file on disk, such as the `data.json` file in our example above. I can put the input file in the same directory as the script, but sometimes I _invoke_ the script from a completely different directory. This makes coding the path to the input file a challenge.

If I can control the current working directory at runtime, I can code a reliable path to the input file. F# has a built-in constant that can help us to accomplish this. The following code sets the current working directory to the F# script's directory:

```fsharp
System.IO.Directory.SetCurrentDirectory(__SOURCE_DIRECTORY__)
```

## Bonus tip: you're awesome

I hope you found this post helpful. Whether you develop on Linux, Mac, or Windows, have fun with F#!