DEV Community

Cover image for F# Scripting on Linux in 2020
Ed DeVries
Ed DeVries

Posted on

F# Scripting on Linux in 2020

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, which is based on Ubuntu 18.04 LTS. As long as you're running a distribution that supports snaps, 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 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. The .NET Core downloads page links to some docs that recommend installation using a package manager. This is a fine solution, as is Docker, but I prefer to use snap packages whenever I can. It turns out there is an official snap for the .NET Core SDK. The following command installs it:

sudo snap install dotnet-sdk --classic
Enter fullscreen mode Exit fullscreen mode

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

$ dotnet --version
Command 'dotnet' not found, but can be installed with:
sudo snap install dotnet-sdk
Enter fullscreen mode Exit fullscreen mode

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?

$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
Enter fullscreen mode Exit fullscreen mode

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...

$ ls -1 /snap
bin
code
core
core18
dotnet-sdk
node
README
slack
supertuxkart
Enter fullscreen mode Exit fullscreen mode

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.

$ 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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

$ snap aliases
Command       Alias    Notes
node.npm      npm      -
node.npx      npx      -
node.yarn     yarn     -
node.yarnpkg  yarnpkg  -
Enter fullscreen mode Exit fullscreen mode

At the time of writing, there is an open issue in the backlog of the dotnet CLI repository to add a dotnet snap alias on install. For now, we can add one manually with the following command:

sudo snap alias dotnet-sdk.dotnet dotnet
Enter fullscreen mode Exit fullscreen mode

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 with the Ionide extension. 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, which, fortunately, works out of the box:

sudo snap install code --classic
Enter fullscreen mode Exit fullscreen mode

Then we can install Ionide from the command line:

code --install-extension ionide.ionide-fsharp
Enter fullscreen mode Exit fullscreen mode

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

let add x y = x + y // int -> int -> int
Enter fullscreen mode Exit fullscreen mode

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:

let add x y = x + y
Enter fullscreen mode Exit fullscreen mode

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:

"FSharp.dotNetRoot": "/snap/dotnet-sdk/current",
"FSharp.useSdkScripts": true
Enter fullscreen mode Exit fullscreen mode

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.

To see how Paket works, we'll follow the "Get started" 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 library.

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

.
|--data.json
`--script.fsx
Enter fullscreen mode Exit fullscreen mode

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:

dotnet new tool-manifest
Enter fullscreen mode Exit fullscreen mode

Here's our directory so far:

.
|--.config
|  `--dotnet-tools.json
|--data.json
`--script.fsx
Enter fullscreen mode Exit fullscreen mode

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

dotnet tool install paket
Enter fullscreen mode Exit fullscreen mode

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:

dotnet paket init
Enter fullscreen mode Exit fullscreen mode

The default paket.dependencies file looks like this:

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

storage: none
framework: netcore3.0, netstandard2.0, netstandard2.1
Enter fullscreen mode Exit fullscreen mode

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:

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

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

nuget Newtonsoft.Json
Enter fullscreen mode Exit fullscreen mode

Now we're finally ready to download the Newtonsoft.Json NuGet package. "Learn how to use Paket" indicates we can do so with the following command:

dotnet paket install
Enter fullscreen mode Exit fullscreen mode

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:

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

Here's what our directory looks like now:

.
|--.config
|  `--dotnet-tools.json
|--paket-files
|  `--paket.restore.cached
|--data.json
|--paket.dependencies
|--paket.lock
`--script.fsx
Enter fullscreen mode Exit fullscreen mode

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

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

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

nuget Newtonsoft.Json
Enter fullscreen mode Exit fullscreen mode

Let's try one more time:

dotnet paket install
Enter fullscreen mode Exit fullscreen mode

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

.
|--.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
Enter fullscreen mode Exit fullscreen mode

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

#r "packages/Newtonsoft.Json/lib/netstandard2.0/Newtonsoft.Json.dll"
Enter fullscreen mode Exit fullscreen mode

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:

dotnet tool restore
Enter fullscreen mode Exit fullscreen mode

Then we can use Paket to restore NuGet packages:

dotnet paket restore
Enter fullscreen mode Exit fullscreen mode

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:

dotnet tool restore
dotnet paket restore
Enter fullscreen mode Exit fullscreen mode

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:

.ionide/
.paket/
paket-files/
packages/
Enter fullscreen mode Exit fullscreen mode

Bonus tip: setting the current directory

Here's a little tip I picked up from the excellent F# for Fun and Profit by Scott Wlaschin. His series on low-risk ways to use F# at work has some helpful example scripts 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:

System.IO.Directory.SetCurrentDirectory(__SOURCE_DIRECTORY__)
Enter fullscreen mode Exit fullscreen mode

Bonus tip: you're awesome

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

Top comments (2)

Collapse
 
lschlee profile image
Lucas Schlee

Thanks a lot for sharing your experience, Ed! I was having a really hard time trying to configure all the stuff on Ubuntu and your walkthrough was extremely helpful and easy to follow. Helped me a lot!

Collapse
 
paytonrules profile image
Eric Smith

I've been using F# on and off for years now, and every time I want to write a one-off .fsx script I get bogged down with setup. It's very disappointing.

These directions are some of the best I've seen, and I'm on a Mac. You might consider using generate_load_scripts: true instead of changing the storage, but that's a detail that's up to you.