Writing Your Own Planetary System DSL and Parser in F#
Introduction
In this post, we’ll be developing an extremely simple Domain Specific Language for describing Planetary Systems for AstroSharp, an Astrophysics library to help me better learn about F# and Functional Programming, by starting off where we left off the previous blog post.
We’ll first start off by describing the structure of the Domain Specific Language and our domain. We’ll then build a pipeline consisting of splitting and parsing modules with appropriate functions to construct our desired domain records from our domain specific language.
Consequently, our freshly baked domain record will eventually be used by our AstroSharp.Core functions to output various celestial results such as the Gravitation Force between objects, Gravitation Acceleration at the Surface of different planets or the Period of Revolution around the Star in the Planetary System; these will be described in a later blog post.
What is a Domain Specific Language?
A Domain Specific Language (DSL) is a programming language designed to express solutions to problems in a specific domain. An example of a DSL is HTML which is used for the domain of Web Based Applications.
The opposite of a DSL is a general-purpose language, for example: F#, meant to be used in a domain agnostic way.
The Domain
A Planetary System is a set of gravitationally bound non-stellar objects in orbit around a star or star system. An example of a Planetary System is our very own photogenic Solar System.
For the sake of simplicity, like our Solar System, we are modeling the subset planetary systems, that orbit around a singular star. Our domain model consists of the following:
- PlanetarySystemInfo: A composite record consisting of a name and a Star represented by the StarInfo record type and a list of Planets each of which are represented by the PlanetInfo record type.
- StarInfo: Information about the Star represented as a record type consisting of the Name, the Mass in kgs and Radius of the Star in meters.
- PlanetInfo: Similar to the StarInfo, the PlanetInfo, is also represented as a record type, consisting of a Name, the Mass in kgs and Radius of the Planet in meters.
Enough of this astro-jargon, let’s start writing some code!
We start by adding a new File to our AstroSharp.Core project called “DSL.fs” so that our directory looks like:
At this point, we still need to add the DSL file to the project; this can be done by opening up the good ole Command Pallet [ Cmd + Shift + P ] and adding the current file i.e. DSL.fs to the project.
Addition of this new file to the project can be confirmed by opening up the AstroSharp.Core.fsproj file and making sure “DSL.fs” is present there.
Before we define our domain specific record types to represent our Planetary System, we’ll need to make use of the BigRational type to represent huge numbers such as the mass of the sun. The BigRational type can be found in the MathNet.Numerics.FSharp [ originally a part of the F# PowerPack Numeric ] library that contains some extremely useful functions.
Time to make good use of PAKET. We start off by navigating our way to the AstroSharp.Core.fsproj file and opening up the Command Pallet [ Cmd + Shift + P ] and use the “Add Nuget Package (to current project)” option.
And type in MathNet.Numerics.FSharp and press enter.
We let PAKET do it’s magic and henceforth, double check if the project reference for MathNet.Numerics and MathNet.Numerics.FSharp was added to our fsproj file:
Additionally, we want to make sure that the MathNet.Numerics.FSharp dependency percolated it’s way through to the project’s paket.reference and the top level paket.dependencies file, as well.
Thepaket.references
file is used to specify which dependencies are to be installed into localized projects such as AstroSharp.Core in our case.
On the other hand, the paket.dependencies
file is used to specify rules regarding your application's dependencies at the top level, i.e. in the AstroSharp top level folder containing AstroSharp.Core and AstroSharp.Tests in this case.
Next, we define our record types representing our domain in the PlanetarySystemDomain module.
The static members ‘empty’ in the PlanetInfo and StarInfo record types are used as our initial state that our parsing function will eventually populate in accordance with the input DSL text.
The Goal
The goal is to be able to digest messages and create Planetary Info records from text like:
"PlanetarySystem 'Solar'
Star 'Sol' 7e8 2e30
Planet 'Mercury' 2440000 3e23
Planet 'Venus' 6052000 5e24
Planet 'Earth' 6378000 6e24
Planet 'Mars' 3397000 6e23
Planet 'Jupiter' 71492000 2e27
Planet 'Saturn' 60268000 6e26
Planet 'Uranus' 25559000 9e25
Planet 'Neptune' 24766000 1e26
Planet 'Pluto' 1150000 1e22"
In a nutshell, our DSL Creation and Usage has us:
- Split the DSL text so as to create a list of tokens that our Parser Function can consume.
- Parse our split strings to create the Planetary System Info record via the Parser Function.
- Consume the Planetary System Info Records by our Expecto-Unit-Tested AstroSharp.Core functions to compute various celestial quantities.
Let’s use an extremely simple, yet complete, example for our DSL text to develop a solution.
"PlanetarySystem 'Solar'
Star 'Sol' 7e8 2e30
Planet 'Earth' 6378000 6e24"
The Splitter Module
We split our DSL text on the basis of:
- Single Spaces [ ‘ ’ ]
- Tabs [ ‘\t’ ]
- New Lines [ ‘\n’ ]
- Single Quotes [ ‘\‘’]
- Carriage Returns [ ‘\r’ ]
These are represented by the char array splitters. The split function, does exactly what it’s supposed to do and more: it splits the input text by the splitters, removes the empty entries and returns a list of the split inputs.
We isolate our splitting functionality in a separate module called Splitter. Altogether, this module looks something like:
The output from the split function on our simple DSL example looks like:
["PlanetarySystem"; "Solar"; "Star"; "Sol"; "7e8"; "2e24"; "Planet"; "Earth"; "6378000"; "6e24"]
The Parser Utility Module
To help us with parsing values with scientific notations like 2e30, we need to employ the use of some helper functions that we keep in the ParserUtility module.
The one function defined in this module checks if the string is in the scientific notation form or as a stringified int and converts it into a BigRational accordingly.
The Parser Function
The parser function is a recursive function that consumes the list of split strings of the DSL text from the splitter to create the Planetary System Info starting from an Empty Planetary System Info record into a complete one.
The parser function’s signature looks like:
let rec parsePlanetarySystem (planetarySystem : PlanetarySystemInfo)
(listOfStrings : string list)
: PlanetarySystemInfo
...
The first input, planetarySystem, is responsible for maintaining state of the baking Planetary System between recursive calls. The second input, listOfStrings, is the list representing the strings left to be parsed to create our Planetary System.
Without further ado, here is the full code of the PlanetarySystemParser module that contains the parser function and all its accoutrements.
The intention is to modularize the main ‘parsePlanetarySystem’ function to rely on two mutually recursive functions ‘parseStarInfo’ and ‘parsePlanetInfo’ that create and update the planetary system with Stars and Planets respectively.
These two functions then call back into ‘parsePlanetarySystem’ function and continue till all the split strings are successfully incorporated and we have desired PlanetarySystemInfo record starting off with using an empty PlanetarySystemInfo.
As you can see here, we have made good use of pattern matching on the expected DSL format i.e. name - mass - radius in case of the StarInfo and PlanetInfo and just the name in case of the PlanetarySystemInfo to create constitutes of the planetary system. If a match isn’t found, we fail with a parser specific exception. In a future post, I want to highlight the use of Railway Oriented Programming into this mix.
The Result
We store the text to parse into a DSL into a variable and call the PlanetarySystemParser module’s createPlanetarySystem method to get our final result.
We go from:
"PlanetarySystem 'Solar'
Star 'Sol' 7e8 2e24
Planet 'Earth' 6378000 6e24"
To our well formed Planetary Info Record:
{Name = "Solar";
Star = {Name = "Sol";
Radius = 2000000000000000000000000N;
Mass = 700000000N;};
Planets = [{Name = "Earth";
Radius = 6378000N;
Mass = 6000000000000000000000000N;}];}
Now, that’s nothing short of AWESOME. As mentioned before, we can do some cool stuff with this record type such as computing the following:
Gravitation Force Between Two Planets:
Gravitational Acceleration of a Body on the Surface of a Planet:
Orbital Period of a Planet around a Star given by Kepler’s Third Law:
Conclusion
In this blogpost we created a simple, albeit complete, Planetary System DSL and it’s associated parser from scratch that takes some text and creates a record representing the star and planets associated with the Planetary System.
Building a DSL in a Functional-First language such as F# is significantly easier than going the Imperative or Object Oriented route solely because of Algebraic Data Types and Pattern Matching.
Hopefully the code wasn’t too challenging to follow. Any feedback will be greatly appreciated. The code associated with this tutorial can be found here.